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 +6 -0
- package/lib/client.js +40 -3
- package/lib/media-route.js +57 -16
- package/lib/parts/api.part.js +5 -0
- package/lib/parts/apply.part.js +4 -0
- package/lib/parts/i18n.part.js +3 -0
- package/lib/parts/preview.part.js +28 -3
- package/package.json +1 -1
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
|
-
|
|
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
|
},
|
package/lib/media-route.js
CHANGED
|
@@ -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
|
|
4
|
-
* every path outside the session working
|
|
5
|
-
* file activity records files the agent
|
|
6
|
-
* sibling repos, … — so images/PDFs
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/parts/api.part.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/parts/apply.part.js
CHANGED
|
@@ -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 }
|
package/lib/parts/i18n.part.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
"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",
|