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/CHANGELOG.md +50 -0
- package/README.md +29 -18
- package/lib/api-route.js +72 -76
- package/lib/bash-ops.js +65 -54
- package/lib/bash-parse.js +154 -162
- package/lib/client.js +739 -361
- package/lib/client.src.js +28 -21
- package/lib/cwd.js +6 -6
- package/lib/index.js +68 -83
- package/lib/media-route.js +119 -90
- package/lib/observer.js +30 -24
- package/lib/parts/api.part.js +1 -5
- package/lib/parts/apply.part.js +91 -82
- package/lib/parts/auto-open.part.js +83 -60
- package/lib/parts/document-previews.part.js +62 -0
- package/lib/parts/format.part.js +1 -2
- package/lib/parts/i18n.part.js +15 -1
- package/lib/parts/interceptor.part.js +16 -27
- package/lib/parts/preview-data.part.js +111 -0
- package/lib/parts/preview-float.part.js +276 -0
- package/lib/parts/rows.part.js +1 -11
- package/lib/parts/settings.part.js +71 -0
- package/lib/parts/store.part.js +19 -0
- package/lib/parts/styles.part.js +49 -3
- package/lib/parts/tree.part.js +1 -2
- package/lib/parts/view.part.js +72 -18
- package/lib/persist.js +137 -0
- package/lib/quota-trim.js +121 -0
- package/lib/quota.js +164 -0
- package/lib/state.js +336 -136
- package/lib/store.js +249 -76
- package/package.json +10 -6
- package/lib/parts/preview.part.js +0 -308
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
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.9] - 2026-09-13
|
|
6
|
+
|
|
7
|
+
### 变更
|
|
8
|
+
|
|
9
|
+
- chore: 项目全面优化和完善
|
|
10
|
+
- fix(dsh-file-activity): #266 侧边栏席位按宿主空 props 契约加载,面板打开即刷新 + 补 guide 菜单入口 (#268)
|
|
11
|
+
- chore(release): #198 dsh-shared 0.1.4 + 消费方 peerDeps 同步 + 文档收口 (#254)
|
|
12
|
+
- refactor(file-activity): #187 批 2 迁移到宿主原生侧边栏 API(移除 better-sidebar,含实测证据) (#249)
|
|
13
|
+
- #184 依赖升级:矩阵脚本 + A 档落地 + jscpd 解锁 + dependabot 覆盖插件目录 (#235)
|
|
14
|
+
- perf(scripts): #188 优化 pre-push 校验耗时(并发 6 + 收窄退化 + 补 D + file-activity 测试提速) (#234)
|
|
15
|
+
- fix(dsh-file-activity): #197 写放大 3665× 与状态无界 (#207)
|
|
16
|
+
- fix(release): 批量提交信息 header 控制在 commitlint 限制内
|
|
17
|
+
- feat(ts): 第四轮 JS→TS 迁移(5 个插件,server + client 全量)
|
|
18
|
+
|
|
19
|
+
## [Unreleased]
|
|
20
|
+
|
|
21
|
+
### 修复
|
|
22
|
+
|
|
23
|
+
- fix(dsh-file-activity): #266 侧边栏「文件活动」面板打开后永远显示「暂无文件活动记录」(P0)——
|
|
24
|
+
宿主渲染 `sidebar.right.pane.tab` 席位时 owner props 是**空对象**
|
|
25
|
+
(`renderSlot(seat, {}, {hookContext})`),可见性只在注入的 `useTabInfo()` 里;
|
|
26
|
+
旧守卫只读 `props.visible`(恒为 `undefined`)→ 守卫恒真 → 面板打开后既不首载也不轮询
|
|
27
|
+
(`POLL_MS` 失效),只有手点「刷新」才有数据。现按宿主真实契约取
|
|
28
|
+
`useTabInfo().tab.visible`(取不到时退回可见:宁可多轮询一个面板,也不能空白),
|
|
29
|
+
切回页签/展开侧边栏立即重新加载并重启轮询,宿主判定不可见时不轮询(省资源)。
|
|
30
|
+
同一 issue 的关联项:
|
|
31
|
+
- B:「新标签页」菜单补 guide 入口(`sidebarRightTabs.register({guide:[…]})`)——此前页签关闭后
|
|
32
|
+
无法从 UI 重新打开(auto-open 每浏览器标签只跑一次);
|
|
33
|
+
- C:HTML 预览按 README 承诺落地为**沙箱 iframe**(此前 `viewerOf()` 无 html 分支,
|
|
34
|
+
`.html` 落到文本渲染器、显示为代码块 `.dfa-fp-code`):iframe
|
|
35
|
+
`sandbox="allow-scripts allow-forms"` + 媒体路由响应头
|
|
36
|
+
`Content-Security-Policy: sandbox allow-scripts allow-forms`(两层都不含
|
|
37
|
+
`allow-same-origin`,直接打开该 URL 同样受限);
|
|
38
|
+
- D:删除客户端挂载探针(此前每次页面加载都往状态文件写一条
|
|
39
|
+
`sessionId="__probe__", path="mounted"` 的幽灵记录,累计 9 行、重启后仍在),
|
|
40
|
+
并在加载时剔除历史残留(#197「状态有界」)。
|
|
41
|
+
- fix(dsh-file-activity): #197 写放大 3,665× 与状态无界(P0)——
|
|
42
|
+
落盘改为 JSON Lines 增量 append(落盘字节 ≈ 事件本体字节)+ 按状态体积自适应的原子 compact;
|
|
43
|
+
新增三维入口上限(会话/每会话路径/全局路径)与 LRU 淘汰(淘汰计数 + warn 可见,非静默丢弃);
|
|
44
|
+
compact 快照显式传 `minIntervalMs`/`maxBytes` 护栏;旧全量 JSON 快照自动兼容并改写。
|
|
45
|
+
实测:单条新事件落盘 789,780 B → 68 B(11,614×↓);稳态每事件 2,104 B → 63 B(33×↓);
|
|
46
|
+
密集流投影 44.18 MB/小时 → 1.94 MB/小时(22.8×↓);内存条目与状态文件均有界。
|
|
47
|
+
本插件并入 `scripts/resource-smoke.mjs` 写放大门禁(此前仅覆盖 observability)。
|
|
48
|
+
|
|
49
|
+
## [0.5.8] - 2026-09-10
|
|
50
|
+
|
|
51
|
+
### 变更
|
|
52
|
+
|
|
53
|
+
- feat(ts): 第四轮 JS→TS 迁移(5 个插件,server + client 全量)
|
|
54
|
+
|
|
5
55
|
## [0.5.7] - 2026-09-07
|
|
6
56
|
|
|
7
57
|
### 变更
|
package/README.md
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# dsh-file-activity
|
|
2
2
|
|
|
3
|
-
[](https://github.com/topics/dsh-better-sidebar)
|
|
4
|
-
|
|
5
3
|
<div align="center">
|
|
6
4
|
<img alt="文件活动插件截图(最近访问 / 文件统计)" src="https://unpkg.com/dsh-file-activity/assets/screenshot.png" width="340" />
|
|
7
5
|
<br />
|
|
@@ -10,13 +8,13 @@
|
|
|
10
8
|
<img alt="浮窗 HTML 预览:内容撑满整个浮窗主体(issue #111)" src="https://unpkg.com/dsh-file-activity/assets/preview-float-0.5.6.png" width="340" />
|
|
11
9
|
</div>
|
|
12
10
|
|
|
13
|
-
**DSH
|
|
11
|
+
**DSH 侧边栏文件活动插件**(基于宿主 0.1.5-rc.1 的原生侧边栏扩展点,零第三方侧边栏依赖):在右侧边栏新增「文件活动」页签,记录 agent 工具与插件自身路由的文件读取/新增/修改事件,提供**最近访问**(LRU)与**文件统计**(树形目录)两块视图,点击文件弹出浮窗预览。
|
|
14
12
|
|
|
15
13
|
## 功能
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
在右侧边栏新增「文件活动」页签:
|
|
18
16
|
|
|
19
|
-
1. **最近访问**:按 LRU 记录文件读取 / 新增 / 修改事件(agent 工具读写 + 侧边栏打开/编辑保存都会记录),**每会话最多保留 5
|
|
17
|
+
1. **最近访问**:按 LRU 记录文件读取 / 新增 / 修改事件(agent 工具读写 + 侧边栏打开/编辑保存都会记录),**每会话最多保留 5 条,同一文件只出现一次**(再次访问会移到列表最前);区块标题可**点击折叠/展开**;点击任意文件即弹出**浮窗预览**(浮窗内渲染:代码高亮 / Markdown 富文本 / 图片 / PDF / 沙箱 HTML,见下方「浮窗预览」)。
|
|
20
18
|
2. **文件统计**:每个文件的「读取 / 新增 / 修改」次数,按文件**绝对路径**组织成**树形目录**;连续单子目录的路径链自动**压缩为点号标签**(如 `a/b/c` → `a.b.c`),文件直接列在下方;**每个文件夹行可点击展开/收起**(箭头指示状态);每个文件行内显示**相对时间**(刚刚 / N 分钟前 / N 小时前 / N 天前),悬停可查看**创建 / 最近访问**的完整时钟时间:
|
|
21
19
|
|
|
22
20
|
```
|
|
@@ -28,17 +26,17 @@
|
|
|
28
26
|
client.js
|
|
29
27
|
```
|
|
30
28
|
|
|
31
|
-
3.
|
|
29
|
+
3. **默认启用**:页签注册后默认开启(无需在设置页勾选),且每个会话首次打开时**自动打开**本页——开关在 **设置 → 插件 → 文件活动**(`settings.plugins.tab`,存储在浏览器 `localStorage`,插件自身持久化)。
|
|
32
30
|
|
|
33
31
|
## 工作原理
|
|
34
32
|
|
|
35
|
-
- **Server 端**(`lib/index.js`):监听 DSH `fs/observed` 事件捕获 agent 的 `read` / `write` / `edit` / `str_replace_editor` / `read_image` 等文件操作;提供 `/file-activity/api` 路由(`stats` / `record` / `clear`);状态按会话持久化到 `$DSH_HOME/file-activity.json
|
|
33
|
+
- **Server 端**(`lib/index.js`):监听 DSH `fs/observed` 事件捕获 agent 的 `read` / `write` / `edit` / `str_replace_editor` / `read_image` 等文件操作;提供 `/file-activity/api` 路由(`stats` / `record` / `clear`);状态按会话持久化到 `$DSH_HOME/file-activity.json`(**JSON Lines 增量 append + 阈值原子 compact**,见下「资源治理」)。
|
|
36
34
|
- `write` 通过每会话的已知文件表自动区分**新增**(首次接触)与**修改**(再次写入)。
|
|
37
|
-
- **Client 端**(`lib/client.js
|
|
35
|
+
- **Client 端**(`lib/client.js`):通过宿主原生扩展点注册——`ctx.sidebarRightTabs.register({id,kind,title})`(页签类型)+ `ctx.slots.register({name:'sidebar.right.pane.tab',key:id}, Body)`(正文席位);`ctx.documentPreviews.register(...)` 声明本插件认领的文件后缀(元数据 only,字节由宿主 document owner 读取);自动打开走 `ctx.sidebarRight.openTab(kind)`(会话表面未挂载时按 1s 重试,彻底失败会写入 `data-dfa-degraded` 标记并在页签内显示提示,绝不静默失效);浮窗自实现并挂在宿主推荐落点 `shell.overlay` 列表席位(不替换宿主 UI);fetch 拦截只记录插件自身路由 `/file-activity/file` 的预览读取。数据按会话(session)**分桶隔离**(`bySession`),每个会话只渲染自己的记录——新建/切换会话立即显示该会话的数据,**不会残留上一个会话的记录**。预览按后缀分流:图片 / PDF / HTML 走插件媒体路由(`<img>` / 原生 PDF frame / **沙箱 iframe**),其余文本走插件文本路由并由官方 `MarkdownText` / `CodeBlock` 渲染(不可用时退回 `<pre>`)。
|
|
38
36
|
|
|
39
37
|
## 安装
|
|
40
38
|
|
|
41
|
-
> 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-file-activity --trust-lockfile`——无需克隆本仓库;依赖自动级联安装:`dsh-
|
|
39
|
+
> 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-file-activity --trust-lockfile`——无需克隆本仓库;依赖自动级联安装:`dsh-shared`(server 端共享工具包)是唯一 dependencies,侧边栏能力全部来自宿主原生扩展点(**不需安装任何第三方侧边栏插件**)。
|
|
42
40
|
|
|
43
41
|
```sh
|
|
44
42
|
# 方式一:dsh plugin(推荐)
|
|
@@ -63,22 +61,22 @@ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-file-activity
|
|
|
63
61
|
|
|
64
62
|
## 使用
|
|
65
63
|
|
|
66
|
-
-
|
|
67
|
-
- 点击任意文件行 →
|
|
64
|
+
- 侧边栏「新标签页」(guide)菜单 → 文件活动(本插件向宿主 `sidebarRightTabs.register` 的 `guide` 提供入口,issue #266 B),或会话开始自动打开。
|
|
65
|
+
- 点击任意文件行 → 弹出**浮窗预览**:按后缀渲染(代码语法高亮 / Markdown 富文本 / 图片 / PDF / 沙箱 HTML),不打开侧边栏编辑器标签;**点击浮窗外任意处或按 `Esc` 即可关闭**,大文件可在浮窗内滚动。
|
|
68
66
|
- 顶部「刷新」手动重载,「清空」删除当前会话全部记录。
|
|
69
67
|
|
|
70
68
|
### 浮窗预览(复用内置渲染)
|
|
71
69
|
|
|
72
|
-
点击文件(最近访问 /
|
|
70
|
+
点击文件(最近访问 / 文件统计中的任意文件行)会打开一个**悬浮预览窗**,窗口由本插件渲染(挂在宿主 `shell.overlay` 浮层席位上,不替换宿主 UI)—— 按后缀分流:图片(svg/png/jpg/gif/webp/avif/bmp/ico)用 `<img>`、PDF 用原生 `<iframe>`、HTML(html/htm/xhtml)用**沙箱 `<iframe>`**,三者都取插件自己的媒体路由 `/file-activity/file`(按 `(sessionId, path)` 授权,因此工作区外的记录路径也能预览);其余文本走插件文本路由 `/file-activity/file?as=text`,由官方 `MarkdownText` / `CodeBlock` 渲染(组件库不可用时退回 `<pre>`)。浮窗为轻量交互:**点击浮窗外任意处、按 `Esc` 或点标题栏右上 `×` 都能关闭**;浮窗主体是可滚动区域,长文件或大图在窗内滚动查看。
|
|
73
71
|
|
|
74
72
|
- **代码**(后台为侧边栏内置 CodeMirror 编辑器):语法高亮 + 行号;支持 预览/编辑 切换与保存。
|
|
75
73
|
- **Markdown**(`.md` / `.markdown`):渲染后的富文本预览,可切换 预览/编辑 模式。
|
|
76
74
|
- **图片**(png / jpg / jpeg / gif / webp / svg / bmp / ico / avif):通过媒体路由渲染。
|
|
77
75
|
- **PDF**:浏览器原生 PDF 查看器(浮窗内嵌,工具栏提供下载)。
|
|
78
|
-
- **HTML
|
|
76
|
+
- **HTML**(`.html` / `.htm` / `.xhtml`):**沙箱化 iframe 渲染**,内容**撑满整个浮窗主体**(iframe 随浮窗尺寸自适应、内部滚动,与代码/Markdown/图片预览体验一致,issue #111)。沙箱有两层且都不含 `allow-same-origin`:iframe 的 `sandbox="allow-scripts allow-forms"` 与媒体路由响应头的 `Content-Security-Policy: sandbox allow-scripts allow-forms`(响应头让**直接打开该 URL 也仍是沙箱文档**)。文档因此拿到 opaque origin:脚本/表单可跑(交互式页面可用),但读不到宿主页面的 Cookie、存储与同源 API,也不能导航父窗口。页内相对路径资源按站点根解析(不重写为文件目录,与浏览器直开行为一致)。
|
|
79
77
|
- 其余文件按内置 viewer 的 `matchFileViewer` 回退匹配(如 `binary-download` 下载未知二进制)。
|
|
80
78
|
|
|
81
|
-
> 💡 **工作区外文件也能预览**:文件活动记录的是 agent 实际触碰过的文件,路径可能在会话工作区之外(如 `/tmp` 临时文件)。侧边栏自带的媒体路由只允许工作区内文件,因此图片/PDF 的字节由插件自身的 `/file-activity/file` 路由提供——它按会话记录授权(只放行本会话记录过的路径,未记录的路径一律 403),fence 与侧边栏一致。Markdown
|
|
79
|
+
> 💡 **工作区外文件也能预览**:文件活动记录的是 agent 实际触碰过的文件,路径可能在会话工作区之外(如 `/tmp` 临时文件)。侧边栏自带的媒体路由只允许工作区内文件,因此图片/PDF 的字节由插件自身的 `/file-activity/file` 路由提供——它按会话记录授权(只放行本会话记录过的路径,未记录的路径一律 403),fence 与侧边栏一致。Markdown/代码文本则仍走侧边栏 `fs.read`(不被工作区限制);HTML 走插件媒体路由的沙箱文档(见上)。
|
|
82
80
|
|
|
83
81
|
## 配置
|
|
84
82
|
|
|
@@ -87,15 +85,28 @@ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-file-activity
|
|
|
87
85
|
## 数据说明
|
|
88
86
|
|
|
89
87
|
- 按会话(session)隔离存储(服务端 `$DSH_HOME/file-activity.json` 按 sessionId 分桶 + 前端 `bySession` 分桶展示);**DSH 重启后历史数据自动恢复**(持久化加载);新建/切换会话不残留其他会话的记录。
|
|
88
|
+
|
|
89
|
+
## 资源治理(issue #197)
|
|
90
|
+
|
|
91
|
+
写放大与无界增长防护(9/2 事故同构模式整改):
|
|
92
|
+
|
|
93
|
+
- **增量落盘**:每次记录只追加一行 JSON(`{"s","p","o","t"}`,落盘字节 ≈ 事件本体字节 ×1.0),
|
|
94
|
+
累计行数达到按状态体积自适应的阈值时做一次原子 compact(tmp+rename 重写快照行),
|
|
95
|
+
文件大小与写入量双向有界(实测写放大 1.0×,旧实现 2,073×);
|
|
96
|
+
- **三维入口上限(LRU 淘汰,非静默丢弃)**:每会话路径 ≤ 300、全局路径 ≤ 20000、会话数 ≤ 64;
|
|
97
|
+
超限淘汰最久未活动条目/会话,淘汰计数暴露在 `store.stats()` 且写 warn 日志(首次 + 每 10 次);
|
|
98
|
+
- **显式护栏**:compact 快照受 `minIntervalMs`(体积分级节流)与 `maxBytes`(8MB 硬上限,超限拒绝写 + 告警)约束;
|
|
99
|
+
- **升级兼容**:旧的全量 JSON 快照(单行/多行)仍可读取,首次启动一次性改写为 JSON Lines;
|
|
100
|
+
文件里只有事件行(未 compact)时也能按行序完整重放(不丢数据)。
|
|
90
101
|
- 最近访问列表每会话最多保留 5 条(LRU:同一文件仅一条,重复访问移到最前),文件统计不受条数限制。
|
|
91
102
|
- agent 的 `bash`/`git` 等命令内部的文件触碰不在此跟踪范围(只跟踪 DSH 原生文件工具与侧边栏操作)。
|
|
92
103
|
|
|
93
104
|
## 依赖
|
|
94
105
|
|
|
95
|
-
| 依赖
|
|
96
|
-
|
|
|
97
|
-
| `dsh-
|
|
98
|
-
| `cordis`
|
|
106
|
+
| 依赖 | 用途 | 可选 |
|
|
107
|
+
| ------------ | ------------------------------------ | -------------------- |
|
|
108
|
+
| `dsh-shared` | server 端共享工具包(路由/JSONL 等) | 否(随插件自动安装) |
|
|
109
|
+
| `cordis` | 插件运行时 | 是(宿主提供) |
|
|
99
110
|
|
|
100
111
|
## 相关文档
|
|
101
112
|
|
package/lib/api-route.js
CHANGED
|
@@ -3,93 +3,89 @@
|
|
|
3
3
|
* Every request passes the trust fence first; responses are JSON with
|
|
4
4
|
* cache-control: no-cache.
|
|
5
5
|
*/
|
|
6
|
-
import { readJsonBody, writeError, writeJson } from 'dsh-shared'
|
|
7
|
-
import { sessionCwdOf } from './cwd.js'
|
|
8
|
-
import { mapOp } from './state.js'
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
6
|
+
import { readJsonBody, writeError, writeJson } from 'dsh-shared';
|
|
7
|
+
import { sessionCwdOf } from './cwd.js';
|
|
8
|
+
import { mapOp } from './state.js';
|
|
9
|
+
export function createApiHandler({ ctx, store, fence, }) {
|
|
10
|
+
return async (request, response) => {
|
|
11
|
+
if (!fence(request)) {
|
|
12
|
+
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const url = new URL(request.url ?? '/', 'http://dsh.internal');
|
|
16
|
+
const method = apiMethodOf(url);
|
|
17
|
+
try {
|
|
18
|
+
if (method === 'record' && request.method === 'POST') {
|
|
19
|
+
await handleRecord(store, request, response);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (method === 'stats' && request.method === 'GET') {
|
|
23
|
+
handleStats(ctx, store, url, response);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (method === 'clear' && request.method === 'POST') {
|
|
27
|
+
await handleClear(store, request, response);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
writeJson(response, 404, {
|
|
31
|
+
ok: false,
|
|
32
|
+
error: { message: 'unknown file-activity API method' },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
writeError(response, error);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
39
|
}
|
|
40
|
-
|
|
41
40
|
/** Strip the /file-activity/api/ prefix; undefined for anything else. */
|
|
42
41
|
function apiMethodOf(url) {
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
const pathname = url.pathname;
|
|
43
|
+
return pathname.startsWith('/file-activity/api/') ? pathname.slice('/file-activity/api/'.length) : undefined;
|
|
45
44
|
}
|
|
46
|
-
|
|
47
45
|
/** POST /file-activity/api/record — client-reported sidebar operation. */
|
|
48
46
|
async function handleRecord(store, request, response) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
const payload = await readJsonBody(request);
|
|
48
|
+
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : '';
|
|
49
|
+
const path = typeof payload.path === 'string' ? payload.path : '';
|
|
50
|
+
const op = typeof payload.op === 'string' ? payload.op : 'read';
|
|
51
|
+
store.record(sessionId, path, mapOp(op), Date.now());
|
|
52
|
+
writeJson(response, 200, { ok: true });
|
|
55
53
|
}
|
|
56
|
-
|
|
57
54
|
/** GET /file-activity/api/stats — recent + counts + session cwd. */
|
|
58
55
|
function handleStats(ctx, store, url, response) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
// Snapshot the leaf fields only — no live objects cross the wire.
|
|
68
|
-
const recent = session.recent.map((entry) => ({
|
|
69
|
-
path: entry.path,
|
|
70
|
-
op: entry.op,
|
|
71
|
-
time: entry.time,
|
|
72
|
-
}))
|
|
73
|
-
const counts = {}
|
|
74
|
-
for (const [path, counter] of Object.entries(session.counts)) {
|
|
75
|
-
counts[path] = {
|
|
76
|
-
read: counter.read,
|
|
77
|
-
create: counter.create,
|
|
78
|
-
modify: counter.modify,
|
|
79
|
-
firstSeen: counter.firstSeen,
|
|
80
|
-
lastSeen: counter.lastSeen,
|
|
56
|
+
const sessionId = url.searchParams.get('sessionId') ?? '';
|
|
57
|
+
// Session working directory, for client-side relative-path display.
|
|
58
|
+
const cwd = sessionCwdOf(ctx, sessionId);
|
|
59
|
+
const session = store.state.sessions[sessionId];
|
|
60
|
+
if (session === undefined) {
|
|
61
|
+
writeJson(response, 200, { ok: true, value: { recent: [], counts: {}, cwd } });
|
|
62
|
+
return;
|
|
81
63
|
}
|
|
82
|
-
|
|
83
|
-
|
|
64
|
+
// Snapshot the leaf fields only — no live objects cross the wire.
|
|
65
|
+
const recent = session.recent.map((entry) => ({
|
|
66
|
+
path: entry.path,
|
|
67
|
+
op: entry.op,
|
|
68
|
+
time: entry.time,
|
|
69
|
+
}));
|
|
70
|
+
const counts = {};
|
|
71
|
+
for (const [path, counter] of Object.entries(session.counts)) {
|
|
72
|
+
counts[path] = {
|
|
73
|
+
read: counter.read,
|
|
74
|
+
create: counter.create,
|
|
75
|
+
modify: counter.modify,
|
|
76
|
+
firstSeen: counter.firstSeen,
|
|
77
|
+
lastSeen: counter.lastSeen,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
writeJson(response, 200, { ok: true, value: { recent, counts, cwd } });
|
|
84
81
|
}
|
|
85
|
-
|
|
86
82
|
/** POST /file-activity/api/clear — wipe one session's records (persisted). */
|
|
87
83
|
async function handleClear(store, request, response) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
84
|
+
const payload = await readJsonBody(request);
|
|
85
|
+
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : '';
|
|
86
|
+
if (sessionId !== '' && store.state.sessions[sessionId] !== undefined) {
|
|
87
|
+
delete store.state.sessions[sessionId];
|
|
88
|
+
store.schedulePersist();
|
|
89
|
+
}
|
|
90
|
+
writeJson(response, 200, { ok: true });
|
|
95
91
|
}
|
package/lib/bash-ops.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { homedir } from 'node:os'
|
|
2
|
-
import { isAbsolute, join, normalize, resolve } from 'node:path'
|
|
3
|
-
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
4
3
|
/**
|
|
5
4
|
* bash-ops.js — command → file-operation mapping for the bash intent parser
|
|
6
5
|
* (see bash-parse.js for the segment/token layer).
|
|
@@ -11,89 +10,101 @@ import { isAbsolute, join, normalize, resolve } from 'node:path'
|
|
|
11
10
|
*/
|
|
12
11
|
/** Options of a command that consume the following argument (never a path). */
|
|
13
12
|
function optionArgsOf(cmd) {
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
if (cmd === 'touch')
|
|
14
|
+
return new Set(['-d', '-t', '-r', '--date', '--time', '--reference']);
|
|
15
|
+
return new Set();
|
|
16
16
|
}
|
|
17
|
-
|
|
18
17
|
/**
|
|
19
18
|
* Path arguments with options stripped: `-rf` / `--force` / `--` are skipped;
|
|
20
19
|
* options listed in `optionArgs` consume their following argument too.
|
|
20
|
+
*
|
|
21
|
+
* `optionArgs` 可选:`cd` 的目标解析(bash-parse.js)只取第一个位置参数,
|
|
22
|
+
* 不传选项表——与迁移前逐字节一致(该路径遇到 `-` 开头的 token 仍会抛错)。
|
|
21
23
|
*/
|
|
22
24
|
export function positionalArgs(args, optionArgs) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
const paths = [];
|
|
26
|
+
let afterDashDash = false;
|
|
27
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
28
|
+
const token = args[i];
|
|
29
|
+
if (!afterDashDash && token.text === '--') {
|
|
30
|
+
afterDashDash = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!afterDashDash && token.text.startsWith('-') && token.text !== '-') {
|
|
34
|
+
if (optionArgs.has(token.text))
|
|
35
|
+
i += 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
paths.push(token);
|
|
34
39
|
}
|
|
35
|
-
paths
|
|
36
|
-
}
|
|
37
|
-
return paths
|
|
40
|
+
return paths;
|
|
38
41
|
}
|
|
39
|
-
|
|
40
42
|
/**
|
|
41
43
|
* -t / --target-directory variants (cp/mv/install) relocate sources into a
|
|
42
44
|
* directory; statically resolving the real destination is unreliable, so the
|
|
43
45
|
* whole segment is skipped (conservative: never record a wrong path).
|
|
44
46
|
*/
|
|
45
47
|
function hasTargetDirOption(cmd, args) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
if (cmd !== 'mv' && cmd !== 'cp' && cmd !== 'install')
|
|
49
|
+
return false;
|
|
50
|
+
return args.some((token) => token.text === '-t' || token.text === '--target-directory');
|
|
48
51
|
}
|
|
49
|
-
|
|
50
52
|
/** Dispatch one segment's command to the matching file-op mapping. */
|
|
51
53
|
export function pushCommandOps(ops, cmd, args, cwd) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
if (hasTargetDirOption(cmd, args))
|
|
55
|
+
return;
|
|
56
|
+
const optionArgs = optionArgsOf(cmd);
|
|
57
|
+
const paths = positionalArgs(args, optionArgs);
|
|
58
|
+
if (cmd === 'rm')
|
|
59
|
+
return pushAll(ops, 'delete', paths, cwd);
|
|
60
|
+
if (cmd === 'touch' || cmd === 'tee')
|
|
61
|
+
return pushAll(ops, 'write', paths, cwd);
|
|
62
|
+
if (cmd === 'mv')
|
|
63
|
+
return pushMv(ops, paths, cwd);
|
|
64
|
+
if (cmd === 'cp' || cmd === 'install')
|
|
65
|
+
return pushLast(ops, paths, cwd);
|
|
66
|
+
if (cmd === 'sed' && hasInPlace(args))
|
|
67
|
+
pushLast(ops, positionalArgs(args, optionArgs), cwd);
|
|
60
68
|
}
|
|
61
|
-
|
|
62
69
|
/** All positional paths get the same op (rm / touch / tee). */
|
|
63
70
|
function pushAll(ops, op, paths, cwd) {
|
|
64
|
-
|
|
71
|
+
for (const token of paths)
|
|
72
|
+
pushOp(ops, op, token, cwd);
|
|
65
73
|
}
|
|
66
|
-
|
|
67
74
|
/** Only the last path is the write destination (cp / install). */
|
|
68
75
|
function pushLast(ops, paths, cwd) {
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
if (paths.length === 0)
|
|
77
|
+
return;
|
|
78
|
+
pushOp(ops, 'write', paths[paths.length - 1], cwd);
|
|
71
79
|
}
|
|
72
|
-
|
|
73
80
|
/** mv: all but the last are sources (delete), the last is the destination. */
|
|
74
81
|
function pushMv(ops, paths, cwd) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
if (paths.length === 0)
|
|
83
|
+
return;
|
|
84
|
+
for (let i = 0; i < paths.length - 1; i += 1)
|
|
85
|
+
pushOp(ops, 'delete', paths[i], cwd);
|
|
86
|
+
pushOp(ops, 'write', paths[paths.length - 1], cwd);
|
|
78
87
|
}
|
|
79
|
-
|
|
80
88
|
/** sed writes files only with -i / --in-place (also -i.bak style). */
|
|
81
89
|
function hasInPlace(args) {
|
|
82
|
-
|
|
90
|
+
return args.some((token) => /^-i($|[A-Za-z0-9_.])/.test(token.text) || token.text === '--in-place');
|
|
83
91
|
}
|
|
84
|
-
|
|
85
92
|
export function pushOp(ops, op, token, cwd) {
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
const path = resolveSafe(token, cwd);
|
|
94
|
+
if (path !== '')
|
|
95
|
+
ops.push({ op, path });
|
|
88
96
|
}
|
|
89
|
-
|
|
90
97
|
/** Resolve a token to an absolute path; '' when unsafe or unusable. */
|
|
91
98
|
export function resolveSafe(token, cwd) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
if (!token.safe || token.text === '')
|
|
100
|
+
return '';
|
|
101
|
+
let path = token.text;
|
|
102
|
+
if (path === '~')
|
|
103
|
+
return homedir();
|
|
104
|
+
if (path.startsWith('~/'))
|
|
105
|
+
path = join(homedir(), path.slice(2));
|
|
106
|
+
if (path.includes('\0'))
|
|
107
|
+
return '';
|
|
108
|
+
const abs = isAbsolute(path) ? normalize(path) : resolve(cwd, path);
|
|
109
|
+
return normalize(abs);
|
|
99
110
|
}
|