@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.
Files changed (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +22 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. package/src/wire.ts +100 -0
@@ -0,0 +1,525 @@
1
+ /**
2
+ * The editor tab host: the single FILES WINDOW. It resolves a file's
3
+ * previewer through the sidebar registry (`matchFileViewer`), fetches bytes
4
+ * per the matched viewer's fetch strategy, and renders its component — or
5
+ * the shared download pane when nothing can render the file. A tab without
6
+ * a path (the seeded "Files" home) renders an empty-state hint instead of
7
+ * the viewer loading flow; that path-less window IS the file explorer.
8
+ *
9
+ * The chrome depends on the `editorExplorer` mode (read reactively so
10
+ * toggling it re-renders without a reload):
11
+ * - merged (in-place): tree click / path-input Enter switch the CURRENT
12
+ * tab in place (updateTab rewrites path/title; the tab keeps its id and
13
+ * meta, so treeOpen/treeWidth survive the switch);
14
+ * - split: they open through `openSidebarFile` (a per-path dedupe tab),
15
+ * and a PATH-LESS window is the standalone explorer — it renders ONLY
16
+ * the tree panel (search + FileTree, full-window), no editor chrome.
17
+ * Editor tabs (with a path) keep the full chrome in both modes.
18
+ * The tree's context menu offers the explicit escapes in both modes: open
19
+ * in a new tab (per-path dedupe) or to the side (a fresh tab in a fresh
20
+ * rightward split of the current pane).
21
+ *
22
+ * The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
23
+ * editor-load.ts); this component only wires it to the host APIs.
24
+ */
25
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
26
+ import { createElement } from 'react'
27
+ import clsx from 'clsx'
28
+ import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives'
29
+ import type { Context } from '../context-types.ts'
30
+ import { api, mediaUrl, type SessionScope } from './api.ts'
31
+ import { BinaryDownload } from './binary-download.tsx'
32
+ import { isSessionEditable, setSessionEditable, subscribeSessionEditable } from './editable-state.ts'
33
+ import { IconLockClosedOutline16, IconLockOpenOutline16 } from './icons.tsx'
34
+ import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
35
+ import { baseName } from './FileTree.tsx'
36
+ import { createFrameBatcher } from './frame-batcher.ts'
37
+ import { openSidebarFile } from './intercept.tsx'
38
+ import { openWithSshActive, openWithUrl, parseOpenWithConfig, resolveOpenWithTargets } from './open-with.ts'
39
+ import { updatePluginSettings } from './plugin-settings.ts'
40
+ import { TreePanel } from './TreePanel.tsx'
41
+ import { t } from './locales.ts'
42
+ import { relativeTo } from './paths.ts'
43
+ import { resolveSidebarPath } from './produced-files.ts'
44
+ import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
45
+ import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
46
+ import css from './sidebar.module.css'
47
+
48
+ type EditorLoad =
49
+ | { status: 'loading' }
50
+ | { status: 'error'; message: string }
51
+ | { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
52
+ | { status: 'binary' }
53
+
54
+ /** The docked tree panel's width bounds (drag-resize clamps into them). */
55
+ const TREE_WIDTH_DEFAULT = 240
56
+ const TREE_WIDTH_MIN = 160
57
+ const TREE_WIDTH_MAX = 480
58
+
59
+ /** Stable empty blob for the editor pluginSettings read (a fresh `?? {}`
60
+ * would change identity every snapshot and loop useSyncExternalStore). */
61
+ const EMPTY_PLUGIN_BLOB: Record<string, unknown> = {}
62
+
63
+ /** The tab's persisted meta object (a malformed meta reads as empty). */
64
+ function metaOf(tab: SidebarTab): Record<string, unknown> {
65
+ return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta)
66
+ ? tab.meta as Record<string, unknown>
67
+ : {}
68
+ }
69
+
70
+ /** Read the persisted tree-panel flag of one editor tab: an explicit
71
+ * boolean meta wins; otherwise path-less tabs (the seeded home) default
72
+ * open and file tabs default closed. */
73
+ function treeOpenOf(tab: SidebarTab): boolean {
74
+ const treeOpen = metaOf(tab).treeOpen
75
+ return typeof treeOpen === 'boolean' ? treeOpen : (tab.path === undefined || tab.path === '')
76
+ }
77
+
78
+ /** Read the persisted tree-panel width (clamped; default 240). */
79
+ function treeWidthOf(tab: SidebarTab): number {
80
+ const width = metaOf(tab).treeWidth
81
+ return typeof width === 'number' && Number.isFinite(width)
82
+ ? Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(width)))
83
+ : TREE_WIDTH_DEFAULT
84
+ }
85
+
86
+ /** Merge a patch into the tab's persisted meta (rides the layout). */
87
+ function patchMeta(ctx: Context, tab: SidebarTab, patch: Record<string, unknown>): void {
88
+ ctx.betterSidebar?.updateTab(tab.id, { meta: { ...metaOf(tab), ...patch } })
89
+ }
90
+
91
+ /** Clamp one dock width into the contract range. */
92
+ function clampTreeWidth(value: number): number {
93
+ return Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(value)))
94
+ }
95
+
96
+ export function EditorHost(props: {
97
+ ctx: Context
98
+ store: SidebarStore
99
+ scope: SessionScope
100
+ tab: SidebarTab
101
+ expanded: string[]
102
+ onToggleDir: (path: string) => void
103
+ onReferenceFile: (path: string) => void
104
+ }) {
105
+ const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props
106
+ const path = tab.path ?? ''
107
+ const title = tab.title
108
+ const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
109
+
110
+ // Reactive prefs read: flipping editorExplorer re-renders this tab with no
111
+ // reload. The snapshot is the bare boolean so unrelated store churn never
112
+ // re-renders the editor.
113
+ const inPlace = useSyncExternalStore(
114
+ useCallback((callback: () => void) => store.subscribe(callback), [store]),
115
+ useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
116
+ )
117
+ // The file tree's "open with" configuration (pluginSettings['editor']): a
118
+ // blob subscription, so a pin click or a settings-page edit re-renders the
119
+ // menu immediately. The parsed config also drives which targets are shown
120
+ // (SSH mode hides the host-local ones).
121
+ const editorBlob = useSyncExternalStore(
122
+ useCallback((callback: () => void) => store.subscribe(callback), [store]),
123
+ useCallback(() => store.getSnapshot().prefs.pluginSettings['editor'] ?? EMPTY_PLUGIN_BLOB, [store]),
124
+ )
125
+ const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
126
+ const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
127
+ // A path-less tab shows the empty-state hint in merged mode — and in split
128
+ // mode it is the standalone explorer (tree-only, see the render below).
129
+ const showEmpty = path === ''
130
+ const treeOnly = showEmpty && !inPlace
131
+
132
+ /**
133
+ * Open a file from THIS window (tree click / search row / path input):
134
+ * merged mode switches this tab in place (stable id, meta survives);
135
+ * split mode opens a per-path dedupe tab through openSidebarFile.
136
+ */
137
+ const openFile = (absolute: string): void => {
138
+ if (inPlace) {
139
+ ctx.betterSidebar?.updateTab(tab.id, { path: absolute, title: baseName(absolute) })
140
+ } else {
141
+ openSidebarFile(ctx, store, scope.sessionId, absolute)
142
+ }
143
+ }
144
+
145
+ /** The context menu's explicit "new tab" escape (per-path dedupe). */
146
+ const openFileNewTab = (absolute: string): void => {
147
+ openSidebarFile(ctx, store, scope.sessionId, absolute)
148
+ }
149
+
150
+ /**
151
+ * The context menu's "open to the side": a fresh editor tab (uid id — the
152
+ * `'editor:' + path` convention would clash with the id safety net on a
153
+ * second side-open of the same file) in a rightward split of THIS pane.
154
+ */
155
+ const openFileSide = (absolute: string): void => {
156
+ store.reduce((state) => {
157
+ const key = treeOf(state, tab.id)
158
+ const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key])
159
+ const fresh: SidebarTab = {
160
+ id: mintTabId(),
161
+ type: 'editor',
162
+ title: baseName(absolute),
163
+ path: absolute,
164
+ meta: { treeOpen: false },
165
+ }
166
+ const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false)
167
+ return { ...state, [key]: node, activePane: leafId }
168
+ })
169
+ }
170
+
171
+ /** The context menu's "open with" action: reveal the path in the OS file
172
+ * manager, or hand the target's URL (a local `file` URL, or the SSH-remote
173
+ * form for VSCode-family editors in remote mode) to the host's external
174
+ * opener. Failures are logged only — a missing handler is the OS's
175
+ * dialog, not a sidebar error. */
176
+ const openWith = (targetId: string, absolute: string): void => {
177
+ const target = openWithTargets.find(item => item.id === targetId)
178
+ if (target === undefined) return
179
+ if (target.kind === 'reveal') {
180
+ void api.openExternal({ action: 'reveal', path: absolute }).catch(
181
+ (error: unknown) => { console.error('open external failed', error) },
182
+ )
183
+ return
184
+ }
185
+ const url = openWithUrl(target, absolute, openWithConfig)
186
+ if (url === undefined) return
187
+ void api.openExternal({ action: 'url', url }).catch(
188
+ (error: unknown) => { console.error('open external failed', error) },
189
+ )
190
+ }
191
+
192
+ /** Toggle one target's pinned state. The write is serialized (see
193
+ * plugin-settings.ts) and the menu re-renders when the store prefs land. */
194
+ const toggleOpenWithPin = (targetId: string): void => {
195
+ updatePluginSettings(store, 'editor', (blob) => {
196
+ const config = parseOpenWithConfig(blob.openWith)
197
+ const pinned = config.pinned.includes(targetId)
198
+ ? config.pinned.filter(id => id !== targetId)
199
+ : [...config.pinned, targetId]
200
+ return { ...blob, openWith: { ...config, pinned } }
201
+ })
202
+ }
203
+
204
+ // The viewer's toolbar, hoisted into THIS header: the text editor reports
205
+ // its state and registers its commands (both null/absent for viewers
206
+ // without a toolbar — image, pdf, binary download).
207
+ const [toolbar, setToolbar] = useState<EditorToolbarState | null>(null)
208
+ const controlsRef = useRef<EditorToolbarControls | null>(null)
209
+ const onToolbarState = useCallback((next: EditorToolbarState) => {
210
+ setToolbar(prev => prev !== null && JSON.stringify(prev) === JSON.stringify(next) ? prev : next)
211
+ }, [])
212
+ const onToolbarControls = useCallback((controls: EditorToolbarControls | null) => {
213
+ controlsRef.current = controls
214
+ }, [])
215
+
216
+ // The docked panel's drag-resize: pointer capture on the handle itself
217
+ // (no window listeners — the captured pointer keeps tracking even off the
218
+ // handle). Local width while dragging, persisted into meta.treeWidth on
219
+ // release. The panel docks right, so dragging LEFT widens it.
220
+ // Moves are BATCHED per frame (createFrameBatcher): applying every
221
+ // pointermove is a setState that re-renders this host AND the editor
222
+ // viewer below it (CodeMirror re-lays out on the width change) at event
223
+ // cadence — the visible drag lag on slower CPUs (#315). The batch applies
224
+ // the latest width once per frame; release flushes it and commits.
225
+ const [dragWidth, setDragWidth] = useState<number | null>(null)
226
+ const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
227
+ const pendingWidthRef = useRef(0)
228
+ const dragBatcher = useRef(createFrameBatcher()).current
229
+ useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
230
+ const treeWidth = dragWidth ?? treeWidthOf(tab)
231
+
232
+ const onResizeStart = (event: React.PointerEvent): void => {
233
+ event.preventDefault()
234
+ // jsdom lacks setPointerCapture — the tests dispatch plain MouseEvents.
235
+ event.currentTarget.setPointerCapture?.(event.pointerId)
236
+ dragRef.current = { startX: event.clientX, startWidth: treeWidth }
237
+ }
238
+ const onResizeMove = (event: React.PointerEvent): void => {
239
+ const drag = dragRef.current
240
+ if (drag === null) return
241
+ pendingWidthRef.current = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
242
+ dragBatcher.schedule(() => setDragWidth(pendingWidthRef.current))
243
+ }
244
+ const onResizeEnd = (event: React.PointerEvent): void => {
245
+ const drag = dragRef.current
246
+ if (drag === null) return
247
+ // Flush the last pending frame (a release can land with the final move
248
+ // still queued; without the flush a stray frame would re-apply the
249
+ // drag width AFTER the null below). Both setStates batch into this same
250
+ // event, so the committed treeWidth wins visually.
251
+ dragBatcher.flushNow()
252
+ dragRef.current = null
253
+ setDragWidth(null)
254
+ const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
255
+ if (finalWidth !== treeWidthOf(tab)) patchMeta(ctx, tab, { treeWidth: finalWidth })
256
+ }
257
+
258
+ useEffect(() => {
259
+ // A (re)load or a path-less tab clears any hoisted toolbar state — the
260
+ // fresh viewer re-registers its own.
261
+ setToolbar(null)
262
+ // The seeded home tab (no path) never loads a viewer — the empty-state
263
+ // hint renders until the user picks a file.
264
+ if (showEmpty) return
265
+ let cancelled = false
266
+ // Aborts the matched viewer's `load` when the editor tears down (tab
267
+ // closed, path changed, session switched) or re-matches the viewer.
268
+ const controller = new AbortController()
269
+ setLoad({ status: 'loading' })
270
+ const mediaUrlOf = (): string => mediaUrl(scope, path)
271
+ const apply = (action: EditorLoadAction): void => {
272
+ if (cancelled) return
273
+ switch (action.kind) {
274
+ case 'binary':
275
+ setLoad({ status: 'binary' })
276
+ return
277
+ case 'render':
278
+ setLoad({
279
+ status: 'ready',
280
+ viewer: action.viewer,
281
+ content: action.content,
282
+ truncated: action.truncated,
283
+ mediaUrl: action.mediaUrl,
284
+ customData: action.customData,
285
+ })
286
+ return
287
+ case 'customLoad':
288
+ void action.viewer.load?.(path, scope, controller.signal).then((data) => {
289
+ if (cancelled) return
290
+ setLoad({ status: 'ready', viewer: action.viewer, customData: data })
291
+ }).catch((error: unknown) => {
292
+ if (cancelled) return
293
+ setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
294
+ })
295
+ return
296
+ case 'fetchFsRead':
297
+ api.fsRead(scope, path).then((result) => {
298
+ if (cancelled) return
299
+ // Binary reads carry the head bytes for the detect re-match.
300
+ const outcome = planFsReadOutcome(action.viewer, {
301
+ binary: result.kind === 'binary',
302
+ content: result.kind === 'text' ? result.content : '',
303
+ truncated: result.truncated,
304
+ head: result.kind === 'binary' ? result.head : undefined,
305
+ }, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf)
306
+ apply(outcome)
307
+ }).catch((error: unknown) => {
308
+ if (cancelled) return
309
+ setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
310
+ })
311
+ return
312
+ }
313
+ }
314
+ apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
315
+ return () => { cancelled = true; controller.abort() }
316
+ }, [scope.sessionId, scope.cwd, path, ctx, showEmpty])
317
+
318
+ const treeOpen = treeOpenOf(tab)
319
+ /** Persist the panel flag on the tab (survives reloads with the layout). */
320
+ const toggleTree = (): void => { patchMeta(ctx, tab, { treeOpen: !treeOpen }) }
321
+ /** Whether THIS session may write (read-only by default; the toggle flips it). */
322
+ const [writable, setWritable] = useState(() => isSessionEditable(scope.sessionId))
323
+ // Follow the shared per-session write permission so the merged host
324
+ // toolbar and the editor stay in lockstep with the git panel.
325
+ useEffect(() => subscribeSessionEditable(scope.sessionId, setWritable), [scope.sessionId])
326
+ /** Flip this session's write permission: local first, then the host (a
327
+ * host failure rolls the flag back so UI and enforcement agree). */
328
+ const toggleWritable = (): void => {
329
+ const next = !writable
330
+ setSessionEditable(scope.sessionId, next)
331
+ void api.configSetEditable(scope, next).catch(() => {
332
+ setSessionEditable(scope.sessionId, !next)
333
+ })
334
+ }
335
+ const saveLabel = toolbar === null ? ''
336
+ : toolbar.saveState === 'saving' ? t('loading')
337
+ : toolbar.saveState === 'saved' ? t('saved')
338
+ : toolbar.saveState === 'failed' ? t('saveFailed') : ''
339
+
340
+ // Split mode: the path-less window IS the standalone explorer — the tree
341
+ // panel fills the whole tab (search + FileTree, full form), no editor
342
+ // chrome. File opens land in new per-path tabs through openFile above.
343
+ if (treeOnly) {
344
+ return (
345
+ <div className={css.editor}>
346
+ <TreePanel
347
+ full
348
+ sessionId={scope.sessionId}
349
+ cwd={scope.cwd}
350
+ expanded={expanded}
351
+ onToggle={onToggleDir}
352
+ onOpenFile={openFile}
353
+ onOpenFileNewTab={openFileNewTab}
354
+ onOpenFileSide={openFileSide}
355
+ openWithTargets={openWithTargets}
356
+ openWithPinned={openWithConfig.pinned}
357
+ openWithSsh={openWithSshActive(openWithConfig)}
358
+ onOpenWith={openWith}
359
+ onToggleOpenWithPin={toggleOpenWithPin}
360
+ onReferenceFile={onReferenceFile}
361
+ />
362
+ </div>
363
+ )
364
+ }
365
+
366
+ return (
367
+ <div className={css.editor}>
368
+ <div className={css.editorHeader}>
369
+ <EditorPathInput key={path} path={path} cwd={scope.cwd} onOpen={openFile} />
370
+ {toolbar?.modes === true && (
371
+ <div className={css.editorModeToggle}>
372
+ <button
373
+ type="button"
374
+ className={clsx(css.editorModeButton, toolbar.mode === 'preview' && css.editorModeActive)}
375
+ onClick={() => { controlsRef.current?.setMode('preview') }}
376
+ >
377
+ {t('preview')}
378
+ </button>
379
+ {writable && (
380
+ <button
381
+ type="button"
382
+ className={clsx(css.editorModeButton, toolbar.mode === 'edit' && css.editorModeActive)}
383
+ onClick={() => { controlsRef.current?.setMode('edit') }}
384
+ >
385
+ {t('edit')}
386
+ </button>
387
+ )}
388
+ </div>
389
+ )}
390
+ {toolbar?.dirty === true && <span className={css.dirtyDot} title={t('unsaved')} />}
391
+ {toolbar?.editable === true && (
392
+ <button
393
+ type="button"
394
+ className={css.iconButton}
395
+ aria-label={writable ? t('switchReadOnlyTitle') : t('switchEditableTitle')}
396
+ title={writable ? t('switchReadOnlyTitle') : t('switchEditableTitle')}
397
+ onClick={toggleWritable}
398
+ >
399
+ {writable ? <IconLockOpenOutline16 size={14} /> : <IconLockClosedOutline16 size={14} />}
400
+ </button>
401
+ )}
402
+ {toolbar?.editable === true && writable && (
403
+ <button
404
+ type="button"
405
+ className={css.iconButton}
406
+ aria-label={t('save')}
407
+ title={`${t('save')} (Ctrl/Cmd+S)`}
408
+ onClick={() => { controlsRef.current?.save() }}
409
+ >
410
+ <IconCheckOutline16 size={14} />
411
+ </button>
412
+ )}
413
+ {saveLabel !== '' && (
414
+ <span className={clsx(css.editorStatus, toolbar?.saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>
415
+ )}
416
+ <button
417
+ type="button"
418
+ className={clsx(css.iconButton, treeOpen && css.editorTreeToggleActive)}
419
+ aria-label={t('editorTreeToggle')}
420
+ title={t('editorTreeToggle')}
421
+ aria-pressed={treeOpen}
422
+ onClick={toggleTree}
423
+ >
424
+ <IconFolderOpen16 size={14} />
425
+ </button>
426
+ </div>
427
+ <div className={css.editorBody}>
428
+ <div className={css.editorMain}>
429
+ {showEmpty && <div className={css.editorPlaceholder}>{t('editorEmptyHint')}</div>}
430
+ {!showEmpty && load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
431
+ {!showEmpty && load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
432
+ {!showEmpty && load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
433
+ {!showEmpty && load.status === 'ready' && createElement(load.viewer.component, {
434
+ ctx, store, scope, path, title,
435
+ viewerId: load.viewer.id,
436
+ content: load.content,
437
+ truncated: load.truncated,
438
+ mediaUrl: load.mediaUrl,
439
+ customData: load.customData,
440
+ // The viewer's toolbar always hoists into this host's header.
441
+ toolbar: 'host',
442
+ onToolbarState,
443
+ onToolbarControls,
444
+ })}
445
+ </div>
446
+ {treeOpen && (
447
+ <div className={css.editorTreeDock} style={{ width: treeWidth }}>
448
+ <div
449
+ className={css.editorTreeResize}
450
+ role="separator"
451
+ aria-orientation="vertical"
452
+ aria-label={t('editorTreeToggle')}
453
+ onPointerDown={onResizeStart}
454
+ onPointerMove={onResizeMove}
455
+ onPointerUp={onResizeEnd}
456
+ onPointerCancel={onResizeEnd}
457
+ />
458
+ <TreePanel
459
+ sessionId={scope.sessionId}
460
+ cwd={scope.cwd}
461
+ expanded={expanded}
462
+ onToggle={onToggleDir}
463
+ onOpenFile={openFile}
464
+ onOpenFileNewTab={openFileNewTab}
465
+ onOpenFileSide={openFileSide}
466
+ openWithTargets={openWithTargets}
467
+ openWithPinned={openWithConfig.pinned}
468
+ openWithSsh={openWithSshActive(openWithConfig)}
469
+ onOpenWith={openWith}
470
+ onToggleOpenWithPin={toggleOpenWithPin}
471
+ onReferenceFile={onReferenceFile}
472
+ />
473
+ </div>
474
+ )}
475
+ </div>
476
+ </div>
477
+ )
478
+ }
479
+
480
+ /**
481
+ * The header's path input: shows the current file relative to the session
482
+ * cwd (absolute when outside it). Enter resolves the typed path (relative
483
+ * input joins onto the cwd — the same resolution `openSidebarFile` uses)
484
+ * and opens it through the parent's mode-aware open (in-place switch or a
485
+ * per-path dedupe tab); Escape/blur restores the current value. The parent
486
+ * keys it by `path` so an in-place switch remounts and reseeds the draft.
487
+ */
488
+ function EditorPathInput(props: { path: string; cwd: string | undefined; onOpen: (path: string) => void }) {
489
+ const { path, cwd, onOpen } = props
490
+ const display = path === '' ? '' : relativeTo(cwd ?? '', path)
491
+ const [value, setValue] = useState(display)
492
+
493
+ const commit = (): void => {
494
+ const input = value.trim()
495
+ if (input === '' || input === display) {
496
+ setValue(display)
497
+ return
498
+ }
499
+ onOpen(resolveSidebarPath(cwd, input))
500
+ // Split mode: the open lands in a NEW/deduped editor tab — THIS tab's
501
+ // path stays, so the input falls back to its own display value. (Merged
502
+ // mode remounts this input on the new path; the reset is harmless.)
503
+ setValue(display)
504
+ }
505
+
506
+ return (
507
+ <input
508
+ className={css.editorPathInput}
509
+ value={value}
510
+ placeholder={t('editorPathPlaceholder')}
511
+ title={path}
512
+ spellCheck={false}
513
+ onChange={(event) => { setValue(event.target.value) }}
514
+ onKeyDown={(event) => {
515
+ if (event.key === 'Enter') {
516
+ event.preventDefault()
517
+ commit()
518
+ } else if (event.key === 'Escape') {
519
+ setValue(display)
520
+ }
521
+ }}
522
+ onBlur={() => { setValue(display) }}
523
+ />
524
+ )
525
+ }