dsh-file-activity 0.5.7 → 0.5.9

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/store.js CHANGED
@@ -1,85 +1,258 @@
1
1
  /**
2
2
  * Store lifecycle for file activity: async state load with buffering of
3
- * records that arrive before it completes, debounced atomic persistence
4
- * (tmp+rename) and a teardown flush. The exposed store object carries a live
5
- * `state` reference so route handlers always read the current document.
3
+ * records that arrive before it completes, incremental JSON Lines persistence
4
+ * (append + threshold compact; issue #197) and a teardown flush. The exposed
5
+ * store object carries a live `state` reference so route handlers always read
6
+ * the current document.
7
+ */
8
+ import { createState, foldRecord, loadState, mapOp, stateFile } from './state.js';
9
+ import { createPersist, readAppenderStats } from './persist.js';
10
+ import { emptyEvicted, enforceQuota, normalizeLimits, } from './quota.js';
11
+ import { statSync } from 'node:fs';
12
+ /** compact 触发下限:快照成本必须远小于触发区间的事件流字节(写放大 ≤ 1.6 的关键)。 */
13
+ const MIN_COMPACT_LINES = 20000;
14
+ /** compact 触发行上限(文件大小上界 ≈ 上限行字节 + 快照字节)。 */
15
+ const MAX_COMPACT_LINES = 60000;
16
+ /** 事件行平均字节(保守估计;用于把快照字节折算成"应当攒多少行再 compact")。 */
17
+ const AVG_EVENT_BYTES = 80;
18
+ /**
19
+ * 自适应 compact 行阈值:快照越大,攒的事件行越多才 compact ——
20
+ * 目标让单次 compact 的快照成本 ≤ 触发区间事件流字节的 ~10%。
21
+ */
22
+ function compactLinesFor(stateBytes) {
23
+ const scaled = Math.ceil((stateBytes * 12) / AVG_EVENT_BYTES);
24
+ return Math.min(MAX_COMPACT_LINES, Math.max(MIN_COMPACT_LINES, scaled));
25
+ }
26
+ /** Build the per-apply store: { state, record, schedulePersist, stats, dispose }. */
27
+ export function createStore(ctx, deps = {}) {
28
+ const limits = normalizeLimits(deps.limits);
29
+ const store = {
30
+ state: createState(),
31
+ record: () => false,
32
+ schedulePersist: () => { },
33
+ stats: () => zeroStats(limits),
34
+ dispose: () => { },
35
+ };
36
+ const handle = {
37
+ ctx,
38
+ file: stateFile(),
39
+ store,
40
+ pending: [],
41
+ ready: false,
42
+ limits,
43
+ evicted: emptyEvicted(),
44
+ facts: [],
45
+ flushTimer: null,
46
+ persist: null,
47
+ dirtyChain: Promise.resolve(),
48
+ compactLines: deps.compactLines ?? MIN_COMPACT_LINES,
49
+ compactedEvents: 0,
50
+ compactLinesFixed: deps.compactLines !== undefined,
51
+ };
52
+ handle.persist = createPersist({
53
+ file: handle.file,
54
+ ctx,
55
+ factory: deps.appender,
56
+ flushMs: deps.flushMs,
57
+ // 内部阈值禁用:compact 完全由本层按「快照字节 / 事件字节」预算调度。
58
+ compactLines: Number.MAX_SAFE_INTEGER,
59
+ }, () => handle.store.state);
60
+ store.record = (sessionId, path, op, time) => record(handle, sessionId, path, op, time);
61
+ store.schedulePersist = () => enqueueFlush(handle, true);
62
+ store.stats = () => snapshotStats(handle);
63
+ store.dispose = () => dispose(handle);
64
+ void loadState(handle.file, limits, ctx.logger).then((loaded) => onLoaded(handle, loaded));
65
+ return store;
66
+ }
67
+ /** 空统计(加载完成前)。 */
68
+ function zeroStats(limits) {
69
+ return {
70
+ bytesWritten: 0,
71
+ writes: 0,
72
+ persistEvents: 0,
73
+ fileBytes: 0,
74
+ evictedSessions: 0,
75
+ evictedPaths: 0,
76
+ evictedPathsTotal: 0,
77
+ maxSessions: limits.maxSessions,
78
+ maxPathsPerSession: limits.maxPathsPerSession,
79
+ maxPathsTotal: limits.maxPathsTotal,
80
+ pathCount: 0,
81
+ };
82
+ }
83
+ /** 当前资源统计(落盘字节 + 淘汰计数 + 上限,供资源冒烟/看门狗读取)。 */
84
+ function snapshotStats(handle) {
85
+ const persist = handle.persist;
86
+ const ioStats = persist === null ? { bytesWritten: 0, writes: 0, events: 0 } : readAppenderStats(persist.appender);
87
+ return {
88
+ bytesWritten: ioStats.bytesWritten,
89
+ writes: ioStats.writes,
90
+ persistEvents: ioStats.events,
91
+ fileBytes: fileBytesOf(handle.file),
92
+ evictedSessions: handle.evicted.sessions,
93
+ evictedPaths: handle.evicted.paths,
94
+ evictedPathsTotal: handle.evicted.pathsTotal,
95
+ maxSessions: handle.limits.maxSessions,
96
+ maxPathsPerSession: handle.limits.maxPathsPerSession,
97
+ maxPathsTotal: handle.limits.maxPathsTotal,
98
+ pathCount: countPaths(handle.store.state),
99
+ };
100
+ }
101
+ /** 状态文件当前字节数(不存在返回 0)。 */
102
+ function fileBytesOf(file) {
103
+ try {
104
+ return statSync(file).size;
105
+ }
106
+ catch {
107
+ return 0;
108
+ }
109
+ }
110
+ /** 全局路径总数(内存有界断言口径)。 */
111
+ function countPaths(state) {
112
+ let total = 0;
113
+ for (const session of Object.values(state.sessions))
114
+ total += Object.keys(session.counts).length;
115
+ return total;
116
+ }
117
+ /** 把淘汰计数合并进 state.stats(state 是落盘真相,两者必须一致)。 */
118
+ function mergeEvicted(state, evicted) {
119
+ state.stats.evicted = { sessions: evicted.sessions, paths: evicted.paths, pathsTotal: evicted.pathsTotal };
120
+ }
121
+ /**
122
+ * State loaded: mark ready, drain buffered records, then rewrite the file in
123
+ * the JSON Lines format when anything changed — trimmed (caps/old history) or
124
+ * legacy (升级前的单行全量 JSON 快照). 只读不写的“纯加载”不产生任何写盘。
6
125
  */
