dsh-file-activity 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  本文件记录 dsh-file-activity 的所有版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.5.4] - 2026-09-01
6
+
7
+ ### 变更
8
+
9
+ - fix(file-activity): 浮窗预览优雅降级与工作区外文本读取(issue #68)
10
+
5
11
  ## [0.5.3] - 2026-09-01
6
12
 
7
13
  ### 变更
package/lib/client.js CHANGED
@@ -86,6 +86,9 @@ const strings = {
86
86
  loading: () => (isZh() ? '加载中…' : 'Loading…'),
87
87
  previewUnsupported: () => (isZh() ? '该文件类型暂不支持预览' : 'This file type cannot be previewed yet'),
88
88
  previewFailed: () => (isZh() ? '预览加载失败' : 'Preview failed to load'),
89
+ fileMissing: () => (isZh() ? '文件不存在或已被删除' : 'This file no longer exists'),
90
+ fileOutside: () =>
91
+ isZh() ? '文件位于工作区外,暂无法读取内容' : 'The file is outside the workspace and cannot be read',
89
92
  downloadToView: () => (isZh() ? '下载查看' : 'download to view'),
90
93
  }
91
94
 
@@ -266,6 +269,11 @@ function mediaUrlOf(sessionId, path) {
266
269
  return `/file-activity/file?${new URLSearchParams({ sessionId, path })}`
267
270
  }
268
271
 
272
+ /** Plugin text route URL (`as=text`): fs.read-shaped JSON for recorded text. */
273
+ function textUrlOf(sessionId, path) {
274
+ return `/file-activity/file?${new URLSearchParams({ sessionId, path, as: 'text' })}`
275
+ }
276
+
269
277
  // ── fetch interception: sidebar file operations ───────────────────────
270
278
  function methodOf(init) {
271
279
  return (init?.method ?? 'GET').toUpperCase()
@@ -1154,14 +1162,26 @@ function isFsReadOk(json) {
1154
1162
  return json !== null && typeof json === 'object' && json.ok === true && typeof json.value?.content === 'string'
1155
1163
  }
1156
1164
 
1157
- /** Error load state from an fs.read API response (or a generic message). */
1165
+ /** Error load state from an fs.read API response (or a generic message).
1166
+ * Raw system errors are translated to friendly, locale-aware messages
1167
+ * (issue #68): deleted files and workspace-fenced paths must never surface
1168
+ * ENOENT / "is outside workspace" verbatim. */
1158
1169
  function fsReadError(json, viewer) {
1159
- return { status: 'error', viewer, message: json?.error?.message ?? strings.previewFailed() }
1170
+ const raw = json?.error?.message ?? ''
1171
+ let message
1172
+ if (raw === '') message = strings.previewFailed()
1173
+ else if (/ENOENT|no such file|does not exist|cannot resolve/i.test(raw)) message = strings.fileMissing()
1174
+ else if (/outside workspace/i.test(raw)) message = strings.fileOutside()
1175
+ else message = raw
1176
+ return { status: 'error', viewer, message }
1160
1177
  }
1161
1178
 
1162
1179
  /**
1163
1180
  * Load fsRead content through the sidebar API and resolve the viewer's
1164
- * load state (ready with text, or error with the API message).
1181
+ * load state (ready with text, or error with the API message). When the
1182
+ * sidebar refuses a recorded path (its workspace fence, e.g. agent-read
1183
+ * files under ~/.dsh), fall back to the plugin's own text route, which
1184
+ * authorizes exactly the paths this session recorded (issue #68).
1165
1185
  */
1166
1186
  async function loadFsReadContent(viewer, path, scope, sessionId) {
1167
1187
  const target = resolvePath(path, scope?.cwd ?? '')
@@ -1172,9 +1192,22 @@ async function loadFsReadContent(viewer, path, scope, sessionId) {
1172
1192
  })
1173
1193
  const json = await response.json()
1174
1194
  if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
1195
+ // The sidebar refused — try OUR recorded-path text route before giving up.
1196
+ const textJson = await fetchTextContent(sessionId, path)
1197
+ if (isFsReadOk(textJson)) return { status: 'ready', viewer, content: textJson.value.content }
1175
1198
  return fsReadError(json, viewer)
1176
1199
  }
1177
1200
 
1201
+ /** Plugin text route (fs.read-shaped JSON), or null on any failure. */
1202
+ async function fetchTextContent(sessionId, path) {
1203
+ try {
1204
+ const response = await fetch(textUrlOf(sessionId, path))
1205
+ return await response.json()
1206
+ } catch {
1207
+ return null
1208
+ }
1209
+ }
1210
+
1178
1211
  /**
1179
1212
  * Fetch the bytes the viewer's fetchStrategy needs (fsRead text /
1180
1213
  * mediaUrl / customData) and resolve its load state.
@@ -1450,6 +1483,10 @@ exports.apply = function apply(ctx) {
1450
1483
  ctx.effect(() => installAutoOpen(ctx, TAB_ID), 'dsh-file-activity: auto-open')
1451
1484
  }
1452
1485
 
1486
+ // Internal functions exposed for the render-path test suite only; inert in
1487
+ // the browser bundle (plain properties on the exports object).
1488
+ exports.__test = { loadFsReadContent, fsReadError, fetchTextContent, textUrlOf, strings }
1489
+
1453
1490
 
1454
1491
  return module.exports
1455
1492
  },
@@ -1,12 +1,17 @@
1
1
  /**
2
- * /file-activity/file media route: serves recorded file bytes (images / PDFs)
3
- * for the floating preview. The sidebar's own /sidebar/file route refuses
4
- * every path outside the session working directory (isWithin(cwd, …)), but
5
- * file activity records files the agent touched ANYWHERE — /tmp scratch files,
6
- * sibling repos, … — so images/PDFs outside the workspace resolve to a broken
7
- * <img>. This route serves the bytes with the same trust fence, swapping the
8
- * "inside the session cwd" boundary for "paths this session actually
9
- * recorded".
2
+ * /file-activity/file media route: serves recorded file bytes (images / PDFs
3
+ * AND, with `as=text`, text content) for the floating preview. The sidebar's
4
+ * own /sidebar/file route refuses every path outside the session working
5
+ * directory (isWithin(cwd, …)), but file activity records files the agent
6
+ * touched ANYWHERE — /tmp scratch files, sibling repos, … — so images/PDFs
7
+ * outside the workspace resolve to a broken <img>. This route serves the
8
+ * bytes with the same trust fence, swapping the "inside the session cwd"
9
+ * boundary for "paths this session actually recorded".
10
+ *
11
+ * `as=text` (issue #68): the floating preview's text path first asks the
12
+ * sidebar fs.read API; when the sidebar refuses a recorded file (workspace
13
+ * fence), the client falls back to this route and receives an fs.read-shaped
14
+ * JSON payload ({ ok, value: { content } }) so the viewer mounts unchanged.
10
15
  */
11
16
  import { readFile, stat } from 'node:fs/promises'
12
17
  import { basename, isAbsolute, join } from 'node:path'
@@ -17,6 +22,9 @@ import { isRecordedPath } from './state.js'
17
22
  /** Cap for the plugin's own media route (bytes): images / PDFs only. */
18
23
  const MEDIA_LIMIT = 64 * 1024 * 1024
19
24
 
25
+ /** Cap for `as=text` payloads (characters): text previews, not archives. */
26
+ const TEXT_LIMIT = 2 * 1024 * 1024
27
+
20
28
  /** Content types served by /file-activity/file (mirrors the sidebar's set). */
21
29
  const MEDIA_TYPES = {
22
30
  '.png': 'image/png',
@@ -55,14 +63,7 @@ export function createMediaHandler({ ctx, store, fence }) {
55
63
  return
56
64
  }
57
65
  try {
58
- const url = new URL(request.url ?? '/', 'http://dsh.internal')
59
- const sessionId = url.searchParams.get('sessionId')
60
- const raw = url.searchParams.get('path')
61
- assertMediaParams(sessionId, raw)
62
- if (!isRecordedPath(store.state, sessionId, raw))
63
- throw mediaError(403, "path is not in this session's file activity")
64
- const abs = isAbsolute(raw) ? raw : join(sessionCwdOf(ctx, sessionId), raw)
65
- await serveMedia(response, abs, url)
66
+ await serveRecordedFile(request, response, ctx, store)
66
67
  } catch (error) {
67
68
  const status = typeof error?.status === 'number' ? error.status : 400
68
69
  writeJson(response, status, {
@@ -73,6 +74,26 @@ export function createMediaHandler({ ctx, store, fence }) {
73
74
  }
74
75
  }
75
76
 
77
+ /**
78
+ * Resolve a recorded path (authorized per session) and serve it: bytes
79
+ * (images / PDFs, `download=1` supported) or, with `as=text`, an
80
+ * fs.read-shaped JSON payload for the floating preview's text fallback.
81
+ */
82
+ async function serveRecordedFile(request, response, ctx, store) {
83
+ const url = new URL(request.url ?? '/', 'http://dsh.internal')
84
+ const sessionId = url.searchParams.get('sessionId')
85
+ const raw = url.searchParams.get('path')
86
+ assertMediaParams(sessionId, raw)
87
+ if (!isRecordedPath(store.state, sessionId, raw))
88
+ throw mediaError(403, "path is not in this session's file activity")
89
+ const abs = isAbsolute(raw) ? raw : join(sessionCwdOf(ctx, sessionId), raw)
90
+ if (url.searchParams.get('as') === 'text') {
91
+ await serveText(response, abs)
92
+ return
93
+ }
94
+ await serveMedia(response, abs, url)
95
+ }
96
+
76
97
  /** Both query parameters are required for a media request. */
77
98
  function assertMediaParams(sessionId, raw) {
78
99
  if (sessionId === null || raw === null || raw === '') throw mediaError(400, 'sessionId and path are required')
@@ -96,3 +117,23 @@ async function serveMedia(response, abs, url) {
96
117
  response.writeHead(200, headers)
97
118
  response.end(body)
98
119
  }
120
+
121
+ /**
122
+ * `as=text` mode: serve a recorded file's text content as an fs.read-shaped
123
+ * JSON payload ({ ok, value: { content } }) so the floating preview's text
124
+ * viewers mount unchanged even when the sidebar fs.read refuses the path
125
+ * (workspace fence — issue #68). Same authorization as bytes serving:
126
+ * recorded paths only.
127
+ */
128
+ async function serveText(response, abs) {
129
+ let info
130
+ try {
131
+ info = await stat(abs)
132
+ } catch {
133
+ throw mediaError(404, 'file not found')
134
+ }
135
+ if (!info.isFile()) throw mediaError(400, 'not a file')
136
+ if (info.size > TEXT_LIMIT) throw mediaError(413, 'file too large')
137
+ const content = await readFile(abs, 'utf8')
138
+ writeJson(response, 200, { ok: true, value: { content } })
139
+ }
@@ -43,3 +43,8 @@ function postClear(sessionId) {
43
43
  function mediaUrlOf(sessionId, path) {
44
44
  return `/file-activity/file?${new URLSearchParams({ sessionId, path })}`
45
45
  }
46
+
47
+ /** Plugin text route URL (`as=text`): fs.read-shaped JSON for recorded text. */
48
+ function textUrlOf(sessionId, path) {
49
+ return `/file-activity/file?${new URLSearchParams({ sessionId, path, as: 'text' })}`
50
+ }
@@ -83,3 +83,7 @@ exports.apply = function apply(ctx) {
83
83
  // auto-open once per session (default on)
84
84
  ctx.effect(() => installAutoOpen(ctx, TAB_ID), 'dsh-file-activity: auto-open')
85
85
  }
86
+
87
+ // Internal functions exposed for the render-path test suite only; inert in
88
+ // the browser bundle (plain properties on the exports object).
89
+ exports.__test = { loadFsReadContent, fsReadError, fetchTextContent, textUrlOf, strings }
@@ -38,5 +38,8 @@ const strings = {
38
38
  loading: () => (isZh() ? '加载中…' : 'Loading…'),
39
39
  previewUnsupported: () => (isZh() ? '该文件类型暂不支持预览' : 'This file type cannot be previewed yet'),
40
40
  previewFailed: () => (isZh() ? '预览加载失败' : 'Preview failed to load'),
41
+ fileMissing: () => (isZh() ? '文件不存在或已被删除' : 'This file no longer exists'),
42
+ fileOutside: () =>
43
+ isZh() ? '文件位于工作区外,暂无法读取内容' : 'The file is outside the workspace and cannot be read',
41
44
  downloadToView: () => (isZh() ? '下载查看' : 'download to view'),
42
45
  }
@@ -12,14 +12,26 @@ function isFsReadOk(json) {
12
12
  return json !== null && typeof json === 'object' && json.ok === true && typeof json.value?.content === 'string'
13
13
  }
14
14
 
15
- /** Error load state from an fs.read API response (or a generic message). */
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. */
16
19
  function fsReadError(json, viewer) {
17
- return { status: 'error', viewer, message: json?.error?.message ?? strings.previewFailed() }
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 }
18
27
  }
19
28
 
20
29
  /**
21
30
  * Load fsRead content through the sidebar API and resolve the viewer's
22
- * load state (ready with text, or error with the API message).
31
+ * load state (ready with text, or error with the API message). When the
32
+ * sidebar refuses a recorded path (its workspace fence, e.g. agent-read
33
+ * files under ~/.dsh), fall back to the plugin's own text route, which
34
+ * authorizes exactly the paths this session recorded (issue #68).
23
35
  */
24
36
  async function loadFsReadContent(viewer, path, scope, sessionId) {
25
37
  const target = resolvePath(path, scope?.cwd ?? '')
@@ -30,9 +42,22 @@ async function loadFsReadContent(viewer, path, scope, sessionId) {
30
42
  })
31
43
  const json = await response.json()
32
44
  if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
45
+ // The sidebar refused — try OUR recorded-path text route before giving up.
46
+ const textJson = await fetchTextContent(sessionId, path)
47
+ if (isFsReadOk(textJson)) return { status: 'ready', viewer, content: textJson.value.content }
33
48
  return fsReadError(json, viewer)
34
49
  }
35
50
 
51
+ /** Plugin text route (fs.read-shaped JSON), or null on any failure. */
52
+ async function fetchTextContent(sessionId, path) {
53
+ try {
54
+ const response = await fetch(textUrlOf(sessionId, path))
55
+ return await response.json()
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+
36
61
  /**
37
62
  * Fetch the bytes the viewer's fetchStrategy needs (fsRead text /
38
63
  * mediaUrl / customData) and resolve its load state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-file-activity",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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",