@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 +4 -2
- package/client.js +257 -29
- package/index.js +3 -3
- package/lib/filekit.js +30 -4
- package/package.json +1 -1
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
|
-
-
|
|
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: '
|
|
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(
|
|
1683
|
-
className: 'dsh-fk-textarea',
|
|
1879
|
+
h(CodeEditor, {
|
|
1684
1880
|
value: draft,
|
|
1685
|
-
|
|
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: () =>
|
|
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
|
-
|
|
2146
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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
|
|
2891
|
-
.dsh-fk-row[data-selected=true]{background:var(--dsh-fk-
|
|
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-
|
|
2905
|
-
.dsh-fk-
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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
|
|
2952
|
-
.dsh-fk-trow[data-selected=true]{background:var(--dsh-fk-
|
|
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(--
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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",
|