7
- import { atomicWriteJson } from 'dsh-shared'
8
- import { applyRecord, createState, loadState, mapOp, stateFile, trimLoadedState } from './state.js'
9
-
10
- /** Build the per-apply store: { state, record, schedulePersist, dispose }. */
11
- export function createStore(ctx) {
12
- const store = { state: createState() }
13
- const handle = {
14
- ctx,
15
- file: stateFile(),
16
- store,
17
- pending: [],
18
- ready: false,
19
- persistTimer: null,
20
- dirtyChain: Promise.resolve(),
21
- }
22
- handle.persistNow = () => persistNow(handle)
23
- handle.persistSoon = () => persistSoon(handle)
24
- store.record = (sessionId, path, op, time) => record(handle, sessionId, path, op, time)
25
- store.schedulePersist = () => persistSoon(handle)
26
- store.dispose = () => dispose(handle)
27
- void loadState(handle.file).then((loaded) => onLoaded(handle, loaded))
28
- return store
29
- }
30
-
31
- /** Write the current state atomically (serialized through dirtyChain). */
32
- function persistNow(handle) {
33
- handle.dirtyChain = handle.dirtyChain
34
- .then(() => atomicWriteJson(handle.file, handle.store.state, handle.ctx.logger, '[dsh-file-activity]'))
35
- .catch(() => {})
36
- }
37
-
38
- /** Debounced (500ms) schedule of a persist. */
39
- function persistSoon(handle) {
40
- if (handle.persistTimer !== null) return
41
- handle.persistTimer = setTimeout(() => {
42
- handle.persistTimer = null
43
- persistNow(handle)
44
- }, 500)
45
- }
46
-
47
- /** State loaded: normalize/trim, mark ready, drain buffered records. */
48
126
  function onLoaded(handle, loaded) {
49
- const result = trimLoadedState(loaded)
50
- handle.store.state = result.state
51
- handle.ready = true
52
- if (drainPending(handle) || result.trimmed) persistSoon(handle)
127
+ handle.store.state = loaded.state;
128
+ handle.evicted = { ...loaded.evicted };
129
+ mergeEvicted(handle.store.state, handle.evicted);
130
+ handle.ready = true;
131
+ const drained = drainPending(handle);
132
+ if (drained || loaded.trimmed || loaded.legacy) {
133
+ flushFacts(handle);
134
+ schedulePersist(handle);
135
+ }
53
136
  }
