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.
@@ -0,0 +1,56 @@
1
+ // ── fetch interception: sidebar file operations ───────────────────────
2
+ function methodOf(init) {
3
+ return (init?.method ?? 'GET').toUpperCase()
4
+ }
5
+
6
+ /** POST body as a plain object (non-string bodies are ignored). */
7
+ function parseBody(init) {
8
+ return typeof init?.body === 'string' ? JSON.parse(init.body) : {}
9
+ }
10
+
11
+ /** Record fs.read / fs.write POSTs observed on the sidebar API. */
12
+ function recordSidebarFs(url, init) {
13
+ if (url.pathname !== '/sidebar/api/fs.read' && url.pathname !== '/sidebar/api/fs.write') return
14
+ if (methodOf(init) !== 'POST') return
15
+ const body = parseBody(init)
16
+ if (typeof body.sessionId !== 'string' || typeof body.path !== 'string') return
17
+ postRecord(body.sessionId, body.path, url.pathname === '/sidebar/api/fs.write' ? 'write' : 'read')
18
+ }
19
+
20
+ /** Record sidebar media opens (/sidebar/file?sessionId=...&path=...). */
21
+ function recordMediaOpen(url, init) {
22
+ if (url.pathname !== '/sidebar/file' || methodOf(init) !== 'GET') return
23
+ const sessionId = url.searchParams.get('sessionId')
24
+ const path = url.searchParams.get('path')
25
+ if (sessionId !== null && path !== null) postRecord(sessionId, path, 'read')
26
+ }
27
+
28
+ /** Observe a resolved fetch URL and record sidebar file operations. */
29
+ function observeSidebarFetch(url, init) {
30
+ try {
31
+ recordSidebarFs(url, init)
32
+ recordMediaOpen(url, init)
33
+ } catch {
34
+ // observation must never break the underlying call
35
+ }
36
+ }
37
+
38
+ function installFetchInterceptor() {
39
+ const original = window.fetch.bind(window)
40
+ window.fetch = (input, init) => {
41
+ const result = original(input, init)
42
+ let url
43
+ try {
44
+ if (typeof input === 'string') url = new URL(input, window.location.href)
45
+ else if (input instanceof URL) url = input
46
+ else return result // Request instances: skip observation
47
+ } catch {
48
+ return result
49
+ }
50
+ observeSidebarFetch(url, init)
51
+ return result
52
+ }
53
+ return () => {
54
+ window.fetch = original
55
+ }
56
+ }
@@ -0,0 +1,178 @@
1
+ // ── floating preview window (reuses the sidebar's native viewer) ──────
2
+ /** Resolve a possibly-relative path against the session cwd. */
3
+ function resolvePath(path, cwd) {
4
+ if (typeof path !== 'string' || path === '') return path
5
+ if (path.startsWith('/')) return path
6
+ if (typeof cwd === 'string' && cwd !== '') return `${cwd.replace(/\/+$/, '')}/${path}`
7
+ return path
8
+ }
9
+
10
+ /** Whether the fs.read API response carries a text content payload. */
11
+ function isFsReadOk(json) {
12
+ return json !== null && typeof json === 'object' && json.ok === true && typeof json.value?.content === 'string'
13
+ }
14
+
15
+ /** Error load state from an fs.read API response (or a generic message). */
16
+ function fsReadError(json, viewer) {
17
+ return { status: 'error', viewer, message: json?.error?.message ?? strings.previewFailed() }
18
+ }
19
+
20
+ /**
21
+ * Load fsRead content through the sidebar API and resolve the viewer's
22
+ * load state (ready with text, or error with the API message).
23
+ */
24
+ async function loadFsReadContent(viewer, path, scope, sessionId) {
25
+ const target = resolvePath(path, scope?.cwd ?? '')
26
+ const response = await fetch('/sidebar/api/fs.read', {
27
+ method: 'POST',
28
+ headers: { 'content-type': 'application/json' },
29
+ body: JSON.stringify({ sessionId, path: target }),
30
+ })
31
+ const json = await response.json()
32
+ if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
33
+ return fsReadError(json, viewer)
34
+ }
35
+
36
+ /**
37
+ * Fetch the bytes the viewer's fetchStrategy needs (fsRead text /
38
+ * mediaUrl / customData) and resolve its load state.
39
+ */
40
+ async function fetchPreviewLoad(viewer, path, scope, sessionId) {
41
+ const strategy = viewer.fetchStrategy
42
+ if (strategy === 'fsRead') return loadFsReadContent(viewer, path, scope, sessionId)
43
+ if (strategy === 'mediaUrl') {
44
+ return { status: 'ready', viewer, mediaUrl: mediaUrlOf(sessionId, path) }
45
+ }
46
+ if (strategy === 'custom') {
47
+ const data = await (viewer.load?.(path, scope) ?? Promise.resolve(undefined))
48
+ return { status: 'ready', viewer, customData: data }
49
+ }
50
+ // 'binary-download' and anything else: mount the viewer's own
51
+ // component (it handles the download / media itself).
52
+ return { status: 'ready', viewer }
53
+ }
54
+
55
+ /**
56
+ * Resolve the file's viewer through the sidebar registry and load the
57
+ * bytes it needs; failures become an error state shown in the window.
58
+ */
59
+ function usePreviewLoader(service, path, sessionId, scope) {
60
+ const [load, setLoad] = useState({ status: 'loading', viewer: null })
61
+ useEffect(() => {
62
+ let cancelled = false
63
+ const viewer = service?.matchFileViewer?.(path)
64
+ if (!viewer) {
65
+ setLoad({ status: 'error', viewer: null, message: strings.previewUnsupported() })
66
+ return () => { cancelled = true }
67
+ }
68
+ setLoad({ status: 'loading', viewer })
69
+ fetchPreviewLoad(viewer, path, scope, sessionId)
70
+ .then((next) => {
71
+ if (!cancelled) setLoad(next)
72
+ })
73
+ .catch((error) => {
74
+ if (!cancelled) setLoad({ status: 'error', viewer, message: error instanceof Error ? error.message : String(error) })
75
+ })
76
+ return () => { cancelled = true }
77
+ }, [path, sessionId, scope])
78
+ return load
79
+ }
80
+
81
+ /** Preview window body: loading note / error panel / viewer mount. */
82
+ function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
83
+ if (load.status === 'loading') {
84
+ return createElement('div', { className: 'dfa-fp-note' }, strings.loading())
85
+ }
86
+ if (load.status === 'error') {
87
+ return createElement('div', { className: 'dfa-fp-err' },
88
+ strings.previewFailed(),
89
+ load.message
90
+ ? createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.85 } }, load.message)
91
+ : null,
92
+ )
93
+ }
94
+ if (load.viewer.id === 'pdf') {
95
+ const url = mediaUrlOf(sessionId, path)
96
+ return createElement(PdfPreview, { src: url, download: `${url}&download=1`, title })
97
+ }
98
+ return createElement(load.viewer.component, {
99
+ ctx, store, scope, path, title,
100
+ viewerId: load.viewer.id,
101
+ content: load.content,
102
+ mediaUrl: load.mediaUrl,
103
+ customData: load.customData,
104
+ })
105
+ }
106
+
107
+ /**
108
+ * A floating preview window. Instead of re-implementing rendering, it
109
+ * asks the sidebar registry for the file's viewer (`matchFileViewer`),
110
+ * fetches the bytes the viewer's fetchStrategy needs (fsRead text /
111
+ * mediaUrl / customData), then mounts that viewer's own component — so
112
+ * code gets syntax highlighting and markdown gets rendered by the SAME
113
+ * built-in renderers the sidebar's editor tab uses.
114
+ *
115
+ * Media caveat: the sidebar's own media route (/sidebar/file) only serves
116
+ * files inside the session working directory, while file activity records
117
+ * files the agent touched anywhere (/tmp scratch files, sibling repos…).
118
+ * Media bytes therefore come from OUR route (/file-activity/file), which
119
+ * authorizes exactly the paths this session recorded; PDF is the one
120
+ * built-in viewer that fetches its own URL internally (it ignores the
121
+ * `mediaUrl` prop), so it gets a small iframe preview instead.
122
+ */
123
+ function FloatingPreview({ ctx, store, scope, preview, onClose }) {
124
+ const sessionId = scope?.sessionId ?? ''
125
+ const path = preview.abs
126
+ const title = preview.name
127
+ const service = ctx.betterSidebar
128
+ const load = usePreviewLoader(service, path, sessionId, scope)
129
+
130
+ // Clicking outside is the primary dismiss (the overlay's onClick);
131
+ // Escape is a keyboard affordance. Both call onClose.
132
+ useEffect(() => {
133
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
134
+ const handler = (event) => { if (event && event.key === 'Escape') onClose() }
135
+ document.addEventListener('keydown', handler)
136
+ return () => document.removeEventListener('keydown', handler)
137
+ }, [onClose])
138
+
139
+ return createElement(
140
+ 'div',
141
+ { className: 'dfa-fp-overlay', onClick: onClose },
142
+ createElement(
143
+ 'div',
144
+ { className: 'dfa-fp', onClick: (event) => { if (event && event.stopPropagation) event.stopPropagation() } },
145
+ createElement(
146
+ 'div',
147
+ { className: 'dfa-fp-head' },
148
+ createElement('span', { className: 'dfa-fp-title' }, title),
149
+ createElement(
150
+ 'span',
151
+ { className: 'dfa-fp-actions' },
152
+ createElement('button', { className: 'dfa-iconbtn', title: strings.closePreview(), 'aria-label': strings.closePreview(), onClick: () => onClose() },
153
+ icon.close(15),
154
+ ),
155
+ ),
156
+ ),
157
+ createElement('div', { className: 'dfa-fp-body' }, renderPreviewBody(load, ctx, store, scope, path, title, sessionId)),
158
+ ),
159
+ )
160
+ }
161
+
162
+ /**
163
+ * Lightweight PDF preview. better-sidebar's built-in PdfView fetches
164
+ * `/sidebar/file` internally (it ignores any injected `mediaUrl` prop),
165
+ * and that route refuses files outside the session working directory — so
166
+ * a recorded /tmp PDF would never load. This tiny view embeds the bytes
167
+ * from OUR media route in a native browser PDF frame, with a download
168
+ * fallback in its toolbar.
169
+ */
170
+ function PdfPreview({ src, download, title }) {
171
+ return createElement('div', { className: 'dfa-pdf' },
172
+ createElement('div', { className: 'dfa-pdf-toolbar' },
173
+ createElement('a', { className: 'dfa-pdf-download', href: download, download: true, title: strings.downloadToView() },
174
+ strings.downloadToView()),
175
+ ),
176
+ createElement('iframe', { className: 'dfa-pdf-frame', src, title }),
177
+ )
178
+ }
@@ -0,0 +1,122 @@
1
+ // ── row rendering helpers (recent list & stats tree) ──────────────────
2
+ const opClass = (op) => (op === 'create' ? 'dfa-op-create' : op === 'modify' ? 'dfa-op-modify' : 'dfa-op-read')
3
+ const opLabel = (op) => (op === 'create' ? strings.create() : op === 'modify' ? strings.modify() : strings.read())
4
+
5
+ /** Tooltip for a stats file row: absolute path + created / last-seen times. */
6
+ const fileTitle = (abs, firstSeen, lastSeen) => {
7
+ const times = []
8
+ if (typeof firstSeen === 'number') times.push(`${strings.created()} ${formatTime(firstSeen)}`)
9
+ if (typeof lastSeen === 'number') times.push(`${strings.lastSeen()} ${formatTime(lastSeen)}`)
10
+ return times.length > 0 ? `${abs}\n${times.join(' · ')}` : abs
11
+ }
12
+
13
+ /** Three colored count pills for a file/dir node (read/create/modify). */
14
+ const countPills = (node) =>
15
+ createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } },
16
+ createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`),
17
+ createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`),
18
+ createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`),
19
+ )
20
+
21
+ /** A stats-tree file row: icon + name + count pills + relative time. */
22
+ const fileRow = (file, depth, onOpen) =>
23
+ createElement(
24
+ 'div',
25
+ {
26
+ key: file.abs,
27
+ className: 'dfa-row',
28
+ onClick: () => onOpen(file.abs),
29
+ style: { paddingLeft: 8 + depth * 20 },
30
+ title: fileTitle(file.abs, file.firstSeen, file.lastSeen),
31
+ },
32
+ createElement('span', { className: 'dfa-row-icon dfa-icon-file' }, icon.file(14)),
33
+ createElement('span', { className: 'dfa-row-name dfa-name-file' }, file.name),
34
+ countPills(file),
35
+ file.lastSeen
36
+ ? createElement('span', { className: 'dfa-time' }, formatRelative(file.lastSeen))
37
+ : null,
38
+ )
39
+
40
+ /** One stats-tree node: file rows render inline, dirs toggle collapse. */
41
+ function renderTreeNode(node, depth, collapsedDirs, onToggleDir, onOpen) {
42
+ if (node.type === 'file') return fileRow(node, depth, onOpen)
43
+ const collapsed = collapsedDirs.has(node.path)
44
+ return createElement(
45
+ 'div',
46
+ { key: node.path },
47
+ createElement(
48
+ 'div',
49
+ {
50
+ className: 'dfa-row dfa-row-dir',
51
+ onClick: () => onToggleDir(node.path),
52
+ style: { paddingLeft: 8 + depth * 20 },
53
+ title: `${node.path}/`,
54
+ },
55
+ createElement('span', { className: 'dfa-chevron' },
56
+ collapsed ? icon.chevronRight(13) : icon.chevronDown(13),
57
+ ),
58
+ createElement('span', { className: 'dfa-row-icon dfa-icon-folder' }, icon.folder(14)),
59
+ createElement('span', { className: 'dfa-row-name' },
60
+ node.compressed ? node.name : node.name + '/',
61
+ ),
62
+ countPills(node),
63
+ ),
64
+ collapsed ? null : node.children.map((child) => renderTreeNode(child, depth + 1, collapsedDirs, onToggleDir, onOpen)),
65
+ )
66
+ }
67
+
68
+ /** A recent-list row: op badge + basename + relative time. */
69
+ const recentEntry = (entry, onOpen) =>
70
+ createElement(
71
+ 'div',
72
+ {
73
+ key: `${entry.path}:${entry.time}:${entry.op}`,
74
+ className: 'dfa-row',
75
+ onClick: () => onOpen(entry.path),
76
+ title: entry.path,
77
+ },
78
+ createElement('span', { className: `dfa-op ${opClass(entry.op)}` }, opLabel(entry.op)),
79
+ createElement('span', { className: 'dfa-row-name' }, basenameOf(entry.path)),
80
+ createElement('span', { className: 'dfa-time' }, formatRelative(entry.time)),
81
+ )
82
+
83
+ /** Toggle a key in a Set (directory collapse state). */
84
+ function toggleInSet(set, key) {
85
+ const next = new Set(set)
86
+ if (next.has(key)) next.delete(key)
87
+ else next.add(key)
88
+ return next
89
+ }
90
+
91
+ /** Clear the current session's records host-side and reset its bucket. */
92
+ function clearSessionData(dataStore, sessionId) {
93
+ if (!window.confirm(strings.clearConfirm())) return
94
+ postClear(sessionId)
95
+ const current = dataStore.getSnapshot()
96
+ dataStore.set({
97
+ bySession: {
98
+ ...(current.bySession ?? {}),
99
+ [sessionId]: { recent: [], counts: {}, loading: false },
100
+ },
101
+ })
102
+ }
103
+
104
+ /** Manual refresh: fetch stats + the authoritative cwd for this session. */
105
+ function refreshSessionData(dataStore, sessionId, setCwd, setError) {
106
+ if (sessionId === '') return
107
+ void fetchStats(sessionId).then((value) => {
108
+ if (value === null) return
109
+ setCwd((prev) => prev || value.cwd || '')
110
+ const current = dataStore.getSnapshot()
111
+ dataStore.set({
112
+ bySession: {
113
+ ...(current.bySession ?? {}),
114
+ [sessionId]: { recent: value.recent ?? [], counts: value.counts ?? {}, loading: false },
115
+ },
116
+ })
117
+ setError(false)
118
+ }).catch(() => setError(true))
119
+ void fetchSessionCwd(sessionId).then((cwd) => {
120
+ if (cwd !== '') setCwd(cwd)
121
+ })
122
+ }
@@ -0,0 +1,16 @@
1
+ // ── tiny external store ───────────────────────────────────────────────
2
+ function createStore(initial) {
3
+ let state = initial
4
+ const listeners = new Set()
5
+ return {
6
+ getSnapshot: () => state,
7
+ set(patch) {
8
+ state = { ...state, ...patch }
9
+ for (const listener of [...listeners]) listener()
10
+ },
11
+ subscribe(listener) {
12
+ listeners.add(listener)
13
+ return () => listeners.delete(listener)
14
+ },
15
+ }
16
+ }
@@ -0,0 +1,76 @@
1
+ // ── themed stylesheet (injected once per activation) ──────────────────
2
+ // Mirrors the better-sidebar explorer surface: tight 2px 6px 8px body,
3
+ // 30px rows, box-sizing border-box indentation, folder rows use the
4
+ // strong type face to read as directories, files stay regular.
5
+ const STYLES = `
6
+ .dfa { display:flex; flex-direction:column; height:100%; overflow-y:auto; overflow-x:hidden;
7
+ padding:2px 6px 8px; gap:2px; font:var(--dsw-font-s-14); color:var(--dsw-alias-label-primary); }
8
+ .dfa-iconbtn { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; padding:0;
9
+ border:none; border-radius:50%; background:transparent; color:var(--dsw-alias-label-secondary); cursor:pointer; flex:none;
10
+ transition:background var(--ds-transition-duration-slow) var(--ds-ease-in-out), color var(--ds-transition-duration-slow) var(--ds-ease-in-out); }
11
+ .dfa-iconbtn svg { display:block; }
12
+ .dfa-iconbtn:hover:not(:disabled) { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }
13
+ .dfa-iconbtn:disabled { opacity:.4; cursor:default; }
14
+ .dfa-iconbtn-danger:hover:not(:disabled) { color:var(--dsw-alias-state-error-primary); }
15
+ .dfa-iconbtn-xs { width:20px; height:20px; }
16
+ .dfa-section-head-actions { display:flex; align-items:center; gap:2px; flex:none; }
17
+ .dfa-section { margin-top:4px; }
18
+ .dfa-section-head { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:2px 6px 2px;
19
+ font:var(--dsw-font-xxxs-strong-11); color:var(--dsw-alias-label-tertiary); text-transform:uppercase; letter-spacing:.04em; }
20
+ .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;
21
+ font:var(--dsw-font-xxxs-strong-11); text-transform:uppercase; letter-spacing:.04em; }
22
+ .dfa-section-head-toggle:hover { color:var(--dsw-alias-label-primary); }
23
+ .dfa-section-head-toggle svg { display:block; flex:none; }
24
+ .dfa-empty { padding:8px 6px; font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-tertiary); line-height:1.7; }
25
+ .dfa-empty-hint { display:block; margin-top:2px; color:var(--dsw-alias-label-dimmed); font:var(--dsw-font-xxxs-11); }
26
+ .dfa-list { display:flex; flex-direction:column; gap:0; }
27
+ .dfa-row { display:flex; align-items:center; gap:6px; box-sizing:border-box; width:100%; min-height:26px;
28
+ margin:0; padding:0 8px; border:none; background:transparent; border-radius:8px; cursor:pointer; text-align:left;
29
+ animation:dfa-row-in 150ms var(--ds-ease-in-out); font:var(--dsw-font-s-14); color:var(--dsw-alias-label-primary); }
30
+ .dfa-row:hover { background:var(--dsw-alias-interactive-bg-hover); }
31
+ .dfa-row-dir { font:var(--dsw-font-s-strong-14); color:var(--dsw-alias-label-primary); }
32
+ .dfa-chevron { flex:none; display:flex; align-items:center; color:var(--dsw-alias-label-tertiary); }
33
+ .dfa-row-icon { flex:none; display:flex; align-items:center; color:var(--dsw-alias-label-secondary); }
34
+ /* Strong folder-vs-file separation: folders get the brand accent ink so the
35
+ directory rows read as the colorful navigation spine; files stay neutral
36
+ and faint, so the eye separates them instantly. */
37
+ .dfa-icon-folder { color:var(--dsw-alias-accent); }
38
+ .dfa-icon-file { color:var(--dsw-alias-label-tertiary); }
39
+ .dfa-row-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
40
+ .dfa-name-file { color:var(--dsw-alias-label-secondary); }
41
+ .dfa-time { flex:none; font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); white-space:nowrap; }
42
+ .dfa-op { flex:none; display:inline-flex; align-items:center; justify-content:center; height:17px; padding:0 5px; border-radius:4px;
43
+ font:var(--dsw-font-xxxs-strong-11); }
44
+ .dfa-op-create { color:var(--dsw-alias-state-success-primary); background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent); }
45
+ .dfa-op-modify { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
46
+ .dfa-op-read { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 12%, transparent); }
47
+ .dfa-counts { flex:none; display:flex; align-items:center; gap:3px; }
48
+ .dfa-count { flex:none; display:inline-flex; align-items:center; justify-content:center; height:15px; padding:0 4px; border-radius:4px;
49
+ font:var(--dsw-font-xxxs-strong-11); }
50
+ .dfa-count-create { color:var(--dsw-alias-state-success-primary); background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); }
51
+ .dfa-count-modify { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 14%, transparent); }
52
+ .dfa-count-read { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 10%, transparent); }
53
+ /* ── floating preview window (uses the sidebar's native viewer rendering) ──
54
+ A transparent-ish scrim fills the viewport and closes the window on any
55
+ outside click / Escape; the window itself stops propagation. Its body is a
56
+ scroll container so large files scroll inside. */
57
+ .dfa-fp-overlay { position:fixed; inset:0; z-index:1990; background:rgba(0,0,0,0.12); }
58
+ .dfa-fp { position:fixed; top:56px; right:340px; width:min(720px, calc(100vw - 376px)); height:76vh; max-height:860px;
59
+ background:var(--dsw-alias-bg-layer-2); color:var(--dsw-alias-label-primary);
60
+ border:1px solid var(--dsw-alias-border-l2); border-radius:10px; box-shadow:var(--dsw-shadow-lv2); z-index:2000;
61
+ display:flex; flex-direction:column; overflow:hidden; }
62
+ .dfa-fp-head { display:flex; align-items:center; gap:6px; padding:6px 8px; border-bottom:1px solid var(--dsw-alias-border-l1); flex:none; }
63
+ .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); }
64
+ .dfa-fp-actions { display:flex; align-items:center; gap:2px; flex:none; }
65
+ .dfa-fp-body { flex:1; overflow:auto; padding:10px 12px; min-height:0; }
66
+ .dfa-fp-note { color:var(--dsw-alias-label-tertiary); font:var(--dsw-font-xxs-12); }
67
+ .dfa-fp-err { color:var(--dsw-alias-state-error-primary); font:var(--dsw-font-xxs-12); white-space:pre-wrap; word-break:break-all; }
68
+ /* PDF preview: a native browser PDF frame filled from the plugin's own media
69
+ route, with a download fallback in the toolbar. */
70
+ .dfa-pdf { display:flex; flex-direction:column; width:100%; height:100%; }
71
+ .dfa-pdf-toolbar { flex:none; display:flex; justify-content:flex-end; padding:2px 4px 6px; }
72
+ .dfa-pdf-download { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-accent); text-decoration:none; }
73
+ .dfa-pdf-download:hover { text-decoration:underline; }
74
+ .dfa-pdf-frame { flex:1; min-height:0; width:100%; border:none; border-radius:6px; background:transparent; }
75
+ @keyframes dfa-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
76
+ `
@@ -0,0 +1,71 @@
1
+ // ── directory tree construction ───────────────────────────────────────
2
+ /**
3
+ * Collapse chain directories: a directory whose only child is another
4
+ * directory merges into it (a → a.b → a.b.c …). Deep single-child paths
5
+ * render as one dotted label with the file(s) directly beneath.
6
+ * `root` itself is never collapsed (its name is '' and would drop the
7
+ * top-level directory).
8
+ */
9
+ function compressChains(node, isRoot) {
10
+ for (const child of node.children) {
11
+ if (child.type === 'dir') compressChains(child, false)
12
+ }
13
+ if (isRoot) return
14
+ while (node.children.length === 1 && node.children[0].type === 'dir') {
15
+ const only = node.children[0]
16
+ node.name = `${node.name}.${only.name}`
17
+ node.children = only.children
18
+ node.compressed = true
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Sort a directory node: directories first (alphabetically), then files
24
+ * (by total activity, then name); recurse into directories.
25
+ */
26
+ function sortNode(node) {
27
+ node.children.sort((a, b) => {
28
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
29
+ if (a.type === 'dir') return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
30
+ const ta = a.read + a.create + a.modify
31
+ const tb = b.read + b.create + b.modify
32
+ return tb - ta || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
33
+ })
34
+ for (const child of node.children) {
35
+ if (child.type === 'dir') sortNode(child)
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Build a nested directory tree from per-file counts, keyed by the file's
41
+ * absolute path. Every directory node aggregates its subtree counters and
42
+ * sorts directories first (alphabetically), then files (by activity).
43
+ */
44
+ function buildTree(counts) {
45
+ const root = { type: 'dir', name: '', path: '', children: [], read: 0, create: 0, modify: 0 }
46
+ for (const [abs, counter] of Object.entries(counts)) {
47
+ const parts = abs.split('/').filter((part) => part !== '')
48
+ if (parts.length === 0) continue
49
+ const name = parts[parts.length - 1]
50
+ let node = root
51
+ for (const dir of parts.slice(0, -1)) {
52
+ let child = node.children.find((c) => c.type === 'dir' && c.name === dir)
53
+ if (child === undefined) {
54
+ child = { type: 'dir', name: dir, path: `${node.path}/${dir}`, children: [], read: 0, create: 0, modify: 0 }
55
+ node.children.push(child)
56
+ }
57
+ node = child
58
+ node.read += counter.read
59
+ node.create += counter.create
60
+ node.modify += counter.modify
61
+ }
62
+ node.children.push({
63
+ type: 'file', name, abs,
64
+ read: counter.read, create: counter.create, modify: counter.modify,
65
+ firstSeen: counter.firstSeen, lastSeen: counter.lastSeen,
66
+ })
67
+ }
68
+ sortNode(root)
69
+ compressChains(root, true)
70
+ return root
71
+ }
@@ -0,0 +1,129 @@
1
+ // ── view component ────────────────────────────────────────────────────
2
+ /** Shared empty bucket for sessions that have never loaded data (stable ref). */
3
+ const EMPTY_SESSION = { recent: [], counts: {}, loading: true }
4
+
5
+ /**
6
+ * Polling loader for one session: fetches stats on mount and on a fixed
7
+ * interval while visible, prefers the sidebar's authoritative session.cwd
8
+ * for relative display, and writes results into the per-session bucket.
9
+ */
10
+ function useSessionLoader(visible, sessionId, scope, dataStore, setCwd, setError) {
11
+ useEffect(() => {
12
+ if (!visible || sessionId === '') return
13
+ let cancelled = false
14
+ const load = () => {
15
+ void fetchStats(sessionId).then((value) => {
16
+ if (cancelled || value === null) return
17
+ setCwd((prev) => prev || value.cwd || '')
18
+ const current = dataStore.getSnapshot()
19
+ dataStore.set({
20
+ bySession: {
21
+ ...(current.bySession ?? {}),
22
+ [sessionId]: { recent: value.recent ?? [], counts: value.counts ?? {}, loading: false },
23
+ },
24
+ })
25
+ setError(false)
26
+ }).catch(() => {
27
+ if (!cancelled) setError(true)
28
+ })
29
+ }
30
+ load()
31
+ void fetchSessionCwd(sessionId).then((cwd) => {
32
+ if (!cancelled && cwd !== '') setCwd(cwd)
33
+ })
34
+ const timer = window.setInterval(load, POLL_MS)
35
+ return () => {
36
+ cancelled = true
37
+ window.clearInterval(timer)
38
+ }
39
+ }, [visible, sessionId, dataStore])
40
+ }
41
+
42
+ /** Error banner element, or null when the last load succeeded. */
43
+ function renderError(error) {
44
+ if (!error) return null
45
+ return createElement('div', { style: { color: 'var(--dsw-alias-state-error-primary)', padding: '4px 6px', font: 'var(--dsw-font-xxs-12)' } }, strings.loadError())
46
+ }
47
+
48
+ /** "最近访问" section: collapsible head with refresh/clear actions. */
49
+ function renderRecentSection(recent, recentOpen, onToggle, onRefresh, onClear, onOpen) {
50
+ return createElement(
51
+ 'div',
52
+ { className: 'dfa-section' },
53
+ createElement(
54
+ 'div',
55
+ { className: 'dfa-section-head' },
56
+ createElement(
57
+ 'button',
58
+ { className: 'dfa-section-head-toggle', onClick: onToggle },
59
+ recentOpen ? icon.chevronDown(13) : icon.chevronRight(13),
60
+ strings.recent(),
61
+ ),
62
+ createElement('span', { className: 'dfa-section-head-actions' },
63
+ createElement('button', { className: 'dfa-iconbtn dfa-iconbtn-xs', onClick: onRefresh, title: strings.refresh(), 'aria-label': strings.refresh() }, icon.refresh(14)),
64
+ createElement('button', { className: 'dfa-iconbtn dfa-iconbtn-xs dfa-iconbtn-danger', onClick: onClear, title: strings.clear(), 'aria-label': strings.clear() }, icon.trash(14)),
65
+ ),
66
+ ),
67
+ !recentOpen ? null : recent.length === 0
68
+ ? createElement(
69
+ 'div',
70
+ { className: 'dfa-empty' },
71
+ strings.empty(),
72
+ createElement('span', { className: 'dfa-empty-hint' }, strings.emptyHint()),
73
+ )
74
+ : createElement('div', { className: 'dfa-list' }, recent.map((entry) => recentEntry(entry, onOpen))),
75
+ )
76
+ }
77
+
78
+ /** "文件统计" section: the directory tree, or an empty hint. */
79
+ function renderStatsSection(tree, collapsedDirs, onToggleDir, onOpen) {
80
+ return createElement(
81
+ 'div',
82
+ { className: 'dfa-section' },
83
+ createElement('div', { className: 'dfa-section-head' }, strings.stats()),
84
+ tree.children.length === 0
85
+ ? createElement('div', { className: 'dfa-empty' }, strings.empty())
86
+ : createElement('div', { className: 'dfa-list' }, tree.children.map((child) => renderTreeNode(child, 0, collapsedDirs, onToggleDir, onOpen))),
87
+ )
88
+ }
89
+
90
+ /**
91
+ * The file-activity tab. Each session renders only its own store bucket:
92
+ * a fresh conversation shows an empty list immediately, with no residue
93
+ * from the previous session. Clicking any file opens a FLOATING preview
94
+ * that reuses the sidebar's NATIVE viewer via matchFileViewer.
95
+ */
96
+ function FileActivityView({ ctx, store, scope, visible, dataStore }) {
97
+ const data = useSyncExternalStore(dataStore.subscribe, dataStore.getSnapshot)
98
+ const [cwd, setCwd] = useState(scope?.cwd || '')
99
+ const [error, setError] = useState(false)
100
+ const [recentOpen, setRecentOpen] = useState(true)
101
+ const [collapsedDirs, setCollapsedDirs] = useState(() => new Set())
102
+ const sessionId = scope?.sessionId ?? ''
103
+ const sessionData = (data.bySession ?? {})[sessionId] ?? EMPTY_SESSION
104
+ const tree = useMemo(() => buildTree(sessionData.counts ?? {}), [sessionData.counts])
105
+ useEffect(() => {
106
+ if (scope?.cwd) setCwd(scope.cwd)
107
+ }, [scope?.cwd])
108
+ useSessionLoader(visible, sessionId, scope, dataStore, setCwd, setError)
109
+ // Switching conversations closes any floating preview left open by the
110
+ // previous session (preview is shared UI state; session data never
111
+ // crosses sessions anymore).
112
+ useEffect(() => {
113
+ dataStore.set({ preview: null })
114
+ }, [sessionId, dataStore])
115
+ const toggleDir = (path) => setCollapsedDirs((prev) => toggleInSet(prev, path))
116
+ const openPreview = (path) => dataStore.set({ preview: { abs: path, name: basenameOf(path) } })
117
+ const closePreview = () => dataStore.set({ preview: null })
118
+ const onClear = () => clearSessionData(dataStore, sessionId)
119
+ const onRefresh = () => refreshSessionData(dataStore, sessionId, setCwd, setError)
120
+ const recent = sessionData.recent ?? []
121
+ return createElement('div', { className: 'dfa' },
122
+ renderError(error),
123
+ renderRecentSection(recent, recentOpen, () => setRecentOpen((v) => !v), onRefresh, onClear, openPreview),
124
+ renderStatsSection(tree, collapsedDirs, toggleDir, openPreview),
125
+ data.preview
126
+ ? createElement(FloatingPreview, { ctx, store, scope, preview: data.preview, onClose: closePreview })
127
+ : null,
128
+ )
129
+ }