@lijian-ui/dsh-file-manager 0.2.3 → 0.2.4
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 -38
- package/README.i18n.yaml +6 -6
- package/README.md +66 -66
- package/README.zh.md +66 -66
- package/cordis.patch.yml +13 -13
- package/lib/client.js +27305 -270
- package/lib/client.js.map +1 -1
- package/lib/index.js +474 -42
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.host.tsbuildinfo +1 -1
- package/lib/types/client/file-source.d.ts +10 -0
- package/lib/types/client/file-source.d.ts.map +1 -1
- package/lib/types/client/fileType.d.ts +19 -4
- package/lib/types/client/fileType.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +3 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/mount.d.ts +1 -1
- package/lib/types/client/mount.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
- package/lib/types/client/preview/content.d.ts +3 -1
- package/lib/types/client/preview/content.d.ts.map +1 -1
- package/lib/types/client/preview/office.d.ts +23 -0
- package/lib/types/client/preview/office.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +17 -0
- package/lib/types/client/reference.d.ts.map +1 -1
- package/lib/types/client/store.d.ts.map +1 -1
- package/lib/types/host/fs-service.d.ts.map +1 -1
- package/lib/types/host/office-preview.d.ts +13 -0
- package/lib/types/host/office-preview.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +19 -4
- package/src/client/FileManagerSettingsCard.tsx +117 -117
- package/src/client/PluginSettingsCard.tsx +337 -337
- package/src/client/chat/file-ref.ts +144 -144
- package/src/client/chat/mermaid-chat.tsx +102 -102
- package/src/client/chat/placeholder-hint.tsx +46 -46
- package/src/client/components/ExplorerPanel.tsx +534 -534
- package/src/client/components/FileIcon.tsx +46 -46
- package/src/client/components/ScmPanel.tsx +480 -480
- package/src/client/components/a11y.ts +20 -20
- package/src/client/components/icons.tsx +274 -274
- package/src/client/components/overlay.tsx +225 -225
- package/src/client/drag/DragFileInlay.tsx +89 -89
- package/src/client/drag/file-drag.ts +67 -67
- package/src/client/drag.ts +154 -154
- package/src/client/file-source.ts +34 -0
- package/src/client/fileType.ts +32 -5
- package/src/client/hooks/useResizableSplit.ts +91 -91
- package/src/client/hooks/useStore.ts +15 -15
- package/src/client/index.ts +42 -3
- package/src/client/layout.ts +523 -523
- package/src/client/locales.ts +6 -0
- package/src/client/maximize.ts +34 -34
- package/src/client/mount.tsx +2 -1
- package/src/client/persist.ts +194 -194
- package/src/client/picker/FilePickerModal.tsx +526 -526
- package/src/client/picker/file-picker.ts +86 -86
- package/src/client/preview/PreviewPanel.tsx +7 -1
- package/src/client/preview/PreviewTabs.tsx +159 -159
- package/src/client/preview/PreviewToolbar.tsx +3 -2
- package/src/client/preview/content.tsx +7 -2
- package/src/client/preview/markdown.ts +351 -351
- package/src/client/preview/mermaid.ts +274 -274
- package/src/client/preview/office.tsx +321 -0
- package/src/client/reference.ts +49 -0
- package/src/client/settings-card.module.css +316 -316
- package/src/client/settings-form.ts +451 -451
- package/src/client/store.ts +17 -12
- package/src/client/styles/drag.module.css +30 -30
- package/src/client/styles/explorer.module.css +371 -371
- package/src/client/styles/picker.module.css +344 -344
- package/src/client/styles/preview.module.css +3 -1
- package/src/client/styles/scm.module.css +380 -380
- package/src/client/styles/tokens.module.css +376 -371
- package/src/core/types.ts +162 -162
- package/src/host/fs-service.ts +7 -1
- package/src/host/gate.ts +73 -73
- package/src/host/git-runner.ts +116 -116
- package/src/host/git-service.ts +395 -395
- package/src/host/loopback.ts +63 -63
- package/src/host/office-preview.ts +399 -0
- package/src/host/poll-guard.ts +109 -109
- package/src/host/routes.ts +72 -1
- package/src/index.ts +3 -1
- package/src/mount-once.ts +48 -48
- package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
- package/lib/types/client/floating.d.ts +0 -27
- package/lib/types/client/floating.d.ts.map +0 -1
- package/lib/types/client/mention.d.ts +0 -44
- package/lib/types/client/mention.d.ts.map +0 -1
package/src/client/locales.ts
CHANGED
|
@@ -53,6 +53,7 @@ const zh = {
|
|
|
53
53
|
'preview.refresh.updated': '文件已在磁盘更新',
|
|
54
54
|
'preview.save': '保存',
|
|
55
55
|
'preview.download': '下载',
|
|
56
|
+
'preview.loading': '正在加载…',
|
|
56
57
|
'preview.copyCode': '复制代码',
|
|
57
58
|
'preview.copyCodeDone': '已复制',
|
|
58
59
|
'preview.openExternal': '在系统应用中打开',
|
|
@@ -64,6 +65,7 @@ const zh = {
|
|
|
64
65
|
'preview.closeConfirmTitle': '关闭未保存的标签页',
|
|
65
66
|
'preview.closeConfirmBody': '{count} 个标签页有未保存的更改,关闭将丢失这些更改。',
|
|
66
67
|
'preview.saved': '已保存',
|
|
68
|
+
'preview.saveFailed': '保存失败,请重试',
|
|
67
69
|
'preview.saveConflict': '文件已在磁盘上被修改,保存冲突:请刷新后重试',
|
|
68
70
|
'preview.errorOversized': '文件过大,仅加载前 80,000 字符',
|
|
69
71
|
'preview.unsupported': '此格式暂不支持预览',
|
|
@@ -116,6 +118,7 @@ const zh = {
|
|
|
116
118
|
|
|
117
119
|
// ── PreviewPanel.tsx ──
|
|
118
120
|
'preview.addToChat': '添加到对话',
|
|
121
|
+
'preview.addSheetSelection': '引用到对话',
|
|
119
122
|
|
|
120
123
|
// ── FilePickerModal.tsx ──
|
|
121
124
|
'picker.title': '引用项目文件',
|
|
@@ -177,6 +180,7 @@ const en: Record<keyof typeof zh, string> = {
|
|
|
177
180
|
'preview.refresh.updated': 'File updated on disk',
|
|
178
181
|
'preview.save': 'Save',
|
|
179
182
|
'preview.download': 'Download',
|
|
183
|
+
'preview.loading': 'Loading…',
|
|
180
184
|
'preview.copyCode': 'Copy code',
|
|
181
185
|
'preview.copyCodeDone': 'Copied',
|
|
182
186
|
'preview.openExternal': 'Open in system app',
|
|
@@ -188,6 +192,7 @@ const en: Record<keyof typeof zh, string> = {
|
|
|
188
192
|
'preview.closeConfirmTitle': 'Close unsaved tabs',
|
|
189
193
|
'preview.closeConfirmBody': '{count} tabs have unsaved changes. Closing will lose them.',
|
|
190
194
|
'preview.saved': 'Saved',
|
|
195
|
+
'preview.saveFailed': 'Save failed, please retry',
|
|
191
196
|
'preview.saveConflict': 'File changed on disk. Save conflict: refresh and retry',
|
|
192
197
|
'preview.errorOversized': 'File too large, only the first 80,000 characters loaded',
|
|
193
198
|
'preview.unsupported': 'Preview not supported for this format',
|
|
@@ -237,6 +242,7 @@ const en: Record<keyof typeof zh, string> = {
|
|
|
237
242
|
|
|
238
243
|
'dock.filePanel.label': 'File Panel',
|
|
239
244
|
'preview.addToChat': 'Add to Chat',
|
|
245
|
+
'preview.addSheetSelection': 'Reference to Chat',
|
|
240
246
|
'picker.title': 'Reference Project Files',
|
|
241
247
|
'picker.searchPlaceholder': 'Search workspace files…',
|
|
242
248
|
'picker.loading': 'Loading…',
|
package/src/client/maximize.ts
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Maximize-mode geometry (issue #315): when a panel is maximized the layout
|
|
3
|
-
* controller rewrites the frame grid so the target column takes the whole
|
|
4
|
-
* row (sidebar, details, chat and the other panel collapse to 0px tracks —
|
|
5
|
-
* everything stays mounted, nothing unmounts). On narrow viewports the
|
|
6
|
-
* maximized column instead leaves the grid and renders as a fixed
|
|
7
|
-
* full-screen overlay (mobile-friendly full-screen mode), so the shell's
|
|
8
|
-
* own narrow layout stays untouched behind it.
|
|
9
|
-
* @module dsh-filemgr/client/maximize
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { MaximizeTarget } from './store.ts'
|
|
13
|
-
|
|
14
|
-
/** Below this available row width the maximized panel renders as a fixed
|
|
15
|
-
* full-screen overlay instead of a grid takeover. */
|
|
16
|
-
export const MAXIMIZE_OVERLAY_BREAKPOINT_PX = 640
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* The five grid tracks while one panel is maximized: shell sidebar, chat,
|
|
20
|
-
* shell details and the other panel all collapse to 0px; the target column
|
|
21
|
-
* takes the whole measured frame width. Tracks are the same string shape the
|
|
22
|
-
* shell's own inline style uses (px + fr), so nothing else needs to change.
|
|
23
|
-
*/
|
|
24
|
-
export function maximizedGridTracks(target: MaximizeTarget, frameWidth: number): string {
|
|
25
|
-
const wide = `${Math.max(0, Math.round(frameWidth))}px`
|
|
26
|
-
return target === 'explorer'
|
|
27
|
-
? `0px 0px 0px 0px ${wide}`
|
|
28
|
-
: `0px 0px 0px ${wide} 0px`
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Whether the maximized panel should render as a fixed full-screen overlay. */
|
|
32
|
-
export function maximizedOverlay(availableWidth: number): boolean {
|
|
33
|
-
return availableWidth > 0 && availableWidth < MAXIMIZE_OVERLAY_BREAKPOINT_PX
|
|
34
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Maximize-mode geometry (issue #315): when a panel is maximized the layout
|
|
3
|
+
* controller rewrites the frame grid so the target column takes the whole
|
|
4
|
+
* row (sidebar, details, chat and the other panel collapse to 0px tracks —
|
|
5
|
+
* everything stays mounted, nothing unmounts). On narrow viewports the
|
|
6
|
+
* maximized column instead leaves the grid and renders as a fixed
|
|
7
|
+
* full-screen overlay (mobile-friendly full-screen mode), so the shell's
|
|
8
|
+
* own narrow layout stays untouched behind it.
|
|
9
|
+
* @module dsh-filemgr/client/maximize
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { MaximizeTarget } from './store.ts'
|
|
13
|
+
|
|
14
|
+
/** Below this available row width the maximized panel renders as a fixed
|
|
15
|
+
* full-screen overlay instead of a grid takeover. */
|
|
16
|
+
export const MAXIMIZE_OVERLAY_BREAKPOINT_PX = 640
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The five grid tracks while one panel is maximized: shell sidebar, chat,
|
|
20
|
+
* shell details and the other panel all collapse to 0px; the target column
|
|
21
|
+
* takes the whole measured frame width. Tracks are the same string shape the
|
|
22
|
+
* shell's own inline style uses (px + fr), so nothing else needs to change.
|
|
23
|
+
*/
|
|
24
|
+
export function maximizedGridTracks(target: MaximizeTarget, frameWidth: number): string {
|
|
25
|
+
const wide = `${Math.max(0, Math.round(frameWidth))}px`
|
|
26
|
+
return target === 'explorer'
|
|
27
|
+
? `0px 0px 0px 0px ${wide}`
|
|
28
|
+
: `0px 0px 0px ${wide} 0px`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether the maximized panel should render as a fixed full-screen overlay. */
|
|
32
|
+
export function maximizedOverlay(availableWidth: number): boolean {
|
|
33
|
+
return availableWidth > 0 && availableWidth < MAXIMIZE_OVERLAY_BREAKPOINT_PX
|
|
34
|
+
}
|
package/src/client/mount.tsx
CHANGED
|
@@ -67,6 +67,7 @@ export function mountPanels(
|
|
|
67
67
|
onToggleExplorer: () => void,
|
|
68
68
|
onReference: (path: string, isDir: boolean) => void,
|
|
69
69
|
onAddFileReference: (selection: FileReferenceSelection) => boolean,
|
|
70
|
+
onInsertSheetReference: (path: string, sheetName: string, range: string) => boolean,
|
|
70
71
|
locale: LocaleFace,
|
|
71
72
|
): () => void {
|
|
72
73
|
let explorerRoot: Root | undefined
|
|
@@ -79,7 +80,7 @@ export function mountPanels(
|
|
|
79
80
|
}))
|
|
80
81
|
disposers.push(waitForElement(PREVIEW_COL_SELECTOR, (el) => {
|
|
81
82
|
previewRoot = createRoot(el)
|
|
82
|
-
previewRoot.render(<LocaleAwareRoot locale={locale}><PreviewPanel stores={stores} onAddFileReference={onAddFileReference} /></LocaleAwareRoot>)
|
|
83
|
+
previewRoot.render(<LocaleAwareRoot locale={locale}><PreviewPanel stores={stores} onAddFileReference={onAddFileReference} onInsertSheetReference={onInsertSheetReference} /></LocaleAwareRoot>)
|
|
83
84
|
}))
|
|
84
85
|
|
|
85
86
|
return () => {
|
package/src/client/persist.ts
CHANGED
|
@@ -1,194 +1,194 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Persistence helpers for panel preferences: range-validated reads (invalid
|
|
3
|
-
* stored values fall back to defaults — a broken or hand-edited value must
|
|
4
|
-
* never produce a 0px or NaN panel), debounced writes, and the LRU registry
|
|
5
|
-
* for preview scopes (at most 12 scopes; the oldest savedAt evicts).
|
|
6
|
-
*
|
|
7
|
-
* Keys follow the FileManager contract verbatim:
|
|
8
|
-
* chat-workspace-width-px, chat-preview-width-px, preview-panel-split-ratio,
|
|
9
|
-
* project-panel-collapse:<root>, explorer-ui:<root>, scm-ui:<root>,
|
|
10
|
-
* preview-ui:<root>.
|
|
11
|
-
* @module dsh-filemgr/client/persist
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/** Read a stored number, validating it against [min, max]; fallback otherwise. */
|
|
15
|
-
export function readStoredNumber(key: string, min: number, max: number, fallback: number): number {
|
|
16
|
-
try {
|
|
17
|
-
const raw = localStorage.getItem(key)
|
|
18
|
-
if (raw === null) return fallback
|
|
19
|
-
const value = Number(raw)
|
|
20
|
-
if (!Number.isFinite(value)) return fallback
|
|
21
|
-
if (value < min || value > max) return fallback
|
|
22
|
-
return value
|
|
23
|
-
} catch {
|
|
24
|
-
return fallback
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Write a number if it differs from the stored value (avoids churn). */
|
|
29
|
-
export function writeStoredNumber(key: string, value: number): void {
|
|
30
|
-
try {
|
|
31
|
-
const raw = String(Math.round(value))
|
|
32
|
-
if (localStorage.getItem(key) === raw) return
|
|
33
|
-
localStorage.setItem(key, raw)
|
|
34
|
-
} catch {
|
|
35
|
-
// persistence is best-effort; the panel still works
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* A single debounce pipeline used by the stores for its search and persist
|
|
41
|
-
* timers: coalesces rapid schedules into one trailing run (the latest fn wins).
|
|
42
|
-
* `flush` runs the pending fn immediately (pagehide/beforeunload), `dispose`
|
|
43
|
-
* cancels a pending schedule. Behavior is equivalent to the stores' previous
|
|
44
|
-
* hand-rolled setTimeout + clearTimeout pairs, just centralized.
|
|
45
|
-
*/
|
|
46
|
-
export interface Debounced {
|
|
47
|
-
/** Queue a fn; repeated calls before the delay replaces the pending fn. */
|
|
48
|
-
schedule: (fn: () => void) => void
|
|
49
|
-
/** Run the pending fn now and clear the timer. */
|
|
50
|
-
flush: () => void
|
|
51
|
-
/** Cancel the pending fn and timer. */
|
|
52
|
-
dispose: () => void
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Create one debounced scheduler (default 150ms). */
|
|
56
|
-
export function createDebounced(delayMs = 150): Debounced {
|
|
57
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
58
|
-
let pending: (() => void) | null = null
|
|
59
|
-
const flush = (): void => {
|
|
60
|
-
if (timer !== undefined) clearTimeout(timer)
|
|
61
|
-
timer = undefined
|
|
62
|
-
const fn = pending
|
|
63
|
-
pending = null
|
|
64
|
-
if (fn !== null) fn()
|
|
65
|
-
}
|
|
66
|
-
return {
|
|
67
|
-
schedule(fn: () => void) {
|
|
68
|
-
pending = fn
|
|
69
|
-
// Reset on every schedule so the run trails the LAST change (the same
|
|
70
|
-
// trailing-edge semantics the stores' former clear+setTimeout had).
|
|
71
|
-
if (timer !== undefined) clearTimeout(timer)
|
|
72
|
-
timer = setTimeout(flush, delayMs)
|
|
73
|
-
},
|
|
74
|
-
flush,
|
|
75
|
-
dispose() {
|
|
76
|
-
if (timer !== undefined) clearTimeout(timer)
|
|
77
|
-
timer = undefined
|
|
78
|
-
pending = null
|
|
79
|
-
},
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** The preview-ui scope registry: keys, savedAt values, eviction. */
|
|
84
|
-
export const PREVIEW_SCOPE_PREFIX = 'preview-ui:'
|
|
85
|
-
/** LRU cap on distinct preview scopes. */
|
|
86
|
-
export const PREVIEW_SCOPE_CAP = 12
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Collect every stored key under a prefix. localStorage has no prefix index,
|
|
90
|
-
* so the whole store is swept once, then filtered to the package's own keys
|
|
91
|
-
* — enumeration is never interleaved with removal (removals would shift the
|
|
92
|
-
* indices mid-loop and skip entries).
|
|
93
|
-
*/
|
|
94
|
-
function listStoredKeysByPrefix(prefix: string): string[] {
|
|
95
|
-
const keys: string[] = []
|
|
96
|
-
try {
|
|
97
|
-
for (let i = 0; i < localStorage.length; i += 1) {
|
|
98
|
-
const key = localStorage.key(i)
|
|
99
|
-
if (key !== null && key.startsWith(prefix)) keys.push(key)
|
|
100
|
-
}
|
|
101
|
-
} catch {
|
|
102
|
-
return []
|
|
103
|
-
}
|
|
104
|
-
return keys
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Precisely delete every stored key under a prefix. Only this package's own
|
|
109
|
-
* prefixed keys are removed — foreign-application keys are never touched,
|
|
110
|
-
* replacing the former all-at-once `localStorage.clear()` sweep.
|
|
111
|
-
*/
|
|
112
|
-
export function removeStoredByPrefix(prefix: string): number {
|
|
113
|
-
const keys = listStoredKeysByPrefix(prefix)
|
|
114
|
-
let removed = 0
|
|
115
|
-
for (const key of keys) {
|
|
116
|
-
try {
|
|
117
|
-
localStorage.removeItem(key)
|
|
118
|
-
removed += 1
|
|
119
|
-
} catch {
|
|
120
|
-
// best-effort; a storage failure does not abort the rest
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return removed
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** All stored preview scopes with their savedAt timestamps, oldest first. */
|
|
127
|
-
export function listPreviewScopes(): Array<{ root: string; savedAt: number }> {
|
|
128
|
-
const out: Array<{ root: string; savedAt: number }> = []
|
|
129
|
-
for (const key of listStoredKeysByPrefix(PREVIEW_SCOPE_PREFIX)) {
|
|
130
|
-
const root = key.slice(PREVIEW_SCOPE_PREFIX.length)
|
|
131
|
-
let savedAt = 0
|
|
132
|
-
try {
|
|
133
|
-
const raw = localStorage.getItem(key)
|
|
134
|
-
if (raw !== null) {
|
|
135
|
-
const parsed = JSON.parse(raw) as { savedAt?: unknown }
|
|
136
|
-
if (typeof parsed.savedAt === 'number') savedAt = parsed.savedAt
|
|
137
|
-
}
|
|
138
|
-
} catch {
|
|
139
|
-
savedAt = 0
|
|
140
|
-
}
|
|
141
|
-
out.push({ root, savedAt })
|
|
142
|
-
}
|
|
143
|
-
out.sort((a, b) => a.savedAt - b.savedAt)
|
|
144
|
-
return out
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Evict the oldest scopes beyond the cap. */
|
|
148
|
-
export function evictPreviewScopes(keep: string): void {
|
|
149
|
-
// Cheap pre-check first: enumerate keys only (no JSON.parse, no sort).
|
|
150
|
-
// Writes far below the cap — the common case — return here instead of
|
|
151
|
-
// parsing and sorting every stored preview scope on each persist.
|
|
152
|
-
if (listStoredKeysByPrefix(PREVIEW_SCOPE_PREFIX).length <= PREVIEW_SCOPE_CAP) return
|
|
153
|
-
const scopes = listPreviewScopes().filter((scope) => scope.root !== keep)
|
|
154
|
-
let excess = scopes.length - (PREVIEW_SCOPE_CAP - 1)
|
|
155
|
-
for (const scope of scopes) {
|
|
156
|
-
if (excess <= 0) break
|
|
157
|
-
try {
|
|
158
|
-
localStorage.removeItem(`${PREVIEW_SCOPE_PREFIX}${scope.root}`)
|
|
159
|
-
} catch {
|
|
160
|
-
// best-effort
|
|
161
|
-
}
|
|
162
|
-
excess -= 1
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Serialize a JSON value with a size guard (quota failures degrade silently). */
|
|
167
|
-
export function writeJson(key: string, value: unknown): boolean {
|
|
168
|
-
try {
|
|
169
|
-
localStorage.setItem(key, JSON.stringify(value))
|
|
170
|
-
return true
|
|
171
|
-
} catch {
|
|
172
|
-
try {
|
|
173
|
-
localStorage.removeItem(key)
|
|
174
|
-
} catch {
|
|
175
|
-
// storage unavailable entirely
|
|
176
|
-
}
|
|
177
|
-
return false
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Parse a stored JSON value; fallback on any failure. */
|
|
182
|
-
export function readJson<T>(key: string, fallback: T): T {
|
|
183
|
-
try {
|
|
184
|
-
const raw = localStorage.getItem(key)
|
|
185
|
-
if (raw === null) return fallback
|
|
186
|
-
const parsed = JSON.parse(raw) as unknown
|
|
187
|
-
// JSON.parse('null') yields null without throwing; descending callers
|
|
188
|
-
// expect an object and would throw on null/primitive, killing their effect.
|
|
189
|
-
if (parsed === null || typeof parsed !== 'object') return fallback
|
|
190
|
-
return parsed as T
|
|
191
|
-
} catch {
|
|
192
|
-
return fallback
|
|
193
|
-
}
|
|
194
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Persistence helpers for panel preferences: range-validated reads (invalid
|
|
3
|
+
* stored values fall back to defaults — a broken or hand-edited value must
|
|
4
|
+
* never produce a 0px or NaN panel), debounced writes, and the LRU registry
|
|
5
|
+
* for preview scopes (at most 12 scopes; the oldest savedAt evicts).
|
|
6
|
+
*
|
|
7
|
+
* Keys follow the FileManager contract verbatim:
|
|
8
|
+
* chat-workspace-width-px, chat-preview-width-px, preview-panel-split-ratio,
|
|
9
|
+
* project-panel-collapse:<root>, explorer-ui:<root>, scm-ui:<root>,
|
|
10
|
+
* preview-ui:<root>.
|
|
11
|
+
* @module dsh-filemgr/client/persist
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Read a stored number, validating it against [min, max]; fallback otherwise. */
|
|
15
|
+
export function readStoredNumber(key: string, min: number, max: number, fallback: number): number {
|
|
16
|
+
try {
|
|
17
|
+
const raw = localStorage.getItem(key)
|
|
18
|
+
if (raw === null) return fallback
|
|
19
|
+
const value = Number(raw)
|
|
20
|
+
if (!Number.isFinite(value)) return fallback
|
|
21
|
+
if (value < min || value > max) return fallback
|
|
22
|
+
return value
|
|
23
|
+
} catch {
|
|
24
|
+
return fallback
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Write a number if it differs from the stored value (avoids churn). */
|
|
29
|
+
export function writeStoredNumber(key: string, value: number): void {
|
|
30
|
+
try {
|
|
31
|
+
const raw = String(Math.round(value))
|
|
32
|
+
if (localStorage.getItem(key) === raw) return
|
|
33
|
+
localStorage.setItem(key, raw)
|
|
34
|
+
} catch {
|
|
35
|
+
// persistence is best-effort; the panel still works
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A single debounce pipeline used by the stores for its search and persist
|
|
41
|
+
* timers: coalesces rapid schedules into one trailing run (the latest fn wins).
|
|
42
|
+
* `flush` runs the pending fn immediately (pagehide/beforeunload), `dispose`
|
|
43
|
+
* cancels a pending schedule. Behavior is equivalent to the stores' previous
|
|
44
|
+
* hand-rolled setTimeout + clearTimeout pairs, just centralized.
|
|
45
|
+
*/
|
|
46
|
+
export interface Debounced {
|
|
47
|
+
/** Queue a fn; repeated calls before the delay replaces the pending fn. */
|
|
48
|
+
schedule: (fn: () => void) => void
|
|
49
|
+
/** Run the pending fn now and clear the timer. */
|
|
50
|
+
flush: () => void
|
|
51
|
+
/** Cancel the pending fn and timer. */
|
|
52
|
+
dispose: () => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Create one debounced scheduler (default 150ms). */
|
|
56
|
+
export function createDebounced(delayMs = 150): Debounced {
|
|
57
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
58
|
+
let pending: (() => void) | null = null
|
|
59
|
+
const flush = (): void => {
|
|
60
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
61
|
+
timer = undefined
|
|
62
|
+
const fn = pending
|
|
63
|
+
pending = null
|
|
64
|
+
if (fn !== null) fn()
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
schedule(fn: () => void) {
|
|
68
|
+
pending = fn
|
|
69
|
+
// Reset on every schedule so the run trails the LAST change (the same
|
|
70
|
+
// trailing-edge semantics the stores' former clear+setTimeout had).
|
|
71
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
72
|
+
timer = setTimeout(flush, delayMs)
|
|
73
|
+
},
|
|
74
|
+
flush,
|
|
75
|
+
dispose() {
|
|
76
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
77
|
+
timer = undefined
|
|
78
|
+
pending = null
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The preview-ui scope registry: keys, savedAt values, eviction. */
|
|
84
|
+
export const PREVIEW_SCOPE_PREFIX = 'preview-ui:'
|
|
85
|
+
/** LRU cap on distinct preview scopes. */
|
|
86
|
+
export const PREVIEW_SCOPE_CAP = 12
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Collect every stored key under a prefix. localStorage has no prefix index,
|
|
90
|
+
* so the whole store is swept once, then filtered to the package's own keys
|
|
91
|
+
* — enumeration is never interleaved with removal (removals would shift the
|
|
92
|
+
* indices mid-loop and skip entries).
|
|
93
|
+
*/
|
|
94
|
+
function listStoredKeysByPrefix(prefix: string): string[] {
|
|
95
|
+
const keys: string[] = []
|
|
96
|
+
try {
|
|
97
|
+
for (let i = 0; i < localStorage.length; i += 1) {
|
|
98
|
+
const key = localStorage.key(i)
|
|
99
|
+
if (key !== null && key.startsWith(prefix)) keys.push(key)
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
return []
|
|
103
|
+
}
|
|
104
|
+
return keys
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Precisely delete every stored key under a prefix. Only this package's own
|
|
109
|
+
* prefixed keys are removed — foreign-application keys are never touched,
|
|
110
|
+
* replacing the former all-at-once `localStorage.clear()` sweep.
|
|
111
|
+
*/
|
|
112
|
+
export function removeStoredByPrefix(prefix: string): number {
|
|
113
|
+
const keys = listStoredKeysByPrefix(prefix)
|
|
114
|
+
let removed = 0
|
|
115
|
+
for (const key of keys) {
|
|
116
|
+
try {
|
|
117
|
+
localStorage.removeItem(key)
|
|
118
|
+
removed += 1
|
|
119
|
+
} catch {
|
|
120
|
+
// best-effort; a storage failure does not abort the rest
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return removed
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** All stored preview scopes with their savedAt timestamps, oldest first. */
|
|
127
|
+
export function listPreviewScopes(): Array<{ root: string; savedAt: number }> {
|
|
128
|
+
const out: Array<{ root: string; savedAt: number }> = []
|
|
129
|
+
for (const key of listStoredKeysByPrefix(PREVIEW_SCOPE_PREFIX)) {
|
|
130
|
+
const root = key.slice(PREVIEW_SCOPE_PREFIX.length)
|
|
131
|
+
let savedAt = 0
|
|
132
|
+
try {
|
|
133
|
+
const raw = localStorage.getItem(key)
|
|
134
|
+
if (raw !== null) {
|
|
135
|
+
const parsed = JSON.parse(raw) as { savedAt?: unknown }
|
|
136
|
+
if (typeof parsed.savedAt === 'number') savedAt = parsed.savedAt
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
savedAt = 0
|
|
140
|
+
}
|
|
141
|
+
out.push({ root, savedAt })
|
|
142
|
+
}
|
|
143
|
+
out.sort((a, b) => a.savedAt - b.savedAt)
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Evict the oldest scopes beyond the cap. */
|
|
148
|
+
export function evictPreviewScopes(keep: string): void {
|
|
149
|
+
// Cheap pre-check first: enumerate keys only (no JSON.parse, no sort).
|
|
150
|
+
// Writes far below the cap — the common case — return here instead of
|
|
151
|
+
// parsing and sorting every stored preview scope on each persist.
|
|
152
|
+
if (listStoredKeysByPrefix(PREVIEW_SCOPE_PREFIX).length <= PREVIEW_SCOPE_CAP) return
|
|
153
|
+
const scopes = listPreviewScopes().filter((scope) => scope.root !== keep)
|
|
154
|
+
let excess = scopes.length - (PREVIEW_SCOPE_CAP - 1)
|
|
155
|
+
for (const scope of scopes) {
|
|
156
|
+
if (excess <= 0) break
|
|
157
|
+
try {
|
|
158
|
+
localStorage.removeItem(`${PREVIEW_SCOPE_PREFIX}${scope.root}`)
|
|
159
|
+
} catch {
|
|
160
|
+
// best-effort
|
|
161
|
+
}
|
|
162
|
+
excess -= 1
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Serialize a JSON value with a size guard (quota failures degrade silently). */
|
|
167
|
+
export function writeJson(key: string, value: unknown): boolean {
|
|
168
|
+
try {
|
|
169
|
+
localStorage.setItem(key, JSON.stringify(value))
|
|
170
|
+
return true
|
|
171
|
+
} catch {
|
|
172
|
+
try {
|
|
173
|
+
localStorage.removeItem(key)
|
|
174
|
+
} catch {
|
|
175
|
+
// storage unavailable entirely
|
|
176
|
+
}
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Parse a stored JSON value; fallback on any failure. */
|
|
182
|
+
export function readJson<T>(key: string, fallback: T): T {
|
|
183
|
+
try {
|
|
184
|
+
const raw = localStorage.getItem(key)
|
|
185
|
+
if (raw === null) return fallback
|
|
186
|
+
const parsed = JSON.parse(raw) as unknown
|
|
187
|
+
// JSON.parse('null') yields null without throwing; descending callers
|
|
188
|
+
// expect an object and would throw on null/primitive, killing their effect.
|
|
189
|
+
if (parsed === null || typeof parsed !== 'object') return fallback
|
|
190
|
+
return parsed as T
|
|
191
|
+
} catch {
|
|
192
|
+
return fallback
|
|
193
|
+
}
|
|
194
|
+
}
|