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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-file-activity — client half (browser).
|
|
3
|
+
*
|
|
4
|
+
* Extends dsh-better-sidebar with a "文件活动 / File Activity" tab:
|
|
5
|
+
* - recent file access history (agent + sidebar operations),
|
|
6
|
+
* - per-file create/modify/read counts flattened by folder, with multi-level
|
|
7
|
+
* folders shown as dotted paths (a.b.c.d) and their files indented below,
|
|
8
|
+
* - clicking any file opens a FLOATING preview that reuses the sidebar's
|
|
9
|
+
* NATIVE viewer via `ctx.betterSidebar.matchFileViewer(path)` — its own
|
|
10
|
+
* `component` is mounted (built-in markdown / code / image / pdf / html
|
|
11
|
+
* renderers), so code gets syntax highlighting and markdown gets rendered
|
|
12
|
+
* with no hand-rolled preview; clicking outside / Esc / × closes it,
|
|
13
|
+
* - auto-opens once per session by default (toggleable in the sidebar
|
|
14
|
+
* settings, enabled by default).
|
|
15
|
+
*
|
|
16
|
+
* Data source: the plugin host half (fs/observed for agent tools) + this
|
|
17
|
+
* half's fetch interception for sidebar file operations (fs.read / fs.write /
|
|
18
|
+
* /sidebar/file media opens), both persisted host-side; the tab polls
|
|
19
|
+
* /file-activity/api/stats.
|
|
20
|
+
*
|
|
21
|
+
* Styling follows the dsh-better-sidebar design language: all colors ride the
|
|
22
|
+
* DSH semantic tokens (--dsw-alias-*), typography rides the font roles
|
|
23
|
+
* (--dsw-font-*), motion rides --ds-*. Flat surfaces (no box-shadow), hairline
|
|
24
|
+
* borders, 28px circular icon controls with hover fills, and 8px-radius rows
|
|
25
|
+
* with hover fills. The stylesheet is injected once per activation and torn
|
|
26
|
+
* down with the fiber, so HMR/disable leaves no residue.
|
|
27
|
+
*
|
|
28
|
+
* BUILD NOTE: this file is the SOURCE TEMPLATE. scripts/build.mjs splices the
|
|
29
|
+
* `lib/parts/*.part.js` pieces into the PART placeholder markers below (each
|
|
30
|
+
* piece is plain function-declaration text sharing this factory scope; the
|
|
31
|
+
* browser ModuleLoader does not support relative-path require) and writes
|
|
32
|
+
* lib/client.js — the file actually served by DSH, which MUST be committed
|
|
33
|
+
* (CI runs node --check + tests against it, not against this template).
|
|
34
|
+
*/
|
|
35
|
+
window.__ModuleLoader__.load({
|
|
36
|
+
id: 'dsh-file-activity',
|
|
37
|
+
factory: (require) => {
|
|
38
|
+
var module = { exports: {} }
|
|
39
|
+
var exports = module.exports
|
|
40
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
41
|
+
const { createElement, useEffect, useMemo, useState, useSyncExternalStore } = require('react')
|
|
42
|
+
|
|
43
|
+
const TAB_ID = 'file-activity:recent'
|
|
44
|
+
const AUTO_OPEN_KEY = 'dsh-file-activity:auto-opened:'
|
|
45
|
+
const POLL_MS = 6000
|
|
46
|
+
|
|
47
|
+
// ── parts (injected by scripts/build.mjs; keep this exact order — the
|
|
48
|
+
// const initializers below run in splice order) ─────────────────────
|
|
49
|
+
__PART_I18N__
|
|
50
|
+
__PART_FORMAT__
|
|
51
|
+
__PART_TREE__
|
|
52
|
+
__PART_STORE__
|
|
53
|
+
__PART_API__
|
|
54
|
+
__PART_INTERCEPTOR__
|
|
55
|
+
__PART_AUTO_OPEN__
|
|
56
|
+
__PART_ICONS__
|
|
57
|
+
__PART_STYLES__
|
|
58
|
+
__PART_ROWS__
|
|
59
|
+
__PART_VIEW__
|
|
60
|
+
__PART_PREVIEW__
|
|
61
|
+
__PART_APPLY__
|
|
62
|
+
|
|
63
|
+
return module.exports
|
|
64
|
+
},
|
|
65
|
+
})
|
package/lib/fence.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust fence for /file-activity routes: only loopback / explicitly trusted
|
|
3
|
+
* hosts may call the plugin's HTTP endpoints (same behavioral contract as the
|
|
4
|
+
* /api gateway). 403-refuses everything else, including cross-site requests.
|
|
5
|
+
*/
|
|
6
|
+
function header(headers, name) {
|
|
7
|
+
const value = headers[name]
|
|
8
|
+
return typeof value === 'string' ? value : undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseAuthority(authority) {
|
|
12
|
+
try {
|
|
13
|
+
return new URL(`http://${authority}`)
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isLoopbackHostname(hostname) {
|
|
20
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
21
|
+
const parts = hostname.split('.')
|
|
22
|
+
return parts.length === 4
|
|
23
|
+
&& parts[0] === '127'
|
|
24
|
+
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function canonicalAuthority(entry, entryUrl) {
|
|
28
|
+
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
|
|
29
|
+
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
33
|
+
return trustedHosts.some((entry) => {
|
|
34
|
+
const entryUrl = parseAuthority(entry)
|
|
35
|
+
if (entryUrl === undefined) return false
|
|
36
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
|
37
|
+
? entryUrl.hostname === hostUrl.hostname
|
|
38
|
+
: entryUrl.host === hostUrl.host
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Host-header trust fence (same behavioral contract as the /api gateway). */
|
|
43
|
+
export function isTrustedApiRequest(request, trustedHosts) {
|
|
44
|
+
const host = header(request.headers, 'host')
|
|
45
|
+
if (host === undefined) return false
|
|
46
|
+
const hostUrl = parseAuthority(host)
|
|
47
|
+
if (hostUrl === undefined) return false
|
|
48
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
|
|
49
|
+
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
|
|
50
|
+
const origin = header(request.headers, 'origin')
|
|
51
|
+
if (origin === undefined) return true
|
|
52
|
+
try {
|
|
53
|
+
return new URL(origin).host === hostUrl.host
|
|
54
|
+
} catch {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
}
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP helpers shared by the /file-activity routes: bounded JSON body
|
|
3
|
+
* reading, JSON responses, and session working-directory resolution.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Read a JSON request body (bounded). */
|
|
7
|
+
export async function readJsonBody(request) {
|
|
8
|
+
let body = ''
|
|
9
|
+
for await (const chunk of request) {
|
|
10
|
+
body += chunk
|
|
11
|
+
if (body.length > 1_000_000) throw new Error('request body too large')
|
|
12
|
+
}
|
|
13
|
+
if (body === '') return {}
|
|
14
|
+
return JSON.parse(body)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function writeJson(response, status, value) {
|
|
18
|
+
const payload = JSON.stringify(value)
|
|
19
|
+
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-cache' })
|
|
20
|
+
response.end(payload)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function writeError(response, error) {
|
|
24
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
25
|
+
writeJson(response, 400, { ok: false, error: { message } })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Session working directory, mirroring better-sidebar's resolution. */
|
|
29
|
+
export function sessionCwdOf(ctx, sessionId) {
|
|
30
|
+
const session = ctx.sessions.get(sessionId)
|
|
31
|
+
const headerCwd = session?.header?.cwd
|
|
32
|
+
if (typeof headerCwd === 'string' && headerCwd !== '') return headerCwd
|
|
33
|
+
return process.cwd()
|
|
34
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-file-activity — host half.
|
|
3
|
+
*
|
|
4
|
+
* Tracks file activity for dsh-better-sidebar:
|
|
5
|
+
* - agent tool file operations arrive as `fs/observed` events (read / write /
|
|
6
|
+
* edit / str_replace_editor / read_image ...), with the tool execution as
|
|
7
|
+
* the actor (name + parsed arguments + owning agent).
|
|
8
|
+
* - sidebar operations (files opened / saved through the better-sidebar
|
|
9
|
+
* explorer & editor) are reported by our client half through the
|
|
10
|
+
* `/file-activity/api/record` route.
|
|
11
|
+
*
|
|
12
|
+
* State (recent history + per-file counts) is kept per session and persisted
|
|
13
|
+
* to $DSH_HOME/file-activity.json (atomic tmp+rename, debounced).
|
|
14
|
+
*/
|
|
15
|
+
import { isTrustedApiRequest } from './fence.js'
|
|
16
|
+
import { createApiHandler } from './api-route.js'
|
|
17
|
+
import { createMediaHandler } from './media-route.js'
|
|
18
|
+
import { createFsObserver } from './observer.js'
|
|
19
|
+
import { createStore } from './store.js'
|
|
20
|
+
|
|
21
|
+
export const name = 'dsh-file-activity'
|
|
22
|
+
|
|
23
|
+
export const inject = ['webServer', 'sessions', 'webRuntime']
|
|
24
|
+
|
|
25
|
+
export function apply(ctx) {
|
|
26
|
+
const store = createStore(ctx)
|
|
27
|
+
|
|
28
|
+
// ── agent-side file operations ──────────────────────────────────────────
|
|
29
|
+
ctx.on('fs/observed', createFsObserver(store.record))
|
|
30
|
+
|
|
31
|
+
// ── routes ──────────────────────────────────────────────────────────────
|
|
32
|
+
const fence = (request) => isTrustedApiRequest(request, ctx.webRuntime.trustedHosts)
|
|
33
|
+
|
|
34
|
+
ctx.effect(() => ctx.webServer.register({
|
|
35
|
+
kind: 'prefix',
|
|
36
|
+
path: '/file-activity/api',
|
|
37
|
+
handler: createApiHandler({ ctx, store, fence }),
|
|
38
|
+
}), 'dsh-file-activity: /file-activity/api routes')
|
|
39
|
+
|
|
40
|
+
// Media route for the floating preview (see media-route.js for the
|
|
41
|
+
// authorization model: recorded paths only, same trust fence).
|
|
42
|
+
ctx.effect(() => ctx.webServer.register({
|
|
43
|
+
kind: 'prefix',
|
|
44
|
+
path: '/file-activity/file',
|
|
45
|
+
handler: createMediaHandler({ ctx, store, fence }),
|
|
46
|
+
}), 'dsh-file-activity: /file-activity/file media route')
|
|
47
|
+
|
|
48
|
+
// Tear down on unload: flush pending persistence.
|
|
49
|
+
ctx.effect(() => store.dispose, 'dsh-file-activity: persistence teardown')
|
|
50
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /file-activity/file media route: serves recorded file bytes (images / PDFs)
|
|
3
|
+
* for the floating preview. The sidebar's own /sidebar/file route refuses
|
|
4
|
+
* every path outside the session working directory (isWithin(cwd, …)), but
|
|
5
|
+
* file activity records files the agent touched ANYWHERE — /tmp scratch files,
|
|
6
|
+
* sibling repos, … — so images/PDFs outside the workspace resolve to a broken
|
|
7
|
+
* <img>. This route serves the bytes with the same trust fence, swapping the
|
|
8
|
+
* "inside the session cwd" boundary for "paths this session actually
|
|
9
|
+
* recorded".
|
|
10
|
+
*/
|
|
11
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
12
|
+
import { basename, isAbsolute, join } from 'node:path'
|
|
13
|
+
import { sessionCwdOf, writeJson } from './http.js'
|
|
14
|
+
import { isRecordedPath } from './state.js'
|
|
15
|
+
|
|
16
|
+
/** Cap for the plugin's own media route (bytes): images / PDFs only. */
|
|
17
|
+
const MEDIA_LIMIT = 64 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
/** Content types served by /file-activity/file (mirrors the sidebar's set). */
|
|
20
|
+
const MEDIA_TYPES = {
|
|
21
|
+
'.png': 'image/png',
|
|
22
|
+
'.jpg': 'image/jpeg',
|
|
23
|
+
'.jpeg': 'image/jpeg',
|
|
24
|
+
'.gif': 'image/gif',
|
|
25
|
+
'.webp': 'image/webp',
|
|
26
|
+
'.svg': 'image/svg+xml',
|
|
27
|
+
'.bmp': 'image/bmp',
|
|
28
|
+
'.ico': 'image/x-icon',
|
|
29
|
+
'.avif': 'image/avif',
|
|
30
|
+
'.pdf': 'application/pdf',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mediaTypeForPath(path) {
|
|
34
|
+
const dot = path.lastIndexOf('.')
|
|
35
|
+
const ext = dot === -1 ? '' : path.slice(dot).toLowerCase()
|
|
36
|
+
return MEDIA_TYPES[ext] ?? 'application/octet-stream'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Error carrying an HTTP status, for the media route's catch-all. */
|
|
40
|
+
function mediaError(status, message) {
|
|
41
|
+
const error = new Error(message)
|
|
42
|
+
error.status = status
|
|
43
|
+
return error
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createMediaHandler({ ctx, store, fence }) {
|
|
47
|
+
return async (request, response) => {
|
|
48
|
+
if (!fence(request)) {
|
|
49
|
+
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
if (request.method !== 'GET') {
|
|
53
|
+
writeJson(response, 405, { ok: false, error: { message: 'method not allowed' } })
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(request.url ?? '/', 'http://dsh.internal')
|
|
58
|
+
const sessionId = url.searchParams.get('sessionId')
|
|
59
|
+
const raw = url.searchParams.get('path')
|
|
60
|
+
assertMediaParams(sessionId, raw)
|
|
61
|
+
if (!isRecordedPath(store.state, sessionId, raw)) throw mediaError(403, 'path is not in this session\'s file activity')
|
|
62
|
+
const abs = isAbsolute(raw) ? raw : join(sessionCwdOf(ctx, sessionId), raw)
|
|
63
|
+
await serveMedia(response, abs, url)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const status = typeof error?.status === 'number' ? error.status : 400
|
|
66
|
+
writeJson(response, status, { ok: false, error: { message: error instanceof Error ? error.message : String(error) } })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Both query parameters are required for a media request. */
|
|
72
|
+
function assertMediaParams(sessionId, raw) {
|
|
73
|
+
if (sessionId === null || raw === null || raw === '') throw mediaError(400, 'sessionId and path are required')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** stat + read + respond with the file's bytes (bounded by MEDIA_LIMIT). */
|
|
77
|
+
async function serveMedia(response, abs, url) {
|
|
78
|
+
let info
|
|
79
|
+
try {
|
|
80
|
+
info = await stat(abs)
|
|
81
|
+
} catch {
|
|
82
|
+
throw mediaError(404, 'file not found')
|
|
83
|
+
}
|
|
84
|
+
if (!info.isFile()) throw mediaError(400, 'not a file')
|
|
85
|
+
if (info.size > MEDIA_LIMIT) throw mediaError(413, 'file too large')
|
|
86
|
+
const body = await readFile(abs)
|
|
87
|
+
const headers = { 'content-type': mediaTypeForPath(abs), 'cache-control': 'no-cache' }
|
|
88
|
+
if (url.searchParams.get('download') === '1') {
|
|
89
|
+
headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(abs))}`
|
|
90
|
+
}
|
|
91
|
+
response.writeHead(200, headers)
|
|
92
|
+
response.end(body)
|
|
93
|
+
}
|
package/lib/observer.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs/observed event listener factory: filters noise (absent observations,
|
|
3
|
+
* missing actors / session ids / tool names), resolves the touched path
|
|
4
|
+
* (backend displayPath preferred, falling back to the file_path argument)
|
|
5
|
+
* and records it through the store's record().
|
|
6
|
+
*/
|
|
7
|
+
import { mapOp } from './state.js'
|
|
8
|
+
|
|
9
|
+
export function createFsObserver(record) {
|
|
10
|
+
return (target, observation, actor) => {
|
|
11
|
+
// Only authoritative PRESENT observations mean a file was actually
|
|
12
|
+
// touched; absent observations (e.g. a failed read of a missing file)
|
|
13
|
+
// are noise.
|
|
14
|
+
if (!isPresentObservation(observation)) return
|
|
15
|
+
if (!isValidActor(actor)) return
|
|
16
|
+
const sessionId = actor.agent?.id
|
|
17
|
+
if (typeof sessionId !== 'string' || sessionId === '') return
|
|
18
|
+
// Prefer the backend-resolved absolute path; fall back to the raw argument.
|
|
19
|
+
const rawPath = resolveObservedPath(target, actor)
|
|
20
|
+
if (rawPath === '') return
|
|
21
|
+
record(sessionId, rawPath, mapOp(actor.name), Date.now())
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A PRESENT observation is the only authoritative "file was touched" signal. */
|
|
26
|
+
function isPresentObservation(observation) {
|
|
27
|
+
return observation !== undefined && observation !== null && observation.kind === 'present'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The actor must exist and carry a non-empty string tool name. */
|
|
31
|
+
function isValidActor(actor) {
|
|
32
|
+
return actor !== undefined && actor !== null && typeof actor.name === 'string' && actor.name !== ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolve the touched path: backend displayPath preferred, else file_path. */
|
|
36
|
+
function resolveObservedPath(target, actor) {
|
|
37
|
+
if (typeof target?.displayPath === 'string' && target.displayPath !== '') return target.displayPath
|
|
38
|
+
const args = actor.arguments
|
|
39
|
+
if (args !== null && typeof args === 'object' && typeof args.file_path === 'string') return args.file_path
|
|
40
|
+
return ''
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// ── data access (host routes) ─────────────────────────────────────────
|
|
2
|
+
async function fetchStats(sessionId) {
|
|
3
|
+
const response = await fetch(`/file-activity/api/stats?sessionId=${encodeURIComponent(sessionId)}`)
|
|
4
|
+
const json = await response.json()
|
|
5
|
+
if (json === null || typeof json !== 'object' || json.ok !== true) return null
|
|
6
|
+
return json.value
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Resolve the session working directory through the sidebar's native API. */
|
|
10
|
+
async function fetchSessionCwd(sessionId) {
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetch('/sidebar/api/session.cwd', {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: { 'content-type': 'application/json' },
|
|
15
|
+
body: JSON.stringify({ sessionId }),
|
|
16
|
+
})
|
|
17
|
+
const json = await response.json()
|
|
18
|
+
const cwd = json?.value?.cwd
|
|
19
|
+
return typeof cwd === 'string' && cwd !== '' ? cwd : ''
|
|
20
|
+
} catch {
|
|
21
|
+
return ''
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function postRecord(sessionId, path, op) {
|
|
26
|
+
if (typeof sessionId !== 'string' || sessionId === '' || typeof path !== 'string' || path === '') return
|
|
27
|
+
void fetch('/file-activity/api/record', {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'content-type': 'application/json' },
|
|
30
|
+
body: JSON.stringify({ sessionId, path, op }),
|
|
31
|
+
}).catch(() => {})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function postClear(sessionId) {
|
|
35
|
+
void fetch('/file-activity/api/clear', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'content-type': 'application/json' },
|
|
38
|
+
body: JSON.stringify({ sessionId }),
|
|
39
|
+
}).catch(() => {})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Plugin media route URL for a recorded path (authorized per session). */
|
|
43
|
+
function mediaUrlOf(sessionId, path) {
|
|
44
|
+
return `/file-activity/file?${new URLSearchParams({ sessionId, path })}`
|
|
45
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// ── plugin body ───────────────────────────────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* The stylesheet is pure static CSS and must NOT depend on the
|
|
4
|
+
* betterSidebar service: inject it first, unconditionally. If it lived
|
|
5
|
+
* behind the `service === undefined` early return, an HMR rebuild or
|
|
6
|
+
* service reload could leave the already-rendered tab WITHOUT its
|
|
7
|
+
* stylesheet — the raw white-text list you see when the CSS is gone.
|
|
8
|
+
* Each fiber owns its own <style> element and the disposer removes
|
|
9
|
+
* only that element, so a rebuild always keeps at least one copy.
|
|
10
|
+
*/
|
|
11
|
+
function injectStyles(ctx) {
|
|
12
|
+
ctx.effect(() => {
|
|
13
|
+
if (typeof document === 'undefined' || document === null || typeof document.head === 'undefined') return () => {}
|
|
14
|
+
const style = document.createElement('style')
|
|
15
|
+
style.setAttribute('data-dsh-file-activity', 'styles')
|
|
16
|
+
style.textContent = STYLES
|
|
17
|
+
document.head.appendChild(style)
|
|
18
|
+
return () => {
|
|
19
|
+
if (style.parentNode) style.parentNode.removeChild(style)
|
|
20
|
+
}
|
|
21
|
+
}, 'dsh-file-activity: styles')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Mount probe: report client activation to the host state (synthetic
|
|
25
|
+
* session id, invisible in the UI — confirms the client half actually
|
|
26
|
+
* loaded after a page refresh). */
|
|
27
|
+
function mountProbe() {
|
|
28
|
+
void fetch('/file-activity/api/record', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'content-type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ sessionId: '__probe__', path: 'mounted', op: 'read' }),
|
|
32
|
+
}).catch(() => {})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Register the tab (enabled by default in the Side card settings). */
|
|
36
|
+
function registerTab(ctx, dataStore) {
|
|
37
|
+
const service = ctx.betterSidebar
|
|
38
|
+
ctx.effect(() => service.registerTab({
|
|
39
|
+
id: TAB_ID,
|
|
40
|
+
title: () => strings.title(),
|
|
41
|
+
icon: (size) => icon.clock(size),
|
|
42
|
+
order: 15,
|
|
43
|
+
single: true,
|
|
44
|
+
settings: {
|
|
45
|
+
pluginToggles: [{
|
|
46
|
+
key: 'autoOpen',
|
|
47
|
+
title: () => (isZh() ? '会话开始时自动打开' : 'Auto-open on session start'),
|
|
48
|
+
desc: () => (isZh() ? '每个会话首次打开时自动显示本页(可在侧边栏设置中关闭)' : 'Opens this tab once per session by default (turn off here)'),
|
|
49
|
+
type: 'switch',
|
|
50
|
+
}],
|
|
51
|
+
},
|
|
52
|
+
component: (props) => createElement(FileActivityView, { ...props, dataStore }),
|
|
53
|
+
}), 'dsh-file-activity: tab registration')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
exports.inject = ['betterSidebar']
|
|
57
|
+
|
|
58
|
+
exports.apply = function apply(ctx) {
|
|
59
|
+
// Stylesheet first, unconditionally (HMR pitfall — see injectStyles).
|
|
60
|
+
injectStyles(ctx)
|
|
61
|
+
const service = ctx.betterSidebar
|
|
62
|
+
if (service === undefined) return
|
|
63
|
+
|
|
64
|
+
// Per-session data store: { bySession: { [sessionId]: { recent, counts, loading } }, preview }
|
|
65
|
+
// Each conversation reads/writes only its own bucket, so switching
|
|
66
|
+
// sessions never leaks another session's file activity into the view.
|
|
67
|
+
const dataStore = createStore({ bySession: {}, preview: null })
|
|
68
|
+
mountProbe()
|
|
69
|
+
|
|
70
|
+
// sidebar operations → host record route
|
|
71
|
+
ctx.effect(() => installFetchInterceptor(), 'dsh-file-activity: sidebar fetch observation')
|
|
72
|
+
registerTab(ctx, dataStore)
|
|
73
|
+
|
|
74
|
+
// auto-open once per session (default on)
|
|
75
|
+
ctx.effect(() => installAutoOpen(ctx, TAB_ID), 'dsh-file-activity: auto-open')
|
|
76
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// ── auto-open (enabled by default) ────────────────────────────────────
|
|
2
|
+
function findTabIn(state, tabId) {
|
|
3
|
+
const leaves = (node) => (node.kind === 'leaf' ? [node] : (node.children ?? []).flatMap(leaves))
|
|
4
|
+
for (const node of [state?.splits, state?.bottomSplits]) {
|
|
5
|
+
if (node === undefined || node === null) continue
|
|
6
|
+
for (const leaf of leaves(node)) {
|
|
7
|
+
if ((leaf.tabs ?? []).some((tab) => tab.type === tabId)) return true
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Current sidebar snapshot, or null when the service is not ready. */
|
|
14
|
+
function sidebarSnapshot(service) {
|
|
15
|
+
try {
|
|
16
|
+
return service.getSnapshot?.()
|
|
17
|
+
} catch {
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The user disabled auto-open for this tab in the sidebar settings. */
|
|
23
|
+
function isAutoOpenDisabled(snapshot, tabId) {
|
|
24
|
+
const settings = snapshot.prefs?.pluginSettings?.[tabId]
|
|
25
|
+
return settings !== undefined && settings.autoOpen === false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether this session was already auto-opened (localStorage marker). */
|
|
29
|
+
function isAutoOpenMarked(sessionId) {
|
|
30
|
+
try {
|
|
31
|
+
return Boolean(window.localStorage.getItem(AUTO_OPEN_KEY + sessionId))
|
|
32
|
+
} catch {
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Persist the auto-opened marker for this session. */
|
|
38
|
+
function markAutoOpened(sessionId) {
|
|
39
|
+
try {
|
|
40
|
+
window.localStorage.setItem(AUTO_OPEN_KEY + sessionId, '1')
|
|
41
|
+
} catch {
|
|
42
|
+
// ignore
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Open the tab once per session unless disabled in the plugin settings. */
|
|
47
|
+
function tryAutoOpen(service, tabId) {
|
|
48
|
+
const snapshot = sidebarSnapshot(service)
|
|
49
|
+
if (snapshot === undefined || snapshot === null || snapshot.sessionId === undefined || snapshot.state === undefined) return
|
|
50
|
+
const sessionId = snapshot.sessionId
|
|
51
|
+
if (isAutoOpenDisabled(snapshot, tabId)) return
|
|
52
|
+
if (isAutoOpenMarked(sessionId)) return
|
|
53
|
+
if (findTabIn(snapshot.state, tabId)) {
|
|
54
|
+
markAutoOpened(sessionId)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
service.openTab({ type: tabId, title: strings.title(), path: '' })
|
|
59
|
+
markAutoOpened(sessionId)
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error('[dsh-file-activity] auto-open failed:', error)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function installAutoOpen(ctx, tabId) {
|
|
66
|
+
const service = ctx.betterSidebar
|
|
67
|
+
tryAutoOpen(service, tabId)
|
|
68
|
+
let off = () => {}
|
|
69
|
+
try {
|
|
70
|
+
off = service.subscribeState?.(() => tryAutoOpen(service, tabId)) ?? off
|
|
71
|
+
} catch {
|
|
72
|
+
// service may lack subscribeState on older versions
|
|
73
|
+
}
|
|
74
|
+
return off
|
|
75
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// ── path / time formatting helpers ────────────────────────────────────
|
|
2
|
+
function basenameOf(path) {
|
|
3
|
+
const norm = path.split('\\').join('/')
|
|
4
|
+
const idx = norm.lastIndexOf('/')
|
|
5
|
+
return idx === -1 ? norm : norm.slice(idx + 1)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Compact relative time: 刚刚 / N 分钟前 / N 小时前 / N 天前 / MM/DD. */
|
|
9
|
+
function formatRelative(time) {
|
|
10
|
+
if (typeof time !== 'number' || !Number.isFinite(time)) return ''
|
|
11
|
+
const diff = Date.now() - time
|
|
12
|
+
if (diff < 30_000) return strings.justNow()
|
|
13
|
+
const minutes = Math.floor(diff / 60_000)
|
|
14
|
+
if (minutes < 60) return strings.minutesAgo(minutes)
|
|
15
|
+
const hours = Math.floor(minutes / 60)
|
|
16
|
+
if (hours < 24) return strings.hoursAgo(hours)
|
|
17
|
+
const days = Math.floor(hours / 24)
|
|
18
|
+
if (days < 7) return strings.daysAgo(days)
|
|
19
|
+
const date = new Date(time)
|
|
20
|
+
return `${date.getMonth() + 1}/${date.getDate()}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Local wall-clock HH:MM:SS (used in tooltips; full precision). */
|
|
24
|
+
function formatTime(time) {
|
|
25
|
+
const date = new Date(time)
|
|
26
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
27
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
28
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// ── i18n ──────────────────────────────────────────────────────────────
|
|
2
|
+
function isZh() {
|
|
3
|
+
try {
|
|
4
|
+
const lang = (navigator.language || 'en').toLowerCase()
|
|
5
|
+
return lang.startsWith('zh')
|
|
6
|
+
} catch {
|
|
7
|
+
return false
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const strings = {
|
|
12
|
+
title: () => (isZh() ? '文件活动' : 'File Activity'),
|
|
13
|
+
recent: () => (isZh() ? '最近访问' : 'Recent'),
|
|
14
|
+
stats: () => (isZh() ? '文件统计' : 'File Stats'),
|
|
15
|
+
empty: () => (isZh() ? '暂无文件活动记录' : 'No file activity yet'),
|
|
16
|
+
emptyHint: () => (isZh()
|
|
17
|
+
? '在侧边栏打开文件、编辑保存,或让 agent 读写文件(创建/读取/修改),都会记录在这里。点击任意文件将在侧边栏内用原生预览打开(代码高亮 / Markdown 渲染 / 图片 / PDF…)。'
|
|
18
|
+
: 'Opening files in the sidebar, editing, or agent file operations (create/read/modify) are recorded here. Click any file to open it in the sidebar with native preview (syntax highlighting / Markdown rendering / images / PDF…).'),
|
|
19
|
+
refresh: () => (isZh() ? '刷新' : 'Refresh'),
|
|
20
|
+
clear: () => (isZh() ? '清空' : 'Clear'),
|
|
21
|
+
clearConfirm: () => (isZh() ? '确定清空当前会话的全部文件活动记录?' : 'Clear all file activity for this session?'),
|
|
22
|
+
read: () => (isZh() ? '读取' : 'read'),
|
|
23
|
+
create: () => (isZh() ? '新增' : 'create'),
|
|
24
|
+
modify: () => (isZh() ? '修改' : 'modify'),
|
|
25
|
+
readShort: () => (isZh() ? '读' : 'R'),
|
|
26
|
+
createShort: () => (isZh() ? '增' : 'C'),
|
|
27
|
+
modifyShort: () => (isZh() ? '改' : 'M'),
|
|
28
|
+
loadError: () => (isZh() ? '加载失败' : 'Load failed'),
|
|
29
|
+
created: () => (isZh() ? '创建' : 'Created'),
|
|
30
|
+
lastSeen: () => (isZh() ? '最近访问' : 'Last seen'),
|
|
31
|
+
justNow: () => (isZh() ? '刚刚' : 'just now'),
|
|
32
|
+
minutesAgo: (m) => (isZh() ? `${m} 分钟前` : `${m}m ago`),
|
|
33
|
+
hoursAgo: (h) => (isZh() ? `${h} 小时前` : `${h}h ago`),
|
|
34
|
+
daysAgo: (d) => (isZh() ? `${d} 天前` : `${d}d ago`),
|
|
35
|
+
closePreview: () => (isZh() ? '关闭预览' : 'Close preview'),
|
|
36
|
+
loading: () => (isZh() ? '加载中…' : 'Loading…'),
|
|
37
|
+
previewUnsupported: () => (isZh() ? '该文件类型暂不支持预览' : 'This file type cannot be previewed yet'),
|
|
38
|
+
previewFailed: () => (isZh() ? '预览加载失败' : 'Preview failed to load'),
|
|
39
|
+
downloadToView: () => (isZh() ? '下载查看' : 'download to view'),
|
|
40
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ── icons (inline, stroke=currentColor, matching better-sidebar) ──────
|
|
2
|
+
const ICON_STROKE = 1.8
|
|
3
|
+
const iconSvg = (children, size) =>
|
|
4
|
+
createElement('svg', {
|
|
5
|
+
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
|
|
6
|
+
stroke: 'currentColor', strokeWidth: ICON_STROKE, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
7
|
+
'aria-hidden': 'true',
|
|
8
|
+
}, children.map((child, i) => (child === null || child === undefined || typeof child === 'boolean')
|
|
9
|
+
? child
|
|
10
|
+
: createElement(child.type, { key: i, ...child.props })))
|
|
11
|
+
|
|
12
|
+
const icon = {
|
|
13
|
+
clock: (size = 16) => iconSvg([
|
|
14
|
+
createElement('circle', { cx: 12, cy: 12, r: 9 }),
|
|
15
|
+
createElement('path', { d: 'M12 7v5l3 2' }),
|
|
16
|
+
], size),
|
|
17
|
+
refresh: (size = 16) => iconSvg([
|
|
18
|
+
createElement('path', { d: 'M21 12a9 9 0 1 1-2.64-6.36' }),
|
|
19
|
+
createElement('polyline', { points: '21 3 21 9 15 9' }),
|
|
20
|
+
], size),
|
|
21
|
+
trash: (size = 16) => iconSvg([
|
|
22
|
+
createElement('path', { d: 'M3 6h18' }),
|
|
23
|
+
createElement('path', { d: 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6' }),
|
|
24
|
+
createElement('path', { d: 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2' }),
|
|
25
|
+
], size),
|
|
26
|
+
chevronRight: (size = 14) => iconSvg([
|
|
27
|
+
createElement('polyline', { points: '9 6 15 12 9 18' }),
|
|
28
|
+
], size),
|
|
29
|
+
chevronDown: (size = 14) => iconSvg([
|
|
30
|
+
createElement('polyline', { points: '6 9 12 15 18 9' }),
|
|
31
|
+
], size),
|
|
32
|
+
file: (size = 16) => iconSvg([
|
|
33
|
+
createElement('path', { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }),
|
|
34
|
+
createElement('path', { d: 'M14 2v6h6' }),
|
|
35
|
+
], size),
|
|
36
|
+
folder: (size = 16) => iconSvg([
|
|
37
|
+
createElement('path', { d: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z' }),
|
|
38
|
+
], size),
|
|
39
|
+
external: (size = 15) => iconSvg([
|
|
40
|
+
createElement('path', { d: 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' }),
|
|
41
|
+
createElement('polyline', { points: '15 3 21 3 21 9' }),
|
|
42
|
+
createElement('line', { x1: 10, y1: 14, x2: 21, y2: 3 }),
|
|
43
|
+
], size),
|
|
44
|
+
close: (size = 15) => iconSvg([
|
|
45
|
+
createElement('line', { x1: 18, y1: 6, x2: 6, y2: 18 }),
|
|
46
|
+
createElement('line', { x1: 6, y1: 6, x2: 18, y2: 18 }),
|
|
47
|
+
], size),
|
|
48
|
+
}
|