dsh-file-activity 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -46,495 +46,669 @@ window.__ModuleLoader__.load({
46
46
 
47
47
  // ── parts (injected by scripts/build.mjs; keep this exact order — the
48
48
  // const initializers below run in splice order) ─────────────────────
49
- // ── i18n ──────────────────────────────────────────────────────────────
50
- function isZh() {
51
- try {
52
- const lang = (navigator.language || 'en').toLowerCase()
53
- return lang.startsWith('zh')
54
- } catch {
55
- return false
56
- }
57
- }
58
-
59
- const strings = {
60
- title: () => (isZh() ? '文件活动' : 'File Activity'),
61
- recent: () => (isZh() ? '最近访问' : 'Recent'),
62
- stats: () => (isZh() ? '文件统计' : 'File Stats'),
63
- empty: () => (isZh() ? '暂无文件活动记录' : 'No file activity yet'),
64
- emptyHint: () => (isZh()
65
- ? '在侧边栏打开文件、编辑保存,或让 agent 读写文件(创建/读取/修改),都会记录在这里。点击任意文件将在侧边栏内用原生预览打开(代码高亮 / Markdown 渲染 / 图片 / PDF…)。'
66
- : 'Opening files in the sidebar, editing, or agent file operations (create/read/modify) are recorded here. Click any file to open it in the sidebar with native preview (syntax highlighting / Markdown rendering / images / PDF…).'),
67
- refresh: () => (isZh() ? '刷新' : 'Refresh'),
68
- clear: () => (isZh() ? '清空' : 'Clear'),
69
- clearConfirm: () => (isZh() ? '确定清空当前会话的全部文件活动记录?' : 'Clear all file activity for this session?'),
70
- read: () => (isZh() ? '读取' : 'read'),
71
- create: () => (isZh() ? '新增' : 'create'),
72
- modify: () => (isZh() ? '修改' : 'modify'),
73
- delete: () => (isZh() ? '删除' : 'delete'),
74
- readShort: () => (isZh() ? '读' : 'R'),
75
- createShort: () => (isZh() ? '增' : 'C'),
76
- modifyShort: () => (isZh() ? '改' : 'M'),
77
- loadError: () => (isZh() ? '加载失败' : 'Load failed'),
78
- created: () => (isZh() ? '创建' : 'Created'),
79
- lastSeen: () => (isZh() ? '最近访问' : 'Last seen'),
80
- justNow: () => (isZh() ? '刚刚' : 'just now'),
81
- minutesAgo: (m) => (isZh() ? `${m} 分钟前` : `${m}m ago`),
82
- hoursAgo: (h) => (isZh() ? `${h} 小时前` : `${h}h ago`),
83
- daysAgo: (d) => (isZh() ? `${d} 天前` : `${d}d ago`),
84
- closePreview: () => (isZh() ? '关闭预览' : 'Close preview'),
85
- loading: () => (isZh() ? '加载中…' : 'Loading…'),
86
- previewUnsupported: () => (isZh() ? '该文件类型暂不支持预览' : 'This file type cannot be previewed yet'),
87
- previewFailed: () => (isZh() ? '预览加载失败' : 'Preview failed to load'),
88
- downloadToView: () => (isZh() ? '下载查看' : 'download to view'),
89
- }
90
-
91
- // ── path / time formatting helpers ────────────────────────────────────
92
- function basenameOf(path) {
93
- const norm = path.split('\\').join('/')
94
- const idx = norm.lastIndexOf('/')
95
- return idx === -1 ? norm : norm.slice(idx + 1)
96
- }
97
-
98
- /** Compact relative time: 刚刚 / N 分钟前 / N 小时前 / N 天前 / MM/DD. */
99
- function formatRelative(time) {
100
- if (typeof time !== 'number' || !Number.isFinite(time)) return ''
101
- const diff = Date.now() - time
102
- if (diff < 30_000) return strings.justNow()
103
- const minutes = Math.floor(diff / 60_000)
104
- if (minutes < 60) return strings.minutesAgo(minutes)
105
- const hours = Math.floor(minutes / 60)
106
- if (hours < 24) return strings.hoursAgo(hours)
107
- const days = Math.floor(hours / 24)
108
- if (days < 7) return strings.daysAgo(days)
109
- const date = new Date(time)
110
- return `${date.getMonth() + 1}/${date.getDate()}`
111
- }
112
-
113
- /** Local wall-clock HH:MM:SS (used in tooltips; full precision). */
114
- function formatTime(time) {
115
- const date = new Date(time)
116
- const pad = (n) => String(n).padStart(2, '0')
117
- return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
118
- }
119
-
120
- // ── directory tree construction ───────────────────────────────────────
121
- /**
122
- * Collapse chain directories: a directory whose only child is another
123
- * directory merges into it (a → a.b → a.b.c …). Deep single-child paths
124
- * render as one dotted label with the file(s) directly beneath.
125
- * `root` itself is never collapsed (its name is '' and would drop the
126
- * top-level directory).
127
- */
128
- function compressChains(node, isRoot) {
129
- for (const child of node.children) {
130
- if (child.type === 'dir') compressChains(child, false)
131
- }
132
- if (isRoot) return
133
- while (node.children.length === 1 && node.children[0].type === 'dir') {
134
- const only = node.children[0]
135
- node.name = `${node.name}.${only.name}`
136
- node.children = only.children
137
- node.compressed = true
138
- }
139
- }
140
-
141
- /**
142
- * Sort a directory node: directories first (alphabetically), then files
143
- * (by total activity, then name); recurse into directories.
144
- */
145
- function sortNode(node) {
146
- node.children.sort((a, b) => {
147
- if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
148
- if (a.type === 'dir') return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
149
- const ta = a.read + a.create + a.modify
150
- const tb = b.read + b.create + b.modify
151
- return tb - ta || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
152
- })
153
- for (const child of node.children) {
154
- if (child.type === 'dir') sortNode(child)
155
- }
156
- }
157
-
158
- /**
159
- * Build a nested directory tree from per-file counts, keyed by the file's
160
- * absolute path. Every directory node aggregates its subtree counters and
161
- * sorts directories first (alphabetically), then files (by activity).
162
- */
163
- function buildTree(counts) {
164
- const root = { type: 'dir', name: '', path: '', children: [], read: 0, create: 0, modify: 0 }
165
- for (const [abs, counter] of Object.entries(counts)) {
166
- const parts = abs.split('/').filter((part) => part !== '')
167
- if (parts.length === 0) continue
168
- const name = parts[parts.length - 1]
169
- let node = root
170
- for (const dir of parts.slice(0, -1)) {
171
- let child = node.children.find((c) => c.type === 'dir' && c.name === dir)
172
- if (child === undefined) {
173
- child = { type: 'dir', name: dir, path: `${node.path}/${dir}`, children: [], read: 0, create: 0, modify: 0 }
174
- node.children.push(child)
175
- }
176
- node = child
177
- node.read += counter.read
178
- node.create += counter.create
179
- node.modify += counter.modify
180
- }
181
- node.children.push({
182
- type: 'file', name, abs,
183
- read: counter.read, create: counter.create, modify: counter.modify,
184
- firstSeen: counter.firstSeen, lastSeen: counter.lastSeen,
185
- })
186
- }
187
- sortNode(root)
188
- compressChains(root, true)
189
- return root
190
- }
191
-
192
- // ── tiny external store ───────────────────────────────────────────────
193
- function createStore(initial) {
194
- let state = initial
195
- const listeners = new Set()
196
- return {
197
- getSnapshot: () => state,
198
- set(patch) {
199
- state = { ...state, ...patch }
200
- for (const listener of [...listeners]) listener()
201
- },
202
- subscribe(listener) {
203
- listeners.add(listener)
204
- return () => listeners.delete(listener)
205
- },
206
- }
207
- }
208
-
209
- // ── data access (host routes) ─────────────────────────────────────────
210
- async function fetchStats(sessionId) {
211
- const response = await fetch(`/file-activity/api/stats?sessionId=${encodeURIComponent(sessionId)}`)
212
- const json = await response.json()
213
- if (json === null || typeof json !== 'object' || json.ok !== true) return null
214
- return json.value
215
- }
216
-
217
- /** Resolve the session working directory through the sidebar's native API. */
218
- async function fetchSessionCwd(sessionId) {
219
- try {
220
- const response = await fetch('/sidebar/api/session.cwd', {
221
- method: 'POST',
222
- headers: { 'content-type': 'application/json' },
223
- body: JSON.stringify({ sessionId }),
224
- })
225
- const json = await response.json()
226
- const cwd = json?.value?.cwd
227
- return typeof cwd === 'string' && cwd !== '' ? cwd : ''
228
- } catch {
229
- return ''
230
- }
231
- }
232
-
233
- function postRecord(sessionId, path, op) {
234
- if (typeof sessionId !== 'string' || sessionId === '' || typeof path !== 'string' || path === '') return
235
- void fetch('/file-activity/api/record', {
236
- method: 'POST',
237
- headers: { 'content-type': 'application/json' },
238
- body: JSON.stringify({ sessionId, path, op }),
239
- }).catch(() => {})
240
- }
241
-
242
- function postClear(sessionId) {
243
- void fetch('/file-activity/api/clear', {
244
- method: 'POST',
245
- headers: { 'content-type': 'application/json' },
246
- body: JSON.stringify({ sessionId }),
247
- }).catch(() => {})
248
- }
249
-
250
- /** Plugin media route URL for a recorded path (authorized per session). */
251
- function mediaUrlOf(sessionId, path) {
252
- return `/file-activity/file?${new URLSearchParams({ sessionId, path })}`
253
- }
254
-
255
- // ── fetch interception: sidebar file operations ───────────────────────
256
- function methodOf(init) {
257
- return (init?.method ?? 'GET').toUpperCase()
258
- }
259
-
260
- /** POST body as a plain object (non-string bodies are ignored). */
261
- function parseBody(init) {
262
- return typeof init?.body === 'string' ? JSON.parse(init.body) : {}
263
- }
264
-
265
- /** Record fs.read / fs.write POSTs observed on the sidebar API. */
266
- function recordSidebarFs(url, init) {
267
- if (url.pathname !== '/sidebar/api/fs.read' && url.pathname !== '/sidebar/api/fs.write') return
268
- if (methodOf(init) !== 'POST') return
269
- const body = parseBody(init)
270
- if (typeof body.sessionId !== 'string' || typeof body.path !== 'string') return
271
- postRecord(body.sessionId, body.path, url.pathname === '/sidebar/api/fs.write' ? 'write' : 'read')
272
- }
273
-
274
- /** Record sidebar media opens (/sidebar/file?sessionId=...&path=...). */
275
- function recordMediaOpen(url, init) {
276
- if (url.pathname !== '/sidebar/file' || methodOf(init) !== 'GET') return
277
- const sessionId = url.searchParams.get('sessionId')
278
- const path = url.searchParams.get('path')
279
- if (sessionId !== null && path !== null) postRecord(sessionId, path, 'read')
280
- }
281
-
282
- /** Observe a resolved fetch URL and record sidebar file operations. */
283
- function observeSidebarFetch(url, init) {
284
- try {
285
- recordSidebarFs(url, init)
286
- recordMediaOpen(url, init)
287
- } catch {
288
- // observation must never break the underlying call
289
- }
290
- }
49
+ // ── i18n ──────────────────────────────────────────────────────────────
50
+ function isZh() {
51
+ try {
52
+ const lang = (navigator.language || 'en').toLowerCase()
53
+ return lang.startsWith('zh')
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ const strings = {
60
+ title: () => (isZh() ? '文件活动' : 'File Activity'),
61
+ recent: () => (isZh() ? '最近访问' : 'Recent'),
62
+ stats: () => (isZh() ? '文件统计' : 'File Stats'),
63
+ empty: () => (isZh() ? '暂无文件活动记录' : 'No file activity yet'),
64
+ emptyHint: () =>
65
+ isZh()
66
+ ? '在侧边栏打开文件、编辑保存,或让 agent 读写文件(创建/读取/修改),都会记录在这里。点击任意文件将在侧边栏内用原生预览打开(代码高亮 / Markdown 渲染 / 图片 / PDF…)。'
67
+ : 'Opening files in the sidebar, editing, or agent file operations (create/read/modify) are recorded here. Click any file to open it in the sidebar with native preview (syntax highlighting / Markdown rendering / images / PDF…).',
68
+ refresh: () => (isZh() ? '刷新' : 'Refresh'),
69
+ clear: () => (isZh() ? '清空' : 'Clear'),
70
+ clearConfirm: () => (isZh() ? '确定清空当前会话的全部文件活动记录?' : 'Clear all file activity for this session?'),
71
+ read: () => (isZh() ? '读取' : 'read'),
72
+ create: () => (isZh() ? '新增' : 'create'),
73
+ modify: () => (isZh() ? '修改' : 'modify'),
74
+ delete: () => (isZh() ? '删除' : 'delete'),
75
+ readShort: () => (isZh() ? '读' : 'R'),
76
+ createShort: () => (isZh() ? '增' : 'C'),
77
+ modifyShort: () => (isZh() ? '改' : 'M'),
78
+ loadError: () => (isZh() ? '加载失败' : 'Load failed'),
79
+ created: () => (isZh() ? '创建' : 'Created'),
80
+ lastSeen: () => (isZh() ? '最近访问' : 'Last seen'),
81
+ justNow: () => (isZh() ? '刚刚' : 'just now'),
82
+ minutesAgo: (m) => (isZh() ? `${m} 分钟前` : `${m}m ago`),
83
+ hoursAgo: (h) => (isZh() ? `${h} 小时前` : `${h}h ago`),
84
+ daysAgo: (d) => (isZh() ? `${d} 天前` : `${d}d ago`),
85
+ closePreview: () => (isZh() ? '关闭预览' : 'Close preview'),
86
+ loading: () => (isZh() ? '加载中…' : 'Loading…'),
87
+ previewUnsupported: () => (isZh() ? '该文件类型暂不支持预览' : 'This file type cannot be previewed yet'),
88
+ previewFailed: () => (isZh() ? '预览加载失败' : 'Preview failed to load'),
89
+ downloadToView: () => (isZh() ? '下载查看' : 'download to view'),
90
+ }
91
+
92
+ // ── path / time formatting helpers ────────────────────────────────────
93
+ function basenameOf(path) {
94
+ const norm = path.split('\\').join('/')
95
+ const idx = norm.lastIndexOf('/')
96
+ return idx === -1 ? norm : norm.slice(idx + 1)
97
+ }
98
+
99
+ /** Compact relative time: 刚刚 / N 分钟前 / N 小时前 / N 天前 / MM/DD. */
100
+ function formatRelative(time) {
101
+ if (typeof time !== 'number' || !Number.isFinite(time)) return ''
102
+ const diff = Date.now() - time
103
+ if (diff < 30_000) return strings.justNow()
104
+ const minutes = Math.floor(diff / 60_000)
105
+ if (minutes < 60) return strings.minutesAgo(minutes)
106
+ const hours = Math.floor(minutes / 60)
107
+ if (hours < 24) return strings.hoursAgo(hours)
108
+ const days = Math.floor(hours / 24)
109
+ if (days < 7) return strings.daysAgo(days)
110
+ const date = new Date(time)
111
+ return `${date.getMonth() + 1}/${date.getDate()}`
112
+ }
113
+
114
+ /** Local wall-clock HH:MM:SS (used in tooltips; full precision). */
115
+ function formatTime(time) {
116
+ const date = new Date(time)
117
+ const pad = (n) => String(n).padStart(2, '0')
118
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
119
+ }
120
+
121
+ // ── directory tree construction ───────────────────────────────────────
122
+ /**
123
+ * Collapse chain directories: a directory whose only child is another
124
+ * directory merges into it (a → a.b → a.b.c …). Deep single-child paths
125
+ * render as one dotted label with the file(s) directly beneath.
126
+ * `root` itself is never collapsed (its name is '' and would drop the
127
+ * top-level directory).
128
+ */
129
+ function compressChains(node, isRoot) {
130
+ for (const child of node.children) {
131
+ if (child.type === 'dir') compressChains(child, false)
132
+ }
133
+ if (isRoot) return
134
+ while (node.children.length === 1 && node.children[0].type === 'dir') {
135
+ const only = node.children[0]
136
+ node.name = `${node.name}.${only.name}`
137
+ node.children = only.children
138
+ node.compressed = true
139
+ }
140
+ }
291
141
 
292
- function installFetchInterceptor() {
293
- const original = window.fetch.bind(window)
294
- window.fetch = (input, init) => {
295
- const result = original(input, init)
296
- let url
297
- try {
298
- if (typeof input === 'string') url = new URL(input, window.location.href)
299
- else if (input instanceof URL) url = input
300
- else return result // Request instances: skip observation
301
- } catch {
302
- return result
303
- }
304
- observeSidebarFetch(url, init)
305
- return result
306
- }
307
- return () => {
308
- window.fetch = original
309
- }
310
- }
142
+ /**
143
+ * Sort a directory node: directories first (alphabetically), then files
144
+ * (by total activity, then name); recurse into directories.
145
+ */
146
+ function sortNode(node) {
147
+ node.children.sort((a, b) => {
148
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
149
+ if (a.type === 'dir') return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
150
+ const ta = a.read + a.create + a.modify
151
+ const tb = b.read + b.create + b.modify
152
+ return tb - ta || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
153
+ })
154
+ for (const child of node.children) {
155
+ if (child.type === 'dir') sortNode(child)
156
+ }
157
+ }
311
158
 
312
- // ── auto-open (enabled by default) ────────────────────────────────────
313
- function findTabIn(state, tabId) {
314
- const leaves = (node) => (node.kind === 'leaf' ? [node] : (node.children ?? []).flatMap(leaves))
315
- for (const node of [state?.splits, state?.bottomSplits]) {
316
- if (node === undefined || node === null) continue
317
- for (const leaf of leaves(node)) {
318
- if ((leaf.tabs ?? []).some((tab) => tab.type === tabId)) return true
159
+ /**
160
+ * Build a nested directory tree from per-file counts, keyed by the file's
161
+ * absolute path. Every directory node aggregates its subtree counters and
162
+ * sorts directories first (alphabetically), then files (by activity).
163
+ */
164
+ function buildTree(counts) {
165
+ const root = { type: 'dir', name: '', path: '', children: [], read: 0, create: 0, modify: 0 }
166
+ for (const [abs, counter] of Object.entries(counts)) {
167
+ const parts = abs.split('/').filter((part) => part !== '')
168
+ if (parts.length === 0) continue
169
+ const name = parts[parts.length - 1]
170
+ let node = root
171
+ for (const dir of parts.slice(0, -1)) {
172
+ let child = node.children.find((c) => c.type === 'dir' && c.name === dir)
173
+ if (child === undefined) {
174
+ child = {
175
+ type: 'dir',
176
+ name: dir,
177
+ path: `${node.path}/${dir}`,
178
+ children: [],
179
+ read: 0,
180
+ create: 0,
181
+ modify: 0,
319
182
  }
183
+ node.children.push(child)
320
184
  }
321
- return false
185
+ node = child
186
+ node.read += counter.read
187
+ node.create += counter.create
188
+ node.modify += counter.modify
322
189
  }
323
-
324
- /** Current sidebar snapshot, or null when the service is not ready. */
325
- function sidebarSnapshot(service) {
326
- try {
327
- return service.getSnapshot?.()
328
- } catch {
329
- return null
330
- }
190
+ node.children.push({
191
+ type: 'file',
192
+ name,
193
+ abs,
194
+ read: counter.read,
195
+ create: counter.create,
196
+ modify: counter.modify,
197
+ firstSeen: counter.firstSeen,
198
+ lastSeen: counter.lastSeen,
199
+ })
200
+ }
201
+ sortNode(root)
202
+ compressChains(root, true)
203
+ return root
204
+ }
205
+
206
+ // ── tiny external store ───────────────────────────────────────────────
207
+ function createStore(initial) {
208
+ let state = initial
209
+ const listeners = new Set()
210
+ return {
211
+ getSnapshot: () => state,
212
+ set(patch) {
213
+ state = { ...state, ...patch }
214
+ for (const listener of [...listeners]) listener()
215
+ },
216
+ subscribe(listener) {
217
+ listeners.add(listener)
218
+ return () => listeners.delete(listener)
219
+ },
220
+ }
221
+ }
222
+
223
+ // ── data access (host routes) ─────────────────────────────────────────
224
+ async function fetchStats(sessionId) {
225
+ const response = await fetch(`/file-activity/api/stats?sessionId=${encodeURIComponent(sessionId)}`)
226
+ const json = await response.json()
227
+ if (json === null || typeof json !== 'object' || json.ok !== true) return null
228
+ return json.value
229
+ }
230
+
231
+ /** Resolve the session working directory through the sidebar's native API. */
232
+ async function fetchSessionCwd(sessionId) {
233
+ try {
234
+ const response = await fetch('/sidebar/api/session.cwd', {
235
+ method: 'POST',
236
+ headers: { 'content-type': 'application/json' },
237
+ body: JSON.stringify({ sessionId }),
238
+ })
239
+ const json = await response.json()
240
+ const cwd = json?.value?.cwd
241
+ return typeof cwd === 'string' && cwd !== '' ? cwd : ''
242
+ } catch {
243
+ return ''
244
+ }
245
+ }
246
+
247
+ function postRecord(sessionId, path, op) {
248
+ if (typeof sessionId !== 'string' || sessionId === '' || typeof path !== 'string' || path === '') return
249
+ void fetch('/file-activity/api/record', {
250
+ method: 'POST',
251
+ headers: { 'content-type': 'application/json' },
252
+ body: JSON.stringify({ sessionId, path, op }),
253
+ }).catch(() => {})
254
+ }
255
+
256
+ function postClear(sessionId) {
257
+ void fetch('/file-activity/api/clear', {
258
+ method: 'POST',
259
+ headers: { 'content-type': 'application/json' },
260
+ body: JSON.stringify({ sessionId }),
261
+ }).catch(() => {})
262
+ }
263
+
264
+ /** Plugin media route URL for a recorded path (authorized per session). */
265
+ function mediaUrlOf(sessionId, path) {
266
+ return `/file-activity/file?${new URLSearchParams({ sessionId, path })}`
267
+ }
268
+
269
+ // ── fetch interception: sidebar file operations ───────────────────────
270
+ function methodOf(init) {
271
+ return (init?.method ?? 'GET').toUpperCase()
272
+ }
273
+
274
+ /** POST body as a plain object (non-string bodies are ignored). */
275
+ function parseBody(init) {
276
+ return typeof init?.body === 'string' ? JSON.parse(init.body) : {}
277
+ }
278
+
279
+ /** Record fs.read / fs.write POSTs observed on the sidebar API. */
280
+ function recordSidebarFs(url, init) {
281
+ if (url.pathname !== '/sidebar/api/fs.read' && url.pathname !== '/sidebar/api/fs.write') return
282
+ if (methodOf(init) !== 'POST') return
283
+ const body = parseBody(init)
284
+ if (typeof body.sessionId !== 'string' || typeof body.path !== 'string') return
285
+ postRecord(body.sessionId, body.path, url.pathname === '/sidebar/api/fs.write' ? 'write' : 'read')
286
+ }
287
+
288
+ /** Record sidebar media opens (/sidebar/file?sessionId=...&path=...). */
289
+ function recordMediaOpen(url, init) {
290
+ if (url.pathname !== '/sidebar/file' || methodOf(init) !== 'GET') return
291
+ const sessionId = url.searchParams.get('sessionId')
292
+ const path = url.searchParams.get('path')
293
+ if (sessionId !== null && path !== null) postRecord(sessionId, path, 'read')
294
+ }
295
+
296
+ /** Observe a resolved fetch URL and record sidebar file operations. */
297
+ function observeSidebarFetch(url, init) {
298
+ try {
299
+ recordSidebarFs(url, init)
300
+ recordMediaOpen(url, init)
301
+ } catch {
302
+ // observation must never break the underlying call
303
+ }
304
+ }
305
+
306
+ function installFetchInterceptor() {
307
+ const original = window.fetch.bind(window)
308
+ window.fetch = (input, init) => {
309
+ const result = original(input, init)
310
+ let url
311
+ try {
312
+ if (typeof input === 'string') url = new URL(input, window.location.href)
313
+ else if (input instanceof URL) url = input
314
+ else return result // Request instances: skip observation
315
+ } catch {
316
+ return result
331
317
  }
332
-
333
- /** The user disabled auto-open for this tab in the sidebar settings. */
334
- function isAutoOpenDisabled(snapshot, tabId) {
335
- const settings = snapshot.prefs?.pluginSettings?.[tabId]
336
- return settings !== undefined && settings.autoOpen === false
318
+ observeSidebarFetch(url, init)
319
+ return result
320
+ }
321
+ return () => {
322
+ window.fetch = original
323
+ }
324
+ }
325
+
326
+ // ── auto-open (enabled by default) ────────────────────────────────────
327
+ function findTabIn(state, tabId) {
328
+ const leaves = (node) => (node.kind === 'leaf' ? [node] : (node.children ?? []).flatMap(leaves))
329
+ for (const node of [state?.splits, state?.bottomSplits]) {
330
+ if (node === undefined || node === null) continue
331
+ for (const leaf of leaves(node)) {
332
+ if ((leaf.tabs ?? []).some((tab) => tab.type === tabId)) return true
337
333
  }
338
-
339
- /** Whether this session was already auto-opened (localStorage marker). */
340
- function isAutoOpenMarked(sessionId) {
341
- try {
342
- return Boolean(window.localStorage.getItem(AUTO_OPEN_KEY + sessionId))
343
- } catch {
344
- return true
345
- }
346
- }
347
-
348
- /** Persist the auto-opened marker for this session. */
349
- function markAutoOpened(sessionId) {
350
- try {
351
- window.localStorage.setItem(AUTO_OPEN_KEY + sessionId, '1')
352
- } catch {
353
- // ignore
354
- }
355
- }
356
-
357
- /** Open the tab once per session unless disabled in the plugin settings. */
358
- function tryAutoOpen(service, tabId) {
359
- const snapshot = sidebarSnapshot(service)
360
- if (snapshot === undefined || snapshot === null || snapshot.sessionId === undefined || snapshot.state === undefined) return
361
- const sessionId = snapshot.sessionId
362
- if (isAutoOpenDisabled(snapshot, tabId)) return
363
- if (isAutoOpenMarked(sessionId)) return
364
- if (findTabIn(snapshot.state, tabId)) {
365
- markAutoOpened(sessionId)
366
- return
367
- }
368
- try {
369
- service.openTab({ type: tabId, title: strings.title(), path: '' })
370
- markAutoOpened(sessionId)
371
- } catch (error) {
372
- console.error('[dsh-file-activity] auto-open failed:', error)
373
- }
374
- }
375
-
376
- function installAutoOpen(ctx, tabId) {
377
- const service = ctx.betterSidebar
378
- tryAutoOpen(service, tabId)
379
- let off = () => {}
380
- try {
381
- off = service.subscribeState?.(() => tryAutoOpen(service, tabId)) ?? off
382
- } catch {
383
- // service may lack subscribeState on older versions
384
- }
385
- return off
386
- }
387
-
388
- // ── icons (inline, stroke=currentColor, matching better-sidebar) ──────
389
- const ICON_STROKE = 1.8
390
- const iconSvg = (children, size) =>
391
- createElement('svg', {
392
- width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
393
- stroke: 'currentColor', strokeWidth: ICON_STROKE, strokeLinecap: 'round', strokeLinejoin: 'round',
394
- 'aria-hidden': 'true',
395
- }, children.map((child, i) => (child === null || child === undefined || typeof child === 'boolean')
334
+ }
335
+ return false
336
+ }
337
+
338
+ /** Current sidebar snapshot, or null when the service is not ready. */
339
+ function sidebarSnapshot(service) {
340
+ try {
341
+ return service.getSnapshot?.()
342
+ } catch {
343
+ return null
344
+ }
345
+ }
346
+
347
+ /** The user disabled auto-open for this tab in the sidebar settings. */
348
+ function isAutoOpenDisabled(snapshot, tabId) {
349
+ const settings = snapshot.prefs?.pluginSettings?.[tabId]
350
+ return settings !== undefined && settings.autoOpen === false
351
+ }
352
+
353
+ /** Whether this session was already auto-opened (localStorage marker). */
354
+ function isAutoOpenMarked(sessionId) {
355
+ try {
356
+ return Boolean(window.localStorage.getItem(AUTO_OPEN_KEY + sessionId))
357
+ } catch {
358
+ return true
359
+ }
360
+ }
361
+
362
+ /** Persist the auto-opened marker for this session. */
363
+ function markAutoOpened(sessionId) {
364
+ try {
365
+ window.localStorage.setItem(AUTO_OPEN_KEY + sessionId, '1')
366
+ } catch {
367
+ // ignore
368
+ }
369
+ }
370
+
371
+ /** Open the tab once per session unless disabled in the plugin settings. */
372
+ function tryAutoOpen(service, tabId) {
373
+ const snapshot = sidebarSnapshot(service)
374
+ if (snapshot === undefined || snapshot === null || snapshot.sessionId === undefined || snapshot.state === undefined)
375
+ return
376
+ const sessionId = snapshot.sessionId
377
+ if (isAutoOpenDisabled(snapshot, tabId)) return
378
+ if (isAutoOpenMarked(sessionId)) return
379
+ if (findTabIn(snapshot.state, tabId)) {
380
+ markAutoOpened(sessionId)
381
+ return
382
+ }
383
+ try {
384
+ service.openTab({ type: tabId, title: strings.title(), path: '' })
385
+ markAutoOpened(sessionId)
386
+ } catch (error) {
387
+ console.error('[dsh-file-activity] auto-open failed:', error)
388
+ }
389
+ }
390
+
391
+ function installAutoOpen(ctx, tabId) {
392
+ const service = ctx.betterSidebar
393
+ tryAutoOpen(service, tabId)
394
+ let off = () => {}
395
+ try {
396
+ off = service.subscribeState?.(() => tryAutoOpen(service, tabId)) ?? off
397
+ } catch {
398
+ // service may lack subscribeState on older versions
399
+ }
400
+ return off
401
+ }
402
+
403
+ // ── shared icons (inline, stroke=currentColor, matching better-sidebar) ──
404
+ // Single source of truth for the plugin UI icon set (issue #54 阶段 0).
405
+ // Extracted from dsh-file-activity's lib/parts/icons.part.js; every plugin's
406
+ // scripts/build.mjs splices this file via the `shared: true` piece marker.
407
+ // Keep the stroke=currentColor outline style — it inherits the surrounding
408
+ // text color and reads on both light and dark themes.
409
+ const ICON_STROKE = 1.8
410
+ const iconSvg = (children, size) =>
411
+ createElement(
412
+ 'svg',
413
+ {
414
+ width: size,
415
+ height: size,
416
+ viewBox: '0 0 24 24',
417
+ fill: 'none',
418
+ stroke: 'currentColor',
419
+ strokeWidth: ICON_STROKE,
420
+ strokeLinecap: 'round',
421
+ strokeLinejoin: 'round',
422
+ 'aria-hidden': 'true',
423
+ },
424
+ children.map((child, i) =>
425
+ child === null || child === undefined || typeof child === 'boolean'
396
426
  ? child
397
- : createElement(child.type, { key: i, ...child.props })))
398
-
399
- const icon = {
400
- clock: (size = 16) => iconSvg([
401
- createElement('circle', { cx: 12, cy: 12, r: 9 }),
402
- createElement('path', { d: 'M12 7v5l3 2' }),
403
- ], size),
404
- refresh: (size = 16) => iconSvg([
427
+ : createElement(child.type, { key: i, ...child.props }),
428
+ ),
429
+ )
430
+
431
+ const icon = {
432
+ clock: (size = 16) =>
433
+ iconSvg([createElement('circle', { cx: 12, cy: 12, r: 9 }), createElement('path', { d: 'M12 7v5l3 2' })], size),
434
+ refresh: (size = 16) =>
435
+ iconSvg(
436
+ [
405
437
  createElement('path', { d: 'M21 12a9 9 0 1 1-2.64-6.36' }),
406
438
  createElement('polyline', { points: '21 3 21 9 15 9' }),
407
- ], size),
408
- trash: (size = 16) => iconSvg([
439
+ ],
440
+ size,
441
+ ),
442
+ trash: (size = 16) =>
443
+ iconSvg(
444
+ [
409
445
  createElement('path', { d: 'M3 6h18' }),
410
446
  createElement('path', { d: 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6' }),
411
447
  createElement('path', { d: 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2' }),
412
- ], size),
413
- chevronRight: (size = 14) => iconSvg([
414
- createElement('polyline', { points: '9 6 15 12 9 18' }),
415
- ], size),
416
- chevronDown: (size = 14) => iconSvg([
417
- createElement('polyline', { points: '6 9 12 15 18 9' }),
418
- ], size),
419
- file: (size = 16) => iconSvg([
448
+ ],
449
+ size,
450
+ ),
451
+ chevronRight: (size = 14) => iconSvg([createElement('polyline', { points: '9 6 15 12 9 18' })], size),
452
+ chevronDown: (size = 14) => iconSvg([createElement('polyline', { points: '6 9 12 15 18 9' })], size),
453
+ file: (size = 16) =>
454
+ iconSvg(
455
+ [
420
456
  createElement('path', { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }),
421
457
  createElement('path', { d: 'M14 2v6h6' }),
422
- ], size),
423
- folder: (size = 16) => iconSvg([
424
- createElement('path', { d: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z' }),
425
- ], size),
426
- external: (size = 15) => iconSvg([
458
+ ],
459
+ size,
460
+ ),
461
+ folder: (size = 16) =>
462
+ iconSvg(
463
+ [
464
+ createElement('path', {
465
+ d: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
466
+ }),
467
+ ],
468
+ size,
469
+ ),
470
+ external: (size = 15) =>
471
+ iconSvg(
472
+ [
427
473
  createElement('path', { d: 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' }),
428
474
  createElement('polyline', { points: '15 3 21 3 21 9' }),
429
475
  createElement('line', { x1: 10, y1: 14, x2: 21, y2: 3 }),
430
- ], size),
431
- close: (size = 15) => iconSvg([
476
+ ],
477
+ size,
478
+ ),
479
+ close: (size = 15) =>
480
+ iconSvg(
481
+ [
432
482
  createElement('line', { x1: 18, y1: 6, x2: 6, y2: 18 }),
433
483
  createElement('line', { x1: 6, y1: 6, x2: 18, y2: 18 }),
434
- ], size),
435
- }
436
-
437
- // Common-language / file-type badges (issue #24): brand fill + contrast
438
- // ink, reading on both light and dark themes. Unmapped extensions keep the
439
- // neutral currentColor file icon above. [bg, fg ink, short mark]
440
- const FILE_BADGES = {
441
- // JavaScript / TypeScript
442
- js: ['#F7DF1E', '#323330', 'JS'], mjs: ['#F7DF1E', '#323330', 'JS'], cjs: ['#F7DF1E', '#323330', 'JS'],
443
- ts: ['#3178C6', '#ffffff', 'TS'], mts: ['#3178C6', '#ffffff', 'TS'], cts: ['#3178C6', '#ffffff', 'TS'],
444
- tsx: ['#3178C6', '#ffffff', 'TSX'], jsx: ['#3178C6', '#ffffff', 'JSX'],
445
- // 后端语言
446
- java: ['#007396', '#ffffff', 'JAVA'],
447
- c: ['#A8B9CC', '#111111', 'C'],
448
- cpp: ['#00599C', '#ffffff', 'C++'], cxx: ['#00599C', '#ffffff', 'C++'], cc: ['#00599C', '#ffffff', 'C++'], hpp: ['#00599C', '#ffffff', 'C++'],
449
- h: ['#A8B9CC', '#111111', 'H'], hh: ['#A8B9CC', '#111111', 'H'],
450
- cs: ['#68217A', '#ffffff', 'C#'], csharp: ['#68217A', '#ffffff', 'C#'],
451
- go: ['#00ADD8', '#ffffff', 'GO'],
452
- rs: ['#CE422B', '#ffffff', 'RS'],
453
- rb: ['#B51624', '#ffffff', 'RB'],
454
- php: ['#777BB4', '#ffffff', 'PHP'],
455
- py: ['#3776AB', '#ffffff', 'PY'],
456
- swift: ['#F05138', '#ffffff', 'SWIFT'],
457
- kt: ['#7F52FF', '#ffffff', 'KT'], kotlin: ['#7F52FF', '#ffffff', 'KT'],
458
- dart: ['#0175C2', '#ffffff', 'DART'],
459
- scala: ['#DC322F', '#ffffff', 'SCALA'],
460
- lua: ['#2C2C7C', '#ffffff', 'LUA'],
461
- pl: ['#0298C3', '#ffffff', 'PERL'],
462
- r: ['#336DC3', '#ffffff', 'R'],
463
- m: ['#C1272D', '#ffffff', 'MAT'], mm: ['#C1272D', '#ffffff', 'MAT'],
464
- // Web / 前端
465
- html: ['#E34F26', '#ffffff', '</>'], htm: ['#E34F26', '#ffffff', '</>'],
466
- css: ['#663399', '#ffffff', 'CSS'],
467
- scss: ['#CD6799', '#ffffff', 'SCSS'], sass: ['#CD6799', '#ffffff', 'SCSS'],
468
- vue: ['#42B883', '#ffffff', 'VUE'],
469
- svelte: ['#FF3E00', '#ffffff', 'SVELTE'],
470
- // 数据 / 结构化
471
- json: ['#F7DF1E', '#323330', '{}'],
472
- sql: ['#00758F', '#ffffff', 'SQL'],
473
- csv: ['#2E7D32', '#ffffff', 'CSV'],
474
- db: ['#0F62FE', '#ffffff', 'DB'], sqlite: ['#0F62FE', '#ffffff', 'DB'], sqlite3: ['#0F62FE', '#ffffff', 'DB'],
475
- xml: ['#FF6F00', '#ffffff', 'XML'],
476
- svg: ['#FF6F00', '#ffffff', 'SVG'],
477
- // 文档
478
- md: ['#42A5F5', '#ffffff', 'M↓'], markdown: ['#42A5F5', '#ffffff', 'M↓'],
479
- txt: ['#90A4AE', '#ffffff', 'TXT'], text: ['#90A4AE', '#ffffff', 'TXT'], log: ['#90A4AE', '#ffffff', 'TXT'],
480
- pdf: ['#E5202B', '#ffffff', 'PDF'],
481
- doc: ['#2B579A', '#ffffff', 'DOC'], docx: ['#2B579A', '#ffffff', 'DOC'],
482
- xls: ['#217346', '#ffffff', 'XLS'], xlsx: ['#217346', '#ffffff', 'XLS'],
483
- ppt: ['#D24726', '#ffffff', 'PPT'], pptx: ['#D24726', '#ffffff', 'PPT'],
484
- // 配置 / 构建
485
- yml: ['#CB171E', '#ffffff', 'YML'], yaml: ['#CB171E', '#ffffff', 'YML'],
486
- toml: ['#8D6E63', '#ffffff', 'TOML'],
487
- ini: ['#546E7A', '#ffffff', 'CFG'], cfg: ['#546E7A', '#ffffff', 'CFG'], config: ['#546E7A', '#ffffff', 'CFG'],
488
- env: ['#F9A825', '#323330', 'ENV'],
489
- properties: ['#7B1FA2', '#ffffff', 'PROP'],
490
- lock: ['#37474F', '#ffffff', 'LOCK'],
491
- dockerfile: ['#2496ED', '#ffffff', 'DOCK'], docker: ['#2496ED', '#ffffff', 'DOCK'],
492
- makefile: ['#607D8B', '#ffffff', 'MAKE'],
493
- gradle: ['#02303A', '#ffffff', 'GRADLE'],
494
- cmake: ['#265774', '#ffffff', 'CMAKE'],
495
- ipynb: ['#F37726', '#ffffff', 'JNB'],
496
- // 脚本 / Shell
497
- sh: ['#89E051', '#111111', '>_'], bash: ['#89E051', '#111111', '>_'], zsh: ['#89E051', '#111111', '>_'],
498
- ps1: ['#012456', '#ffffff', 'PS1'],
499
- bat: ['#546E7A', '#ffffff', 'CMD'], cmd: ['#546E7A', '#ffffff', 'CMD'],
500
- // 打包 / 二进制
501
- zip: ['#FFA726', '#323330', 'ZIP'], tar: ['#FFA726', '#323330', 'ZIP'], gz: ['#FFA726', '#323330', 'ZIP'],
502
- '7z': ['#FFA726', '#323330', 'ZIP'], rar: ['#FFA726', '#323330', 'ZIP'],
503
- exe: ['#0078D4', '#ffffff', 'EXE'], msi: ['#0078D4', '#ffffff', 'EXE'],
504
- wasm: ['#654FF0', '#ffffff', 'WASM'],
505
- // 图片 / 媒体
506
- png: ['#8E44AD', '#ffffff', 'IMG'], jpg: ['#8E44AD', '#ffffff', 'IMG'], jpeg: ['#8E44AD', '#ffffff', 'IMG'],
507
- gif: ['#8E44AD', '#ffffff', 'IMG'], webp: ['#8E44AD', '#ffffff', 'IMG'], ico: ['#8E44AD', '#ffffff', 'IMG'], bmp: ['#8E44AD', '#ffffff', 'IMG'],
508
- // 版本控制
509
- gitignore: ['#F05032', '#ffffff', 'GIT'], gitattributes: ['#F05032', '#ffffff', 'GIT'],
510
- }
511
-
512
- /** One self-colored badge svg: rounded brand rect + short contrast mark.
513
- * Mark font scales by length so 5-6 char marks (JAVA/SCALA/SWIFT) stay
514
- * inside the 24×24 viewBox. */
515
- const badgeIcon = ([bg, fg, mark], size) =>
516
- createElement('svg', {
517
- width: size, height: size, viewBox: '0 0 24 24', 'aria-hidden': 'true',
484
+ ],
485
+ size,
486
+ ),
487
+ help: (size = 16) =>
488
+ iconSvg(
489
+ [
490
+ createElement('circle', { cx: 12, cy: 12, r: 9 }),
491
+ createElement('path', { d: 'M9.1 9.2a3 3 0 0 1 5.8 1.2c0 1.8-2.7 2.4-2.7 3.6' }),
492
+ createElement('line', { x1: 12, y1: 17.2, x2: 12.01, y2: 17.2 }),
493
+ ],
494
+ size,
495
+ ),
496
+ // ── generic action icons (issue #54 阶段 0) ─────────────────────────────
497
+ // Added for the upcoming plugin UI refresh: save/confirm (check), add/
498
+ // install (plus), market search (search), settings entry (settings).
499
+ check: (size = 16) => iconSvg([createElement('polyline', { points: '20 6 9 17 4 12' })], size),
500
+ plus: (size = 16) =>
501
+ iconSvg(
502
+ [
503
+ createElement('line', { x1: 12, y1: 5, x2: 12, y2: 19 }),
504
+ createElement('line', { x1: 5, y1: 12, x2: 19, y2: 12 }),
505
+ ],
506
+ size,
507
+ ),
508
+ pencil: (size = 15) =>
509
+ iconSvg([createElement('path', { d: 'M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z' })], size),
510
+ search: (size = 16) =>
511
+ iconSvg(
512
+ [
513
+ createElement('circle', { cx: 11, cy: 11, r: 8 }),
514
+ createElement('line', { x1: 21, y1: 21, x2: 16.65, y2: 16.65 }),
515
+ ],
516
+ size,
517
+ ),
518
+ settings: (size = 16) =>
519
+ iconSvg(
520
+ [
521
+ createElement('circle', { cx: 12, cy: 12, r: 3 }),
522
+ createElement('path', {
523
+ d: 'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z',
524
+ }),
525
+ ],
526
+ size,
527
+ ),
528
+ // 警告(issue #54 阶段 1 新增):安全护栏告警类型图标(投毒/提示注入),
529
+ // 三角警示 + 感叹号,stroke=currentColor 风格与其余图标一致。
530
+ alert: (size = 16) =>
531
+ iconSvg(
532
+ [
533
+ createElement('path', {
534
+ d: 'M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z',
535
+ }),
536
+ createElement('line', { x1: 12, y1: 9, x2: 12, y2: 13 }),
537
+ createElement('line', { x1: 12, y1: 17, x2: 12.01, y2: 17 }),
538
+ ],
539
+ size,
540
+ ),
541
+ // 代码(issue #54 阶段 1 新增):尖括号 `</>`,预览/代码切换的代码视图
542
+ // 图标(dsh-mermaid-render 卡片),stroke=currentColor 风格与其余图标一致。
543
+ code: (size = 16) =>
544
+ iconSvg(
545
+ [
546
+ createElement('polyline', { points: '16 18 22 12 16 6' }),
547
+ createElement('polyline', { points: '8 6 2 12 8 18' }),
548
+ ],
549
+ size,
550
+ ),
551
+ }
552
+
553
+ // Common-language / file-type badges (issue #24): brand fill + contrast
554
+ // ink, reading on both light and dark themes. Unmapped extensions keep the
555
+ // neutral currentColor file icon above. [bg, fg ink, short mark]
556
+ const FILE_BADGES = {
557
+ // JavaScript / TypeScript
558
+ js: ['#F7DF1E', '#323330', 'JS'],
559
+ mjs: ['#F7DF1E', '#323330', 'JS'],
560
+ cjs: ['#F7DF1E', '#323330', 'JS'],
561
+ ts: ['#3178C6', '#ffffff', 'TS'],
562
+ mts: ['#3178C6', '#ffffff', 'TS'],
563
+ cts: ['#3178C6', '#ffffff', 'TS'],
564
+ tsx: ['#3178C6', '#ffffff', 'TSX'],
565
+ jsx: ['#3178C6', '#ffffff', 'JSX'],
566
+ // 后端语言
567
+ java: ['#007396', '#ffffff', 'JAVA'],
568
+ c: ['#A8B9CC', '#111111', 'C'],
569
+ cpp: ['#00599C', '#ffffff', 'C++'],
570
+ cxx: ['#00599C', '#ffffff', 'C++'],
571
+ cc: ['#00599C', '#ffffff', 'C++'],
572
+ hpp: ['#00599C', '#ffffff', 'C++'],
573
+ h: ['#A8B9CC', '#111111', 'H'],
574
+ hh: ['#A8B9CC', '#111111', 'H'],
575
+ cs: ['#68217A', '#ffffff', 'C#'],
576
+ csharp: ['#68217A', '#ffffff', 'C#'],
577
+ go: ['#00ADD8', '#ffffff', 'GO'],
578
+ rs: ['#CE422B', '#ffffff', 'RS'],
579
+ rb: ['#B51624', '#ffffff', 'RB'],
580
+ php: ['#777BB4', '#ffffff', 'PHP'],
581
+ py: ['#3776AB', '#ffffff', 'PY'],
582
+ swift: ['#F05138', '#ffffff', 'SWIFT'],
583
+ kt: ['#7F52FF', '#ffffff', 'KT'],
584
+ kotlin: ['#7F52FF', '#ffffff', 'KT'],
585
+ dart: ['#0175C2', '#ffffff', 'DART'],
586
+ scala: ['#DC322F', '#ffffff', 'SCALA'],
587
+ lua: ['#2C2C7C', '#ffffff', 'LUA'],
588
+ pl: ['#0298C3', '#ffffff', 'PERL'],
589
+ r: ['#336DC3', '#ffffff', 'R'],
590
+ m: ['#C1272D', '#ffffff', 'MAT'],
591
+ mm: ['#C1272D', '#ffffff', 'MAT'],
592
+ // Web / 前端
593
+ html: ['#E34F26', '#ffffff', '</>'],
594
+ htm: ['#E34F26', '#ffffff', '</>'],
595
+ css: ['#663399', '#ffffff', 'CSS'],
596
+ scss: ['#CD6799', '#ffffff', 'SCSS'],
597
+ sass: ['#CD6799', '#ffffff', 'SCSS'],
598
+ vue: ['#42B883', '#ffffff', 'VUE'],
599
+ svelte: ['#FF3E00', '#ffffff', 'SVELTE'],
600
+ // 数据 / 结构化
601
+ json: ['#F7DF1E', '#323330', '{}'],
602
+ sql: ['#00758F', '#ffffff', 'SQL'],
603
+ csv: ['#2E7D32', '#ffffff', 'CSV'],
604
+ db: ['#0F62FE', '#ffffff', 'DB'],
605
+ sqlite: ['#0F62FE', '#ffffff', 'DB'],
606
+ sqlite3: ['#0F62FE', '#ffffff', 'DB'],
607
+ xml: ['#FF6F00', '#ffffff', 'XML'],
608
+ svg: ['#FF6F00', '#ffffff', 'SVG'],
609
+ // 文档
610
+ md: ['#42A5F5', '#ffffff', 'M↓'],
611
+ markdown: ['#42A5F5', '#ffffff', 'M↓'],
612
+ txt: ['#90A4AE', '#ffffff', 'TXT'],
613
+ text: ['#90A4AE', '#ffffff', 'TXT'],
614
+ log: ['#90A4AE', '#ffffff', 'TXT'],
615
+ pdf: ['#E5202B', '#ffffff', 'PDF'],
616
+ doc: ['#2B579A', '#ffffff', 'DOC'],
617
+ docx: ['#2B579A', '#ffffff', 'DOC'],
618
+ xls: ['#217346', '#ffffff', 'XLS'],
619
+ xlsx: ['#217346', '#ffffff', 'XLS'],
620
+ ppt: ['#D24726', '#ffffff', 'PPT'],
621
+ pptx: ['#D24726', '#ffffff', 'PPT'],
622
+ // 配置 / 构建
623
+ yml: ['#CB171E', '#ffffff', 'YML'],
624
+ yaml: ['#CB171E', '#ffffff', 'YML'],
625
+ toml: ['#8D6E63', '#ffffff', 'TOML'],
626
+ ini: ['#546E7A', '#ffffff', 'CFG'],
627
+ cfg: ['#546E7A', '#ffffff', 'CFG'],
628
+ config: ['#546E7A', '#ffffff', 'CFG'],
629
+ env: ['#F9A825', '#323330', 'ENV'],
630
+ properties: ['#7B1FA2', '#ffffff', 'PROP'],
631
+ lock: ['#37474F', '#ffffff', 'LOCK'],
632
+ dockerfile: ['#2496ED', '#ffffff', 'DOCK'],
633
+ docker: ['#2496ED', '#ffffff', 'DOCK'],
634
+ makefile: ['#607D8B', '#ffffff', 'MAKE'],
635
+ gradle: ['#02303A', '#ffffff', 'GRADLE'],
636
+ cmake: ['#265774', '#ffffff', 'CMAKE'],
637
+ ipynb: ['#F37726', '#ffffff', 'JNB'],
638
+ // 脚本 / Shell
639
+ sh: ['#89E051', '#111111', '>_'],
640
+ bash: ['#89E051', '#111111', '>_'],
641
+ zsh: ['#89E051', '#111111', '>_'],
642
+ ps1: ['#012456', '#ffffff', 'PS1'],
643
+ bat: ['#546E7A', '#ffffff', 'CMD'],
644
+ cmd: ['#546E7A', '#ffffff', 'CMD'],
645
+ // 打包 / 二进制
646
+ zip: ['#FFA726', '#323330', 'ZIP'],
647
+ tar: ['#FFA726', '#323330', 'ZIP'],
648
+ gz: ['#FFA726', '#323330', 'ZIP'],
649
+ '7z': ['#FFA726', '#323330', 'ZIP'],
650
+ rar: ['#FFA726', '#323330', 'ZIP'],
651
+ exe: ['#0078D4', '#ffffff', 'EXE'],
652
+ msi: ['#0078D4', '#ffffff', 'EXE'],
653
+ wasm: ['#654FF0', '#ffffff', 'WASM'],
654
+ // 图片 / 媒体
655
+ png: ['#8E44AD', '#ffffff', 'IMG'],
656
+ jpg: ['#8E44AD', '#ffffff', 'IMG'],
657
+ jpeg: ['#8E44AD', '#ffffff', 'IMG'],
658
+ gif: ['#8E44AD', '#ffffff', 'IMG'],
659
+ webp: ['#8E44AD', '#ffffff', 'IMG'],
660
+ ico: ['#8E44AD', '#ffffff', 'IMG'],
661
+ bmp: ['#8E44AD', '#ffffff', 'IMG'],
662
+ // 版本控制
663
+ gitignore: ['#F05032', '#ffffff', 'GIT'],
664
+ gitattributes: ['#F05032', '#ffffff', 'GIT'],
665
+ }
666
+
667
+ /** One self-colored badge svg: rounded brand rect + short contrast mark.
668
+ * Mark font scales by length so 5-6 char marks (JAVA/SCALA/SWIFT) stay
669
+ * inside the 24×24 viewBox. */
670
+ const badgeIcon = ([bg, fg, mark], size) =>
671
+ createElement(
672
+ 'svg',
673
+ {
674
+ width: size,
675
+ height: size,
676
+ viewBox: '0 0 24 24',
677
+ 'aria-hidden': 'true',
678
+ },
679
+ createElement('rect', { x: 1, y: 1, width: 22, height: 22, rx: 5, fill: bg }),
680
+ createElement(
681
+ 'text',
682
+ {
683
+ x: 12,
684
+ y: 16,
685
+ textAnchor: 'middle',
686
+ fontSize: mark.length <= 2 ? 9 : mark.length <= 4 ? 7 : 5.5,
687
+ fontWeight: 700,
688
+ fill: fg,
518
689
  },
519
- createElement('rect', { x: 1, y: 1, width: 22, height: 22, rx: 5, fill: bg }),
520
- createElement('text', {
521
- x: 12, y: 16, textAnchor: 'middle', fontSize: mark.length <= 2 ? 9 : mark.length <= 4 ? 7 : 5.5,
522
- fontWeight: 700, fill: fg,
523
- }, mark))
524
-
525
- /** File-type icon dispatcher: branded badge for known extensions, the
526
- * neutral file icon for everything else (case-insensitive, tolerates a
527
- * leading dot like ".md"). */
528
- const fileIconByExt = (ext, size = 14) => {
529
- const spec = FILE_BADGES[String(ext ?? '').toLowerCase().replace(/^\./, '')]
530
- return spec === undefined ? icon.file(size) : badgeIcon(spec, size)
531
- }
532
-
533
- // ── themed stylesheet (injected once per activation) ──────────────────
534
- // Mirrors the better-sidebar explorer surface: tight 2px 6px 8px body,
535
- // 30px rows, box-sizing border-box indentation, folder rows use the
536
- // strong type face to read as directories, files stay regular.
537
- const STYLES = `
690
+ mark,
691
+ ),
692
+ )
693
+
694
+ /** File-type icon dispatcher: branded badge for known extensions, the
695
+ * neutral file icon for everything else (case-insensitive, tolerates a
696
+ * leading dot like ".md"). */
697
+ const fileIconByExt = (ext, size = 14) => {
698
+ const spec =
699
+ FILE_BADGES[
700
+ String(ext ?? '')
701
+ .toLowerCase()
702
+ .replace(/^\./, '')
703
+ ]
704
+ return spec === undefined ? icon.file(size) : badgeIcon(spec, size)
705
+ }
706
+
707
+ // ── themed stylesheet (injected once per activation) ──────────────────
708
+ // Mirrors the better-sidebar explorer surface: tight 2px 6px 8px body,
709
+ // 30px rows, box-sizing border-box indentation, folder rows use the
710
+ // strong type face to read as directories, files stay regular.
711
+ const STYLES = `
538
712
  .dfa { display:flex; flex-direction:column; height:100%; overflow-y:auto; overflow-x:hidden;
539
713
  padding:2px 6px 8px; gap:2px; font:var(--dsw-font-s-14); color:var(--dsw-alias-label-primary); }
540
714
  .dfa-iconbtn { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; padding:0;
@@ -606,559 +780,675 @@ window.__ModuleLoader__.load({
606
780
  .dfa-pdf-download:hover { text-decoration:underline; }
607
781
  .dfa-pdf-frame { flex:1; min-height:0; width:100%; border:none; border-radius:6px; background:transparent; }
608
782
  @keyframes dfa-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
609
- /* ── sidebar tab selected-state overrides (issue #25) ─────────────────────
610
- The host (dsh-better-sidebar) renders tabs with CSS-modules hashed class
611
- names (e.g. tabActive_xxxxx) and no data attributes, so the selected tab
612
- is targeted via the [class*="tabActive"] substring selector — the fallback
613
- contract that keeps these rules working if the host ever renames its
614
- classes. The double attribute selector [class*="tab"][class*="tabActive"]
615
- (0,2,0) beats the host .tabActive (0,1,0) by specificity alone: no
616
- !important, no reliance on injection order. Selected = theme-aware brand
617
- fill + contrast ink (light #4176e6/#fff, dark #679efe/#0f1115); the :hover
618
- variant (0,3,0) beats the host .tab:hover (0,2,0) so the selected state
619
- never collapses into the host's grey hover. Unselected tabs stay untouched
620
- (transparent + secondary label ink, host grey hover). */
621
- [class*="tab"][class*="tabActive"] { background:var(--dsw-alias-state-business-primary); color:var(--dsw-alias-label-primary-foreground); }
622
- [class*="tab"][class*="tabActive"]:hover { background:var(--dsw-alias-state-business-primary); color:var(--dsw-alias-label-primary-foreground); }
623
- [class*="tab"][class*="tabActive"] [class*="tabClose"] { color:var(--dsw-alias-label-primary-foreground); }
624
- [class*="tab"][class*="tabActive"] [class*="tabClose"]:hover { background:color-mix(in srgb, var(--dsw-alias-label-primary-foreground) 18%, transparent); color:var(--dsw-alias-label-primary-foreground); }
783
+ /* issue #60: 移除 #25 的侧边栏页签选中态品牌蓝覆盖([class*="tab"][class*=
784
+ "tabActive"] 全局子串选择器误伤宿主对话/工作区 tab 选中态,出现用户不
785
+ 想要的蓝色高亮)。页签选中态回归宿主(dsh-better-sidebar)默认样式。 */
625
786
  `
626
787
 
627
- // ── row rendering helpers (recent list & stats tree) ──────────────────
628
- const opClass = (op) => (op === 'create' ? 'dfa-op-create' : op === 'modify' ? 'dfa-op-modify' : op === 'delete' ? 'dfa-op-delete' : 'dfa-op-read')
629
- const opLabel = (op) => (op === 'create' ? strings.create() : op === 'modify' ? strings.modify() : op === 'delete' ? strings.delete() : strings.read())
630
-
631
- /** Tooltip for a stats file row: absolute path + created / last-seen times. */
632
- const fileTitle = (abs, firstSeen, lastSeen) => {
633
- const times = []
634
- if (typeof firstSeen === 'number') times.push(`${strings.created()} ${formatTime(firstSeen)}`)
635
- if (typeof lastSeen === 'number') times.push(`${strings.lastSeen()} ${formatTime(lastSeen)}`)
636
- return times.length > 0 ? `${abs}\n${times.join(' · ')}` : abs
637
- }
638
-
639
- /** Count pills for a file/dir node — only actions that actually happened are
640
- * shown (a zero count renders no pill; all-zero nodes render no pill group,
641
- * keeping untouched files visually quiet). */
642
- const countPills = (node) => {
643
- const pills = []
644
- if (node.read > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`))
645
- if (node.create > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`))
646
- if (node.modify > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`))
647
- if (pills.length === 0) return null
648
- return createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } }, ...pills)
649
- }
650
-
651
- /** Extension of a file name (lowercase, no leading dot); '' when none.
652
- * Dotfiles map to their whole name ('.gitignore' → 'gitignore') so the
653
- * badge table can cover them; 'notes.' still yields ''. */
654
- const extOf = (name) => {
655
- const dot = name.lastIndexOf('.')
656
- if (dot > 0) return name.slice(dot + 1).toLowerCase()
657
- if (dot === 0) return name.slice(1).toLowerCase()
658
- return ''
659
- }
660
-
661
- /** Extension-less but common build files → their badge key. */
662
- const NAME_BADGES = {
663
- makefile: 'makefile', dockerfile: 'dockerfile', 'cmakelists.txt': 'cmake',
664
- }
665
-
666
- /** Badge key for a file name: basename match first, then extension. */
667
- const badgeKeyOf = (name) => {
668
- const base = name.toLowerCase()
669
- const named = NAME_BADGES[base]
670
- if (named !== undefined) return named
671
- return extOf(name)
672
- }
673
-
674
- /** A stats-tree file row: icon + name + count pills + relative time. */
675
- const fileRow = (file, depth, onOpen) =>
676
- createElement(
677
- 'div',
678
- {
679
- key: file.abs,
680
- className: 'dfa-row',
681
- onClick: () => onOpen(file.abs),
682
- style: { paddingLeft: 8 + depth * 20 },
683
- title: fileTitle(file.abs, file.firstSeen, file.lastSeen),
684
- },
685
- createElement('span', { className: 'dfa-row-icon dfa-icon-file' }, fileIconByExt(badgeKeyOf(file.name))),
686
- createElement('span', { className: 'dfa-row-name dfa-name-file' }, file.name),
687
- countPills(file),
688
- file.lastSeen
689
- ? createElement('span', { className: 'dfa-time' }, formatRelative(file.lastSeen))
690
- : null,
691
- )
692
-
693
- /** One stats-tree node: file rows render inline, dirs toggle collapse. */
694
- function renderTreeNode(node, depth, collapsedDirs, onToggleDir, onOpen) {
695
- if (node.type === 'file') return fileRow(node, depth, onOpen)
696
- const collapsed = collapsedDirs.has(node.path)
697
- return createElement(
698
- 'div',
699
- { key: node.path },
700
- createElement(
701
- 'div',
702
- {
703
- className: 'dfa-row dfa-row-dir',
704
- onClick: () => onToggleDir(node.path),
705
- style: { paddingLeft: 8 + depth * 20 },
706
- title: `${node.path}/`,
707
- },
708
- createElement('span', { className: 'dfa-chevron' },
709
- collapsed ? icon.chevronRight(13) : icon.chevronDown(13),
710
- ),
711
- createElement('span', { className: 'dfa-row-icon dfa-icon-folder' }, icon.folder(14)),
712
- createElement('span', { className: 'dfa-row-name' },
713
- node.compressed ? node.name : node.name + '/',
714
- ),
715
- countPills(node),
716
- ),
717
- collapsed ? null : node.children.map((child) => renderTreeNode(child, depth + 1, collapsedDirs, onToggleDir, onOpen)),
718
- )
719
- }
720
-
721
- /** A recent-list row: op badge + basename + relative time. */
722
- const recentEntry = (entry, onOpen) =>
723
- createElement(
724
- 'div',
725
- {
726
- key: `${entry.path}:${entry.time}:${entry.op}`,
727
- className: 'dfa-row',
728
- onClick: () => onOpen(entry.path),
729
- title: entry.path,
730
- },
731
- createElement('span', { className: `dfa-op ${opClass(entry.op)}` }, opLabel(entry.op)),
732
- createElement('span', { className: 'dfa-row-name' }, basenameOf(entry.path)),
733
- createElement('span', { className: 'dfa-time' }, formatRelative(entry.time)),
734
- )
735
-
736
- /** Toggle a key in a Set (directory collapse state). */
737
- function toggleInSet(set, key) {
738
- const next = new Set(set)
739
- if (next.has(key)) next.delete(key)
740
- else next.add(key)
741
- return next
742
- }
743
-
744
- /** Clear the current session's records host-side and reset its bucket. */
745
- function clearSessionData(dataStore, sessionId) {
746
- if (!window.confirm(strings.clearConfirm())) return
747
- postClear(sessionId)
788
+ // ── row rendering helpers (recent list & stats tree) ──────────────────
789
+ const opClass = (op) =>
790
+ op === 'create'
791
+ ? 'dfa-op-create'
792
+ : op === 'modify'
793
+ ? 'dfa-op-modify'
794
+ : op === 'delete'
795
+ ? 'dfa-op-delete'
796
+ : 'dfa-op-read'
797
+ const opLabel = (op) =>
798
+ op === 'create'
799
+ ? strings.create()
800
+ : op === 'modify'
801
+ ? strings.modify()
802
+ : op === 'delete'
803
+ ? strings.delete()
804
+ : strings.read()
805
+
806
+ /** Tooltip for a stats file row: absolute path + created / last-seen times. */
807
+ const fileTitle = (abs, firstSeen, lastSeen) => {
808
+ const times = []
809
+ if (typeof firstSeen === 'number') times.push(`${strings.created()} ${formatTime(firstSeen)}`)
810
+ if (typeof lastSeen === 'number') times.push(`${strings.lastSeen()} ${formatTime(lastSeen)}`)
811
+ return times.length > 0 ? `${abs}\n${times.join(' · ')}` : abs
812
+ }
813
+
814
+ /** Count pills for a file/dir node — only actions that actually happened are
815
+ * shown (a zero count renders no pill; all-zero nodes render no pill group,
816
+ * keeping untouched files visually quiet). */
817
+ const countPills = (node) => {
818
+ const pills = []
819
+ if (node.read > 0)
820
+ pills.push(createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`))
821
+ if (node.create > 0)
822
+ pills.push(
823
+ createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`),
824
+ )
825
+ if (node.modify > 0)
826
+ pills.push(
827
+ createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`),
828
+ )
829
+ if (pills.length === 0) return null
830
+ return createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } }, ...pills)
831
+ }
832
+
833
+ /** Extension of a file name (lowercase, no leading dot); '' when none.
834
+ * Dotfiles map to their whole name ('.gitignore' → 'gitignore') so the
835
+ * badge table can cover them; 'notes.' still yields ''. */
836
+ const extOf = (name) => {
837
+ const dot = name.lastIndexOf('.')
838
+ if (dot > 0) return name.slice(dot + 1).toLowerCase()
839
+ if (dot === 0) return name.slice(1).toLowerCase()
840
+ return ''
841
+ }
842
+
843
+ /** Extension-less but common build files → their badge key. */
844
+ const NAME_BADGES = {
845
+ makefile: 'makefile',
846
+ dockerfile: 'dockerfile',
847
+ 'cmakelists.txt': 'cmake',
848
+ }
849
+
850
+ /** Badge key for a file name: basename match first, then extension. */
851
+ const badgeKeyOf = (name) => {
852
+ const base = name.toLowerCase()
853
+ const named = NAME_BADGES[base]
854
+ if (named !== undefined) return named
855
+ return extOf(name)
856
+ }
857
+
858
+ /** A stats-tree file row: icon + name + count pills + relative time. */
859
+ const fileRow = (file, depth, onOpen) =>
860
+ createElement(
861
+ 'div',
862
+ {
863
+ key: file.abs,
864
+ className: 'dfa-row',
865
+ onClick: () => onOpen(file.abs),
866
+ style: { paddingLeft: 8 + depth * 20 },
867
+ title: fileTitle(file.abs, file.firstSeen, file.lastSeen),
868
+ },
869
+ createElement('span', { className: 'dfa-row-icon dfa-icon-file' }, fileIconByExt(badgeKeyOf(file.name))),
870
+ createElement('span', { className: 'dfa-row-name dfa-name-file' }, file.name),
871
+ countPills(file),
872
+ file.lastSeen ? createElement('span', { className: 'dfa-time' }, formatRelative(file.lastSeen)) : null,
873
+ )
874
+
875
+ /** One stats-tree node: file rows render inline, dirs toggle collapse. */
876
+ function renderTreeNode(node, depth, collapsedDirs, onToggleDir, onOpen) {
877
+ if (node.type === 'file') return fileRow(node, depth, onOpen)
878
+ const collapsed = collapsedDirs.has(node.path)
879
+ return createElement(
880
+ 'div',
881
+ { key: node.path },
882
+ createElement(
883
+ 'div',
884
+ {
885
+ className: 'dfa-row dfa-row-dir',
886
+ onClick: () => onToggleDir(node.path),
887
+ style: { paddingLeft: 8 + depth * 20 },
888
+ title: `${node.path}/`,
889
+ },
890
+ createElement('span', { className: 'dfa-chevron' }, collapsed ? icon.chevronRight(13) : icon.chevronDown(13)),
891
+ createElement('span', { className: 'dfa-row-icon dfa-icon-folder' }, icon.folder(14)),
892
+ createElement('span', { className: 'dfa-row-name' }, node.compressed ? node.name : node.name + '/'),
893
+ countPills(node),
894
+ ),
895
+ collapsed
896
+ ? null
897
+ : node.children.map((child) => renderTreeNode(child, depth + 1, collapsedDirs, onToggleDir, onOpen)),
898
+ )
899
+ }
900
+
901
+ /** A recent-list row: op badge + basename + relative time. */
902
+ const recentEntry = (entry, onOpen) =>
903
+ createElement(
904
+ 'div',
905
+ {
906
+ key: `${entry.path}:${entry.time}:${entry.op}`,
907
+ className: 'dfa-row',
908
+ onClick: () => onOpen(entry.path),
909
+ title: entry.path,
910
+ },
911
+ createElement('span', { className: `dfa-op ${opClass(entry.op)}` }, opLabel(entry.op)),
912
+ createElement('span', { className: 'dfa-row-name' }, basenameOf(entry.path)),
913
+ createElement('span', { className: 'dfa-time' }, formatRelative(entry.time)),
914
+ )
915
+
916
+ /** Toggle a key in a Set (directory collapse state). */
917
+ function toggleInSet(set, key) {
918
+ const next = new Set(set)
919
+ if (next.has(key)) next.delete(key)
920
+ else next.add(key)
921
+ return next
922
+ }
923
+
924
+ /** Clear the current session's records host-side and reset its bucket. */
925
+ function clearSessionData(dataStore, sessionId) {
926
+ if (!window.confirm(strings.clearConfirm())) return
927
+ postClear(sessionId)
928
+ const current = dataStore.getSnapshot()
929
+ dataStore.set({
930
+ bySession: {
931
+ ...(current.bySession ?? {}),
932
+ [sessionId]: { recent: [], counts: {}, loading: false },
933
+ },
934
+ })
935
+ }
936
+
937
+ /** Manual refresh: fetch stats + the authoritative cwd for this session. */
938
+ function refreshSessionData(dataStore, sessionId, setCwd, setError) {
939
+ if (sessionId === '') return
940
+ void fetchStats(sessionId)
941
+ .then((value) => {
942
+ if (value === null) return
943
+ setCwd((prev) => prev || value.cwd || '')
748
944
  const current = dataStore.getSnapshot()
749
945
  dataStore.set({
750
946
  bySession: {
751
947
  ...(current.bySession ?? {}),
752
- [sessionId]: { recent: [], counts: {}, loading: false },
948
+ [sessionId]: { recent: value.recent ?? [], counts: value.counts ?? {}, loading: false },
753
949
  },
754
950
  })
755
- }
951
+ setError(false)
952
+ })
953
+ .catch(() => setError(true))
954
+ void fetchSessionCwd(sessionId).then((cwd) => {
955
+ if (cwd !== '') setCwd(cwd)
956
+ })
957
+ }
958
+
959
+ // ── view component ────────────────────────────────────────────────────
960
+ /** Shared empty bucket for sessions that have never loaded data (stable ref). */
961
+ const EMPTY_SESSION = { recent: [], counts: {}, loading: true }
756
962
 
757
- /** Manual refresh: fetch stats + the authoritative cwd for this session. */
758
- function refreshSessionData(dataStore, sessionId, setCwd, setError) {
759
- if (sessionId === '') return
760
- void fetchStats(sessionId).then((value) => {
761
- if (value === null) return
762
- setCwd((prev) => prev || value.cwd || '')
763
- const current = dataStore.getSnapshot()
764
- dataStore.set({
765
- bySession: {
766
- ...(current.bySession ?? {}),
767
- [sessionId]: { recent: value.recent ?? [], counts: value.counts ?? {}, loading: false },
768
- },
769
- })
770
- setError(false)
771
- }).catch(() => setError(true))
772
- void fetchSessionCwd(sessionId).then((cwd) => {
773
- if (cwd !== '') setCwd(cwd)
774
- })
775
- }
776
-
777
- // ── view component ────────────────────────────────────────────────────
778
- /** Shared empty bucket for sessions that have never loaded data (stable ref). */
779
- const EMPTY_SESSION = { recent: [], counts: {}, loading: true }
780
-
781
- /**
782
- * Polling loader for one session: fetches stats on mount and on a fixed
783
- * interval while visible, prefers the sidebar's authoritative session.cwd
784
- * for relative display, and writes results into the per-session bucket.
785
- */
786
- function useSessionLoader(visible, sessionId, scope, dataStore, setCwd, setError) {
787
- useEffect(() => {
788
- if (!visible || sessionId === '') return
789
- let cancelled = false
790
- const load = () => {
791
- void fetchStats(sessionId).then((value) => {
792
- if (cancelled || value === null) return
793
- setCwd((prev) => prev || value.cwd || '')
794
- const current = dataStore.getSnapshot()
795
- dataStore.set({
796
- bySession: {
797
- ...(current.bySession ?? {}),
798
- [sessionId]: { recent: value.recent ?? [], counts: value.counts ?? {}, loading: false },
963
+ /**
964
+ * Polling loader for one session: fetches stats on mount and on a fixed
965
+ * interval while visible, prefers the sidebar's authoritative session.cwd
966
+ * for relative display, and writes results into the per-session bucket.
967
+ */
968
+ function useSessionLoader(visible, sessionId, scope, dataStore, setCwd, setError) {
969
+ useEffect(() => {
970
+ if (!visible || sessionId === '') return
971
+ let cancelled = false
972
+ const load = () => {
973
+ void fetchStats(sessionId)
974
+ .then((value) => {
975
+ if (cancelled || value === null) return
976
+ setCwd((prev) => prev || value.cwd || '')
977
+ const current = dataStore.getSnapshot()
978
+ dataStore.set({
979
+ bySession: {
980
+ ...(current.bySession ?? {}),
981
+ [sessionId]: {
982
+ recent: value.recent ?? [],
983
+ counts: value.counts ?? {},
984
+ loading: false,
799
985
  },
800
- })
801
- setError(false)
802
- }).catch(() => {
803
- if (!cancelled) setError(true)
986
+ },
804
987
  })
805
- }
806
- load()
807
- void fetchSessionCwd(sessionId).then((cwd) => {
808
- if (!cancelled && cwd !== '') setCwd(cwd)
988
+ setError(false)
989
+ })
990
+ .catch(() => {
991
+ if (!cancelled) setError(true)
809
992
  })
810
- const timer = window.setInterval(load, POLL_MS)
811
- return () => {
812
- cancelled = true
813
- window.clearInterval(timer)
814
- }
815
- }, [visible, sessionId, dataStore])
816
993
  }
817
-
818
- /** Error banner element, or null when the last load succeeded. */
819
- function renderError(error) {
820
- if (!error) return null
821
- return createElement('div', { style: { color: 'var(--dsw-alias-state-error-primary)', padding: '4px 6px', font: 'var(--dsw-font-xxs-12)' } }, strings.loadError())
994
+ load()
995
+ void fetchSessionCwd(sessionId).then((cwd) => {
996
+ if (!cancelled && cwd !== '') setCwd(cwd)
997
+ })
998
+ const timer = window.setInterval(load, POLL_MS)
999
+ return () => {
1000
+ cancelled = true
1001
+ window.clearInterval(timer)
822
1002
  }
823
-
824
- /** "最近访问" section: collapsible head with refresh/clear actions. */
825
- function renderRecentSection(recent, recentOpen, onToggle, onRefresh, onClear, onOpen) {
826
- return createElement(
827
- 'div',
828
- { className: 'dfa-section' },
1003
+ }, [visible, sessionId, dataStore])
1004
+ }
1005
+
1006
+ /** Error banner element, or null when the last load succeeded. */
1007
+ function renderError(error) {
1008
+ if (!error) return null
1009
+ return createElement(
1010
+ 'div',
1011
+ {
1012
+ style: {
1013
+ color: 'var(--dsw-alias-state-error-primary)',
1014
+ padding: '4px 6px',
1015
+ font: 'var(--dsw-font-xxs-12)',
1016
+ },
1017
+ },
1018
+ strings.loadError(),
1019
+ )
1020
+ }
1021
+
1022
+ /** "最近访问" section: collapsible head with refresh/clear actions. */
1023
+ function renderRecentSection(recent, recentOpen, onToggle, onRefresh, onClear, onOpen) {
1024
+ return createElement(
1025
+ 'div',
1026
+ { className: 'dfa-section' },
1027
+ createElement(
1028
+ 'div',
1029
+ { className: 'dfa-section-head' },
1030
+ createElement(
1031
+ 'button',
1032
+ { className: 'dfa-section-head-toggle', onClick: onToggle },
1033
+ recentOpen ? icon.chevronDown(13) : icon.chevronRight(13),
1034
+ strings.recent(),
1035
+ ),
1036
+ createElement(
1037
+ 'span',
1038
+ { className: 'dfa-section-head-actions' },
829
1039
  createElement(
830
- 'div',
831
- { className: 'dfa-section-head' },
832
- createElement(
833
- 'button',
834
- { className: 'dfa-section-head-toggle', onClick: onToggle },
835
- recentOpen ? icon.chevronDown(13) : icon.chevronRight(13),
836
- strings.recent(),
837
- ),
838
- createElement('span', { className: 'dfa-section-head-actions' },
839
- createElement('button', { className: 'dfa-iconbtn dfa-iconbtn-xs', onClick: onRefresh, title: strings.refresh(), 'aria-label': strings.refresh() }, icon.refresh(14)),
840
- createElement('button', { className: 'dfa-iconbtn dfa-iconbtn-xs dfa-iconbtn-danger', onClick: onClear, title: strings.clear(), 'aria-label': strings.clear() }, icon.trash(14)),
1040
+ 'button',
1041
+ {
1042
+ className: 'dfa-iconbtn dfa-iconbtn-xs',
1043
+ onClick: onRefresh,
1044
+ title: strings.refresh(),
1045
+ 'aria-label': strings.refresh(),
1046
+ },
1047
+ icon.refresh(14),
1048
+ ),
1049
+ createElement(
1050
+ 'button',
1051
+ {
1052
+ className: 'dfa-iconbtn dfa-iconbtn-xs dfa-iconbtn-danger',
1053
+ onClick: onClear,
1054
+ title: strings.clear(),
1055
+ 'aria-label': strings.clear(),
1056
+ },
1057
+ icon.trash(14),
1058
+ ),
1059
+ ),
1060
+ ),
1061
+ !recentOpen
1062
+ ? null
1063
+ : recent.length === 0
1064
+ ? createElement(
1065
+ 'div',
1066
+ { className: 'dfa-empty' },
1067
+ strings.empty(),
1068
+ createElement('span', { className: 'dfa-empty-hint' }, strings.emptyHint()),
1069
+ )
1070
+ : createElement(
1071
+ 'div',
1072
+ { className: 'dfa-list' },
1073
+ recent.map((entry) => recentEntry(entry, onOpen)),
841
1074
  ),
1075
+ )
1076
+ }
1077
+
1078
+ /** "文件统计" section: the directory tree, or an empty hint. */
1079
+ function renderStatsSection(tree, collapsedDirs, onToggleDir, onOpen) {
1080
+ return createElement(
1081
+ 'div',
1082
+ { className: 'dfa-section' },
1083
+ createElement('div', { className: 'dfa-section-head' }, strings.stats()),
1084
+ tree.children.length === 0
1085
+ ? createElement('div', { className: 'dfa-empty' }, strings.empty())
1086
+ : createElement(
1087
+ 'div',
1088
+ { className: 'dfa-list' },
1089
+ tree.children.map((child) => renderTreeNode(child, 0, collapsedDirs, onToggleDir, onOpen)),
842
1090
  ),
843
- !recentOpen ? null : recent.length === 0
844
- ? createElement(
845
- 'div',
846
- { className: 'dfa-empty' },
847
- strings.empty(),
848
- createElement('span', { className: 'dfa-empty-hint' }, strings.emptyHint()),
849
- )
850
- : createElement('div', { className: 'dfa-list' }, recent.map((entry) => recentEntry(entry, onOpen))),
851
- )
852
- }
853
-
854
- /** "文件统计" section: the directory tree, or an empty hint. */
855
- function renderStatsSection(tree, collapsedDirs, onToggleDir, onOpen) {
856
- return createElement(
857
- 'div',
858
- { className: 'dfa-section' },
859
- createElement('div', { className: 'dfa-section-head' }, strings.stats()),
860
- tree.children.length === 0
861
- ? createElement('div', { className: 'dfa-empty' }, strings.empty())
862
- : createElement('div', { className: 'dfa-list' }, tree.children.map((child) => renderTreeNode(child, 0, collapsedDirs, onToggleDir, onOpen))),
863
- )
864
- }
865
-
866
- /**
867
- * The file-activity tab. Each session renders only its own store bucket:
868
- * a fresh conversation shows an empty list immediately, with no residue
869
- * from the previous session. Clicking any file opens a FLOATING preview
870
- * that reuses the sidebar's NATIVE viewer via matchFileViewer.
871
- */
872
- function FileActivityView({ ctx, store, scope, visible, dataStore }) {
873
- const data = useSyncExternalStore(dataStore.subscribe, dataStore.getSnapshot)
874
- const [cwd, setCwd] = useState(scope?.cwd || '')
875
- const [error, setError] = useState(false)
876
- const [recentOpen, setRecentOpen] = useState(true)
877
- const [collapsedDirs, setCollapsedDirs] = useState(() => new Set())
878
- const sessionId = scope?.sessionId ?? ''
879
- const sessionData = (data.bySession ?? {})[sessionId] ?? EMPTY_SESSION
880
- const tree = useMemo(() => buildTree(sessionData.counts ?? {}), [sessionData.counts])
881
- useEffect(() => {
882
- if (scope?.cwd) setCwd(scope.cwd)
883
- }, [scope?.cwd])
884
- useSessionLoader(visible, sessionId, scope, dataStore, setCwd, setError)
885
- // Switching conversations closes any floating preview left open by the
886
- // previous session (preview is shared UI state; session data never
887
- // crosses sessions anymore).
888
- useEffect(() => {
889
- dataStore.set({ preview: null })
890
- }, [sessionId, dataStore])
891
- const toggleDir = (path) => setCollapsedDirs((prev) => toggleInSet(prev, path))
892
- const openPreview = (path) => dataStore.set({ preview: { abs: path, name: basenameOf(path) } })
893
- const closePreview = () => dataStore.set({ preview: null })
894
- const onClear = () => clearSessionData(dataStore, sessionId)
895
- const onRefresh = () => refreshSessionData(dataStore, sessionId, setCwd, setError)
896
- const recent = sessionData.recent ?? []
897
- return createElement('div', { className: 'dfa' },
898
- renderError(error),
899
- renderRecentSection(recent, recentOpen, () => setRecentOpen((v) => !v), onRefresh, onClear, openPreview),
900
- renderStatsSection(tree, collapsedDirs, toggleDir, openPreview),
901
- data.preview
902
- ? createElement(FloatingPreview, { ctx, store, scope, preview: data.preview, onClose: closePreview })
903
- : null,
904
- )
905
- }
1091
+ )
1092
+ }
906
1093
 
907
- // ── floating preview window (reuses the sidebar's native viewer) ──────
908
- /** Resolve a possibly-relative path against the session cwd. */
909
- function resolvePath(path, cwd) {
910
- if (typeof path !== 'string' || path === '') return path
911
- if (path.startsWith('/')) return path
912
- if (typeof cwd === 'string' && cwd !== '') return `${cwd.replace(/\/+$/, '')}/${path}`
913
- return path
914
- }
915
-
916
- /** Whether the fs.read API response carries a text content payload. */
917
- function isFsReadOk(json) {
918
- return json !== null && typeof json === 'object' && json.ok === true && typeof json.value?.content === 'string'
919
- }
1094
+ /**
1095
+ * The file-activity tab. Each session renders only its own store bucket:
1096
+ * a fresh conversation shows an empty list immediately, with no residue
1097
+ * from the previous session. Clicking any file opens a FLOATING preview
1098
+ * that reuses the sidebar's NATIVE viewer via matchFileViewer.
1099
+ */
1100
+ function FileActivityView({ ctx, store, scope, visible, dataStore }) {
1101
+ const data = useSyncExternalStore(dataStore.subscribe, dataStore.getSnapshot)
1102
+ const [cwd, setCwd] = useState(scope?.cwd || '')
1103
+ const [error, setError] = useState(false)
1104
+ const [recentOpen, setRecentOpen] = useState(true)
1105
+ const [collapsedDirs, setCollapsedDirs] = useState(() => new Set())
1106
+ const sessionId = scope?.sessionId ?? ''
1107
+ const sessionData = (data.bySession ?? {})[sessionId] ?? EMPTY_SESSION
1108
+ const tree = useMemo(() => buildTree(sessionData.counts ?? {}), [sessionData.counts])
1109
+ useEffect(() => {
1110
+ if (scope?.cwd) setCwd(scope.cwd)
1111
+ }, [scope?.cwd])
1112
+ useSessionLoader(visible, sessionId, scope, dataStore, setCwd, setError)
1113
+ // Switching conversations closes any floating preview left open by the
1114
+ // previous session (preview is shared UI state; session data never
1115
+ // crosses sessions anymore).
1116
+ useEffect(() => {
1117
+ dataStore.set({ preview: null })
1118
+ }, [sessionId, dataStore])
1119
+ const toggleDir = (path) => setCollapsedDirs((prev) => toggleInSet(prev, path))
1120
+ const openPreview = (path) => dataStore.set({ preview: { abs: path, name: basenameOf(path) } })
1121
+ const closePreview = () => dataStore.set({ preview: null })
1122
+ const onClear = () => clearSessionData(dataStore, sessionId)
1123
+ const onRefresh = () => refreshSessionData(dataStore, sessionId, setCwd, setError)
1124
+ const recent = sessionData.recent ?? []
1125
+ return createElement(
1126
+ 'div',
1127
+ { className: 'dfa' },
1128
+ renderError(error),
1129
+ renderRecentSection(recent, recentOpen, () => setRecentOpen((v) => !v), onRefresh, onClear, openPreview),
1130
+ renderStatsSection(tree, collapsedDirs, toggleDir, openPreview),
1131
+ data.preview
1132
+ ? createElement(FloatingPreview, {
1133
+ ctx,
1134
+ store,
1135
+ scope,
1136
+ preview: data.preview,
1137
+ onClose: closePreview,
1138
+ })
1139
+ : null,
1140
+ )
1141
+ }
1142
+
1143
+ // ── floating preview window (reuses the sidebar's native viewer) ──────
1144
+ /** Resolve a possibly-relative path against the session cwd. */
1145
+ function resolvePath(path, cwd) {
1146
+ if (typeof path !== 'string' || path === '') return path
1147
+ if (path.startsWith('/')) return path
1148
+ if (typeof cwd === 'string' && cwd !== '') return `${cwd.replace(/\/+$/, '')}/${path}`
1149
+ return path
1150
+ }
1151
+
1152
+ /** Whether the fs.read API response carries a text content payload. */
1153
+ function isFsReadOk(json) {
1154
+ return json !== null && typeof json === 'object' && json.ok === true && typeof json.value?.content === 'string'
1155
+ }
1156
+
1157
+ /** Error load state from an fs.read API response (or a generic message). */
1158
+ function fsReadError(json, viewer) {
1159
+ return { status: 'error', viewer, message: json?.error?.message ?? strings.previewFailed() }
1160
+ }
920
1161
 
921
- /** Error load state from an fs.read API response (or a generic message). */
922
- function fsReadError(json, viewer) {
923
- return { status: 'error', viewer, message: json?.error?.message ?? strings.previewFailed() }
924
- }
1162
+ /**
1163
+ * Load fsRead content through the sidebar API and resolve the viewer's
1164
+ * load state (ready with text, or error with the API message).
1165
+ */
1166
+ async function loadFsReadContent(viewer, path, scope, sessionId) {
1167
+ const target = resolvePath(path, scope?.cwd ?? '')
1168
+ const response = await fetch('/sidebar/api/fs.read', {
1169
+ method: 'POST',
1170
+ headers: { 'content-type': 'application/json' },
1171
+ body: JSON.stringify({ sessionId, path: target }),
1172
+ })
1173
+ const json = await response.json()
1174
+ if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
1175
+ return fsReadError(json, viewer)
1176
+ }
925
1177
 
926
- /**
927
- * Load fsRead content through the sidebar API and resolve the viewer's
928
- * load state (ready with text, or error with the API message).
929
- */
930
- async function loadFsReadContent(viewer, path, scope, sessionId) {
931
- const target = resolvePath(path, scope?.cwd ?? '')
932
- const response = await fetch('/sidebar/api/fs.read', {
933
- method: 'POST',
934
- headers: { 'content-type': 'application/json' },
935
- body: JSON.stringify({ sessionId, path: target }),
936
- })
937
- const json = await response.json()
938
- if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
939
- return fsReadError(json, viewer)
940
- }
1178
+ /**
1179
+ * Fetch the bytes the viewer's fetchStrategy needs (fsRead text /
1180
+ * mediaUrl / customData) and resolve its load state.
1181
+ */
1182
+ async function fetchPreviewLoad(viewer, path, scope, sessionId) {
1183
+ const strategy = viewer.fetchStrategy
1184
+ if (strategy === 'fsRead') return loadFsReadContent(viewer, path, scope, sessionId)
1185
+ if (strategy === 'mediaUrl') {
1186
+ return { status: 'ready', viewer, mediaUrl: mediaUrlOf(sessionId, path) }
1187
+ }
1188
+ if (strategy === 'custom') {
1189
+ const data = await (viewer.load?.(path, scope) ?? Promise.resolve(undefined))
1190
+ return { status: 'ready', viewer, customData: data }
1191
+ }
1192
+ // 'binary-download' and anything else: mount the viewer's own
1193
+ // component (it handles the download / media itself).
1194
+ return { status: 'ready', viewer }
1195
+ }
941
1196
 
942
- /**
943
- * Fetch the bytes the viewer's fetchStrategy needs (fsRead text /
944
- * mediaUrl / customData) and resolve its load state.
945
- */
946
- async function fetchPreviewLoad(viewer, path, scope, sessionId) {
947
- const strategy = viewer.fetchStrategy
948
- if (strategy === 'fsRead') return loadFsReadContent(viewer, path, scope, sessionId)
949
- if (strategy === 'mediaUrl') {
950
- return { status: 'ready', viewer, mediaUrl: mediaUrlOf(sessionId, path) }
951
- }
952
- if (strategy === 'custom') {
953
- const data = await (viewer.load?.(path, scope) ?? Promise.resolve(undefined))
954
- return { status: 'ready', viewer, customData: data }
1197
+ /**
1198
+ * Resolve the file's viewer through the sidebar registry and load the
1199
+ * bytes it needs; failures become an error state shown in the window.
1200
+ */
1201
+ function usePreviewLoader(service, path, sessionId, scope) {
1202
+ const [load, setLoad] = useState({ status: 'loading', viewer: null })
1203
+ useEffect(() => {
1204
+ let cancelled = false
1205
+ const viewer = service?.matchFileViewer?.(path)
1206
+ if (!viewer) {
1207
+ setLoad({ status: 'error', viewer: null, message: strings.previewUnsupported() })
1208
+ return () => {
1209
+ cancelled = true
955
1210
  }
956
- // 'binary-download' and anything else: mount the viewer's own
957
- // component (it handles the download / media itself).
958
- return { status: 'ready', viewer }
959
1211
  }
960
-
961
- /**
962
- * Resolve the file's viewer through the sidebar registry and load the
963
- * bytes it needs; failures become an error state shown in the window.
964
- */
965
- function usePreviewLoader(service, path, sessionId, scope) {
966
- const [load, setLoad] = useState({ status: 'loading', viewer: null })
967
- useEffect(() => {
968
- let cancelled = false
969
- const viewer = service?.matchFileViewer?.(path)
970
- if (!viewer) {
971
- setLoad({ status: 'error', viewer: null, message: strings.previewUnsupported() })
972
- return () => { cancelled = true }
973
- }
974
- setLoad({ status: 'loading', viewer })
975
- fetchPreviewLoad(viewer, path, scope, sessionId)
976
- .then((next) => {
977
- if (!cancelled) setLoad(next)
978
- })
979
- .catch((error) => {
980
- if (!cancelled) setLoad({ status: 'error', viewer, message: error instanceof Error ? error.message : String(error) })
1212
+ setLoad({ status: 'loading', viewer })
1213
+ fetchPreviewLoad(viewer, path, scope, sessionId)
1214
+ .then((next) => {
1215
+ if (!cancelled) setLoad(next)
1216
+ })
1217
+ .catch((error) => {
1218
+ if (!cancelled)
1219
+ setLoad({
1220
+ status: 'error',
1221
+ viewer,
1222
+ message: error instanceof Error ? error.message : String(error),
981
1223
  })
982
- return () => { cancelled = true }
983
- }, [path, sessionId, scope])
984
- return load
985
- }
986
-
987
- /** Preview window body: loading note / error panel / viewer mount. */
988
- function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
989
- if (load.status === 'loading') {
990
- return createElement('div', { className: 'dfa-fp-note' }, strings.loading())
991
- }
992
- if (load.status === 'error') {
993
- return createElement('div', { className: 'dfa-fp-err' },
994
- strings.previewFailed(),
995
- load.message
996
- ? createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.85 } }, load.message)
997
- : null,
998
- )
999
- }
1000
- if (load.viewer.id === 'pdf') {
1001
- const url = mediaUrlOf(sessionId, path)
1002
- return createElement(PdfPreview, { src: url, download: `${url}&download=1`, title })
1003
- }
1004
- return createElement(load.viewer.component, {
1005
- ctx, store, scope, path, title,
1006
- viewerId: load.viewer.id,
1007
- content: load.content,
1008
- mediaUrl: load.mediaUrl,
1009
- customData: load.customData,
1010
1224
  })
1225
+ return () => {
1226
+ cancelled = true
1011
1227
  }
1228
+ }, [path, sessionId, scope])
1229
+ return load
1230
+ }
1231
+
1232
+ /** Preview window body: loading note / error panel / viewer mount. */
1233
+ function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
1234
+ if (load.status === 'loading') {
1235
+ return createElement('div', { className: 'dfa-fp-note' }, strings.loading())
1236
+ }
1237
+ if (load.status === 'error') {
1238
+ return createElement(
1239
+ 'div',
1240
+ { className: 'dfa-fp-err' },
1241
+ strings.previewFailed(),
1242
+ load.message
1243
+ ? createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.85 } }, load.message)
1244
+ : null,
1245
+ )
1246
+ }
1247
+ if (load.viewer.id === 'pdf') {
1248
+ const url = mediaUrlOf(sessionId, path)
1249
+ return createElement(PdfPreview, { src: url, download: `${url}&download=1`, title })
1250
+ }
1251
+ return createElement(load.viewer.component, {
1252
+ ctx,
1253
+ store,
1254
+ scope,
1255
+ path,
1256
+ title,
1257
+ viewerId: load.viewer.id,
1258
+ content: load.content,
1259
+ mediaUrl: load.mediaUrl,
1260
+ customData: load.customData,
1261
+ })
1262
+ }
1012
1263
 
1013
- /**
1014
- * A floating preview window. Instead of re-implementing rendering, it
1015
- * asks the sidebar registry for the file's viewer (`matchFileViewer`),
1016
- * fetches the bytes the viewer's fetchStrategy needs (fsRead text /
1017
- * mediaUrl / customData), then mounts that viewer's own component — so
1018
- * code gets syntax highlighting and markdown gets rendered by the SAME
1019
- * built-in renderers the sidebar's editor tab uses.
1020
- *
1021
- * Media caveat: the sidebar's own media route (/sidebar/file) only serves
1022
- * files inside the session working directory, while file activity records
1023
- * files the agent touched anywhere (/tmp scratch files, sibling repos…).
1024
- * Media bytes therefore come from OUR route (/file-activity/file), which
1025
- * authorizes exactly the paths this session recorded; PDF is the one
1026
- * built-in viewer that fetches its own URL internally (it ignores the
1027
- * `mediaUrl` prop), so it gets a small iframe preview instead.
1028
- */
1029
- function FloatingPreview({ ctx, store, scope, preview, onClose }) {
1030
- const sessionId = scope?.sessionId ?? ''
1031
- const path = preview.abs
1032
- const title = preview.name
1033
- const service = ctx.betterSidebar
1034
- const load = usePreviewLoader(service, path, sessionId, scope)
1035
-
1036
- // Clicking outside is the primary dismiss (the overlay's onClick);
1037
- // Escape is a keyboard affordance. Both call onClose.
1038
- useEffect(() => {
1039
- if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
1040
- const handler = (event) => { if (event && event.key === 'Escape') onClose() }
1041
- document.addEventListener('keydown', handler)
1042
- return () => document.removeEventListener('keydown', handler)
1043
- }, [onClose])
1044
-
1045
- return createElement(
1264
+ /**
1265
+ * A floating preview window. Instead of re-implementing rendering, it
1266
+ * asks the sidebar registry for the file's viewer (`matchFileViewer`),
1267
+ * fetches the bytes the viewer's fetchStrategy needs (fsRead text /
1268
+ * mediaUrl / customData), then mounts that viewer's own component — so
1269
+ * code gets syntax highlighting and markdown gets rendered by the SAME
1270
+ * built-in renderers the sidebar's editor tab uses.
1271
+ *
1272
+ * Media caveat: the sidebar's own media route (/sidebar/file) only serves
1273
+ * files inside the session working directory, while file activity records
1274
+ * files the agent touched anywhere (/tmp scratch files, sibling repos…).
1275
+ * Media bytes therefore come from OUR route (/file-activity/file), which
1276
+ * authorizes exactly the paths this session recorded; PDF is the one
1277
+ * built-in viewer that fetches its own URL internally (it ignores the
1278
+ * `mediaUrl` prop), so it gets a small iframe preview instead.
1279
+ */
1280
+ function FloatingPreview({ ctx, store, scope, preview, onClose }) {
1281
+ const sessionId = scope?.sessionId ?? ''
1282
+ const path = preview.abs
1283
+ const title = preview.name
1284
+ const service = ctx.betterSidebar
1285
+ const load = usePreviewLoader(service, path, sessionId, scope)
1286
+
1287
+ // Clicking outside is the primary dismiss (the overlay's onClick);
1288
+ // Escape is a keyboard affordance. Both call onClose.
1289
+ useEffect(() => {
1290
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
1291
+ const handler = (event) => {
1292
+ if (event && event.key === 'Escape') onClose()
1293
+ }
1294
+ document.addEventListener('keydown', handler)
1295
+ return () => document.removeEventListener('keydown', handler)
1296
+ }, [onClose])
1297
+
1298
+ return createElement(
1299
+ 'div',
1300
+ { className: 'dfa-fp-overlay', onClick: onClose },
1301
+ createElement(
1302
+ 'div',
1303
+ {
1304
+ className: 'dfa-fp',
1305
+ onClick: (event) => {
1306
+ if (event && event.stopPropagation) event.stopPropagation()
1307
+ },
1308
+ },
1309
+ createElement(
1046
1310
  'div',
1047
- { className: 'dfa-fp-overlay', onClick: onClose },
1311
+ { className: 'dfa-fp-head' },
1312
+ createElement('span', { className: 'dfa-fp-title' }, title),
1048
1313
  createElement(
1049
- 'div',
1050
- { className: 'dfa-fp', onClick: (event) => { if (event && event.stopPropagation) event.stopPropagation() } },
1314
+ 'span',
1315
+ { className: 'dfa-fp-actions' },
1051
1316
  createElement(
1052
- 'div',
1053
- { className: 'dfa-fp-head' },
1054
- createElement('span', { className: 'dfa-fp-title' }, title),
1055
- createElement(
1056
- 'span',
1057
- { className: 'dfa-fp-actions' },
1058
- createElement('button', { className: 'dfa-iconbtn', title: strings.closePreview(), 'aria-label': strings.closePreview(), onClick: () => onClose() },
1059
- icon.close(15),
1060
- ),
1061
- ),
1317
+ 'button',
1318
+ {
1319
+ className: 'dfa-iconbtn',
1320
+ title: strings.closePreview(),
1321
+ 'aria-label': strings.closePreview(),
1322
+ onClick: () => onClose(),
1323
+ },
1324
+ icon.close(15),
1062
1325
  ),
1063
- createElement('div', { className: 'dfa-fp-body' }, renderPreviewBody(load, ctx, store, scope, path, title, sessionId)),
1064
1326
  ),
1065
- )
1066
- }
1067
-
1068
- /**
1069
- * Lightweight PDF preview. better-sidebar's built-in PdfView fetches
1070
- * `/sidebar/file` internally (it ignores any injected `mediaUrl` prop),
1071
- * and that route refuses files outside the session working directory — so
1072
- * a recorded /tmp PDF would never load. This tiny view embeds the bytes
1073
- * from OUR media route in a native browser PDF frame, with a download
1074
- * fallback in its toolbar.
1075
- */
1076
- function PdfPreview({ src, download, title }) {
1077
- return createElement('div', { className: 'dfa-pdf' },
1078
- createElement('div', { className: 'dfa-pdf-toolbar' },
1079
- createElement('a', { className: 'dfa-pdf-download', href: download, download: true, title: strings.downloadToView() },
1080
- strings.downloadToView()),
1081
- ),
1082
- createElement('iframe', { className: 'dfa-pdf-frame', src, title }),
1083
- )
1084
- }
1085
-
1086
- // ── plugin body ───────────────────────────────────────────────────────
1087
- /**
1088
- * The stylesheet is pure static CSS and must NOT depend on the
1089
- * betterSidebar service: inject it first, unconditionally. If it lived
1090
- * behind the `service === undefined` early return, an HMR rebuild or
1091
- * service reload could leave the already-rendered tab WITHOUT its
1092
- * stylesheet — the raw white-text list you see when the CSS is gone.
1093
- * Each fiber owns its own <style> element and the disposer removes
1094
- * only that element, so a rebuild always keeps at least one copy.
1095
- */
1096
- function injectStyles(ctx) {
1097
- ctx.effect(() => {
1098
- if (typeof document === 'undefined' || document === null || typeof document.head === 'undefined') return () => {}
1099
- const style = document.createElement('style')
1100
- style.setAttribute('data-dsh-file-activity', 'styles')
1101
- style.textContent = STYLES
1102
- document.head.appendChild(style)
1103
- return () => {
1104
- if (style.parentNode) style.parentNode.removeChild(style)
1105
- }
1106
- }, 'dsh-file-activity: styles')
1107
- }
1327
+ ),
1328
+ createElement(
1329
+ 'div',
1330
+ { className: 'dfa-fp-body' },
1331
+ renderPreviewBody(load, ctx, store, scope, path, title, sessionId),
1332
+ ),
1333
+ ),
1334
+ )
1335
+ }
1108
1336
 
1109
- /** Mount probe: report client activation to the host state (synthetic
1110
- * session id, invisible in the UI — confirms the client half actually
1111
- * loaded after a page refresh). */
1112
- function mountProbe() {
1113
- void fetch('/file-activity/api/record', {
1114
- method: 'POST',
1115
- headers: { 'content-type': 'application/json' },
1116
- body: JSON.stringify({ sessionId: '__probe__', path: 'mounted', op: 'read' }),
1117
- }).catch(() => {})
1337
+ /**
1338
+ * Lightweight PDF preview. better-sidebar's built-in PdfView fetches
1339
+ * `/sidebar/file` internally (it ignores any injected `mediaUrl` prop),
1340
+ * and that route refuses files outside the session working directory — so
1341
+ * a recorded /tmp PDF would never load. This tiny view embeds the bytes
1342
+ * from OUR media route in a native browser PDF frame, with a download
1343
+ * fallback in its toolbar.
1344
+ */
1345
+ function PdfPreview({ src, download, title }) {
1346
+ return createElement(
1347
+ 'div',
1348
+ { className: 'dfa-pdf' },
1349
+ createElement(
1350
+ 'div',
1351
+ { className: 'dfa-pdf-toolbar' },
1352
+ createElement(
1353
+ 'a',
1354
+ {
1355
+ className: 'dfa-pdf-download',
1356
+ href: download,
1357
+ download: true,
1358
+ title: strings.downloadToView(),
1359
+ },
1360
+ strings.downloadToView(),
1361
+ ),
1362
+ ),
1363
+ createElement('iframe', { className: 'dfa-pdf-frame', src, title }),
1364
+ )
1365
+ }
1366
+
1367
+ // ── plugin body ───────────────────────────────────────────────────────
1368
+ /**
1369
+ * The stylesheet is pure static CSS and must NOT depend on the
1370
+ * betterSidebar service: inject it first, unconditionally. If it lived
1371
+ * behind the `service === undefined` early return, an HMR rebuild or
1372
+ * service reload could leave the already-rendered tab WITHOUT its
1373
+ * stylesheet — the raw white-text list you see when the CSS is gone.
1374
+ * Each fiber owns its own <style> element and the disposer removes
1375
+ * only that element, so a rebuild always keeps at least one copy.
1376
+ */
1377
+ function injectStyles(ctx) {
1378
+ ctx.effect(() => {
1379
+ if (typeof document === 'undefined' || document === null || typeof document.head === 'undefined') return () => {}
1380
+ const style = document.createElement('style')
1381
+ style.setAttribute('data-dsh-file-activity', 'styles')
1382
+ style.textContent = STYLES
1383
+ document.head.appendChild(style)
1384
+ return () => {
1385
+ if (style.parentNode) style.parentNode.removeChild(style)
1118
1386
  }
1119
-
1120
- /** Register the tab (enabled by default in the Side card settings). */
1121
- function registerTab(ctx, dataStore) {
1122
- const service = ctx.betterSidebar
1123
- ctx.effect(() => service.registerTab({
1387
+ }, 'dsh-file-activity: styles')
1388
+ }
1389
+
1390
+ /** Mount probe: report client activation to the host state (synthetic
1391
+ * session id, invisible in the UI — confirms the client half actually
1392
+ * loaded after a page refresh). */
1393
+ function mountProbe() {
1394
+ void fetch('/file-activity/api/record', {
1395
+ method: 'POST',
1396
+ headers: { 'content-type': 'application/json' },
1397
+ body: JSON.stringify({ sessionId: '__probe__', path: 'mounted', op: 'read' }),
1398
+ }).catch(() => {})
1399
+ }
1400
+
1401
+ /** Register the tab (enabled by default in the Side card settings). */
1402
+ function registerTab(ctx, dataStore) {
1403
+ const service = ctx.betterSidebar
1404
+ ctx.effect(
1405
+ () =>
1406
+ service.registerTab({
1124
1407
  id: TAB_ID,
1125
1408
  title: () => strings.title(),
1126
1409
  icon: (size) => icon.clock(size),
1127
1410
  order: 15,
1128
1411
  single: true,
1129
1412
  settings: {
1130
- pluginToggles: [{
1131
- key: 'autoOpen',
1132
- title: () => (isZh() ? '会话开始时自动打开' : 'Auto-open on session start'),
1133
- desc: () => (isZh() ? '每个会话首次打开时自动显示本页(可在侧边栏设置中关闭)' : 'Opens this tab once per session by default (turn off here)'),
1134
- type: 'switch',
1135
- }],
1413
+ pluginToggles: [
1414
+ {
1415
+ key: 'autoOpen',
1416
+ title: () => (isZh() ? '会话开始时自动打开' : 'Auto-open on session start'),
1417
+ desc: () =>
1418
+ isZh()
1419
+ ? '每个会话首次打开时自动显示本页(可在侧边栏设置中关闭)'
1420
+ : 'Opens this tab once per session by default (turn off here)',
1421
+ type: 'switch',
1422
+ },
1423
+ ],
1136
1424
  },
1137
1425
  component: (props) => createElement(FileActivityView, { ...props, dataStore }),
1138
- }), 'dsh-file-activity: tab registration')
1139
- }
1140
-
1141
- exports.inject = ['betterSidebar']
1142
-
1143
- exports.apply = function apply(ctx) {
1144
- // Stylesheet first, unconditionally (HMR pitfall — see injectStyles).
1145
- injectStyles(ctx)
1146
- const service = ctx.betterSidebar
1147
- if (service === undefined) return
1148
-
1149
- // Per-session data store: { bySession: { [sessionId]: { recent, counts, loading } }, preview }
1150
- // Each conversation reads/writes only its own bucket, so switching
1151
- // sessions never leaks another session's file activity into the view.
1152
- const dataStore = createStore({ bySession: {}, preview: null })
1153
- mountProbe()
1154
-
1155
- // sidebar operations → host record route
1156
- ctx.effect(() => installFetchInterceptor(), 'dsh-file-activity: sidebar fetch observation')
1157
- registerTab(ctx, dataStore)
1158
-
1159
- // auto-open once per session (default on)
1160
- ctx.effect(() => installAutoOpen(ctx, TAB_ID), 'dsh-file-activity: auto-open')
1161
- }
1426
+ }),
1427
+ 'dsh-file-activity: tab registration',
1428
+ )
1429
+ }
1430
+
1431
+ exports.inject = ['betterSidebar']
1432
+
1433
+ exports.apply = function apply(ctx) {
1434
+ // Stylesheet first, unconditionally (HMR pitfall — see injectStyles).
1435
+ injectStyles(ctx)
1436
+ const service = ctx.betterSidebar
1437
+ if (service === undefined) return
1438
+
1439
+ // Per-session data store: { bySession: { [sessionId]: { recent, counts, loading } }, preview }
1440
+ // Each conversation reads/writes only its own bucket, so switching
1441
+ // sessions never leaks another session's file activity into the view.
1442
+ const dataStore = createStore({ bySession: {}, preview: null })
1443
+ mountProbe()
1444
+
1445
+ // sidebar operations → host record route
1446
+ ctx.effect(() => installFetchInterceptor(), 'dsh-file-activity: sidebar fetch observation')
1447
+ registerTab(ctx, dataStore)
1448
+
1449
+ // auto-open once per session (default on)
1450
+ ctx.effect(() => installAutoOpen(ctx, TAB_ID), 'dsh-file-activity: auto-open')
1451
+ }
1162
1452
 
1163
1453
 
1164
1454
  return module.exports