@lijian-ui/dsh-file-manager 0.1.20

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 (169) hide show
  1. package/LICENSE +38 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +66 -0
  4. package/README.zh.md +66 -0
  5. package/cordis.patch.yml +13 -0
  6. package/lib/assets/mermaid.min.js +3587 -0
  7. package/lib/client.js +7621 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/index.js +3207 -0
  10. package/lib/tsconfig.client.tsbuildinfo +1 -0
  11. package/lib/tsconfig.host.tsbuildinfo +1 -0
  12. package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
  13. package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
  14. package/lib/types/client/PluginSettingsCard.d.ts +104 -0
  15. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  16. package/lib/types/client/api.d.ts +75 -0
  17. package/lib/types/client/api.d.ts.map +1 -0
  18. package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
  19. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
  20. package/lib/types/client/chat/file-ref.d.ts +35 -0
  21. package/lib/types/client/chat/file-ref.d.ts.map +1 -0
  22. package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
  23. package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
  24. package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
  25. package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
  26. package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
  27. package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
  28. package/lib/types/client/components/FileIcon.d.ts +15 -0
  29. package/lib/types/client/components/FileIcon.d.ts.map +1 -0
  30. package/lib/types/client/components/ScmPanel.d.ts +23 -0
  31. package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
  32. package/lib/types/client/components/a11y.d.ts +12 -0
  33. package/lib/types/client/components/a11y.d.ts.map +1 -0
  34. package/lib/types/client/components/icons.d.ts +41 -0
  35. package/lib/types/client/components/icons.d.ts.map +1 -0
  36. package/lib/types/client/components/overlay.d.ts +45 -0
  37. package/lib/types/client/components/overlay.d.ts.map +1 -0
  38. package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
  39. package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
  40. package/lib/types/client/drag/file-drag.d.ts +47 -0
  41. package/lib/types/client/drag/file-drag.d.ts.map +1 -0
  42. package/lib/types/client/drag.d.ts +31 -0
  43. package/lib/types/client/drag.d.ts.map +1 -0
  44. package/lib/types/client/file-source.d.ts +29 -0
  45. package/lib/types/client/file-source.d.ts.map +1 -0
  46. package/lib/types/client/fileType.d.ts +31 -0
  47. package/lib/types/client/fileType.d.ts.map +1 -0
  48. package/lib/types/client/floating.d.ts +27 -0
  49. package/lib/types/client/floating.d.ts.map +1 -0
  50. package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
  51. package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
  52. package/lib/types/client/hooks/useStore.d.ts +10 -0
  53. package/lib/types/client/hooks/useStore.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +57 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/layout.d.ts +104 -0
  57. package/lib/types/client/layout.d.ts.map +1 -0
  58. package/lib/types/client/locales.d.ts +125 -0
  59. package/lib/types/client/locales.d.ts.map +1 -0
  60. package/lib/types/client/maximize.d.ts +24 -0
  61. package/lib/types/client/maximize.d.ts.map +1 -0
  62. package/lib/types/client/mention.d.ts +44 -0
  63. package/lib/types/client/mention.d.ts.map +1 -0
  64. package/lib/types/client/mount.d.ts +22 -0
  65. package/lib/types/client/mount.d.ts.map +1 -0
  66. package/lib/types/client/persist.d.ts +55 -0
  67. package/lib/types/client/persist.d.ts.map +1 -0
  68. package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
  69. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
  70. package/lib/types/client/picker/file-picker.d.ts +62 -0
  71. package/lib/types/client/picker/file-picker.d.ts.map +1 -0
  72. package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
  73. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
  74. package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
  75. package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
  76. package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
  77. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
  78. package/lib/types/client/preview/content.d.ts +43 -0
  79. package/lib/types/client/preview/content.d.ts.map +1 -0
  80. package/lib/types/client/preview/markdown.d.ts +60 -0
  81. package/lib/types/client/preview/markdown.d.ts.map +1 -0
  82. package/lib/types/client/preview/mermaid.d.ts +78 -0
  83. package/lib/types/client/preview/mermaid.d.ts.map +1 -0
  84. package/lib/types/client/preview/selection.d.ts +26 -0
  85. package/lib/types/client/preview/selection.d.ts.map +1 -0
  86. package/lib/types/client/reference.d.ts +75 -0
  87. package/lib/types/client/reference.d.ts.map +1 -0
  88. package/lib/types/client/settings-form.d.ts +206 -0
  89. package/lib/types/client/settings-form.d.ts.map +1 -0
  90. package/lib/types/client/store.d.ts +279 -0
  91. package/lib/types/client/store.d.ts.map +1 -0
  92. package/lib/types/core/types.d.ts +132 -0
  93. package/lib/types/core/types.d.ts.map +1 -0
  94. package/lib/types/host/fs-service.d.ts +96 -0
  95. package/lib/types/host/fs-service.d.ts.map +1 -0
  96. package/lib/types/host/gate.d.ts +45 -0
  97. package/lib/types/host/gate.d.ts.map +1 -0
  98. package/lib/types/host/git-runner.d.ts +80 -0
  99. package/lib/types/host/git-runner.d.ts.map +1 -0
  100. package/lib/types/host/git-service.d.ts +130 -0
  101. package/lib/types/host/git-service.d.ts.map +1 -0
  102. package/lib/types/host/loopback.d.ts +25 -0
  103. package/lib/types/host/loopback.d.ts.map +1 -0
  104. package/lib/types/host/poll-guard.d.ts +55 -0
  105. package/lib/types/host/poll-guard.d.ts.map +1 -0
  106. package/lib/types/host/routes.d.ts +54 -0
  107. package/lib/types/host/routes.d.ts.map +1 -0
  108. package/lib/types/index.d.ts +35 -0
  109. package/lib/types/index.d.ts.map +1 -0
  110. package/lib/types/mount-once.d.ts +25 -0
  111. package/lib/types/mount-once.d.ts.map +1 -0
  112. package/package.json +85 -0
  113. package/src/client/FileManagerSettingsCard.tsx +117 -0
  114. package/src/client/PluginSettingsCard.tsx +337 -0
  115. package/src/client/api.ts +141 -0
  116. package/src/client/chat/file-ref.ts +144 -0
  117. package/src/client/chat/mermaid-chat.tsx +102 -0
  118. package/src/client/chat/placeholder-hint.tsx +46 -0
  119. package/src/client/components/ExplorerPanel.tsx +534 -0
  120. package/src/client/components/FileIcon.tsx +46 -0
  121. package/src/client/components/ScmPanel.tsx +480 -0
  122. package/src/client/components/a11y.ts +20 -0
  123. package/src/client/components/icons.tsx +274 -0
  124. package/src/client/components/overlay.tsx +225 -0
  125. package/src/client/css-modules.d.ts +5 -0
  126. package/src/client/drag/DragFileInlay.tsx +89 -0
  127. package/src/client/drag/file-drag.ts +67 -0
  128. package/src/client/drag.ts +154 -0
  129. package/src/client/file-source.ts +58 -0
  130. package/src/client/fileType.ts +129 -0
  131. package/src/client/floating.ts +63 -0
  132. package/src/client/hooks/useResizableSplit.ts +91 -0
  133. package/src/client/hooks/useStore.ts +15 -0
  134. package/src/client/index.ts +389 -0
  135. package/src/client/layout.ts +546 -0
  136. package/src/client/locales.ts +247 -0
  137. package/src/client/maximize.ts +34 -0
  138. package/src/client/mount.tsx +74 -0
  139. package/src/client/persist.ts +194 -0
  140. package/src/client/picker/FilePickerModal.tsx +506 -0
  141. package/src/client/picker/file-picker.ts +86 -0
  142. package/src/client/preview/PreviewPanel.tsx +233 -0
  143. package/src/client/preview/PreviewTabs.tsx +159 -0
  144. package/src/client/preview/PreviewToolbar.tsx +160 -0
  145. package/src/client/preview/content.tsx +617 -0
  146. package/src/client/preview/markdown.ts +351 -0
  147. package/src/client/preview/mermaid.ts +274 -0
  148. package/src/client/preview/selection.ts +82 -0
  149. package/src/client/reference.ts +161 -0
  150. package/src/client/settings-card.module.css +316 -0
  151. package/src/client/settings-form.ts +451 -0
  152. package/src/client/store.ts +1290 -0
  153. package/src/client/styles/chip.module.css +38 -0
  154. package/src/client/styles/drag.module.css +30 -0
  155. package/src/client/styles/explorer.module.css +371 -0
  156. package/src/client/styles/picker.module.css +344 -0
  157. package/src/client/styles/preview.module.css +904 -0
  158. package/src/client/styles/scm.module.css +380 -0
  159. package/src/client/styles/tokens.module.css +480 -0
  160. package/src/core/types.ts +162 -0
  161. package/src/host/fs-service.ts +513 -0
  162. package/src/host/gate.ts +74 -0
  163. package/src/host/git-runner.ts +116 -0
  164. package/src/host/git-service.ts +395 -0
  165. package/src/host/loopback.ts +63 -0
  166. package/src/host/poll-guard.ts +109 -0
  167. package/src/host/routes.ts +695 -0
  168. package/src/index.ts +97 -0
  169. package/src/mount-once.ts +48 -0
