dsh-plugin-workbench 0.0.9 → 0.0.10

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/lib/index.js CHANGED
@@ -670,13 +670,25 @@ async function copyEntry(ctx, payload, signal) {
670
670
  return fail(mapError(error));
671
671
  }
672
672
  }
673
- /** Spawn one short-lived desktop command; resolves once the process launched. */
674
- function runDesktop(command, args) {
673
+ /**
674
+ * Spawn one short-lived desktop command; resolves once the process launched.
675
+ *
676
+ * `windowsHide` defaults to true (no console flash for console-subsystem
677
+ * helpers). EXPLORER.EXE IS THE ONE EXCEPTION: spawning it with
678
+ * `windowsHide: true` sets CREATE_NO_WINDOW, and the new shell folder window
679
+ * is then created HIDDEN — the folder opens on screen but stays invisible, so
680
+ * the user sees "nothing happened". (Verified empirically: the CabinetWClass
681
+ * window exists with `visible=False`; dropping the flag makes it visible.)
682
+ * explorer.exe is a GUI-subsystem app, so `windowsHide: false` never flashes
683
+ * a console — pass false on every Windows explorer.exe spawn.
684
+ * @param args - argv (never a shell string).
685
+ */
686
+ function runDesktop(command, args, windowsHide = true) {
675
687
  return new Promise((resolve, reject) => {
676
688
  const child = spawn(command, args, {
677
689
  detached: true,
678
690
  stdio: "ignore",
679
- windowsHide: true
691
+ windowsHide
680
692
  });
681
693
  child.once("error", reject);
682
694
  child.once("spawn", () => {
@@ -705,7 +717,7 @@ async function revealNative(osPath, isDir, signal) {
705
717
  signal.throwIfAborted();
706
718
  const platform = process.platform;
707
719
  if (platform === "win32") {
708
- await runDesktop("explorer.exe", isDir ? [osPath] : ["/select,", osPath]);
720
+ await runDesktop("explorer.exe", isDir ? [osPath] : ["/select,", osPath], false);
709
721
  return;
710
722
  }
711
723
  if (platform === "darwin") {
@@ -717,7 +729,7 @@ async function revealNative(osPath, isDir, signal) {
717
729
  if (env.WSL_DISTRO_NAME !== void 0 || env.WSL_INTEROP !== void 0) {
718
730
  const windowsPath = (await execCapture("wslpath", ["-w", osPath])).replace(/[\r\n]+$/, "");
719
731
  if (windowsPath === "") throw new Error("wslpath returned no Windows path");
720
- await runDesktop("explorer.exe", isDir ? [windowsPath] : ["/select,", windowsPath]);
732
+ await runDesktop("explorer.exe", isDir ? [windowsPath] : ["/select,", windowsPath], false);
721
733
  return;
722
734
  }
723
735
  await runDesktop("xdg-open", [isDir ? osPath : dirname(osPath)]);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-workbench",
3
3
  "description": "VS Code-style workspace file explorer + editable preview for the dsh web GUI",
4
- "version": "0.0.9",
4
+ "version": "0.0.10",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",
7
7
  "engines": {
@@ -10,6 +10,7 @@ import { FileIcon } from './fileIcons'
10
10
  import type { FilesKey } from './locales'
11
11
  import { cancelCut, clearClipboard, closeFilesUnder, copyToClipboard, expandPreview, openFile, popUndo, pushUndo, retargetFile, setCwd, toggleTheme, useClipboard, useTabsState } from './store'
12
12
  import type { ClipboardItem, ClipboardMode, UndoEntry } from './store'
13
+ import { DRAG_TYPE, insertIntoComposer, relPathOf } from './composer'
13
14
 
14
15
  export interface FsListEntry {
15
16
  name: string
@@ -286,9 +287,10 @@ export function FileExplorer({
286
287
  const dragPathsRef = useRef<string[] | null>(null)
287
288
  const [dropTarget, setDropTarget] = useState<string | null>(null)
288
289
 
289
- // ---- context menu (rows only: right-clicking blank space shows no menu) ----
290
+ // ---- context menu (rows, blank tree space, header and clipboard bar —
291
+ // the whole column carries a menu; right-clicking a row keeps its own) ----
290
292
  interface MenuState {
291
- kind: 'file' | 'dir'
293
+ kind: 'file' | 'dir' | 'blank'
292
294
  path: string
293
295
  x: number
294
296
  y: number
@@ -337,6 +339,24 @@ export function FileExplorer({
337
339
  }
338
340
  }, [menu])
339
341
 
342
+ // Explorer semantics: clicking (or right-clicking) ANYWHERE outside the
343
+ // tree — the chat, the header, other columns — clears the selection. Clicks
344
+ // on the tree rows and inside the open menu keep it (the menu's batch
345
+ // actions operate on the selection).
346
+ useEffect(() => {
347
+ const onDocMouseDown = (e: MouseEvent) => {
348
+ const target = e.target
349
+ if (!(target instanceof Node)) return
350
+ const tree = treeAreaRef.current
351
+ if (tree !== null && tree.contains(target)) return
352
+ const m = menuRef.current
353
+ if (m !== null && m.contains(target)) return
354
+ setSelected({})
355
+ }
356
+ document.addEventListener('mousedown', onDocMouseDown)
357
+ return () => document.removeEventListener('mousedown', onDocMouseDown)
358
+ }, [])
359
+
340
360
  // Latest tree snapshot for the polling tick (avoids stale closures).
341
361
  const treeRef = useRef({ root, children, expanded, rootLoading })
342
362
  treeRef.current = { root, children, expanded, rootLoading }
@@ -517,6 +537,16 @@ export function FileExplorer({
517
537
  setMenu({ kind: entry.kind === 'dir' ? 'dir' : 'file', path: entry.path, x: e.clientX, y: e.clientY })
518
538
  }, [])
519
539
 
540
+ /** Right-click anywhere else in the column (header, clipboard bar, blank tree space): folder menu for the cwd. */
541
+ const onColumnContextMenu = useCallback((e: ReactMouseEvent) => {
542
+ e.preventDefault()
543
+ // Right-clicking the open menu itself keeps it (no browser menu either).
544
+ const el = menuRef.current
545
+ if (el !== null && e.target instanceof Node && el.contains(e.target)) return
546
+ if (cwd === undefined) return
547
+ setMenu({ kind: 'blank', path: cwd, x: e.clientX, y: e.clientY })
548
+ }, [cwd])
549
+
520
550
  const runAction = useCallback((action: () => void) => {
521
551
  setMenu(undefined)
522
552
  action()
@@ -809,11 +839,17 @@ export function FileExplorer({
809
839
  const paths = selected[entry.path] !== undefined ? Object.keys(selected) : [entry.path]
810
840
  dragPathsRef.current = paths
811
841
  try {
812
- e.dataTransfer.setData('text/plain', entry.path)
842
+ // text/plain: natural browser fallback (a native textarea drop inserts
843
+ // the paths); the custom type carries the machine-readable list for the
844
+ // workbench's own chat-drop integration (composer.ts).
845
+ e.dataTransfer.setData('text/plain', paths.join('\n'))
846
+ e.dataTransfer.setData(DRAG_TYPE, JSON.stringify(paths))
813
847
  } catch {
814
848
  // dataTransfer may be unavailable — the ref still carries the paths
815
849
  }
816
- e.dataTransfer.effectAllowed = 'move'
850
+ // copyMove: dropping on a folder row moves (tree), dropping on the chat
851
+ // copies the path text in.
852
+ e.dataTransfer.effectAllowed = 'copyMove'
817
853
  }, [selected])
818
854
 
819
855
  const onRowDragEnd = useCallback(() => {
@@ -897,6 +933,30 @@ export function FileExplorer({
897
933
  void openPath(entry.path)
898
934
  }, [openPath])
899
935
 
936
+ /** Explorer-style "Select All": every visible (non-trash) entry at any depth. */
937
+ const selectAllEntries = useCallback(() => {
938
+ const all: Record<string, FsListEntry['kind']> = {}
939
+ const collect = (entries: FsListEntry[]) => {
940
+ for (const entry of entries) {
941
+ if (entry.name === TRASH_NAME) continue
942
+ all[entry.path] = entry.kind
943
+ if (entry.kind === 'dir' && expanded.has(entry.path) && children[entry.path] !== undefined) {
944
+ collect(children[entry.path] as FsListEntry[])
945
+ }
946
+ }
947
+ }
948
+ const rootEntries = root !== undefined ? children[root] : undefined
949
+ if (rootEntries !== undefined) collect(rootEntries)
950
+ setSelected(all)
951
+ }, [children, expanded, root])
952
+
953
+ /** Insert `@<relative-workspace-path>` into the composer (Claude Code-style mention). */
954
+ const onMention = useCallback((path: string) => {
955
+ const mention = relPathOf(path, cwd)
956
+ if (mention.length === 0) return
957
+ insertIntoComposer(`@${mention} `)
958
+ }, [cwd])
959
+
900
960
  // Explorer-style keyboard: Ctrl/Cmd+C/X copy-cut, Ctrl/Cmd+V paste,
901
961
  // Ctrl/Cmd+A select-all, Escape clears the selection (and cancels a cut).
902
962
  const onTreeKeyDown = useCallback((e: ReactKeyboardEvent) => {
@@ -935,19 +995,7 @@ export function FileExplorer({
935
995
  }
936
996
  if (mod && key === 'a') {
937
997
  e.preventDefault()
938
- const all: Record<string, FsListEntry['kind']> = {}
939
- const collect = (entries: FsListEntry[]) => {
940
- for (const entry of entries) {
941
- if (entry.name === TRASH_NAME) continue
942
- all[entry.path] = entry.kind
943
- if (entry.kind === 'dir' && expanded.has(entry.path) && children[entry.path] !== undefined) {
944
- collect(children[entry.path] as FsListEntry[])
945
- }
946
- }
947
- }
948
- const rootEntries = root !== undefined ? children[root] : undefined
949
- if (rootEntries !== undefined) collect(rootEntries)
950
- setSelected(all)
998
+ selectAllEntries()
951
999
  return
952
1000
  }
953
1001
  if (mod && key === 'z' && !e.shiftKey) {
@@ -966,7 +1014,7 @@ export function FileExplorer({
966
1014
  setSelected({})
967
1015
  cancelCut()
968
1016
  }
969
- }, [cancelCut, children, clipboard.items.length, deleteSelection, expanded, onPaste, performUndo, root, selectedItems, undoEntries.length])
1017
+ }, [cancelCut, clipboard.items.length, deleteSelection, onPaste, performUndo, selectAllEntries, selectedItems, undoEntries.length])
970
1018
 
971
1019
  // ---- placeholder: no session / no cwd ----
972
1020
  if (cwd === undefined) {
@@ -1043,9 +1091,12 @@ export function FileExplorer({
1043
1091
  const selectionCount = Object.keys(selected).length
1044
1092
  const deleteItem: MenuItem = selectionCount > 1
1045
1093
  ? { label: t('menu.deleteSelected', { count: selectionCount }), danger: true, onClick: () => void deleteSelection() }
1046
- : { label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, m.kind) }
1094
+ // 'blank' never renders deleteItem (the blank branch returns earlier);
1095
+ // the fallback only satisfies the union type.
1096
+ : { label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, m.kind === 'blank' ? 'dir' : m.kind) }
1047
1097
  if (m.kind === 'file') {
1048
1098
  return [
1099
+ { label: t('menu.atFile'), onClick: () => onMention(m.path) },
1049
1100
  { label: t('menu.open'), onClick: () => openFile(m.path) },
1050
1101
  'divider',
1051
1102
  { label: t('menu.copy'), onClick: () => onCopySelection('copy') },
@@ -1077,16 +1128,38 @@ export function FileExplorer({
1077
1128
  deleteItem,
1078
1129
  ]
1079
1130
  }
1080
- // Empty tree area: right-clicking blank space intentionally shows no
1081
- // custom menu (only file/folder rows do); this branch is unreachable but
1082
- // kept for the type. Paste happens via Ctrl+V into the current folder.
1131
+ // Blank space / header / clipboard bar: operations against the current
1132
+ // workspace folder (right-clicking blank space acts on the cwd, VS Code
1133
+ // file-explorer style).
1134
+ if (m.kind === 'blank') {
1135
+ const dirPath = m.path
1136
+ return [
1137
+ { label: t('menu.newFile'), onClick: () => onNewFile(dirPath) },
1138
+ { label: t('menu.newFolder'), onClick: () => onNewFolder(dirPath) },
1139
+ { label: t('menu.paste'), disabled: clipboard.items.length === 0, onClick: () => void onPaste() },
1140
+ 'divider',
1141
+ { label: t('menu.selectAll'), onClick: selectAllEntries },
1142
+ { label: t('menu.undo'), disabled: undoEntries.length === 0, onClick: () => void performUndo() },
1143
+ 'divider',
1144
+ { label: t('menu.copyPath'), onClick: () => onCopyPath(dirPath) },
1145
+ { label: t('menu.revealInExplorer'), onClick: () => onReveal(dirPath, 'dir') },
1146
+ { label: t('menu.openSystem'), onClick: () => void openPath(dirPath) },
1147
+ { label: t('menu.refresh'), onClick: () => refreshDir(dirPath) },
1148
+ ]
1149
+ }
1083
1150
  return [
1084
1151
  { label: t('menu.paste'), disabled: clipboard.items.length === 0, onClick: () => void onPaste() },
1085
1152
  ]
1086
1153
  }
1087
1154
 
1088
1155
  return (
1089
- <div className={styles.column} style={{ width: width > 0 ? width : undefined }} data-pane="explorer" data-fe-theme={theme}>
1156
+ <div
1157
+ className={styles.column}
1158
+ style={{ width: width > 0 ? width : undefined }}
1159
+ data-pane="explorer"
1160
+ data-fe-theme={theme}
1161
+ onContextMenu={onColumnContextMenu}
1162
+ >
1090
1163
  <div className={styles.header}>
1091
1164
  <span className={styles.headerTitle} title={root ?? cwd}>{basenameOf(root ?? cwd)}</span>
1092
1165
  <span className={styles.headerActions}>
@@ -1133,8 +1206,10 @@ export function FileExplorer({
1133
1206
  className={styles.treeArea}
1134
1207
  onKeyDown={onTreeKeyDown}
1135
1208
  onClick={(e) => {
1136
- // Clicking blank space deselects (Explorer behavior).
1137
- if (e.target === e.currentTarget) setSelected({})
1209
+ // Clicking blank space (or a hint/error line — anything that is not
1210
+ // a tree row) deselects, Explorer behavior.
1211
+ const target = e.target as HTMLElement
1212
+ if (target.closest('[role="treeitem"]') === null) setSelected({})
1138
1213
  }}
1139
1214
  onDragOver={(e) => {
1140
1215
  // Blank tree area also accepts a drop: move into the current folder.
@@ -1158,6 +1233,12 @@ export function FileExplorer({
1158
1233
  ref={menuRef}
1159
1234
  className={styles.contextMenu}
1160
1235
  role="menu"
1236
+ onContextMenu={(e) => {
1237
+ // Right-clicking the open menu keeps it (and never shows the
1238
+ // browser menu).
1239
+ e.preventDefault()
1240
+ e.stopPropagation()
1241
+ }}
1161
1242
  style={menuPos !== undefined ? { left: menuPos.x, top: menuPos.y } : { left: menu.x, top: menu.y, visibility: 'hidden' }}
1162
1243
  >
1163
1244
  {menuItems(menu).map((item, index) =>
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Composer integration for the workbench file column.
3
+ *
4
+ * Two gestures land text in the chat composer without touching the core:
5
+ *
6
+ * 1. Drag & drop — dragging one or more tree rows and dropping ANYWHERE
7
+ * outside the file column (the chat, the composer, the message list)
8
+ * inserts the dragged paths into the composer. Dropping INSIDE the file
9
+ * column still performs the tree's own move operation — the tree's drop
10
+ * handler runs first and stops propagation, so this document-level
11
+ * listener never sees those drops.
12
+ *
13
+ * 2. Context-menu "@引用" — inserts `@<relative-workspace-path>` at the
14
+ * composer caret.
15
+ *
16
+ * The composer is a controlled React textarea, so the value is updated
17
+ * through the native `value` setter + a bubbling `input` event (the standard
18
+ * trick that makes React's onChange see the change), and the caret is
19
+ * restored on the next frame because the React re-render may reset it.
20
+ */
21
+ import { getTabsState, openFile } from './store'
22
+
23
+ /** Custom dataTransfer type carrying the dragged workspace paths (JSON array). */
24
+ export const DRAG_TYPE = 'application/x-dsh-workbench-files'
25
+
26
+ /** One-time install guard (the client bundle re-applies on HMR). */
27
+ let dropsInstalled = false
28
+
29
+ /** Start the document-level drop listener (idempotent). */
30
+ export function installComposerDrops(): void {
31
+ if (dropsInstalled || typeof document === 'undefined') return
32
+ dropsInstalled = true
33
+ // Bubble phase: the tree's own drop handlers (React, attached at the root
34
+ // container) run first and stop the event, so drops inside the file column
35
+ // never reach this listener.
36
+ document.addEventListener('drop', onDocumentDrop)
37
+ }
38
+
39
+ function onDocumentDrop(e: DragEvent): void {
40
+ const dt = e.dataTransfer
41
+ if (dt === null || !dt.types.includes(DRAG_TYPE)) return
42
+ const paths = readDraggedPaths(dt)
43
+ if (paths.length === 0) return
44
+ if (!insertIntoComposer(paths.join('\n'))) return
45
+ e.preventDefault()
46
+ e.stopPropagation()
47
+ }
48
+
49
+ /** Read the JSON paths from the custom type; falls back to raw text. */
50
+ export function readDraggedPaths(dt: DataTransfer | null): string[] {
51
+ if (dt === null) return []
52
+ let raw = ''
53
+ try {
54
+ raw = dt.getData(DRAG_TYPE)
55
+ } catch {
56
+ return []
57
+ }
58
+ try {
59
+ const parsed: unknown = JSON.parse(raw)
60
+ if (Array.isArray(parsed) && parsed.every((p) => typeof p === 'string')) return parsed as string[]
61
+ } catch {
62
+ // Not JSON — fall through to the raw-text fallback.
63
+ }
64
+ return raw.trim().length > 0 ? [raw] : []
65
+ }
66
+
67
+ /** The visible composer textarea, or null when none is usable. */
68
+ function composerTextarea(): HTMLTextAreaElement | null {
69
+ if (typeof document === 'undefined') return null
70
+ const seat = document.querySelector('[data-composer-seat]')
71
+ if (seat === null) return null
72
+ const input = seat.querySelector('textarea')
73
+ if (!(input instanceof HTMLTextAreaElement)) return null
74
+ // A disabled input has no usable draft; a read-only one is either the hero
75
+ // workspace picker or a transient submitting state — skip both.
76
+ if (input.disabled || input.readOnly) return null
77
+ return input
78
+ }
79
+
80
+ /**
81
+ * Insert `text` into the composer. If the composer textarea has focus, the
82
+ * text goes at the caret; otherwise it is appended to the draft. Returns
83
+ * false when no usable composer is present.
84
+ */
85
+ export function insertIntoComposer(text: string): boolean {
86
+ const textarea = composerTextarea()
87
+ if (textarea === null) return false
88
+ const value = textarea.value
89
+ const atCaret = document.activeElement === textarea
90
+ const start = atCaret ? textarea.selectionStart : value.length
91
+ const end = atCaret ? textarea.selectionEnd : value.length
92
+ const next = value.slice(0, start) + text + value.slice(end)
93
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
94
+ if (setter !== undefined) setter.call(textarea, next)
95
+ else textarea.value = next
96
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
97
+ // The React re-render may reset the selection, so restore on the next frame.
98
+ const caret = start + text.length
99
+ requestAnimationFrame(() => {
100
+ try {
101
+ textarea.setSelectionRange(caret, caret)
102
+ } catch {
103
+ // Selection state unavailable — the text is in the draft either way.
104
+ }
105
+ })
106
+ return true
107
+ }
108
+
109
+ /** Relative workspace path of `path` under `cwd` ('' when it IS the cwd). */
110
+ export function relPathOf(path: string, cwd: string | undefined): string {
111
+ if (cwd === undefined || cwd.length === 0) return path
112
+ const sep = cwd.includes('\\') ? '\\' : '/'
113
+ const prefix = cwd.endsWith('\\') || cwd.endsWith('/') ? cwd : cwd + sep
114
+ const lowerPath = path.toLowerCase()
115
+ if (lowerPath.startsWith(prefix.toLowerCase())) return path.slice(prefix.length)
116
+ if (lowerPath === cwd.toLowerCase()) return ''
117
+ return path
118
+ }
119
+
120
+ /**
121
+ * Resolve an @-mention path (workspace-relative or absolute) against the
122
+ * current workspace cwd; returns the absolute OS path, or undefined when the
123
+ * mention cannot be resolved (no cwd and the path is not absolute).
124
+ */
125
+ export function resolveMentionPath(mention: string): string | undefined {
126
+ const absolute = /^[A-Za-z]:[\\/]/.test(mention) || mention.startsWith('/') || mention.startsWith('\\')
127
+ const { cwd } = getTabsState()
128
+ if (absolute) return mention
129
+ if (cwd === undefined) return undefined
130
+ const sep = cwd.includes('\\') ? '\\' : '/'
131
+ return cwd.endsWith('\\') || cwd.endsWith('/') ? cwd + mention : cwd + sep + mention
132
+ }
133
+
134
+ /** Open an @-mention's file in the workbench preview (used by the linkifier). */
135
+ export function openMention(mention: string): void {
136
+ const abs = resolveMentionPath(mention)
137
+ if (abs === undefined) return
138
+ openFile(abs)
139
+ }
@@ -11,6 +11,8 @@ import type { Context } from '@deepseek-ai/cordis'
11
11
  import { FileExplorer } from './FileExplorer'
12
12
  import { FilePreview } from './FilePreview'
13
13
  import { NS, zh, en } from './locales'
14
+ import { installComposerDrops } from './composer'
15
+ import { installMentionLinkifier } from './mentions'
14
16
 
15
17
  const CHANNEL = '/dsh-plugin-files'
16
18
 
@@ -20,6 +22,11 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'
20
22
  export function apply(ctx: Context): void {
21
23
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'files-explorer: dictionaries')
22
24
 
25
+ // Drag-to-chat + @-mention linkifier: DOM-level integrations, one-time
26
+ // install (module guards make re-applies on HMR idempotent).
27
+ installComposerDrops()
28
+ ctx.effect(() => installMentionLinkifier(), 'files-explorer: @mention linkifier')
29
+
23
30
  const listDir = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'list', { path }, signal))
24
31
  const readFile = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'read', { path }, signal))
25
32
  const writeFile = (path: string, content: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'write', { path, content }, signal))
@@ -18,6 +18,7 @@ export const zh = {
18
18
  'tab.expand': '弹出文件详情',
19
19
  'tab.diskChanged': '文件已在磁盘上被修改,点击重新加载(会放弃未保存的编辑)',
20
20
  'menu.open': '打开预览',
21
+ 'menu.atFile': '@ 在消息中引用',
21
22
  'menu.newFile': '新建文件',
22
23
  'menu.newFolder': '新建文件夹',
23
24
  'menu.rename': '重命名',
@@ -30,6 +31,8 @@ export const zh = {
30
31
  'menu.openSystem': '在系统中打开',
31
32
  'menu.revealInExplorer': '在资源管理器打开',
32
33
  'menu.deleteSelected': '删除选中的 {count} 项',
34
+ 'menu.selectAll': '全选',
35
+ 'menu.undo': '撤销',
33
36
  'action.undo': '撤销(Ctrl+Z)',
34
37
  'undo.copy': '复制「{name}」',
35
38
  'undo.move': '移动「{name}」',
@@ -76,6 +79,7 @@ export const en: Record<FilesKey, string> = {
76
79
  'tab.expand': 'Expand file details',
77
80
  'tab.diskChanged': 'File changed on disk — click to reload (discards unsaved edits)',
78
81
  'menu.open': 'Open preview',
82
+ 'menu.atFile': '@ Mention in message',
79
83
  'menu.newFile': 'New File',
80
84
  'menu.newFolder': 'New Folder',
81
85
  'menu.rename': 'Rename',
@@ -88,6 +92,8 @@ export const en: Record<FilesKey, string> = {
88
92
  'menu.openSystem': 'Open in System',
89
93
  'menu.revealInExplorer': 'Reveal in Explorer',
90
94
  'menu.deleteSelected': 'Delete {count} selected item(s)',
95
+ 'menu.selectAll': 'Select All',
96
+ 'menu.undo': 'Undo',
91
97
  'action.undo': 'Undo (Ctrl+Z)',
92
98
  'undo.copy': 'Copy "{name}"',
93
99
  'undo.move': 'Move "{name}"',