dsh-file-activity 0.4.6 → 0.5.0
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 +13 -0
- package/README.md +5 -2
- package/lib/bash-ops.js +99 -0
- package/lib/bash-parse.js +262 -0
- package/lib/client.js +15 -9
- package/lib/index.js +38 -0
- package/lib/parts/i18n.part.js +1 -0
- package/lib/parts/rows.part.js +13 -9
- package/lib/parts/styles.part.js +1 -0
- package/lib/state.js +28 -10
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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.0] - 2026-08-26
|
|
6
|
+
|
|
7
|
+
### 变更
|
|
8
|
+
|
|
9
|
+
- feat(file-activity): bash 命令文件操作识别(rm/touch/mv/cp/tee/重定向)+ 0 值计数徽标过滤
|
|
10
|
+
- docs+test: 全面审查修复——文档同步补全 + mermaid 测试增强
|
|
11
|
+
|
|
12
|
+
## [0.4.7] - 2026-08-25
|
|
13
|
+
|
|
14
|
+
### 变更
|
|
15
|
+
|
|
16
|
+
- **npm 页面元数据优化**:description 改为中英双语(中文在前);README 效果截图引用改为绝对 URL(unpkg),npm 包页面可直接显示图片。
|
|
17
|
+
|
|
5
18
|
## [0.4.6] - 2026-08-25
|
|
6
19
|
|
|
7
20
|
### 变更
|
package/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
[](https://github.com/topics/dsh-better-sidebar)
|
|
4
4
|
|
|
5
5
|
<div align="center">
|
|
6
|
-
<img alt="文件活动插件截图(最近访问 / 文件统计)" src="
|
|
6
|
+
<img alt="文件活动插件截图(最近访问 / 文件统计)" src="https://unpkg.com/dsh-file-activity/assets/screenshot.png" width="340" />
|
|
7
7
|
<br />
|
|
8
|
-
<img alt="浮窗预览:点击文件复用侧边栏内置 Markdown 渲染" src="
|
|
8
|
+
<img alt="浮窗预览:点击文件复用侧边栏内置 Markdown 渲染" src="https://unpkg.com/dsh-file-activity/assets/preview-float.png" width="340" />
|
|
9
9
|
</div>
|
|
10
10
|
|
|
11
11
|
**DSH 侧边栏文件活动插件**(基于 [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) 扩展):在 better-sidebar 中新增「文件活动」页签,记录 agent 工具与侧边栏自身的文件读取/新增/修改事件,提供**最近访问**(LRU)与**文件统计**(树形目录)两块视图,点击文件弹出浮窗预览。
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
|
|
37
37
|
## 安装
|
|
38
38
|
|
|
39
|
+
> 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-file-activity`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。
|
|
40
|
+
|
|
41
|
+
|
|
39
42
|
前置:已安装 `dsh-better-sidebar`(v0.12+,推荐 v0.14)。
|
|
40
43
|
|
|
41
44
|
```sh
|
package/lib/bash-ops.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { isAbsolute, join, normalize, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* bash-ops.js — command → file-operation mapping for the bash intent parser
|
|
6
|
+
* (see bash-parse.js for the segment/token layer).
|
|
7
|
+
*
|
|
8
|
+
* Conservative by design: only unambiguous commands map to ops; paths the
|
|
9
|
+
* parser cannot resolve statically (option-argument pairs, `-t` relocations,
|
|
10
|
+
* sed without -i, unknown commands) contribute nothing.
|
|
11
|
+
*/
|
|
12
|
+
/** Options of a command that consume the following argument (never a path). */
|
|
13
|
+
function optionArgsOf(cmd) {
|
|
14
|
+
if (cmd === 'touch') return new Set(['-d', '-t', '-r', '--date', '--time', '--reference'])
|
|
15
|
+
return new Set()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Path arguments with options stripped: `-rf` / `--force` / `--` are skipped;
|
|
20
|
+
* options listed in `optionArgs` consume their following argument too.
|
|
21
|
+
*/
|
|
22
|
+
export function positionalArgs(args, optionArgs) {
|
|
23
|
+
const paths = []
|
|
24
|
+
let afterDashDash = false
|
|
25
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
26
|
+
const token = args[i]
|
|
27
|
+
if (!afterDashDash && token.text === '--') {
|
|
28
|
+
afterDashDash = true
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
if (!afterDashDash && token.text.startsWith('-') && token.text !== '-') {
|
|
32
|
+
if (optionArgs.has(token.text)) i += 1
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
paths.push(token)
|
|
36
|
+
}
|
|
37
|
+
return paths
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* -t / --target-directory variants (cp/mv/install) relocate sources into a
|
|
42
|
+
* directory; statically resolving the real destination is unreliable, so the
|
|
43
|
+
* whole segment is skipped (conservative: never record a wrong path).
|
|
44
|
+
*/
|
|
45
|
+
function hasTargetDirOption(cmd, args) {
|
|
46
|
+
if (cmd !== 'mv' && cmd !== 'cp' && cmd !== 'install') return false
|
|
47
|
+
return args.some((token) => token.text === '-t' || token.text === '--target-directory')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Dispatch one segment's command to the matching file-op mapping. */
|
|
51
|
+
export function pushCommandOps(ops, cmd, args, cwd) {
|
|
52
|
+
if (hasTargetDirOption(cmd, args)) return
|
|
53
|
+
const optionArgs = optionArgsOf(cmd)
|
|
54
|
+
const paths = positionalArgs(args, optionArgs)
|
|
55
|
+
if (cmd === 'rm') return pushAll(ops, 'delete', paths, cwd)
|
|
56
|
+
if (cmd === 'touch' || cmd === 'tee') return pushAll(ops, 'write', paths, cwd)
|
|
57
|
+
if (cmd === 'mv') return pushMv(ops, paths, cwd)
|
|
58
|
+
if (cmd === 'cp' || cmd === 'install') return pushLast(ops, paths, cwd)
|
|
59
|
+
if (cmd === 'sed' && hasInPlace(args)) pushLast(ops, positionalArgs(args, optionArgs), cwd)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** All positional paths get the same op (rm / touch / tee). */
|
|
63
|
+
function pushAll(ops, op, paths, cwd) {
|
|
64
|
+
for (const token of paths) pushOp(ops, op, token, cwd)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Only the last path is the write destination (cp / install). */
|
|
68
|
+
function pushLast(ops, paths, cwd) {
|
|
69
|
+
if (paths.length === 0) return
|
|
70
|
+
pushOp(ops, 'write', paths[paths.length - 1], cwd)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** mv: all but the last are sources (delete), the last is the destination. */
|
|
74
|
+
function pushMv(ops, paths, cwd) {
|
|
75
|
+
if (paths.length === 0) return
|
|
76
|
+
for (let i = 0; i < paths.length - 1; i += 1) pushOp(ops, 'delete', paths[i], cwd)
|
|
77
|
+
pushOp(ops, 'write', paths[paths.length - 1], cwd)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** sed writes files only with -i / --in-place (also -i.bak style). */
|
|
81
|
+
function hasInPlace(args) {
|
|
82
|
+
return args.some((token) => /^-i($|[A-Za-z0-9_.])/.test(token.text) || token.text === '--in-place')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function pushOp(ops, op, token, cwd) {
|
|
86
|
+
const path = resolveSafe(token, cwd)
|
|
87
|
+
if (path !== '') ops.push({ op, path })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolve a token to an absolute path; '' when unsafe or unusable. */
|
|
91
|
+
export function resolveSafe(token, cwd) {
|
|
92
|
+
if (!token.safe || token.text === '') return ''
|
|
93
|
+
let path = token.text
|
|
94
|
+
if (path === '~') return homedir()
|
|
95
|
+
if (path.startsWith('~/')) path = join(homedir(), path.slice(2))
|
|
96
|
+
if (path.includes('\0')) return ''
|
|
97
|
+
const abs = isAbsolute(path) ? normalize(path) : resolve(cwd, path)
|
|
98
|
+
return normalize(abs)
|
|
99
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight static parser for bash command text → file-touch intents.
|
|
3
|
+
*
|
|
4
|
+
* Feeds the `tools/pre-execute` observer (index.js): a bash tool call carries
|
|
5
|
+
* the exact command string, and we resolve the file operations it is about to
|
|
6
|
+
* perform. Conservative by design — 宁可漏报也不误报:
|
|
7
|
+
*
|
|
8
|
+
* - only well-known commands with unambiguous file effects are recognized
|
|
9
|
+
* (rm → delete; touch / cp / install / tee / sed -i / `> file` → write;
|
|
10
|
+
* mv → source delete + destination write) — see bash-ops.js;
|
|
11
|
+
* - paths containing variables ($…), command substitution ($(…), `…`),
|
|
12
|
+
* globs (* ? [ {) or fd redirections (2>&1) are skipped;
|
|
13
|
+
* - unknown commands contribute nothing (only their `>` redirects count);
|
|
14
|
+
* - relative paths resolve against the base dir (workdir / session cwd),
|
|
15
|
+
* and `cd <dir>` segments update the base for the following segments.
|
|
16
|
+
*
|
|
17
|
+
* The returned operations are applied through the regular record pipeline
|
|
18
|
+
* ('write' is classified create/modify by the known-file registry, 'delete'
|
|
19
|
+
* removes the file from stats), so the view stays consistent with the disk.
|
|
20
|
+
*/
|
|
21
|
+
import { pushCommandOps, positionalArgs, resolveSafe, pushOp } from './bash-ops.js'
|
|
22
|
+
|
|
23
|
+
/** Prefix wrappers that do not touch files themselves. */
|
|
24
|
+
const PREFIX_CMDS = new Set(['sudo', 'nohup', 'command', 'time', 'env'])
|
|
25
|
+
|
|
26
|
+
/** Parse one bash command into [{ op: 'write'|'delete', path }] (deduped). */
|
|
27
|
+
export function parseBashFileOps(command, baseDir) {
|
|
28
|
+
const ops = []
|
|
29
|
+
let cwd = baseDir
|
|
30
|
+
for (const segment of splitSegments(command)) {
|
|
31
|
+
const tokens = tokenize(segment)
|
|
32
|
+
const start = commandStartOf(tokens)
|
|
33
|
+
if (start < 0) continue
|
|
34
|
+
const cmd = basenameOf(tokens[start].text)
|
|
35
|
+
const args = tokens.slice(start + 1)
|
|
36
|
+
if (cmd === 'cd') {
|
|
37
|
+
const target = cdTargetOf(args)
|
|
38
|
+
if (target !== '') cwd = resolveSafe({ text: target, safe: true }, cwd)
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
pushCommandOps(ops, cmd, args, cwd)
|
|
42
|
+
pushRedirectOps(ops, tokens, cwd)
|
|
43
|
+
}
|
|
44
|
+
return dedupeOps(ops)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── quoting pre-pass ───────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Per-char quote state: 0 = unquoted, 1 = single-quoted, 2 = double-quoted,
|
|
51
|
+
* -1 = the quote character itself (kept in segment text, stripped by
|
|
52
|
+
* tokenize). Escape handling: outside quotes and inside double quotes a
|
|
53
|
+
* backslash makes the next char literal; inside single quotes backslash is
|
|
54
|
+
* literal (bash). The output array has one entry per input character.
|
|
55
|
+
*/
|
|
56
|
+
function quoteMarks(text) {
|
|
57
|
+
const marks = []
|
|
58
|
+
let quote = 0
|
|
59
|
+
let i = 0
|
|
60
|
+
while (i < text.length) {
|
|
61
|
+
const step = quoteStep(text, i, quote)
|
|
62
|
+
marks.push(...step.marks)
|
|
63
|
+
quote = step.quote
|
|
64
|
+
i += step.advance
|
|
65
|
+
}
|
|
66
|
+
return marks
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One character's quote transition: { marks, quote, advance }. */
|
|
70
|
+
function quoteStep(text, i, quote) {
|
|
71
|
+
const ch = text[i]
|
|
72
|
+
if (ch === '\\' && quote !== 1 && i + 1 < text.length) return { marks: [quote, quote], quote, advance: 2 }
|
|
73
|
+
if (quote !== 0 && ch === quoteCharOf(quote)) return { marks: [-1], quote: 0, advance: 1 }
|
|
74
|
+
if (quote === 0 && isQuoteChar(ch)) return { marks: [-1], quote: quoteOf(ch), advance: 1 }
|
|
75
|
+
return { marks: [quote], quote, advance: 1 }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function quoteCharOf(quote) {
|
|
79
|
+
return quote === 1 ? "'" : '"'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function quoteOf(ch) {
|
|
83
|
+
return ch === "'" ? 1 : 2
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isQuoteChar(ch) {
|
|
87
|
+
return ch === "'" || ch === '"'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── segment splitting ──────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Split a command on the top-level separators && || ; | & and newlines.
|
|
94
|
+
* Quoted separators never split; a bare `&` (background marker) splits only
|
|
95
|
+
* when followed by whitespace/end — `2>&1` stays one word.
|
|
96
|
+
*/
|
|
97
|
+
export function splitSegments(command) {
|
|
98
|
+
const marks = quoteMarks(command)
|
|
99
|
+
const segments = []
|
|
100
|
+
let current = ''
|
|
101
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
102
|
+
if (marks[i] !== 0 || !isSeparator(command, i)) {
|
|
103
|
+
current += command[i]
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
flushSegment(segments, current)
|
|
107
|
+
current = ''
|
|
108
|
+
i += separatorLength(command, i) - 1
|
|
109
|
+
}
|
|
110
|
+
flushSegment(segments, current)
|
|
111
|
+
return segments
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isSeparator(command, i) {
|
|
115
|
+
const ch = command[i]
|
|
116
|
+
if (ch === '&') return command[i + 1] === '&' || i + 1 >= command.length || /\s/.test(command[i + 1])
|
|
117
|
+
return ch === '|' || ch === ';' || ch === '\n' || ch === '\r'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function separatorLength(command, i) {
|
|
121
|
+
return command[i] === '&' && command[i + 1] === '&' ? 2 : 1
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function flushSegment(segments, current) {
|
|
125
|
+
const trimmed = current.trim()
|
|
126
|
+
if (trimmed !== '') segments.push(trimmed)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── tokenizing ─────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Split one segment into words. Tokens keep their quoted content (quotes
|
|
133
|
+
* stripped) and a `safe` flag: false when the word contains variables /
|
|
134
|
+
* command substitution / globs — such paths must never be recorded.
|
|
135
|
+
*/
|
|
136
|
+
export function tokenize(segment) {
|
|
137
|
+
const marks = quoteMarks(segment)
|
|
138
|
+
const tokens = []
|
|
139
|
+
let current = ''
|
|
140
|
+
let safe = true
|
|
141
|
+
const push = () => {
|
|
142
|
+
if (current !== '') {
|
|
143
|
+
tokens.push({ text: current, safe })
|
|
144
|
+
current = ''
|
|
145
|
+
safe = true
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
149
|
+
const ch = segment[i]
|
|
150
|
+
if (marks[i] === -1) continue // quote characters are stripped
|
|
151
|
+
if (marks[i] === 0 && /\s/.test(ch)) {
|
|
152
|
+
push()
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
if (isEscape(segment, i, marks[i])) {
|
|
156
|
+
current += segment[i + 1]
|
|
157
|
+
i += 1
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
if (isUnsafeChar(ch, marks[i])) {
|
|
161
|
+
safe = false
|
|
162
|
+
current += ch
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
current += ch
|
|
166
|
+
}
|
|
167
|
+
push()
|
|
168
|
+
return tokens
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Backslash escapes the next char outside quotes and inside double quotes. */
|
|
172
|
+
function isEscape(segment, i, quote) {
|
|
173
|
+
if (segment[i] !== '\\' || quote === 1) return false
|
|
174
|
+
return i + 1 < segment.length
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `$` expands everywhere except single quotes; globs only outside quotes. */
|
|
178
|
+
function isUnsafeChar(ch, quote) {
|
|
179
|
+
if (ch === '$') return quote !== 1
|
|
180
|
+
if (quote !== 0) return false
|
|
181
|
+
return ch === '`' || ch === '*' || ch === '?' || ch === '[' || ch === '{'
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── command head ───────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/** Index of the first real command token (skipping VAR=x and wrappers). */
|
|
187
|
+
function findStartOf(tokens, index) {
|
|
188
|
+
while (index < tokens.length) {
|
|
189
|
+
const token = tokens[index]
|
|
190
|
+
if (isAssignment(token.text) || PREFIX_CMDS.has(basenameOf(token.text))) {
|
|
191
|
+
index += 1
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
return index
|
|
195
|
+
}
|
|
196
|
+
return -1
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function commandStartOf(tokens) {
|
|
200
|
+
const start = findStartOf(tokens, 0)
|
|
201
|
+
if (start < 0) return -1
|
|
202
|
+
if (basenameOf(tokens[start].text) === 'env') {
|
|
203
|
+
// env VAR=value cmd: assignments after env are skipped too.
|
|
204
|
+
return findStartOf(tokens, start + 1)
|
|
205
|
+
}
|
|
206
|
+
return start
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isAssignment(text) {
|
|
210
|
+
const eq = text.indexOf('=')
|
|
211
|
+
if (eq <= 0) return false
|
|
212
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(text)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function basenameOf(text) {
|
|
216
|
+
const slash = text.lastIndexOf('/')
|
|
217
|
+
return slash === -1 ? text : text.slice(slash + 1)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── paths & redirects ──────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/** `> file` / `>> file` / `2> file` redirects write the target file. */
|
|
223
|
+
function pushRedirectOps(ops, tokens, cwd) {
|
|
224
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
225
|
+
const match = /^(\d*)>{1,2}(.*)$/.exec(tokens[i].text)
|
|
226
|
+
if (match === null) continue
|
|
227
|
+
const target = redirectTarget(tokens, i, match[2])
|
|
228
|
+
if (target === null) continue
|
|
229
|
+
pushOp(ops, 'write', { text: target.text, safe: target.safe }, cwd)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The file a redirect writes; null when it is an fd / /dev/null / missing. */
|
|
234
|
+
function redirectTarget(tokens, i, inline) {
|
|
235
|
+
if (inline !== '') return usable(inline, tokens[i].safe) ? { text: inline, safe: tokens[i].safe } : null
|
|
236
|
+
const next = tokens[i + 1]
|
|
237
|
+
if (next === undefined || !next.safe) return null
|
|
238
|
+
return usable(next.text, true) ? { text: next.text, safe: true } : null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function usable(text, safe) {
|
|
242
|
+
if (!safe || text === '') return false
|
|
243
|
+
return !text.startsWith('&') && !text.startsWith('/dev/')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** `cd` target: the first positional argument ('' when missing/unsafe). */
|
|
247
|
+
function cdTargetOf(args) {
|
|
248
|
+
const paths = positionalArgs(args)
|
|
249
|
+
if (paths.length === 0) return ''
|
|
250
|
+
return paths[0].safe ? paths[0].text : ''
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Dedupe by op+path, keeping the first occurrence. */
|
|
254
|
+
function dedupeOps(ops) {
|
|
255
|
+
const seen = new Set()
|
|
256
|
+
return ops.filter((op) => {
|
|
257
|
+
const key = `${op.op}\u0000${op.path}`
|
|
258
|
+
if (seen.has(key)) return false
|
|
259
|
+
seen.add(key)
|
|
260
|
+
return true
|
|
261
|
+
})
|
|
262
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -70,6 +70,7 @@ window.__ModuleLoader__.load({
|
|
|
70
70
|
read: () => (isZh() ? '读取' : 'read'),
|
|
71
71
|
create: () => (isZh() ? '新增' : 'create'),
|
|
72
72
|
modify: () => (isZh() ? '修改' : 'modify'),
|
|
73
|
+
delete: () => (isZh() ? '删除' : 'delete'),
|
|
73
74
|
readShort: () => (isZh() ? '读' : 'R'),
|
|
74
75
|
createShort: () => (isZh() ? '增' : 'C'),
|
|
75
76
|
modifyShort: () => (isZh() ? '改' : 'M'),
|
|
@@ -479,6 +480,7 @@ window.__ModuleLoader__.load({
|
|
|
479
480
|
.dfa-op-create { color:var(--dsw-alias-state-success-primary); background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent); }
|
|
480
481
|
.dfa-op-modify { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
|
|
481
482
|
.dfa-op-read { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 12%, transparent); }
|
|
483
|
+
.dfa-op-delete { color:var(--dsw-alias-state-danger-primary); background:color-mix(in srgb, var(--dsw-alias-state-danger-primary) 14%, transparent); }
|
|
482
484
|
.dfa-counts { flex:none; display:flex; align-items:center; gap:3px; }
|
|
483
485
|
.dfa-count { flex:none; display:inline-flex; align-items:center; justify-content:center; height:15px; padding:0 4px; border-radius:4px;
|
|
484
486
|
font:var(--dsw-font-xxxs-strong-11); }
|
|
@@ -511,8 +513,8 @@ window.__ModuleLoader__.load({
|
|
|
511
513
|
`
|
|
512
514
|
|
|
513
515
|
// ── row rendering helpers (recent list & stats tree) ──────────────────
|
|
514
|
-
const opClass = (op) => (op === 'create' ? 'dfa-op-create' : op === 'modify' ? 'dfa-op-modify' : 'dfa-op-read')
|
|
515
|
-
const opLabel = (op) => (op === 'create' ? strings.create() : op === 'modify' ? strings.modify() : strings.read())
|
|
516
|
+
const opClass = (op) => (op === 'create' ? 'dfa-op-create' : op === 'modify' ? 'dfa-op-modify' : op === 'delete' ? 'dfa-op-delete' : 'dfa-op-read')
|
|
517
|
+
const opLabel = (op) => (op === 'create' ? strings.create() : op === 'modify' ? strings.modify() : op === 'delete' ? strings.delete() : strings.read())
|
|
516
518
|
|
|
517
519
|
/** Tooltip for a stats file row: absolute path + created / last-seen times. */
|
|
518
520
|
const fileTitle = (abs, firstSeen, lastSeen) => {
|
|
@@ -522,13 +524,17 @@ window.__ModuleLoader__.load({
|
|
|
522
524
|
return times.length > 0 ? `${abs}\n${times.join(' · ')}` : abs
|
|
523
525
|
}
|
|
524
526
|
|
|
525
|
-
/**
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
)
|
|
527
|
+
/** Count pills for a file/dir node — only actions that actually happened are
|
|
528
|
+
* shown (a zero count renders no pill; all-zero nodes render no pill group,
|
|
529
|
+
* keeping untouched files visually quiet). */
|
|
530
|
+
const countPills = (node) => {
|
|
531
|
+
const pills = []
|
|
532
|
+
if (node.read > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`))
|
|
533
|
+
if (node.create > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`))
|
|
534
|
+
if (node.modify > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`))
|
|
535
|
+
if (pills.length === 0) return null
|
|
536
|
+
return createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } }, ...pills)
|
|
537
|
+
}
|
|
532
538
|
|
|
533
539
|
/** A stats-tree file row: icon + name + count pills + relative time. */
|
|
534
540
|
const fileRow = (file, depth, onOpen) =>
|
package/lib/index.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* - agent tool file operations arrive as `fs/observed` events (read / write /
|
|
6
6
|
* edit / str_replace_editor / read_image ...), with the tool execution as
|
|
7
7
|
* the actor (name + parsed arguments + owning agent).
|
|
8
|
+
* - bash tool calls carry file-touching commands (rm/touch/mv/…): the
|
|
9
|
+
* `tools/pre-execute` observer parses them and records the intents.
|
|
8
10
|
* - sidebar operations (files opened / saved through the better-sidebar
|
|
9
11
|
* explorer & editor) are reported by our client half through the
|
|
10
12
|
* `/file-activity/api/record` route.
|
|
@@ -17,16 +19,52 @@ import { createApiHandler } from './api-route.js'
|
|
|
17
19
|
import { createMediaHandler } from './media-route.js'
|
|
18
20
|
import { createFsObserver } from './observer.js'
|
|
19
21
|
import { createStore } from './store.js'
|
|
22
|
+
import { parseBashFileOps } from './bash-parse.js'
|
|
23
|
+
import { sessionCwdOf } from './http.js'
|
|
20
24
|
|
|
21
25
|
export const name = 'dsh-file-activity'
|
|
22
26
|
|
|
23
27
|
export const inject = ['webServer', 'sessions', 'webRuntime']
|
|
24
28
|
|
|
29
|
+
/** 观察型监听:只读取 bash 命令隐含的文件操作并上报,不拦截(返回 next())。 */
|
|
30
|
+
function observeBashIntents(exec, ctx, store) {
|
|
31
|
+
const sessionId = bashSessionOf(exec)
|
|
32
|
+
const command = bashCommandOf(exec)
|
|
33
|
+
if (sessionId === '' || command === '') return
|
|
34
|
+
const baseDir = bashBaseDirOf(exec, ctx, sessionId)
|
|
35
|
+
for (const touched of parseBashFileOps(command, baseDir)) {
|
|
36
|
+
store.record(sessionId, touched.path, touched.op, Date.now())
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 会话 id:仅 bash 工具、agent id 非空时返回。 */
|
|
41
|
+
function bashSessionOf(exec) {
|
|
42
|
+
if (exec?.name !== 'bash') return ''
|
|
43
|
+
const id = exec.agent?.id
|
|
44
|
+
return typeof id === 'string' && id !== '' ? id : ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 命令文本:仅非空字符串时返回。 */
|
|
48
|
+
function bashCommandOf(exec) {
|
|
49
|
+
const command = exec.arguments?.command
|
|
50
|
+
return typeof command === 'string' && command !== '' ? command : ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 相对路径基准:workdir 参数优先,否则会话 cwd。 */
|
|
54
|
+
function bashBaseDirOf(exec, ctx, sessionId) {
|
|
55
|
+
const workdir = exec.arguments?.workdir
|
|
56
|
+
return typeof workdir === 'string' && workdir !== '' ? workdir : sessionCwdOf(ctx, sessionId)
|
|
57
|
+
}
|
|
58
|
+
|
|
25
59
|
export function apply(ctx) {
|
|
26
60
|
const store = createStore(ctx)
|
|
27
61
|
|
|
28
62
|
// ── agent-side file operations ──────────────────────────────────────────
|
|
29
63
|
ctx.on('fs/observed', createFsObserver(store.record))
|
|
64
|
+
ctx.on('tools/pre-execute', (exec, next) => {
|
|
65
|
+
observeBashIntents(exec, ctx, store)
|
|
66
|
+
return next()
|
|
67
|
+
})
|
|
30
68
|
|
|
31
69
|
// ── routes ──────────────────────────────────────────────────────────────
|
|
32
70
|
const fence = (request) => isTrustedApiRequest(request, ctx.webRuntime.trustedHosts)
|
package/lib/parts/i18n.part.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
read: () => (isZh() ? '读取' : 'read'),
|
|
23
23
|
create: () => (isZh() ? '新增' : 'create'),
|
|
24
24
|
modify: () => (isZh() ? '修改' : 'modify'),
|
|
25
|
+
delete: () => (isZh() ? '删除' : 'delete'),
|
|
25
26
|
readShort: () => (isZh() ? '读' : 'R'),
|
|
26
27
|
createShort: () => (isZh() ? '增' : 'C'),
|
|
27
28
|
modifyShort: () => (isZh() ? '改' : 'M'),
|
package/lib/parts/rows.part.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ── row rendering helpers (recent list & stats tree) ──────────────────
|
|
2
|
-
const opClass = (op) => (op === 'create' ? 'dfa-op-create' : op === 'modify' ? 'dfa-op-modify' : 'dfa-op-read')
|
|
3
|
-
const opLabel = (op) => (op === 'create' ? strings.create() : op === 'modify' ? strings.modify() : strings.read())
|
|
2
|
+
const opClass = (op) => (op === 'create' ? 'dfa-op-create' : op === 'modify' ? 'dfa-op-modify' : op === 'delete' ? 'dfa-op-delete' : 'dfa-op-read')
|
|
3
|
+
const opLabel = (op) => (op === 'create' ? strings.create() : op === 'modify' ? strings.modify() : op === 'delete' ? strings.delete() : strings.read())
|
|
4
4
|
|
|
5
5
|
/** Tooltip for a stats file row: absolute path + created / last-seen times. */
|
|
6
6
|
const fileTitle = (abs, firstSeen, lastSeen) => {
|
|
@@ -10,13 +10,17 @@
|
|
|
10
10
|
return times.length > 0 ? `${abs}\n${times.join(' · ')}` : abs
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
)
|
|
13
|
+
/** Count pills for a file/dir node — only actions that actually happened are
|
|
14
|
+
* shown (a zero count renders no pill; all-zero nodes render no pill group,
|
|
15
|
+
* keeping untouched files visually quiet). */
|
|
16
|
+
const countPills = (node) => {
|
|
17
|
+
const pills = []
|
|
18
|
+
if (node.read > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`))
|
|
19
|
+
if (node.create > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`))
|
|
20
|
+
if (node.modify > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`))
|
|
21
|
+
if (pills.length === 0) return null
|
|
22
|
+
return createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } }, ...pills)
|
|
23
|
+
}
|
|
20
24
|
|
|
21
25
|
/** A stats-tree file row: icon + name + count pills + relative time. */
|
|
22
26
|
const fileRow = (file, depth, onOpen) =>
|
package/lib/parts/styles.part.js
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
.dfa-op-create { color:var(--dsw-alias-state-success-primary); background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent); }
|
|
45
45
|
.dfa-op-modify { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
|
|
46
46
|
.dfa-op-read { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 12%, transparent); }
|
|
47
|
+
.dfa-op-delete { color:var(--dsw-alias-state-danger-primary); background:color-mix(in srgb, var(--dsw-alias-state-danger-primary) 14%, transparent); }
|
|
47
48
|
.dfa-counts { flex:none; display:flex; align-items:center; gap:3px; }
|
|
48
49
|
.dfa-count { flex:none; display:inline-flex; align-items:center; justify-content:center; height:15px; padding:0 4px; border-radius:4px;
|
|
49
50
|
font:var(--dsw-font-xxxs-strong-11); }
|
package/lib/state.js
CHANGED
|
@@ -35,12 +35,13 @@ export async function loadState(file) {
|
|
|
35
35
|
return createState()
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** Map a raw operation kind (tool name or client op) to 'read' | 'write' | 'edit'. */
|
|
38
|
+
/** Map a raw operation kind (tool name or client op) to 'read' | 'write' | 'edit' | 'delete'. */
|
|
39
39
|
export function mapOp(op) {
|
|
40
40
|
switch (op) {
|
|
41
41
|
case 'write': return 'write'
|
|
42
42
|
case 'edit':
|
|
43
43
|
case 'str_replace_editor': return 'edit'
|
|
44
|
+
case 'delete': return 'delete'
|
|
44
45
|
case 'read':
|
|
45
46
|
case 'read_image':
|
|
46
47
|
default: return 'read'
|
|
@@ -51,28 +52,43 @@ export function mapOp(op) {
|
|
|
51
52
|
* Fold one observed operation into the state.
|
|
52
53
|
* 'write' is classified create vs modify through the per-session known-file
|
|
53
54
|
* registry (first contact = create, later writes = modify); edits are always
|
|
54
|
-
* modifies.
|
|
55
|
-
*
|
|
55
|
+
* modifies. 'delete' removes the file from stats entirely (disk state is the
|
|
56
|
+
* truth: the file no longer exists) and records a single delete history entry.
|
|
57
|
+
* Each file's counters also track firstSeen (first contact time, i.e.
|
|
58
|
+
* creation time) and lastSeen (most recent activity time).
|
|
56
59
|
* Returns true when a record was produced.
|
|
57
60
|
*/
|
|
58
61
|
export function applyRecord(state, sessionId, path, op, time) {
|
|
59
62
|
if (!isValidRecordTarget(sessionId, path)) return false
|
|
60
63
|
const session = state.sessions[sessionId] ?? (state.sessions[sessionId] = { known: {}, counts: {}, recent: [] })
|
|
61
64
|
const timestamp = typeof time === 'number' ? time : Date.now()
|
|
65
|
+
if (op === 'delete') return applyDelete(session, path, timestamp)
|
|
62
66
|
const firstSeen = typeof session.known[path] === 'number' ? session.known[path] : timestamp
|
|
63
67
|
const finalOp = classifyOp(op, session.known[path])
|
|
64
68
|
session.known[path] = firstSeen
|
|
65
69
|
const counts = session.counts[path] ?? (session.counts[path] = { read: 0, create: 0, modify: 0 })
|
|
66
70
|
bumpCount(counts, finalOp, firstSeen, timestamp)
|
|
67
|
-
|
|
68
|
-
// instead of appending a duplicate; cap at RECENT_LIMIT entries.
|
|
69
|
-
const existing = session.recent.findIndex((entry) => entry.path === path)
|
|
70
|
-
if (existing !== -1) session.recent.splice(existing, 1)
|
|
71
|
-
session.recent.unshift({ path, op: finalOp, time: timestamp })
|
|
72
|
-
if (session.recent.length > RECENT_LIMIT) session.recent.length = RECENT_LIMIT
|
|
71
|
+
pushRecent(session.recent, path, finalOp, timestamp)
|
|
73
72
|
return true
|
|
74
73
|
}
|
|
75
74
|
|
|
75
|
+
/** 'delete': the file no longer exists on disk — drop it from stats and the
|
|
76
|
+
* known-file registry, and record a single delete history entry. */
|
|
77
|
+
function applyDelete(session, path, timestamp) {
|
|
78
|
+
delete session.counts[path]
|
|
79
|
+
delete session.known[path]
|
|
80
|
+
pushRecent(session.recent, path, 'delete', timestamp)
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Newest-first LRU history: one entry per path, cap at RECENT_LIMIT. */
|
|
85
|
+
function pushRecent(recent, path, op, time) {
|
|
86
|
+
const existing = recent.findIndex((entry) => entry.path === path)
|
|
87
|
+
if (existing !== -1) recent.splice(existing, 1)
|
|
88
|
+
recent.unshift({ path, op, time })
|
|
89
|
+
if (recent.length > RECENT_LIMIT) recent.length = RECENT_LIMIT
|
|
90
|
+
}
|
|
91
|
+
|
|
76
92
|
/** A record target is valid when both ids are non-empty strings (no NUL). */
|
|
77
93
|
function isValidRecordTarget(sessionId, path) {
|
|
78
94
|
return typeof sessionId === 'string' && sessionId !== ''
|
|
@@ -135,6 +151,8 @@ export function isRecordedPath(state, sessionId, path) {
|
|
|
135
151
|
const session = state.sessions[sessionId]
|
|
136
152
|
if (session === undefined) return false
|
|
137
153
|
if (session.counts !== undefined && typeof session.counts[path] === 'object' && session.counts[path] !== null) return true
|
|
138
|
-
|
|
154
|
+
// Deleted files no longer exist on disk — a delete history entry must not
|
|
155
|
+
// authorize media preview for them.
|
|
156
|
+
if (Array.isArray(session.recent)) return session.recent.some((entry) => entry.path === path && entry.op !== 'delete')
|
|
139
157
|
return false
|
|
140
158
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-file-activity",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH web plugin: file activity tracker
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -57,4 +57,4 @@
|
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"license": "MIT"
|
|
60
|
-
}
|
|
60
|
+
}
|