dsh-plugin-workbench 0.0.5 → 0.0.7

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.
@@ -11,22 +11,43 @@ export const zh = {
11
11
  'action.saveHint': 'Ctrl+S 保存',
12
12
  'action.wrapOn': '开启自动换行(显示层换行,不改文件)',
13
13
  'action.wrapOff': '关闭自动换行(长行横向滚动)',
14
+ 'action.mdRender': '渲染预览(Markdown)',
15
+ 'action.mdSource': '查看 Markdown 源码',
14
16
  'tab.close': '关闭标签页',
15
17
  'tab.collapse': '收起文件详情',
16
18
  'tab.expand': '弹出文件详情',
19
+ 'tab.diskChanged': '文件已在磁盘上被修改,点击重新加载(会放弃未保存的编辑)',
17
20
  'menu.open': '打开预览',
18
21
  'menu.newFile': '新建文件',
19
22
  'menu.newFolder': '新建文件夹',
20
23
  'menu.rename': '重命名',
21
24
  'menu.delete': '删除',
22
25
  'menu.copyPath': '复制路径',
26
+ 'menu.copy': '复制',
27
+ 'menu.cut': '剪切',
28
+ 'menu.paste': '粘贴',
23
29
  'menu.refresh': '刷新',
24
30
  'menu.openSystem': '在系统中打开',
31
+ 'menu.revealInExplorer': '在资源管理器打开',
32
+ 'menu.deleteSelected': '删除选中的 {count} 项',
33
+ 'action.undo': '撤销(Ctrl+Z)',
34
+ 'undo.copy': '复制「{name}」',
35
+ 'undo.move': '移动「{name}」',
36
+ 'undo.rename': '重命名「{name}」',
37
+ 'undo.create': '新建「{name}」',
38
+ 'undo.delete': '删除「{name}」',
39
+ 'error.reveal': '在资源管理器打开失败',
25
40
  'prompt.newFileName': '新文件名',
26
41
  'prompt.newFolderName': '新文件夹名',
27
42
  'prompt.renameTo': '重命名为',
28
- 'confirm.deleteFile': '确定删除文件「{name}」吗?',
29
- 'confirm.deleteDir': '确定删除文件夹「{name}」及其全部内容吗?',
43
+ 'confirm.deleteFile': '确定删除文件「{name}」吗?(可撤销)',
44
+ 'confirm.deleteDir': '确定删除文件夹「{name}」及其全部内容吗?(可撤销)',
45
+ 'confirm.deleteSelected': '确定删除选中的 {count} 项吗?{names}(可撤销)',
46
+ 'confirm.overwrite': '「{name}」已存在,是否覆盖?',
47
+ 'clipboard.copied': '已复制 {count} 项',
48
+ 'clipboard.cut': '已剪切 {count} 项',
49
+ 'clipboard.pasteHint': ' · Ctrl+V 粘贴到当前目录',
50
+ 'clipboard.clear': '清除剪贴板',
30
51
  'preview.loading': '加载中…',
31
52
  'preview.binary': '二进制文件,无法预览',
32
53
  'preview.imageFailed': '图片加载失败',
@@ -34,6 +55,7 @@ export const zh = {
34
55
  'preview.emptyDir': '空目录',
35
56
  'preview.error': '加载失败',
36
57
  'preview.highlightOff': '大文件:已关闭语法高亮(仍可编辑)',
58
+ 'preview.mdRenderOff': '大文件:已切换为源码视图(仍可编辑)',
37
59
  } as const
38
60
 
39
61
  export type FilesKey = keyof typeof zh
@@ -47,22 +69,43 @@ export const en: Record<FilesKey, string> = {
47
69
  'action.saveHint': 'Ctrl+S to save',
48
70
  'action.wrapOn': 'Enable word wrap (display only, file unchanged)',
49
71
  'action.wrapOff': 'Disable word wrap (long lines scroll horizontally)',
72
+ 'action.mdRender': 'Rendered preview',
73
+ 'action.mdSource': 'Show markdown source',
50
74
  'tab.close': 'Close tab',
51
75
  'tab.collapse': 'Collapse file details',
52
76
  'tab.expand': 'Expand file details',
77
+ 'tab.diskChanged': 'File changed on disk — click to reload (discards unsaved edits)',
53
78
  'menu.open': 'Open preview',
54
79
  'menu.newFile': 'New File',
55
80
  'menu.newFolder': 'New Folder',
56
81
  'menu.rename': 'Rename',
57
82
  'menu.delete': 'Delete',
58
83
  'menu.copyPath': 'Copy Path',
84
+ 'menu.copy': 'Copy',
85
+ 'menu.cut': 'Cut',
86
+ 'menu.paste': 'Paste',
59
87
  'menu.refresh': 'Refresh',
60
88
  'menu.openSystem': 'Open in System',
89
+ 'menu.revealInExplorer': 'Reveal in Explorer',
90
+ 'menu.deleteSelected': 'Delete {count} selected item(s)',
91
+ 'action.undo': 'Undo (Ctrl+Z)',
92
+ 'undo.copy': 'Copy "{name}"',
93
+ 'undo.move': 'Move "{name}"',
94
+ 'undo.rename': 'Rename "{name}"',
95
+ 'undo.create': 'Create "{name}"',
96
+ 'undo.delete': 'Delete "{name}"',
97
+ 'error.reveal': 'Failed to reveal in Explorer',
61
98
  'prompt.newFileName': 'New file name',
62
99
  'prompt.newFolderName': 'New folder name',
63
100
  'prompt.renameTo': 'Rename to',
64
- 'confirm.deleteFile': 'Delete file "{name}"?',
65
- 'confirm.deleteDir': 'Delete folder "{name}" and all its contents?',
101
+ 'confirm.deleteFile': 'Delete file "{name}"? (undoable)',
102
+ 'confirm.deleteDir': 'Delete folder "{name}" and all its contents? (undoable)',
103
+ 'confirm.deleteSelected': 'Delete {count} selected item(s)? {names} (undoable)',
104
+ 'confirm.overwrite': '"{name}" already exists. Overwrite?',
105
+ 'clipboard.copied': '{count} item(s) copied',
106
+ 'clipboard.cut': '{count} item(s) cut',
107
+ 'clipboard.pasteHint': ' · Ctrl+V to paste here',
108
+ 'clipboard.clear': 'Clear clipboard',
66
109
  'preview.loading': 'Loading…',
67
110
  'preview.binary': 'Binary file, cannot preview',
68
111
  'preview.imageFailed': 'Failed to load image',
@@ -70,4 +113,5 @@ export const en: Record<FilesKey, string> = {
70
113
  'preview.emptyDir': 'Empty directory',
71
114
  'preview.error': 'Load failed',
72
115
  'preview.highlightOff': 'Large file: syntax highlighting off (still editable)',
116
+ 'preview.mdRenderOff': 'Large file: source view (still editable)',
73
117
  }
@@ -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()
@@ -204,3 +236,101 @@ export function toggleWrap(): void {
204
236
  }
205
237
  commit({ ...state, wrap })
206
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
+ }