@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.
- package/LICENSE +38 -0
- package/README.i18n.yaml +6 -0
- package/README.md +66 -0
- package/README.zh.md +66 -0
- package/cordis.patch.yml +13 -0
- package/lib/assets/mermaid.min.js +3587 -0
- package/lib/client.js +7621 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3207 -0
- package/lib/tsconfig.client.tsbuildinfo +1 -0
- package/lib/tsconfig.host.tsbuildinfo +1 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +104 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/api.d.ts +75 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
- package/lib/types/client/chat/file-ref.d.ts +35 -0
- package/lib/types/client/chat/file-ref.d.ts.map +1 -0
- package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
- package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
- package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
- package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
- package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
- package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
- package/lib/types/client/components/FileIcon.d.ts +15 -0
- package/lib/types/client/components/FileIcon.d.ts.map +1 -0
- package/lib/types/client/components/ScmPanel.d.ts +23 -0
- package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
- package/lib/types/client/components/a11y.d.ts +12 -0
- package/lib/types/client/components/a11y.d.ts.map +1 -0
- package/lib/types/client/components/icons.d.ts +41 -0
- package/lib/types/client/components/icons.d.ts.map +1 -0
- package/lib/types/client/components/overlay.d.ts +45 -0
- package/lib/types/client/components/overlay.d.ts.map +1 -0
- package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
- package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
- package/lib/types/client/drag/file-drag.d.ts +47 -0
- package/lib/types/client/drag/file-drag.d.ts.map +1 -0
- package/lib/types/client/drag.d.ts +31 -0
- package/lib/types/client/drag.d.ts.map +1 -0
- package/lib/types/client/file-source.d.ts +29 -0
- package/lib/types/client/file-source.d.ts.map +1 -0
- package/lib/types/client/fileType.d.ts +31 -0
- package/lib/types/client/fileType.d.ts.map +1 -0
- package/lib/types/client/floating.d.ts +27 -0
- package/lib/types/client/floating.d.ts.map +1 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
- package/lib/types/client/hooks/useStore.d.ts +10 -0
- package/lib/types/client/hooks/useStore.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +57 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/layout.d.ts +104 -0
- package/lib/types/client/layout.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +125 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/maximize.d.ts +24 -0
- package/lib/types/client/maximize.d.ts.map +1 -0
- package/lib/types/client/mention.d.ts +44 -0
- package/lib/types/client/mention.d.ts.map +1 -0
- package/lib/types/client/mount.d.ts +22 -0
- package/lib/types/client/mount.d.ts.map +1 -0
- package/lib/types/client/persist.d.ts +55 -0
- package/lib/types/client/persist.d.ts.map +1 -0
- package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
- package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
- package/lib/types/client/picker/file-picker.d.ts +62 -0
- package/lib/types/client/picker/file-picker.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
- package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
- package/lib/types/client/preview/content.d.ts +43 -0
- package/lib/types/client/preview/content.d.ts.map +1 -0
- package/lib/types/client/preview/markdown.d.ts +60 -0
- package/lib/types/client/preview/markdown.d.ts.map +1 -0
- package/lib/types/client/preview/mermaid.d.ts +78 -0
- package/lib/types/client/preview/mermaid.d.ts.map +1 -0
- package/lib/types/client/preview/selection.d.ts +26 -0
- package/lib/types/client/preview/selection.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +75 -0
- package/lib/types/client/reference.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +206 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/store.d.ts +279 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/core/types.d.ts +132 -0
- package/lib/types/core/types.d.ts.map +1 -0
- package/lib/types/host/fs-service.d.ts +96 -0
- package/lib/types/host/fs-service.d.ts.map +1 -0
- package/lib/types/host/gate.d.ts +45 -0
- package/lib/types/host/gate.d.ts.map +1 -0
- package/lib/types/host/git-runner.d.ts +80 -0
- package/lib/types/host/git-runner.d.ts.map +1 -0
- package/lib/types/host/git-service.d.ts +130 -0
- package/lib/types/host/git-service.d.ts.map +1 -0
- package/lib/types/host/loopback.d.ts +25 -0
- package/lib/types/host/loopback.d.ts.map +1 -0
- package/lib/types/host/poll-guard.d.ts +55 -0
- package/lib/types/host/poll-guard.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +54 -0
- package/lib/types/host/routes.d.ts.map +1 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/package.json +85 -0
- package/src/client/FileManagerSettingsCard.tsx +117 -0
- package/src/client/PluginSettingsCard.tsx +337 -0
- package/src/client/api.ts +141 -0
- package/src/client/chat/file-ref.ts +144 -0
- package/src/client/chat/mermaid-chat.tsx +102 -0
- package/src/client/chat/placeholder-hint.tsx +46 -0
- package/src/client/components/ExplorerPanel.tsx +534 -0
- package/src/client/components/FileIcon.tsx +46 -0
- package/src/client/components/ScmPanel.tsx +480 -0
- package/src/client/components/a11y.ts +20 -0
- package/src/client/components/icons.tsx +274 -0
- package/src/client/components/overlay.tsx +225 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/drag/DragFileInlay.tsx +89 -0
- package/src/client/drag/file-drag.ts +67 -0
- package/src/client/drag.ts +154 -0
- package/src/client/file-source.ts +58 -0
- package/src/client/fileType.ts +129 -0
- package/src/client/floating.ts +63 -0
- package/src/client/hooks/useResizableSplit.ts +91 -0
- package/src/client/hooks/useStore.ts +15 -0
- package/src/client/index.ts +389 -0
- package/src/client/layout.ts +546 -0
- package/src/client/locales.ts +247 -0
- package/src/client/maximize.ts +34 -0
- package/src/client/mount.tsx +74 -0
- package/src/client/persist.ts +194 -0
- package/src/client/picker/FilePickerModal.tsx +506 -0
- package/src/client/picker/file-picker.ts +86 -0
- package/src/client/preview/PreviewPanel.tsx +233 -0
- package/src/client/preview/PreviewTabs.tsx +159 -0
- package/src/client/preview/PreviewToolbar.tsx +160 -0
- package/src/client/preview/content.tsx +617 -0
- package/src/client/preview/markdown.ts +351 -0
- package/src/client/preview/mermaid.ts +274 -0
- package/src/client/preview/selection.ts +82 -0
- package/src/client/reference.ts +161 -0
- package/src/client/settings-card.module.css +316 -0
- package/src/client/settings-form.ts +451 -0
- package/src/client/store.ts +1290 -0
- package/src/client/styles/chip.module.css +38 -0
- package/src/client/styles/drag.module.css +30 -0
- package/src/client/styles/explorer.module.css +371 -0
- package/src/client/styles/picker.module.css +344 -0
- package/src/client/styles/preview.module.css +904 -0
- package/src/client/styles/scm.module.css +380 -0
- package/src/client/styles/tokens.module.css +480 -0
- package/src/core/types.ts +162 -0
- package/src/host/fs-service.ts +513 -0
- package/src/host/gate.ts +74 -0
- package/src/host/git-runner.ts +116 -0
- package/src/host/git-service.ts +395 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/poll-guard.ts +109 -0
- package/src/host/routes.ts +695 -0
- package/src/index.ts +97 -0
- 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
|
+
}
|