dsh-plugin-workbench 0.0.5 → 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 +32 -1
- package/README.md +18 -5
- package/lib/client.js +6399 -155
- package/lib/client.js.map +1 -1
- package/lib/index.js +302 -5
- package/package.json +2 -1
- package/src/client/FileExplorer.tsx +537 -38
- package/src/client/FilePreview.tsx +182 -6
- package/src/client/files.module.css +279 -0
- package/src/client/highlight.ts +18 -1
- package/src/client/index.ts +23 -4
- package/src/client/locales.ts +48 -4
- package/src/client/markdown.ts +69 -0
- package/src/client/store.ts +132 -2
- package/src/index.ts +400 -7
package/src/client/locales.ts
CHANGED
|
@@ -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
|
+
}
|
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()
|
|
@@ -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
|
+
}
|