dsh-file-activity 0.5.3 → 0.5.5
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 +21 -0
- package/README.md +1 -3
- package/lib/client.js +139 -13
- package/lib/media-route.js +56 -16
- package/lib/parts/api.part.js +5 -0
- package/lib/parts/apply.part.js +26 -1
- package/lib/parts/i18n.part.js +5 -0
- package/lib/parts/preview.part.js +97 -12
- package/lib/parts/styles.part.js +1 -0
- package/lib/parts/view.part.js +5 -0
- package/package.json +6 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
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.5] - 2026-09-02
|
|
6
|
+
|
|
7
|
+
### 变更
|
|
8
|
+
|
|
9
|
+
- fix(scripts): #72 插件依赖未随安装自动安装(dsh-shared 未发布 npm) (#96)
|
|
10
|
+
- fix(file-activity): 浮窗预览失焦自动关闭(issue #76) (#95)
|
|
11
|
+
- fix(file-activity): #72 补充 dsh-better-sidebar 级联安装——dependencies 声明 + 缺失提示
|
|
12
|
+
- style: prettier 全量格式修复(CI 格式门禁)
|
|
13
|
+
|
|
14
|
+
## [Unreleased]
|
|
15
|
+
|
|
16
|
+
### 变更
|
|
17
|
+
|
|
18
|
+
- fix(ui): 浮窗预览失焦自动关闭——点击外部任意处/切换页签即关,预览失败 2.5s 自动关闭或点击任意处关闭,头部「点击外部关闭」提示(issue #76)
|
|
19
|
+
|
|
20
|
+
## [0.5.4] - 2026-09-01
|
|
21
|
+
|
|
22
|
+
### 变更
|
|
23
|
+
|
|
24
|
+
- fix(file-activity): 浮窗预览优雅降级与工作区外文本读取(issue #68)
|
|
25
|
+
|
|
5
26
|
## [0.5.3] - 2026-09-01
|
|
6
27
|
|
|
7
28
|
### 变更
|
package/README.md
CHANGED
|
@@ -36,9 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
## 安装
|
|
38
38
|
|
|
39
|
-
> 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-file-activity
|
|
40
|
-
|
|
41
|
-
前置:已安装 `dsh-better-sidebar`(v0.12+,推荐 v0.14)。
|
|
39
|
+
> 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-file-activity`——无需克隆本仓库;依赖自动级联安装:`dsh-better-sidebar`(宿主,提供侧边栏扩展点)与 `dsh-shared`(server 端共享工具包)均声明为 dependencies,安装时自动安装并加入 profile bundles,无需手动单独处理。
|
|
42
40
|
|
|
43
41
|
```sh
|
|
44
42
|
# 方式一:dsh plugin(推荐)
|
package/lib/client.js
CHANGED
|
@@ -86,7 +86,12 @@ 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'),
|
|
93
|
+
clickOutsideToClose: () => (isZh() ? '点击外部关闭' : 'Click outside to close'),
|
|
94
|
+
autoCloseHint: () => (isZh() ? '预览失败,即将自动关闭' : 'Preview failed — closing automatically'),
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
// ── path / time formatting helpers ────────────────────────────────────
|
|
@@ -266,6 +271,11 @@ function mediaUrlOf(sessionId, path) {
|
|
|
266
271
|
return `/file-activity/file?${new URLSearchParams({ sessionId, path })}`
|
|
267
272
|
}
|
|
268
273
|
|
|
274
|
+
/** Plugin text route URL (`as=text`): fs.read-shaped JSON for recorded text. */
|
|
275
|
+
function textUrlOf(sessionId, path) {
|
|
276
|
+
return `/file-activity/file?${new URLSearchParams({ sessionId, path, as: 'text' })}`
|
|
277
|
+
}
|
|
278
|
+
|
|
269
279
|
// ── fetch interception: sidebar file operations ───────────────────────
|
|
270
280
|
function methodOf(init) {
|
|
271
281
|
return (init?.method ?? 'GET').toUpperCase()
|
|
@@ -768,6 +778,7 @@ const STYLES = `
|
|
|
768
778
|
display:flex; flex-direction:column; overflow:hidden; }
|
|
769
779
|
.dfa-fp-head { display:flex; align-items:center; gap:6px; padding:6px 8px; border-bottom:1px solid var(--dsw-alias-border-l1); flex:none; }
|
|
770
780
|
.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); }
|
|
781
|
+
.dfa-fp-hint { flex:none; font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-tertiary); opacity:0.8; }
|
|
771
782
|
.dfa-fp-actions { display:flex; align-items:center; gap:2px; flex:none; }
|
|
772
783
|
.dfa-fp-body { flex:1; overflow:auto; padding:10px 12px; min-height:0; }
|
|
773
784
|
.dfa-fp-note { color:var(--dsw-alias-label-tertiary); font:var(--dsw-font-xxs-12); }
|
|
@@ -1116,6 +1127,11 @@ function FileActivityView({ ctx, store, scope, visible, dataStore }) {
|
|
|
1116
1127
|
useEffect(() => {
|
|
1117
1128
|
dataStore.set({ preview: null })
|
|
1118
1129
|
}, [sessionId, dataStore])
|
|
1130
|
+
// Switching tabs hides this view: close any floating preview so it never
|
|
1131
|
+
// lingers over the main UI (issue #76).
|
|
1132
|
+
useEffect(() => {
|
|
1133
|
+
closePreviewOnHidden(visible, dataStore)
|
|
1134
|
+
}, [visible, dataStore])
|
|
1119
1135
|
const toggleDir = (path) => setCollapsedDirs((prev) => toggleInSet(prev, path))
|
|
1120
1136
|
const openPreview = (path) => dataStore.set({ preview: { abs: path, name: basenameOf(path) } })
|
|
1121
1137
|
const closePreview = () => dataStore.set({ preview: null })
|
|
@@ -1154,14 +1170,54 @@ function isFsReadOk(json) {
|
|
|
1154
1170
|
return json !== null && typeof json === 'object' && json.ok === true && typeof json.value?.content === 'string'
|
|
1155
1171
|
}
|
|
1156
1172
|
|
|
1157
|
-
/** Error load state from an fs.read API response (or a generic message).
|
|
1173
|
+
/** Error load state from an fs.read API response (or a generic message).
|
|
1174
|
+
* Raw system errors are translated to friendly, locale-aware messages
|
|
1175
|
+
* (issue #68): deleted files and workspace-fenced paths must never surface
|
|
1176
|
+
* ENOENT / "is outside workspace" verbatim. */
|
|
1158
1177
|
function fsReadError(json, viewer) {
|
|
1159
|
-
|
|
1178
|
+
const raw = json?.error?.message ?? ''
|
|
1179
|
+
let message
|
|
1180
|
+
if (raw === '') message = strings.previewFailed()
|
|
1181
|
+
else if (/ENOENT|no such file|does not exist|cannot resolve/i.test(raw)) message = strings.fileMissing()
|
|
1182
|
+
else if (/outside workspace/i.test(raw)) message = strings.fileOutside()
|
|
1183
|
+
else message = raw
|
|
1184
|
+
return { status: 'error', viewer, message }
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** Milliseconds after which an error-state preview closes itself (issue #76):
|
|
1188
|
+
* a shell that failed to load any content is useless, so it must not linger
|
|
1189
|
+
* over the main UI until the user finds the × button. */
|
|
1190
|
+
const AUTO_CLOSE_MS = 2500
|
|
1191
|
+
|
|
1192
|
+
/** Whether a pointerdown target lies inside the floating window. The window
|
|
1193
|
+
* surface carries the `.dfa-fp` class; anything else counts as "outside"
|
|
1194
|
+
* and dismisses the preview (issue #76 — click anywhere outside closes). */
|
|
1195
|
+
function isInsideFloating(target) {
|
|
1196
|
+
if (!target || typeof target.closest !== 'function') return false
|
|
1197
|
+
return target.closest('.dfa-fp') !== null
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Click behavior for the window surface: in the error state ANY click
|
|
1201
|
+
* closes the shell (there is no content to interact with), otherwise the
|
|
1202
|
+
* click is swallowed so the viewer's own interactions keep working. */
|
|
1203
|
+
function previewClickAction(load, event) {
|
|
1204
|
+
if (load.status === 'error') return 'close'
|
|
1205
|
+
if (event && event.stopPropagation) event.stopPropagation()
|
|
1206
|
+
return 'stop'
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/** Close the floating preview when the tab goes hidden (switching tabs /
|
|
1210
|
+
* operating the main UI), so it never lingers over the interface. */
|
|
1211
|
+
function closePreviewOnHidden(visible, dataStore) {
|
|
1212
|
+
if (!visible) dataStore.set({ preview: null })
|
|
1160
1213
|
}
|
|
1161
1214
|
|
|
1162
1215
|
/**
|
|
1163
1216
|
* Load fsRead content through the sidebar API and resolve the viewer's
|
|
1164
|
-
* load state (ready with text, or error with the API message).
|
|
1217
|
+
* load state (ready with text, or error with the API message). When the
|
|
1218
|
+
* sidebar refuses a recorded path (its workspace fence, e.g. agent-read
|
|
1219
|
+
* files under ~/.dsh), fall back to the plugin's own text route, which
|
|
1220
|
+
* authorizes exactly the paths this session recorded (issue #68).
|
|
1165
1221
|
*/
|
|
1166
1222
|
async function loadFsReadContent(viewer, path, scope, sessionId) {
|
|
1167
1223
|
const target = resolvePath(path, scope?.cwd ?? '')
|
|
@@ -1172,9 +1228,22 @@ async function loadFsReadContent(viewer, path, scope, sessionId) {
|
|
|
1172
1228
|
})
|
|
1173
1229
|
const json = await response.json()
|
|
1174
1230
|
if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
|
|
1231
|
+
// The sidebar refused — try OUR recorded-path text route before giving up.
|
|
1232
|
+
const textJson = await fetchTextContent(sessionId, path)
|
|
1233
|
+
if (isFsReadOk(textJson)) return { status: 'ready', viewer, content: textJson.value.content }
|
|
1175
1234
|
return fsReadError(json, viewer)
|
|
1176
1235
|
}
|
|
1177
1236
|
|
|
1237
|
+
/** Plugin text route (fs.read-shaped JSON), or null on any failure. */
|
|
1238
|
+
async function fetchTextContent(sessionId, path) {
|
|
1239
|
+
try {
|
|
1240
|
+
const response = await fetch(textUrlOf(sessionId, path))
|
|
1241
|
+
return await response.json()
|
|
1242
|
+
} catch {
|
|
1243
|
+
return null
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1178
1247
|
/**
|
|
1179
1248
|
* Fetch the bytes the viewer's fetchStrategy needs (fsRead text /
|
|
1180
1249
|
* mediaUrl / customData) and resolve its load state.
|
|
@@ -1242,6 +1311,7 @@ function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
|
|
|
1242
1311
|
load.message
|
|
1243
1312
|
? createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.85 } }, load.message)
|
|
1244
1313
|
: null,
|
|
1314
|
+
createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.7 } }, strings.autoCloseHint()),
|
|
1245
1315
|
)
|
|
1246
1316
|
}
|
|
1247
1317
|
if (load.viewer.id === 'pdf') {
|
|
@@ -1277,15 +1347,25 @@ function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
|
|
|
1277
1347
|
* built-in viewer that fetches its own URL internally (it ignores the
|
|
1278
1348
|
* `mediaUrl` prop), so it gets a small iframe preview instead.
|
|
1279
1349
|
*/
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1350
|
+
/**
|
|
1351
|
+
* Dismissal affordances (issue #76 — the preview must never linger):
|
|
1352
|
+
* 1. pointerdown anywhere OUTSIDE the window closes it (capture phase, so
|
|
1353
|
+
* it fires even if another element sits above the scrim);
|
|
1354
|
+
* 2. the scrim's own onClick (kept as a fallback);
|
|
1355
|
+
* 3. Escape;
|
|
1356
|
+
* 4. the × button;
|
|
1357
|
+
* 5. an error state closes itself after AUTO_CLOSE_MS (no content to show).
|
|
1358
|
+
*/
|
|
1359
|
+
function usePreviewDismiss(load, onClose) {
|
|
1360
|
+
useEffect(() => {
|
|
1361
|
+
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
|
|
1362
|
+
const handler = (event) => {
|
|
1363
|
+
if (!isInsideFloating(event?.target)) onClose()
|
|
1364
|
+
}
|
|
1365
|
+
document.addEventListener('pointerdown', handler, true)
|
|
1366
|
+
return () => document.removeEventListener('pointerdown', handler, true)
|
|
1367
|
+
}, [onClose])
|
|
1286
1368
|
|
|
1287
|
-
// Clicking outside is the primary dismiss (the overlay's onClick);
|
|
1288
|
-
// Escape is a keyboard affordance. Both call onClose.
|
|
1289
1369
|
useEffect(() => {
|
|
1290
1370
|
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
|
|
1291
1371
|
const handler = (event) => {
|
|
@@ -1295,6 +1375,16 @@ function FloatingPreview({ ctx, store, scope, preview, onClose }) {
|
|
|
1295
1375
|
return () => document.removeEventListener('keydown', handler)
|
|
1296
1376
|
}, [onClose])
|
|
1297
1377
|
|
|
1378
|
+
useEffect(() => {
|
|
1379
|
+
if (load.status !== 'error') return undefined
|
|
1380
|
+
if (typeof window === 'undefined' || typeof window.setTimeout !== 'function') return undefined
|
|
1381
|
+
const timer = window.setTimeout(onClose, AUTO_CLOSE_MS)
|
|
1382
|
+
return () => window.clearTimeout(timer)
|
|
1383
|
+
}, [load.status, onClose])
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** The floating window element: head (title + hint + close) and body. */
|
|
1387
|
+
function renderFloatingWindow(load, ctx, store, scope, path, title, sessionId, onClose) {
|
|
1298
1388
|
return createElement(
|
|
1299
1389
|
'div',
|
|
1300
1390
|
{ className: 'dfa-fp-overlay', onClick: onClose },
|
|
@@ -1303,13 +1393,14 @@ function FloatingPreview({ ctx, store, scope, preview, onClose }) {
|
|
|
1303
1393
|
{
|
|
1304
1394
|
className: 'dfa-fp',
|
|
1305
1395
|
onClick: (event) => {
|
|
1306
|
-
if (event
|
|
1396
|
+
if (previewClickAction(load, event) === 'close') onClose()
|
|
1307
1397
|
},
|
|
1308
1398
|
},
|
|
1309
1399
|
createElement(
|
|
1310
1400
|
'div',
|
|
1311
1401
|
{ className: 'dfa-fp-head' },
|
|
1312
1402
|
createElement('span', { className: 'dfa-fp-title' }, title),
|
|
1403
|
+
createElement('span', { className: 'dfa-fp-hint' }, strings.clickOutsideToClose()),
|
|
1313
1404
|
createElement(
|
|
1314
1405
|
'span',
|
|
1315
1406
|
{ className: 'dfa-fp-actions' },
|
|
@@ -1334,6 +1425,16 @@ function FloatingPreview({ ctx, store, scope, preview, onClose }) {
|
|
|
1334
1425
|
)
|
|
1335
1426
|
}
|
|
1336
1427
|
|
|
1428
|
+
function FloatingPreview({ ctx, store, scope, preview, onClose }) {
|
|
1429
|
+
const sessionId = scope?.sessionId ?? ''
|
|
1430
|
+
const path = preview.abs
|
|
1431
|
+
const title = preview.name
|
|
1432
|
+
const service = ctx.betterSidebar
|
|
1433
|
+
const load = usePreviewLoader(service, path, sessionId, scope)
|
|
1434
|
+
usePreviewDismiss(load, onClose)
|
|
1435
|
+
return renderFloatingWindow(load, ctx, store, scope, path, title, sessionId, onClose)
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1337
1438
|
/**
|
|
1338
1439
|
* Lightweight PDF preview. better-sidebar's built-in PdfView fetches
|
|
1339
1440
|
* `/sidebar/file` internally (it ignores any injected `mediaUrl` prop),
|
|
@@ -1434,7 +1535,17 @@ exports.apply = function apply(ctx) {
|
|
|
1434
1535
|
// Stylesheet first, unconditionally (HMR pitfall — see injectStyles).
|
|
1435
1536
|
injectStyles(ctx)
|
|
1436
1537
|
const service = ctx.betterSidebar
|
|
1437
|
-
if (service === undefined)
|
|
1538
|
+
if (service === undefined) {
|
|
1539
|
+
// 依赖缺失提示(issue #72 同类问题):dsh-file-activity 的 client 端
|
|
1540
|
+
// 依赖 dsh-better-sidebar 提供侧边栏扩展点,未安装时静默返回会让用户
|
|
1541
|
+
// 以为插件坏了——明确提示安装方式。
|
|
1542
|
+
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
1543
|
+
console.warn(
|
|
1544
|
+
'[dsh-file-activity] dsh-better-sidebar 未安装:文件活动页签无法挂载。请安装宿主插件:dsh plugin --profile web add dsh-better-sidebar dsh-file-activity',
|
|
1545
|
+
)
|
|
1546
|
+
}
|
|
1547
|
+
return
|
|
1548
|
+
}
|
|
1438
1549
|
|
|
1439
1550
|
// Per-session data store: { bySession: { [sessionId]: { recent, counts, loading } }, preview }
|
|
1440
1551
|
// Each conversation reads/writes only its own bucket, so switching
|
|
@@ -1450,6 +1561,21 @@ exports.apply = function apply(ctx) {
|
|
|
1450
1561
|
ctx.effect(() => installAutoOpen(ctx, TAB_ID), 'dsh-file-activity: auto-open')
|
|
1451
1562
|
}
|
|
1452
1563
|
|
|
1564
|
+
// Internal functions exposed for the render-path test suite only; inert in
|
|
1565
|
+
// the browser bundle (plain properties on the exports object).
|
|
1566
|
+
exports.__test = {
|
|
1567
|
+
loadFsReadContent,
|
|
1568
|
+
fsReadError,
|
|
1569
|
+
fetchTextContent,
|
|
1570
|
+
textUrlOf,
|
|
1571
|
+
strings,
|
|
1572
|
+
renderPreviewBody,
|
|
1573
|
+
previewClickAction,
|
|
1574
|
+
isInsideFloating,
|
|
1575
|
+
closePreviewOnHidden,
|
|
1576
|
+
AUTO_CLOSE_MS,
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1453
1579
|
|
|
1454
1580
|
return module.exports
|
|
1455
1581
|
},
|
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,25 @@ 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)) throw mediaError(403, "path is not in this session's file activity")
|
|
88
|
+
const abs = isAbsolute(raw) ? raw : join(sessionCwdOf(ctx, sessionId), raw)
|
|
89
|
+
if (url.searchParams.get('as') === 'text') {
|
|
90
|
+
await serveText(response, abs)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
await serveMedia(response, abs, url)
|
|
94
|
+
}
|
|
95
|
+
|
|
76
96
|
/** Both query parameters are required for a media request. */
|
|
77
97
|
function assertMediaParams(sessionId, raw) {
|
|
78
98
|
if (sessionId === null || raw === null || raw === '') throw mediaError(400, 'sessionId and path are required')
|
|
@@ -96,3 +116,23 @@ async function serveMedia(response, abs, url) {
|
|
|
96
116
|
response.writeHead(200, headers)
|
|
97
117
|
response.end(body)
|
|
98
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `as=text` mode: serve a recorded file's text content as an fs.read-shaped
|
|
122
|
+
* JSON payload ({ ok, value: { content } }) so the floating preview's text
|
|
123
|
+
* viewers mount unchanged even when the sidebar fs.read refuses the path
|
|
124
|
+
* (workspace fence — issue #68). Same authorization as bytes serving:
|
|
125
|
+
* recorded paths only.
|
|
126
|
+
*/
|
|
127
|
+
async function serveText(response, abs) {
|
|
128
|
+
let info
|
|
129
|
+
try {
|
|
130
|
+
info = await stat(abs)
|
|
131
|
+
} catch {
|
|
132
|
+
throw mediaError(404, 'file not found')
|
|
133
|
+
}
|
|
134
|
+
if (!info.isFile()) throw mediaError(400, 'not a file')
|
|
135
|
+
if (info.size > TEXT_LIMIT) throw mediaError(413, 'file too large')
|
|
136
|
+
const content = await readFile(abs, 'utf8')
|
|
137
|
+
writeJson(response, 200, { ok: true, value: { content } })
|
|
138
|
+
}
|
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
|
@@ -68,7 +68,17 @@ exports.apply = function apply(ctx) {
|
|
|
68
68
|
// Stylesheet first, unconditionally (HMR pitfall — see injectStyles).
|
|
69
69
|
injectStyles(ctx)
|
|
70
70
|
const service = ctx.betterSidebar
|
|
71
|
-
if (service === undefined)
|
|
71
|
+
if (service === undefined) {
|
|
72
|
+
// 依赖缺失提示(issue #72 同类问题):dsh-file-activity 的 client 端
|
|
73
|
+
// 依赖 dsh-better-sidebar 提供侧边栏扩展点,未安装时静默返回会让用户
|
|
74
|
+
// 以为插件坏了——明确提示安装方式。
|
|
75
|
+
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
76
|
+
console.warn(
|
|
77
|
+
'[dsh-file-activity] dsh-better-sidebar 未安装:文件活动页签无法挂载。请安装宿主插件:dsh plugin --profile web add dsh-better-sidebar dsh-file-activity',
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
return
|
|
81
|
+
}
|
|
72
82
|
|
|
73
83
|
// Per-session data store: { bySession: { [sessionId]: { recent, counts, loading } }, preview }
|
|
74
84
|
// Each conversation reads/writes only its own bucket, so switching
|
|
@@ -83,3 +93,18 @@ exports.apply = function apply(ctx) {
|
|
|
83
93
|
// auto-open once per session (default on)
|
|
84
94
|
ctx.effect(() => installAutoOpen(ctx, TAB_ID), 'dsh-file-activity: auto-open')
|
|
85
95
|
}
|
|
96
|
+
|
|
97
|
+
// Internal functions exposed for the render-path test suite only; inert in
|
|
98
|
+
// the browser bundle (plain properties on the exports object).
|
|
99
|
+
exports.__test = {
|
|
100
|
+
loadFsReadContent,
|
|
101
|
+
fsReadError,
|
|
102
|
+
fetchTextContent,
|
|
103
|
+
textUrlOf,
|
|
104
|
+
strings,
|
|
105
|
+
renderPreviewBody,
|
|
106
|
+
previewClickAction,
|
|
107
|
+
isInsideFloating,
|
|
108
|
+
closePreviewOnHidden,
|
|
109
|
+
AUTO_CLOSE_MS,
|
|
110
|
+
}
|
package/lib/parts/i18n.part.js
CHANGED
|
@@ -38,5 +38,10 @@ 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'),
|
|
45
|
+
clickOutsideToClose: () => (isZh() ? '点击外部关闭' : 'Click outside to close'),
|
|
46
|
+
autoCloseHint: () => (isZh() ? '预览失败,即将自动关闭' : 'Preview failed — closing automatically'),
|
|
42
47
|
}
|
|
@@ -12,14 +12,54 @@ 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 }
|
|
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 })
|
|
18
55
|
}
|
|
19
56
|
|
|
20
57
|
/**
|
|
21
58
|
* Load fsRead content through the sidebar API and resolve the viewer's
|
|
22
|
-
* load state (ready with text, or error with the API message).
|
|
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).
|
|
23
63
|
*/
|
|
24
64
|
async function loadFsReadContent(viewer, path, scope, sessionId) {
|
|
25
65
|
const target = resolvePath(path, scope?.cwd ?? '')
|
|
@@ -30,9 +70,22 @@ async function loadFsReadContent(viewer, path, scope, sessionId) {
|
|
|
30
70
|
})
|
|
31
71
|
const json = await response.json()
|
|
32
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 }
|
|
33
76
|
return fsReadError(json, viewer)
|
|
34
77
|
}
|
|
35
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
|
+
|
|
36
89
|
/**
|
|
37
90
|
* Fetch the bytes the viewer's fetchStrategy needs (fsRead text /
|
|
38
91
|
* mediaUrl / customData) and resolve its load state.
|
|
@@ -100,6 +153,7 @@ function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
|
|
|
100
153
|
load.message
|
|
101
154
|
? createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.85 } }, load.message)
|
|
102
155
|
: null,
|
|
156
|
+
createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.7 } }, strings.autoCloseHint()),
|
|
103
157
|
)
|
|
104
158
|
}
|
|
105
159
|
if (load.viewer.id === 'pdf') {
|
|
@@ -135,15 +189,25 @@ function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
|
|
|
135
189
|
* built-in viewer that fetches its own URL internally (it ignores the
|
|
136
190
|
* `mediaUrl` prop), so it gets a small iframe preview instead.
|
|
137
191
|
*/
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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])
|
|
144
210
|
|
|
145
|
-
// Clicking outside is the primary dismiss (the overlay's onClick);
|
|
146
|
-
// Escape is a keyboard affordance. Both call onClose.
|
|
147
211
|
useEffect(() => {
|
|
148
212
|
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
|
|
149
213
|
const handler = (event) => {
|
|
@@ -153,6 +217,16 @@ function FloatingPreview({ ctx, store, scope, preview, onClose }) {
|
|
|
153
217
|
return () => document.removeEventListener('keydown', handler)
|
|
154
218
|
}, [onClose])
|
|
155
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) {
|
|
156
230
|
return createElement(
|
|
157
231
|
'div',
|
|
158
232
|
{ className: 'dfa-fp-overlay', onClick: onClose },
|
|
@@ -161,13 +235,14 @@ function FloatingPreview({ ctx, store, scope, preview, onClose }) {
|
|
|
161
235
|
{
|
|
162
236
|
className: 'dfa-fp',
|
|
163
237
|
onClick: (event) => {
|
|
164
|
-
if (event
|
|
238
|
+
if (previewClickAction(load, event) === 'close') onClose()
|
|
165
239
|
},
|
|
166
240
|
},
|
|
167
241
|
createElement(
|
|
168
242
|
'div',
|
|
169
243
|
{ className: 'dfa-fp-head' },
|
|
170
244
|
createElement('span', { className: 'dfa-fp-title' }, title),
|
|
245
|
+
createElement('span', { className: 'dfa-fp-hint' }, strings.clickOutsideToClose()),
|
|
171
246
|
createElement(
|
|
172
247
|
'span',
|
|
173
248
|
{ className: 'dfa-fp-actions' },
|
|
@@ -192,6 +267,16 @@ function FloatingPreview({ ctx, store, scope, preview, onClose }) {
|
|
|
192
267
|
)
|
|
193
268
|
}
|
|
194
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
|
+
|
|
195
280
|
/**
|
|
196
281
|
* Lightweight PDF preview. better-sidebar's built-in PdfView fetches
|
|
197
282
|
* `/sidebar/file` internally (it ignores any injected `mediaUrl` prop),
|
package/lib/parts/styles.part.js
CHANGED
|
@@ -62,6 +62,7 @@ const STYLES = `
|
|
|
62
62
|
display:flex; flex-direction:column; overflow:hidden; }
|
|
63
63
|
.dfa-fp-head { display:flex; align-items:center; gap:6px; padding:6px 8px; border-bottom:1px solid var(--dsw-alias-border-l1); flex:none; }
|
|
64
64
|
.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); }
|
|
65
|
+
.dfa-fp-hint { flex:none; font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-tertiary); opacity:0.8; }
|
|
65
66
|
.dfa-fp-actions { display:flex; align-items:center; gap:2px; flex:none; }
|
|
66
67
|
.dfa-fp-body { flex:1; overflow:auto; padding:10px 12px; min-height:0; }
|
|
67
68
|
.dfa-fp-note { color:var(--dsw-alias-label-tertiary); font:var(--dsw-font-xxs-12); }
|
package/lib/parts/view.part.js
CHANGED
|
@@ -158,6 +158,11 @@ function FileActivityView({ ctx, store, scope, visible, dataStore }) {
|
|
|
158
158
|
useEffect(() => {
|
|
159
159
|
dataStore.set({ preview: null })
|
|
160
160
|
}, [sessionId, dataStore])
|
|
161
|
+
// Switching tabs hides this view: close any floating preview so it never
|
|
162
|
+
// lingers over the main UI (issue #76).
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
closePreviewOnHidden(visible, dataStore)
|
|
165
|
+
}, [visible, dataStore])
|
|
161
166
|
const toggleDir = (path) => setCollapsedDirs((prev) => toggleInSet(prev, path))
|
|
162
167
|
const openPreview = (path) => dataStore.set({ preview: { abs: path, name: basenameOf(path) } })
|
|
163
168
|
const closePreview = () => dataStore.set({ preview: null })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-file-activity",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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",
|
|
@@ -43,16 +43,15 @@
|
|
|
43
43
|
]
|
|
44
44
|
}
|
|
45
45
|
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"dsh-better-sidebar": ">=0.14.0 <0.18.0",
|
|
48
|
+
"dsh-shared": "^0.1.0"
|
|
49
|
+
},
|
|
46
50
|
"peerDependencies": {
|
|
47
51
|
"cordis": "^4.0.0-rc.8",
|
|
48
|
-
"
|
|
49
|
-
"react": "^18.2.0 || ^19.2.0",
|
|
50
|
-
"dsh-shared": "^0.1.0"
|
|
52
|
+
"react": "^18.2.0 || ^19.2.0"
|
|
51
53
|
},
|
|
52
54
|
"peerDependenciesMeta": {
|
|
53
|
-
"dsh-better-sidebar": {
|
|
54
|
-
"optional": true
|
|
55
|
-
},
|
|
56
55
|
"cordis": {
|
|
57
56
|
"optional": true
|
|
58
57
|
}
|