54
-
55
- /** Apply every record buffered before the state load finished. */
137
+ /** Apply every record buffered before the state load finished (deferred flush). */
56
138
  function drainPending(handle) {
57
- const drained = handle.pending.splice(0)
58
- for (const item of drained) {
59
- applyRecord(handle.store.state, item.sessionId, item.path, mapOp(item.op), item.time)
60
- }
61
- return drained.length > 0
62
- }
63
-
64
- /** Record an operation once state is loaded (buffered before that). */
139
+ const drained = handle.pending.splice(0);
140
+ if (drained.length === 0)
141
+ return false;
142
+ handle.ready = false;
143
+ for (const item of drained) {
144
+ const fact = applyToState(handle, item.sessionId, mapOp(item.op), item.path, item.time);
145
+ if (fact !== null)
146
+ handle.facts.push(fact);
147
+ }
148
+ handle.ready = true;
149
+ return true;
150
+ }
151
+ /** 记录一条操作(状态未就绪时缓冲;非法记录不入内存)。 */
65
152
  function record(handle, sessionId, path, op, time) {
66
- if (!handle.ready) {
67
- handle.pending.push({ sessionId, path, op, time })
68
- return true
69
- }
70
- if (applyRecord(handle.store.state, sessionId, path, op, time)) {
71
- persistSoon(handle)
72
- return true
73
- }
74
- return false
75
- }
76
-
77
- /** Tear down on unload: flush pending persistence. */
153
+ if (!handle.ready) {
154
+ handle.pending.push({ sessionId, path, op, time });
155
+ return true;
156
+ }
157
+ const fact = applyToState(handle, sessionId, mapOp(op), path, time);
158
+ if (fact === null)
159
+ return false;
160
+ handle.facts.push(fact);
161
+ enqueueFlush(handle, false);
162
+ return true;
163
+ }
164
+ /** 应用一条记录到内存态并执行入口配额(非法记录返回 null)。 */
165
+ function applyToState(handle, sessionId, op, path, time) {
166
+ const folded = foldRecord(handle.store.state, sessionId, path, op, time);
167
+ if (folded === null)
168
+ return null;
169
+ // 配额只在「新增条目」时可能被突破:命中已记录路径/删除不触发扫描(热路径 O(1))。
170
+ if (!folded.created && !folded.newPath)
171
+ return { sessionId, path, op, time: typeof time === 'number' ? time : Date.now() };
172
+ const before = handle.evicted;
173
+ const evicted = emptyEvicted();
174
+ enforceQuota(handle.store.state, sessionId, handle.limits, evicted, handle.ctx.logger);
175
+ if (evicted.sessions + evicted.paths + evicted.pathsTotal > 0) {
176
+ handle.evicted = {
177
+ sessions: before.sessions + evicted.sessions,
178
+ paths: before.paths + evicted.paths,
179
+ pathsTotal: before.pathsTotal + evicted.pathsTotal,
180
+ };
181
+ mergeEvicted(handle.store.state, handle.evicted);
182
+ }
183
+ return { sessionId, path, op, time: typeof time === 'number' ? time : Date.now() };
184
+ }
185
+ /** 防抖排空:窗口内所有事件合并成一次 appendFile(写放大 ≤ 1 + 小系数)。 */
186
+ function enqueueFlush(handle, immediate) {
187
+ if (immediate) {
188
+ flushFacts(handle);
189
+ return;
190
+ }
191
+ if (handle.flushTimer !== null)
192
+ return;
193
+ handle.flushTimer = setTimeout(() => {
194
+ handle.flushTimer = null;
195
+ flushFacts(handle);
196
+ }, 500);
197
+ }
198
+ /** 把挂起记录以 JSON Lines 增量追加落盘(O(事件字节))。 */
199
+ function flushFacts(handle) {
200
+ if (handle.flushTimer !== null) {
201
+ clearTimeout(handle.flushTimer);
202
+ handle.flushTimer = null;
203
+ }
204
+ const persist = handle.persist;
205
+ const facts = handle.facts.splice(0);
206
+ if (persist === null || facts.length === 0)
207
+ return;
208
+ for (const fact of facts)
209
+ persist.append(fact);
210
+ handle.dirtyChain = handle.dirtyChain.then(() => persist.flush()).catch(() => { });
211
+ maybeCompact(handle);
212
+ }
213
+ /**
214
+ * 预算驱动的 compact:累计追加行数达到阈值时原子快照一次(文件大小有界),
215
+ * 阈值随快照体积自适应 —— 保证 compact 写入摊薄到足够多的事件行上。
216
+ */
217
+ function maybeCompact(handle) {
218
+ const persist = handle.persist;
219
+ if (persist === null)
220
+ return;
221
+ const total = readAppenderStats(persist.appender).events;
222
+ if (total - handle.compactedEvents < handle.compactLines)
223
+ return;
224
+ handle.compactedEvents = total;
225
+ handle.dirtyChain = handle.dirtyChain
226
+ .then(() => persist.compact(handle.store.state))
227
+ .then(() => {
228
+ if (handle.compactLinesFixed)
229
+ return;
230
+ handle.compactLines = compactLinesFor(Buffer.byteLength(JSON.stringify(handle.store.state)));
231
+ })
232
+ .catch(() => { });
233
+ }
234
+ /** 显式调度(schedulePersist:行冲刷 + 原子快照,文件大小有界)。 */
235
+ function schedulePersist(handle) {
236
+ const persist = handle.persist;
237
+ if (persist === null)
238
+ return;
239
+ flushFacts(handle);
240
+ handle.dirtyChain = handle.dirtyChain.then(() => persist.compact(handle.store.state)).catch(() => { });
241
+ }
242
+ /** Tear down on unload: flush pending lines + compact once (memory = truth). */
78
243
  function dispose(handle) {
79
- if (handle.persistTimer !== null) {
80
- clearTimeout(handle.persistTimer)
81
- handle.persistTimer = null
82
- }
83
- persistNow(handle)
84
- void handle.dirtyChain
244
+ if (handle.flushTimer !== null) {
245
+ clearTimeout(handle.flushTimer);
246
+ handle.flushTimer = null;
247
+ }
248
+ const persist = handle.persist;
249
+ if (persist === null)
250
+ return;
251
+ for (const fact of handle.facts.splice(0))
252
+ persist.append(fact);
253
+ handle.dirtyChain = handle.dirtyChain
254
+ .then(() => persist.flush())
255
+ .then(() => persist.dispose())
256
+ .then(() => persist.compact(handle.store.state))
257
+ .catch(() => { });
85
258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-file-activity",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "DSH 侧边栏文件活动插件:按 LRU 记录文件读取/新增/修改事件(最近访问),按绝对路径树形统计(文件统计),点击文件浮窗预览(代码高亮/Markdown/图片/PDF)。DSH web plugin: file activity tracker — LRU recent-access list plus per-file read/create/modify counts in a folder tree, with floating preview.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -12,7 +12,8 @@
12
12
  "node": ">=22"
13
13
  },
