@restregular/dsh-filekit 0.2.1 → 0.2.2

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/README.md CHANGED
@@ -30,7 +30,8 @@ dsh plugin --profile web add @restregular/dsh-filekit
30
30
  | 新建文件 / 文件夹 | 右键菜单 | 在当前目录或目标目录创建 |
31
31
  | 重命名 | 右键菜单 | 只改名字,不改变所在目录 |
32
32
  | 移动到… | 右键菜单 | 目录选择器 |
33
- | 复制一份 | 右键菜单 | 在同目录生成副本 |
33
+ | 复制 / 剪切 | 右键菜单 | 把条目放进 FileKit 剪贴板,不立即改动磁盘 |
34
+ | 粘贴 | 目录 / 空白处右键 | 粘贴到该目录;重名时自动用 `名称 (2).扩展名`,不会覆盖 |
34
35
  | 复制路径 | 右键菜单 | 相对路径与绝对路径 |
35
36
  | 引用到对话框 | 右键菜单 | 插入 `@相对路径 `;输入框不可用时退回复制 |
36
37
  | 删除到回收站 | 右键菜单 | 移动到 `<工作区>/.dsh-trash`,可恢复 |
@@ -40,7 +41,7 @@ dsh plugin --profile web add @restregular/dsh-filekit
40
41
 
41
42
  - 独立侧边栏标签页(`filekit` kind),不是浮层弹窗;
42
43
  - 面包屑浏览、新建、复制、移动、删除、回收站;
43
- - 内置文本编辑器(`Ctrl/Cmd+S` 保存、未保存守卫、保存后刷新);
44
+ - 内置文本编辑器(与内置预览同一个 Shiki 高亮器、`Ctrl/Cmd+S` 保存、未保存守卫、保存后刷新);
44
45
  - 右键菜单与拖拽移动;
45
46
  - 有未保存修改时,关闭标签页会被阻止并提示先保存或放弃。
46
47
 
@@ -113,6 +114,7 @@ action: list | read | write | create | rename | move | copy | delete
113
114
  - 回收站恢复要求原父目录仍然存在;父目录被改名或删除时需要先手动恢复目录。
114
115
  - Host 路由只监听本机回环地址;如果通过 Pocket/公网隧道在另一台设备上打开页面,界面数据请求会被拒绝(有意的安全边界)。
115
116
  - 编辑器按 UTF-8 读写,不做编码转换;超限文件以只读截断方式展示。
117
+ - 文件剪贴板是页面内存状态(不是系统剪贴板),刷新页面或重启 DSH 后清空。
116
118
  - 接管文件页后,宿主「在系统中打开目录」按钮不再出现在该页标题栏(宿主把该按钮挂在文件页的子槽位里,接管页不使用该子槽位)。
117
119
 
118
120
  ## 故障排查
package/client.js CHANGED
@@ -42,6 +42,20 @@
42
42
  const React = require('react');
43
43
  const h = React.createElement;
44
44
 
45
+ // Harness primitives give the editor the same Shiki highlighter the
46
+ // built-in document preview uses. Optional: a missing module degrades to
47
+ // a plain textarea instead of breaking the whole plugin.
48
+ let primitives = undefined;
49
+ try {
50
+ primitives = require('@deepseek-ai/dsh-client-ui-primitives');
51
+ } catch {
52
+ primitives = undefined;
53
+ }
54
+ const useCodeHighlighter =
55
+ typeof primitives?.useCodeHighlighter === 'function' ? primitives.useCodeHighlighter : () => () => undefined;
56
+ const languageForPath =
57
+ typeof primitives?.languageForPath === 'function' ? primitives.languageForPath : () => undefined;
58
+
45
59
  // ------------------------------------------------------------------
46
60
  // Copy
47
61
  // ------------------------------------------------------------------
@@ -79,7 +93,10 @@
79
93
  openPreview: '打开预览',
80
94
  rename: '重命名',
81
95
  move: '移动到…',
82
- copy: '复制一份',
96
+ copy: '复制',
97
+ cut: '剪切',
98
+ paste: '粘贴',
99
+ pasteHere: '粘贴到此处',
83
100
  copyRel: '复制相对路径',
84
101
  copyAbs: '复制绝对路径',
85
102
  reference: '引用到对话框',
@@ -120,6 +137,9 @@
120
137
  renamed: '已重命名',
121
138
  moved: '已移动',
122
139
  copied: '已复制',
140
+ cutDone: '已剪切',
141
+ pasted: '已粘贴',
142
+ pastedTo: '已粘贴到 {path}',
123
143
  deleted: '已移入回收站',
124
144
  restored: '已恢复',
125
145
  purged: '已永久删除',
@@ -216,7 +236,10 @@
216
236
  openPreview: 'Open preview',
217
237
  rename: 'Rename',
218
238
  move: 'Move to…',
219
- copy: 'Duplicate',
239
+ copy: 'Copy',
240
+ cut: 'Cut',
241
+ paste: 'Paste',
242
+ pasteHere: 'Paste here',
220
243
  copyRel: 'Copy relative path',
221
244
  copyAbs: 'Copy absolute path',
222
245
  reference: 'Reference in composer',
@@ -257,6 +280,9 @@
257
280
  renamed: 'Renamed',
258
281
  moved: 'Moved',
259
282
  copied: 'Copied',
283
+ cutDone: 'Cut',
284
+ pasted: 'Pasted',
285
+ pastedTo: 'Pasted into {path}',
260
286
  deleted: 'Moved to the recycle bin',
261
287
  restored: 'Restored',
