dsh-file-activity 0.5.7 → 0.5.9
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 +50 -0
- package/README.md +29 -18
- package/lib/api-route.js +72 -76
- package/lib/bash-ops.js +65 -54
- package/lib/bash-parse.js +154 -162
- package/lib/client.js +739 -361
- package/lib/client.src.js +28 -21
- package/lib/cwd.js +6 -6
- package/lib/index.js +68 -83
- package/lib/media-route.js +119 -90
- package/lib/observer.js +30 -24
- package/lib/parts/api.part.js +1 -5
- package/lib/parts/apply.part.js +91 -82
- package/lib/parts/auto-open.part.js +83 -60
- package/lib/parts/document-previews.part.js +62 -0
- package/lib/parts/format.part.js +1 -2
- package/lib/parts/i18n.part.js +15 -1
- package/lib/parts/interceptor.part.js +16 -27
- package/lib/parts/preview-data.part.js +111 -0
- package/lib/parts/preview-float.part.js +276 -0
- package/lib/parts/rows.part.js +1 -11
- package/lib/parts/settings.part.js +71 -0
- package/lib/parts/store.part.js +19 -0
- package/lib/parts/styles.part.js +49 -3
- package/lib/parts/tree.part.js +1 -2
- package/lib/parts/view.part.js +72 -18
- package/lib/persist.js +137 -0
- package/lib/quota-trim.js +121 -0
- package/lib/quota.js +164 -0
- package/lib/state.js +336 -136
- package/lib/store.js +249 -76
- package/package.json +10 -6
- package/lib/parts/preview.part.js +0 -308
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// ── preview data access (routes, viewer choice, loading) ────────────────
|
|
3
|
+
//
|
|
4
|
+
// The floating window used to mount the third-party sidebar's built-in viewer,
|
|
5
|
+
// picked by the third-party sidebar matchFileViewer(path), and fed it the bytes its
|
|
6
|
+
// fetchStrategy asked for. That service is gone (issue #187 batch 2), so this
|
|
7
|
+
// module does the reading itself through the plugin's own routes:
|
|
8
|
+
//
|
|
9
|
+
// /file-activity/file?sessionId&path → raw bytes (image / pdf)
|
|
10
|
+
// /file-activity/file?sessionId&path&as=text → fs.read-shaped JSON text
|
|
11
|
+
//
|
|
12
|
+
// The route matters: file activity records files the agent touched ANYWHERE
|
|
13
|
+
// (scratch files in /tmp, sibling repos, ~/.dsh), while the host's file
|
|
14
|
+
// provider is fenced to the session workspace. The plugin route authorizes
|
|
15
|
+
// exactly the paths this session recorded.
|
|
16
|
+
/** Image suffixes the browser renders natively. */
|
|
17
|
+
const IMAGE_EXT = /^(svg|png|jpe?g|gif|webp|avif|bmp|ico)$/i
|
|
18
|
+
/** Suffixes rendered as Markdown. */
|
|
19
|
+
const MARKDOWN_EXT = /^(md|markdown|mdx)$/i
|
|
20
|
+
/** Suffixes rendered as a sandboxed HTML document (issue #266 C). */
|
|
21
|
+
const HTML_EXT = /^(html?|xhtml)$/i
|
|
22
|
+
/** Lower-case suffix of a path ('' when it has none). extOf (rows.ts) works on
|
|
23
|
+
* a file NAME, so the basename is sliced off first. */
|
|
24
|
+
function extOfPath(path) {
|
|
25
|
+
return extOf(path.slice(path.lastIndexOf('/') + 1))
|
|
26
|
+
}
|
|
27
|
+
/** Official UI atoms come from the host's staticModules (zero install). */
|
|
28
|
+
function uiAtoms() {
|
|
29
|
+
try {
|
|
30
|
+
return require('@deepseek-ai/dsh-client-ui-primitives')
|
|
31
|
+
} catch {
|
|
32
|
+
return {}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Resolve a possibly-relative path against the session cwd. */
|
|
36
|
+
function resolvePath(path, cwd) {
|
|
37
|
+
if (typeof path !== 'string' || path === '') return path
|
|
38
|
+
if (path.startsWith('/')) return path
|
|
39
|
+
if (typeof cwd === 'string' && cwd !== '') return `${cwd.replace(/\/+$/, '')}/${path}`
|
|
40
|
+
return path
|
|
41
|
+
}
|
|
42
|
+
/** Whether the fs.read API response carries a text content payload. */
|
|
43
|
+
function isFsReadOk(json) {
|
|
44
|
+
return json !== null && typeof json === 'object' && json.ok === true && typeof json.value?.content === 'string'
|
|
45
|
+
}
|
|
46
|
+
/** Error load state from an fs.read API response (or a generic message).
|
|
47
|
+
* Raw system errors are translated to friendly, locale-aware messages
|
|
48
|
+
* (issue #68): deleted files and workspace-fenced paths must never surface
|
|
49
|
+
* ENOENT / "is outside workspace" verbatim. */
|
|
50
|
+
function fsReadError(json, viewer) {
|
|
51
|
+
const raw = json?.error?.message ?? ''
|
|
52
|
+
let message
|
|
53
|
+
if (raw === '') message = strings.previewFailed()
|
|
54
|
+
else if (/ENOENT|no such file|does not exist|cannot resolve/i.test(raw)) message = strings.fileMissing()
|
|
55
|
+
else if (/outside workspace/i.test(raw)) message = strings.fileOutside()
|
|
56
|
+
else message = raw
|
|
57
|
+
return { status: 'error', viewer, message }
|
|
58
|
+
}
|
|
59
|
+
/** Plugin text route (fs.read-shaped JSON), or null on any failure. */
|
|
60
|
+
async function fetchTextContent(sessionId, path) {
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetch(textUrlOf(sessionId, path))
|
|
63
|
+
return await response.json()
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Load the recorded file's text through the plugin's own route, falling back
|
|
70
|
+
* to the host sidebar route when the plugin refuses the path. Whatever the
|
|
71
|
+
* host viewer used to do with its own fetchStrategy, this window does the
|
|
72
|
+
* reading itself now — the routes are the same ones the previous
|
|
73
|
+
* implementation used for its fsRead strategy.
|
|
74
|
+
*/
|
|
75
|
+
async function loadFsReadContent(viewer, path, scope, sessionId) {
|
|
76
|
+
const cwd = scope === null || scope === undefined ? '' : scope.cwd
|
|
77
|
+
const target = resolvePath(path, cwd ?? '')
|
|
78
|
+
const own = await fetchTextContent(sessionId, target)
|
|
79
|
+
if (isFsReadOk(own)) return { status: 'ready', viewer, content: own.value.content }
|
|
80
|
+
try {
|
|
81
|
+
const response = await fetch('/sidebar/api/fs.read', {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'content-type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ sessionId, path: target }),
|
|
85
|
+
})
|
|
86
|
+
const json = await response.json()
|
|
87
|
+
if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
|
|
88
|
+
return fsReadError(json, viewer)
|
|
89
|
+
} catch {
|
|
90
|
+
return fsReadError(own, viewer)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Which renderer a recorded path gets. */
|
|
94
|
+
function viewerOf(path) {
|
|
95
|
+
const ext = extOfPath(path)
|
|
96
|
+
if (IMAGE_EXT.test(ext)) return 'image'
|
|
97
|
+
if (ext === 'pdf') return 'pdf'
|
|
98
|
+
if (HTML_EXT.test(ext)) return 'html'
|
|
99
|
+
if (MARKDOWN_EXT.test(ext)) return 'markdown'
|
|
100
|
+
return 'text'
|
|
101
|
+
}
|
|
102
|
+
/** Fetch the bytes the chosen renderer needs (media URL, or text content). */
|
|
103
|
+
async function fetchPreviewLoad(target) {
|
|
104
|
+
const viewer = { id: viewerOf(target.abs) }
|
|
105
|
+
// image / pdf / html are served as BYTES by the plugin's own media route;
|
|
106
|
+
// html goes through an iframe (see HtmlBody), not through the text path.
|
|
107
|
+
if (viewer.id === 'image' || viewer.id === 'pdf' || viewer.id === 'html') {
|
|
108
|
+
return { status: 'ready', viewer, mediaUrl: mediaUrlOf(target.sessionId, target.abs) }
|
|
109
|
+
}
|
|
110
|
+
return loadFsReadContent(viewer, target.abs, null, target.sessionId)
|
|
111
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// ── floating preview window (own renderer, shell.overlay seat) ─────────
|
|
3
|
+
//
|
|
4
|
+
// History: this window used to mount the third-party sidebar's built-in
|
|
5
|
+
// viewer and let it fetch its own bytes. That service is gone (issue #187
|
|
6
|
+
// batch 2), so the window renders the recorded file itself:
|
|
7
|
+
//
|
|
8
|
+
// image (svg/png/jpeg/gif/webp/avif/bmp/ico) → <img src> on the plugin media route
|
|
9
|
+
// pdf → <iframe src> on the same route
|
|
10
|
+
// markdown → host MarkdownText atom
|
|
11
|
+
// anything else → host CodeBlock atom, else <pre>
|
|
12
|
+
//
|
|
13
|
+
// The overlay lives in the root-scoped 'shell.overlay' list seat (the host's
|
|
14
|
+
// own floating layer: above every column, click-through unless the entry opts
|
|
15
|
+
// into pointer events) and renders nothing at all while no preview is open.
|
|
16
|
+
// Data access (routes, viewer choice, loading) lives in preview-data.ts.
|
|
17
|
+
/** Milliseconds after which an error-state preview closes itself (issue #76):
|
|
18
|
+
* a shell that failed to load any content is useless, so it must not linger
|
|
19
|
+
* over the main UI until the user finds the × button. */
|
|
20
|
+
const AUTO_CLOSE_MS = 2500
|
|
21
|
+
/** Whether a pointerdown target lies inside the floating window. The window
|
|
22
|
+
* surface carries the .dfa-fp class; anything else counts as "outside"
|
|
23
|
+
* and dismisses the preview (issue #76 — click anywhere outside closes). */
|
|
24
|
+
function isInsideFloating(target) {
|
|
25
|
+
if (!target || typeof target.closest !== 'function') return false
|
|
26
|
+
return target.closest('.dfa-fp') !== null
|
|
27
|
+
}
|
|
28
|
+
/** Click behavior for the window surface: in the error state ANY click
|
|
29
|
+
* closes the shell (there is no content to interact with), otherwise the
|
|
30
|
+
* click is swallowed so the viewer's own interactions keep working. */
|
|
31
|
+
function previewClickAction(load, event) {
|
|
32
|
+
if (load.status === 'error') return 'close'
|
|
33
|
+
if (event && event.stopPropagation) event.stopPropagation()
|
|
34
|
+
return 'stop'
|
|
35
|
+
}
|
|
36
|
+
/** Close the floating preview when the tab goes hidden (switching tabs /
|
|
37
|
+
* operating the main UI), so it never lingers over the interface. */
|
|
38
|
+
function closePreviewOnHidden(visible, dataStore) {
|
|
39
|
+
if (!visible) dataStore.set({ preview: null })
|
|
40
|
+
}
|
|
41
|
+
function usePreviewLoader(target) {
|
|
42
|
+
const [load, setLoad] = useState({ status: 'loading', viewer: null })
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
let cancelled = false
|
|
45
|
+
setLoad({ status: 'loading', viewer: { id: viewerOf(target.abs) } })
|
|
46
|
+
fetchPreviewLoad(target)
|
|
47
|
+
.then((next) => {
|
|
48
|
+
if (!cancelled) setLoad(next)
|
|
49
|
+
})
|
|
50
|
+
.catch((error) => {
|
|
51
|
+
if (!cancelled)
|
|
52
|
+
setLoad({
|
|
53
|
+
status: 'error',
|
|
54
|
+
viewer: null,
|
|
55
|
+
message: error instanceof Error ? error.message : String(error),
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
return () => {
|
|
59
|
+
cancelled = true
|
|
60
|
+
}
|
|
61
|
+
}, [target.abs, target.sessionId])
|
|
62
|
+
return load
|
|
63
|
+
}
|
|
64
|
+
/** Text body: the host's own Markdown / code atoms, with a plain <pre> fallback. */
|
|
65
|
+
function TextBody({ load, path }) {
|
|
66
|
+
const text = load.content ?? ''
|
|
67
|
+
const ui = uiAtoms()
|
|
68
|
+
if (load.viewer?.id === 'markdown' && typeof ui.MarkdownText === 'function') {
|
|
69
|
+
return createElement('div', { className: 'dfa-fp-md' }, createElement(ui.MarkdownText, { text }))
|
|
70
|
+
}
|
|
71
|
+
if (typeof ui.CodeBlock === 'function') {
|
|
72
|
+
return createElement(
|
|
73
|
+
'div',
|
|
74
|
+
{ className: 'dfa-fp-code' },
|
|
75
|
+
createElement(ui.CodeBlock, { code: text, language: extOfPath(path) }),
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return createElement('pre', { className: 'dfa-fp-pre' }, text)
|
|
79
|
+
}
|
|
80
|
+
/** Image body: the plugin's media route renders the recorded bytes. */
|
|
81
|
+
function ImageBody({ load, title }) {
|
|
82
|
+
return createElement('img', { className: 'dfa-fp-img', src: load.mediaUrl, alt: title })
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* HTML body: a SANDBOXED iframe over the plugin's media route (issue #266 C —
|
|
86
|
+
* the README promised a sandboxed iframe while the code rendered the markup as
|
|
87
|
+
* a code block).
|
|
88
|
+
*
|
|
89
|
+
* Two independent sandbox layers, deliberately without `allow-same-origin`:
|
|
90
|
+
* - the iframe's `sandbox` attribute gives the document an opaque origin, so
|
|
91
|
+
* a previewed page cannot read the host's storage, cookies or same-origin
|
|
92
|
+
* APIs, and cannot reach the parent document;
|
|
93
|
+
* - the route answers text/html with a response-level
|
|
94
|
+
* `Content-Security-Policy: sandbox …` header, so the SAME protection holds
|
|
95
|
+
* when the URL is opened directly instead of inside this iframe.
|
|
96
|
+
* Scripts/forms stay enabled so interactive documents (charts, demos) work;
|
|
97
|
+
* navigation of the top window is not granted.
|
|
98
|
+
*/
|
|
99
|
+
function HtmlBody({ load, title }) {
|
|
100
|
+
return createElement(
|
|
101
|
+
'div',
|
|
102
|
+
{ className: 'dfa-fp-html' },
|
|
103
|
+
createElement('iframe', {
|
|
104
|
+
className: 'dfa-fp-html-frame',
|
|
105
|
+
src: load.mediaUrl,
|
|
106
|
+
sandbox: 'allow-scripts allow-forms',
|
|
107
|
+
title,
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
/** Preview window body: loading note / error panel / renderer mount. */
|
|
112
|
+
function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
|
|
113
|
+
if (load.status === 'loading') {
|
|
114
|
+
return createElement('div', { className: 'dfa-fp-note' }, strings.loading())
|
|
115
|
+
}
|
|
116
|
+
if (load.status === 'error') {
|
|
117
|
+
return createElement(
|
|
118
|
+
'div',
|
|
119
|
+
{ className: 'dfa-fp-err' },
|
|
120
|
+
strings.previewFailed(),
|
|
121
|
+
load.message
|
|
122
|
+
? createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.85 } }, load.message)
|
|
123
|
+
: null,
|
|
124
|
+
createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.7 } }, strings.autoCloseHint()),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
const props = { load, path, title, sessionId }
|
|
128
|
+
if (load.viewer?.id === 'image') return ImageBody(props)
|
|
129
|
+
if (load.viewer?.id === 'html') return HtmlBody(props)
|
|
130
|
+
if (load.viewer?.id === 'pdf') {
|
|
131
|
+
const url = mediaUrlOf(sessionId, path)
|
|
132
|
+
return createElement(PdfPreview, { src: url, download: `${url}&download=1`, title })
|
|
133
|
+
}
|
|
134
|
+
return TextBody(props)
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Dismissal affordances (issue #76 — the preview must never linger):
|
|
138
|
+
* 1. pointerdown anywhere OUTSIDE the window closes it (capture phase, so
|
|
139
|
+
* it fires even if another element sits above the scrim);
|
|
140
|
+
* 2. the scrim's own onClick (kept as a fallback);
|
|
141
|
+
* 3. Escape;
|
|
142
|
+
* 4. the × button;
|
|
143
|
+
* 5. an error state closes itself after AUTO_CLOSE_MS (no content to show).
|
|
144
|
+
*/
|
|
145
|
+
function usePreviewDismiss(load, onClose) {
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
|
|
148
|
+
const handler = (event) => {
|
|
149
|
+
if (!isInsideFloating(event && event.target)) onClose()
|
|
150
|
+
}
|
|
151
|
+
document.addEventListener('pointerdown', handler, true)
|
|
152
|
+
return () => document.removeEventListener('pointerdown', handler, true)
|
|
153
|
+
}, [onClose])
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
|
|
156
|
+
const handler = (event) => {
|
|
157
|
+
if (event && event.key === 'Escape') onClose()
|
|
158
|
+
}
|
|
159
|
+
document.addEventListener('keydown', handler)
|
|
160
|
+
return () => document.removeEventListener('keydown', handler)
|
|
161
|
+
}, [onClose])
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (load.status !== 'error') return undefined
|
|
164
|
+
if (typeof window === 'undefined' || typeof window.setTimeout !== 'function') return undefined
|
|
165
|
+
const timer = window.setTimeout(onClose, AUTO_CLOSE_MS)
|
|
166
|
+
return () => window.clearTimeout(timer)
|
|
167
|
+
}, [load.status, onClose])
|
|
168
|
+
}
|
|
169
|
+
/** The floating window element: head (title + hint + close) and body. */
|
|
170
|
+
function renderFloatingWindow(load, ctx, store, scope, path, title, sessionId, onClose) {
|
|
171
|
+
return createElement(
|
|
172
|
+
'div',
|
|
173
|
+
{ className: 'dfa-fp-overlay', onClick: onClose },
|
|
174
|
+
createElement(
|
|
175
|
+
'div',
|
|
176
|
+
{
|
|
177
|
+
className: 'dfa-fp',
|
|
178
|
+
'data-dfa-preview': '1',
|
|
179
|
+
onClick: (event) => {
|
|
180
|
+
if (previewClickAction(load, event) === 'close') onClose()
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
createElement(
|
|
184
|
+
'div',
|
|
185
|
+
{ className: 'dfa-fp-head' },
|
|
186
|
+
createElement('span', { className: 'dfa-fp-title' }, title),
|
|
187
|
+
createElement('span', { className: 'dfa-fp-hint' }, strings.clickOutsideToClose()),
|
|
188
|
+
createElement(
|
|
189
|
+
'span',
|
|
190
|
+
{ className: 'dfa-fp-actions' },
|
|
191
|
+
createElement(
|
|
192
|
+
'button',
|
|
193
|
+
{
|
|
194
|
+
className: 'dfa-iconbtn',
|
|
195
|
+
title: strings.closePreview(),
|
|
196
|
+
'aria-label': strings.closePreview(),
|
|
197
|
+
onClick: () => onClose(),
|
|
198
|
+
},
|
|
199
|
+
icon.close(15),
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
createElement(
|
|
204
|
+
'div',
|
|
205
|
+
{ className: 'dfa-fp-body' },
|
|
206
|
+
renderPreviewBody(load, ctx, store, scope, path, title, sessionId),
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The floating preview, mounted by the root-scoped 'shell.overlay' list seat
|
|
213
|
+
* (the host's own floating layer: above every column, click-through unless the
|
|
214
|
+
* entry opts into pointer events). It renders nothing at all while no preview
|
|
215
|
+
* is open, so the layer stays invisible.
|
|
216
|
+
*/
|
|
217
|
+
function FloatingPreviewOverlay() {
|
|
218
|
+
const store = sharedStore()
|
|
219
|
+
const state = useSyncExternalStore(
|
|
220
|
+
store === null ? () => () => {} : store.subscribe,
|
|
221
|
+
store === null ? () => null : store.getSnapshot,
|
|
222
|
+
)
|
|
223
|
+
const preview = state === null || state === undefined ? null : state.preview
|
|
224
|
+
const sessionId = preview === null ? '' : preview.sessionId
|
|
225
|
+
// Hooks must run unconditionally: the loader is fed an empty target while
|
|
226
|
+
// the window is closed (it then never resolves a load, and nothing renders).
|
|
227
|
+
const target = preview ?? { abs: '', name: '', sessionId: '' }
|
|
228
|
+
const load = usePreviewLoader(target)
|
|
229
|
+
const onClose = () => {
|
|
230
|
+
if (store !== null) store.set({ preview: null })
|
|
231
|
+
}
|
|
232
|
+
usePreviewDismiss(load, onClose)
|
|
233
|
+
if (preview === null) return null
|
|
234
|
+
return renderFloatingWindow(load, undefined, null, null, preview.abs, preview.name, sessionId, onClose)
|
|
235
|
+
}
|
|
236
|
+
/** Register the overlay seat (list slot: additive, never replaces host UI). */
|
|
237
|
+
function registerPreviewOverlay(ctx, dataStore) {
|
|
238
|
+
void dataStore
|
|
239
|
+
ctx.effect(
|
|
240
|
+
() =>
|
|
241
|
+
ctx.slots.inject('shell.overlay', () =>
|
|
242
|
+
ctx.slots.register(
|
|
243
|
+
{ name: 'shell.overlay', id: PREVIEW_ID, order: 100, label: () => strings.title() },
|
|
244
|
+
FloatingPreviewOverlay,
|
|
245
|
+
),
|
|
246
|
+
),
|
|
247
|
+
'dsh-file-activity: preview overlay',
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Lightweight PDF preview. The recorded file often lives outside the session
|
|
252
|
+
* workspace, and the host's own PDF route is fenced to it — so the bytes come
|
|
253
|
+
* from the plugin's media route inside a native browser PDF frame, with a
|
|
254
|
+
* download fallback in its toolbar.
|
|
255
|
+
*/
|
|
256
|
+
function PdfPreview({ src, download, title }) {
|
|
257
|
+
return createElement(
|
|
258
|
+
'div',
|
|
259
|
+
{ className: 'dfa-pdf' },
|
|
260
|
+
createElement(
|
|
261
|
+
'div',
|
|
262
|
+
{ className: 'dfa-pdf-toolbar' },
|
|
263
|
+
createElement(
|
|
264
|
+
'a',
|
|
265
|
+
{
|
|
266
|
+
className: 'dfa-pdf-download',
|
|
267
|
+
href: download,
|
|
268
|
+
download: true,
|
|
269
|
+
title: strings.downloadToView(),
|
|
270
|
+
},
|
|
271
|
+
strings.downloadToView(),
|
|
272
|
+
),
|
|
273
|
+
),
|
|
274
|
+
createElement('iframe', { className: 'dfa-pdf-frame', src, title }),
|
|
275
|
+
)
|
|
276
|
+
}
|
package/lib/parts/rows.part.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
'use strict'
|
|
1
2
|
// ── row rendering helpers (recent list & stats tree) ──────────────────
|
|
2
3
|
const opClass = (op) =>
|
|
3
4
|
op === 'create'
|
|
@@ -15,7 +16,6 @@ const opLabel = (op) =>
|
|
|
15
16
|
: op === 'delete'
|
|
16
17
|
? strings.delete()
|
|
17
18
|
: strings.read()
|
|
18
|
-
|
|
19
19
|
/** Tooltip for a stats file row: absolute path + created / last-seen times. */
|
|
20
20
|
const fileTitle = (abs, firstSeen, lastSeen) => {
|
|
21
21
|
const times = []
|
|
@@ -23,7 +23,6 @@ const fileTitle = (abs, firstSeen, lastSeen) => {
|
|
|
23
23
|
if (typeof lastSeen === 'number') times.push(`${strings.lastSeen()} ${formatTime(lastSeen)}`)
|
|
24
24
|
return times.length > 0 ? `${abs}\n${times.join(' · ')}` : abs
|
|
25
25
|
}
|
|
26
|
-
|
|
27
26
|
/** Count pills for a file/dir node — only actions that actually happened are
|
|
28
27
|
* shown (a zero count renders no pill; all-zero nodes render no pill group,
|
|
29
28
|
* keeping untouched files visually quiet). */
|
|
@@ -42,7 +41,6 @@ const countPills = (node) => {
|
|
|
42
41
|
if (pills.length === 0) return null
|
|
43
42
|
return createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } }, ...pills)
|
|
44
43
|
}
|
|
45
|
-
|
|
46
44
|
/** Extension of a file name (lowercase, no leading dot); '' when none.
|
|
47
45
|
* Dotfiles map to their whole name ('.gitignore' → 'gitignore') so the
|
|
48
46
|
* badge table can cover them; 'notes.' still yields ''. */
|
|
@@ -52,14 +50,12 @@ const extOf = (name) => {
|
|
|
52
50
|
if (dot === 0) return name.slice(1).toLowerCase()
|
|
53
51
|
return ''
|
|
54
52
|
}
|
|
55
|
-
|
|
56
53
|
/** Extension-less but common build files → their badge key. */
|
|
57
54
|
const NAME_BADGES = {
|
|
58
55
|
makefile: 'makefile',
|
|
59
56
|
dockerfile: 'dockerfile',
|
|
60
57
|
'cmakelists.txt': 'cmake',
|
|
61
58
|
}
|
|
62
|
-
|
|
63
59
|
/** Badge key for a file name: basename match first, then extension. */
|
|
64
60
|
const badgeKeyOf = (name) => {
|
|
65
61
|
const base = name.toLowerCase()
|
|
@@ -67,7 +63,6 @@ const badgeKeyOf = (name) => {
|
|
|
67
63
|
if (named !== undefined) return named
|
|
68
64
|
return extOf(name)
|
|
69
65
|
}
|
|
70
|
-
|
|
71
66
|
/** A stats-tree file row: icon + name + count pills + relative time. */
|
|
72
67
|
const fileRow = (file, depth, onOpen) =>
|
|
73
68
|
createElement(
|
|
@@ -84,7 +79,6 @@ const fileRow = (file, depth, onOpen) =>
|
|
|
84
79
|
countPills(file),
|
|
85
80
|
file.lastSeen ? createElement('span', { className: 'dfa-time' }, formatRelative(file.lastSeen)) : null,
|
|
86
81
|
)
|
|
87
|
-
|
|
88
82
|
/** One stats-tree node: file rows render inline, dirs toggle collapse. */
|
|
89
83
|
function renderTreeNode(node, depth, collapsedDirs, onToggleDir, onOpen) {
|
|
90
84
|
if (node.type === 'file') return fileRow(node, depth, onOpen)
|
|
@@ -110,7 +104,6 @@ function renderTreeNode(node, depth, collapsedDirs, onToggleDir, onOpen) {
|
|
|
110
104
|
: node.children.map((child) => renderTreeNode(child, depth + 1, collapsedDirs, onToggleDir, onOpen)),
|
|
111
105
|
)
|
|
112
106
|
}
|
|
113
|
-
|
|
114
107
|
/** A recent-list row: op badge + basename + relative time. */
|
|
115
108
|
const recentEntry = (entry, onOpen) =>
|
|
116
109
|
createElement(
|
|
@@ -125,7 +118,6 @@ const recentEntry = (entry, onOpen) =>
|
|
|
125
118
|
createElement('span', { className: 'dfa-row-name' }, basenameOf(entry.path)),
|
|
126
119
|
createElement('span', { className: 'dfa-time' }, formatRelative(entry.time)),
|
|
127
120
|
)
|
|
128
|
-
|
|
129
121
|
/** Toggle a key in a Set (directory collapse state). */
|
|
130
122
|
function toggleInSet(set, key) {
|
|
131
123
|
const next = new Set(set)
|
|
@@ -133,7 +125,6 @@ function toggleInSet(set, key) {
|
|
|
133
125
|
else next.add(key)
|
|
134
126
|
return next
|
|
135
127
|
}
|
|
136
|
-
|
|
137
128
|
/** Clear the current session's records host-side and reset its bucket. */
|
|
138
129
|
function clearSessionData(dataStore, sessionId) {
|
|
139
130
|
if (!window.confirm(strings.clearConfirm())) return
|
|
@@ -146,7 +137,6 @@ function clearSessionData(dataStore, sessionId) {
|
|
|
146
137
|
},
|
|
147
138
|
})
|
|
148
139
|
}
|
|
149
|
-
|
|
150
140
|
/** Manual refresh: fetch stats + the authoritative cwd for this session. */
|
|
151
141
|
function refreshSessionData(dataStore, sessionId, setCwd, setError) {
|
|
152
142
|
if (sessionId === '') return
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// ── settings tab (replaces 迁移前的 settings.pluginToggles) ────
|
|
3
|
+
//
|
|
4
|
+
// The host renders no per-plugin toggle UI for third-party tabs, so this
|
|
5
|
+
// plugin contributes its own tab to the Web Settings → Plugins section
|
|
6
|
+
// ('settings.plugins.tab': a list seat declared by the settings section). The
|
|
7
|
+
// section only exists while it is mounted, so the seat is declared with
|
|
8
|
+
// ctx.slots.inject — the declarative form that survives HMR and late mounts
|
|
9
|
+
// (a plain register on a not-yet-mounted seat would silently contribute
|
|
10
|
+
// nothing).
|
|
11
|
+
/** Auto-open preference (plugin-owned; 迁移前的插件偏好已不存在). */
|
|
12
|
+
function autoOpenEnabled() {
|
|
13
|
+
try {
|
|
14
|
+
return window.localStorage.getItem(AUTO_OPEN_PREF_KEY) !== '0'
|
|
15
|
+
} catch {
|
|
16
|
+
return true
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Persist the auto-open preference. */
|
|
20
|
+
function setAutoOpenEnabled(enabled) {
|
|
21
|
+
try {
|
|
22
|
+
window.localStorage.setItem(AUTO_OPEN_PREF_KEY, enabled ? '1' : '0')
|
|
23
|
+
} catch {
|
|
24
|
+
// storage unavailable (private mode): the in-memory toggle still applies
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** One labeled switch row. */
|
|
28
|
+
function settingsRow(label, hint, checked, onChange) {
|
|
29
|
+
return createElement(
|
|
30
|
+
'label',
|
|
31
|
+
{ className: 'dfa-set-row' },
|
|
32
|
+
createElement(
|
|
33
|
+
'span',
|
|
34
|
+
{ className: 'dfa-set-text' },
|
|
35
|
+
createElement('span', { className: 'dfa-set-label' }, label),
|
|
36
|
+
createElement('span', { className: 'dfa-set-hint' }, hint),
|
|
37
|
+
),
|
|
38
|
+
createElement('input', {
|
|
39
|
+
type: 'checkbox',
|
|
40
|
+
className: 'dfa-set-switch',
|
|
41
|
+
checked,
|
|
42
|
+
onChange: (event) => onChange(event?.target?.checked === true),
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
/** Settings panel body: the auto-open switch plus its explanatory hint. */
|
|
47
|
+
function FileActivitySettings() {
|
|
48
|
+
const [enabled, setEnabled] = useState(autoOpenEnabled)
|
|
49
|
+
return createElement(
|
|
50
|
+
'div',
|
|
51
|
+
{ className: 'dfa-set', 'data-dfa-settings': '1' },
|
|
52
|
+
createElement('div', { className: 'dfa-set-title' }, strings.title()),
|
|
53
|
+
settingsRow(strings.autoOpenLabel(), strings.autoOpenHint(), enabled, (next) => {
|
|
54
|
+
setAutoOpenEnabled(next)
|
|
55
|
+
setEnabled(next)
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
/** Register the settings tab into the settings section's list seat. */
|
|
60
|
+
function registerSettingsTab(ctx) {
|
|
61
|
+
ctx.effect(
|
|
62
|
+
() =>
|
|
63
|
+
ctx.slots.inject('settings.plugins.tab', () =>
|
|
64
|
+
ctx.slots.register(
|
|
65
|
+
{ name: 'settings.plugins.tab', id: TAB_ID, order: 60, label: () => strings.title() },
|
|
66
|
+
FileActivitySettings,
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
'dsh-file-activity: settings tab',
|
|
70
|
+
)
|
|
71
|
+
}
|
package/lib/parts/store.part.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
'use strict'
|
|
1
2
|
// ── tiny external store ───────────────────────────────────────────────
|
|
2
3
|
function createStore(initial) {
|
|
3
4
|
let state = initial
|
|
@@ -14,3 +15,21 @@ function createStore(initial) {
|
|
|
14
15
|
},
|
|
15
16
|
}
|
|
16
17
|
}
|
|
18
|
+
// ── shared store slot ─────────────────────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// The tab body renders inside a session-scoped seat while the floating preview
|
|
21
|
+
// renders inside the root-scoped 'shell.overlay' seat, so the store the two
|
|
22
|
+
// halves share cannot travel as a prop from one to the other. One module-level
|
|
23
|
+
// reference is enough: the overlay seat is mounted for the plugin's whole
|
|
24
|
+
// lifetime and only ever reads the CURRENT store, and a rebuild replaces the
|
|
25
|
+
// reference before the new overlay renders.
|
|
26
|
+
/** The store the overlay seat reads; set once per activation. */
|
|
27
|
+
let sharedDataStore = null
|
|
28
|
+
/** Publish the activation's store for the root-scoped overlay seat. */
|
|
29
|
+
function registerSharedStore(store) {
|
|
30
|
+
sharedDataStore = store
|
|
31
|
+
}
|
|
32
|
+
/** The activation's store, or null before apply() ran. */
|
|
33
|
+
function sharedStore() {
|
|
34
|
+
return sharedDataStore
|
|
35
|
+
}
|
package/lib/parts/styles.part.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
|
+
'use strict'
|
|
1
2
|
// ── themed stylesheet (injected once per activation) ──────────────────
|
|
2
|
-
// Mirrors the
|
|
3
|
+
// Mirrors the host explorer surface: tight 2px 6px 8px body,
|
|
3
4
|
// 30px rows, box-sizing border-box indentation, folder rows use the
|
|
4
5
|
// strong type face to read as directories, files stay regular.
|
|
6
|
+
/**
|
|
7
|
+
* Inject this activation's stylesheet into the document head exactly once per
|
|
8
|
+
* fiber. Static CSS only: it must not sit behind any service check, or an HMR
|
|
9
|
+
* rebuild / service reload can leave an already-rendered tab unstyled. The
|
|
10
|
+
* disposer removes only this fiber's own <style> element, so a rebuild always
|
|
11
|
+
* keeps at least one copy.
|
|
12
|
+
*/
|
|
13
|
+
function injectStyles(ctx) {
|
|
14
|
+
ctx.effect(() => {
|
|
15
|
+
if (typeof document === 'undefined' || document === null || typeof document.head === 'undefined') return () => {}
|
|
16
|
+
const style = document.createElement('style')
|
|
17
|
+
style.setAttribute('data-dsh-file-activity', 'styles')
|
|
18
|
+
style.textContent = STYLES
|
|
19
|
+
document.head.appendChild(style)
|
|
20
|
+
return () => {
|
|
21
|
+
if (style.parentNode) style.parentNode.removeChild(style)
|
|
22
|
+
}
|
|
23
|
+
}, 'dsh-file-activity: styles')
|
|
24
|
+
}
|
|
5
25
|
const STYLES = `
|
|
6
26
|
.dfa { display:flex; flex-direction:column; height:100%; overflow-y:auto; overflow-x:hidden;
|
|
7
27
|
padding:2px 6px 8px; gap:2px; font:var(--dsw-font-s-14); color:var(--dsw-alias-label-primary); }
|
|
@@ -65,7 +85,7 @@ const STYLES = `
|
|
|
65
85
|
.dfa-fp-hint { flex:none; font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-tertiary); opacity:0.8; }
|
|
66
86
|
.dfa-fp-actions { display:flex; align-items:center; gap:2px; flex:none; }
|
|
67
87
|
/* issue #111: the preview body is a FLEX COLUMN so the mounted viewer
|
|
68
|
-
component (
|
|
88
|
+
component (the host's TextEditor for html/code/markdown, the image
|
|
69
89
|
wrap, and the pdf frame) actually fills the window. Their roots size via
|
|
70
90
|
flex:1 (html iframe .editorHtml, .editorCm, .editorMd, .editorImageWrap,
|
|
71
91
|
.dfa-pdf), which is IGNORED in a block context — so the HTML iframe, which
|
|
@@ -86,5 +106,31 @@ const STYLES = `
|
|
|
86
106
|
@keyframes dfa-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
|
|
87
107
|
/* issue #60: 移除 #25 的侧边栏页签选中态品牌蓝覆盖([class*="tab"][class*=
|
|
88
108
|
"tabActive"] 全局子串选择器误伤宿主对话/工作区 tab 选中态,出现用户不
|
|
89
|
-
|
|
109
|
+
想要的蓝色高亮)。页签选中态回归宿主原生样式(issue #187 批 2:原生
|
|
110
|
+
dockkit 页签自己负责选中态,本插件不再覆盖)。 */
|
|
111
|
+
/* issue #187 批 2:页签 chip 由本插件的 title 席位渲染(原生 chip 无图标
|
|
112
|
+
API),因此标题连同图标一起画在这里。 */
|
|
113
|
+
.dfa-chip { display:inline-flex; align-items:center; gap:4px; min-width:0; }
|
|
114
|
+
/* 扩展点注册失败的显式提示(绝不静默失效)。 */
|
|
115
|
+
.dfa-degraded { margin:6px 2px; padding:6px 8px; border-radius:6px;
|
|
116
|
+
border:1px solid var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); }
|
|
117
|
+
.dfa-degraded-title { font:var(--dsw-font-xs-13); color:var(--dsw-alias-state-error-primary); }
|
|
118
|
+
.dfa-degraded-hint { margin-top:2px; font:var(--dsw-font-xxs-12); opacity:.8; }
|
|
119
|
+
/* 设置页(settings.plugins.tab):开关行。 */
|
|
120
|
+
.dfa-set { display:flex; flex-direction:column; gap:10px; padding:10px 2px; }
|
|
121
|
+
.dfa-set-title { font:var(--dsw-font-sm-14); }
|
|
122
|
+
.dfa-set-row { display:flex; align-items:center; justify-content:space-between; gap:12px; cursor:pointer; }
|
|
123
|
+
.dfa-set-text { display:flex; flex-direction:column; gap:2px; min-width:0; }
|
|
124
|
+
.dfa-set-label { font:var(--dsw-font-xs-13); }
|
|
125
|
+
.dfa-set-hint { font:var(--dsw-font-xxs-12); opacity:.75; }
|
|
126
|
+
.dfa-set-switch { flex:none; width:16px; height:16px; accent-color:var(--dsw-alias-accent); cursor:pointer; }
|
|
127
|
+
/* 浮窗正文:Markdown / 代码 / 纯文本 / 图片四种渲染体。 */
|
|
128
|
+
.dfa-fp-md { height:100%; overflow:auto; padding:2px 4px; }
|
|
129
|
+
.dfa-fp-code { height:100%; overflow:auto; }
|
|
130
|
+
.dfa-fp-pre { margin:0; padding:8px 10px; overflow:auto; height:100%; font:var(--dsw-font-xxs-12);
|
|
131
|
+
white-space:pre-wrap; word-break:break-word; }
|
|
132
|
+
.dfa-fp-img { display:block; margin:auto; max-width:100%; max-height:100%; object-fit:contain; }
|
|
133
|
+
/* HTML 预览(issue #266 C):沙箱 iframe 撑满浮窗主体并内部滚动(与 PDF 一致)。 */
|
|
134
|
+
.dfa-fp-html { display:flex; flex-direction:column; width:100%; height:100%; }
|
|
135
|
+
.dfa-fp-html-frame { flex:1; min-height:0; width:100%; border:none; border-radius:6px; background:#fff; }
|
|
90
136
|
`
|
package/lib/parts/tree.part.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
'use strict'
|
|
1
2
|
// ── directory tree construction ───────────────────────────────────────
|
|
2
3
|
/**
|
|
3
4
|
* Collapse chain directories: a directory whose only child is another
|
|
@@ -18,7 +19,6 @@ function compressChains(node, isRoot) {
|
|
|
18
19
|
node.compressed = true
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
|
-
|
|
22
22
|
/**
|
|
23
23
|
* Sort a directory node: directories first (alphabetically), then files
|
|
24
24
|
* (by total activity, then name); recurse into directories.
|
|
@@ -35,7 +35,6 @@ function sortNode(node) {
|
|
|
35
35
|
if (child.type === 'dir') sortNode(child)
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
-
|
|
39
38
|
/**
|
|
40
39
|
* Build a nested directory tree from per-file counts, keyed by the file's
|
|
41
40
|
* absolute path. Every directory node aggregates its subtree counters and
|