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.
@@ -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
+ }
@@ -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
+ }