262
288
  purged: 'Permanently deleted',
@@ -599,6 +625,147 @@
599
625
  return h('div', { className: 'dsh-fk-toast', 'data-kind': spec.kind, role: 'status' }, spec.text);
600
626
  }
601
627
 
628
+ // ------------------------------------------------------------------
629
+ // File clipboard (copy / cut → paste)
630
+ // ------------------------------------------------------------------
631
+
632
+ /** Shared across both panels: copy in one, paste in the other. */
633
+ let fileClipboard = null;
634
+ const clipboardListeners = new Set();
635
+
636
+ function setFileClipboard(value) {
637
+ fileClipboard = value;
638
+ for (const listener of clipboardListeners) {
639
+ try {
640
+ listener();
641
+ } catch {
642
+ /* ignore */
643
+ }
644
+ }
645
+ }
646
+
647
+ function useFileClipboard() {
648
+ const [value, setValue] = React.useState(fileClipboard);
649
+ React.useEffect(() => {
650
+ const listener = () => setValue(fileClipboard);
651
+ clipboardListeners.add(listener);
652
+ return () => clipboardListeners.delete(listener);
653
+ }, []);
654
+ return value;
655
+ }
656
+
657
+ /** Put one entry on the FileKit clipboard (no filesystem work yet). */
658
+ function copyToFileClipboard(root, rel, kind, mode) {
659
+ setFileClipboard({ mode, root, rel, kind, name: baseOf(rel) });
660
+ showToast(mode === 'cut' ? t('cutDone') : t('copied'), 'ok');
661
+ }
662
+
663
+ // ------------------------------------------------------------------
664
+ // Highlighted code editor
665
+ // ------------------------------------------------------------------
666
+
667
+ /** Above this size the editor skips highlighting so typing stays fluid. */
668
+ const HIGHLIGHT_MAX_CHARS = 200_000;
669
+
670
+ function CodeEditor({ value, onChange, rel, fontSize, readOnly = false }) {
671
+ const inputRef = React.useRef(null);
672
+ const preRef = React.useRef(null);
673
+ const [composing, setComposing] = React.useState(false);
674
+ const deferred = React.useDeferredValue(value);
675
+ const language = React.useMemo(() => languageForPath(rel), [rel]);
676
+ const highlight = useCodeHighlighter(language);
677
+ const plainLines = React.useMemo(() => String(deferred).split('\n'), [deferred]);
678
+ const lines = React.useMemo(() => {
679
+ if (typeof highlight !== 'function' || deferred.length > HIGHLIGHT_MAX_CHARS) return undefined;
680
+ try {
681
+ const result = highlight(deferred);
682
+ return Array.isArray(result) ? result : undefined;
683
+ } catch {
684
+ return undefined;
685
+ }
686
+ }, [deferred, highlight]);
687
+ const totalLines = String(value).split('\n').length;
688
+ const rendered = [];
689
+ const source = lines ?? plainLines;
690
+ for (let index = 0; index < Math.max(source.length, totalLines); index += 1) {
691
+ const line = source[index];
692
+ rendered.push(
693
+ h(
694
+ 'div',
695
+ { className: 'dsh-fk-codeeditor-line', key: index },
696
+ Array.isArray(line)
697
+ ? line.map((token, tokenIndex) => h('span', { key: tokenIndex, style: token.style }, token.text))
698
+ : line ?? '',
699
+ ),
700
+ );
701
+ }
702
+
703
+ const syncScroll = (event) => {
704
+ const pre = preRef.current;
705
+ if (pre === null) return;
706
+ pre.scrollTop = event.currentTarget.scrollTop;
707
+ pre.scrollLeft = event.currentTarget.scrollLeft;
708
+ };
709
+
710
+ // After edits (or a highlight pass) the textarea may have scrolled under
711
+ // the caret; keep the painted layer on the same scroll offset.
712
+ React.useEffect(() => {
713
+ const input = inputRef.current;
714
+ const pre = preRef.current;
715
+ if (input === null || pre === null) return;
716
+ pre.scrollTop = input.scrollTop;
717
+ pre.scrollLeft = input.scrollLeft;
718
+ }, [value, lines]);
719
+
720
+ return h(
721
+ 'div',
722
+ { className: 'dsh-fk-codeeditor' },
723
+ h(
724
+ 'pre',
725
+ {
726
+ className: 'dsh-fk-codeeditor-pre',
727
+ ref: preRef,
728
+ 'aria-hidden': true,
729
+ style: { fontSize: `${fontSize}px`, visibility: composing ? 'hidden' : 'visible' },
730
+ },
731
+ rendered,
732
+ ),
733
+ h('textarea', {
734
+ ref: inputRef,
735
+ className: 'dsh-fk-codeeditor-input',
736
+ value,
737
+ spellCheck: false,
738
+ readOnly,
739
+ wrap: 'off',
740
+ autoCapitalize: 'off',
741
+ autoCorrect: 'off',
742
+ autoComplete: 'off',
743
+ style: {
744
+ fontSize: `${fontSize}px`,
745
+ // IME composition text would be invisible through the transparent
746
+ // layer; show it plainly while the candidate window is open.
747
+ color: composing ? 'var(--dsw-alias-label-primary)' : 'transparent',
748
+ },
749
+ onChange: (event) => onChange(event.target.value),
750
+ onScroll: syncScroll,
751
+ onCompositionStart: () => setComposing(true),
752
+ onCompositionEnd: () => setComposing(false),
753
+ onKeyDown: (event) => {
754
+ if (readOnly || event.key !== 'Tab') return;
755
+ event.preventDefault();
756
+ const area = event.currentTarget;
757
+ const start = area.selectionStart;
758
+ const end = area.selectionEnd;
759
+ onChange(`${value.slice(0, start)} ${value.slice(end)}`);
760
+ window.requestAnimationFrame(() => {
761
+ area.selectionStart = start + 2;
762
+ area.selectionEnd = start + 2;
763
+ });
764
+ },
765
+ }),
766
+ );
767
+ }
768
+
602
769
  function createOwnedStyle(css) {
603
770
  const el = document.createElement('style');
604
771
  el.setAttribute('data-plugin', OWNER);
@@ -676,6 +843,22 @@
676
843
  return h('svg', common, h('path', { d: 'M4 20h4L19 9l-4-4L4 16z' }), h('path', { d: 'M13.5 6.5l4 4' }));
677
844
  case 'copy':
678
845
  return h('svg', common, h('rect', { x: 9, y: 9, width: 11, height: 11, rx: 2 }), h('path', { d: 'M5 15H4V4h11v1' }));
846
+ case 'cut':
847
+ return h(
848
+ 'svg',
849
+ common,
850
+ h('circle', { cx: 6.5, cy: 17.5, r: 2.5 }),
851
+ h('circle', { cx: 17.5, cy: 17.5, r: 2.5 }),
852
+ h('path', { d: 'M8.2 15.6L16 3M15.8 15.6L8 3' }),
853
+ );
854
+ case 'paste':
855
+ return h(
856
+ 'svg',
857
+ common,
858
+ h('path', { d: 'M9 4.5h6v3H9z' }),
859
+ h('path', { d: 'M9 6H6.5A1.5 1.5 0 0 0 5 7.5v12A1.5 1.5 0 0 0 6.5 21h11a1.5 1.5 0 0 0 1.5-1.5v-12A1.5 1.5 0 0 0 17.5 6H15' }),
860
+ h('path', { d: 'M9 12h6M9 16h6' }),
861
+ );
679
862
  case 'link':
680
863
  return h(
681
864
  'svg',
@@ -1156,6 +1339,8 @@
1156
1339
  const [dragging, setDragging] = React.useState(null);
1157
1340
  const [dropDir, setDropDir] = React.useState(null);
1158
1341
  const settingsSnapshot = useSettings();
1342
+ const clipboard = useFileClipboard();
1343
+ const canPaste = clipboard !== null && clipboard.root === root;
1159
1344
  const navigation = tab.navigation;
1160
1345
  const editorRef = React.useRef(null);
1161
1346
  const draftRef = React.useRef('');
@@ -1309,8 +1494,8 @@
1309
1494
  const mutate = async (action, successText) => {
1310
1495
  if (root === '') return false;
1311
1496
  try {
1312
- await action();
1313
- succeed(typeof successText === 'function' ? successText() : successText);
1497
+ const result = await action();
1498
+ succeed(typeof successText === 'function' ? successText(result) : successText);
1314
1499
  return true;
1315
1500
  } catch (failure) {
1316
1501
  setNotice({ kind: 'error', text: errorText(failure) });
@@ -1318,6 +1503,18 @@
1318
1503
  }
1319
1504
  };
1320
1505
 
1506
+ /** Paste the FileKit clipboard into one directory; a taken name gets " (n)". */
1507
+ const pasteInto = async (toDir) => {
1508
+ const clip = fileClipboard;
1509
+ if (clip === null || clip.root !== root) return false;
1510
+ const ok = await mutate(
1511
+ () => api(clip.mode === 'cut' ? 'move' : 'copy', { root, path: clip.rel, toDir, unique: true, sessionId }),
1512
+ (result) => t('pastedTo', { path: result.path }),
1513
+ );
1514
+ if (ok && clip.mode === 'cut') setFileClipboard(null);
1515
+ return ok;
1516
+ };
1517
+
1321
1518
  const copyRelPath = async (rel) => {
1322
1519
  const ok = await copyText(rel);
1323
1520
  showToast(ok ? t('copiedPath') : t('error.internal'), ok ? 'ok' : 'error');
@@ -1679,13 +1876,12 @@
1679
1876
  'div',
1680
1877
  { className: 'dsh-fk-editor' },
1681
1878
  editor.truncated ? h('p', { className: 'dsh-fk-warn' }, h(Icon, { name: 'warning', size: 14 }), t('truncated')) : null,
1682
- h('textarea', {
1683
- className: 'dsh-fk-textarea',
1879
+ h(CodeEditor, {
1684
1880
  value: draft,
1685
- spellCheck: false,
1881
+ onChange: setDraft,
1882
+ rel: editor.rel,
1883
+ fontSize: settingsSnapshot.editorFontSize,
1686
1884
  readOnly: editor.truncated,
1687
- style: { fontSize: `${settingsSnapshot.editorFontSize}px` },
1688
- onChange: (event) => setDraft(event.target.value),
1689
1885
  }),
1690
1886
  h(
1691
1887
  'div',
@@ -1711,6 +1907,7 @@
1711
1907
  className: 'dsh-fk-row',
1712
1908
  'data-kind': entry.kind,
1713
1909
  'data-selected': selected === entry.path,
1910
+ 'data-menu': menu !== null && menu.rel === entry.path,
1714
1911
  'data-drop': dropDir === entry.path,
1715
1912
  tabIndex: 0,
1716
1913
  draggable: true,
@@ -1874,6 +2071,7 @@
1874
2071
  if (menu !== null) {
1875
2072
  if (menu.rel === null) {
1876
2073
  menuItems = [
2074
+ ...(canPaste ? [{ label: t('paste'), icon: 'paste', onSelect: () => pasteInto(cwd) }] : []),
1877
2075
  { label: t('newFile'), onSelect: () => setDialog({ kind: 'create-file' }) },
1878
2076
  { label: t('newDir'), onSelect: () => setDialog({ kind: 'create-dir' }) },
1879
2077
  { separator: true },
@@ -1885,10 +2083,15 @@
1885
2083
  ...(menu.kind === 'file' ? [{ label: t('openPreview'), icon: 'file', onSelect: () => openPreview(menu.rel) }] : []),
1886
2084
  ...(menu.kind === 'directory' ? [{ label: t('newFileHere'), icon: 'filePlus', onSelect: () => setDialog({ kind: 'create-file', dir: menu.rel }) }] : []),
1887
2085
  ...(menu.kind === 'directory' ? [{ label: t('newDirHere'), icon: 'folderPlus', onSelect: () => setDialog({ kind: 'create-dir', dir: menu.rel }) }] : []),
2086
+ ...(menu.kind === 'directory' ? [{ separator: true }] : []),
2087
+ ...(menu.kind === 'directory'
2088
+ ? [{ label: t('pasteHere'), icon: 'paste', disabled: !canPaste, onSelect: () => pasteInto(menu.rel) }]
2089
+ : []),
1888
2090
  { separator: true },
1889
2091
  { label: t('rename'), icon: 'edit', onSelect: () => setDialog({ kind: 'rename', rel: menu.rel }) },
1890
2092
  { label: t('move'), icon: 'folder', onSelect: () => setDialog({ kind: 'move', rel: menu.rel, entryKind: menu.kind }) },
1891
- { label: t('copy'), icon: 'copy', onSelect: () => mutate(() => api('copy', { root, path: menu.rel, toDir: dirOf(menu.rel), sessionId }), t('copied')) },
2093
+ { label: t('copy'), icon: 'copy', onSelect: () => copyToFileClipboard(root, menu.rel, menu.kind, 'copy') },
2094
+ { label: t('cut'), icon: 'cut', onSelect: () => copyToFileClipboard(root, menu.rel, menu.kind, 'cut') },
1892
2095
  { separator: true },
1893
2096
  { label: t('copyRel'), icon: 'copy', onSelect: () => copyRelPath(menu.rel) },
1894
2097
  { label: t('copyAbs'), icon: 'copy', onSelect: () => copyAbsPath(menu.rel) },
@@ -1952,6 +2155,7 @@
1952
2155
  const { useTabInfo, sessionId, inputActions, useSessions } = props;
1953
2156
  const { tab } = useTabInfo();
1954
2157
  const settingsSnapshot = useSettings();
2158
+ const clipboard = useFileClipboard();
1955
2159
  const [root, setRoot] = React.useState('');
1956
2160
  const [rootError, setRootError] = React.useState('');
1957
2161
  const [levels, setLevels] = React.useState({});
@@ -2142,8 +2346,20 @@
2142
2346
  });
2143
2347
  };
2144
2348
 
2145
- const copyEntry = (rel) =>
2146
- mutate('copy', { path: rel, toDir: dirOf(rel) }, t('copied'), [dirOf(rel)]);
2349
+ /** Paste the shared clipboard into one directory of this workspace. */
2350
+ const pasteInto = (toDir) => {
2351
+ const clip = fileClipboard;
2352
+ if (clip === null || clip.root !== root) return Promise.resolve(false);
2353
+ return mutate(
2354
+ clip.mode === 'cut' ? 'move' : 'copy',
2355
+ { path: clip.rel, toDir, unique: true },
2356
+ t('pasted'),
2357
+ [dirOf(clip.rel), toDir],
2358
+ clip.mode === 'cut' ? () => setFileClipboard(null) : undefined,
2359
+ );
2360
+ };
2361
+
2362
+ const canPaste = clipboard !== null && clipboard.root === root;
2147
2363
 
2148
2364
  const copyRelPath = async (rel) => {
2149
2365
  const ok = await copyText(rel);
@@ -2219,19 +2435,22 @@
2219
2435
  const openEntryMenu = (event, entry) => {
2220
2436
  event.preventDefault();
2221
2437
  event.stopPropagation();
2222
- setSelected(entry.path);
2438
+ // The menu target is highlighted through `data-menu`; closing the menu
2439
+ // must not leave a selection behind (the row was never "selected").
2223
2440
  setMenu({ x: event.clientX, y: event.clientY, rel: entry.path, kind: entry.kind, name: entry.name });
2224
2441
  };
2225
2442
 
2226
2443
  const openBlankMenu = (event) => {
2227
2444
  if (typeof event.target.closest === 'function' && event.target.closest('.dsh-fk-row, .dsh-fk-trow') !== null) return;
2228
2445
  event.preventDefault();
2446
+ setSelected(null);
2229
2447
  setMenu({ x: event.clientX, y: event.clientY, rel: null, kind: 'directory' });
2230
2448
  };
2231
2449
 
2232
2450
  const menuItems = [];
2233
2451
  if (menu !== null) {
2234
2452
  if (menu.rel === null) {
2453
+ if (canPaste) menuItems.push({ label: t('paste'), icon: 'paste', onSelect: () => void pasteInto('') });
2235
2454
  menuItems.push(
2236
2455
  { label: t('newFile'), icon: 'filePlus', onSelect: () => setDialog({ kind: 'create-file', dir: '' }) },
2237
2456
  { label: t('newDir'), icon: 'folderPlus', onSelect: () => setDialog({ kind: 'create-dir', dir: '' }) },
@@ -2250,12 +2469,14 @@
2250
2469
  if (isDir) {
2251
2470
  menuItems.push({ label: t('newFileHere'), icon: 'filePlus', onSelect: () => setDialog({ kind: 'create-file', dir: menu.rel }) });
2252
2471
  menuItems.push({ label: t('newDirHere'), icon: 'folderPlus', onSelect: () => setDialog({ kind: 'create-dir', dir: menu.rel }) });
2472
+ menuItems.push({ label: t('pasteHere'), icon: 'paste', disabled: !canPaste, onSelect: () => void pasteInto(menu.rel) });
2253
2473
  menuItems.push({ separator: true });
2254
2474
  }
2255
2475
  menuItems.push(
2256
2476
  { label: t('rename'), icon: 'edit', onSelect: () => setDialog({ kind: 'rename', rel: menu.rel, entryKind: menu.kind }) },
2257
2477
  { label: t('move'), icon: 'folder', onSelect: () => setDialog({ kind: 'move', rel: menu.rel, entryKind: menu.kind }) },
2258
- { label: t('copy'), icon: 'copy', onSelect: () => void copyEntry(menu.rel) },
2478
+ { label: t('copy'), icon: 'copy', onSelect: () => copyToFileClipboard(root, menu.rel, menu.kind, 'copy') },
2479
+ { label: t('cut'), icon: 'cut', onSelect: () => copyToFileClipboard(root, menu.rel, menu.kind, 'cut') },
2259
2480
  { separator: true },
2260
2481
  { label: t('copyRel'), icon: 'copy', onSelect: () => void copyRelPath(menu.rel) },
2261
2482
  { label: t('copyAbs'), icon: 'copy', onSelect: () => void copyAbsPath(menu.rel) },
@@ -2351,6 +2572,7 @@
2351
2572
  className: 'dsh-fk-trow',
2352
2573
  'data-kind': entry.kind,
2353
2574
  'data-selected': selected === entry.path,
2575
+ 'data-menu': menu !== null && menu.rel === entry.path,
2354
2576
  'data-drop': dropDir === entry.path,
2355
2577
  role: 'treeitem',
2356
2578
  'aria-expanded': isDir ? open : undefined,
@@ -2853,10 +3075,10 @@
2853
3075
  // ------------------------------------------------------------------
2854
3076
 
2855
3077
  const CSS = `
2856
- :root{--dsh-fk-gold:#f5b83d;--dsh-fk-orange:#f4511e;--dsh-fk-grad:linear-gradient(135deg,#f7c948 0%,#f4932a 52%,#f4511e 100%);--dsh-fk-dir:#c47a12;--dsh-fk-soft:rgb(244 81 30/.12);--dsh-fk-editor-font:12px}
3078
+ :root{--dsh-fk-gold:#f5b83d;--dsh-fk-orange:#f4511e;--dsh-fk-grad:linear-gradient(135deg,#f7c948 0%,#f4932a 52%,#f4511e 100%);--dsh-fk-dir:#c47a12;--dsh-fk-soft:rgb(244 81 30/.12);--dsh-fk-hover:var(--dsw-alias-interactive-bg-hover,rgb(0 0 0/.055));--dsh-fk-active:var(--dsw-alias-interactive-bg-active,rgb(0 0 0/.09));--dsh-fk-editor-font:12px}
2857
3079
  body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
2858
3080
  .dsh-fk-tool{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;flex:none}
2859
- .dsh-fk-tool:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsh-fk-orange)}
3081
+ .dsh-fk-tool:hover{background:var(--dsh-fk-hover);color:var(--dsh-fk-orange)}
2860
3082
  .dsh-fk-doc-actions{display:inline-flex;align-items:center;gap:1px;flex:none}
2861
3083
  .dsh-fk-tab{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;background:var(--dsw-alias-bg-base)}
2862
3084
  .dsh-fk-tabstatus{display:flex;flex-direction:column;gap:12px;align-items:flex-start;justify-content:center;height:100%;padding:24px}
@@ -2869,26 +3091,27 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
2869
3091
  .dsh-fk-toolbar{display:flex;align-items:center;gap:8px;padding:7px 10px;border-bottom:1px solid var(--dsw-alias-border-l2);flex:none;flex-wrap:wrap}
2870
3092
  .dsh-fk-crumbs{display:flex;align-items:center;gap:1px;min-width:0;flex:1;overflow:hidden;color:var(--dsw-alias-label-secondary);font-size:12px}
2871
3093
  .dsh-fk-crumb{display:inline-flex;align-items:center;gap:4px;max-width:220px;padding:3px 7px;border:0;border-radius:6px;background:transparent;color:inherit;font:inherit;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2872
- .dsh-fk-crumb:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
3094
+ .dsh-fk-crumb:hover{background:var(--dsh-fk-hover);color:var(--dsw-alias-label-primary)}
2873
3095
  .dsh-fk-crumb[data-drop=true]{background:var(--dsh-fk-soft);box-shadow:inset 0 0 0 1px var(--dsh-fk-orange);color:var(--dsh-fk-orange)}
2874
3096
  .dsh-fk-crumb-current{color:var(--dsw-alias-label-primary);font-weight:500;cursor:default}
2875
3097
  .dsh-fk-crumb-sep{opacity:.5}
2876
3098
  .dsh-fk-toolbar-actions{display:flex;align-items:center;gap:4px;flex:none}
2877
3099
  .dsh-fk-btn{display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;cursor:pointer;white-space:nowrap}
2878
- .dsh-fk-btn:hover{background:var(--dsw-alias-bg-layer-2)}
3100
+ .dsh-fk-btn:hover{background:var(--dsh-fk-hover)}
2879
3101
  .dsh-fk-btn:disabled{opacity:.45;cursor:default}
2880
3102
  .dsh-fk-btn-primary{background:var(--dsh-fk-grad);border-color:transparent;color:#fff;box-shadow:0 1px 6px rgb(244 81 30/.25)}
2881
3103
  .dsh-fk-btn-primary:hover{filter:brightness(1.05);background:var(--dsh-fk-grad)}
2882
3104
  .dsh-fk-btn-danger{background:var(--dsw-alias-state-error-primary);border-color:transparent;color:#fff}
2883
3105
  .dsh-fk-btn-danger:hover{filter:brightness(1.06);background:var(--dsw-alias-state-error-primary)}
2884
3106
  .dsh-fk-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;flex:none}
2885
- .dsh-fk-iconbtn:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsh-fk-orange)}
3107
+ .dsh-fk-iconbtn:hover{background:var(--dsh-fk-hover);color:var(--dsh-fk-orange)}
2886
3108
  .dsh-fk-iconbtn:disabled{opacity:.4;cursor:default}
2887
3109
  .dsh-fk-iconbtn[data-danger=true]:hover{color:var(--dsw-alias-state-error-primary)}
2888
3110
  .dsh-fk-body{flex:1;min-height:0;overflow:auto;padding:4px 6px 10px}
2889
3111
  .dsh-fk-row{display:flex;align-items:center;gap:8px;min-height:32px;padding:3px 6px;border-radius:7px;cursor:pointer;outline:none}
2890
- .dsh-fk-row:hover,.dsh-fk-row:focus-visible{background:var(--dsw-alias-bg-layer-2)}
2891
- .dsh-fk-row[data-selected=true]{background:var(--dsh-fk-soft)}
3112
+ .dsh-fk-row:hover{background:var(--dsh-fk-hover)}
3113
+ .dsh-fk-row[data-menu=true],.dsh-fk-row[data-selected=true]{background:var(--dsh-fk-active);box-shadow:inset 2px 0 0 var(--dsh-fk-orange)}
3114
+ .dsh-fk-row:focus-visible{outline:1.5px solid var(--dsh-fk-orange);outline-offset:-2px}
2892
3115
  .dsh-fk-row[data-drop=true]{outline:1.5px dashed var(--dsh-fk-orange);outline-offset:-2px;background:var(--dsh-fk-soft)}
2893
3116
  .dsh-fk-row-static{cursor:default}
2894
3117
  .dsh-fk-rowicon{display:flex;color:var(--dsw-alias-label-secondary);flex:none}
@@ -2901,8 +3124,12 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
2901
3124
  .dsh-fk-row:hover .dsh-fk-rowactions,.dsh-fk-row:focus-within .dsh-fk-rowactions{opacity:1}
2902
3125
  @media (hover:none){.dsh-fk-rowactions{opacity:1}}
2903
3126
  .dsh-fk-editor{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px 12px 12px}
2904
- .dsh-fk-textarea{flex:1;min-height:160px;width:100%;resize:none;box-sizing:border-box;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:var(--dsh-fk-editor-font)/1.62 var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace);outline:none;tab-size:2}
2905
- .dsh-fk-textarea:focus{border-color:var(--dsh-fk-orange)}
3127
+ .dsh-fk-codeeditor{position:relative;flex:1;min-height:160px;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-base)}
3128
+ .dsh-fk-codeeditor:focus-within{border-color:var(--dsh-fk-orange)}
3129
+ .dsh-fk-codeeditor-pre{position:absolute;inset:0;margin:0;padding:10px 12px;overflow:hidden;pointer-events:none;font:var(--dsh-fk-editor-font)/1.62 var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace);white-space:pre;tab-size:2;color:var(--dsw-alias-label-primary)}
3130
+ .dsh-fk-codeeditor-line{min-height:1.62em;white-space:pre}
3131
+ .dsh-fk-codeeditor-input{position:absolute;inset:0;box-sizing:border-box;width:100%;height:100%;margin:0;padding:10px 12px;border:0;resize:none;background:transparent;color:transparent;caret-color:var(--dsw-alias-label-primary);font:var(--dsh-fk-editor-font)/1.62 var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace);white-space:pre;overflow:auto;tab-size:2;outline:none}
3132
+ .dsh-fk-codeeditor-input::selection{background:rgb(244 81 30/.24)}
2906
3133
  .dsh-fk-editor-meta{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dsw-alias-label-secondary);flex:none}
2907
3134
  .dsh-fk-badge{display:inline-flex;align-items:center;height:18px;padding:0 7px;margin-left:6px;border-radius:9px;background:var(--dsh-fk-grad);color:#fff;font-size:10px;font-weight:600}
2908
3135
  .dsh-fk-warn{display:flex;align-items:center;gap:6px;margin:0;padding:7px 10px;border-radius:7px;background:var(--dsh-fk-soft);color:var(--dsw-alias-state-warn-primary);font-size:12px}
@@ -2926,11 +3153,11 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
2926
3153
  .dsh-fk-dialog-crumbs{flex:none;border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:6px}
2927
3154
  .dsh-fk-picklist{display:flex;flex-direction:column;gap:2px;max-height:260px;overflow:auto;padding:2px}
2928
3155
  .dsh-fk-pickrow{display:flex;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;text-align:left;cursor:pointer}
2929
- .dsh-fk-pickrow:hover{background:var(--dsw-alias-bg-layer-2)}
3156
+ .dsh-fk-pickrow:hover{background:var(--dsh-fk-hover)}
2930
3157
  .dsh-fk-scrim{position:fixed;inset:0;z-index:1300}
2931
3158
  .dsh-fk-menu{position:fixed;z-index:1301;display:flex;flex-direction:column;gap:1px;padding:5px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-overlay);box-shadow:0 14px 40px rgb(0 0 0/.26)}
2932
3159
  .dsh-fk-menu button{display:flex;align-items:center;gap:8px;width:100%;padding:6px 9px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;text-align:left;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2933
- .dsh-fk-menu button:hover{background:var(--dsw-alias-bg-layer-2)}
3160
+ .dsh-fk-menu button:hover{background:var(--dsh-fk-hover)}
2934
3161
  .dsh-fk-menu button:disabled{opacity:.45;cursor:default}
2935
3162
  .dsh-fk-menu button[data-danger=true]{color:var(--dsw-alias-state-error-primary)}
2936
3163
  .dsh-fk-menulabel{overflow:hidden;text-overflow:ellipsis}
@@ -2942,14 +3169,15 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
2942
3169
  .dsh-fk-filestab{background:transparent}
2943
3170
  .dsh-fk-treehead{display:flex;align-items:center;gap:6px;padding:7px 8px 7px 10px;border-bottom:1px solid var(--dsw-alias-border-l2);flex:none}
2944
3171
  .dsh-fk-rootpath{display:flex;align-items:center;gap:6px;min-width:0;max-width:100%;padding:4px 7px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
2945
- .dsh-fk-rootpath:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
3172
+ .dsh-fk-rootpath:hover{background:var(--dsh-fk-hover);color:var(--dsw-alias-label-primary)}
2946
3173
  .dsh-fk-rootpath[data-drop=true]{background:var(--dsh-fk-soft);box-shadow:inset 0 0 0 1px var(--dsh-fk-orange);color:var(--dsh-fk-orange)}
2947
3174
  .dsh-fk-rootpathtext{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2948
3175
  .dsh-fk-treebody{position:relative;flex:1;min-height:0;overflow:auto;padding:4px 5px 22px}
2949
3176
  .dsh-fk-tlevel{display:flex;flex-direction:column;gap:0}
2950
3177
  .dsh-fk-trow{display:flex;align-items:center;gap:5px;min-height:26px;padding:2px 6px 2px 5px;border-radius:6px;cursor:pointer;outline:none;user-select:none}
2951
- .dsh-fk-trow:hover,.dsh-fk-trow:focus-visible{background:var(--dsw-alias-bg-layer-2)}
2952
- .dsh-fk-trow[data-selected=true]{background:var(--dsh-fk-soft)}
3178
+ .dsh-fk-trow:hover{background:var(--dsh-fk-hover)}
3179
+ .dsh-fk-trow[data-menu=true],.dsh-fk-trow[data-selected=true]{background:var(--dsh-fk-active);box-shadow:inset 2px 0 0 var(--dsh-fk-orange)}
3180
+ .dsh-fk-trow:focus-visible{outline:1.5px solid var(--dsh-fk-orange);outline-offset:-2px}
2953
3181
  .dsh-fk-trow[data-drop=true]{outline:1.5px dashed var(--dsh-fk-orange);outline-offset:-2px;background:var(--dsh-fk-soft)}
2954
3182
  .dsh-fk-trow[data-kind=directory] .dsh-fk-trowname{font-weight:500}
2955
3183
  .dsh-fk-tchev{display:flex;align-items:center;justify-content:center;width:13px;flex:none;color:var(--dsw-alias-label-tertiary,#8b8b8b)}
@@ -2992,7 +3220,7 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
2992
3220
  .dsh-fk-switch[data-on=true] .dsh-fk-switchknob{left:19px}
2993
3221
  .dsh-fk-segmented{display:inline-flex;gap:2px;padding:2px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;flex:none}
2994
3222
  .dsh-fk-segment{padding:4px 9px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11.5px;cursor:pointer;white-space:nowrap}
2995
- .dsh-fk-segment:hover{background:var(--dsw-alias-bg-layer-2)}
3223
+ .dsh-fk-segment:hover{background:var(--dsh-fk-hover)}
2996
3224
  .dsh-fk-segment[data-on=true]{background:var(--dsh-fk-grad);color:#fff}
2997
3225
  .dsh-fk-fontpicker{display:flex;align-items:center;gap:10px;flex:none}
2998
3226
  .dsh-fk-range{width:130px;accent-color:var(--dsh-fk-orange)}
package/index.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  const ROUTE = '/dsh-filekit';
39
39
 
40
40
  /** Plugin version mirrored in package.json. */
41
- const VERSION = '0.2.1';
41
+ const VERSION = '0.2.2';
42
42
 
43
43
  /** Request body cap: larger than the editor's text cap, small enough to stay sane. */
44
44
  const BODY_LIMIT = 16 * 1024 * 1024;
@@ -272,12 +272,12 @@ async function runOperation(ctx, op, args) {
272
272
  }
273
273
  case 'move': {
274
274
  const root = await resolveRequestRoot(ctx, args);
275
- const result = await moveEntry(root, args.path, args.toDir);
275
+ const result = await moveEntry(root, args.path, args.toDir, { unique: args.unique === true });
276
276
  return { ...result, change: { kind: 'move', ...result } };
277
277
  }
278
278
  case 'copy': {
279
279
  const root = await resolveRequestRoot(ctx, args);
280
- const result = await copyEntry(root, args.path, args.toDir);
280
+ const result = await copyEntry(root, args.path, args.toDir, { unique: args.unique === true });
281
281
  return { ...result, change: { kind: 'copy', ...result } };
282
282
  }
283
283
  case 'delete': {
package/lib/filekit.js CHANGED
@@ -356,8 +356,9 @@ export async function renameEntry(root, rel, name) {
356
356
  * @param {string} root - workspace root.
357
357
  * @param {string} rel - existing entry path.
358
358
  * @param {string} toDirRel - destination directory.
359
+ * @param {{ unique?: boolean }} [options] - `unique` picks a free "name (n)" when the name is taken.
359
360
  */
360
- export async function moveEntry(root, rel, toDirRel) {
361
+ export async function moveEntry(root, rel, toDirRel, options = {}) {
361
362
  const safeRel = normalizeRel(rel);
362
363
  const toDir = normalizeRel(toDirRel, { allowRoot: true });
363
364
  if (safeRel === '') fail('protected', 'The workspace root cannot be moved.');
@@ -371,7 +372,11 @@ export async function moveEntry(root, rel, toDirRel) {
371
372
  await assertExistingInside(root, dirAbs);
372
373
  if (path.dirname(abs) === dirAbs) return { root, path: safeRel, from: safeRel, unchanged: true };
373
374
  if (within(abs, dirAbs)) fail('bad-move', 'Cannot move a directory into itself.');
374
- const target = path.join(dirAbs, path.basename(abs));
375
+ const name = path.basename(abs);
376
+ const target =
377
+ (await lstatOrNull(path.join(dirAbs, name))) === undefined || options.unique !== true
378
+ ? path.join(dirAbs, name)
379
+ : path.join(dirAbs, await uniqueTargetName(dirAbs, name));
375
380
  if ((await lstatOrNull(target)) !== undefined) fail('exists', 'Destination already has an entry with that name.');
376
381
  await fs.rename(abs, target);
377
382
  return { root, path: relOf(root, target), from: safeRel };
@@ -382,8 +387,9 @@ export async function moveEntry(root, rel, toDirRel) {
382
387
  * @param {string} root - workspace root.
383
388
  * @param {string} rel - source entry path.
384
389
  * @param {string} toDirRel - destination directory.
390
+ * @param {{ unique?: boolean }} [options] - `unique` picks a free "name (n)" when the name is taken.
385
391
  */
386
- export async function copyEntry(root, rel, toDirRel) {
392
+ export async function copyEntry(root, rel, toDirRel, options = {}) {
387
393
  const safeRel = normalizeRel(rel);
388
394
  const toDir = normalizeRel(toDirRel, { allowRoot: true });
389
395
  const abs = absOf(root, safeRel);
@@ -397,7 +403,11 @@ export async function copyEntry(root, rel, toDirRel) {
397
403
  if (!dirStat.isDirectory()) fail('not-directory', 'Destination is not a directory.');
398
404
  await assertExistingInside(root, dirAbs);
399
405
  if (within(abs, dirAbs)) fail('bad-copy', 'Cannot copy a directory into itself.');
400
- const target = path.join(dirAbs, path.basename(abs));
406
+ const name = path.basename(abs);
407
+ const target =
408
+ (await lstatOrNull(path.join(dirAbs, name))) === undefined || options.unique !== true
409
+ ? path.join(dirAbs, name)
410
+ : path.join(dirAbs, await uniqueTargetName(dirAbs, name));
401
411
  if ((await lstatOrNull(target)) !== undefined) fail('exists', 'Destination already has an entry with that name.');
402
412
  if (source.isDirectory()) {
403
413
  await fs.cp(abs, target, { recursive: true, force: false, errorOnExist: true, preserveTimestamps: true });
@@ -407,6 +417,22 @@ export async function copyEntry(root, rel, toDirRel) {
407
417
  return { root, path: relOf(root, target), from: safeRel };
408
418
  }
409
419
 
420
+ /**
421
+ * First free `stem (n).ext` (or `name (n)` for directories) in one directory.
422
+ * @param {string} dirAbs - destination directory (absolute).
423
+ * @param {string} name - original entry name.
424
+ * @returns {Promise<string>} a name no entry currently uses.
425
+ */
426
+ async function uniqueTargetName(dirAbs, name) {
427
+ const ext = path.extname(name);
428
+ const stem = ext === '' ? name : name.slice(0, -ext.length);
429
+ for (let index = 2; index < 1000; index += 1) {
430
+ const candidate = `${stem} (${index})${ext}`;
431
+ if ((await lstatOrNull(path.join(dirAbs, candidate))) === undefined) return candidate;
432
+ }
433
+ fail('exists', 'Could not find a free name for the pasted entry.');
434
+ }
435
+
410
436
  /**
411
437
  * Ensure the trash directory exists.
412
438
  * @param {string} root - workspace root.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@restregular/dsh-filekit",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "FileKit — 在右侧「工作区文件」面板中右键操作、拖拽移动、编辑、复制引用与可恢复删除(.dsh-trash 回收站),变更自动告知 Agent;另含独立 FileKit 文件管理页与设置页。 · Workspace file operations for the DeepSeek Harness right sidebar: context menus, drag-to-move, editor, recoverable trash, Agent change notices, a FileKit page, and settings.",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",