@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.
Files changed (96) hide show
  1. package/LICENSE +38 -38
  2. package/README.i18n.yaml +6 -6
  3. package/README.md +66 -66
  4. package/README.zh.md +66 -66
  5. package/cordis.patch.yml +13 -13
  6. package/lib/client.js +27305 -270
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +474 -42
  9. package/lib/tsconfig.client.tsbuildinfo +1 -1
  10. package/lib/tsconfig.host.tsbuildinfo +1 -1
  11. package/lib/types/client/file-source.d.ts +10 -0
  12. package/lib/types/client/file-source.d.ts.map +1 -1
  13. package/lib/types/client/fileType.d.ts +19 -4
  14. package/lib/types/client/fileType.d.ts.map +1 -1
  15. package/lib/types/client/index.d.ts.map +1 -1
  16. package/lib/types/client/locales.d.ts +3 -0
  17. package/lib/types/client/locales.d.ts.map +1 -1
  18. package/lib/types/client/mount.d.ts +1 -1
  19. package/lib/types/client/mount.d.ts.map +1 -1
  20. package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
  21. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
  22. package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
  23. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
  24. package/lib/types/client/preview/content.d.ts +3 -1
  25. package/lib/types/client/preview/content.d.ts.map +1 -1
  26. package/lib/types/client/preview/office.d.ts +23 -0
  27. package/lib/types/client/preview/office.d.ts.map +1 -0
  28. package/lib/types/client/reference.d.ts +17 -0
  29. package/lib/types/client/reference.d.ts.map +1 -1
  30. package/lib/types/client/store.d.ts.map +1 -1
  31. package/lib/types/host/fs-service.d.ts.map +1 -1
  32. package/lib/types/host/office-preview.d.ts +13 -0
  33. package/lib/types/host/office-preview.d.ts.map +1 -0
  34. package/lib/types/host/routes.d.ts.map +1 -1
  35. package/lib/types/index.d.ts.map +1 -1
  36. package/package.json +19 -4
  37. package/src/client/FileManagerSettingsCard.tsx +117 -117
  38. package/src/client/PluginSettingsCard.tsx +337 -337
  39. package/src/client/chat/file-ref.ts +144 -144
  40. package/src/client/chat/mermaid-chat.tsx +102 -102
  41. package/src/client/chat/placeholder-hint.tsx +46 -46
  42. package/src/client/components/ExplorerPanel.tsx +534 -534
  43. package/src/client/components/FileIcon.tsx +46 -46
  44. package/src/client/components/ScmPanel.tsx +480 -480
  45. package/src/client/components/a11y.ts +20 -20
  46. package/src/client/components/icons.tsx +274 -274
  47. package/src/client/components/overlay.tsx +225 -225
  48. package/src/client/drag/DragFileInlay.tsx +89 -89
  49. package/src/client/drag/file-drag.ts +67 -67
  50. package/src/client/drag.ts +154 -154
  51. package/src/client/file-source.ts +34 -0
  52. package/src/client/fileType.ts +32 -5
  53. package/src/client/hooks/useResizableSplit.ts +91 -91
  54. package/src/client/hooks/useStore.ts +15 -15
  55. package/src/client/index.ts +42 -3
  56. package/src/client/layout.ts +523 -523
  57. package/src/client/locales.ts +6 -0
  58. package/src/client/maximize.ts +34 -34
  59. package/src/client/mount.tsx +2 -1
  60. package/src/client/persist.ts +194 -194
  61. package/src/client/picker/FilePickerModal.tsx +526 -526
  62. package/src/client/picker/file-picker.ts +86 -86
  63. package/src/client/preview/PreviewPanel.tsx +7 -1
  64. package/src/client/preview/PreviewTabs.tsx +159 -159
  65. package/src/client/preview/PreviewToolbar.tsx +3 -2
  66. package/src/client/preview/content.tsx +7 -2
  67. package/src/client/preview/markdown.ts +351 -351
  68. package/src/client/preview/mermaid.ts +274 -274
  69. package/src/client/preview/office.tsx +321 -0
  70. package/src/client/reference.ts +49 -0
  71. package/src/client/settings-card.module.css +316 -316
  72. package/src/client/settings-form.ts +451 -451
  73. package/src/client/store.ts +17 -12
  74. package/src/client/styles/drag.module.css +30 -30
  75. package/src/client/styles/explorer.module.css +371 -371
  76. package/src/client/styles/picker.module.css +344 -344
  77. package/src/client/styles/preview.module.css +3 -1
  78. package/src/client/styles/scm.module.css +380 -380
  79. package/src/client/styles/tokens.module.css +376 -371
  80. package/src/core/types.ts +162 -162
  81. package/src/host/fs-service.ts +7 -1
  82. package/src/host/gate.ts +73 -73
  83. package/src/host/git-runner.ts +116 -116
  84. package/src/host/git-service.ts +395 -395
  85. package/src/host/loopback.ts +63 -63
  86. package/src/host/office-preview.ts +399 -0
  87. package/src/host/poll-guard.ts +109 -109
  88. package/src/host/routes.ts +72 -1
  89. package/src/index.ts +3 -1
  90. package/src/mount-once.ts +48 -48
  91. package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
  92. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
  93. package/lib/types/client/floating.d.ts +0 -27
  94. package/lib/types/client/floating.d.ts.map +0 -1
  95. package/lib/types/client/mention.d.ts +0 -44
  96. package/lib/types/client/mention.d.ts.map +0 -1
@@ -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…',
@@ -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
+ }
@@ -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 () => {
@@ -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
+ }