dsh-plugin-workbench 0.0.4 → 0.0.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 +65 -2
- package/README.md +29 -7
- package/lib/client.js +6770 -117
- package/lib/client.js.map +1 -1
- package/lib/index.js +467 -7
- package/package.json +2 -1
- package/src/client/FileExplorer.tsx +740 -13
- package/src/client/FilePreview.tsx +279 -17
- package/src/client/files.module.css +389 -3
- package/src/client/highlight.ts +18 -1
- package/src/client/index.ts +27 -4
- package/src/client/locales.ts +72 -0
- package/src/client/markdown.ts +69 -0
- package/src/client/store.ts +157 -2
- package/src/dsh.d.ts +9 -0
- package/src/index.ts +601 -10
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown rendering for the file preview.
|
|
3
|
+
*
|
|
4
|
+
* markdown-it with raw HTML disabled (escaped, never executed — same
|
|
5
|
+
* no-unsafe-HTML stance as every other preview in this plugin), linkified
|
|
6
|
+
* text, and fenced code blocks highlighted through the highlight.js languages
|
|
7
|
+
* registered in ./highlight. Relative image srcs are rewritten onto the
|
|
8
|
+
* plugin's raw-bytes route (the same one image tabs use), resolved against the
|
|
9
|
+
* markdown file's own directory, so README images render next to their file.
|
|
10
|
+
*/
|
|
11
|
+
import MarkdownIt from 'markdown-it'
|
|
12
|
+
import { canHighlight, highlightFence } from './highlight'
|
|
13
|
+
|
|
14
|
+
/** Same-origin raw-bytes route registered by the host half (see src/index.ts). */
|
|
15
|
+
const RAW_PREFIX = '/dsh-plugin-files/raw'
|
|
16
|
+
|
|
17
|
+
/** srcs that pass through unchanged: scheme URLs, anchors, protocol-relative. */
|
|
18
|
+
const ABSOLUTE_SRC = /^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i
|
|
19
|
+
|
|
20
|
+
const md = new MarkdownIt({
|
|
21
|
+
// Raw HTML in the source is escaped, never rendered (XSS containment).
|
|
22
|
+
html: false,
|
|
23
|
+
linkify: true,
|
|
24
|
+
typographer: true,
|
|
25
|
+
highlight: (code: string, lang: string): string => {
|
|
26
|
+
// Empty return lets markdown-it apply its own default escaping.
|
|
27
|
+
if (!canHighlight(lang)) return ''
|
|
28
|
+
return `<pre class="hljs"><code>${highlightFence(code, lang)}</code></pre>`
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Relative image srcs resolve against the md file's directory and are served
|
|
33
|
+
// through the raw-bytes route; absolute URLs / data URIs pass through.
|
|
34
|
+
const defaultImage = md.renderer.rules.image
|
|
35
|
+
md.renderer.rules.image = (tokens, idx, options, env, self) => {
|
|
36
|
+
const token = tokens[idx]
|
|
37
|
+
const src = String(token.attrGet('src') ?? '')
|
|
38
|
+
const base = typeof env === 'object' && env !== null && typeof (env as { base?: unknown }).base === 'string'
|
|
39
|
+
? (env as { base: string }).base
|
|
40
|
+
: ''
|
|
41
|
+
if (src !== '' && !ABSOLUTE_SRC.test(src) && base !== '') {
|
|
42
|
+
const resolved = base.endsWith('/') || base.endsWith('\\') ? base + src : `${base}/${src}`
|
|
43
|
+
token.attrSet('src', `${RAW_PREFIX}/${encodeURIComponent(resolved)}`)
|
|
44
|
+
}
|
|
45
|
+
return defaultImage(tokens, idx, options, env, self)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// markdown-it does not sanitize link protocols: a hostile .md file could carry
|
|
49
|
+
// `[x](javascript:…)`. Follow markdown-it's default policy — reject the
|
|
50
|
+
// vbscript/javascript/file/data schemes (data:image/* stays allowed so data-URI
|
|
51
|
+
// images keep working); relative URLs pass through unchanged, and rejected
|
|
52
|
+
// links render as inert text. Raw HTML is escaped above (html: false), so no
|
|
53
|
+
// script/iframe can pass through either.
|
|
54
|
+
const BAD_LINK_PROTOCOL = /^(?:vbscript|javascript|file|data):/i
|
|
55
|
+
const GOOD_DATA_IMAGE = /^data:image\/(?:gif|png|jpeg|webp);/i
|
|
56
|
+
md.validateLink = (url: string): boolean => {
|
|
57
|
+
const trimmed = url.trim()
|
|
58
|
+
return !BAD_LINK_PROTOCOL.test(trimmed) || GOOD_DATA_IMAGE.test(trimmed)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render markdown content to safe HTML.
|
|
63
|
+
* @param content - raw markdown text.
|
|
64
|
+
* @param baseDir - directory of the source file, used to resolve relative
|
|
65
|
+
* image paths ('' when there is none).
|
|
66
|
+
*/
|
|
67
|
+
export function renderMarkdown(content: string, baseDir: string): string {
|
|
68
|
+
return md.render(content, { base: baseDir })
|
|
69
|
+
}
|
package/src/client/store.ts
CHANGED
|
@@ -10,10 +10,37 @@ import { useSyncExternalStore } from 'react'
|
|
|
10
10
|
|
|
11
11
|
export type FeTheme = 'light' | 'dark'
|
|
12
12
|
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Undo history (Copy / Move / Rename / New / Delete)
|
|
15
|
+
//
|
|
16
|
+
// One stack PER WORKSPACE, mirroring the tabs: undoing in workspace B never
|
|
17
|
+
// touches workspace A's file operations. Delete is reversible by design —
|
|
18
|
+
// deleting RENAMES the item into a hidden `.dsh-trash` folder next to it, and
|
|
19
|
+
// undo renames it back (no bytes are ever copied). When a stack overflows the
|
|
20
|
+
// oldest entry is evicted; the caller permanently purges evicted delete
|
|
21
|
+
// entries' trash items so the trash folder stays bounded.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** How many operations each workspace remembers (older ones are dropped). */
|
|
25
|
+
export const UNDO_LIMIT = 30
|
|
26
|
+
|
|
27
|
+
export type UndoEntry =
|
|
28
|
+
/** copy/paste (duplicate) — undo removes the copy. */
|
|
29
|
+
| { kind: 'copy'; label: string; from: string; to: string; parent: string }
|
|
30
|
+
/** cut-paste or drag-move — undo renames the item back. */
|
|
31
|
+
| { kind: 'move'; label: string; from: string; to: string; parent: string }
|
|
32
|
+
/** rename — undo renames back. */
|
|
33
|
+
| { kind: 'rename'; label: string; from: string; to: string; parent: string }
|
|
34
|
+
/** new file / new folder — undo trashes it. */
|
|
35
|
+
| { kind: 'create'; label: string; path: string; parent: string }
|
|
36
|
+
/** delete (moved into .dsh-trash) — undo renames it back. */
|
|
37
|
+
| { kind: 'delete'; label: string; path: string; trash: string; parent: string }
|
|
38
|
+
|
|
13
39
|
interface PerWorkspace {
|
|
14
40
|
tabs: string[]
|
|
15
41
|
active: string | undefined
|
|
16
42
|
collapsed: boolean
|
|
43
|
+
undo: UndoEntry[]
|
|
17
44
|
}
|
|
18
45
|
|
|
19
46
|
export interface TabsStateView {
|
|
@@ -24,10 +51,13 @@ export interface TabsStateView {
|
|
|
24
51
|
wrap: boolean
|
|
25
52
|
theme: FeTheme
|
|
26
53
|
cwd: string | undefined
|
|
54
|
+
/** Undoable file operations, most recent last. */
|
|
55
|
+
undo: UndoEntry[]
|
|
27
56
|
}
|
|
28
57
|
|
|
29
58
|
const EMPTY_TABS: string[] = []
|
|
30
|
-
const
|
|
59
|
+
const EMPTY_UNDO: UndoEntry[] = []
|
|
60
|
+
const EMPTY_WORKSPACE: PerWorkspace = { tabs: EMPTY_TABS, active: undefined, collapsed: false, undo: EMPTY_UNDO }
|
|
31
61
|
|
|
32
62
|
const WRAP_KEY = 'dsh-plugin-workbench:wrap'
|
|
33
63
|
|
|
@@ -51,7 +81,7 @@ interface StoreState {
|
|
|
51
81
|
|
|
52
82
|
const initialWrap = storedWrap()
|
|
53
83
|
let state: StoreState = { currentCwd: undefined, workspaces: {}, theme: 'dark', wrap: initialWrap }
|
|
54
|
-
let view: TabsStateView = { tabs: EMPTY_TABS, active: undefined, collapsed: false, wrap: initialWrap, theme: 'dark', cwd: undefined }
|
|
84
|
+
let view: TabsStateView = { tabs: EMPTY_TABS, active: undefined, collapsed: false, wrap: initialWrap, theme: 'dark', cwd: undefined, undo: EMPTY_UNDO }
|
|
55
85
|
const listeners = new Set<() => void>()
|
|
56
86
|
|
|
57
87
|
function workspaceOf(cwd: string | undefined): PerWorkspace {
|
|
@@ -83,6 +113,7 @@ function commit(next: StoreState): void {
|
|
|
83
113
|
wrap: state.wrap,
|
|
84
114
|
theme: state.theme,
|
|
85
115
|
cwd: state.currentCwd,
|
|
116
|
+
undo: ws.undo,
|
|
86
117
|
}
|
|
87
118
|
if (
|
|
88
119
|
nextView.tabs === view.tabs
|
|
@@ -91,6 +122,7 @@ function commit(next: StoreState): void {
|
|
|
91
122
|
&& nextView.wrap === view.wrap
|
|
92
123
|
&& nextView.theme === view.theme
|
|
93
124
|
&& nextView.cwd === view.cwd
|
|
125
|
+
&& nextView.undo === view.undo
|
|
94
126
|
) return
|
|
95
127
|
view = nextView
|
|
96
128
|
for (const listener of [...listeners]) listener()
|
|
@@ -140,6 +172,31 @@ export function activateFile(path: string): void {
|
|
|
140
172
|
updateCurrent((ws) => (ws.active === path || !ws.tabs.includes(path) ? ws : { ...ws, active: path }))
|
|
141
173
|
}
|
|
142
174
|
|
|
175
|
+
/** Point any open tab at a new path after a rename (the disk path changed). */
|
|
176
|
+
export function retargetFile(oldPath: string, newPath: string): void {
|
|
177
|
+
updateCurrent((ws) => {
|
|
178
|
+
if (!ws.tabs.includes(oldPath)) return ws
|
|
179
|
+
const tabs = ws.tabs.map((t) => (t === oldPath ? newPath : t))
|
|
180
|
+
return { ...ws, tabs, active: ws.active === oldPath ? newPath : ws.active }
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Close every open tab at or under a path (used after deleting it). */
|
|
185
|
+
export function closeFilesUnder(path: string): void {
|
|
186
|
+
updateCurrent((ws) => {
|
|
187
|
+
const sep = path.includes('\\') ? '\\' : '/'
|
|
188
|
+
const prefix = path.endsWith('\\') || path.endsWith('/') ? path : path + sep
|
|
189
|
+
const kept = ws.tabs.filter((t) => t !== path && !t.startsWith(prefix))
|
|
190
|
+
if (kept.length === ws.tabs.length) return ws
|
|
191
|
+
let active = ws.active
|
|
192
|
+
if (active !== undefined && (active === path || active.startsWith(prefix))) {
|
|
193
|
+
const index = ws.tabs.indexOf(active)
|
|
194
|
+
active = kept[Math.min(index, kept.length - 1)]
|
|
195
|
+
}
|
|
196
|
+
return { ...ws, tabs: kept, active }
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
143
200
|
/** Move a tab before another tab (drag-to-reorder). */
|
|
144
201
|
export function moveTab(dragged: string, target: string): void {
|
|
145
202
|
updateCurrent((ws) => {
|
|
@@ -179,3 +236,101 @@ export function toggleWrap(): void {
|
|
|
179
236
|
}
|
|
180
237
|
commit({ ...state, wrap })
|
|
181
238
|
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// File clipboard (Copy / Cut + Paste)
|
|
242
|
+
//
|
|
243
|
+
// Lives OUTSIDE the per-workspace records on purpose: copying in workspace A
|
|
244
|
+
// and pasting into workspace B is the whole point, so the clipboard survives
|
|
245
|
+
// workspace switches. Cut keeps the sources alive until a paste moves them
|
|
246
|
+
// (or Escape downgrades the cut back to a copy), mirroring the OS explorer.
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
export type ClipboardMode = 'copy' | 'cut'
|
|
250
|
+
|
|
251
|
+
export interface ClipboardItem {
|
|
252
|
+
path: string
|
|
253
|
+
name: string
|
|
254
|
+
kind: 'file' | 'dir' | 'other'
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface ClipboardView {
|
|
258
|
+
items: ClipboardItem[]
|
|
259
|
+
mode: ClipboardMode
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const EMPTY_CLIPBOARD: ClipboardView = { items: [], mode: 'copy' }
|
|
263
|
+
|
|
264
|
+
let clipboard: ClipboardView = EMPTY_CLIPBOARD
|
|
265
|
+
const clipboardListeners = new Set<() => void>()
|
|
266
|
+
|
|
267
|
+
export function getClipboard(): ClipboardView {
|
|
268
|
+
return clipboard
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function subscribeClipboard(listener: () => void): () => void {
|
|
272
|
+
clipboardListeners.add(listener)
|
|
273
|
+
return () => {
|
|
274
|
+
clipboardListeners.delete(listener)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Clipboard contents shared across every workspace (Explorer semantics). */
|
|
279
|
+
export function useClipboard(): ClipboardView {
|
|
280
|
+
return useSyncExternalStore(subscribeClipboard, getClipboard)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function commitClipboard(next: ClipboardView): void {
|
|
284
|
+
if (next === clipboard) return
|
|
285
|
+
clipboard = next
|
|
286
|
+
for (const listener of [...clipboardListeners]) listener()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Put entries on the clipboard, replacing whatever was there. */
|
|
290
|
+
export function copyToClipboard(items: ClipboardItem[], mode: ClipboardMode): void {
|
|
291
|
+
if (items.length === 0) return
|
|
292
|
+
commitClipboard({ items, mode })
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Drop the clipboard contents (after a cut-paste, or via the clear button). */
|
|
296
|
+
export function clearClipboard(): void {
|
|
297
|
+
if (clipboard === EMPTY_CLIPBOARD) return
|
|
298
|
+
commitClipboard(EMPTY_CLIPBOARD)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Escape in the source workspace: cancel an armed cut without dropping the items. */
|
|
302
|
+
export function cancelCut(): void {
|
|
303
|
+
if (clipboard.mode === 'cut') commitClipboard({ ...clipboard, mode: 'copy' })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Undo stack (see the types at the top of this file)
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Append an operation to the current workspace's undo stack, capped at
|
|
312
|
+
* UNDO_LIMIT entries. Returns the evicted oldest entry (the caller purges its
|
|
313
|
+
* trash item when it was a delete), or undefined when nothing was evicted.
|
|
314
|
+
*/
|
|
315
|
+
export function pushUndo(entry: UndoEntry): UndoEntry | undefined {
|
|
316
|
+
const cwd = state.currentCwd
|
|
317
|
+
if (cwd === undefined) return undefined
|
|
318
|
+
const prev = state.workspaces[cwd] ?? EMPTY_WORKSPACE
|
|
319
|
+
const undo = [...prev.undo, entry]
|
|
320
|
+
let evicted: UndoEntry | undefined
|
|
321
|
+
if (undo.length > UNDO_LIMIT) evicted = undo.shift()
|
|
322
|
+
commit({ ...state, workspaces: { ...state.workspaces, [cwd]: { ...prev, undo } } })
|
|
323
|
+
return evicted
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Remove and return the current workspace's most recent operation (or undefined). */
|
|
327
|
+
export function popUndo(): UndoEntry | undefined {
|
|
328
|
+
const cwd = state.currentCwd
|
|
329
|
+
if (cwd === undefined) return undefined
|
|
330
|
+
const prev = state.workspaces[cwd] ?? EMPTY_WORKSPACE
|
|
331
|
+
if (prev.undo.length === 0) return undefined
|
|
332
|
+
const undo = [...prev.undo]
|
|
333
|
+
const entry = undo.pop()
|
|
334
|
+
commit({ ...state, workspaces: { ...state.workspaces, [cwd]: { ...prev, undo } } })
|
|
335
|
+
return entry
|
|
336
|
+
}
|
package/src/dsh.d.ts
CHANGED
|
@@ -55,8 +55,17 @@ declare module '@deepseek-ai/cordis' {
|
|
|
55
55
|
stat(target: unknown, signal?: AbortSignal): Promise<{ version: unknown; type: 'file' | 'directory' | 'other'; size?: number } | undefined>
|
|
56
56
|
listDir(target: unknown, signal?: AbortSignal): Promise<Array<{ name: string; type: 'file' | 'directory' | 'other'; target: unknown; version?: unknown; size?: number }>>
|
|
57
57
|
readText(target: unknown, signal?: AbortSignal): Promise<string>
|
|
58
|
+
readBytes(target: unknown, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>
|
|
58
59
|
writeText(target: unknown, content: string, version?: unknown, signal?: AbortSignal, options?: { mode?: string; workspaceRoot?: string }): Promise<void>
|
|
59
60
|
processPath(target: unknown): string
|
|
60
61
|
}
|
|
62
|
+
webServer: {
|
|
63
|
+
/** Register a named HTTP route (exact path or prefix). Returns the disposer. */
|
|
64
|
+
register(route: {
|
|
65
|
+
kind: 'exact' | 'prefix'
|
|
66
|
+
path: string
|
|
67
|
+
handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void | Promise<void>
|
|
68
|
+
}): () => void
|
|
69
|
+
}
|
|
61
70
|
}
|
|
62
71
|
}
|