dsh-coding-sidebar 1.0.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/LICENSE +21 -0
- package/README.md +524 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +48229 -0
- package/lib/client-mermaid.js +200745 -0
- package/lib/client-registry.js +21867 -0
- package/lib/client-terminal.js +9361 -0
- package/lib/client.js +21867 -0
- package/lib/index.js +4556 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-opens.d.ts +65 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +39 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +13 -0
- package/lib/types/client/FileTree.d.ts +38 -0
- package/lib/types/client/FreeWindow.d.ts +29 -0
- package/lib/types/client/GitView.d.ts +10 -0
- package/lib/types/client/MarkdownHtml.d.ts +32 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +54 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +24 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +275 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +17 -0
- package/lib/types/client/browser.d.ts +60 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +131 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +59 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +25 -0
- package/lib/types/client/intercept.d.ts +46 -0
- package/lib/types/client/lang.d.ts +14 -0
- package/lib/types/client/layout-push.d.ts +15 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales-ar.d.ts +18 -0
- package/lib/types/client/locales-de.d.ts +4 -0
- package/lib/types/client/locales-fr.d.ts +10 -0
- package/lib/types/client/locales-hi.d.ts +17 -0
- package/lib/types/client/locales-id.d.ts +15 -0
- package/lib/types/client/locales-it.d.ts +8 -0
- package/lib/types/client/locales-ja.d.ts +17 -0
- package/lib/types/client/locales-ko.d.ts +9 -0
- package/lib/types/client/locales-nl.d.ts +16 -0
- package/lib/types/client/locales-pl.d.ts +19 -0
- package/lib/types/client/locales-pt.d.ts +1 -0
- package/lib/types/client/locales-ru.d.ts +13 -0
- package/lib/types/client/locales-sv.d.ts +1 -0
- package/lib/types/client/locales-th.d.ts +17 -0
- package/lib/types/client/locales-tr.d.ts +17 -0
- package/lib/types/client/locales-vi.d.ts +17 -0
- package/lib/types/client/locales-zh-HK.d.ts +32 -0
- package/lib/types/client/locales-zh-MO.d.ts +32 -0
- package/lib/types/client/locales-zh-TW.d.ts +32 -0
- package/lib/types/client/locales.d.ts +452 -0
- package/lib/types/client/markdown-html.d.ts +96 -0
- package/lib/types/client/markdown-images.d.ts +45 -0
- package/lib/types/client/markdown-labels.d.ts +38 -0
- package/lib/types/client/md-toc.d.ts +4 -0
- package/lib/types/client/mermaid-blocks.d.ts +47 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +85 -0
- package/lib/types/client/paths.d.ts +43 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/pinned.d.ts +86 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +29 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +439 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +36 -0
- package/lib/types/client/state.d.ts +432 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +40 -0
- package/lib/types/client/terminal-font.d.ts +78 -0
- package/lib/types/client/terminal-links.d.ts +119 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +605 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +124 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/path-security.d.ts +20 -0
- package/lib/types/prefs-shared.d.ts +236 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +34 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +172 -0
- package/scripts/install.ps1 +306 -0
- package/scripts/install.sh +287 -0
- package/src/agent-opens.ts +265 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +289 -0
- package/src/client/DiffTab.tsx +114 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +558 -0
- package/src/client/FileTree.tsx +669 -0
- package/src/client/FreeWindow.tsx +337 -0
- package/src/client/GitView.tsx +717 -0
- package/src/client/MarkdownHtml.tsx +296 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1163 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +645 -0
- package/src/client/Sidebar.tsx +1370 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +365 -0
- package/src/client/TerminalView.tsx +424 -0
- package/src/client/TextEditor.tsx +468 -0
- package/src/client/TreePanel.tsx +275 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +357 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +61 -0
- package/src/client/browser.ts +167 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +340 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +358 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +152 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +427 -0
- package/src/client/intercept.tsx +188 -0
- package/src/client/lang.ts +225 -0
- package/src/client/layout-push.ts +24 -0
- package/src/client/layout.css +90 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales-ar.ts +368 -0
- package/src/client/locales-de.ts +353 -0
- package/src/client/locales-fr.ts +360 -0
- package/src/client/locales-hi.ts +367 -0
- package/src/client/locales-id.ts +365 -0
- package/src/client/locales-it.ts +358 -0
- package/src/client/locales-ja.ts +367 -0
- package/src/client/locales-ko.ts +359 -0
- package/src/client/locales-nl.ts +365 -0
- package/src/client/locales-pl.ts +369 -0
- package/src/client/locales-pt.ts +350 -0
- package/src/client/locales-ru.ts +365 -0
- package/src/client/locales-sv.ts +350 -0
- package/src/client/locales-th.ts +367 -0
- package/src/client/locales-tr.ts +367 -0
- package/src/client/locales-vi.ts +367 -0
- package/src/client/locales-zh-HK.ts +382 -0
- package/src/client/locales-zh-MO.ts +382 -0
- package/src/client/locales-zh-TW.ts +382 -0
- package/src/client/locales.ts +894 -0
- package/src/client/markdown-html.ts +331 -0
- package/src/client/markdown-images.ts +137 -0
- package/src/client/markdown-labels.tsx +52 -0
- package/src/client/md-toc.tsx +127 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +406 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +143 -0
- package/src/client/paths.ts +63 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/pinned.ts +183 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +96 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +210 -0
- package/src/client/produced-files.ts +120 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +859 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2788 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +331 -0
- package/src/client/state.ts +1497 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +55 -0
- package/src/client/terminal-font.ts +224 -0
- package/src/client/terminal-links.ts +194 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +154 -0
- package/src/context-types.ts +611 -0
- package/src/fs-operations.ts +95 -0
- package/src/fs-search.ts +113 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +446 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1291 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/path-security.ts +75 -0
- package/src/prefs-shared.ts +280 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +482 -0
- package/src/trust-fence.ts +84 -0
- package/src/wire.ts +100 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal URL hyperlinks: xterm's `registerLinkProvider` is fed per-line
|
|
3
|
+
* link descriptors built from the pty stream, so URLs printed by any tool
|
|
4
|
+
* become hoverable / clickable spans.
|
|
5
|
+
*
|
|
6
|
+
* To stay out of the user's way (a terminal's primary interaction is text
|
|
7
|
+
* selection, not browsing), a click only activates when the user holds
|
|
8
|
+
* Ctrl (Win/Linux) or Cmd (mac) — a plain click is left for xterm's
|
|
9
|
+
* normal selection handling. Only http(s) URLs are dispatched to the
|
|
10
|
+
* browser; other schemes (`file://`, `mailto:`, `javascript:`, …) are
|
|
11
|
+
* underlined for visibility but rejected at activation, so a `file://`
|
|
12
|
+
* URL printed by a tool stays inert instead of being handed to
|
|
13
|
+
* `window.open`.
|
|
14
|
+
*
|
|
15
|
+
* Kept as a pure module (no xterm import) so the regex, the line
|
|
16
|
+
* scanner, the modifier gate and the scheme guard are unit-testable
|
|
17
|
+
* without mounting a terminal. `buildTerminalLinks` returns plain-object
|
|
18
|
+
* descriptors whose shape matches xterm's `ILink` minus the `activate`
|
|
19
|
+
* callback — the caller attaches `activate` (which closes over the
|
|
20
|
+
* event modifier check + `openTerminalUrl`) so this module never
|
|
21
|
+
* imports xterm types.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Scheme allowlist for activation. Only http(s) is opened externally. */
|
|
25
|
+
const OPENABLE_SCHEMES = new Set(['http:', 'https:'])
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The URL pattern used to scan each terminal line.
|
|
29
|
+
*
|
|
30
|
+
* A word boundary (`\b`) guards the leading scheme so `notttps://…` does
|
|
31
|
+
* not match. The character class excludes ASCII whitespace and the
|
|
32
|
+
* wrapping punctuation that shells commonly emit around URLs (quotes,
|
|
33
|
+
* angle brackets, brackets/braces, pipes, backslashes, backticks) so a
|
|
34
|
+
* URL printed as `"https://example.com"` or `<https://example.com>` does
|
|
35
|
+
* not drag the wrapping character into the link target.
|
|
36
|
+
*
|
|
37
|
+
* Carries the `g` flag so `findTerminalUrlsInLine` can iterate every
|
|
38
|
+
* match on a line; callers must reset `lastIndex` before reuse (the
|
|
39
|
+
* helper does this defensively).
|
|
40
|
+
*/
|
|
41
|
+
export const TERMINAL_URL_REGEX = /\bhttps?:\/\/[^\s"'<>\[\]{}|\\^`]+/gi
|
|
42
|
+
|
|
43
|
+
/** A URL match in a terminal line: the text and its 0-based start offset. */
|
|
44
|
+
export interface TerminalUrlMatch {
|
|
45
|
+
/** 0-based start index of the URL within the line string. */
|
|
46
|
+
start: number
|
|
47
|
+
/** The matched URL text. */
|
|
48
|
+
text: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Strip trailing closing parens that are not balanced by an opening paren
|
|
53
|
+
* earlier in the URL.
|
|
54
|
+
*
|
|
55
|
+
* The regex's character class keeps `(` and `)` (Wikipedia-style URLs
|
|
56
|
+
* carry real parens: `…/Python_(programming_language)`), so a URL wrapped
|
|
57
|
+
* in parens by a shell (`(https://example.com)`) captures the trailing
|
|
58
|
+
* `)`. Balanced pairs are kept intact; only the unmatched excess is
|
|
59
|
+
* trimmed, so:
|
|
60
|
+
* - `https://example.com)` → `https://example.com`
|
|
61
|
+
* - `https://en.wikipedia.org/wiki/Python_(programming_language)` → unchanged
|
|
62
|
+
* - `https://example.com/(` → unchanged (more openers than closers; the
|
|
63
|
+
* trailing `(` is the URL's own, not a wrapper)
|
|
64
|
+
*/
|
|
65
|
+
function trimUnbalancedTrailingParens(url: string): string {
|
|
66
|
+
let opens = 0
|
|
67
|
+
let closers = 0
|
|
68
|
+
for (let i = 0; i < url.length; i += 1) {
|
|
69
|
+
const ch = url[i]
|
|
70
|
+
if (ch === '(') opens += 1
|
|
71
|
+
else if (ch === ')') closers += 1
|
|
72
|
+
}
|
|
73
|
+
const excess = closers - opens
|
|
74
|
+
if (excess <= 0) return url
|
|
75
|
+
let end = url.length
|
|
76
|
+
let stripped = 0
|
|
77
|
+
while (end > 0 && url[end - 1] === ')' && stripped < excess) {
|
|
78
|
+
end -= 1
|
|
79
|
+
stripped += 1
|
|
80
|
+
}
|
|
81
|
+
return url.slice(0, end)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Find every http(s) URL in a line of terminal text, in source order
|
|
86
|
+
* with 0-based start offsets. A link provider maps these to buffer
|
|
87
|
+
* ranges for xterm's `registerLinkProvider`.
|
|
88
|
+
*
|
|
89
|
+
* Trailing unmatched closing parens are trimmed from each match (see
|
|
90
|
+
* {@link trimUnbalancedTrailingParens}), so a URL wrapped in parens by
|
|
91
|
+
* a shell opens without the trailing `)`, while Wikipedia-style URLs
|
|
92
|
+
* with balanced parens stay intact.
|
|
93
|
+
*
|
|
94
|
+
* Resets the regex's `lastIndex` before and after the scan so a
|
|
95
|
+
* previous partial iteration can't desynchronize a later one (the
|
|
96
|
+
* regex carries the `g` flag and is module-shared).
|
|
97
|
+
*/
|
|
98
|
+
export function findTerminalUrlsInLine(line: string): TerminalUrlMatch[] {
|
|
99
|
+
TERMINAL_URL_REGEX.lastIndex = 0
|
|
100
|
+
const matches: TerminalUrlMatch[] = []
|
|
101
|
+
let m: RegExpExecArray | null
|
|
102
|
+
while ((m = TERMINAL_URL_REGEX.exec(line)) !== null) {
|
|
103
|
+
const trimmed = trimUnbalancedTrailingParens(m[0])
|
|
104
|
+
if (trimmed.length > 0) {
|
|
105
|
+
matches.push({ start: m.index, text: trimmed })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
TERMINAL_URL_REGEX.lastIndex = 0
|
|
109
|
+
return matches
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A buffer range in xterm's coordinate system. xterm's cell `x` is
|
|
114
|
+
* 1-based (1..cols) and `y` is the buffer line number; the `end` is
|
|
115
|
+
* inclusive (the last cell covered by the link). Structurally
|
|
116
|
+
* compatible with xterm's `IBufferRange` so the caller can pass it
|
|
117
|
+
* straight through without importing xterm types here.
|
|
118
|
+
*/
|
|
119
|
+
export interface TerminalLinkRange {
|
|
120
|
+
start: { x: number; y: number }
|
|
121
|
+
end: { x: number; y: number }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A link descriptor: a buffer range plus the URL text. The caller
|
|
126
|
+
* (the link provider in `TerminalView`) attaches the `activate`
|
|
127
|
+
* callback, which closes over the modifier gate and `openTerminalUrl`.
|
|
128
|
+
*/
|
|
129
|
+
export interface TerminalLinkDescriptor {
|
|
130
|
+
range: TerminalLinkRange
|
|
131
|
+
text: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build link descriptors for every URL found in a terminal line.
|
|
136
|
+
*
|
|
137
|
+
* @param lineText - the line's text (e.g. from
|
|
138
|
+
* `IBufferLine.translateToString(true)`).
|
|
139
|
+
* @param lineNumber - the buffer line number xterm passed to
|
|
140
|
+
* `ILinkProvider.provideLinks` (used as the `y` of every range; URLs
|
|
141
|
+
* never span wrapped lines because each wrapped row is its own
|
|
142
|
+
* buffer line).
|
|
143
|
+
* @returns descriptors in source order; empty when the line has no URL.
|
|
144
|
+
*/
|
|
145
|
+
export function buildTerminalLinks(lineText: string, lineNumber: number): TerminalLinkDescriptor[] {
|
|
146
|
+
return findTerminalUrlsInLine(lineText).map(({ start, text: url }) => ({
|
|
147
|
+
range: {
|
|
148
|
+
// xterm's cell `x` is 1-based; `start` is 0-based from
|
|
149
|
+
// translateToString, so the first URL character sits at x = start + 1.
|
|
150
|
+
start: { x: start + 1, y: lineNumber },
|
|
151
|
+
// `end` is inclusive: the last URL character is at 0-based
|
|
152
|
+
// (start + url.length - 1), which is 1-based (start + url.length).
|
|
153
|
+
end: { x: start + url.length, y: lineNumber },
|
|
154
|
+
},
|
|
155
|
+
text: url,
|
|
156
|
+
}))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Decide whether a click on a terminal link should activate (open the URL).
|
|
161
|
+
*
|
|
162
|
+
* Mirrors what every modern terminal does (Windows Terminal, iTerm2, the
|
|
163
|
+
* VSCode integrated terminal): a plain click stays a text-selection
|
|
164
|
+
* gesture, and only Ctrl (Win/Linux) or Cmd (mac) hands the URL to the
|
|
165
|
+
* browser. The modifier is read off the activating `MouseEvent`, not
|
|
166
|
+
* tracked separately, so a key-up between hover and click never
|
|
167
|
+
* desynchronizes the gate.
|
|
168
|
+
*/
|
|
169
|
+
export function shouldActivateTerminalLink(event: MouseEvent): boolean {
|
|
170
|
+
return event.ctrlKey || event.metaKey
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Open a URL matched in the terminal, with a scheme guard so a printed
|
|
175
|
+
* `file://` or anything that slipped past the regex cannot reach
|
|
176
|
+
* `window.open`. The URL is constructed via `new URL(...)` which throws
|
|
177
|
+
* on malformed input; the catch makes the function total so the xterm
|
|
178
|
+
* handler never throws into the terminal's event loop.
|
|
179
|
+
*
|
|
180
|
+
* @returns `true` when the URL was dispatched to `window.open`, `false`
|
|
181
|
+
* when it was rejected (bad URL, disallowed scheme, no `window`).
|
|
182
|
+
*/
|
|
183
|
+
export function openTerminalUrl(uri: string): boolean {
|
|
184
|
+
if (typeof window === 'undefined') return false
|
|
185
|
+
let url: URL
|
|
186
|
+
try {
|
|
187
|
+
url = new URL(uri)
|
|
188
|
+
} catch {
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
if (!OPENABLE_SCHEMES.has(url.protocol)) return false
|
|
192
|
+
window.open(url.toString(), '_blank', 'noopener,noreferrer')
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live theme access for surfaces that cannot consume the token colors
|
|
3
|
+
* directly — xterm's palette and CodeMirror's theme extensions need concrete
|
|
4
|
+
* values, but the app's scheme flips at runtime (ui-layout's ThemePresenter
|
|
5
|
+
* projects prefers-color-scheme and the user's choice onto
|
|
6
|
+
* body[data-ds-dark-theme] and html { color-scheme }). This module reads the
|
|
7
|
+
* resolved scheme and token values, and notifies subscribers on flips, so
|
|
8
|
+
* the terminal and the editor re-theme in place instead of freezing in the
|
|
9
|
+
* scheme they happened to be created under.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Whether the app shell resolved to the dark scheme.
|
|
13
|
+
*
|
|
14
|
+
* The presenter sets `html { color-scheme }` together with the body palette
|
|
15
|
+
* attribute, so a set color-scheme means the decision is authoritative (an
|
|
16
|
+
* absent attribute is then LIGHT even when the OS prefers dark — the user
|
|
17
|
+
* chose light). Before the presenter has run, fall back to the OS media
|
|
18
|
+
* query as the best guess.
|
|
19
|
+
*/
|
|
20
|
+
export function isDarkScheme(): boolean {
|
|
21
|
+
if (typeof document === 'undefined') return true
|
|
22
|
+
const decided = document.documentElement.style.colorScheme !== ''
|
|
23
|
+
if (decided) return document.body.hasAttribute('data-ds-dark-theme')
|
|
24
|
+
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One token's computed value on <body> ('' while the theme has not applied). */
|
|
28
|
+
export function tokenValue(name: string): string {
|
|
29
|
+
if (typeof document === 'undefined') return ''
|
|
30
|
+
return getComputedStyle(document.body).getPropertyValue(name).trim()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Minimal alpha for a token color to count as effectively opaque. Skin
|
|
34
|
+
* systems turn `--dsw-alias-bg-base` translucent for glass panels (the
|
|
35
|
+
* dsh-web-ui skins use rgba 0.16–0.7; `transparent` is 0); below this
|
|
36
|
+
* floor a text surface (terminal, editor) would render over the skin's
|
|
37
|
+
* backdrop art, so callers fall back to an opaque color. Values at or
|
|
38
|
+
* above the floor (e.g. a skin's scoped 0.96 porcelain) pass through —
|
|
39
|
+
* the skin still controls the surface. */
|
|
40
|
+
const OPAQUE_ALPHA_MIN = 0.9
|
|
41
|
+
|
|
42
|
+
/** The alpha channel of a computed CSS color, or null when the format is
|
|
43
|
+
* not parseable (named colors, `color()`… — treated as opaque). Handles
|
|
44
|
+
* the shapes getComputedStyle actually returns: the rgb()/rgba() and
|
|
45
|
+
* hsl()/hsla() function forms (comma or space syntax, with or without the
|
|
46
|
+
* `/ alpha` slot) and the #rgb/#rgba/#rrggbb/#rrggbbaa hex family. */
|
|
47
|
+
export function colorAlpha(color: string): number | null {
|
|
48
|
+
const s = color.trim()
|
|
49
|
+
// #rgb / #rgba / #rrggbb / #rrggbbaa
|
|
50
|
+
const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(s)
|
|
51
|
+
if (hex !== null) {
|
|
52
|
+
const digits = hex[1]!
|
|
53
|
+
if (digits.length === 3 || digits.length === 4) {
|
|
54
|
+
const a = digits.length === 4 ? digits[3]! : 'f'
|
|
55
|
+
return parseInt(a + a, 16) / 255
|
|
56
|
+
}
|
|
57
|
+
const alphaHex = digits.length === 8 ? digits.slice(6) : 'ff'
|
|
58
|
+
return parseInt(alphaHex, 16) / 255
|
|
59
|
+
}
|
|
60
|
+
// rgb(r g b) / rgb(r g b / a) / rgba(r, g, b, a) — the trailing slot is alpha.
|
|
61
|
+
const fn = /^(rgba?|hsla?)\(([^)]+)\)$/i.exec(s)
|
|
62
|
+
if (fn !== null) {
|
|
63
|
+
const parts = fn[2]!.split(/[,\s/]+/).filter(Boolean)
|
|
64
|
+
const alphaPart = parts[3]
|
|
65
|
+
if (alphaPart === undefined) return 1
|
|
66
|
+
const alpha = Number.parseFloat(alphaPart)
|
|
67
|
+
return Number.isFinite(alpha) ? alpha : 1
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A token value that actually PAINTS something — the guard for text
|
|
74
|
+
* surfaces (issue #90). Skin systems routinely set global tokens to
|
|
75
|
+
* `transparent` (glass skins) or translucent glass values (`rgba(…,0.16–0.7)`,
|
|
76
|
+
* e.g. the dsh-web-ui skins) — both are truthy strings, so callers using
|
|
77
|
+
* `|| fallback` never fire and the terminal/editor goes see-through over
|
|
78
|
+
* the skin's backdrop. This returns '' for visually inert values (unset
|
|
79
|
+
* keywords, transparent, and any color below the opacity floor) so the
|
|
80
|
+
* caller's fallback chain engages; effectively opaque values pass through.
|
|
81
|
+
*/
|
|
82
|
+
export function effectiveTokenValue(name: string): string {
|
|
83
|
+
const raw = tokenValue(name)
|
|
84
|
+
switch (raw) {
|
|
85
|
+
case '':
|
|
86
|
+
case 'transparent':
|
|
87
|
+
case 'initial':
|
|
88
|
+
case 'inherit':
|
|
89
|
+
case 'unset':
|
|
90
|
+
return ''
|
|
91
|
+
default: {
|
|
92
|
+
const alpha = colorAlpha(raw)
|
|
93
|
+
if (alpha !== null && alpha < OPAQUE_ALPHA_MIN) return ''
|
|
94
|
+
return raw
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Subscribe to color-scheme flips (the presenter toggles the body
|
|
101
|
+
* attribute). The callback fires after the attribute changed; re-read the
|
|
102
|
+
* scheme inside it.
|
|
103
|
+
* @returns the disposer.
|
|
104
|
+
*/
|
|
105
|
+
export function subscribeColorScheme(callback: () => void): () => void {
|
|
106
|
+
if (typeof document === 'undefined') return () => {}
|
|
107
|
+
const observer = new MutationObserver(() => { callback() })
|
|
108
|
+
observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
|
|
109
|
+
return () => { observer.disconnect() }
|
|
110
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The title-bar strip resolution chain — the ONE place that decides how
|
|
3
|
+
* many pixels the sidebar yields at the top. Standard signals first, then
|
|
4
|
+
* the user's chosen scheme; never a per-shell branch:
|
|
5
|
+
*
|
|
6
|
+
* 0. `web` scheme — EXPLICIT "DSH official web": never adapt, not even
|
|
7
|
+
* standard WCO geometry (the user declares the plain web UI).
|
|
8
|
+
* 1. Window Controls Overlay real geometry (standard API, authoritative
|
|
9
|
+
* when present — even 0, e.g. the overlay is hidden while maximized).
|
|
10
|
+
* 2. The `dsh-desktop-titlebar-inset` URL contract parameter (a shell
|
|
11
|
+
* declares the exact pixels it reserves).
|
|
12
|
+
* 3. The active shell preset's strip (scheme `preset` — opt-in data).
|
|
13
|
+
* 4. The legacy manual `titleBarStripPx` (scheme `custom`).
|
|
14
|
+
* 5. 0 — plain-browser semantics, nothing modified.
|
|
15
|
+
*
|
|
16
|
+
* The result drives `--dsh-title-bar-strip` + `body[data-dsh-title-bar-compat]`
|
|
17
|
+
* exactly like the legacy boolean did; only the VALUE source changed.
|
|
18
|
+
*/
|
|
19
|
+
import type { DesktopEnv } from './desktop-env.ts'
|
|
20
|
+
import type { TitleBarScheme } from '../prefs-shared.ts'
|
|
21
|
+
import type { WcoSnapshot } from './wco.ts'
|
|
22
|
+
import { presetStripFor, type ShellPreset } from './shell-presets.ts'
|
|
23
|
+
|
|
24
|
+
export function computeTitleBarStrip(
|
|
25
|
+
env: DesktopEnv,
|
|
26
|
+
wco: WcoSnapshot,
|
|
27
|
+
scheme: TitleBarScheme,
|
|
28
|
+
preset: ShellPreset | undefined,
|
|
29
|
+
customStripPx: number,
|
|
30
|
+
): number {
|
|
31
|
+
if (scheme === 'web') return 0
|
|
32
|
+
if (wco.present) return wco.height
|
|
33
|
+
if (env.titlebarInset > 0) return env.titlebarInset
|
|
34
|
+
if (scheme === 'preset') return presetStripFor(preset, env) ?? 0
|
|
35
|
+
if (scheme === 'custom') return customStripPx
|
|
36
|
+
return 0
|
|
37
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-upload plumbing for the files window: turn a file picker or a drag-drop
|
|
3
|
+
* into per-file raw-byte uploads through the sidebar's `/sidebar/upload` route.
|
|
4
|
+
*
|
|
5
|
+
* Folders keep their tree in both flows: the picker's `webkitdirectory`
|
|
6
|
+
* selection arrives as Files with `webkitRelativePath` filled, and dropped
|
|
7
|
+
* folders — which never surface in `dataTransfer.files` — are traversed via
|
|
8
|
+
* `webkitGetAsEntry`, so the relative path is preserved for every nested file
|
|
9
|
+
* and the host recreates the tree under the chosen directory. The File is
|
|
10
|
+
* streamed straight into the POST body (no base64 inflation); uploads run
|
|
11
|
+
* sequentially so one slow file cannot starve the others, and each result
|
|
12
|
+
* reports its own outcome (the tree keeps going after a failure). An
|
|
13
|
+
* optional `AbortSignal` stops the queue at the next item boundary and
|
|
14
|
+
* aborts the in-flight request; the host cleans up its temp file when the
|
|
15
|
+
* request stream dies.
|
|
16
|
+
*/
|
|
17
|
+
import { api, SidebarApiError, type SessionScope } from './api.ts'
|
|
18
|
+
import { isAbsolutePath } from './paths.ts'
|
|
19
|
+
import type { CopyKey } from './locales.ts'
|
|
20
|
+
|
|
21
|
+
/** One pending file: the browser File plus the workspace-relative target path. */
|
|
22
|
+
export interface UploadItem {
|
|
23
|
+
file: File
|
|
24
|
+
relativePath: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One settled upload. */
|
|
28
|
+
export interface UploadResult {
|
|
29
|
+
relativePath: string
|
|
30
|
+
ok: boolean
|
|
31
|
+
path?: string
|
|
32
|
+
/** Wire error code when the host refused the upload ('too-large', ...). */
|
|
33
|
+
code?: string
|
|
34
|
+
/** The host's error message (English wire text; localize via `code`). */
|
|
35
|
+
error?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Sanitize a relative target: absolute paths, traversal, and empty segments
|
|
39
|
+
* are rejected (the host enforces the same rules with a 400). */
|
|
40
|
+
function sanitizeRelativePath(rel: string): string | undefined {
|
|
41
|
+
if (rel === '' || isAbsolutePath(rel)) return undefined
|
|
42
|
+
if (rel.split(/[\\/]/).some((s) => s === '' || s === '.' || s === '..')) return undefined
|
|
43
|
+
return rel
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The picker's relative path: webkitRelativePath when present, else the name. */
|
|
47
|
+
function relativePathOf(file: File): string | undefined {
|
|
48
|
+
return sanitizeRelativePath(file.webkitRelativePath || file.name || '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Collect a picker selection (webkitdirectory folders carry relative paths). */
|
|
52
|
+
export function uploadItemsFromFiles(files: FileList | readonly File[]): UploadItem[] {
|
|
53
|
+
const items: UploadItem[] = []
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
const rel = relativePathOf(file)
|
|
56
|
+
if (rel !== undefined) items.push({ file, relativePath: rel })
|
|
57
|
+
}
|
|
58
|
+
return items
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read one dropped file-system entry into upload items; directories
|
|
62
|
+
* recurse, prefixing their name onto every descendant's relative path. */
|
|
63
|
+
async function itemsFromEntry(entry: FileSystemEntry, prefix: string): Promise<UploadItem[]> {
|
|
64
|
+
if (entry.isFile) {
|
|
65
|
+
const file = await new Promise<File>((resolve, reject) => {
|
|
66
|
+
;(entry as FileSystemFileEntry).file(resolve, reject)
|
|
67
|
+
})
|
|
68
|
+
const rel = sanitizeRelativePath(prefix + file.name)
|
|
69
|
+
return rel === undefined ? [] : [{ file, relativePath: rel }]
|
|
70
|
+
}
|
|
71
|
+
if (entry.isDirectory) {
|
|
72
|
+
const reader = (entry as FileSystemDirectoryEntry).createReader()
|
|
73
|
+
const entries: FileSystemEntry[] = []
|
|
74
|
+
// readEntries returns one BATCH per call; drain until an empty batch.
|
|
75
|
+
for (;;) {
|
|
76
|
+
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
|
77
|
+
reader.readEntries(resolve, reject)
|
|
78
|
+
})
|
|
79
|
+
if (batch.length === 0) break
|
|
80
|
+
entries.push(...batch)
|
|
81
|
+
}
|
|
82
|
+
const nested = await Promise.all(entries.map(child => itemsFromEntry(child, `${prefix}${entry.name}/`)))
|
|
83
|
+
return nested.flat()
|
|
84
|
+
}
|
|
85
|
+
return []
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Collect a drag-drop payload. Dropped folders do NOT surface in
|
|
90
|
+
* `dataTransfer.files` — they arrive as directory items, so entries are
|
|
91
|
+
* captured via `webkitGetAsEntry` and traversed (draining readEntries
|
|
92
|
+
* batches), keeping each nested file's relative path. MUST be invoked
|
|
93
|
+
* synchronously from the drop handler: the dataTransfer enters protected
|
|
94
|
+
* mode once the event dispatch ends, while the captured entry handles stay
|
|
95
|
+
* readable asynchronously. Falls back to the flat file list when the entry
|
|
96
|
+
* API is unavailable; an entry that fails to read is skipped, not fatal.
|
|
97
|
+
*/
|
|
98
|
+
export async function uploadItemsFromDrop(data: DataTransfer | undefined): Promise<UploadItem[]> {
|
|
99
|
+
if (data === undefined) return []
|
|
100
|
+
// Synchronous section: capture every entry handle before returning.
|
|
101
|
+
const entries = [...data.items]
|
|
102
|
+
.map(item => (item.kind === 'file' ? item.webkitGetAsEntry() : null))
|
|
103
|
+
.filter((entry): entry is FileSystemEntry => entry !== null)
|
|
104
|
+
if (entries.length === 0) return uploadItemsFromFiles(data.files)
|
|
105
|
+
const nested = await Promise.all(entries.map(entry => itemsFromEntry(entry, '').catch(() => [])))
|
|
106
|
+
return nested.flat()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Client-side pre-check cap. Mirrors the host's default (config `uploadLimit`)
|
|
111
|
+
* only — the host enforces the real, configurable limit, so a lowered host
|
|
112
|
+
* limit surfaces as a `too-large` wire error, never as a silent pass.
|
|
113
|
+
*/
|
|
114
|
+
export const MAX_UPLOAD_BYTES = 128 * 1024 * 1024
|
|
115
|
+
|
|
116
|
+
/** How long a success hint stays before fading (failures stay until the next action). */
|
|
117
|
+
export const UPLOAD_HINT_MS = 3500
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One-line upload progress text: 'Uploading into {dir}…' while no file is in
|
|
121
|
+
* flight, then 'Uploading {done}/{total}: {name}' per file. Shared by the tree
|
|
122
|
+
* hint and the full-window upload overlay.
|
|
123
|
+
*/
|
|
124
|
+
export function uploadHintText(
|
|
125
|
+
done: number,
|
|
126
|
+
total: number,
|
|
127
|
+
current: string,
|
|
128
|
+
dir: string,
|
|
129
|
+
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
|
130
|
+
): string {
|
|
131
|
+
return current === '' ? t('uploadingTo', { dir }) : t('uploadProgress', { done, total, name: current })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Upload every item into `dir` (absolute, inside the session workspace),
|
|
136
|
+
* sequentially, reporting progress as `(done, total, currentRelativePath)`.
|
|
137
|
+
* Resolves with one result per item — never rejects; `signal.aborted` stops
|
|
138
|
+
* the queue at the next item boundary (completed items stay uploaded).
|
|
139
|
+
*/
|
|
140
|
+
export async function uploadToDir(
|
|
141
|
+
scope: SessionScope,
|
|
142
|
+
dir: string,
|
|
143
|
+
items: UploadItem[],
|
|
144
|
+
onProgress?: (done: number, total: number, current: string) => void,
|
|
145
|
+
signal?: AbortSignal,
|
|
146
|
+
): Promise<UploadResult[]> {
|
|
147
|
+
const results: UploadResult[] = []
|
|
148
|
+
let done = 0
|
|
149
|
+
for (const item of items) {
|
|
150
|
+
if (signal?.aborted) break
|
|
151
|
+
onProgress?.(done, items.length, item.relativePath)
|
|
152
|
+
try {
|
|
153
|
+
if (item.file.size > MAX_UPLOAD_BYTES) {
|
|
154
|
+
results.push({ relativePath: item.relativePath, ok: false, code: 'too-large' })
|
|
155
|
+
} else {
|
|
156
|
+
const res = await api.uploadFile(scope, dir, item.relativePath, item.file, signal)
|
|
157
|
+
results.push({ relativePath: item.relativePath, ok: true, path: res.path })
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// An aborted in-flight request is a cancel, not a failure: stop quietly.
|
|
161
|
+
if (error instanceof DOMException && error.name === 'AbortError') break
|
|
162
|
+
results.push({
|
|
163
|
+
relativePath: item.relativePath,
|
|
164
|
+
ok: false,
|
|
165
|
+
code: error instanceof SidebarApiError ? error.code : undefined,
|
|
166
|
+
error: error instanceof Error ? error.message : String(error),
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
done++
|
|
170
|
+
}
|
|
171
|
+
onProgress?.(done, items.length, '')
|
|
172
|
+
return results
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Fold a result list into a one-line status for the tree hint. */
|
|
176
|
+
export function summarizeResults(
|
|
177
|
+
results: UploadResult[],
|
|
178
|
+
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
|
179
|
+
): string {
|
|
180
|
+
const okCount = results.filter((r) => r.ok).length
|
|
181
|
+
const failed = results.find((r) => !r.ok)
|
|
182
|
+
if (failed !== undefined) {
|
|
183
|
+
const detail = failed.code === 'too-large' ? t('uploadTooLarge') : (failed.error ?? t('uploadFailedUnknown'))
|
|
184
|
+
return t('uploadFailed', { error: detail })
|
|
185
|
+
}
|
|
186
|
+
return t('uploadDone', { count: okCount })
|
|
187
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive Window Controls Overlay geometry — the STANDARD web mechanism
|
|
3
|
+
* for frameless Chromium shells that draw the native caption buttons
|
|
4
|
+
* (minimize / maximize / close) OVER the web content (Electron
|
|
5
|
+
* `titleBarOverlay` on Windows; issue #257). Feature-detected: absent in
|
|
6
|
+
* plain browsers, macOS, Tauri etc., where the snapshot stays `NONE` and
|
|
7
|
+
* the sidebar adapts nothing — this is the conservative "auto" signal.
|
|
8
|
+
*
|
|
9
|
+
* The API reports the real titlebar rect (CSS px), which differs between
|
|
10
|
+
* shells and Electron versions (32 / 36 …), and fires `geometrychange`
|
|
11
|
+
* when the window maximizes / restores / moves — so the strip must be
|
|
12
|
+
* reactive, not memoized like the URL stamps.
|
|
13
|
+
*
|
|
14
|
+
* Module-level store so the Sidebar shell can subscribe via
|
|
15
|
+
* `useSyncExternalStore`; `setWcoSourceForTests` swaps the source for unit
|
|
16
|
+
* tests (no real API exists in jsdom/headless).
|
|
17
|
+
*/
|
|
18
|
+
export interface WcoSource {
|
|
19
|
+
readonly visible: boolean
|
|
20
|
+
getTitlebarAreaRect(): { readonly x: number; readonly y: number; readonly width: number; readonly height: number }
|
|
21
|
+
addEventListener(type: 'geometrychange', listener: () => void): void
|
|
22
|
+
removeEventListener(type: 'geometrychange', listener: () => void): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The current overlay geometry (present=false = API unavailable). */
|
|
26
|
+
export interface WcoSnapshot {
|
|
27
|
+
readonly present: boolean
|
|
28
|
+
readonly height: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Snapshot when the API is unavailable (plain browser / non-overlay shell). */
|
|
32
|
+
export const WCO_NONE: WcoSnapshot = Object.freeze({ present: false, height: 0 })
|
|
33
|
+
|
|
34
|
+
type Listener = () => void
|
|
35
|
+
|
|
36
|
+
let source: WcoSource | undefined
|
|
37
|
+
let snapshot: WcoSnapshot = WCO_NONE
|
|
38
|
+
let attached = false
|
|
39
|
+
let sourceListener: (() => void) | undefined
|
|
40
|
+
const listeners = new Set<Listener>()
|
|
41
|
+
|
|
42
|
+
function read(): WcoSnapshot {
|
|
43
|
+
if (source === undefined) return WCO_NONE
|
|
44
|
+
try {
|
|
45
|
+
// A present-but-NOT-visible API is a PHANTOM: headless Chromium and
|
|
46
|
+
// macOS builds expose the interface but draw no overlay (empty rect,
|
|
47
|
+
// visible=false). Only a genuinely visible overlay is authoritative —
|
|
48
|
+
// otherwise the resolution chain would trust a zero rect and silently
|
|
49
|
+
// skip the shell's real reserved space.
|
|
50
|
+
if (source.visible !== true) return { present: false, height: 0 }
|
|
51
|
+
const rect = source.getTitlebarAreaRect()
|
|
52
|
+
const height = Math.round(rect.height)
|
|
53
|
+
return Number.isFinite(height) && height > 0
|
|
54
|
+
? { present: true, height }
|
|
55
|
+
: { present: true, height: 0 }
|
|
56
|
+
} catch {
|
|
57
|
+
// A hostile/racy API must never break the layout — treat as absent.
|
|
58
|
+
return { present: false, height: 0 }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function onGeometryChange(): void {
|
|
63
|
+
snapshot = read()
|
|
64
|
+
emit()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function emit(): void {
|
|
68
|
+
for (const listener of listeners) listener()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Attach the native geometrychange listener (once). */
|
|
72
|
+
function attach(): void {
|
|
73
|
+
if (attached) return
|
|
74
|
+
attached = true
|
|
75
|
+
// The test hook may have installed a source already; otherwise attach to
|
|
76
|
+
// the real navigator API (absent in plain browsers → stay NONE).
|
|
77
|
+
const candidate = source ?? (navigator as unknown as { windowControlsOverlay?: WcoSource }).windowControlsOverlay
|
|
78
|
+
if (candidate === undefined) return
|
|
79
|
+
source = candidate
|
|
80
|
+
sourceListener = onGeometryChange
|
|
81
|
+
snapshot = read()
|
|
82
|
+
source.addEventListener('geometrychange', sourceListener)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Detach the native listener (last subscriber left or source swapped). */
|
|
86
|
+
function detach(): void {
|
|
87
|
+
if (source !== undefined && sourceListener !== undefined) {
|
|
88
|
+
source.removeEventListener('geometrychange', sourceListener)
|
|
89
|
+
}
|
|
90
|
+
sourceListener = undefined
|
|
91
|
+
attached = false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Read the current snapshot (returns the frozen NONE when unavailable). */
|
|
95
|
+
export function getWcoSnapshot(): WcoSnapshot {
|
|
96
|
+
return snapshot
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Subscribe to overlay geometry changes. Attaches to the real
|
|
101
|
+
* `navigator.windowControlsOverlay` on first subscribe; the disposer
|
|
102
|
+
* detaches the native listener when the last subscriber leaves.
|
|
103
|
+
*/
|
|
104
|
+
export function subscribeWco(onChange: Listener): () => void {
|
|
105
|
+
listeners.add(onChange)
|
|
106
|
+
attach()
|
|
107
|
+
return () => {
|
|
108
|
+
listeners.delete(onChange)
|
|
109
|
+
if (listeners.size === 0) detach()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Test hook: swap the geometry source (undefined = API unavailable). */
|
|
114
|
+
export function setWcoSourceForTests(next: WcoSource | undefined): void {
|
|
115
|
+
detach()
|
|
116
|
+
source = next
|
|
117
|
+
snapshot = next === undefined ? WCO_NONE : read()
|
|
118
|
+
if (next !== undefined && listeners.size > 0) attach()
|
|
119
|
+
emit()
|
|
120
|
+
}
|