14
14
  "scripts": {
15
- "build": "node scripts/build.mjs",
15
+ "build": "npx tsc -p tsconfig.json && node scripts/build.mjs",
16
+ "typecheck": "npx tsc --noEmit -p tsconfig.json && npx tsc --noEmit -p tsconfig.client.json",
16
17
  "test": "npx vitest run --coverage && cucumber-js test/features --import 'test/features/steps/*.mjs'"
17
18
  },
18
19
  "exports": {
@@ -41,17 +42,20 @@
41
42
  }
42
43
  },
43
44
  "dependencies": {
44
- "dsh-better-sidebar": ">=0.14.0",
45
- "dsh-shared": "^0.1.0"
45
+ "dsh-shared": "^0.1.4"
46
46
  },
47
47
  "peerDependencies": {
48
- "cordis": "^4.0.0-rc.8",
49
- "react": "^18.2.0 || ^19.2.0"
48
+ "cordis": "^4.0.0-rc.10",
49
+ "react": "^18.2.0 || ^19.3.0"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "cordis": {
53
53
  "optional": true
54
54
  }
55
55
  },
56
+ "devDependencies": {
57
+ "@types/node": "^26.5.1",
58
+ "typescript": "^7.0.2"
59
+ },
56
60
  "license": "MIT"
