@keith9681/dsh-sidebar 0.15.2
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 +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -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 +22 -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 +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -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 +43 -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 +23 -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 +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -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 +124 -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/editable-state.d.ts +27 -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 +57 -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 +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -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.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -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 +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -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 +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -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 +30 -0
- package/lib/types/client/state.d.ts +359 -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 +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -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 +603 -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 +82 -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/prefs-shared.d.ts +225 -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 +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -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 +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -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 +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -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/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -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 +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -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 +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -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 +479 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +100 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy chunk loader for the client bundle. The heavy preview/terminal
|
|
3
|
+
* libraries (CodeMirror, xterm — the editor/terminal stacks, several MB)
|
|
4
|
+
* live in separate build-time bundles (`lib/client-<name>.js`) fetched only
|
|
5
|
+
* on first use of the feature that needs them, so startup downloads/parses
|
|
6
|
+
* only the ~1MB core bundle. (The office stack — Univer / docx-preview /
|
|
7
|
+
* pptx-renderer — is no longer bundled here: Office previews moved to the
|
|
8
|
+
* recommended office plugin, see plugins-viewers.ts.)
|
|
9
|
+
*
|
|
10
|
+
* How a chunk script works (see tsdown.config.ts chunkBundle):
|
|
11
|
+
*
|
|
12
|
+
* globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
|
|
13
|
+
* globalThis.__dshChunks__["terminal"] = (require) => { ...exports };
|
|
14
|
+
*
|
|
15
|
+
* The script registers its factory on a plugin-owned global registry (NOT
|
|
16
|
+
* through window.__ModuleLoader__.load — the module loader's import() only
|
|
17
|
+
* resolves seed words, shell-own modules, registered factories, and boot
|
|
18
|
+
* graph rows; a chunk id is none of those, so resolution would be version-
|
|
19
|
+
* dependent). Materialization is plugin-owned:
|
|
20
|
+
*
|
|
21
|
+
* 1. inject <script src="/sidebar/bundle/<name>.js"> (classic same-origin
|
|
22
|
+
* script; the official /plugins/<id>/client.js route cannot serve
|
|
23
|
+
* arbitrary file names, so the plugin's own host route serves the chunks),
|
|
24
|
+
* 2. read the factory from the global registry,
|
|
25
|
+
* 3. call it with a require that resolves the platform externals through
|
|
26
|
+
* `__DSH_MODULES__.import(spec)` — the seed-word branch, the one part of
|
|
27
|
+
* the module system that is stable across versions.
|
|
28
|
+
*
|
|
29
|
+
* Caching contract (three layers, each with a failure path):
|
|
30
|
+
* - In-memory: one in-flight promise per chunk, memoized until
|
|
31
|
+
* {@link resetChunks}; a failed load removes its entry so the next call
|
|
32
|
+
* retries from scratch. HMR re-activation keeps the resolved exports of
|
|
33
|
+
* unchanged chunks (ETag revalidation via
|
|
34
|
+
* {@link revalidateChunksOnReactivate}) — the next lazy open skips the
|
|
35
|
+
* re-inject / re-execute.
|
|
36
|
+
* - Script execution: each re-execution overwrites the global registry slot
|
|
37
|
+
* (assignment, never registration) — no "duplicate factory registration"
|
|
38
|
+
* class of errors; a failed materialization clears the cache so the retry
|
|
39
|
+
* re-injects and re-executes.
|
|
40
|
+
* - HTTP: the bundle route revalidates every request (`cache-control:
|
|
41
|
+
* no-cache` + ETag, 304 when unchanged), so page refreshes and HMR
|
|
42
|
+
* re-activations never re-download a multi-MB chunk that did not change.
|
|
43
|
+
*
|
|
44
|
+
* HMR: each plugin activation calls {@link revalidateChunksOnReactivate},
|
|
45
|
+
* which HEADs every loaded chunk against the bundle route and keeps the
|
|
46
|
+
* in-memory cache for the ones whose ETag is unchanged (a hot-reloaded core
|
|
47
|
+
* bundle therefore does not re-execute multi-MB chunk scripts). Chunk-only
|
|
48
|
+
* source edits still need a manual page refresh (the HMR poll watches only
|
|
49
|
+
* client.js); an edit that does land while a core HMR happens is caught by
|
|
50
|
+
* the ETag comparison on the next activation.
|
|
51
|
+
*/
|
|
52
|
+
export type ChunkName = 'terminal' | 'editor' | 'mermaid'
|
|
53
|
+
|
|
54
|
+
/** The module exports a chunk factory provides (namespace-ish record). */
|
|
55
|
+
export type ChunkExports = Record<string, unknown>
|
|
56
|
+
|
|
57
|
+
/** A chunk factory: (require) => exports (the chunk's CJS closure shape). */
|
|
58
|
+
type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The platform externals a chunk bundle may require (mirror of
|
|
62
|
+
* CLIENT_EXTERNALS in tsdown.config.ts — the chunk builds keep these
|
|
63
|
+
* external and the loader resolves them here). A superset is safe: the
|
|
64
|
+
* require only answers what the chunk actually asks for. The shell's static
|
|
65
|
+
* module table seeds React, Cordis, and the UI libraries (primitives/slots);
|
|
66
|
+
* `dsh-client-runtime/client` normalizes onto the runtime package row
|
|
67
|
+
* (stripClientSuffix). dsh-client-web-react / dsh-client-schema-form were
|
|
68
|
+
* dropped in DSH 0.1.0-rc.8 (no rc.8 publish, nothing requires them) — the
|
|
69
|
+
* chunks never asked for them, so they no longer belong here.
|
|
70
|
+
*/
|
|
71
|
+
export const CHUNK_EXTERNALS: readonly string[] = [
|
|
72
|
+
'react',
|
|
73
|
+
'react/jsx-runtime',
|
|
74
|
+
'react-dom',
|
|
75
|
+
'react-dom/client',
|
|
76
|
+
'cordis',
|
|
77
|
+
'@deepseek-ai/dsh-client-ui-slots',
|
|
78
|
+
'@deepseek-ai/dsh-client-ui-primitives',
|
|
79
|
+
'@deepseek-ai/dsh-client-runtime/client',
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
/** Chunk script endpoint served by the plugin host half (src/bundle-route.ts). */
|
|
83
|
+
const CHUNK_URL = (name: ChunkName): string => `/sidebar/bundle/${name}.js`
|
|
84
|
+
|
|
85
|
+
/** Bound on the revalidation HEAD round-trip. A timeout fails open (drop +
|
|
86
|
+
* re-fetch on the next open) so a stuck bundle route can never wedge lazy
|
|
87
|
+
* chunk loads behind the revalidation barrier. */
|
|
88
|
+
const CHUNK_REVALIDATE_TIMEOUT_MS = 5_000
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The client module system surface this loader needs to resolve externals.
|
|
92
|
+
* DSH 0.1.0-rc.8 provides it as the `ctx.modules` service (no page global
|
|
93
|
+
* anymore); the plugin injects it at activation via
|
|
94
|
+
* {@link setChunkModuleSystem}. The rc.7-era `window.__DSH_MODULES__` global
|
|
95
|
+
* remains as a fallback so older hosts and the test harness keep working.
|
|
96
|
+
*/
|
|
97
|
+
export interface ChunkModuleSystem {
|
|
98
|
+
import(specifier: string): Promise<unknown>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The module system injected by the client half at activation (rc.8+). */
|
|
102
|
+
let injectedModuleSystem: ChunkModuleSystem | undefined
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Plugin-owned page global carrying the injected module system across
|
|
106
|
+
* bundle copies: the lazy chunk bundles (client-editor.js etc.) inline their
|
|
107
|
+
* own chunk-loader instance, and rc.8 no longer exposes the shell module
|
|
108
|
+
* system as a page global — so the core bundle's injection must be visible
|
|
109
|
+
* to the chunk copies through a namespace of our own.
|
|
110
|
+
*/
|
|
111
|
+
const MODULE_SYSTEM_GLOBAL = '__dshSidebarModuleSystem__'
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Inject the client module system the chunk externals resolve through.
|
|
115
|
+
* Called by the client half's apply() with `ctx.modules` (rc.8+); pass
|
|
116
|
+
* undefined to clear (tests). Survives {@link resetChunks} — the module
|
|
117
|
+
* system is shell state, not chunk state, and stays live across HMR.
|
|
118
|
+
*/
|
|
119
|
+
export function setChunkModuleSystem(system: ChunkModuleSystem | undefined): void {
|
|
120
|
+
injectedModuleSystem = system
|
|
121
|
+
const g = globalThis as Record<string, unknown>
|
|
122
|
+
if (system === undefined) delete g[MODULE_SYSTEM_GLOBAL]
|
|
123
|
+
else g[MODULE_SYSTEM_GLOBAL] = system
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolve the shell-installed module system (injected, then the plugin
|
|
127
|
+
* global shared with chunk-bundle copies, then the rc.7 page global). */
|
|
128
|
+
function moduleSystem(): ChunkModuleSystem | undefined {
|
|
129
|
+
const g = globalThis as Record<string, unknown>
|
|
130
|
+
return injectedModuleSystem
|
|
131
|
+
?? g[MODULE_SYSTEM_GLOBAL] as ChunkModuleSystem | undefined
|
|
132
|
+
?? (g as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The plugin-owned chunk factory registry the chunk scripts populate. */
|
|
136
|
+
interface ChunkRegistry {
|
|
137
|
+
[name: string]: ChunkFactory | undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function chunkRegistry(): ChunkRegistry {
|
|
141
|
+
const g = globalThis as { __dshChunks__?: ChunkRegistry }
|
|
142
|
+
return g.__dshChunks__ ??= {}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Script-load hook; tests replace it with a stub (the default needs a real DOM + network). */
|
|
146
|
+
export type ChunkScriptLoader = (src: string) => Promise<void>
|
|
147
|
+
|
|
148
|
+
const defaultScriptLoader: ChunkScriptLoader = (src) => new Promise((resolve, reject) => {
|
|
149
|
+
const el = document.createElement('script')
|
|
150
|
+
el.async = true
|
|
151
|
+
el.src = src
|
|
152
|
+
el.addEventListener('load', () => {
|
|
153
|
+
el.remove()
|
|
154
|
+
resolve()
|
|
155
|
+
}, { once: true })
|
|
156
|
+
el.addEventListener('error', () => {
|
|
157
|
+
el.remove()
|
|
158
|
+
reject(new Error(`[dsh-better-sidebar] chunk script ${src} failed to load`))
|
|
159
|
+
}, { once: true })
|
|
160
|
+
document.head.append(el)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
let scriptLoader: ChunkScriptLoader = defaultScriptLoader
|
|
164
|
+
|
|
165
|
+
/** Test hook: replace the chunk-script loader (pass null to restore the default). */
|
|
166
|
+
export function setChunkScriptLoaderForTests(loader: ChunkScriptLoader | null): void {
|
|
167
|
+
scriptLoader = loader ?? defaultScriptLoader
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Test/dev hook: resolve a chunk without fetching a script (e.g. vitest). */
|
|
171
|
+
const testLoaders = new Map<ChunkName, () => Promise<ChunkExports>>()
|
|
172
|
+
export function registerChunkForTests(name: ChunkName, loader: () => Promise<ChunkExports>): void {
|
|
173
|
+
testLoaders.set(name, loader)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Memoized externals require, resolved once per page from the seed table. */
|
|
177
|
+
let externalsRequire: ((spec: string) => unknown) | undefined
|
|
178
|
+
|
|
179
|
+
async function buildExternalsRequire(modules: ChunkModuleSystem): Promise<(spec: string) => unknown> {
|
|
180
|
+
if (externalsRequire !== undefined) return externalsRequire
|
|
181
|
+
// Per-spec tolerance: a spec the running DSH version cannot resolve (e.g.
|
|
182
|
+
// the runtime/client exemption row) stays unresolved until a chunk
|
|
183
|
+
// actually requires it — only then it is a loud error.
|
|
184
|
+
const entries = await Promise.all(CHUNK_EXTERNALS.map(async (spec) => {
|
|
185
|
+
try {
|
|
186
|
+
return [spec, await modules.import(spec)] as const
|
|
187
|
+
} catch {
|
|
188
|
+
return [spec, undefined] as const
|
|
189
|
+
}
|
|
190
|
+
}))
|
|
191
|
+
const table = new Map<string, unknown>(entries)
|
|
192
|
+
externalsRequire = (spec: string): unknown => {
|
|
193
|
+
if (!table.has(spec)) {
|
|
194
|
+
// Single quotes: the bundle-consistency scan regexes for require("...")
|
|
195
|
+
// lexical calls — a double-quoted literal here would trip it.
|
|
196
|
+
throw new Error(`[dsh-better-sidebar] chunk require('${spec}') missed the module table`)
|
|
197
|
+
}
|
|
198
|
+
return table.get(spec)
|
|
199
|
+
}
|
|
200
|
+
return externalsRequire
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** In-flight/memoized chunk loads; a failure removes its entry so a retry re-fetches. */
|
|
204
|
+
const cache = new Map<ChunkName, Promise<ChunkExports>>()
|
|
205
|
+
|
|
206
|
+
/** Chunk names whose exports are currently cached (loaded successfully). */
|
|
207
|
+
const loadedChunks = new Set<ChunkName>()
|
|
208
|
+
|
|
209
|
+
/** ETags observed for loaded chunks (HEAD revalidation, see
|
|
210
|
+
* {@link revalidateChunksOnReactivate}). */
|
|
211
|
+
const chunkEtags = new Map<ChunkName, string>()
|
|
212
|
+
|
|
213
|
+
/** Pending revalidation barrier: while set, {@link loadChunk} awaits it
|
|
214
|
+
* before serving cache (see revalidateChunksOnReactivate). */
|
|
215
|
+
let revalidation: Promise<void> | null = null
|
|
216
|
+
|
|
217
|
+
/** Best-effort ETag capture for revalidation. The script tag itself exposes
|
|
218
|
+
* no response headers, so after a successful load we HEAD the bundle route
|
|
219
|
+
* once. Failures (including a stuck route — bounded by the timeout) are
|
|
220
|
+
* ignored — revalidation then fails open (re-fetch). */
|
|
221
|
+
async function recordEtag(name: ChunkName): Promise<void> {
|
|
222
|
+
try {
|
|
223
|
+
const res = await fetch(CHUNK_URL(name), {
|
|
224
|
+
method: 'HEAD',
|
|
225
|
+
cache: 'no-cache',
|
|
226
|
+
signal: AbortSignal.timeout(CHUNK_REVALIDATE_TIMEOUT_MS),
|
|
227
|
+
})
|
|
228
|
+
const etag = res.headers.get('etag')
|
|
229
|
+
if (etag !== null && etag !== '') chunkEtags.set(name, etag)
|
|
230
|
+
} catch {
|
|
231
|
+
chunkEtags.delete(name)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Load (once) and materialize a lazy chunk, returning its module exports.
|
|
237
|
+
* Concurrent callers share one in-flight load; a failure clears the cache
|
|
238
|
+
* entry so the next call retries (the script re-executes and overwrites its
|
|
239
|
+
* global registry slot — assignments are idempotent).
|
|
240
|
+
* @param name - the chunk to load.
|
|
241
|
+
*/
|
|
242
|
+
export async function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
|
243
|
+
// Barrier: never serve a cache entry that a pending revalidation is about
|
|
244
|
+
// to inspect — a stale chunk could otherwise render mid-HMR (CR #232 P1).
|
|
245
|
+
if (revalidation !== null) await revalidation
|
|
246
|
+
const cached = cache.get(name)
|
|
247
|
+
if (cached !== undefined) return cached
|
|
248
|
+
let task: Promise<ChunkExports>
|
|
249
|
+
task = (async (): Promise<ChunkExports> => {
|
|
250
|
+
const test = testLoaders.get(name)
|
|
251
|
+
if (test !== undefined) return test()
|
|
252
|
+
const modules = moduleSystem()
|
|
253
|
+
if (modules === undefined) {
|
|
254
|
+
throw new Error(`[dsh-better-sidebar] chunk "${name}": client module system unavailable`)
|
|
255
|
+
}
|
|
256
|
+
await scriptLoader(CHUNK_URL(name))
|
|
257
|
+
const factory = chunkRegistry()[name]
|
|
258
|
+
if (typeof factory !== 'function') {
|
|
259
|
+
throw new Error(`[dsh-better-sidebar] chunk "${name}" script did not register its factory`)
|
|
260
|
+
}
|
|
261
|
+
const require = await buildExternalsRequire(modules)
|
|
262
|
+
const exports = factory(require)
|
|
263
|
+
// Only track production loads whose cache entry survived (a revalidation
|
|
264
|
+
// sweep may have dropped it mid-flight; the caller still gets these
|
|
265
|
+
// exports, they just are not memoized for the next open). Test-registry
|
|
266
|
+
// loads return above and never reach this tracking.
|
|
267
|
+
if (cache.get(name) !== undefined) {
|
|
268
|
+
loadedChunks.add(name)
|
|
269
|
+
void recordEtag(name)
|
|
270
|
+
}
|
|
271
|
+
return exports
|
|
272
|
+
})()
|
|
273
|
+
cache.set(name, task)
|
|
274
|
+
void task.catch(() => {
|
|
275
|
+
cache.delete(name)
|
|
276
|
+
loadedChunks.delete(name)
|
|
277
|
+
chunkEtags.delete(name)
|
|
278
|
+
})
|
|
279
|
+
return task
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Drop all chunk state for a fresh plugin activation (HMR-safe): clear the
|
|
284
|
+
* in-memory cache and any test-registry entries, so the next lazy open
|
|
285
|
+
* re-fetches and re-executes the current chunk scripts (the registry slots
|
|
286
|
+
* are overwritten by the re-execution — no cleanup needed). A pending
|
|
287
|
+
* revalidation barrier is cleared too: it was only guarding the cache reads
|
|
288
|
+
* of the state being dropped, so the next load must not wait on it (the
|
|
289
|
+
* orphaned task still settles and its identity-guarded `finally` no-ops).
|
|
290
|
+
*/
|
|
291
|
+
export function resetChunks(): void {
|
|
292
|
+
cache.clear()
|
|
293
|
+
loadedChunks.clear()
|
|
294
|
+
chunkEtags.clear()
|
|
295
|
+
testLoaders.clear()
|
|
296
|
+
externalsRequire = undefined
|
|
297
|
+
revalidation = null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* HMR-safe re-activation hook (index.tsx calls this instead of a full
|
|
302
|
+
* reset): keep the resolved exports of every loaded chunk and drop only the
|
|
303
|
+
* ones whose script changed on disk — the bundle route revalidates every
|
|
304
|
+
* request (cache-control: no-cache + ETag), so an unchanged chunk keeps its
|
|
305
|
+
* memory cache and the next lazy open skips the re-inject / re-execute.
|
|
306
|
+
* Fail-open: an unreachable, ETag-less, or timed-out chunk is dropped
|
|
307
|
+
* (re-fetch on next open). Test-registry entries are always cleared
|
|
308
|
+
* (per-test fixtures).
|
|
309
|
+
* A page refresh remains the authoritative reset (the HMR poll watches only
|
|
310
|
+
* client.js; chunk-only edits surface here on the next core re-activation).
|
|
311
|
+
*
|
|
312
|
+
* The returned promise is also a BARRIER for {@link loadChunk}: while a
|
|
313
|
+
* revalidation is pending, every chunk load awaits it before serving cache,
|
|
314
|
+
* so a lazy tab opening mid-revalidation can never render stale exports
|
|
315
|
+
* that the sweep is about to invalidate (CR #232 P1).
|
|
316
|
+
*/
|
|
317
|
+
export function revalidateChunksOnReactivate(): Promise<void> {
|
|
318
|
+
testLoaders.clear()
|
|
319
|
+
const task = (async (): Promise<void> => {
|
|
320
|
+
// Entries not tracked as production-loaded (test fixtures, orphans) never
|
|
321
|
+
// survive a re-activation — their resolved exports came from per-test
|
|
322
|
+
// stubs, not from the bundle route.
|
|
323
|
+
for (const name of [...cache.keys()]) {
|
|
324
|
+
if (!loadedChunks.has(name)) cache.delete(name)
|
|
325
|
+
}
|
|
326
|
+
if (loadedChunks.size === 0) return
|
|
327
|
+
const stale: ChunkName[] = []
|
|
328
|
+
await Promise.all([...loadedChunks].map(async (name) => {
|
|
329
|
+
try {
|
|
330
|
+
const res = await fetch(CHUNK_URL(name), {
|
|
331
|
+
method: 'HEAD',
|
|
332
|
+
cache: 'no-cache',
|
|
333
|
+
signal: AbortSignal.timeout(CHUNK_REVALIDATE_TIMEOUT_MS),
|
|
334
|
+
})
|
|
335
|
+
const etag = res.headers.get('etag')
|
|
336
|
+
if (etag !== null && etag !== '' && chunkEtags.get(name) === etag) return
|
|
337
|
+
} catch {
|
|
338
|
+
// Fail open below (network errors and timeouts alike).
|
|
339
|
+
}
|
|
340
|
+
stale.push(name)
|
|
341
|
+
}))
|
|
342
|
+
for (const name of stale) {
|
|
343
|
+
cache.delete(name)
|
|
344
|
+
loadedChunks.delete(name)
|
|
345
|
+
chunkEtags.delete(name)
|
|
346
|
+
}
|
|
347
|
+
})()
|
|
348
|
+
revalidation = task
|
|
349
|
+
void task.finally(() => { if (revalidation === task) revalidation = null })
|
|
350
|
+
return task
|
|
351
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy chunk entry: the code/markdown/html text editor (CodeMirror 6 + the
|
|
3
|
+
* language packages). Built as `lib/client-editor.js` and registered under
|
|
4
|
+
* `dsh-better-sidebar/editor` — fetched only when a text file is first
|
|
5
|
+
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
|
|
6
|
+
* Never import this module from the core bundle: it pulls CodeMirror into
|
|
7
|
+
* the startup path.
|
|
8
|
+
*/
|
|
9
|
+
export { TextEditor } from '../TextEditor.tsx'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy chunk entry: the mermaid diagram renderer for the markdown preview
|
|
3
|
+
* (mermaid + its d3/dagre/cytoscape graph deps). Built as
|
|
4
|
+
* lib/client-mermaid.js and registered under the `mermaid` global chunk
|
|
5
|
+
* slot — fetched only when a previewed markdown file contains a mermaid
|
|
6
|
+
* fence (see chunk-loader.ts and the mermaid markdown preview design doc).
|
|
7
|
+
* Never import this module from the core bundle: it pulls mermaid into the
|
|
8
|
+
* startup path.
|
|
9
|
+
*/
|
|
10
|
+
export { MermaidMarkdown } from '../mermaid.tsx'
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy chunk entry: the interactive terminal (xterm + fit addon). Built as
|
|
3
|
+
* `lib/client-terminal.js` and registered under
|
|
4
|
+
* `dsh-better-sidebar/terminal` — fetched only when a terminal tab is first
|
|
5
|
+
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
|
|
6
|
+
* Never import this module from the core bundle: it pulls xterm (and its
|
|
7
|
+
* stylesheet) into the startup path.
|
|
8
|
+
*/
|
|
9
|
+
export { TerminalView } from '../TerminalView.tsx'
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeMirror 6 theme pieces for the sidebar editor. The editor surface
|
|
3
|
+
* (background, caret, gutter) rides the DSH theme tokens so it blends with
|
|
4
|
+
* the panel in both schemes; only the syntax token colors need concrete
|
|
5
|
+
* values, and those come from the same designed palettes the app's code
|
|
6
|
+
* surfaces use — the one-dark family for dark, the one-light family for
|
|
7
|
+
* light. The scheme flip reconfigures these via a compartment (see
|
|
8
|
+
* TextEditor), so the document, undo history and scroll survive re-theming.
|
|
9
|
+
*/
|
|
10
|
+
import { Compartment } from '@codemirror/state'
|
|
11
|
+
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
|
12
|
+
import { tags, type Tag } from '@lezer/highlight'
|
|
13
|
+
import { EditorView } from '@codemirror/view'
|
|
14
|
+
|
|
15
|
+
/** Token-driven surface shared by both schemes (pure CSS values). */
|
|
16
|
+
export const cmSurfaceTheme = EditorView.theme({
|
|
17
|
+
'&': {
|
|
18
|
+
height: '100%',
|
|
19
|
+
fontSize: '13px',
|
|
20
|
+
backgroundColor: 'transparent',
|
|
21
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
22
|
+
},
|
|
23
|
+
'.cm-scroller': {
|
|
24
|
+
overflow: 'auto',
|
|
25
|
+
fontFamily: 'var(--ds-font-family-code)',
|
|
26
|
+
},
|
|
27
|
+
'.cm-content': {
|
|
28
|
+
caretColor: 'var(--dsw-alias-label-primary)',
|
|
29
|
+
},
|
|
30
|
+
'.cm-gutters': {
|
|
31
|
+
backgroundColor: 'transparent',
|
|
32
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
33
|
+
border: 'none',
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** Scheme-specific surface tints (selection, active line). */
|
|
38
|
+
function cmSurfaceTint(dark: boolean): ReturnType<typeof EditorView.theme> {
|
|
39
|
+
return EditorView.theme({
|
|
40
|
+
'.cm-selectionBackground, .cm-focused .cm-selectionBackground, ::selection': {
|
|
41
|
+
backgroundColor: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
|
|
42
|
+
},
|
|
43
|
+
'.cm-activeLine, .cm-activeLineGutter': {
|
|
44
|
+
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)',
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One syntax rule: a tag (or tag set) mapped to a concrete color/style. */
|
|
50
|
+
interface HighlightRule {
|
|
51
|
+
tag: Tag | readonly Tag[]
|
|
52
|
+
color?: string
|
|
53
|
+
fontStyle?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** one-dark syntax palette (mirrors @codemirror/theme-one-dark). */
|
|
57
|
+
const HIGHLIGHTS_DARK: HighlightRule[] = [
|
|
58
|
+
{ tag: tags.comment, color: '#5c6370', fontStyle: 'italic' },
|
|
59
|
+
{ tag: tags.keyword, color: '#c678dd' },
|
|
60
|
+
{ tag: tags.string, color: '#98c379' },
|
|
61
|
+
{ tag: tags.number, color: '#d19a66' },
|
|
62
|
+
{ tag: tags.bool, color: '#d19a66' },
|
|
63
|
+
{ tag: tags.atom, color: '#d19a66' },
|
|
64
|
+
{ tag: tags.typeName, color: '#e5c07b' },
|
|
65
|
+
{ tag: tags.className, color: '#e5c07b' },
|
|
66
|
+
{ tag: tags.propertyName, color: '#e06c75' },
|
|
67
|
+
{ tag: tags.function(tags.variableName), color: '#61afef' },
|
|
68
|
+
{ tag: tags.variableName, color: '#e06c75' },
|
|
69
|
+
{ tag: tags.operator, color: '#56b6c2' },
|
|
70
|
+
{ tag: tags.tagName, color: '#e06c75' },
|
|
71
|
+
{ tag: tags.attributeName, color: '#d19a66' },
|
|
72
|
+
{ tag: tags.heading, color: '#e06c75', fontStyle: 'bold' },
|
|
73
|
+
{ tag: tags.emphasis, fontStyle: 'italic' },
|
|
74
|
+
{ tag: tags.strong, fontStyle: 'bold' },
|
|
75
|
+
{ tag: tags.link, color: '#61afef', fontStyle: 'underline' },
|
|
76
|
+
{ tag: tags.meta, color: '#e5c07b' },
|
|
77
|
+
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
/** one-light syntax palette (the light counterpart of one-dark). */
|
|
81
|
+
const HIGHLIGHTS_LIGHT: HighlightRule[] = [
|
|
82
|
+
{ tag: tags.comment, color: '#a0a1a7', fontStyle: 'italic' },
|
|
83
|
+
{ tag: tags.keyword, color: '#a626a4' },
|
|
84
|
+
{ tag: tags.string, color: '#50a14f' },
|
|
85
|
+
{ tag: tags.number, color: '#986801' },
|
|
86
|
+
{ tag: tags.bool, color: '#0184bc' },
|
|
87
|
+
{ tag: tags.atom, color: '#0184bc' },
|
|
88
|
+
{ tag: tags.typeName, color: '#c18401' },
|
|
89
|
+
{ tag: tags.className, color: '#c18401' },
|
|
90
|
+
{ tag: tags.propertyName, color: '#e45649' },
|
|
91
|
+
{ tag: tags.function(tags.variableName), color: '#c18401' },
|
|
92
|
+
{ tag: tags.variableName, color: '#e45649' },
|
|
93
|
+
{ tag: tags.operator, color: '#383a42' },
|
|
94
|
+
{ tag: tags.tagName, color: '#e45649' },
|
|
95
|
+
{ tag: tags.attributeName, color: '#986801' },
|
|
96
|
+
{ tag: tags.heading, color: '#e45649', fontStyle: 'bold' },
|
|
97
|
+
{ tag: tags.emphasis, fontStyle: 'italic' },
|
|
98
|
+
{ tag: tags.strong, fontStyle: 'bold' },
|
|
99
|
+
{ tag: tags.link, color: '#4078f2', fontStyle: 'underline' },
|
|
100
|
+
{ tag: tags.meta, color: '#c18401' },
|
|
101
|
+
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
/** The scheme-dependent extension pair (surface tint + syntax highlight). */
|
|
105
|
+
function cmThemeExtensions(dark: boolean): Array<ReturnType<typeof EditorView.theme>> {
|
|
106
|
+
return [
|
|
107
|
+
cmSurfaceTint(dark),
|
|
108
|
+
syntaxHighlighting(HighlightStyle.define(dark ? HIGHLIGHTS_DARK : HIGHLIGHTS_LIGHT)),
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A Compartment holding the two scheme-dependent extensions. Created once
|
|
114
|
+
* per editor view; a scheme flip dispatches `reconfigure(dark)` on it, so
|
|
115
|
+
* the document, undo history, scroll and keymaps survive re-theming.
|
|
116
|
+
*/
|
|
117
|
+
export class CmThemeCompartment {
|
|
118
|
+
private readonly compartment = new Compartment()
|
|
119
|
+
|
|
120
|
+
/** `of(...)` payload for EditorState.create. */
|
|
121
|
+
of(dark: boolean): ReturnType<Compartment['of']> {
|
|
122
|
+
return this.compartment.of(cmThemeExtensions(dark))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Reconfigure for a new scheme. */
|
|
126
|
+
reconfigure(dark: boolean): ReturnType<Compartment['reconfigure']> {
|
|
127
|
+
return this.compartment.reconfigure(cmThemeExtensions(dark))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append text to the current session's composer draft through the
|
|
3
|
+
* conversation service — the shared path behind the explorer's @-reference
|
|
4
|
+
* button and the viewer selection popup. The service is resolved lazily
|
|
5
|
+
* through `ctx.get` (the inject-free read the app's own plugins use); a
|
|
6
|
+
* missing service or scope degrades to a logged no-op, never a crash.
|
|
7
|
+
*/
|
|
8
|
+
import type { Context, SidebarConversation } from '../context-types.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Append `text` to the session's composer draft (space-separated, like the
|
|
12
|
+
* @-mentions). Returns false — and logs — when the conversation service or
|
|
13
|
+
* the session scope is unavailable.
|
|
14
|
+
*/
|
|
15
|
+
export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
|
|
16
|
+
try {
|
|
17
|
+
const actx = ctx.sessions.scope(sessionId)
|
|
18
|
+
if (actx === undefined) return false
|
|
19
|
+
const conversation = ctx.get('conversation') as SidebarConversation | undefined
|
|
20
|
+
if (conversation === undefined) return false
|
|
21
|
+
const input = conversation.input.for(actx)
|
|
22
|
+
const draft = input.state.getSnapshot().draft
|
|
23
|
+
input.setDraft(draft.trim() === '' ? text : `${draft} ${text}`)
|
|
24
|
+
return true
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.warn('[dsh-better-sidebar] draft insert failed:', error)
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop-shell detection for the sidebar. Shells may stamp the render URL
|
|
3
|
+
* with `dsh-desktop-mode` / `dsh-desktop-platform` (the official Electron
|
|
4
|
+
* shell does) and expose `window.__DSH_DESKTOP_FILE_PATH__` through a
|
|
5
|
+
* preload. Parsed once per page and memoized (the URL never changes
|
|
6
|
+
* mid-session); `resetDesktopEnvForTests` clears the memo for unit tests.
|
|
7
|
+
*
|
|
8
|
+
* GEOMETRY POLICY: this module only REPORTS shell facts — it never decides
|
|
9
|
+
* how to adapt. The strip height comes from standard signals first (the
|
|
10
|
+
* Window Controls Overlay API, see wco.ts), then the documented contract
|
|
11
|
+
* parameter `dsh-desktop-titlebar-inset` (a shell may stamp the real pixels
|
|
12
|
+
* it reserves at the top), then the user's chosen scheme (preset / custom).
|
|
13
|
+
* The legacy win32-advanced 32px constant is gone from the core: it lives
|
|
14
|
+
* in the opt-in shell preset (shell-presets.ts) as a fallback for shells
|
|
15
|
+
* without the WCO API.
|
|
16
|
+
*/
|
|
17
|
+
export interface DesktopEnv {
|
|
18
|
+
/** Running inside a desktop shell (any URL stamp or preload marker). */
|
|
19
|
+
readonly desktop: boolean
|
|
20
|
+
/** `advanced` = frameless/custom-titlebar shell; `compatibility` = native frame. */
|
|
21
|
+
readonly mode: 'compatibility' | 'advanced' | null
|
|
22
|
+
/** Shell platform stamp ('darwin' | 'win32' | …), lowercased, or null. */
|
|
23
|
+
readonly platform: string | null
|
|
24
|
+
/**
|
|
25
|
+
* Contract parameter `dsh-desktop-titlebar-inset`: pixels the shell
|
|
26
|
+
* reserves at the top of the web content for its own chrome (0–120,
|
|
27
|
+
* clamped; 0 when absent). Standard WCO geometry takes precedence over
|
|
28
|
+
* this whenever the API is available.
|
|
29
|
+
*/
|
|
30
|
+
readonly titlebarInset: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let cached: DesktopEnv | undefined
|
|
34
|
+
|
|
35
|
+
/** Read the shell's desktop stamps (memoized per page; SSR-safe). */
|
|
36
|
+
export function parseDesktopEnv(): DesktopEnv {
|
|
37
|
+
if (cached !== undefined) return cached
|
|
38
|
+
const hasWindow = typeof window !== 'undefined'
|
|
39
|
+
const hasPreloadMarker = hasWindow
|
|
40
|
+
&& typeof (window as { __DSH_DESKTOP_FILE_PATH__?: unknown }).__DSH_DESKTOP_FILE_PATH__ !== 'undefined'
|
|
41
|
+
// location.search includes the leading '?', which URLSearchParams does NOT
|
|
42
|
+
// strip (it would become part of the first key) — drop it explicitly.
|
|
43
|
+
// SSR (no window) resolves the empty environment: nothing desktop.
|
|
44
|
+
const params = hasWindow
|
|
45
|
+
? new URLSearchParams(window.location.search.replace(/^\?/, ''))
|
|
46
|
+
: new URLSearchParams()
|
|
47
|
+
const modeParam = params.get('dsh-desktop-mode')
|
|
48
|
+
const mode = modeParam === 'compatibility' || modeParam === 'advanced' ? modeParam : null
|
|
49
|
+
const platformParam = params.get('dsh-desktop-platform')
|
|
50
|
+
const platform = platformParam !== null && platformParam !== '' ? platformParam.toLowerCase() : null
|
|
51
|
+
const desktop = mode !== null || hasPreloadMarker
|
|
52
|
+
cached = {
|
|
53
|
+
desktop,
|
|
54
|
+
mode,
|
|
55
|
+
platform,
|
|
56
|
+
titlebarInset: parseTitlebarInset(params.get('dsh-desktop-titlebar-inset')),
|
|
57
|
+
}
|
|
58
|
+
return cached
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Clamp the contract inset parameter into 0–120 (invalid/absent → 0). */
|
|
62
|
+
function parseTitlebarInset(raw: string | null): number {
|
|
63
|
+
if (raw === null) return 0
|
|
64
|
+
const parsed = Number(raw)
|
|
65
|
+
if (!Number.isFinite(parsed)) return 0
|
|
66
|
+
return Math.min(120, Math.max(0, Math.round(parsed)))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Test hook: drop the memo so the next parse re-reads the URL/globals. */
|
|
70
|
+
export function resetDesktopEnvForTests(): void {
|
|
71
|
+
cached = undefined
|
|
72
|
+
}
|