dsh-file-activity 0.4.6
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 +90 -0
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/assets/preview-float.png +0 -0
- package/assets/screenshot.png +0 -0
- package/cordis.patch.yml +15 -0
- package/lib/api-route.js +81 -0
- package/lib/client.js +1025 -0
- package/lib/client.src.js +65 -0
- package/lib/fence.js +57 -0
- package/lib/http.js +34 -0
- package/lib/index.js +50 -0
- package/lib/media-route.js +93 -0
- package/lib/observer.js +41 -0
- package/lib/parts/api.part.js +45 -0
- package/lib/parts/apply.part.js +76 -0
- package/lib/parts/auto-open.part.js +75 -0
- package/lib/parts/format.part.js +28 -0
- package/lib/parts/i18n.part.js +40 -0
- package/lib/parts/icons.part.js +48 -0
- package/lib/parts/interceptor.part.js +56 -0
- package/lib/parts/preview.part.js +178 -0
- package/lib/parts/rows.part.js +122 -0
- package/lib/parts/store.part.js +16 -0
- package/lib/parts/styles.part.js +76 -0
- package/lib/parts/tree.part.js +71 -0
- package/lib/parts/view.part.js +129 -0
- package/lib/state.js +140 -0
- package/lib/store.js +92 -0
- package/package.json +60 -0
package/lib/state.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State model + persistence for file activity.
|
|
3
|
+
*
|
|
4
|
+
* State (recent history + per-file counts) is kept per session and persisted
|
|
5
|
+
* to $DSH_HOME/file-activity.json (atomic tmp+rename, debounced). Loading is
|
|
6
|
+
* defensive: missing / corrupt / wrong-version files degrade to fresh state.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile } from 'node:fs/promises'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
|
|
11
|
+
/** How many recent entries to keep per session (LRU: one entry per path). */
|
|
12
|
+
const RECENT_LIMIT = 5
|
|
13
|
+
|
|
14
|
+
/** State file: $DSH_HOME/file-activity.json (fallback: ~/.dsh/file-activity.json). */
|
|
15
|
+
export function stateFile() {
|
|
16
|
+
const home = process.env.DSH_HOME
|
|
17
|
+
if (typeof home === 'string' && home !== '') return `${home}/file-activity.json`
|
|
18
|
+
return `${homedir()}/.dsh/file-activity.json`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Empty state document. */
|
|
22
|
+
export function createState() {
|
|
23
|
+
return { version: 1, sessions: {} }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Load persisted state (missing/corrupt file → fresh state). */
|
|
27
|
+
export async function loadState(file) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await readFile(file, 'utf8')
|
|
30
|
+
const parsed = JSON.parse(raw)
|
|
31
|
+
if (parsed !== null && typeof parsed === 'object' && parsed.version === 1) return parsed
|
|
32
|
+
} catch {
|
|
33
|
+
// first run or unreadable file: start fresh
|
|
34
|
+
}
|
|
35
|
+
return createState()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Map a raw operation kind (tool name or client op) to 'read' | 'write' | 'edit'. */
|
|
39
|
+
export function mapOp(op) {
|
|
40
|
+
switch (op) {
|
|
41
|
+
case 'write': return 'write'
|
|
42
|
+
case 'edit':
|
|
43
|
+
case 'str_replace_editor': return 'edit'
|
|
44
|
+
case 'read':
|
|
45
|
+
case 'read_image':
|
|
46
|
+
default: return 'read'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fold one observed operation into the state.
|
|
52
|
+
* 'write' is classified create vs modify through the per-session known-file
|
|
53
|
+
* 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).
|
|
56
|
+
* Returns true when a record was produced.
|
|
57
|
+
*/
|
|
58
|
+
export function applyRecord(state, sessionId, path, op, time) {
|
|
59
|
+
if (!isValidRecordTarget(sessionId, path)) return false
|
|
60
|
+
const session = state.sessions[sessionId] ?? (state.sessions[sessionId] = { known: {}, counts: {}, recent: [] })
|
|
61
|
+
const timestamp = typeof time === 'number' ? time : Date.now()
|
|
62
|
+
const firstSeen = typeof session.known[path] === 'number' ? session.known[path] : timestamp
|
|
63
|
+
const finalOp = classifyOp(op, session.known[path])
|
|
64
|
+
session.known[path] = firstSeen
|
|
65
|
+
const counts = session.counts[path] ?? (session.counts[path] = { read: 0, create: 0, modify: 0 })
|
|
66
|
+
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
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A record target is valid when both ids are non-empty strings (no NUL). */
|
|
77
|
+
function isValidRecordTarget(sessionId, path) {
|
|
78
|
+
return typeof sessionId === 'string' && sessionId !== ''
|
|
79
|
+
&& typeof path === 'string' && path !== '' && !path.includes('\0')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 'write' → create/modify by the known-file registry; 'edit' → modify; else read. */
|
|
83
|
+
function classifyOp(op, knownTime) {
|
|
84
|
+
if (op === 'write') return knownTime ? 'modify' : 'create'
|
|
85
|
+
if (op === 'edit') return 'modify'
|
|
86
|
+
return 'read'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Increment the matching counter and refresh firstSeen/lastSeen. */
|
|
90
|
+
function bumpCount(counts, finalOp, firstSeen, timestamp) {
|
|
91
|
+
if (finalOp === 'create') counts.create += 1
|
|
92
|
+
else if (finalOp === 'modify') counts.modify += 1
|
|
93
|
+
else counts.read += 1
|
|
94
|
+
counts.firstSeen = firstSeen
|
|
95
|
+
counts.lastSeen = timestamp
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalize + trim a loaded state document in place: null/absent sessions are
|
|
100
|
+
* reset to {}, pre-existing history is deduped by path and capped at
|
|
101
|
+
* RECENT_LIMIT (LRU semantics: one entry per path, newest occurrence wins —
|
|
102
|
+
* the array is newest-first, so the first occurrence of each path is kept).
|
|
103
|
+
* Returns { state, trimmed } where trimmed reports whether anything changed.
|
|
104
|
+
*/
|
|
105
|
+
export function trimLoadedState(loaded) {
|
|
106
|
+
if (loaded.sessions === undefined || loaded.sessions === null || typeof loaded.sessions !== 'object') loaded.sessions = {}
|
|
107
|
+
let trimmed = false
|
|
108
|
+
for (const session of Object.values(loaded.sessions)) {
|
|
109
|
+
if (Array.isArray(session.recent)) {
|
|
110
|
+
const seen = new Set()
|
|
111
|
+
const deduped = session.recent.filter((entry) => {
|
|
112
|
+
if (typeof entry?.path !== 'string' || entry.path === '') return false
|
|
113
|
+
if (seen.has(entry.path)) return false
|
|
114
|
+
seen.add(entry.path)
|
|
115
|
+
return true
|
|
116
|
+
})
|
|
117
|
+
if (deduped.length !== session.recent.length) trimmed = true
|
|
118
|
+
session.recent = deduped
|
|
119
|
+
if (session.recent.length > RECENT_LIMIT) {
|
|
120
|
+
session.recent.length = RECENT_LIMIT
|
|
121
|
+
trimmed = true
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { state: loaded, trimmed }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether `path` appears in this session's recorded file activity (counts or
|
|
130
|
+
* recent). The media route authorizes EXACTLY these paths — the record itself
|
|
131
|
+
* is the permission: the agent actually touched the file, so previewing it is
|
|
132
|
+
* expected, while arbitrary unrecorded paths stay refused.
|
|
133
|
+
*/
|
|
134
|
+
export function isRecordedPath(state, sessionId, path) {
|
|
135
|
+
const session = state.sessions[sessionId]
|
|
136
|
+
if (session === undefined) return false
|
|
137
|
+
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)
|
|
139
|
+
return false
|
|
140
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store lifecycle for file activity: async state load with buffering of
|
|
3
|
+
* records that arrive before it completes, debounced atomic persistence
|
|
4
|
+
* (tmp+rename) and a teardown flush. The exposed store object carries a live
|
|
5
|
+
* `state` reference so route handlers always read the current document.
|
|
6
|
+
*/
|
|
7
|
+
import { rename, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { applyRecord, createState, loadState, mapOp, stateFile, trimLoadedState } from './state.js'
|
|
9
|
+
|
|
10
|
+
/** Build the per-apply store: { state, record, schedulePersist, dispose }. */
|
|
11
|
+
export function createStore(ctx) {
|
|
12
|
+
const store = { state: createState() }
|
|
13
|
+
const handle = {
|
|
14
|
+
ctx,
|
|
15
|
+
file: stateFile(),
|
|
16
|
+
store,
|
|
17
|
+
pending: [],
|
|
18
|
+
ready: false,
|
|
19
|
+
persistTimer: null,
|
|
20
|
+
dirtyChain: Promise.resolve(),
|
|
21
|
+
}
|
|
22
|
+
handle.persistNow = () => persistNow(handle)
|
|
23
|
+
handle.persistSoon = () => persistSoon(handle)
|
|
24
|
+
store.record = (sessionId, path, op, time) => record(handle, sessionId, path, op, time)
|
|
25
|
+
store.schedulePersist = () => persistSoon(handle)
|
|
26
|
+
store.dispose = () => dispose(handle)
|
|
27
|
+
void loadState(handle.file).then((loaded) => onLoaded(handle, loaded))
|
|
28
|
+
return store
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Write the current state atomically (serialized through dirtyChain). */
|
|
32
|
+
function persistNow(handle) {
|
|
33
|
+
const snapshot = JSON.stringify(handle.store.state)
|
|
34
|
+
const tmp = `${handle.file}.tmp-${process.pid}`
|
|
35
|
+
handle.dirtyChain = handle.dirtyChain.then(async () => {
|
|
36
|
+
try {
|
|
37
|
+
await writeFile(tmp, snapshot, 'utf8')
|
|
38
|
+
await rename(tmp, handle.file)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
handle.ctx.logger?.warn(`[dsh-file-activity] persist failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
41
|
+
}
|
|
42
|
+
}).catch(() => {})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Debounced (500ms) schedule of a persist. */
|
|
46
|
+
function persistSoon(handle) {
|
|
47
|
+
if (handle.persistTimer !== null) return
|
|
48
|
+
handle.persistTimer = setTimeout(() => {
|
|
49
|
+
handle.persistTimer = null
|
|
50
|
+
persistNow(handle)
|
|
51
|
+
}, 500)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** State loaded: normalize/trim, mark ready, drain buffered records. */
|
|
55
|
+
function onLoaded(handle, loaded) {
|
|
56
|
+
const result = trimLoadedState(loaded)
|
|
57
|
+
handle.store.state = result.state
|
|
58
|
+
handle.ready = true
|
|
59
|
+
if (drainPending(handle) || result.trimmed) persistSoon(handle)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Apply every record buffered before the state load finished. */
|
|
63
|
+
function drainPending(handle) {
|
|
64
|
+
const drained = handle.pending.splice(0)
|
|
65
|
+
for (const item of drained) {
|
|
66
|
+
applyRecord(handle.store.state, item.sessionId, item.path, mapOp(item.op), item.time)
|
|
67
|
+
}
|
|
68
|
+
return drained.length > 0
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Record an operation once state is loaded (buffered before that). */
|
|
72
|
+
function record(handle, sessionId, path, op, time) {
|
|
73
|
+
if (!handle.ready) {
|
|
74
|
+
handle.pending.push({ sessionId, path, op, time })
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
if (applyRecord(handle.store.state, sessionId, path, op, time)) {
|
|
78
|
+
persistSoon(handle)
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Tear down on unload: flush pending persistence. */
|
|
85
|
+
function dispose(handle) {
|
|
86
|
+
if (handle.persistTimer !== null) {
|
|
87
|
+
clearTimeout(handle.persistTimer)
|
|
88
|
+
handle.persistTimer = null
|
|
89
|
+
}
|
|
90
|
+
persistNow(handle)
|
|
91
|
+
void handle.dirtyChain
|
|
92
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-file-activity",
|
|
3
|
+
"version": "0.4.6",
|
|
4
|
+
"description": "DSH web plugin: file activity tracker for dsh-better-sidebar — recent file access history plus create/modify/read counts grouped by folder (dotted multi-level paths), opening files in the sidebar's built-in native preview (syntax highlighting / Markdown rendering / images / PDF) and auto-open.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/baosfeng/my-dsh-plugins"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "node scripts/build.mjs",
|
|
16
|
+
"test": "npx vitest run --coverage && cucumber-js test/features --import 'test/features/steps/*.mjs'"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"default": "./lib/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./client": {
|
|
23
|
+
"default": "./lib/client.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib",
|
|
29
|
+
"cordis.patch.yml",
|
|
30
|
+
"README.md",
|
|
31
|
+
"CHANGELOG.md",
|
|
32
|
+
"LICENSE",
|
|
33
|
+
"assets"
|
|
34
|
+
],
|
|
35
|
+
"dsh": {
|
|
36
|
+
"bundle": {
|
|
37
|
+
"patch": "./cordis.patch.yml"
|
|
38
|
+
},
|
|
39
|
+
"client": {
|
|
40
|
+
"platform": "web",
|
|
41
|
+
"inject": [
|
|
42
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"cordis": "^4.0.0-rc.8",
|
|
48
|
+
"dsh-better-sidebar": "^0.14.0",
|
|
49
|
+
"react": "^18.2.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"dsh-better-sidebar": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"cordis": {
|
|
56
|
+
"optional": true
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"license": "MIT"
|
|
60
|
+
}
|