57
61
  }
@@ -1,308 +0,0 @@
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
- * Raw system errors are translated to friendly, locale-aware messages
17
- * (issue #68): deleted files and workspace-fenced paths must never surface
18
- * ENOENT / "is outside workspace" verbatim. */
19
- function fsReadError(json, viewer) {
20
- const raw = json?.error?.message ?? ''
21
- let message
22
- if (raw === '') message = strings.previewFailed()
23
- else if (/ENOENT|no such file|does not exist|cannot resolve/i.test(raw)) message = strings.fileMissing()
24
- else if (/outside workspace/i.test(raw)) message = strings.fileOutside()
25
- else message = raw
26
- return { status: 'error', viewer, message }
27
- }
28
-
29
- /** Milliseconds after which an error-state preview closes itself (issue #76):
30
- * a shell that failed to load any content is useless, so it must not linger
31
- * over the main UI until the user finds the × button. */
32
- const AUTO_CLOSE_MS = 2500
33
-
34
- /** Whether a pointerdown target lies inside the floating window. The window
35
- * surface carries the `.dfa-fp` class; anything else counts as "outside"
36
- * and dismisses the preview (issue #76 — click anywhere outside closes). */
37
- function isInsideFloating(target) {
38
- if (!target || typeof target.closest !== 'function') return false
39
- return target.closest('.dfa-fp') !== null
40
- }
41
-
42
- /** Click behavior for the window surface: in the error state ANY click
43
- * closes the shell (there is no content to interact with), otherwise the
44
- * click is swallowed so the viewer's own interactions keep working. */
45
- function previewClickAction(load, event) {
46
- if (load.status === 'error') return 'close'
47
- if (event && event.stopPropagation) event.stopPropagation()
48
- return 'stop'
49
- }
50
-
51
- /** Close the floating preview when the tab goes hidden (switching tabs /
52
- * operating the main UI), so it never lingers over the interface. */
53
- function closePreviewOnHidden(visible, dataStore) {
54
- if (!visible) dataStore.set({ preview: null })
55
- }
56
-
57
- /**
58
- * Load fsRead content through the sidebar API and resolve the viewer's
59
- * load state (ready with text, or error with the API message). When the
60
- * sidebar refuses a recorded path (its workspace fence, e.g. agent-read
61
- * files under ~/.dsh), fall back to the plugin's own text route, which
62
- * authorizes exactly the paths this session recorded (issue #68).
63
- */
64
- async function loadFsReadContent(viewer, path, scope, sessionId) {
65
- const target = resolvePath(path, scope?.cwd ?? '')
66
- const response = await fetch('/sidebar/api/fs.read', {
67
- method: 'POST',
68
- headers: { 'content-type': 'application/json' },
69
- body: JSON.stringify({ sessionId, path: target }),
70
- })
71
- const json = await response.json()
72
- if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
73
- // The sidebar refused — try OUR recorded-path text route before giving up.
74
- const textJson = await fetchTextContent(sessionId, path)
75
- if (isFsReadOk(textJson)) return { status: 'ready', viewer, content: textJson.value.content }
76
- return fsReadError(json, viewer)
77
- }
78
-
79
- /** Plugin text route (fs.read-shaped JSON), or null on any failure. */
80
- async function fetchTextContent(sessionId, path) {
81
- try {
82
- const response = await fetch(textUrlOf(sessionId, path))
83
- return await response.json()
84
- } catch {
85
- return null
86
- }
87
- }
88
-
89
- /**
90
- * Fetch the bytes the viewer's fetchStrategy needs (fsRead text /
91
- * mediaUrl / customData) and resolve its load state.
92
- */
93
- async function fetchPreviewLoad(viewer, path, scope, sessionId) {
94
- const strategy = viewer.fetchStrategy
95
- if (strategy === 'fsRead') return loadFsReadContent(viewer, path, scope, sessionId)
96
- if (strategy === 'mediaUrl') {
97
- return { status: 'ready', viewer, mediaUrl: mediaUrlOf(sessionId, path) }
98
- }
99
- if (strategy === 'custom') {
100
- const data = await (viewer.load?.(path, scope) ?? Promise.resolve(undefined))
101
- return { status: 'ready', viewer, customData: data }
102
- }
103
- // 'binary-download' and anything else: mount the viewer's own
104
- // component (it handles the download / media itself).
105
- return { status: 'ready', viewer }
106
- }
107
-
108
- /**
109
- * Resolve the file's viewer through the sidebar registry and load the
110
- * bytes it needs; failures become an error state shown in the window.
111
- */
112
- function usePreviewLoader(service, path, sessionId, scope) {
113
- const [load, setLoad] = useState({ status: 'loading', viewer: null })
114
- useEffect(() => {
115
- let cancelled = false
116
- const viewer = service?.matchFileViewer?.(path)
117
- if (!viewer) {
118
- setLoad({ status: 'error', viewer: null, message: strings.previewUnsupported() })
119
- return () => {
120
- cancelled = true
121
- }
122
- }
123
- setLoad({ status: 'loading', viewer })
124
- fetchPreviewLoad(viewer, path, scope, sessionId)
125
- .then((next) => {
126
- if (!cancelled) setLoad(next)
127
- })
128
- .catch((error) => {
129
- if (!cancelled)
130
- setLoad({
131
- status: 'error',
132
- viewer,
133
- message: error instanceof Error ? error.message : String(error),
134
- })
135
- })
136
- return () => {
137
- cancelled = true
138
- }
139
- }, [path, sessionId, scope])
140
- return load
141
- }
142
-
143
- /** Preview window body: loading note / error panel / viewer mount. */
144
- function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
145
- if (load.status === 'loading') {
146
- return createElement('div', { className: 'dfa-fp-note' }, strings.loading())
147
- }
148
- if (load.status === 'error') {
149
- return createElement(
150
- 'div',
151
- { className: 'dfa-fp-err' },
152
- strings.previewFailed(),
153
- load.message
154
- ? createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.85 } }, load.message)
155
- : null,
156
- createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.7 } }, strings.autoCloseHint()),
157
- )
158
- }
159
- if (load.viewer.id === 'pdf') {
160
- const url = mediaUrlOf(sessionId, path)
161
- return createElement(PdfPreview, { src: url, download: `${url}&download=1`, title })
162
- }
163
- return createElement(load.viewer.component, {
164
- ctx,
165
- store,
166
- scope,
167
- path,
168
- title,
169
- viewerId: load.viewer.id,
170
- content: load.content,
171
- mediaUrl: load.mediaUrl,
172
- customData: load.customData,
173
- })
174
- }
175
-
176
- /**
177
- * A floating preview window. Instead of re-implementing rendering, it
178
- * asks the sidebar registry for the file's viewer (`matchFileViewer`),
179
- * fetches the bytes the viewer's fetchStrategy needs (fsRead text /
180
- * mediaUrl / customData), then mounts that viewer's own component — so
181
- * code gets syntax highlighting and markdown gets rendered by the SAME
182
- * built-in renderers the sidebar's editor tab uses.
183
- *
184
- * Media caveat: the sidebar's own media route (/sidebar/file) only serves
185
- * files inside the session working directory, while file activity records
186
- * files the agent touched anywhere (/tmp scratch files, sibling repos…).
187
- * Media bytes therefore come from OUR route (/file-activity/file), which
188
- * authorizes exactly the paths this session recorded; PDF is the one
189
- * built-in viewer that fetches its own URL internally (it ignores the
190
- * `mediaUrl` prop), so it gets a small iframe preview instead.
191
- */
192
- /**
193
- * Dismissal affordances (issue #76 — the preview must never linger):
194
- * 1. pointerdown anywhere OUTSIDE the window closes it (capture phase, so
195
- * it fires even if another element sits above the scrim);
196
- * 2. the scrim's own onClick (kept as a fallback);
197
- * 3. Escape;
198
- * 4. the × button;
199
- * 5. an error state closes itself after AUTO_CLOSE_MS (no content to show).
200
- */
201
- function usePreviewDismiss(load, onClose) {
202
- useEffect(() => {
203
- if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
204
- const handler = (event) => {
205
- if (!isInsideFloating(event?.target)) onClose()
206
- }
207
- document.addEventListener('pointerdown', handler, true)
208
- return () => document.removeEventListener('pointerdown', handler, true)
209
- }, [onClose])
210
-
211
- useEffect(() => {
212
- if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
213
- const handler = (event) => {
214
- if (event && event.key === 'Escape') onClose()
215
- }
216
- document.addEventListener('keydown', handler)
217
- return () => document.removeEventListener('keydown', handler)
218
- }, [onClose])
219
-
220
- useEffect(() => {
221
- if (load.status !== 'error') return undefined
222
- if (typeof window === 'undefined' || typeof window.setTimeout !== 'function') return undefined
223
- const timer = window.setTimeout(onClose, AUTO_CLOSE_MS)
224
- return () => window.clearTimeout(timer)
225
- }, [load.status, onClose])
226
- }
227
-
228
- /** The floating window element: head (title + hint + close) and body. */
229
- function renderFloatingWindow(load, ctx, store, scope, path, title, sessionId, onClose) {
230
- return createElement(
231
- 'div',
232
- { className: 'dfa-fp-overlay', onClick: onClose },
233
- createElement(
234
- 'div',
235
- {
236
- className: 'dfa-fp',
237
- onClick: (event) => {
238
- if (previewClickAction(load, event) === 'close') onClose()
239
- },
240
- },
241
- createElement(
242
- 'div',
243
- { className: 'dfa-fp-head' },
244
- createElement('span', { className: 'dfa-fp-title' }, title),
245
- createElement('span', { className: 'dfa-fp-hint' }, strings.clickOutsideToClose()),
246
- createElement(
247
- 'span',
248
- { className: 'dfa-fp-actions' },
249
- createElement(
250
- 'button',
251
- {
252
- className: 'dfa-iconbtn',
253
- title: strings.closePreview(),
254
- 'aria-label': strings.closePreview(),
255
- onClick: () => onClose(),
256
- },
257
- icon.close(15),
258
- ),
259
- ),
260
- ),
261
- createElement(
262
- 'div',
263
- { className: 'dfa-fp-body' },
264
- renderPreviewBody(load, ctx, store, scope, path, title, sessionId),
265
- ),
266
- ),
267
- )
268
- }
269
-
270
- function FloatingPreview({ ctx, store, scope, preview, onClose }) {
271
- const sessionId = scope?.sessionId ?? ''
272
- const path = preview.abs
273
- const title = preview.name
274
- const service = ctx.betterSidebar
275
- const load = usePreviewLoader(service, path, sessionId, scope)
276
- usePreviewDismiss(load, onClose)
277
- return renderFloatingWindow(load, ctx, store, scope, path, title, sessionId, onClose)
278
- }
279
-
280
- /**
281
- * Lightweight PDF preview. better-sidebar's built-in PdfView fetches
282
- * `/sidebar/file` internally (it ignores any injected `mediaUrl` prop),
283
- * and that route refuses files outside the session working directory — so
284
- * a recorded /tmp PDF would never load. This tiny view embeds the bytes
285
- * from OUR media route in a native browser PDF frame, with a download
286
- * fallback in its toolbar.
287
- */
288
- function PdfPreview({ src, download, title }) {
289
- return createElement(
290
- 'div',
291
- { className: 'dfa-pdf' },
292
- createElement(
293
- 'div',
294
- { className: 'dfa-pdf-toolbar' },
295
- createElement(
296
- 'a',
297
- {
298
- className: 'dfa-pdf-download',
299
- href: download,
300
- download: true,
301
- title: strings.downloadToView(),
302
- },
303
- strings.downloadToView(),
304
- ),
305
- ),
306
- createElement('iframe', { className: 'dfa-pdf-frame', src, title }),
307
- )
308
- }