dsh-file-activity 0.4.6

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 ADDED
@@ -0,0 +1,1025 @@
1
+ /**
2
+ * dsh-file-activity — client half (browser).
3
+ *
4
+ * Extends dsh-better-sidebar with a "文件活动 / File Activity" tab:
5
+ * - recent file access history (agent + sidebar operations),
6
+ * - per-file create/modify/read counts flattened by folder, with multi-level
7
+ * folders shown as dotted paths (a.b.c.d) and their files indented below,
8
+ * - clicking any file opens a FLOATING preview that reuses the sidebar's
9
+ * NATIVE viewer via `ctx.betterSidebar.matchFileViewer(path)` — its own
10
+ * `component` is mounted (built-in markdown / code / image / pdf / html
11
+ * renderers), so code gets syntax highlighting and markdown gets rendered
12
+ * with no hand-rolled preview; clicking outside / Esc / × closes it,
13
+ * - auto-opens once per session by default (toggleable in the sidebar
14
+ * settings, enabled by default).
15
+ *
16
+ * Data source: the plugin host half (fs/observed for agent tools) + this
17
+ * half's fetch interception for sidebar file operations (fs.read / fs.write /
18
+ * /sidebar/file media opens), both persisted host-side; the tab polls
19
+ * /file-activity/api/stats.
20
+ *
21
+ * Styling follows the dsh-better-sidebar design language: all colors ride the
22
+ * DSH semantic tokens (--dsw-alias-*), typography rides the font roles
23
+ * (--dsw-font-*), motion rides --ds-*. Flat surfaces (no box-shadow), hairline
24
+ * borders, 28px circular icon controls with hover fills, and 8px-radius rows
25
+ * with hover fills. The stylesheet is injected once per activation and torn
26
+ * down with the fiber, so HMR/disable leaves no residue.
27
+ *
28
+ * BUILD NOTE: this file is the SOURCE TEMPLATE. scripts/build.mjs splices the
29
+ * `lib/parts/*.part.js` pieces into the PART placeholder markers below (each
30
+ * piece is plain function-declaration text sharing this factory scope; the
31
+ * browser ModuleLoader does not support relative-path require) and writes
32
+ * lib/client.js — the file actually served by DSH, which MUST be committed
33
+ * (CI runs node --check + tests against it, not against this template).
34
+ */
35
+ window.__ModuleLoader__.load({
36
+ id: 'dsh-file-activity',
37
+ factory: (require) => {
38
+ var module = { exports: {} }
39
+ var exports = module.exports
40
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
41
+ const { createElement, useEffect, useMemo, useState, useSyncExternalStore } = require('react')
42
+
43
+ const TAB_ID = 'file-activity:recent'
44
+ const AUTO_OPEN_KEY = 'dsh-file-activity:auto-opened:'
45
+ const POLL_MS = 6000
46
+
47
+ // ── parts (injected by scripts/build.mjs; keep this exact order — the
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
+ readShort: () => (isZh() ? '读' : 'R'),
74
+ createShort: () => (isZh() ? '增' : 'C'),
75
+ modifyShort: () => (isZh() ? '改' : 'M'),
76
+ loadError: () => (isZh() ? '加载失败' : 'Load failed'),
77
+ created: () => (isZh() ? '创建' : 'Created'),
78
+ lastSeen: () => (isZh() ? '最近访问' : 'Last seen'),
79
+ justNow: () => (isZh() ? '刚刚' : 'just now'),
80
+ minutesAgo: (m) => (isZh() ? `${m} 分钟前` : `${m}m ago`),
81
+ hoursAgo: (h) => (isZh() ? `${h} 小时前` : `${h}h ago`),
82
+ daysAgo: (d) => (isZh() ? `${d} 天前` : `${d}d ago`),
83
+ closePreview: () => (isZh() ? '关闭预览' : 'Close preview'),
84
+ loading: () => (isZh() ? '加载中…' : 'Loading…'),
85
+ previewUnsupported: () => (isZh() ? '该文件类型暂不支持预览' : 'This file type cannot be previewed yet'),
86
+ previewFailed: () => (isZh() ? '预览加载失败' : 'Preview failed to load'),
87
+ downloadToView: () => (isZh() ? '下载查看' : 'download to view'),
88
+ }
89
+
90
+ // ── path / time formatting helpers ────────────────────────────────────
91
+ function basenameOf(path) {
92
+ const norm = path.split('\\').join('/')
93
+ const idx = norm.lastIndexOf('/')
94
+ return idx === -1 ? norm : norm.slice(idx + 1)
95
+ }
96
+
97
+ /** Compact relative time: 刚刚 / N 分钟前 / N 小时前 / N 天前 / MM/DD. */
98
+ function formatRelative(time) {
99
+ if (typeof time !== 'number' || !Number.isFinite(time)) return ''
100
+ const diff = Date.now() - time
101
+ if (diff < 30_000) return strings.justNow()
102
+ const minutes = Math.floor(diff / 60_000)
103
+ if (minutes < 60) return strings.minutesAgo(minutes)
104
+ const hours = Math.floor(minutes / 60)
105
+ if (hours < 24) return strings.hoursAgo(hours)
106
+ const days = Math.floor(hours / 24)
107
+ if (days < 7) return strings.daysAgo(days)
108
+ const date = new Date(time)
109
+ return `${date.getMonth() + 1}/${date.getDate()}`
110
+ }
111
+
112
+ /** Local wall-clock HH:MM:SS (used in tooltips; full precision). */
113
+ function formatTime(time) {
114
+ const date = new Date(time)
115
+ const pad = (n) => String(n).padStart(2, '0')
116
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
117
+ }
118
+
119
+ // ── directory tree construction ───────────────────────────────────────
120
+ /**
121
+ * Collapse chain directories: a directory whose only child is another
122
+ * directory merges into it (a → a.b → a.b.c …). Deep single-child paths
123
+ * render as one dotted label with the file(s) directly beneath.
124
+ * `root` itself is never collapsed (its name is '' and would drop the
125
+ * top-level directory).
126
+ */
127
+ function compressChains(node, isRoot) {
128
+ for (const child of node.children) {
129
+ if (child.type === 'dir') compressChains(child, false)
130
+ }
131
+ if (isRoot) return
132
+ while (node.children.length === 1 && node.children[0].type === 'dir') {
133
+ const only = node.children[0]
134
+ node.name = `${node.name}.${only.name}`
135
+ node.children = only.children
136
+ node.compressed = true
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Sort a directory node: directories first (alphabetically), then files
142
+ * (by total activity, then name); recurse into directories.
143
+ */
144
+ function sortNode(node) {
145
+ node.children.sort((a, b) => {
146
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
147
+ if (a.type === 'dir') return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
148
+ const ta = a.read + a.create + a.modify
149
+ const tb = b.read + b.create + b.modify
150
+ return tb - ta || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
151
+ })
152
+ for (const child of node.children) {
153
+ if (child.type === 'dir') sortNode(child)
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Build a nested directory tree from per-file counts, keyed by the file's
159
+ * absolute path. Every directory node aggregates its subtree counters and
160
+ * sorts directories first (alphabetically), then files (by activity).
161
+ */
162
+ function buildTree(counts) {
163
+ const root = { type: 'dir', name: '', path: '', children: [], read: 0, create: 0, modify: 0 }
164
+ for (const [abs, counter] of Object.entries(counts)) {
165
+ const parts = abs.split('/').filter((part) => part !== '')
166
+ if (parts.length === 0) continue
167
+ const name = parts[parts.length - 1]
168
+ let node = root
169
+ for (const dir of parts.slice(0, -1)) {
170
+ let child = node.children.find((c) => c.type === 'dir' && c.name === dir)
171
+ if (child === undefined) {
172
+ child = { type: 'dir', name: dir, path: `${node.path}/${dir}`, children: [], read: 0, create: 0, modify: 0 }
173
+ node.children.push(child)
174
+ }
175
+ node = child
176
+ node.read += counter.read
177
+ node.create += counter.create
178
+ node.modify += counter.modify
179
+ }
180
+ node.children.push({
181
+ type: 'file', name, abs,
182
+ read: counter.read, create: counter.create, modify: counter.modify,
183
+ firstSeen: counter.firstSeen, lastSeen: counter.lastSeen,
184
+ })
185
+ }
186
+ sortNode(root)
187
+ compressChains(root, true)
188
+ return root
189
+ }
190
+
191
+ // ── tiny external store ───────────────────────────────────────────────
192
+ function createStore(initial) {
193
+ let state = initial
194
+ const listeners = new Set()
195
+ return {
196
+ getSnapshot: () => state,
197
+ set(patch) {
198
+ state = { ...state, ...patch }
199
+ for (const listener of [...listeners]) listener()
200
+ },
201
+ subscribe(listener) {
202
+ listeners.add(listener)
203
+ return () => listeners.delete(listener)
204
+ },
205
+ }
206
+ }
207
+
208
+ // ── data access (host routes) ─────────────────────────────────────────
209
+ async function fetchStats(sessionId) {
210
+ const response = await fetch(`/file-activity/api/stats?sessionId=${encodeURIComponent(sessionId)}`)
211
+ const json = await response.json()
212
+ if (json === null || typeof json !== 'object' || json.ok !== true) return null
213
+ return json.value
214
+ }
215
+
216
+ /** Resolve the session working directory through the sidebar's native API. */
217
+ async function fetchSessionCwd(sessionId) {
218
+ try {
219
+ const response = await fetch('/sidebar/api/session.cwd', {
220
+ method: 'POST',
221
+ headers: { 'content-type': 'application/json' },
222
+ body: JSON.stringify({ sessionId }),
223
+ })
224
+ const json = await response.json()
225
+ const cwd = json?.value?.cwd
226
+ return typeof cwd === 'string' && cwd !== '' ? cwd : ''
227
+ } catch {
228
+ return ''
229
+ }
230
+ }
231
+
232
+ function postRecord(sessionId, path, op) {
233
+ if (typeof sessionId !== 'string' || sessionId === '' || typeof path !== 'string' || path === '') return
234
+ void fetch('/file-activity/api/record', {
235
+ method: 'POST',
236
+ headers: { 'content-type': 'application/json' },
237
+ body: JSON.stringify({ sessionId, path, op }),
238
+ }).catch(() => {})
239
+ }
240
+
241
+ function postClear(sessionId) {
242
+ void fetch('/file-activity/api/clear', {
243
+ method: 'POST',
244
+ headers: { 'content-type': 'application/json' },
245
+ body: JSON.stringify({ sessionId }),
246
+ }).catch(() => {})
247
+ }
248
+
249
+ /** Plugin media route URL for a recorded path (authorized per session). */
250
+ function mediaUrlOf(sessionId, path) {
251
+ return `/file-activity/file?${new URLSearchParams({ sessionId, path })}`
252
+ }
253
+
254
+ // ── fetch interception: sidebar file operations ───────────────────────
255
+ function methodOf(init) {
256
+ return (init?.method ?? 'GET').toUpperCase()
257
+ }
258
+
259
+ /** POST body as a plain object (non-string bodies are ignored). */
260
+ function parseBody(init) {
261
+ return typeof init?.body === 'string' ? JSON.parse(init.body) : {}
262
+ }
263
+
264
+ /** Record fs.read / fs.write POSTs observed on the sidebar API. */
265
+ function recordSidebarFs(url, init) {
266
+ if (url.pathname !== '/sidebar/api/fs.read' && url.pathname !== '/sidebar/api/fs.write') return
267
+ if (methodOf(init) !== 'POST') return
268
+ const body = parseBody(init)
269
+ if (typeof body.sessionId !== 'string' || typeof body.path !== 'string') return
270
+ postRecord(body.sessionId, body.path, url.pathname === '/sidebar/api/fs.write' ? 'write' : 'read')
271
+ }
272
+
273
+ /** Record sidebar media opens (/sidebar/file?sessionId=...&path=...). */
274
+ function recordMediaOpen(url, init) {
275
+ if (url.pathname !== '/sidebar/file' || methodOf(init) !== 'GET') return
276
+ const sessionId = url.searchParams.get('sessionId')
277
+ const path = url.searchParams.get('path')
278
+ if (sessionId !== null && path !== null) postRecord(sessionId, path, 'read')
279
+ }
280
+
281
+ /** Observe a resolved fetch URL and record sidebar file operations. */
282
+ function observeSidebarFetch(url, init) {
283
+ try {
284
+ recordSidebarFs(url, init)
285
+ recordMediaOpen(url, init)
286
+ } catch {
287
+ // observation must never break the underlying call
288
+ }
289
+ }
290
+
291
+ function installFetchInterceptor() {
292
+ const original = window.fetch.bind(window)
293
+ window.fetch = (input, init) => {
294
+ const result = original(input, init)
295
+ let url
296
+ try {
297
+ if (typeof input === 'string') url = new URL(input, window.location.href)
298
+ else if (input instanceof URL) url = input
299
+ else return result // Request instances: skip observation
300
+ } catch {
301
+ return result
302
+ }
303
+ observeSidebarFetch(url, init)
304
+ return result
305
+ }
306
+ return () => {
307
+ window.fetch = original
308
+ }
309
+ }
310
+
311
+ // ── auto-open (enabled by default) ────────────────────────────────────
312
+ function findTabIn(state, tabId) {
313
+ const leaves = (node) => (node.kind === 'leaf' ? [node] : (node.children ?? []).flatMap(leaves))
314
+ for (const node of [state?.splits, state?.bottomSplits]) {
315
+ if (node === undefined || node === null) continue
316
+ for (const leaf of leaves(node)) {
317
+ if ((leaf.tabs ?? []).some((tab) => tab.type === tabId)) return true
318
+ }
319
+ }
320
+ return false
321
+ }
322
+
323
+ /** Current sidebar snapshot, or null when the service is not ready. */
324
+ function sidebarSnapshot(service) {
325
+ try {
326
+ return service.getSnapshot?.()
327
+ } catch {
328
+ return null
329
+ }
330
+ }
331
+
332
+ /** The user disabled auto-open for this tab in the sidebar settings. */
333
+ function isAutoOpenDisabled(snapshot, tabId) {
334
+ const settings = snapshot.prefs?.pluginSettings?.[tabId]
335
+ return settings !== undefined && settings.autoOpen === false
336
+ }
337
+
338
+ /** Whether this session was already auto-opened (localStorage marker). */
339
+ function isAutoOpenMarked(sessionId) {
340
+ try {
341
+ return Boolean(window.localStorage.getItem(AUTO_OPEN_KEY + sessionId))
342
+ } catch {
343
+ return true
344
+ }
345
+ }
346
+
347
+ /** Persist the auto-opened marker for this session. */
348
+ function markAutoOpened(sessionId) {
349
+ try {
350
+ window.localStorage.setItem(AUTO_OPEN_KEY + sessionId, '1')
351
+ } catch {
352
+ // ignore
353
+ }
354
+ }
355
+
356
+ /** Open the tab once per session unless disabled in the plugin settings. */
357
+ function tryAutoOpen(service, tabId) {
358
+ const snapshot = sidebarSnapshot(service)
359
+ if (snapshot === undefined || snapshot === null || snapshot.sessionId === undefined || snapshot.state === undefined) return
360
+ const sessionId = snapshot.sessionId
361
+ if (isAutoOpenDisabled(snapshot, tabId)) return
362
+ if (isAutoOpenMarked(sessionId)) return
363
+ if (findTabIn(snapshot.state, tabId)) {
364
+ markAutoOpened(sessionId)
365
+ return
366
+ }
367
+ try {
368
+ service.openTab({ type: tabId, title: strings.title(), path: '' })
369
+ markAutoOpened(sessionId)
370
+ } catch (error) {
371
+ console.error('[dsh-file-activity] auto-open failed:', error)
372
+ }
373
+ }
374
+
375
+ function installAutoOpen(ctx, tabId) {
376
+ const service = ctx.betterSidebar
377
+ tryAutoOpen(service, tabId)
378
+ let off = () => {}
379
+ try {
380
+ off = service.subscribeState?.(() => tryAutoOpen(service, tabId)) ?? off
381
+ } catch {
382
+ // service may lack subscribeState on older versions
383
+ }
384
+ return off
385
+ }
386
+
387
+ // ── icons (inline, stroke=currentColor, matching better-sidebar) ──────
388
+ const ICON_STROKE = 1.8
389
+ const iconSvg = (children, size) =>
390
+ createElement('svg', {
391
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
392
+ stroke: 'currentColor', strokeWidth: ICON_STROKE, strokeLinecap: 'round', strokeLinejoin: 'round',
393
+ 'aria-hidden': 'true',
394
+ }, children.map((child, i) => (child === null || child === undefined || typeof child === 'boolean')
395
+ ? child
396
+ : createElement(child.type, { key: i, ...child.props })))
397
+
398
+ const icon = {
399
+ clock: (size = 16) => iconSvg([
400
+ createElement('circle', { cx: 12, cy: 12, r: 9 }),
401
+ createElement('path', { d: 'M12 7v5l3 2' }),
402
+ ], size),
403
+ refresh: (size = 16) => iconSvg([
404
+ createElement('path', { d: 'M21 12a9 9 0 1 1-2.64-6.36' }),
405
+ createElement('polyline', { points: '21 3 21 9 15 9' }),
406
+ ], size),
407
+ trash: (size = 16) => iconSvg([
408
+ createElement('path', { d: 'M3 6h18' }),
409
+ createElement('path', { d: 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6' }),
410
+ createElement('path', { d: 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2' }),
411
+ ], size),
412
+ chevronRight: (size = 14) => iconSvg([
413
+ createElement('polyline', { points: '9 6 15 12 9 18' }),
414
+ ], size),
415
+ chevronDown: (size = 14) => iconSvg([
416
+ createElement('polyline', { points: '6 9 12 15 18 9' }),
417
+ ], size),
418
+ file: (size = 16) => iconSvg([
419
+ createElement('path', { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }),
420
+ createElement('path', { d: 'M14 2v6h6' }),
421
+ ], size),
422
+ folder: (size = 16) => iconSvg([
423
+ 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' }),
424
+ ], size),
425
+ external: (size = 15) => iconSvg([
426
+ createElement('path', { d: 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' }),
427
+ createElement('polyline', { points: '15 3 21 3 21 9' }),
428
+ createElement('line', { x1: 10, y1: 14, x2: 21, y2: 3 }),
429
+ ], size),
430
+ close: (size = 15) => iconSvg([
431
+ createElement('line', { x1: 18, y1: 6, x2: 6, y2: 18 }),
432
+ createElement('line', { x1: 6, y1: 6, x2: 18, y2: 18 }),
433
+ ], size),
434
+ }
435
+
436
+ // ── themed stylesheet (injected once per activation) ──────────────────
437
+ // Mirrors the better-sidebar explorer surface: tight 2px 6px 8px body,
438
+ // 30px rows, box-sizing border-box indentation, folder rows use the
439
+ // strong type face to read as directories, files stay regular.
440
+ const STYLES = `
441
+ .dfa { display:flex; flex-direction:column; height:100%; overflow-y:auto; overflow-x:hidden;
442
+ padding:2px 6px 8px; gap:2px; font:var(--dsw-font-s-14); color:var(--dsw-alias-label-primary); }
443
+ .dfa-iconbtn { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; padding:0;
444
+ border:none; border-radius:50%; background:transparent; color:var(--dsw-alias-label-secondary); cursor:pointer; flex:none;
445
+ transition:background var(--ds-transition-duration-slow) var(--ds-ease-in-out), color var(--ds-transition-duration-slow) var(--ds-ease-in-out); }
446
+ .dfa-iconbtn svg { display:block; }
447
+ .dfa-iconbtn:hover:not(:disabled) { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }
448
+ .dfa-iconbtn:disabled { opacity:.4; cursor:default; }
449
+ .dfa-iconbtn-danger:hover:not(:disabled) { color:var(--dsw-alias-state-error-primary); }
450
+ .dfa-iconbtn-xs { width:20px; height:20px; }
451
+ .dfa-section-head-actions { display:flex; align-items:center; gap:2px; flex:none; }
452
+ .dfa-section { margin-top:4px; }
453
+ .dfa-section-head { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:2px 6px 2px;
454
+ font:var(--dsw-font-xxxs-strong-11); color:var(--dsw-alias-label-tertiary); text-transform:uppercase; letter-spacing:.04em; }
455
+ .dfa-section-head-toggle { display:flex; align-items:center; gap:5px; cursor:pointer; color:var(--dsw-alias-label-secondary); border:none; background:transparent; padding:0;
456
+ font:var(--dsw-font-xxxs-strong-11); text-transform:uppercase; letter-spacing:.04em; }
457
+ .dfa-section-head-toggle:hover { color:var(--dsw-alias-label-primary); }
458
+ .dfa-section-head-toggle svg { display:block; flex:none; }
459
+ .dfa-empty { padding:8px 6px; font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-tertiary); line-height:1.7; }
460
+ .dfa-empty-hint { display:block; margin-top:2px; color:var(--dsw-alias-label-dimmed); font:var(--dsw-font-xxxs-11); }
461
+ .dfa-list { display:flex; flex-direction:column; gap:0; }
462
+ .dfa-row { display:flex; align-items:center; gap:6px; box-sizing:border-box; width:100%; min-height:26px;
463
+ margin:0; padding:0 8px; border:none; background:transparent; border-radius:8px; cursor:pointer; text-align:left;
464
+ animation:dfa-row-in 150ms var(--ds-ease-in-out); font:var(--dsw-font-s-14); color:var(--dsw-alias-label-primary); }
465
+ .dfa-row:hover { background:var(--dsw-alias-interactive-bg-hover); }
466
+ .dfa-row-dir { font:var(--dsw-font-s-strong-14); color:var(--dsw-alias-label-primary); }
467
+ .dfa-chevron { flex:none; display:flex; align-items:center; color:var(--dsw-alias-label-tertiary); }
468
+ .dfa-row-icon { flex:none; display:flex; align-items:center; color:var(--dsw-alias-label-secondary); }
469
+ /* Strong folder-vs-file separation: folders get the brand accent ink so the
470
+ directory rows read as the colorful navigation spine; files stay neutral
471
+ and faint, so the eye separates them instantly. */
472
+ .dfa-icon-folder { color:var(--dsw-alias-accent); }
473
+ .dfa-icon-file { color:var(--dsw-alias-label-tertiary); }
474
+ .dfa-row-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
475
+ .dfa-name-file { color:var(--dsw-alias-label-secondary); }
476
+ .dfa-time { flex:none; font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); white-space:nowrap; }
477
+ .dfa-op { flex:none; display:inline-flex; align-items:center; justify-content:center; height:17px; padding:0 5px; border-radius:4px;
478
+ font:var(--dsw-font-xxxs-strong-11); }
479
+ .dfa-op-create { color:var(--dsw-alias-state-success-primary); background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent); }
480
+ .dfa-op-modify { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
481
+ .dfa-op-read { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 12%, transparent); }
482
+ .dfa-counts { flex:none; display:flex; align-items:center; gap:3px; }
483
+ .dfa-count { flex:none; display:inline-flex; align-items:center; justify-content:center; height:15px; padding:0 4px; border-radius:4px;
484
+ font:var(--dsw-font-xxxs-strong-11); }
485
+ .dfa-count-create { color:var(--dsw-alias-state-success-primary); background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); }
486
+ .dfa-count-modify { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 14%, transparent); }
487
+ .dfa-count-read { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 10%, transparent); }
488
+ /* ── floating preview window (uses the sidebar's native viewer rendering) ──
489
+ A transparent-ish scrim fills the viewport and closes the window on any
490
+ outside click / Escape; the window itself stops propagation. Its body is a
491
+ scroll container so large files scroll inside. */
492
+ .dfa-fp-overlay { position:fixed; inset:0; z-index:1990; background:rgba(0,0,0,0.12); }
493
+ .dfa-fp { position:fixed; top:56px; right:340px; width:min(720px, calc(100vw - 376px)); height:76vh; max-height:860px;
494
+ background:var(--dsw-alias-bg-layer-2); color:var(--dsw-alias-label-primary);
495
+ border:1px solid var(--dsw-alias-border-l2); border-radius:10px; box-shadow:var(--dsw-shadow-lv2); z-index:2000;
496
+ display:flex; flex-direction:column; overflow:hidden; }
497
+ .dfa-fp-head { display:flex; align-items:center; gap:6px; padding:6px 8px; border-bottom:1px solid var(--dsw-alias-border-l1); flex:none; }
498
+ .dfa-fp-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font:var(--dsw-font-s-strong-14); color:var(--dsw-alias-label-primary); }
499
+ .dfa-fp-actions { display:flex; align-items:center; gap:2px; flex:none; }
500
+ .dfa-fp-body { flex:1; overflow:auto; padding:10px 12px; min-height:0; }
501
+ .dfa-fp-note { color:var(--dsw-alias-label-tertiary); font:var(--dsw-font-xxs-12); }
502
+ .dfa-fp-err { color:var(--dsw-alias-state-error-primary); font:var(--dsw-font-xxs-12); white-space:pre-wrap; word-break:break-all; }
503
+ /* PDF preview: a native browser PDF frame filled from the plugin's own media
504
+ route, with a download fallback in the toolbar. */
505
+ .dfa-pdf { display:flex; flex-direction:column; width:100%; height:100%; }
506
+ .dfa-pdf-toolbar { flex:none; display:flex; justify-content:flex-end; padding:2px 4px 6px; }
507
+ .dfa-pdf-download { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-accent); text-decoration:none; }
508
+ .dfa-pdf-download:hover { text-decoration:underline; }
509
+ .dfa-pdf-frame { flex:1; min-height:0; width:100%; border:none; border-radius:6px; background:transparent; }
510
+ @keyframes dfa-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
511
+ `
512
+
513
+ // ── row rendering helpers (recent list & stats tree) ──────────────────
514
+ const opClass = (op) => (op === 'create' ? 'dfa-op-create' : op === 'modify' ? 'dfa-op-modify' : 'dfa-op-read')
515
+ const opLabel = (op) => (op === 'create' ? strings.create() : op === 'modify' ? strings.modify() : strings.read())
516
+
517
+ /** Tooltip for a stats file row: absolute path + created / last-seen times. */
518
+ const fileTitle = (abs, firstSeen, lastSeen) => {
519
+ const times = []
520
+ if (typeof firstSeen === 'number') times.push(`${strings.created()} ${formatTime(firstSeen)}`)
521
+ if (typeof lastSeen === 'number') times.push(`${strings.lastSeen()} ${formatTime(lastSeen)}`)
522
+ return times.length > 0 ? `${abs}\n${times.join(' · ')}` : abs
523
+ }
524
+
525
+ /** Three colored count pills for a file/dir node (read/create/modify). */
526
+ const countPills = (node) =>
527
+ createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } },
528
+ createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`),
529
+ createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`),
530
+ createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`),
531
+ )
532
+
533
+ /** A stats-tree file row: icon + name + count pills + relative time. */
534
+ const fileRow = (file, depth, onOpen) =>
535
+ createElement(
536
+ 'div',
537
+ {
538
+ key: file.abs,
539
+ className: 'dfa-row',
540
+ onClick: () => onOpen(file.abs),
541
+ style: { paddingLeft: 8 + depth * 20 },
542
+ title: fileTitle(file.abs, file.firstSeen, file.lastSeen),
543
+ },
544
+ createElement('span', { className: 'dfa-row-icon dfa-icon-file' }, icon.file(14)),
545
+ createElement('span', { className: 'dfa-row-name dfa-name-file' }, file.name),
546
+ countPills(file),
547
+ file.lastSeen
548
+ ? createElement('span', { className: 'dfa-time' }, formatRelative(file.lastSeen))
549
+ : null,
550
+ )
551
+
552
+ /** One stats-tree node: file rows render inline, dirs toggle collapse. */
553
+ function renderTreeNode(node, depth, collapsedDirs, onToggleDir, onOpen) {
554
+ if (node.type === 'file') return fileRow(node, depth, onOpen)
555
+ const collapsed = collapsedDirs.has(node.path)
556
+ return createElement(
557
+ 'div',
558
+ { key: node.path },
559
+ createElement(
560
+ 'div',
561
+ {
562
+ className: 'dfa-row dfa-row-dir',
563
+ onClick: () => onToggleDir(node.path),
564
+ style: { paddingLeft: 8 + depth * 20 },
565
+ title: `${node.path}/`,
566
+ },
567
+ createElement('span', { className: 'dfa-chevron' },
568
+ collapsed ? icon.chevronRight(13) : icon.chevronDown(13),
569
+ ),
570
+ createElement('span', { className: 'dfa-row-icon dfa-icon-folder' }, icon.folder(14)),
571
+ createElement('span', { className: 'dfa-row-name' },
572
+ node.compressed ? node.name : node.name + '/',
573
+ ),
574
+ countPills(node),
575
+ ),
576
+ collapsed ? null : node.children.map((child) => renderTreeNode(child, depth + 1, collapsedDirs, onToggleDir, onOpen)),
577
+ )
578
+ }
579
+
580
+ /** A recent-list row: op badge + basename + relative time. */
581
+ const recentEntry = (entry, onOpen) =>
582
+ createElement(
583
+ 'div',
584
+ {
585
+ key: `${entry.path}:${entry.time}:${entry.op}`,
586
+ className: 'dfa-row',
587
+ onClick: () => onOpen(entry.path),
588
+ title: entry.path,
589
+ },
590
+ createElement('span', { className: `dfa-op ${opClass(entry.op)}` }, opLabel(entry.op)),
591
+ createElement('span', { className: 'dfa-row-name' }, basenameOf(entry.path)),
592
+ createElement('span', { className: 'dfa-time' }, formatRelative(entry.time)),
593
+ )
594
+
595
+ /** Toggle a key in a Set (directory collapse state). */
596
+ function toggleInSet(set, key) {
597
+ const next = new Set(set)
598
+ if (next.has(key)) next.delete(key)
599
+ else next.add(key)
600
+ return next
601
+ }
602
+
603
+ /** Clear the current session's records host-side and reset its bucket. */
604
+ function clearSessionData(dataStore, sessionId) {
605
+ if (!window.confirm(strings.clearConfirm())) return
606
+ postClear(sessionId)
607
+ const current = dataStore.getSnapshot()
608
+ dataStore.set({
609
+ bySession: {
610
+ ...(current.bySession ?? {}),
611
+ [sessionId]: { recent: [], counts: {}, loading: false },
612
+ },
613
+ })
614
+ }
615
+
616
+ /** Manual refresh: fetch stats + the authoritative cwd for this session. */
617
+ function refreshSessionData(dataStore, sessionId, setCwd, setError) {
618
+ if (sessionId === '') return
619
+ void fetchStats(sessionId).then((value) => {
620
+ if (value === null) return
621
+ setCwd((prev) => prev || value.cwd || '')
622
+ const current = dataStore.getSnapshot()
623
+ dataStore.set({
624
+ bySession: {
625
+ ...(current.bySession ?? {}),
626
+ [sessionId]: { recent: value.recent ?? [], counts: value.counts ?? {}, loading: false },
627
+ },
628
+ })
629
+ setError(false)
630
+ }).catch(() => setError(true))
631
+ void fetchSessionCwd(sessionId).then((cwd) => {
632
+ if (cwd !== '') setCwd(cwd)
633
+ })
634
+ }
635
+
636
+ // ── view component ────────────────────────────────────────────────────
637
+ /** Shared empty bucket for sessions that have never loaded data (stable ref). */
638
+ const EMPTY_SESSION = { recent: [], counts: {}, loading: true }
639
+
640
+ /**
641
+ * Polling loader for one session: fetches stats on mount and on a fixed
642
+ * interval while visible, prefers the sidebar's authoritative session.cwd
643
+ * for relative display, and writes results into the per-session bucket.
644
+ */
645
+ function useSessionLoader(visible, sessionId, scope, dataStore, setCwd, setError) {
646
+ useEffect(() => {
647
+ if (!visible || sessionId === '') return
648
+ let cancelled = false
649
+ const load = () => {
650
+ void fetchStats(sessionId).then((value) => {
651
+ if (cancelled || value === null) return
652
+ setCwd((prev) => prev || value.cwd || '')
653
+ const current = dataStore.getSnapshot()
654
+ dataStore.set({
655
+ bySession: {
656
+ ...(current.bySession ?? {}),
657
+ [sessionId]: { recent: value.recent ?? [], counts: value.counts ?? {}, loading: false },
658
+ },
659
+ })
660
+ setError(false)
661
+ }).catch(() => {
662
+ if (!cancelled) setError(true)
663
+ })
664
+ }
665
+ load()
666
+ void fetchSessionCwd(sessionId).then((cwd) => {
667
+ if (!cancelled && cwd !== '') setCwd(cwd)
668
+ })
669
+ const timer = window.setInterval(load, POLL_MS)
670
+ return () => {
671
+ cancelled = true
672
+ window.clearInterval(timer)
673
+ }
674
+ }, [visible, sessionId, dataStore])
675
+ }
676
+
677
+ /** Error banner element, or null when the last load succeeded. */
678
+ function renderError(error) {
679
+ if (!error) return null
680
+ return createElement('div', { style: { color: 'var(--dsw-alias-state-error-primary)', padding: '4px 6px', font: 'var(--dsw-font-xxs-12)' } }, strings.loadError())
681
+ }
682
+
683
+ /** "最近访问" section: collapsible head with refresh/clear actions. */
684
+ function renderRecentSection(recent, recentOpen, onToggle, onRefresh, onClear, onOpen) {
685
+ return createElement(
686
+ 'div',
687
+ { className: 'dfa-section' },
688
+ createElement(
689
+ 'div',
690
+ { className: 'dfa-section-head' },
691
+ createElement(
692
+ 'button',
693
+ { className: 'dfa-section-head-toggle', onClick: onToggle },
694
+ recentOpen ? icon.chevronDown(13) : icon.chevronRight(13),
695
+ strings.recent(),
696
+ ),
697
+ createElement('span', { className: 'dfa-section-head-actions' },
698
+ createElement('button', { className: 'dfa-iconbtn dfa-iconbtn-xs', onClick: onRefresh, title: strings.refresh(), 'aria-label': strings.refresh() }, icon.refresh(14)),
699
+ createElement('button', { className: 'dfa-iconbtn dfa-iconbtn-xs dfa-iconbtn-danger', onClick: onClear, title: strings.clear(), 'aria-label': strings.clear() }, icon.trash(14)),
700
+ ),
701
+ ),
702
+ !recentOpen ? null : recent.length === 0
703
+ ? createElement(
704
+ 'div',
705
+ { className: 'dfa-empty' },
706
+ strings.empty(),
707
+ createElement('span', { className: 'dfa-empty-hint' }, strings.emptyHint()),
708
+ )
709
+ : createElement('div', { className: 'dfa-list' }, recent.map((entry) => recentEntry(entry, onOpen))),
710
+ )
711
+ }
712
+
713
+ /** "文件统计" section: the directory tree, or an empty hint. */
714
+ function renderStatsSection(tree, collapsedDirs, onToggleDir, onOpen) {
715
+ return createElement(
716
+ 'div',
717
+ { className: 'dfa-section' },
718
+ createElement('div', { className: 'dfa-section-head' }, strings.stats()),
719
+ tree.children.length === 0
720
+ ? createElement('div', { className: 'dfa-empty' }, strings.empty())
721
+ : createElement('div', { className: 'dfa-list' }, tree.children.map((child) => renderTreeNode(child, 0, collapsedDirs, onToggleDir, onOpen))),
722
+ )
723
+ }
724
+
725
+ /**
726
+ * The file-activity tab. Each session renders only its own store bucket:
727
+ * a fresh conversation shows an empty list immediately, with no residue
728
+ * from the previous session. Clicking any file opens a FLOATING preview
729
+ * that reuses the sidebar's NATIVE viewer via matchFileViewer.
730
+ */
731
+ function FileActivityView({ ctx, store, scope, visible, dataStore }) {
732
+ const data = useSyncExternalStore(dataStore.subscribe, dataStore.getSnapshot)
733
+ const [cwd, setCwd] = useState(scope?.cwd || '')
734
+ const [error, setError] = useState(false)
735
+ const [recentOpen, setRecentOpen] = useState(true)
736
+ const [collapsedDirs, setCollapsedDirs] = useState(() => new Set())
737
+ const sessionId = scope?.sessionId ?? ''
738
+ const sessionData = (data.bySession ?? {})[sessionId] ?? EMPTY_SESSION
739
+ const tree = useMemo(() => buildTree(sessionData.counts ?? {}), [sessionData.counts])
740
+ useEffect(() => {
741
+ if (scope?.cwd) setCwd(scope.cwd)
742
+ }, [scope?.cwd])
743
+ useSessionLoader(visible, sessionId, scope, dataStore, setCwd, setError)
744
+ // Switching conversations closes any floating preview left open by the
745
+ // previous session (preview is shared UI state; session data never
746
+ // crosses sessions anymore).
747
+ useEffect(() => {
748
+ dataStore.set({ preview: null })
749
+ }, [sessionId, dataStore])
750
+ const toggleDir = (path) => setCollapsedDirs((prev) => toggleInSet(prev, path))
751
+ const openPreview = (path) => dataStore.set({ preview: { abs: path, name: basenameOf(path) } })
752
+ const closePreview = () => dataStore.set({ preview: null })
753
+ const onClear = () => clearSessionData(dataStore, sessionId)
754
+ const onRefresh = () => refreshSessionData(dataStore, sessionId, setCwd, setError)
755
+ const recent = sessionData.recent ?? []
756
+ return createElement('div', { className: 'dfa' },
757
+ renderError(error),
758
+ renderRecentSection(recent, recentOpen, () => setRecentOpen((v) => !v), onRefresh, onClear, openPreview),
759
+ renderStatsSection(tree, collapsedDirs, toggleDir, openPreview),
760
+ data.preview
761
+ ? createElement(FloatingPreview, { ctx, store, scope, preview: data.preview, onClose: closePreview })
762
+ : null,
763
+ )
764
+ }
765
+
766
+ // ── floating preview window (reuses the sidebar's native viewer) ──────
767
+ /** Resolve a possibly-relative path against the session cwd. */
768
+ function resolvePath(path, cwd) {
769
+ if (typeof path !== 'string' || path === '') return path
770
+ if (path.startsWith('/')) return path
771
+ if (typeof cwd === 'string' && cwd !== '') return `${cwd.replace(/\/+$/, '')}/${path}`
772
+ return path
773
+ }
774
+
775
+ /** Whether the fs.read API response carries a text content payload. */
776
+ function isFsReadOk(json) {
777
+ return json !== null && typeof json === 'object' && json.ok === true && typeof json.value?.content === 'string'
778
+ }
779
+
780
+ /** Error load state from an fs.read API response (or a generic message). */
781
+ function fsReadError(json, viewer) {
782
+ return { status: 'error', viewer, message: json?.error?.message ?? strings.previewFailed() }
783
+ }
784
+
785
+ /**
786
+ * Load fsRead content through the sidebar API and resolve the viewer's
787
+ * load state (ready with text, or error with the API message).
788
+ */
789
+ async function loadFsReadContent(viewer, path, scope, sessionId) {
790
+ const target = resolvePath(path, scope?.cwd ?? '')
791
+ const response = await fetch('/sidebar/api/fs.read', {
792
+ method: 'POST',
793
+ headers: { 'content-type': 'application/json' },
794
+ body: JSON.stringify({ sessionId, path: target }),
795
+ })
796
+ const json = await response.json()
797
+ if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
798
+ return fsReadError(json, viewer)
799
+ }
800
+
801
+ /**
802
+ * Fetch the bytes the viewer's fetchStrategy needs (fsRead text /
803
+ * mediaUrl / customData) and resolve its load state.
804
+ */
805
+ async function fetchPreviewLoad(viewer, path, scope, sessionId) {
806
+ const strategy = viewer.fetchStrategy
807
+ if (strategy === 'fsRead') return loadFsReadContent(viewer, path, scope, sessionId)
808
+ if (strategy === 'mediaUrl') {
809
+ return { status: 'ready', viewer, mediaUrl: mediaUrlOf(sessionId, path) }
810
+ }
811
+ if (strategy === 'custom') {
812
+ const data = await (viewer.load?.(path, scope) ?? Promise.resolve(undefined))
813
+ return { status: 'ready', viewer, customData: data }
814
+ }
815
+ // 'binary-download' and anything else: mount the viewer's own
816
+ // component (it handles the download / media itself).
817
+ return { status: 'ready', viewer }
818
+ }
819
+
820
+ /**
821
+ * Resolve the file's viewer through the sidebar registry and load the
822
+ * bytes it needs; failures become an error state shown in the window.
823
+ */
824
+ function usePreviewLoader(service, path, sessionId, scope) {
825
+ const [load, setLoad] = useState({ status: 'loading', viewer: null })
826
+ useEffect(() => {
827
+ let cancelled = false
828
+ const viewer = service?.matchFileViewer?.(path)
829
+ if (!viewer) {
830
+ setLoad({ status: 'error', viewer: null, message: strings.previewUnsupported() })
831
+ return () => { cancelled = true }
832
+ }
833
+ setLoad({ status: 'loading', viewer })
834
+ fetchPreviewLoad(viewer, path, scope, sessionId)
835
+ .then((next) => {
836
+ if (!cancelled) setLoad(next)
837
+ })
838
+ .catch((error) => {
839
+ if (!cancelled) setLoad({ status: 'error', viewer, message: error instanceof Error ? error.message : String(error) })
840
+ })
841
+ return () => { cancelled = true }
842
+ }, [path, sessionId, scope])
843
+ return load
844
+ }
845
+
846
+ /** Preview window body: loading note / error panel / viewer mount. */
847
+ function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
848
+ if (load.status === 'loading') {
849
+ return createElement('div', { className: 'dfa-fp-note' }, strings.loading())
850
+ }
851
+ if (load.status === 'error') {
852
+ return createElement('div', { className: 'dfa-fp-err' },
853
+ strings.previewFailed(),
854
+ load.message
855
+ ? createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.85 } }, load.message)
856
+ : null,
857
+ )
858
+ }
859
+ if (load.viewer.id === 'pdf') {
860
+ const url = mediaUrlOf(sessionId, path)
861
+ return createElement(PdfPreview, { src: url, download: `${url}&download=1`, title })
862
+ }
863
+ return createElement(load.viewer.component, {
864
+ ctx, store, scope, path, title,
865
+ viewerId: load.viewer.id,
866
+ content: load.content,
867
+ mediaUrl: load.mediaUrl,
868
+ customData: load.customData,
869
+ })
870
+ }
871
+
872
+ /**
873
+ * A floating preview window. Instead of re-implementing rendering, it
874
+ * asks the sidebar registry for the file's viewer (`matchFileViewer`),
875
+ * fetches the bytes the viewer's fetchStrategy needs (fsRead text /
876
+ * mediaUrl / customData), then mounts that viewer's own component — so
877
+ * code gets syntax highlighting and markdown gets rendered by the SAME
878
+ * built-in renderers the sidebar's editor tab uses.
879
+ *
880
+ * Media caveat: the sidebar's own media route (/sidebar/file) only serves
881
+ * files inside the session working directory, while file activity records
882
+ * files the agent touched anywhere (/tmp scratch files, sibling repos…).
883
+ * Media bytes therefore come from OUR route (/file-activity/file), which
884
+ * authorizes exactly the paths this session recorded; PDF is the one
885
+ * built-in viewer that fetches its own URL internally (it ignores the
886
+ * `mediaUrl` prop), so it gets a small iframe preview instead.
887
+ */
888
+ function FloatingPreview({ ctx, store, scope, preview, onClose }) {
889
+ const sessionId = scope?.sessionId ?? ''
890
+ const path = preview.abs
891
+ const title = preview.name
892
+ const service = ctx.betterSidebar
893
+ const load = usePreviewLoader(service, path, sessionId, scope)
894
+
895
+ // Clicking outside is the primary dismiss (the overlay's onClick);
896
+ // Escape is a keyboard affordance. Both call onClose.
897
+ useEffect(() => {
898
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
899
+ const handler = (event) => { if (event && event.key === 'Escape') onClose() }
900
+ document.addEventListener('keydown', handler)
901
+ return () => document.removeEventListener('keydown', handler)
902
+ }, [onClose])
903
+
904
+ return createElement(
905
+ 'div',
906
+ { className: 'dfa-fp-overlay', onClick: onClose },
907
+ createElement(
908
+ 'div',
909
+ { className: 'dfa-fp', onClick: (event) => { if (event && event.stopPropagation) event.stopPropagation() } },
910
+ createElement(
911
+ 'div',
912
+ { className: 'dfa-fp-head' },
913
+ createElement('span', { className: 'dfa-fp-title' }, title),
914
+ createElement(
915
+ 'span',
916
+ { className: 'dfa-fp-actions' },
917
+ createElement('button', { className: 'dfa-iconbtn', title: strings.closePreview(), 'aria-label': strings.closePreview(), onClick: () => onClose() },
918
+ icon.close(15),
919
+ ),
920
+ ),
921
+ ),
922
+ createElement('div', { className: 'dfa-fp-body' }, renderPreviewBody(load, ctx, store, scope, path, title, sessionId)),
923
+ ),
924
+ )
925
+ }
926
+
927
+ /**
928
+ * Lightweight PDF preview. better-sidebar's built-in PdfView fetches
929
+ * `/sidebar/file` internally (it ignores any injected `mediaUrl` prop),
930
+ * and that route refuses files outside the session working directory — so
931
+ * a recorded /tmp PDF would never load. This tiny view embeds the bytes
932
+ * from OUR media route in a native browser PDF frame, with a download
933
+ * fallback in its toolbar.
934
+ */
935
+ function PdfPreview({ src, download, title }) {
936
+ return createElement('div', { className: 'dfa-pdf' },
937
+ createElement('div', { className: 'dfa-pdf-toolbar' },
938
+ createElement('a', { className: 'dfa-pdf-download', href: download, download: true, title: strings.downloadToView() },
939
+ strings.downloadToView()),
940
+ ),
941
+ createElement('iframe', { className: 'dfa-pdf-frame', src, title }),
942
+ )
943
+ }
944
+
945
+ // ── plugin body ───────────────────────────────────────────────────────
946
+ /**
947
+ * The stylesheet is pure static CSS and must NOT depend on the
948
+ * betterSidebar service: inject it first, unconditionally. If it lived
949
+ * behind the `service === undefined` early return, an HMR rebuild or
950
+ * service reload could leave the already-rendered tab WITHOUT its
951
+ * stylesheet — the raw white-text list you see when the CSS is gone.
952
+ * Each fiber owns its own <style> element and the disposer removes
953
+ * only that element, so a rebuild always keeps at least one copy.
954
+ */
955
+ function injectStyles(ctx) {
956
+ ctx.effect(() => {
957
+ if (typeof document === 'undefined' || document === null || typeof document.head === 'undefined') return () => {}
958
+ const style = document.createElement('style')
959
+ style.setAttribute('data-dsh-file-activity', 'styles')
960
+ style.textContent = STYLES
961
+ document.head.appendChild(style)
962
+ return () => {
963
+ if (style.parentNode) style.parentNode.removeChild(style)
964
+ }
965
+ }, 'dsh-file-activity: styles')
966
+ }
967
+
968
+ /** Mount probe: report client activation to the host state (synthetic
969
+ * session id, invisible in the UI — confirms the client half actually
970
+ * loaded after a page refresh). */
971
+ function mountProbe() {
972
+ void fetch('/file-activity/api/record', {
973
+ method: 'POST',
974
+ headers: { 'content-type': 'application/json' },
975
+ body: JSON.stringify({ sessionId: '__probe__', path: 'mounted', op: 'read' }),
976
+ }).catch(() => {})
977
+ }
978
+
979
+ /** Register the tab (enabled by default in the Side card settings). */
980
+ function registerTab(ctx, dataStore) {
981
+ const service = ctx.betterSidebar
982
+ ctx.effect(() => service.registerTab({
983
+ id: TAB_ID,
984
+ title: () => strings.title(),
985
+ icon: (size) => icon.clock(size),
986
+ order: 15,
987
+ single: true,
988
+ settings: {
989
+ pluginToggles: [{
990
+ key: 'autoOpen',
991
+ title: () => (isZh() ? '会话开始时自动打开' : 'Auto-open on session start'),
992
+ desc: () => (isZh() ? '每个会话首次打开时自动显示本页(可在侧边栏设置中关闭)' : 'Opens this tab once per session by default (turn off here)'),
993
+ type: 'switch',
994
+ }],
995
+ },
996
+ component: (props) => createElement(FileActivityView, { ...props, dataStore }),
997
+ }), 'dsh-file-activity: tab registration')
998
+ }
999
+
1000
+ exports.inject = ['betterSidebar']
1001
+
1002
+ exports.apply = function apply(ctx) {
1003
+ // Stylesheet first, unconditionally (HMR pitfall — see injectStyles).
1004
+ injectStyles(ctx)
1005
+ const service = ctx.betterSidebar
1006
+ if (service === undefined) return
1007
+
1008
+ // Per-session data store: { bySession: { [sessionId]: { recent, counts, loading } }, preview }
1009
+ // Each conversation reads/writes only its own bucket, so switching
1010
+ // sessions never leaks another session's file activity into the view.
1011
+ const dataStore = createStore({ bySession: {}, preview: null })
1012
+ mountProbe()
1013
+
1014
+ // sidebar operations → host record route
1015
+ ctx.effect(() => installFetchInterceptor(), 'dsh-file-activity: sidebar fetch observation')
1016
+ registerTab(ctx, dataStore)
1017
+
1018
+ // auto-open once per session (default on)
1019
+ ctx.effect(() => installAutoOpen(ctx, TAB_ID), 'dsh-file-activity: auto-open')
1020
+ }
1021
+
1022
+
1023
+ return module.exports
1024
+ },
1025
+ })