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.
@@ -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
+ }
@@ -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 EMPTY_WORKSPACE: PerWorkspace = { tabs: EMPTY_TABS, active: undefined, collapsed: false }
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
  }