@@ -0,0 +1,1290 @@
1
+ /**
2
+ * Framework-free state core of the panel system: four small stores (layout,
3
+ * explorer, scm, preview) built on a minimal subscribe/getSnapshot primitive
4
+ * so every decision lives outside React (StrictMode-safe: update reducers are
5
+ * pure; async work — fetches, persistence — runs in the action layer).
6
+ *
7
+ * FileManager's right-panel architecture (Apache-2.0, re-implemented): the width
8
+ * clamps below are the exact ordered pair that keeps the chat area >= 360px
9
+ * at all times (see the research report's section 4.2).
10
+ * @module dsh-filemgr/client/store
11
+ */
12
+
13
+ import type { FileRead, FsEntry, GitStatusView, PreviewContentType, SearchHit } from '../core/types.ts'
14
+ import type { PanelApi } from './api.ts'
15
+ import { detectContentType, isTextType, pdfPreviewUrl, tabIdOf } from './fileType.ts'
16
+ import {
17
+ createDebounced, evictPreviewScopes, readJson, readStoredNumber, writeJson, writeStoredNumber,
18
+ } from './persist.ts'
19
+
20
+ // ─── state primitive ────────────────────────────────────────────────────────
21
+
22
+ /** Internal channel for the stored-layout flush used by pagehide flushing. */
23
+ const FLUSH_PERSIST = Symbol('flushPersist')
24
+
25
+ /** A minimal external store usable with useSyncExternalStore. */
26
+ export interface StateHandle<S> {
27
+ getSnapshot: () => S
28
+ subscribe: (listener: () => void) => () => void
29
+ /** Pure update: fn receives the previous state and returns the next. */
30
+ update: (fn: (prev: S) => S) => void
31
+ }
32
+
33
+ /** Create a state handle with an immutable snapshot (new object per update). */
34
+ export function createState<S>(initial: S): StateHandle<S> {
35
+ let state = initial
36
+ const listeners = new Set<() => void>()
37
+ return {
38
+ getSnapshot: () => state,
39
+ subscribe(listener) {
40
+ listeners.add(listener)
41
+ return () => { listeners.delete(listener) }
42
+ },
43
+ update(fn) {
44
+ const next = fn(state)
45
+ if (next === state) return
46
+ state = next
47
+ for (const listener of listeners) listener()
48
+ },
49
+ }
50
+ }
51
+
52
+ // ─── layout: constants, clamps, store ───────────────────────────────────────
53
+
54
+ /** Chat-area floor the two clamps guarantee (never below this). */
55
+ export const MIN_CHAT_PANEL_PX = 360
56
+ /** Preview region width contract. */
57
+ export const MIN_PREVIEW_PANEL_PX = 340
58
+ export const DEFAULT_PREVIEW_REGION_PX = 480
59
+ export const MAX_PREVIEW_REGION_PX = 1200
60
+ /** Explorer (workspace) width contract. */
61
+ export const MIN_WORKSPACE_PANEL_PX = 220
62
+ export const MAX_WORKSPACE_PANEL_PX = 500
63
+ export const DEFAULT_WORKSPACE_PANEL_PX = 260
64
+ /** Preview region horizontal chrome (margins + borders) the clamps subtract. */
65
+ export const PREVIEW_REGION_CHROME_PX = 24
66
+
67
+ /** Storage keys (FileManager contract, verbatim). */
68
+ export const KEY_EXPLORER_WIDTH = 'chat-workspace-width-px'
69
+ export const KEY_PREVIEW_WIDTH = 'chat-preview-width-px'
70
+ export const KEY_COLLAPSE = 'project-panel-collapse:'
71
+ export const KEY_EXPLORER_UI = 'explorer-ui:'
72
+ /** Trailing window for coalescing explorer fs-event refetches (ms). */
73
+ export const FS_COALESCE_MS = 200
74
+ export const KEY_SCM_UI = 'scm-ui:'
75
+
76
+ /**
77
+ * Explorer clamp (runs first): reserve chat's floor plus the preview region
78
+ * (min + chrome) when open, so the explorer never grows into the preview's
79
+ * space; floor at the explorer minimum so a narrow container cannot squeeze
80
+ * it to nothing.
81
+ */
82
+ export function clampExplorerWidth(requested: number, available: number, previewOpen: boolean): number {
83
+ const reserve = MIN_CHAT_PANEL_PX + (previewOpen ? MIN_PREVIEW_PANEL_PX + PREVIEW_REGION_CHROME_PX : 0)
84
+ const maxByContainer = Math.max(MIN_WORKSPACE_PANEL_PX, available - reserve)
85
+ return Math.min(requested, maxByContainer)
86
+ }
87
+
88
+ /**
89
+ * Preview clamp (runs after the explorer clamp): reserve chat's floor plus
90
+ * the already-clamped explorer width plus the region chrome. The ordered pair
91
+ * guarantees chat = available - explorer - preview >= 360.
92
+ */
93
+ export function clampPreviewWidth(requested: number, available: number, explorerWidth: number): number {
94
+ const maxByContainer = Math.max(
95
+ MIN_PREVIEW_PANEL_PX,
96
+ available - MIN_CHAT_PANEL_PX - explorerWidth - PREVIEW_REGION_CHROME_PX,
97
+ )
98
+ return Math.min(requested, maxByContainer)
99
+ }
100
+
101
+ /** Which panel is maximized (null = normal layout). */
102
+ export type MaximizeTarget = 'explorer' | 'preview'
103
+
104
+ /** Layout panel state (project-scoped). */
105
+ export interface LayoutState {
106
+ /** The project root ('' when no project is bound). */
107
+ root: string
108
+ /** Requested explorer width (persisted; clamped on render). */
109
+ explorerWidth: number
110
+ /** Requested preview width (persisted; clamped on render). */
111
+ previewWidth: number
112
+ /** Explorer collapsed (width 0, kept mounted). */
113
+ explorerCollapsed: boolean
114
+ /** Preview region visible. */
115
+ previewOpen: boolean
116
+ /** Measured available width of the [content | panels] row. */
117
+ availableWidth: number
118
+ /** True while a panel drag is in flight (disables transitions). */
119
+ dragging: boolean
120
+ /**
121
+ * Maximized panel (transient, never persisted): the panel takes over the
122
+ * whole frame row (or the viewport as an overlay on narrow screens) while
123
+ * the other panel, the chat area and the shell side columns hide. Restoring
124
+ * re-applies the stored widths/collapse — nothing else changes.
125
+ */
126
+ maximized: MaximizeTarget | null
127
+ }
128
+
129
+ /** The layout store plus its pure width math. */
130
+ export interface LayoutStore extends StateHandle<LayoutState> {
131
+ /** Effective explorer width after the ordered clamp. */
132
+ explorerWidthPx: (state: LayoutState) => number
133
+ /** Effective preview width after the ordered clamp. */
134
+ previewWidthPx: (state: LayoutState) => number
135
+ /** Persist a clamped shrink when the stored width no longer fits. */
136
+ shrinkToFit: (state: LayoutState) => void
137
+ }
138
+
139
+ /** Storage key of the collapse preference for one root. */
140
+ export const collapseKey = (root: string): string => `${KEY_COLLAPSE}${root}`
141
+
142
+ /** Create the layout store (reads persisted widths on init). */
143
+ export function createLayoutStore(): LayoutStore {
144
+ const handle = createState<LayoutState>({
145
+ root: '',
146
+ explorerWidth: readStoredNumber(KEY_EXPLORER_WIDTH, MIN_WORKSPACE_PANEL_PX, MAX_WORKSPACE_PANEL_PX, DEFAULT_WORKSPACE_PANEL_PX),
147
+ previewWidth: readStoredNumber(KEY_PREVIEW_WIDTH, MIN_PREVIEW_PANEL_PX, MAX_PREVIEW_REGION_PX, DEFAULT_PREVIEW_REGION_PX),
148
+ explorerCollapsed: false,
149
+ previewOpen: false,
150
+ availableWidth: 0,
151
+ dragging: false,
152
+ maximized: null,
153
+ })
154
+ const store: LayoutStore = Object.assign(handle, {
155
+ explorerWidthPx(state: LayoutState): number {
156
+ return state.explorerCollapsed ? 0 : clampExplorerWidth(state.explorerWidth, state.availableWidth, state.previewOpen)
157
+ },
158
+ previewWidthPx(state: LayoutState): number {
159
+ if (!state.previewOpen) return 0
160
+ const explorer = state.explorerCollapsed ? 0 : clampExplorerWidth(state.explorerWidth, state.availableWidth, true)
161
+ return clampPreviewWidth(state.previewWidth, state.availableWidth, explorer)
162
+ },
163
+ shrinkToFit(state: LayoutState): void {
164
+ if (state.availableWidth <= 0) return
165
+ const explorer = clampExplorerWidth(state.explorerWidth, state.availableWidth, state.previewOpen)
166
+ if (state.explorerWidth > explorer && !state.explorerCollapsed) {
167
+ writeStoredNumber(KEY_EXPLORER_WIDTH, explorer)
168
+ handle.update((prev) => ({ ...prev, explorerWidth: explorer }))
169
+ }
170
+ const preview = clampPreviewWidth(state.previewWidth, state.availableWidth, explorer)
171
+ if (state.previewOpen && state.previewWidth > preview) {
172
+ writeStoredNumber(KEY_PREVIEW_WIDTH, preview)
173
+ handle.update((prev) => ({ ...prev, previewWidth: preview }))
174
+ }
175
+ },
176
+ })
177
+ return store
178
+ }
179
+
180
+ /** Switch the layout to a project root (restores collapse + widths). */
181
+ export function layoutSetRoot(store: LayoutStore, root: string, previewOpen: boolean): void {
182
+ store.update((prev) => {
183
+ if (prev.root === root && prev.previewOpen === previewOpen) return prev
184
+ let collapsed = prev.explorerCollapsed
185
+ if (prev.root !== root) {
186
+ try {
187
+ collapsed = localStorage.getItem(collapseKey(root)) === 'collapsed'
188
+ } catch {
189
+ collapsed = false
190
+ }
191
+ }
192
+ // Maximize is a transient layout state: it must never leak from one
193
+ // project/session into another (issue #315 acceptance).
194
+ return { ...prev, root, explorerCollapsed: collapsed, previewOpen, maximized: prev.root === root ? prev.maximized : null }
195
+ })
196
+ }
197
+
198
+ // ─── explorer store ─────────────────────────────────────────────────────────
199
+
200
+ /** Explorer panel state. */
201
+ export interface ExplorerState {
202
+ root: string
203
+ /** rel path -> listing cache ('' = root). */
204
+ dirs: Record<string, FsEntry[]>
205
+ /** Expanded dir rel paths (order = display order). */
206
+ expanded: string[]
207
+ /** Selected node rel path (null = none). */
208
+ selected: string | null
209
+ /** Dirs currently fetching. */
210
+ loading: string[]
211
+ /** Active tab: files | changes. */
212
+ activeTab: 'files' | 'changes'
213
+ /** Filename search state. */
214
+ search: {
215
+ query: string
216
+ status: 'idle' | 'searching' | 'done' | 'error'
217
+ hits: SearchHit[]
218
+ truncated: boolean
219
+ }
220
+ /** Bumped on every fs change event (drives refetch + re-render). */
221
+ version: number
222
+ }
223
+
224
+ /** The explorer store with its async actions. */
225
+ export interface ExplorerStore extends StateHandle<ExplorerState> {
226
+ setRoot: (root: string) => void
227
+ setActiveTab: (tab: 'files' | 'changes') => void
228
+ toggleDir: (rel: string) => void
229
+ select: (rel: string | null) => void
230
+ reveal: (rel: string) => void
231
+ setSearchQuery: (query: string) => void
232
+ cancelSearch: () => void
233
+ /** Refetch every expanded dir + active search after a host change event. */
234
+ handleFsChange: () => void
235
+ /** Reveal a path in the OS file manager (right-click menu). */
236
+ revealInFileManager: (rel: string) => Promise<boolean>
237
+ /** Open a path with the OS default app (files only, right-click menu). */
238
+ openWithDefaultApp: (rel: string) => Promise<boolean>
239
+ /** Rename a path (newName is a bare name). */
240
+ renameEntry: (rel: string, newName: string) => Promise<boolean>
241
+ /** Create a directory at rel (parent dirs untouched). */
242
+ createDir: (rel: string) => Promise<boolean>
243
+ /** Create an empty file at rel. */
244
+ createFile: (rel: string) => Promise<boolean>
245
+ /** Delete a path (recursive for directories). */
246
+ deleteEntry: (rel: string) => Promise<boolean>
247
+ }
248
+
249
+ /** Read the persisted explorer UI state for a root (range-guarded). */
250
+ export function readExplorerUi(root: string): { expanded: string[]; selected: string | null } {
251
+ const stored = readJson<{ expanded?: unknown; selected?: unknown }>(`${KEY_EXPLORER_UI}${root}`, {})
252
+ const expanded = Array.isArray(stored.expanded)
253
+ ? stored.expanded.filter((item): item is string => typeof item === 'string')
254
+ : []
255
+ const selected = typeof stored.selected === 'string' ? stored.selected : null
256
+ return { expanded, selected }
257
+ }
258
+
259
+ const EMPTY_SEARCH = { query: '', status: 'idle' as const, hits: [], truncated: false }
260
+
261
+ /** Create the explorer store (per-root persistence, debounced writes). */
262
+ export function createExplorerStore(api: PanelApi): ExplorerStore {
263
+ const handle = createState<ExplorerState>({
264
+ root: '',
265
+ dirs: {},
266
+ expanded: [],
267
+ selected: null,
268
+ loading: [],
269
+ activeTab: 'files',
270
+ search: { ...EMPTY_SEARCH },
271
+ version: 0,
272
+ })
273
+
274
+ const persistDebounced = createDebounced()
275
+ const searchDebounced = createDebounced()
276
+ let fsVersion = 0
277
+ let fsInFlight = false
278
+ let fsScheduled: ReturnType<typeof setTimeout> | undefined
279
+ let persistRoot = ''
280
+ let persistExpanded: string[] = []
281
+ let persistSelected: string | null = null
282
+ // The debounced write (schedulePersist queues this; flushNow runs it now).
283
+ const persistWrite = (): void => {
284
+ if (persistRoot !== '') writeJson(`${KEY_EXPLORER_UI}${persistRoot}`, { expanded: persistExpanded, selected: persistSelected })
285
+ }
286
+ const flushPersist = (): void => { persistDebounced.flush() }
287
+ const schedulePersist = (root: string, expanded: string[], selected: string | null): void => {
288
+ if (root === '') return
289
+ persistRoot = root
290
+ persistExpanded = expanded
291
+ persistSelected = selected
292
+ persistDebounced.schedule(persistWrite)
293
+ }
294
+
295
+ /** Load one dir's listing into the cache (no-op when already present). */
296
+ const ensureDir = async (root: string, rel: string): Promise<void> => {
297
+ const state = handle.getSnapshot()
298
+ if (state.root !== root || state.dirs[rel] !== undefined || state.loading.includes(rel)) return
299
+ handle.update((prev) => ({ ...prev, loading: [...prev.loading, rel] }))
300
+ const result = await api.list(root, rel)
301
+ handle.update((prev) => {
302
+ if (prev.root !== root) return prev
303
+ // A listing that landed after its dir collapsed must not re-populate
304
+ // the cache (the expand/collapse race would resurrect stale children).
305
+ if (rel !== '' && !prev.expanded.includes(rel)) {
306
+ return { ...prev, loading: prev.loading.filter((item) => item !== rel) }
307
+ }
308
+ const dirs = { ...prev.dirs }
309
+ if (result.ok) dirs[rel] = result.value.entries
310
+ else delete dirs[rel]
311
+ return { ...prev, dirs, loading: prev.loading.filter((item) => item !== rel) }
312
+ })
313
+ }
314
+
315
+ /** Drop cached subtrees under a collapsed dir (its own key included). */
316
+ const dropSubtree = (dirs: Record<string, FsEntry[]>, rel: string): Record<string, FsEntry[]> => {
317
+ const prefix = rel === '' ? '' : `${rel}/`
318
+ const next: Record<string, FsEntry[]> = {}
319
+ for (const key of Object.keys(dirs)) {
320
+ if (rel !== '' && (key === rel || key.startsWith(prefix))) continue
321
+ next[key] = dirs[key]
322
+ }
323
+ return next
324
+ }
325
+
326
+ /** A dir's ancestor chain ('' .. parent). */
327
+ const ancestors = (rel: string): string[] => {
328
+ const out: string[] = []
329
+ const parts = rel.split('/').filter(Boolean)
330
+ let acc = ''
331
+ for (const part of parts) {
332
+ acc = acc === '' ? part : `${acc}/${part}`
333
+ out.push(acc)
334
+ }
335
+ return out
336
+ }
337
+
338
+ /**
339
+ * Refetch the root plus every expanded dir (seq-guarded against stale
340
+ * results) and the active search after an fs change event.
341
+ */
342
+ const runFsRefresh = async (): Promise<void> => {
343
+ const state = handle.getSnapshot()
344
+ const root = state.root
345
+ if (root === '') return
346
+ const dirs = [...new Set(['', ...state.expanded])]
347
+ const seq = ++fsVersion
348
+ const results = await Promise.allSettled(dirs.map((rel) => api.list(root, rel)))
349
+ handle.update((prev) => {
350
+ if (prev.root !== root || seq !== fsVersion) return prev
351
+ const nextDirs = { ...prev.dirs }
352
+ results.forEach((result, index) => {
353
+ const rel = dirs[index]
354
+ if (result.status !== 'fulfilled' || !result.value.ok) return
355
+ // A dir folded while the event burst was in flight must not be
356
+ // re-populated (the collapse would revive from a stale snapshot).
357
+ if (rel !== '' && !prev.expanded.includes(rel)) return
358
+ nextDirs[rel] = result.value.value.entries
359
+ })
360
+ return { ...prev, dirs: nextDirs, version: prev.version + 1 }
361
+ })
362
+ if (state.search.query !== '') {
363
+ void api.search(root, state.search.query).then((result) => {
364
+ handle.update((prev) => {
365
+ if (prev.root !== root || prev.search.query !== state.search.query) return prev
366
+ return {
367
+ ...prev,
368
+ search: result.ok
369
+ ? { query: state.search.query, status: 'done', hits: result.value.hits, truncated: result.value.truncated }
370
+ : prev.search,
371
+ }
372
+ })
373
+ })
374
+ }
375
+ }
376
+
377
+ const store: ExplorerStore = Object.assign(handle, {
378
+ setRoot(root: string) {
379
+ handle.update((prev) => {
380
+ if (prev.root === root) return prev
381
+ const ui = readExplorerUi(root)
382
+ return {
383
+ ...prev,
384
+ root,
385
+ dirs: {},
386
+ expanded: ui.expanded,
387
+ selected: ui.selected,
388
+ loading: [],
389
+ search: { ...EMPTY_SEARCH },
390
+ }
391
+ })
392
+ void ensureDir(root, '')
393
+ },
394
+ setActiveTab(tab: 'files' | 'changes') {
395
+ handle.update((prev) => (prev.activeTab === tab ? prev : { ...prev, activeTab: tab }))
396
+ },
397
+ toggleDir(rel: string) {
398
+ const state = handle.getSnapshot()
399
+ const isExpanded = state.expanded.includes(rel)
400
+ if (isExpanded) {
401
+ handle.update((prev) => ({
402
+ ...prev,
403
+ expanded: prev.expanded.filter((item) => item !== rel),
404
+ dirs: dropSubtree(prev.dirs, rel),
405
+ }))
406
+ } else {
407
+ handle.update((prev) => ({ ...prev, expanded: [...prev.expanded, rel] }))
408
+ void ensureDir(state.root, rel)
409
+ }
410
+ schedulePersist(state.root, isExpanded ? state.expanded.filter((item) => item !== rel) : [...state.expanded, rel], state.selected)
411
+ },
412
+ select(rel: string | null) {
413
+ handle.update((prev) => (prev.selected === rel ? prev : { ...prev, selected: rel }))
414
+ const state = handle.getSnapshot()
415
+ schedulePersist(state.root, state.expanded, rel)
416
+ },
417
+ reveal(rel: string) {
418
+ const state = handle.getSnapshot()
419
+ const chain = ancestors(rel)
420
+ const missing = chain.filter((item) => !state.expanded.includes(item))
421
+ handle.update((prev) => {
422
+ const expanded = [...prev.expanded]
423
+ for (const item of missing) {
424
+ if (!expanded.includes(item)) expanded.push(item)
425
+ }
426
+ return { ...prev, expanded, selected: rel, search: { ...EMPTY_SEARCH } }
427
+ })
428
+ for (const item of missing) void ensureDir(state.root, item)
429
+ schedulePersist(state.root, [...state.expanded, ...missing], rel)
430
+ },
431
+ setSearchQuery(query: string) {
432
+ const trimmed = query.trim()
433
+ handle.update((prev) => {
434
+ if (trimmed === '' && prev.search.query === '') return prev
435
+ return {
436
+ ...prev,
437
+ search: trimmed === ''
438
+ ? { ...EMPTY_SEARCH }
439
+ : { ...prev.search, query: trimmed, status: 'searching' },
440
+ }
441
+ })
442
+ searchDebounced.dispose()
443
+ if (trimmed === '') return
444
+ const root = handle.getSnapshot().root
445
+ searchDebounced.schedule(() => {
446
+ void api.search(root, trimmed).then((result) => {
447
+ handle.update((prev) => {
448
+ if (prev.root !== root || prev.search.query !== trimmed) return prev
449
+ return {
450
+ ...prev,
451
+ search: result.ok
452
+ ? { query: trimmed, status: 'done', hits: result.value.hits, truncated: result.value.truncated }
453
+ : { ...prev.search, status: 'error', hits: [] },
454
+ }
455
+ })
456
+ })
457
+ })
458
+ },
459
+ cancelSearch() {
460
+ searchDebounced.dispose()
461
+ handle.update((prev) => (prev.search.query === '' ? prev : { ...prev, search: { ...EMPTY_SEARCH } }))
462
+ },
463
+ async revealInFileManager(rel: string) {
464
+ const root = handle.getSnapshot().root
465
+ if (root === '') return false
466
+ const result = await api.reveal(root, rel)
467
+ return result.ok
468
+ },
469
+ async openWithDefaultApp(rel: string) {
470
+ const root = handle.getSnapshot().root
471
+ if (root === '') return false
472
+ const result = await api.openWithDefault(root, rel)
473
+ return result.ok
474
+ },
475
+ async renameEntry(rel: string, newName: string) {
476
+ const root = handle.getSnapshot().root
477
+ if (root === '' || rel === '') return false
478
+ const result = await api.rename(root, rel, newName)
479
+ if (result.ok) {
480
+ // Drop the old subtree, move the selection to the new path, and
481
+ // forget the expansion of the renamed dir (its cache is gone).
482
+ handle.update((prev) => {
483
+ const parent = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
484
+ const newRel = parent === '' ? newName : `${parent}/${newName}`
485
+ const next: ExplorerState = {
486
+ ...prev,
487
+ dirs: dropSubtree(prev.dirs, rel),
488
+ expanded: prev.expanded.filter((item) => item !== rel),
489
+ }
490
+ if (prev.selected === rel) next.selected = newRel
491
+ return next
492
+ })
493
+ void this.handleFsChange()
494
+ }
495
+ return result.ok
496
+ },
497
+ async createDir(rel: string) {
498
+ const root = handle.getSnapshot().root
499
+ if (root === '') return false
500
+ const result = await api.mkdir(root, rel)
501
+ if (result.ok) {
502
+ // Expand the parent so the new directory shows up immediately.
503
+ const parent = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
504
+ const state = handle.getSnapshot()
505
+ if (parent !== '' && !state.expanded.includes(parent)) this.toggleDir(parent)
506
+ void this.handleFsChange()
507
+ }
508
+ return result.ok
509
+ },
510
+ async createFile(rel: string) {
511
+ const root = handle.getSnapshot().root
512
+ if (root === '') return false
513
+ const result = await api.newFile(root, rel)
514
+ if (result.ok) {
515
+ // Expand the parent so the new file shows up immediately.
516
+ const parent = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
517
+ const state = handle.getSnapshot()
518
+ if (parent !== '' && !state.expanded.includes(parent)) this.toggleDir(parent)
519
+ void this.handleFsChange()
520
+ }
521
+ return result.ok
522
+ },
523
+ async deleteEntry(rel: string) {
524
+ const root = handle.getSnapshot().root
525
+ if (root === '' || rel === '') return false
526
+ const result = await api.delete(root, rel)
527
+ if (result.ok) {
528
+ handle.update((prev) => ({
529
+ ...prev,
530
+ selected: prev.selected === rel || prev.selected?.startsWith(rel + '/') ? null : prev.selected,
531
+ dirs: dropSubtree(prev.dirs, rel),
532
+ }))
533
+ void this.handleFsChange()
534
+ }
535
+ return result.ok
536
+ },
537
+ async handleFsChange() {
538
+ const root = handle.getSnapshot().root
539
+ if (root === '') return
540
+ if (fsInFlight) {
541
+ // Collapse a burst of events into one trailing pass: schedule it
542
+ // once, then let the timer re-enter here once it fires.
543
+ if (fsScheduled === undefined) {
544
+ fsScheduled = setTimeout(() => {
545
+ fsScheduled = undefined
546
+ void this.handleFsChange()
547
+ }, FS_COALESCE_MS)
548
+ }
549
+ return
550
+ }
551
+ fsInFlight = true
552
+ try {
553
+ await runFsRefresh()
554
+ } finally {
555
+ fsInFlight = false
556
+ }
557
+ },
558
+ })
559
+ ;(store as unknown as Record<symbol, unknown>)[FLUSH_PERSIST] = flushPersist
560
+ return store
561
+ }
562
+
563
+ // ─── scm store ──────────────────────────────────────────────────────────────
564
+
565
+ /** SCM panel state. */
566
+ export interface ScmState {
567
+ root: string
568
+ /** null: not a git repository (or still loading). */
569
+ status: GitStatusView | null
570
+ /** True when the host reported git is not installed (SSE gitUnavailable). */
571
+ gitMissing: boolean
572
+ loading: boolean
573
+ /** Paths with an action in flight. */
574
+ busy: string[]
575
+ /** Paths the last action reported failed. */
576
+ failed: string[]
577
+ /** list | tree. */
578
+ viewMode: 'list' | 'tree'
579
+ /** Section collapse map (repositories | changes). */
580
+ sectionCollapsed: Record<string, boolean>
581
+ /** Tree-view expanded dir keys. */
582
+ treeExpanded: string[]
583
+ /** Path of the last row opened in the preview panel (null = none). */
584
+ selected: string | null
585
+ }
586
+
587
+ /** The scm store with its async actions. */
588
+ export interface ScmStore extends StateHandle<ScmState> {
589
+ setRoot: (root: string) => void
590
+ refresh: () => Promise<void>
591
+ stage: (paths: string[]) => Promise<void>
592
+ unstage: (paths: string[]) => Promise<void>
593
+ discard: (paths: string[]) => Promise<void>
594
+ discardAll: () => Promise<void>
595
+ setViewMode: (mode: 'list' | 'tree') => void
596
+ setSectionCollapsed: (id: string, collapsed: boolean) => void
597
+ setTreeExpanded: (keys: string[]) => void
598
+ setFailed: (paths: string[]) => void
599
+ select: (path: string | null) => void
600
+ }
601
+
602
+ /** Read the persisted scm UI state for a root (guarded). */
603
+ export function readScmUi(root: string): { viewMode: 'list' | 'tree'; sectionCollapsed: Record<string, boolean>; treeExpanded: string[]; selected: string | null } {
604
+ const stored = readJson<{ viewMode?: unknown; sectionCollapsed?: unknown; treeExpanded?: unknown; selected?: unknown }>(`${KEY_SCM_UI}${root}`, {})
605
+ const viewMode = stored.viewMode === 'tree' ? 'tree' : 'list'
606
+ const sectionCollapsed: Record<string, boolean> = typeof stored.sectionCollapsed === 'object' && stored.sectionCollapsed !== null
607
+ ? Object.fromEntries(Object.entries(stored.sectionCollapsed as Record<string, unknown>).filter(([, v]) => typeof v === 'boolean')) as Record<string, boolean>
608
+ : {}
609
+ const treeExpanded = Array.isArray(stored.treeExpanded)
610
+ ? stored.treeExpanded.filter((item): item is string => typeof item === 'string')
611
+ : []
612
+ const selected = typeof stored.selected === 'string' ? stored.selected : null
613
+ return { viewMode, sectionCollapsed, treeExpanded, selected }
614
+ }
615
+
616
+ /** Create the scm store (host status is the only truth — no optimistic rows). */
617
+ export function createScmStore(api: PanelApi): ScmStore {
618
+ const handle = createState<ScmState>({
619
+ root: '',
620
+ status: null,
621
+ gitMissing: false,
622
+ loading: false,
623
+ busy: [],
624
+ failed: [],
625
+ viewMode: 'list',
626
+ sectionCollapsed: {},
627
+ treeExpanded: [],
628
+ selected: null,
629
+ })
630
+
631
+ const persistDebounced = createDebounced()
632
+ let persistState: ScmState | null = null
633
+ let loadSeq = 0
634
+ const persistWrite = (): void => {
635
+ if (persistState !== null && persistState.root !== '') {
636
+ writeJson(`${KEY_SCM_UI}${persistState.root}`, {
637
+ viewMode: persistState.viewMode,
638
+ sectionCollapsed: persistState.sectionCollapsed,
639
+ treeExpanded: persistState.treeExpanded,
640
+ selected: persistState.selected,
641
+ })
642
+ }
643
+ }
644
+ const flushPersist = (): void => { persistDebounced.flush() }
645
+ const schedulePersist = (state: ScmState): void => {
646
+ if (state.root === '') return
647
+ persistState = state
648
+ persistDebounced.schedule(persistWrite)
649
+ }
650
+
651
+ /** Fetch the status and land it (guarded against root switches + out-of-order). */
652
+ const load = async (root: string, keepBusy: string[] = []): Promise<void> => {
653
+ const seq = ++loadSeq
654
+ handle.update((prev) => ({ ...prev, loading: true }))
655
+ const result = await api.gitStatus(root)
656
+ handle.update((prev) => {
657
+ // Only the newest in-flight load may land; a stale response must not
658
+ // overwrite fresher state (focus refresh vs SSE push race).
659
+ if (prev.root !== root || seq !== loadSeq) return prev
660
+ return {
661
+ ...prev,
662
+ status: result.ok ? result.value : prev.status,
663
+ // A real repo view clears the missing-git banner; null keeps it.
664
+ gitMissing: result.ok && result.value !== null ? false : prev.gitMissing,
665
+ loading: false,
666
+ busy: keepBusy,
667
+ }
668
+ })
669
+ }
670
+
671
+ const store: ScmStore = Object.assign(handle, {
672
+ setRoot(root: string) {
673
+ handle.update((prev) => {
674
+ if (prev.root === root) return prev
675
+ const ui = readScmUi(root)
676
+ return {
677
+ ...prev,
678
+ root,
679
+ status: null,
680
+ gitMissing: false,
681
+ loading: true,
682
+ busy: [],
683
+ failed: [],
684
+ viewMode: ui.viewMode,
685
+ sectionCollapsed: ui.sectionCollapsed,
686
+ treeExpanded: ui.treeExpanded,
687
+ selected: ui.selected,
688
+ }
689
+ })
690
+ void load(root)
691
+ },
692
+ async refresh() {
693
+ const root = handle.getSnapshot().root
694
+ if (root !== '') await load(root)
695
+ },
696
+ async stage(paths: string[]) {
697
+ const root = handle.getSnapshot().root
698
+ if (root === '' || paths.length === 0) return
699
+ handle.update((prev) => ({ ...prev, busy: [...prev.busy, ...paths] }))
700
+ const result = await api.gitStage(root, paths)
701
+ handle.update((prev) => ({
702
+ ...prev,
703
+ failed: result.ok && Array.isArray(result.value?.failed) ? result.value.failed : (result.ok ? [] : paths),
704
+ busy: prev.busy.filter((item) => !paths.includes(item)),
705
+ }))
706
+ await load(root)
707
+ },
708
+ async unstage(paths: string[]) {
709
+ const root = handle.getSnapshot().root
710
+ if (root === '' || paths.length === 0) return
711
+ handle.update((prev) => ({ ...prev, busy: [...prev.busy, ...paths] }))
712
+ const result = await api.gitUnstage(root, paths)
713
+ handle.update((prev) => ({
714
+ ...prev,
715
+ failed: result.ok && Array.isArray(result.value?.failed) ? result.value.failed : (result.ok ? [] : paths),
716
+ busy: prev.busy.filter((item) => !paths.includes(item)),
717
+ }))
718
+ await load(root)
719
+ },
720
+ async discard(paths: string[]) {
721
+ const root = handle.getSnapshot().root
722
+ if (root === '' || paths.length === 0) return
723
+ handle.update((prev) => ({ ...prev, busy: [...prev.busy, ...paths] }))
724
+ const result = await api.gitDiscard(root, paths)
725
+ handle.update((prev) => ({
726
+ ...prev,
727
+ failed: result.ok && Array.isArray(result.value?.failed) ? result.value.failed : (result.ok ? [] : paths),
728
+ busy: prev.busy.filter((item) => !paths.includes(item)),
729
+ }))
730
+ await load(root)
731
+ },
732
+ async discardAll() {
733
+ const state = handle.getSnapshot()
734
+ const paths = [
735
+ ...(state.status?.unstaged ?? []),
736
+ ...(state.status?.untracked ?? []),
737
+ ].map((row) => row.path)
738
+ await this.discard(paths)
739
+ },
740
+ setViewMode(mode: 'list' | 'tree') {
741
+ handle.update((prev) => (prev.viewMode === mode ? prev : { ...prev, viewMode: mode }))
742
+ schedulePersist(handle.getSnapshot())
743
+ },
744
+ setSectionCollapsed(id: string, collapsed: boolean) {
745
+ handle.update((prev) => ({ ...prev, sectionCollapsed: { ...prev.sectionCollapsed, [id]: collapsed } }))
746
+ schedulePersist(handle.getSnapshot())
747
+ },
748
+ setTreeExpanded(keys: string[]) {
749
+ handle.update((prev) => ({ ...prev, treeExpanded: keys }))
750
+ schedulePersist(handle.getSnapshot())
751
+ },
752
+ setFailed(paths: string[]) {
753
+ handle.update((prev) => ({ ...prev, failed: paths }))
754
+ },
755
+ select(path: string | null) {
756
+ handle.update((prev) => (prev.selected === path ? prev : { ...prev, selected: path }))
757
+ schedulePersist(handle.getSnapshot())
758
+ },
759
+ })
760
+ ;(store as unknown as Record<symbol, unknown>)[FLUSH_PERSIST] = flushPersist
761
+ return store
762
+ }
763
+
764
+ // ─── preview store ──────────────────────────────────────────────────────────
765
+
766
+ /** One preview tab. */
767
+ export interface PreviewTabState {
768
+ id: string
769
+ title: string
770
+ root: string
771
+ path: string
772
+ contentType: PreviewContentType
773
+ /** Diff tabs (opened from the SCM panel): content is the path's git diff. */
774
+ diff?: { staged: boolean }
775
+ /** URL tabs: bumped by reloadTab to re-navigate the preview frame. */
776
+ reloadNonce?: number
777
+ /** null: content not loaded yet. */
778
+ content: string | null
779
+ /** Image dimensions for image tabs. */
780
+ image?: { width: number; height: number }
781
+ dirty: boolean
782
+ /** mtime the loaded/saved content is based on (write-conflict base). */
783
+ mtime?: number
784
+ /** Disk is newer than the loaded content (refresh affordance). */
785
+ updated: boolean
786
+ loading: boolean
787
+ truncated: boolean
788
+ error: string | null
789
+ savedAt: number
790
+ }
791
+
792
+ /** Preview panel state. */
793
+ export interface PreviewState {
794
+ root: string
795
+ open: boolean
796
+ tabs: PreviewTabState[]
797
+ activeTabId: string | null
798
+ /** Bumped on every fs change event (drives staleness checks). */
799
+ version: number
800
+ }
801
+
802
+ /** The preview store with its async actions. */
803
+ export interface PreviewStore extends StateHandle<PreviewState> {
804
+ setRoot: (root: string) => void
805
+ openFile: (root: string, path: string) => void
806
+ openDiff: (root: string, path: string, staged: boolean) => void
807
+ switchTab: (id: string) => void
808
+ closeTabs: (ids: string[]) => void
809
+ updateContent: (id: string, content: string) => void
810
+ saveTab: (id: string) => Promise<void>
811
+ reloadTab: (id: string) => Promise<void>
812
+ setOpen: (open: boolean) => void
813
+ handleFsChange: () => void
814
+ handleGitChange: (root: string) => void
815
+ }
816
+
817
+ /** Persisted tab meta (content is re-fetched on restore). */
818
+ interface PersistedTab {
819
+ id: string
820
+ title: string
821
+ root: string
822
+ path: string
823
+ contentType: PreviewContentType
824
+ diff?: { staged: boolean }
825
+ savedAt: number
826
+ }
827
+
828
+ /** Read persisted tabs for a root (guarded, content-less). */
829
+ export function readPreviewTabs(root: string): PersistedTab[] {
830
+ const stored = readJson<{ savedAt?: unknown; tabs?: unknown }>(`preview-ui:${root}`, {})
831
+ if (!Array.isArray(stored.tabs)) return []
832
+ const out: PersistedTab[] = []
833
+ for (const item of stored.tabs) {
834
+ if (typeof item !== 'object' || item === null) continue
835
+ const record = item as Record<string, unknown>
836
+ if (typeof record.id !== 'string' || typeof record.path !== 'string') continue
837
+ const rawDiff = record.diff
838
+ const diff = typeof rawDiff === 'object' && rawDiff !== null
839
+ && typeof (rawDiff as Record<string, unknown>).staged === 'boolean'
840
+ ? { staged: (rawDiff as { staged: boolean }).staged }
841
+ : undefined
842
+ out.push({
843
+ id: record.id,
844
+ title: typeof record.title === 'string' ? record.title : record.path,
845
+ root: typeof record.root === 'string' ? record.root : root,
846
+ path: record.path,
847
+ contentType: typeof record.contentType === 'string' ? record.contentType as PreviewContentType : 'text',
848
+ diff,
849
+ savedAt: typeof record.savedAt === 'number' ? record.savedAt : 0,
850
+ })
851
+ }
852
+ return out
853
+ }
854
+
855
+ /** Create the preview store (per-root tab persistence with LRU scopes). */
856
+ export function createPreviewStore(api: PanelApi): PreviewStore {
857
+ const handle = createState<PreviewState>({
858
+ root: '',
859
+ open: false,
860
+ tabs: [],
861
+ activeTabId: null,
862
+ version: 0,
863
+ })
864
+
865
+ // handleFsChange single-flight + trailing coalesce (see its guard).
866
+ let previewFsInFlight = false
867
+ let previewFsScheduled: ReturnType<typeof setTimeout> | undefined
868
+
869
+ const persistDebounced = createDebounced()
870
+ const persistWrite = (): void => {
871
+ const current = handle.getSnapshot()
872
+ if (current.root === '') return
873
+ const meta: PersistedTab[] = current.tabs.map((tab) => ({
874
+ id: tab.id,
875
+ title: tab.title,
876
+ root: tab.root,
877
+ path: tab.path,
878
+ contentType: tab.contentType,
879
+ diff: tab.diff,
880
+ savedAt: tab.savedAt,
881
+ }))
882
+ writeJson(`preview-ui:${current.root}`, { savedAt: Date.now(), tabs: meta })
883
+ evictPreviewScopes(current.root)
884
+ }
885
+ const flushPersist = (): void => { persistDebounced.flush() }
886
+ const schedulePersist = (state: PreviewState): void => {
887
+ if (state.root === '') return
888
+ persistDebounced.schedule(persistWrite)
889
+ }
890
+
891
+ /** Load content for one tab (text or image data URL, or git diff). */
892
+ const loadContent = async (root: string, id: string): Promise<void> => {
893
+ const tab = handle.getSnapshot().tabs.find((item) => item.id === id)
894
+ if (tab === undefined || tab.content !== null || tab.loading) return
895
+ handle.update((prev) => ({
896
+ ...prev,
897
+ tabs: prev.tabs.map((item) => (item.id === id ? { ...item, loading: true, error: null } : item)),
898
+ }))
899
+ // Pdf tabs never fetch through /read: the raw route streams the bytes
900
+ // straight into the preview iframe, so the tab content is the route URL.
901
+ if (tab.contentType === 'pdf') {
902
+ handle.update((prev) => {
903
+ if (prev.root !== root) return prev
904
+ return {
905
+ ...prev,
906
+ tabs: prev.tabs.map((item) => (item.id === id
907
+ ? { ...item, loading: false, content: pdfPreviewUrl(root, item.path, Date.now()), updated: false }
908
+ : item)),
909
+ }
910
+ })
911
+ return
912
+ }
913
+ const asImage = tab.contentType === 'image'
914
+ const result = tab.diff !== undefined
915
+ ? await api.gitDiff(root, tab.path, tab.diff.staged)
916
+ : await api.read(root, tab.path, asImage)
917
+ handle.update((prev) => {
918
+ if (prev.root !== root) return prev
919
+ return {
920
+ ...prev,
921
+ tabs: prev.tabs.map((item) => {
922
+ if (item.id !== id) return item
923
+ if (!result.ok) {
924
+ return { ...item, loading: false, error: result.error.message }
925
+ }
926
+ // The user started typing while the fetch was in flight: their newer
927
+ // content must not be overwritten by this (already stale) disk read.
928
+ if (item.dirty) return { ...item, loading: false }
929
+ // read and gitDiff share the content field; only read carries the
930
+ // rest (image/mtime/truncated), so the union is read as its merge.
931
+ const loaded = result.value as { content: string; image?: FileRead['image']; mtime?: number; truncated?: boolean }
932
+ return {
933
+ ...item,
934
+ loading: false,
935
+ content: loaded.content,
936
+ image: loaded.image,
937
+ mtime: loaded.mtime,
938
+ truncated: loaded.truncated ?? false,
939
+ updated: false,
940
+ }
941
+ }),
942
+ }
943
+ })
944
+ }
945
+
946
+ /** Touch a tab's savedAt (LRU order within the scope). */
947
+ const touch = (id: string): void => {
948
+ handle.update((prev) => ({
949
+ ...prev,
950
+ tabs: prev.tabs.map((item) => (item.id === id ? { ...item, savedAt: Date.now() } : item)),
951
+ }))
952
+ }
953
+
954
+ /**
955
+ * Re-fetch every loaded diff tab of the root in place (fs/git change
956
+ * events). In-flight or not-yet-loaded tabs are skipped — the next load or
957
+ * event covers them; landing guards keep a newer edit from being clobbered.
958
+ */
959
+ const refreshDiffs = async (root: string): Promise<void> => {
960
+ if (handle.getSnapshot().root !== root) return
961
+ const diffs = handle.getSnapshot().tabs
962
+ .filter((tab): tab is PreviewTabState & { diff: { staged: boolean } } => tab.diff !== undefined)
963
+ await Promise.all(diffs.map(async (tab) => {
964
+ if (tab.content === null || tab.loading) return
965
+ const result = await api.gitDiff(root, tab.path, tab.diff.staged)
966
+ handle.update((prev) => {
967
+ if (prev.root !== root) return prev
968
+ return {
969
+ ...prev,
970
+ tabs: prev.tabs.map((item) => {
971
+ if (item.id !== tab.id || !result.ok) return item
972
+ if (item.dirty || item.loading) return item
973
+ return { ...item, content: result.value.content, error: null }
974
+ }),
975
+ }
976
+ })
977
+ }))
978
+ }
979
+
980
+ const store: PreviewStore = Object.assign(handle, {
981
+ setRoot(root: string) {
982
+ handle.update((prev) => {
983
+ if (prev.root === root) return prev
984
+ const persisted = readPreviewTabs(root)
985
+ const tabs: PreviewTabState[] = persisted.map((meta) => ({
986
+ id: meta.id,
987
+ title: meta.title,
988
+ root: meta.root,
989
+ path: meta.path,
990
+ contentType: meta.contentType,
991
+ diff: meta.diff,
992
+ content: null,
993
+ dirty: false,
994
+ updated: false,
995
+ loading: false,
996
+ truncated: false,
997
+ error: null,
998
+ savedAt: meta.savedAt,
999
+ }))
1000
+ const activeTabId = tabs.length > 0 ? tabs[tabs.length - 1].id : null
1001
+ return { ...prev, root, tabs, activeTabId, open: tabs.length > 0 }
1002
+ })
1003
+ const state = handle.getSnapshot()
1004
+ if (state.activeTabId !== null) void loadContent(root, state.activeTabId)
1005
+ },
1006
+ openFile(root: string, path: string) {
1007
+ const type = detectContentType(path)
1008
+ const id = tabIdOf(root, path, type)
1009
+ const existing = handle.getSnapshot().tabs.find((tab) => tab.id === id)
1010
+ if (existing !== undefined) {
1011
+ handle.update((prev) => ({
1012
+ ...prev,
1013
+ root,
1014
+ open: true,
1015
+ activeTabId: id,
1016
+ tabs: prev.tabs.map((tab) => (tab.id === id ? { ...tab, savedAt: Date.now() } : tab)),
1017
+ }))
1018
+ void loadContent(root, id)
1019
+ schedulePersist(handle.getSnapshot())
1020
+ return
1021
+ }
1022
+ handle.update((prev) => {
1023
+ if (prev.root !== root) return prev
1024
+ const tab: PreviewTabState = {
1025
+ id,
1026
+ title: path.split('/').pop() ?? path,
1027
+ root,
1028
+ path,
1029
+ contentType: type,
1030
+ content: null,
1031
+ dirty: false,
1032
+ updated: false,
1033
+ loading: false,
1034
+ truncated: false,
1035
+ error: null,
1036
+ savedAt: Date.now(),
1037
+ }
1038
+ return { ...prev, open: true, tabs: [...prev.tabs, tab], activeTabId: id }
1039
+ })
1040
+ void loadContent(root, id)
1041
+ schedulePersist(handle.getSnapshot())
1042
+ },
1043
+ openDiff(root: string, path: string, staged: boolean) {
1044
+ // A distinct id space (scm-diff: side + root + path) so the same file
1045
+ // can carry a diff tab AND a file tab, and staged/unstaged diffs of one
1046
+ // path are separate tabs — each reflects the side it was opened from.
1047
+ const id = `scm-diff:${staged ? 's' : 'u'}\u0000${root}\u0000${path}`
1048
+ const existing = handle.getSnapshot().tabs.find((tab) => tab.id === id)
1049
+ if (existing !== undefined) {
1050
+ handle.update((prev) => ({
1051
+ ...prev,
1052
+ root,
1053
+ open: true,
1054
+ activeTabId: id,
1055
+ tabs: prev.tabs.map((tab) => (tab.id === id ? { ...tab, savedAt: Date.now() } : tab)),
1056
+ }))
1057
+ void loadContent(root, id)
1058
+ schedulePersist(handle.getSnapshot())
1059
+ return
1060
+ }
1061
+ handle.update((prev) => {
1062
+ if (prev.root !== root) return prev
1063
+ const tab: PreviewTabState = {
1064
+ id,
1065
+ title: path.split('/').pop() ?? path,
1066
+ root,
1067
+ path,
1068
+ contentType: 'diff',
1069
+ diff: { staged },
1070
+ content: null,
1071
+ dirty: false,
1072
+ updated: false,
1073
+ loading: false,
1074
+ truncated: false,
1075
+ error: null,
1076
+ savedAt: Date.now(),
1077
+ }
1078
+ return { ...prev, open: true, tabs: [...prev.tabs, tab], activeTabId: id }
1079
+ })
1080
+ void loadContent(root, id)
1081
+ schedulePersist(handle.getSnapshot())
1082
+ },
1083
+ switchTab(id: string) {
1084
+ const state = handle.getSnapshot()
1085
+ if (state.activeTabId === id) return
1086
+ handle.update((prev) => ({ ...prev, activeTabId: id }))
1087
+ touch(id)
1088
+ const tab = handle.getSnapshot().tabs.find((item) => item.id === id)
1089
+ if (tab !== undefined && tab.content === null) void loadContent(state.root, id)
1090
+ schedulePersist(handle.getSnapshot())
1091
+ },
1092
+ closeTabs(ids: string[]) {
1093
+ const state = handle.getSnapshot()
1094
+ const remaining = state.tabs.filter((tab) => !ids.includes(tab.id))
1095
+ const active = remaining.some((tab) => tab.id === state.activeTabId)
1096
+ const activeTabId = active
1097
+ ? state.activeTabId
1098
+ : remaining.length > 0
1099
+ ? remaining[Math.min(state.tabs.findIndex((tab) => tab.id === state.activeTabId), remaining.length - 1)]?.id ?? remaining[remaining.length - 1].id
1100
+ : null
1101
+ handle.update((prev) => ({
1102
+ ...prev,
1103
+ tabs: remaining,
1104
+ activeTabId,
1105
+ open: remaining.length > 0 ? prev.open : false,
1106
+ }))
1107
+ schedulePersist(handle.getSnapshot())
1108
+ },
1109
+ updateContent(id: string, content: string) {
1110
+ handle.update((prev) => ({
1111
+ ...prev,
1112
+ tabs: prev.tabs.map((tab) => (tab.id === id ? { ...tab, content, dirty: true, updated: false } : tab)),
1113
+ }))
1114
+ },
1115
+ async saveTab(id: string) {
1116
+ const state = handle.getSnapshot()
1117
+ const tab = state.tabs.find((item) => item.id === id)
1118
+ if (tab === undefined || tab.content === null || !isTextType(tab.contentType) || tab.diff !== undefined) return
1119
+ const sentContent = tab.content
1120
+ handle.update((prev) => ({
1121
+ ...prev,
1122
+ tabs: prev.tabs.map((item) => (item.id === id ? { ...item, loading: true, error: null } : item)),
1123
+ }))
1124
+ const result = await api.write(state.root, tab.path, tab.content, tab.mtime)
1125
+ handle.update((prev) => {
1126
+ if (prev.root !== state.root) return prev
1127
+ return {
1128
+ ...prev,
1129
+ tabs: prev.tabs.map((item) => {
1130
+ if (item.id !== id) return item
1131
+ if (!result.ok) {
1132
+ return {
1133
+ ...item,
1134
+ loading: false,
1135
+ error: result.error.code === 'write-conflict'
1136
+ ? '文件已在磁盘上被修改,保存冲突:请刷新后重试'
1137
+ : result.error.message,
1138
+ }
1139
+ }
1140
+ if (item.content !== sentContent) {
1141
+ // The user kept typing while the save was in flight: the disk now
1142
+ // holds the sent snapshot, but the tab's newer edits are unsaved.
1143
+ // Refresh the write base so the next save is conflict-safe and
1144
+ // keep the dirty flag so the UI still shows an unsaved edit.
1145
+ return { ...item, loading: false, mtime: result.value.mtime, error: null }
1146
+ }
1147
+ return { ...item, loading: false, dirty: false, mtime: result.value.mtime, error: null }
1148
+ }),
1149
+ }
1150
+ })
1151
+ },
1152
+ async reloadTab(id: string) {
1153
+ const state = handle.getSnapshot()
1154
+ const tab = state.tabs.find((item) => item.id === id)
1155
+ if (tab === undefined) return
1156
+ if (tab.contentType === 'url') {
1157
+ // URL tabs own a live document inside the frame; reload bumps the
1158
+ // nonce so UrlViewer re-navigates the frame to its address.
1159
+ handle.update((prev) => ({
1160
+ ...prev,
1161
+ tabs: prev.tabs.map((item) =>
1162
+ item.id === id ? { ...item, reloadNonce: (item.reloadNonce ?? 0) + 1 } : item,
1163
+ ),
1164
+ }))
1165
+ return
1166
+ }
1167
+ if (tab.contentType === 'pdf') {
1168
+ // Streamed tab: re-point the iframe at the raw route with a fresh
1169
+ // nonce so the browser re-fetches the bytes (no /read round-trip).
1170
+ handle.update((prev) => ({
1171
+ ...prev,
1172
+ tabs: prev.tabs.map((item) => (item.id === id
1173
+ ? { ...item, content: pdfPreviewUrl(state.root, item.path, Date.now()), updated: false, error: null }
1174
+ : item)),
1175
+ }))
1176
+ return
1177
+ }
1178
+ handle.update((prev) => ({
1179
+ ...prev,
1180
+ tabs: prev.tabs.map((item) => (item.id === id ? { ...item, loading: true } : item)),
1181
+ }))
1182
+ const result = tab.diff !== undefined
1183
+ ? await api.gitDiff(state.root, tab.path, tab.diff.staged)
1184
+ : await api.read(state.root, tab.path, tab.contentType === 'image')
1185
+ handle.update((prev) => {
1186
+ if (prev.root !== state.root) return prev
1187
+ return {
1188
+ ...prev,
1189
+ tabs: prev.tabs.map((item) => {
1190
+ if (item.id !== id) return item
1191
+ if (!result.ok) return { ...item, loading: false, error: result.error.message }
1192
+ const loaded = result.value as { content: string; image?: FileRead['image']; mtime?: number; truncated?: boolean }
1193
+ return {
1194
+ ...item,
1195
+ loading: false,
1196
+ content: loaded.content,
1197
+ image: loaded.image,
1198
+ mtime: loaded.mtime,
1199
+ truncated: loaded.truncated ?? false,
1200
+ updated: false,
1201
+ dirty: false,
1202
+ error: null,
1203
+ }
1204
+ }),
1205
+ }
1206
+ })
1207
+ },
1208
+ setOpen(open: boolean) {
1209
+ handle.update((prev) => (prev.open === open ? prev : { ...prev, open }))
1210
+ },
1211
+ async handleFsChange() {
1212
+ const state = handle.getSnapshot()
1213
+ if (state.root === '') return
1214
+ if (previewFsInFlight) {
1215
+ // Collapse a burst of fs events into one trailing pass: each refresh
1216
+ // respawns git per diff tab and re-reads the active file, so a write
1217
+ // storm must not queue one full round per event.
1218
+ if (previewFsScheduled === undefined) {
1219
+ previewFsScheduled = setTimeout(() => {
1220
+ previewFsScheduled = undefined
1221
+ void this.handleFsChange()
1222
+ }, FS_COALESCE_MS)
1223
+ }
1224
+ return
1225
+ }
1226
+ previewFsInFlight = true
1227
+ try {
1228
+ handle.update((prev) => ({ ...prev, version: prev.version + 1 }))
1229
+ // Diff tabs are derived views: any fs change may alter them, so refresh
1230
+ // them in place (never mark "updated" — the refresh is automatic).
1231
+ await refreshDiffs(state.root)
1232
+ // Staleness probe for the ACTIVE file tab only (cheap; the fs watcher
1233
+ // debounces bursts). A newer disk mtime flips the tab to "updated".
1234
+ const active = handle.getSnapshot().tabs.find((tab) => tab.id === handle.getSnapshot().activeTabId)
1235
+ if (active === undefined || active.content === null || active.dirty || active.diff !== undefined || !isTextType(active.contentType)) return
1236
+ const result = await api.read(state.root, active.path, false)
1237
+ handle.update((prev) => {
1238
+ if (prev.root !== state.root) return prev
1239
+ return {
1240
+ ...prev,
1241
+ tabs: prev.tabs.map((tab) => {
1242
+ if (tab.id !== active.id || tab.dirty) return tab
1243
+ if (!result.ok) return tab
1244
+ return { ...tab, updated: tab.mtime !== undefined && result.value.mtime > tab.mtime + 1 }
1245
+ }),
1246
+ }
1247
+ })
1248
+ } finally {
1249
+ previewFsInFlight = false
1250
+ }
1251
+ },
1252
+ async handleGitChange(root: string) {
1253
+ // A git push means the index/worktree moved (stage/unstage/discard or
1254
+ // external git): every open diff tab is stale by definition.
1255
+ await refreshDiffs(root)
1256
+ },
1257
+ })
1258
+ ;(store as unknown as Record<symbol, unknown>)[FLUSH_PERSIST] = flushPersist
1259
+ return store
1260
+ }
1261
+
1262
+ /** Convenience bundle: the four stores wired to one api. */
1263
+ export interface PanelStores {
1264
+ layout: LayoutStore
1265
+ explorer: ExplorerStore
1266
+ scm: ScmStore
1267
+ preview: PreviewStore
1268
+ }
1269
+
1270
+ /** PanelStores plus a pagehide flush hook. */
1271
+ export interface PanelStoresWithFlush extends PanelStores {
1272
+ /** Flush every pending debounced persist immediately (pagehide/beforeunload). */
1273
+ flushNow: () => void
1274
+ }
1275
+
1276
+ /** Create the full store bundle. */
1277
+ export function createPanelStores(api: PanelApi): PanelStoresWithFlush {
1278
+ const layout = createLayoutStore()
1279
+ const explorer = createExplorerStore(api)
1280
+ const scm = createScmStore(api)
1281
+ const preview = createPreviewStore(api)
1282
+ const flushNow = (): void => {
1283
+ for (const store of [explorer, scm, preview]) {
1284
+ const flush = (store as unknown as Record<symbol, unknown>)[FLUSH_PERSIST]
1285
+ if (typeof flush === 'function') (flush as () => void)()
1286
+ }
1287
+ }
1288
+ const stores: PanelStoresWithFlush = { layout, explorer, scm, preview, flushNow }
1289
+ return stores
1290
+ }