dsh-file-activity 0.4.7 → 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 CHANGED
@@ -2,6 +2,13 @@
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
+
5
12
  ## [0.4.7] - 2026-08-25
6
13
 
7
14
  ### 变更
package/README.md CHANGED
@@ -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
@@ -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
- /** Three colored count pills for a file/dir node (read/create/modify). */
526
- const countPills = (node) =>
527
- createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } },
528
- createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`),
529
- createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`),
530
- createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`),
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)
@@ -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'),
@@ -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
- /** Three colored count pills for a file/dir node (read/create/modify). */
14
- const countPills = (node) =>
15
- createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } },
16
- createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`),
17
- createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`),
18
- createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`),
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) =>
@@ -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. Each file's counters also track firstSeen (first contact time,
55
- * i.e. creation time) and lastSeen (most recent activity time).
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
- // Newest-first LRU history: revisiting a path moves it to the front
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
- if (Array.isArray(session.recent)) return session.recent.some((entry) => entry.path === path)
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,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-file-activity",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
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",
@@ -57,4 +57,4 @@
57
57
  }
58
58
  },
59
59
  "license": "MIT"
60
- }
60
+ }