dsh-wsl-desktop 0.7.6 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +8 -0
- package/README.md +5 -0
- package/lib/client.js +452 -4
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -86,6 +86,14 @@ A few details you will run into:
|
|
|
86
86
|
this plugin does not override it.
|
|
87
87
|
- Inside an open WSL workspace, the file list and the terminal are the distro's. PowerShell in Windows
|
|
88
88
|
workspaces is unaffected.
|
|
89
|
+
- **The W button has its own shortcut, and its hint follows a rebind.** The Windows desktop default is
|
|
90
|
+
`Ctrl+Alt+W` (the web profile declares none: the sidebar page-close command's web binding is the
|
|
91
|
+
same combination, and registration validates every profile's defaults, so declaring it would refuse
|
|
92
|
+
the command on the desktop too); hovering or focusing W shows the action name and keycaps the same way the
|
|
93
|
+
adjacent "+" does. The command is registered in Keyboard shortcuts, so rebinding or clearing it
|
|
94
|
+
updates the keycaps and `aria-keyshortcuts` immediately. When another command already owns the
|
|
95
|
+
default combination and registration is refused, the W button still works and merely omits the
|
|
96
|
+
keycaps; the shortcut service itself is a desktop dependency present since 0.1.7.
|
|
89
97
|
|
|
90
98
|
## What will surprise you
|
|
91
99
|
|
package/README.md
CHANGED
|
@@ -72,6 +72,11 @@ plugin_manager install_bundle dsh-wsl-desktop@<version>
|
|
|
72
72
|
表示网络/子系统路径的写法,形如 `\\wsl.localhost\<发行版>\home\...`。
|
|
73
73
|
- 自带的「+」保持部署原本的行为(桌面上是 Electron 的目录选择器),本插件不改写它。
|
|
74
74
|
- 已经打开的 WSL 工作区里,文件列表和终端都是发行版内的。Windows 工作区里的 PowerShell 不受影响。
|
|
75
|
+
- **W 按钮有自己的快捷键,提示会跟着改绑走。** Windows 桌面的默认是 `Ctrl+Alt+W`(Web 端不声明默认:
|
|
76
|
+
侧栏页面关闭(`page.close`)的 Web 组合也是它,而快捷键注册会校验所有平台的默认值,声明它会让命令在桌面端也注册失败);
|
|
77
|
+
悬停或聚焦 W 时,提示会和左边的「+」一样显示动作名与键帽。命令登记在「快捷键」设置里,
|
|
78
|
+
改绑或清除后键帽与 `aria-keyshortcuts` 随即更新。默认组合被别的命令占用、导致注册被拒时,
|
|
79
|
+
W 按钮照常工作,只是不显示键帽;快捷键服务本身是 0.1.7 起桌面自带的依赖。
|
|
75
80
|
|
|
76
81
|
## 会意外的地方
|
|
77
82
|
|
package/lib/client.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Adds one control beside the shipped "add workspace" button in the sidebar
|
|
5
5
|
* section header: a `W` button opening this plugin's WSL workspace picker.
|
|
6
|
+
* The button carries the same kind of shortcut hint the shipped trigger does:
|
|
7
|
+
* its tooltip shows the effective keys from the shell's shortcut catalog, and
|
|
8
|
+
* the registered command opens the picker from anywhere the page owns the
|
|
9
|
+
* keyboard.
|
|
6
10
|
*
|
|
7
11
|
* The shipped button keeps its own behaviour: this half registers no slot
|
|
8
12
|
* occupant, because `sidebar.workspaces.directoryFlow` is `kind: 'single'` and
|
|
@@ -71,6 +75,28 @@ window.__ModuleLoader__.load({
|
|
|
71
75
|
/** Sprite symbol id shape, as `iconSymbolId` composes it: `dsw-icon-<glyph>-<weight>`. */
|
|
72
76
|
const SPRITE_ICON_ID = /^#?dsw-icon-(.+)-(?:regular|medium)$/
|
|
73
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Stable command identity of the W button's keyboard action.
|
|
80
|
+
*
|
|
81
|
+
* The id is the plugin's public shortcut identity: it travels through the
|
|
82
|
+
* shell's command catalog, appears in the Keyboard shortcuts reference, and
|
|
83
|
+
* survives user rebinds. Renaming it orphans every saved override, so it
|
|
84
|
+
* names the action rather than the button's ever-changing presentation.
|
|
85
|
+
*/
|
|
86
|
+
const SHORTCUT_ID = 'wsl.workspace.open'
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The add-workspace trigger's own tooltip delay, mirrored so the two
|
|
90
|
+
* controls beside each other reveal their shortcut hints together.
|
|
91
|
+
*/
|
|
92
|
+
const TOOLTIP_HOVER_DELAY_MS = 500
|
|
93
|
+
|
|
94
|
+
/** Gap between the W button and its shortcut tooltip, in CSS pixels. */
|
|
95
|
+
const TOOLTIP_GAP = 8
|
|
96
|
+
|
|
97
|
+
/** Minimum distance the shortcut tooltip keeps from the viewport edges. */
|
|
98
|
+
const TOOLTIP_MARGIN = 12
|
|
99
|
+
|
|
74
100
|
/**
|
|
75
101
|
* The row glyphs whose artwork is FILL-only, as sprite names.
|
|
76
102
|
*
|
|
@@ -1518,7 +1544,15 @@ window.__ModuleLoader__.load({
|
|
|
1518
1544
|
|
|
1519
1545
|
/** The injected services the picker and its trigger read. */
|
|
1520
1546
|
// ── 5. the companion button and workspace rows ─────────────────────────────
|
|
1521
|
-
const companion = {
|
|
1547
|
+
const companion = {
|
|
1548
|
+
button: null,
|
|
1549
|
+
fallback: null,
|
|
1550
|
+
hideHint: null,
|
|
1551
|
+
trigger: null,
|
|
1552
|
+
observer: null,
|
|
1553
|
+
scheduled: false,
|
|
1554
|
+
disposed: false,
|
|
1555
|
+
}
|
|
1522
1556
|
|
|
1523
1557
|
/** Whether the missing-workspace-source warning was already printed. */
|
|
1524
1558
|
let warnedWorkspaceSource = false
|
|
@@ -1876,6 +1910,10 @@ window.__ModuleLoader__.load({
|
|
|
1876
1910
|
companion.trigger = trigger
|
|
1877
1911
|
}
|
|
1878
1912
|
if (trigger === null) {
|
|
1913
|
+
// React took the anchor away, so the companion leaves too. The tooltip
|
|
1914
|
+
// is a fixed portal with no pointer to leave; withdraw it explicitly or
|
|
1915
|
+
// it outlives the control it describes.
|
|
1916
|
+
companion.hideHint?.()
|
|
1879
1917
|
if (button.isConnected) button.remove()
|
|
1880
1918
|
return
|
|
1881
1919
|
}
|
|
@@ -1896,7 +1934,13 @@ window.__ModuleLoader__.load({
|
|
|
1896
1934
|
const box = button.getBoundingClientRect()
|
|
1897
1935
|
const fits = row === undefined || box.right <= row.right + 1
|
|
1898
1936
|
const triggerVisible = getComputedStyle(trigger).visibility !== 'hidden'
|
|
1899
|
-
|
|
1937
|
+
const shown = fits && triggerVisible
|
|
1938
|
+
button.style.display = shown ? '' : 'none'
|
|
1939
|
+
// The shipped cluster hides itself (inline search, narrow rail) by
|
|
1940
|
+
// visibility and the companion follows via display; a visible tooltip must
|
|
1941
|
+
// follow too, because no pointerleave fires for a control that left the
|
|
1942
|
+
// hit-test tree.
|
|
1943
|
+
if (!shown) companion.hideHint?.()
|
|
1900
1944
|
}
|
|
1901
1945
|
|
|
1902
1946
|
/**
|
|
@@ -1911,6 +1955,362 @@ window.__ModuleLoader__.load({
|
|
|
1911
1955
|
return document.body ?? document.documentElement
|
|
1912
1956
|
}
|
|
1913
1957
|
|
|
1958
|
+
/**
|
|
1959
|
+
* The effective catalog row for the W shortcut, or null when the shell has
|
|
1960
|
+
* no shortcut service or has not published the command yet.
|
|
1961
|
+
*
|
|
1962
|
+
* The row is the SINGLE source of the hint: `keys` are the platform-formatted
|
|
1963
|
+
* labels the shortcut service presents, and `aria` is the same binding in
|
|
1964
|
+
* `aria-keyshortcuts` spelling. A rebind replaces the row, so the tooltip
|
|
1965
|
+
* follows the user's setting instead of the default first seen.
|
|
1966
|
+
* @param {object} ctx - the client plugin context.
|
|
1967
|
+
* @returns {object | null} the row for `SHORTCUT_ID`, or null.
|
|
1968
|
+
*/
|
|
1969
|
+
function shortcutRowOf(ctx) {
|
|
1970
|
+
const catalog = ctx.shortcuts?.catalog
|
|
1971
|
+
if (typeof catalog?.getSnapshot !== 'function') return null
|
|
1972
|
+
const rows = catalog.getSnapshot()
|
|
1973
|
+
if (!Array.isArray(rows)) return null
|
|
1974
|
+
return rows.find((row) => row.id === SHORTCUT_ID) ?? null
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* Publish or withdraw the accessible shortcut claim on one control.
|
|
1979
|
+
* @param {HTMLElement | null} node - the control, or null when it is absent.
|
|
1980
|
+
* @param {object | null} row - the effective catalog row.
|
|
1981
|
+
*/
|
|
1982
|
+
function setKeyshortcuts(node, row) {
|
|
1983
|
+
if (node === null) return
|
|
1984
|
+
if (typeof row?.aria === 'string' && row.aria !== '') node.setAttribute('aria-keyshortcuts', row.aria)
|
|
1985
|
+
else node.removeAttribute('aria-keyshortcuts')
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/** Tooltip bubble styling, mirrored from the shipped tooltip's semantic tokens. */
|
|
1989
|
+
const SHORTCUT_TOOLTIP_STYLE = {
|
|
1990
|
+
position: 'fixed',
|
|
1991
|
+
zIndex: '1100',
|
|
1992
|
+
display: 'none',
|
|
1993
|
+
alignItems: 'center',
|
|
1994
|
+
gap: '8px',
|
|
1995
|
+
width: 'max-content',
|
|
1996
|
+
maxWidth: 'min(50vw, calc(100vw - 24px))',
|
|
1997
|
+
boxSizing: 'border-box',
|
|
1998
|
+
padding: '3px 7px',
|
|
1999
|
+
borderRadius: 'var(--dsw-radius-sm, 8px)',
|
|
2000
|
+
background: 'var(--dsw-alias-tooltip-bg, #3a3a3a)',
|
|
2001
|
+
color: 'var(--dsw-static-neutral-bluish-00, #ffffff)',
|
|
2002
|
+
fontSize: '13px',
|
|
2003
|
+
lineHeight: '20px',
|
|
2004
|
+
whiteSpace: 'pre-line',
|
|
2005
|
+
overflowWrap: 'break-word',
|
|
2006
|
+
pointerEvents: 'none',
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/** The keycap strip's layout inside a tooltip. */
|
|
2010
|
+
const SHORTCUT_KEYS_STYLE = {
|
|
2011
|
+
display: 'inline-flex',
|
|
2012
|
+
flex: 'none',
|
|
2013
|
+
alignItems: 'center',
|
|
2014
|
+
gap: '2px',
|
|
2015
|
+
color: 'inherit',
|
|
2016
|
+
fontSize: '11px',
|
|
2017
|
+
lineHeight: '14px',
|
|
2018
|
+
whiteSpace: 'nowrap',
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/** One keycap: the shipped tooltip's 16px badge. */
|
|
2022
|
+
const SHORTCUT_KEY_STYLE = {
|
|
2023
|
+
boxSizing: 'border-box',
|
|
2024
|
+
display: 'inline-flex',
|
|
2025
|
+
alignItems: 'center',
|
|
2026
|
+
justifyContent: 'center',
|
|
2027
|
+
font: 'inherit',
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/**
|
|
2031
|
+
* One shortcut tooltip attached to a plain DOM control.
|
|
2032
|
+
*
|
|
2033
|
+
* The shipped "add workspace" button gets this from the React Tooltip in
|
|
2034
|
+
* `ui-primitives`; this half is a hand-written factory with no module graph,
|
|
2035
|
+
* and a feature plugin may not reach into another feature's values, so the
|
|
2036
|
+
* same presentation is rebuilt here on the shared semantic tokens. The
|
|
2037
|
+
* bubble is portaled to the body and positioned fixed, so the sidebar's
|
|
2038
|
+
* clipping and stacking contexts cannot hide it.
|
|
2039
|
+
* @param {HTMLElement} anchor - the control the tooltip belongs to.
|
|
2040
|
+
* @param {string} label - the action name shown before the keycaps.
|
|
2041
|
+
* @returns {{ setKeys: (keys: readonly string[]) => void, dismiss: () => void, dispose: () => void }}
|
|
2042
|
+
* the key publisher, the layout-driven dismissal, and the disposer.
|
|
2043
|
+
*/
|
|
2044
|
+
function createShortcutHint(anchor, label) {
|
|
2045
|
+
let bubble = null
|
|
2046
|
+
let keys = []
|
|
2047
|
+
let showTimer = null
|
|
2048
|
+
let visible = false
|
|
2049
|
+
// The shipped Tooltip keeps hover and focus as INDEPENDENT triggers and
|
|
2050
|
+
// withdraws the bubble only after BOTH clear: hovering away from a focused
|
|
2051
|
+
// anchor must not drop it, and blurring while the pointer is still over
|
|
2052
|
+
// the anchor must not drop it either.
|
|
2053
|
+
let hovered = false
|
|
2054
|
+
let focused = false
|
|
2055
|
+
|
|
2056
|
+
function clearShowTimer() {
|
|
2057
|
+
if (showTimer === null) return
|
|
2058
|
+
clearTimeout(showTimer)
|
|
2059
|
+
showTimer = null
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
/**
|
|
2063
|
+
* Render the current keycap labels into the bubble, or hide the strip.
|
|
2064
|
+
*
|
|
2065
|
+
* Windows presents a combination as one plus-separated run in a single
|
|
2066
|
+
* pill; a modifier set without a separator (macOS symbols) gets one cap
|
|
2067
|
+
* per key. Both shapes are built from the catalog's labels, never from a
|
|
2068
|
+
* key spelling written in this file.
|
|
2069
|
+
*/
|
|
2070
|
+
function renderKeys() {
|
|
2071
|
+
if (bubble === null) return
|
|
2072
|
+
const strip = bubble.querySelector('[data-wsl-tooltip-keys]')
|
|
2073
|
+
if (strip === null) return
|
|
2074
|
+
strip.replaceChildren()
|
|
2075
|
+
if (keys.length === 0) {
|
|
2076
|
+
strip.style.display = 'none'
|
|
2077
|
+
bubble.style.paddingInlineEnd = '7px'
|
|
2078
|
+
return
|
|
2079
|
+
}
|
|
2080
|
+
strip.style.display = 'inline-flex'
|
|
2081
|
+
bubble.style.paddingInlineEnd = '5px'
|
|
2082
|
+
const joined = keys.includes('+')
|
|
2083
|
+
strip.style.background = joined
|
|
2084
|
+
? 'var(--dsw-alias-tooltip-key-bg, rgba(255,255,255,0.18))'
|
|
2085
|
+
: 'transparent'
|
|
2086
|
+
strip.style.height = joined ? '16px' : ''
|
|
2087
|
+
strip.style.padding = joined ? '0 4px' : '0'
|
|
2088
|
+
strip.style.borderRadius = joined ? '4px' : ''
|
|
2089
|
+
for (const token of keys) {
|
|
2090
|
+
const key = document.createElement('kbd')
|
|
2091
|
+
key.textContent = token
|
|
2092
|
+
Object.assign(key.style, SHORTCUT_KEY_STYLE)
|
|
2093
|
+
if (!joined) {
|
|
2094
|
+
key.style.minWidth = '16px'
|
|
2095
|
+
key.style.height = '16px'
|
|
2096
|
+
key.style.padding = '0 2px'
|
|
2097
|
+
key.style.borderRadius = '4px'
|
|
2098
|
+
key.style.background = 'var(--dsw-alias-tooltip-key-bg, rgba(255,255,255,0.18))'
|
|
2099
|
+
}
|
|
2100
|
+
strip.append(key)
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
/** Build the bubble once: a label span and a keycap strip. */
|
|
2105
|
+
function ensureBubble() {
|
|
2106
|
+
if (bubble !== null) return bubble
|
|
2107
|
+
bubble = el('span', SHORTCUT_TOOLTIP_STYLE)
|
|
2108
|
+
bubble.setAttribute('role', 'tooltip')
|
|
2109
|
+
bubble.setAttribute('aria-hidden', 'true')
|
|
2110
|
+
bubble.setAttribute('data-wsl-shortcut-tooltip', '')
|
|
2111
|
+
const text = el('span', { minWidth: '0' }, label)
|
|
2112
|
+
text.setAttribute('data-wsl-tooltip-label', '')
|
|
2113
|
+
const strip = el('span', SHORTCUT_KEYS_STYLE)
|
|
2114
|
+
strip.setAttribute('data-wsl-tooltip-keys', '')
|
|
2115
|
+
bubble.append(text, strip)
|
|
2116
|
+
renderKeys()
|
|
2117
|
+
return bubble
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/** Place the bubble below the anchor, clamped to the viewport. */
|
|
2121
|
+
function position() {
|
|
2122
|
+
if (bubble === null) return
|
|
2123
|
+
const anchorBox = anchor.getBoundingClientRect()
|
|
2124
|
+
// A media query or an ancestor class can collapse the header on resize
|
|
2125
|
+
// without any DOM mutation, so no layout pass reaches the hint. A zero
|
|
2126
|
+
// box means the tooltip has no anchor left to describe: withdraw it
|
|
2127
|
+
// instead of placing it from (0, 0).
|
|
2128
|
+
if (anchorBox.width <= 0 && anchorBox.height <= 0) {
|
|
2129
|
+
hide()
|
|
2130
|
+
return
|
|
2131
|
+
}
|
|
2132
|
+
const bubbleBox = bubble.getBoundingClientRect()
|
|
2133
|
+
const centered = anchorBox.left + anchorBox.width / 2 - bubbleBox.width / 2
|
|
2134
|
+
const left = Math.min(
|
|
2135
|
+
Math.max(TOOLTIP_MARGIN, centered),
|
|
2136
|
+
Math.max(TOOLTIP_MARGIN, window.innerWidth - bubbleBox.width - TOOLTIP_MARGIN),
|
|
2137
|
+
)
|
|
2138
|
+
let top = anchorBox.bottom + TOOLTIP_GAP
|
|
2139
|
+
// The sidebar header sits near the top edge, but a short window or a
|
|
2140
|
+
// moved trigger still has to get a readable bubble: flip above the
|
|
2141
|
+
// anchor when the space below cannot hold it.
|
|
2142
|
+
if (top + bubbleBox.height > window.innerHeight - TOOLTIP_MARGIN) {
|
|
2143
|
+
top = Math.max(TOOLTIP_MARGIN, anchorBox.top - bubbleBox.height - TOOLTIP_GAP)
|
|
2144
|
+
}
|
|
2145
|
+
bubble.style.left = `${left}px`
|
|
2146
|
+
bubble.style.top = `${top}px`
|
|
2147
|
+
bubble.style.visibility = 'visible'
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function show() {
|
|
2151
|
+
if (!anchor.isConnected || anchor.style.display === 'none') return
|
|
2152
|
+
// Closing the picker refocuses its opener. When that opener already sits
|
|
2153
|
+
// in a hidden subtree its box is zero; placing a fixed bubble from that
|
|
2154
|
+
// box would paint it at the viewport origin.
|
|
2155
|
+
const anchorBox = anchor.getBoundingClientRect()
|
|
2156
|
+
if (anchorBox.width <= 0 && anchorBox.height <= 0) return
|
|
2157
|
+
const node = ensureBubble()
|
|
2158
|
+
if (!node.isConnected) hostElement().append(node)
|
|
2159
|
+
visible = true
|
|
2160
|
+
node.style.display = 'inline-flex'
|
|
2161
|
+
node.style.visibility = 'hidden'
|
|
2162
|
+
position()
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
function hide() {
|
|
2166
|
+
clearShowTimer()
|
|
2167
|
+
visible = false
|
|
2168
|
+
if (bubble !== null) bubble.style.display = 'none'
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/**
|
|
2172
|
+
* Withdraw the bubble AND clear both triggers.
|
|
2173
|
+
*
|
|
2174
|
+
* The layout pass calls this when the anchor leaves the layout, mirroring
|
|
2175
|
+
* the shipped Tooltip's disabled effect: without the reset, a hover that
|
|
2176
|
+
* was live when the header hid survives the withdrawal, and a later
|
|
2177
|
+
* focus/blur can keep a bubble on screen with no pointer over the anchor.
|
|
2178
|
+
*/
|
|
2179
|
+
function dismiss() {
|
|
2180
|
+
hovered = false
|
|
2181
|
+
focused = false
|
|
2182
|
+
hide()
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
function reveal(delay) {
|
|
2186
|
+
clearShowTimer()
|
|
2187
|
+
if (delay <= 0) {
|
|
2188
|
+
show()
|
|
2189
|
+
return
|
|
2190
|
+
}
|
|
2191
|
+
showTimer = setTimeout(() => {
|
|
2192
|
+
showTimer = null
|
|
2193
|
+
show()
|
|
2194
|
+
}, delay)
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
const onPointerEnter = (event) => {
|
|
2198
|
+
// Touch has no hover state; tapping still invokes the button.
|
|
2199
|
+
if (event.pointerType === 'touch') return
|
|
2200
|
+
hovered = true
|
|
2201
|
+
reveal(TOOLTIP_HOVER_DELAY_MS)
|
|
2202
|
+
}
|
|
2203
|
+
const onPointerLeave = () => {
|
|
2204
|
+
hovered = false
|
|
2205
|
+
// A pending hover reveal is cancelled even when focus holds the bubble
|
|
2206
|
+
// open; otherwise it would reappear after the pointer has left.
|
|
2207
|
+
clearShowTimer()
|
|
2208
|
+
if (!focused) hide()
|
|
2209
|
+
}
|
|
2210
|
+
const onFocus = () => {
|
|
2211
|
+
// The shared input-modality attribute is how the shell publishes
|
|
2212
|
+
// pointer-acquired focus; a picker closing back onto its trigger must
|
|
2213
|
+
// not flash the shortcut bubble after a click.
|
|
2214
|
+
if (document.documentElement.getAttribute('data-input-modality') === 'pointer') return
|
|
2215
|
+
focused = true
|
|
2216
|
+
reveal(0)
|
|
2217
|
+
}
|
|
2218
|
+
const onBlur = () => {
|
|
2219
|
+
focused = false
|
|
2220
|
+
clearShowTimer()
|
|
2221
|
+
if (!hovered) hide()
|
|
2222
|
+
}
|
|
2223
|
+
const onClick = () => {
|
|
2224
|
+
// Activating the button dismisses the bubble even though the pointer
|
|
2225
|
+
// stays over it; the shipped tooltip clears its focus trigger here too.
|
|
2226
|
+
focused = false
|
|
2227
|
+
hide()
|
|
2228
|
+
}
|
|
2229
|
+
const onResize = () => { if (visible) position() }
|
|
2230
|
+
anchor.addEventListener('pointerenter', onPointerEnter)
|
|
2231
|
+
anchor.addEventListener('pointerleave', onPointerLeave)
|
|
2232
|
+
anchor.addEventListener('focus', onFocus)
|
|
2233
|
+
anchor.addEventListener('blur', onBlur)
|
|
2234
|
+
anchor.addEventListener('click', onClick)
|
|
2235
|
+
window.addEventListener('resize', onResize)
|
|
2236
|
+
|
|
2237
|
+
return {
|
|
2238
|
+
/**
|
|
2239
|
+
* Publish the effective keys to the (possibly not yet built) bubble.
|
|
2240
|
+
* @param {readonly string[]} nextKeys - the catalog's presented labels.
|
|
2241
|
+
*/
|
|
2242
|
+
setKeys(nextKeys) {
|
|
2243
|
+
const normalized = Array.isArray(nextKeys) ? nextKeys.map(String) : []
|
|
2244
|
+
if (normalized.join('\u0000') === keys.join('\u0000')) return
|
|
2245
|
+
keys = normalized
|
|
2246
|
+
if (bubble === null) return
|
|
2247
|
+
renderKeys()
|
|
2248
|
+
if (visible) position()
|
|
2249
|
+
},
|
|
2250
|
+
/** Withdraw the bubble and clear both triggers when its anchor leaves the layout. */
|
|
2251
|
+
dismiss,
|
|
2252
|
+
/** Release the bubble and every listener it installed. */
|
|
2253
|
+
dispose() {
|
|
2254
|
+
clearShowTimer()
|
|
2255
|
+
visible = false
|
|
2256
|
+
hovered = false
|
|
2257
|
+
focused = false
|
|
2258
|
+
anchor.removeEventListener('pointerenter', onPointerEnter)
|
|
2259
|
+
anchor.removeEventListener('pointerleave', onPointerLeave)
|
|
2260
|
+
anchor.removeEventListener('focus', onFocus)
|
|
2261
|
+
anchor.removeEventListener('blur', onBlur)
|
|
2262
|
+
anchor.removeEventListener('click', onClick)
|
|
2263
|
+
window.removeEventListener('resize', onResize)
|
|
2264
|
+
if (bubble !== null) {
|
|
2265
|
+
bubble.remove()
|
|
2266
|
+
bubble = null
|
|
2267
|
+
}
|
|
2268
|
+
keys = []
|
|
2269
|
+
},
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/**
|
|
2274
|
+
* Register the W button's keyboard command, tolerating a host without the
|
|
2275
|
+
* shortcut service or a catalog that refuses the default.
|
|
2276
|
+
*
|
|
2277
|
+
* Registration is deliberately outside the button's click path: the command
|
|
2278
|
+
* resolves to the same `openPicker` the click calls, so the two entries cannot
|
|
2279
|
+
* drift. A refused registration (another command already owns the same
|
|
2280
|
+
* default on one of this command's profiles) is logged and leaves the
|
|
2281
|
+
* button usable; the hint then carries the label alone.
|
|
2282
|
+
* @param {object} ctx - the client plugin context.
|
|
2283
|
+
* @returns {() => void} the registration's disposer, or a no-op.
|
|
2284
|
+
*/
|
|
2285
|
+
function installShortcut(ctx) {
|
|
2286
|
+
try {
|
|
2287
|
+
// The access itself is inside the guard: a context whose service getter
|
|
2288
|
+
// throws must degrade the hint, not abort the install that owns W.
|
|
2289
|
+
const shortcuts = ctx.shortcuts
|
|
2290
|
+
if (typeof shortcuts?.register !== 'function') return () => {}
|
|
2291
|
+
return shortcuts.register({
|
|
2292
|
+
id: SHORTCUT_ID,
|
|
2293
|
+
label: () => BUTTON_LABEL,
|
|
2294
|
+
aliases: ['wsl', 'wsl workspace', 'open wsl workspace'],
|
|
2295
|
+
defaults: {
|
|
2296
|
+
// Desktop only. On the Web profile the shell's page.close command
|
|
2297
|
+
// already owns KeyW+primary+alt, and the shortcut registry
|
|
2298
|
+
// validates EVERY profile's defaults at registration:
|
|
2299
|
+
// declaring the Web default here makes this command throw even on
|
|
2300
|
+
// a desktop runtime, so the hint loses its keys and the key does
|
|
2301
|
+
// nothing. Web users can bind the command themselves.
|
|
2302
|
+
'desktop:windows': { code: 'KeyW', modifiers: ['primary', 'alt'] },
|
|
2303
|
+
},
|
|
2304
|
+
regions: ['page', 'editable'],
|
|
2305
|
+
modals: ['sidebar'],
|
|
2306
|
+
resolve: () => ({ status: 'handled', run: () => openPicker(ctx) }),
|
|
2307
|
+
})
|
|
2308
|
+
} catch (error) {
|
|
2309
|
+
console.warn('dsh-wsl-desktop: W 快捷键注册失败,W 按钮仍然可用', error)
|
|
2310
|
+
return () => {}
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
1914
2314
|
/**
|
|
1915
2315
|
* Install the companion button and keep it attached.
|
|
1916
2316
|
* @param {object} ctx - the client plugin context.
|
|
@@ -1923,19 +2323,22 @@ window.__ModuleLoader__.load({
|
|
|
1923
2323
|
button.setAttribute('aria-label', BUTTON_LABEL)
|
|
1924
2324
|
button.setAttribute('aria-haspopup', 'dialog')
|
|
1925
2325
|
button.setAttribute('aria-expanded', 'false')
|
|
1926
|
-
button.title = BUTTON_LABEL
|
|
1927
2326
|
button.append(el('span', {
|
|
1928
2327
|
fontWeight: '600',
|
|
1929
2328
|
fontSize: '13px',
|
|
1930
2329
|
lineHeight: '1',
|
|
1931
2330
|
letterSpacing: '0.02em',
|
|
1932
2331
|
}, 'W'))
|
|
2332
|
+
// The hint replaces the native title: it carries the SAME action name plus
|
|
2333
|
+
// the effective keycaps, and a title would show a second, stale tooltip.
|
|
2334
|
+
const hint = createShortcutHint(button, BUTTON_LABEL)
|
|
1933
2335
|
button.addEventListener('click', (event) => {
|
|
1934
2336
|
event.preventDefault()
|
|
1935
2337
|
event.stopPropagation()
|
|
1936
2338
|
openPicker(ctx)
|
|
1937
2339
|
})
|
|
1938
2340
|
companion.button = button
|
|
2341
|
+
companion.hideHint = hint.dismiss
|
|
1939
2342
|
|
|
1940
2343
|
/** The degraded entry shown when the sidebar anchor is missing. */
|
|
1941
2344
|
let fallbackEntry = null
|
|
@@ -1994,7 +2397,45 @@ window.__ModuleLoader__.load({
|
|
|
1994
2397
|
hostElement().append(entry)
|
|
1995
2398
|
fallbackEntry = entry
|
|
1996
2399
|
companion.fallback = open
|
|
2400
|
+
// The fallback is the W button's only visible entry in this phase, so
|
|
2401
|
+
// the shortcut claim belongs on it too; the visual bubble stays with the
|
|
2402
|
+
// real button to keep the failure path minimal.
|
|
2403
|
+
applyShortcut()
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/**
|
|
2407
|
+
* Publish the effective shortcut row to the button, hint, and fallback.
|
|
2408
|
+
*
|
|
2409
|
+
* Called once at install and again for every catalog change (a rebind, a
|
|
2410
|
+
* clear, another command's registration). The row is read from the live
|
|
2411
|
+
* catalog rather than remembered, so a rebind cannot leave the hint
|
|
2412
|
+
* claiming keys the command no longer owns.
|
|
2413
|
+
*/
|
|
2414
|
+
function applyShortcut() {
|
|
2415
|
+
let row = null
|
|
2416
|
+
try {
|
|
2417
|
+
row = shortcutRowOf(ctx)
|
|
2418
|
+
} catch (error) {
|
|
2419
|
+
// A throwing catalog is a shell fault, not a reason to lose the W
|
|
2420
|
+
// button: the hint is peripheral and degrades to its label.
|
|
2421
|
+
console.warn('dsh-wsl-desktop: W 快捷键目录读取失败,W 按钮仍然可用', error)
|
|
2422
|
+
}
|
|
2423
|
+
setKeyshortcuts(button, row)
|
|
2424
|
+
hint.setKeys(row?.keys ?? [])
|
|
2425
|
+
setKeyshortcuts(fallbackEntry?.querySelector('[data-wsl-fallback-entry]') ?? null, row)
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
const disposeShortcut = installShortcut(ctx)
|
|
2429
|
+
let disposeCatalog = null
|
|
2430
|
+
try {
|
|
2431
|
+
const catalog = ctx.shortcuts?.catalog
|
|
2432
|
+
disposeCatalog = typeof catalog?.subscribe === 'function'
|
|
2433
|
+
? catalog.subscribe(applyShortcut)
|
|
2434
|
+
: null
|
|
2435
|
+
} catch (error) {
|
|
2436
|
+
console.warn('dsh-wsl-desktop: W 快捷键目录订阅失败,W 按钮仍然可用', error)
|
|
1997
2437
|
}
|
|
2438
|
+
applyShortcut()
|
|
1998
2439
|
|
|
1999
2440
|
// `sync` is idempotent and never removes the node it manages, so its own
|
|
2000
2441
|
// work cannot re-trigger the observer it runs from.
|
|
@@ -2049,6 +2490,12 @@ window.__ModuleLoader__.load({
|
|
|
2049
2490
|
return () => {
|
|
2050
2491
|
clearTimeout(warning)
|
|
2051
2492
|
removeFallback()
|
|
2493
|
+
// Unsubscribe BEFORE the command leaves the catalog: removing it makes
|
|
2494
|
+
// the service publish a fresh snapshot, and a listener still attached
|
|
2495
|
+
// would rebuild the bubble this teardown is about to delete.
|
|
2496
|
+
if (disposeCatalog !== null) disposeCatalog()
|
|
2497
|
+
disposeShortcut()
|
|
2498
|
+
hint.dispose()
|
|
2052
2499
|
observer.disconnect()
|
|
2053
2500
|
unsubscribe?.()
|
|
2054
2501
|
// disconnect() does not cancel a callback already handed to
|
|
@@ -2066,6 +2513,7 @@ window.__ModuleLoader__.load({
|
|
|
2066
2513
|
}
|
|
2067
2514
|
button.remove()
|
|
2068
2515
|
companion.button = null
|
|
2516
|
+
companion.hideHint = null
|
|
2069
2517
|
companion.trigger = null
|
|
2070
2518
|
companion.observer = null
|
|
2071
2519
|
teardown?.()
|
|
@@ -2078,7 +2526,7 @@ window.__ModuleLoader__.load({
|
|
|
2078
2526
|
// `remote.session` is the namespace the create request travels through
|
|
2079
2527
|
// (see `createBoundSession`); the harness's own client plugins inject it
|
|
2080
2528
|
// the same way.
|
|
2081
|
-
inject: ['workspaces', 'uiWorkspace', 'sessions', 'remote', 'remote.session'],
|
|
2529
|
+
inject: ['workspaces', 'uiWorkspace', 'sessions', 'remote', 'remote.session', 'shortcuts'],
|
|
2082
2530
|
apply(ctx) {
|
|
2083
2531
|
ctx.effect(() => install(ctx), 'wsl-desktop: sidebar WSL workspace trigger')
|
|
2084
2532
|
},
|