@restregular/dsh-filekit 0.2.1 → 0.2.3

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`,可恢复 |
@@ -39,10 +40,17 @@ dsh plugin --profile web add @restregular/dsh-filekit
39
40
  ### FileKit 文件管理页
40
41
 
41
42
  - 独立侧边栏标签页(`filekit` kind),不是浮层弹窗;
42
- - 面包屑浏览、新建、复制、移动、删除、回收站;
43
- - 内置文本编辑器(`Ctrl/Cmd+S` 保存、未保存守卫、保存后刷新);
44
- - 右键菜单与拖拽移动;
45
- - 有未保存修改时,关闭标签页会被阻止并提示先保存或放弃。
43
+ - 面包屑浏览、新建、复制/剪切/粘贴、移动、删除、回收站;
44
+ - 右键菜单与拖拽移动。
45
+
46
+ ### 编辑器标签页
47
+
48
+ - 每个文件一个独立标签页(`filekit-edit` kind),和预览页可以互相切换:
49
+ - 预览标题栏的**编辑**按钮把当前预览标签页切换成编辑页;
50
+ - 编辑页的**保存**按钮保存后切回该文件的预览页;**放弃修改**确认后也切回预览。
51
+ - 与内置预览同一套 Shiki 高亮器、行内滚动同步、`Ctrl/Cmd+S` 保存;
52
+ - 未保存时标签页标题右侧显示橙色圆点;关闭未保存的标签页会被阻止并提示;
53
+ - 工具栏全部为图标按钮,悬停显示提示文本(复制相对路径、引用、预览、放弃修改、保存)。
46
54
 
47
55
  ### 设置页
48
56
 
@@ -113,6 +121,7 @@ action: list | read | write | create | rename | move | copy | delete
113
121
  - 回收站恢复要求原父目录仍然存在;父目录被改名或删除时需要先手动恢复目录。
114
122
  - Host 路由只监听本机回环地址;如果通过 Pocket/公网隧道在另一台设备上打开页面,界面数据请求会被拒绝(有意的安全边界)。
115
123
  - 编辑器按 UTF-8 读写,不做编码转换;超限文件以只读截断方式展示。
124
+ - 文件剪贴板是页面内存状态(不是系统剪贴板),刷新页面或重启 DSH 后清空。
116
125
  - 接管文件页后,宿主「在系统中打开目录」按钮不再出现在该页标题栏(宿主把该按钮挂在文件页的子槽位里,接管页不使用该子槽位)。
117
126
 
118
127
  ## 故障排查
package/client.js CHANGED
@@ -32,6 +32,11 @@
32
32
  const FILEKIT_ID = '@restregular/dsh-filekit';
33
33
  const FILES_KIND = 'files';
34
34
  const FILEKIT_KIND = 'filekit';
35
+ const EDITOR_KIND = 'filekit-edit';
36
+ /** Identity of the per-file editor tab type and its body cell. */
37
+ const EDITOR_ID = '@restregular/dsh-filekit/editor';
38
+ /** Resource scheme for editor tabs; the address carries the file, so it survives restarts. */
39
+ const EDITOR_PREFIX = 'dsh-resource://filekit-edit/session/';
35
40
 
36
41
  const factories = window.__ModuleLoader__?.factories;
37
42
  if (factories !== undefined && typeof factories.has === 'function' && factories.has(MODULE_ID)) return;
@@ -42,6 +47,20 @@
42
47
  const React = require('react');
43
48
  const h = React.createElement;
44
49
 
50
+ // Harness primitives give the editor the same Shiki highlighter the
51
+ // built-in document preview uses. Optional: a missing module degrades to
52
+ // a plain textarea instead of breaking the whole plugin.
53
+ let primitives = undefined;
54
+ try {
55
+ primitives = require('@deepseek-ai/dsh-client-ui-primitives');
56
+ } catch {
57
+ primitives = undefined;
58
+ }
59
+ const useCodeHighlighter =
60
+ typeof primitives?.useCodeHighlighter === 'function' ? primitives.useCodeHighlighter : () => () => undefined;
61
+ const languageForPath =
62
+ typeof primitives?.languageForPath === 'function' ? primitives.languageForPath : () => undefined;
63
+
45
64
  // ------------------------------------------------------------------
46
65
  // Copy
47
66
  // ------------------------------------------------------------------
@@ -79,7 +98,10 @@
79
98
  openPreview: '打开预览',
80
99
  rename: '重命名',
81
100
  move: '移动到…',
82
- copy: '复制一份',
101
+ copy: '复制',
102
+ cut: '剪切',
103
+ paste: '粘贴',
104
+ pasteHere: '粘贴到此处',
83
105
  copyRel: '复制相对路径',
84
106
  copyAbs: '复制绝对路径',
85
107
  reference: '引用到对话框',
@@ -109,6 +131,9 @@
109
131
  emptyHint: '回收站里的所有条目都会被永久删除,无法恢复。',
110
132
  discardTitle: '放弃未保存的修改?',
111
133
  discardConfirm: '放弃修改',
134
+ discardHint: '放弃后该标签页会切换回文件预览。',
135
+ editorTab: '编辑',
136
+ editorNoFile: '这个标签页没有要编辑的文件。',
112
137
  dirty: '未保存',
113
138
  discard: '放弃修改',
114
139
  binary: '这是二进制文件,不能用文本编辑器打开。',
@@ -120,6 +145,9 @@
120
145
  renamed: '已重命名',
121
146
  moved: '已移动',
122
147
  copied: '已复制',
148
+ cutDone: '已剪切',
149
+ pasted: '已粘贴',
150
+ pastedTo: '已粘贴到 {path}',
123
151
  deleted: '已移入回收站',
124
152
  restored: '已恢复',
125
153
  purged: '已永久删除',
@@ -216,7 +244,10 @@
216
244
  openPreview: 'Open preview',
217
245
  rename: 'Rename',
218
246
  move: 'Move to…',
219
- copy: 'Duplicate',
247
+ copy: 'Copy',
248
+ cut: 'Cut',
249
+ paste: 'Paste',
250
+ pasteHere: 'Paste here',
220
251
  copyRel: 'Copy relative path',
221
252
  copyAbs: 'Copy absolute path',
222
253
  reference: 'Reference in composer',
@@ -246,6 +277,9 @@
246
277
  emptyHint: 'Every entry in the recycle bin is permanently deleted; this cannot be undone.',
247
278
  discardTitle: 'Discard unsaved changes?',
248
279
  discardConfirm: 'Discard',
280
+ discardHint: 'Discarding switches this tab back to the file preview.',
281
+ editorTab: 'Edit',
282
+ editorNoFile: 'This tab has no file to edit.',
249
283
  dirty: 'Unsaved',
250
284
  discard: 'Discard',
251
285
  binary: 'This is a binary file; the text editor cannot open it.',
@@ -257,6 +291,9 @@
257
291
  renamed: 'Renamed',
258
292
  moved: 'Moved',
259
293
  copied: 'Copied',
294
+ cutDone: 'Cut',
295
+ pasted: 'Pasted',
296
+ pastedTo: 'Pasted into {path}',
260
297
  deleted: 'Moved to the recycle bin',
261
298
  restored: 'Restored',
262
299
  purged: 'Permanently deleted',
@@ -517,6 +554,26 @@
517
554
  return 'dsh-resource://file/session/' + id + '/' + rel.split('/').map((part) => encodeURIComponent(part)).join('/');
518
555
  }
519
556
 
557
+ /** Address of a FileKit editor tab; unique per session+file, and persisted as its contentId. */
558
+ function editorAddress(sessionId, rel) {
559
+ return EDITOR_PREFIX + encodeURIComponent(String(sessionId)) + '/' + rel.split('/').map((part) => encodeURIComponent(part)).join('/');
560
+ }
561
+
562
+ /** Read the file out of an editor tab address; `undefined` for any other resource. */
563
+ function parseEditorAddress(address) {
564
+ if (typeof address !== 'string' || !address.startsWith(EDITOR_PREFIX)) return undefined;
565
+ const rest = address.slice(EDITOR_PREFIX.length);
566
+ const slash = rest.indexOf('/');
567
+ if (slash <= 0 || slash === rest.length - 1) return undefined;
568
+ try {
569
+ const sessionId = decodeURIComponent(rest.slice(0, slash));
570
+ const rel = rest.slice(slash + 1).split('/').map((part) => decodeURIComponent(part)).join('/');
571
+ return { sessionId, rel };
572
+ } catch {
573
+ return undefined;
574
+ }
575
+ }
576
+
520
577
  function formatBytes(size) {
521
578
  if (typeof size !== 'number' || !Number.isFinite(size)) return '';
522
579
  if (size < 1024) return `${size} B`;
@@ -599,6 +656,405 @@
599
656
  return h('div', { className: 'dsh-fk-toast', 'data-kind': spec.kind, role: 'status' }, spec.text);
600
657
  }
601
658
 
659
+ // ------------------------------------------------------------------
660
+ // File clipboard (copy / cut → paste)
661
+ // ------------------------------------------------------------------
662
+
663
+ /** Shared across both panels: copy in one, paste in the other. */
664
+ let fileClipboard = null;
665
+ const clipboardListeners = new Set();
666
+
667
+ function setFileClipboard(value) {
668
+ fileClipboard = value;
669
+ for (const listener of clipboardListeners) {
670
+ try {
671
+ listener();
672
+ } catch {
673
+ /* ignore */
674
+ }
675
+ }
676
+ }
677
+
678
+ function useFileClipboard() {
679
+ const [value, setValue] = React.useState(fileClipboard);
680
+ React.useEffect(() => {
681
+ const listener = () => setValue(fileClipboard);
682
+ clipboardListeners.add(listener);
683
+ return () => clipboardListeners.delete(listener);
684
+ }, []);
685
+ return value;
686
+ }
687
+
688
+ /** Put one entry on the FileKit clipboard (no filesystem work yet). */
689
+ function copyToFileClipboard(root, rel, kind, mode) {
690
+ setFileClipboard({ mode, root, rel, kind, name: baseOf(rel) });
691
+ showToast(mode === 'cut' ? t('cutDone') : t('copied'), 'ok');
692
+ }
693
+
694
+ // ------------------------------------------------------------------
695
+ // Highlighted code editor
696
+ // ------------------------------------------------------------------
697
+
698
+ /** Above this size the editor skips highlighting so typing stays fluid. */
699
+ const HIGHLIGHT_MAX_CHARS = 200_000;
700
+
701
+ function CodeEditor({ value, onChange, rel, fontSize, readOnly = false }) {
702
+ const inputRef = React.useRef(null);
703
+ const preRef = React.useRef(null);
704
+ const [composing, setComposing] = React.useState(false);
705
+ const deferred = React.useDeferredValue(value);
706
+ const language = React.useMemo(() => languageForPath(rel), [rel]);
707
+ const highlight = useCodeHighlighter(language);
708
+ const plainLines = React.useMemo(() => String(deferred).split('\n'), [deferred]);
709
+ const lines = React.useMemo(() => {
710
+ if (typeof highlight !== 'function' || deferred.length > HIGHLIGHT_MAX_CHARS) return undefined;
711
+ try {
712
+ const result = highlight(deferred);
713
+ return Array.isArray(result) ? result : undefined;
714
+ } catch {
715
+ return undefined;
716
+ }
717
+ }, [deferred, highlight]);
718
+ const totalLines = String(value).split('\n').length;
719
+ const rendered = [];
720
+ const source = lines ?? plainLines;
721
+ for (let index = 0; index < Math.max(source.length, totalLines); index += 1) {
722
+ const line = source[index];
723
+ rendered.push(
724
+ h(
725
+ 'div',
726
+ { className: 'dsh-fk-codeeditor-line', key: index },
727
+ Array.isArray(line)
728
+ ? line.map((token, tokenIndex) => h('span', { key: tokenIndex, style: token.style }, token.text))
729
+ : line ?? '',
730
+ ),
731
+ );
732
+ }
733
+
734
+ const syncScroll = (event) => {
735
+ const pre = preRef.current;
736
+ if (pre === null) return;
737
+ pre.scrollTop = event.currentTarget.scrollTop;
738
+ pre.scrollLeft = event.currentTarget.scrollLeft;
739
+ };
740
+
741
+ // After edits (or a highlight pass) the textarea may have scrolled under
742
+ // the caret; keep the painted layer on the same scroll offset.
743
+ React.useEffect(() => {
744
+ const input = inputRef.current;
745
+ const pre = preRef.current;
746
+ if (input === null || pre === null) return;
747
+ pre.scrollTop = input.scrollTop;
748
+ pre.scrollLeft = input.scrollLeft;
749
+ }, [value, lines]);
750
+
751
+ return h(
752
+ 'div',
753
+ { className: 'dsh-fk-codeeditor' },
754
+ h(
755
+ 'pre',
756
+ {
757
+ className: 'dsh-fk-codeeditor-pre',
758
+ ref: preRef,
759
+ 'aria-hidden': true,
760
+ style: { fontSize: `${fontSize}px`, visibility: composing ? 'hidden' : 'visible' },
761
+ },
762
+ rendered,
763
+ ),
764
+ h('textarea', {
765
+ ref: inputRef,
766
+ className: 'dsh-fk-codeeditor-input',
767
+ value,
768
+ spellCheck: false,
769
+ readOnly,
770
+ wrap: 'off',
771
+ autoCapitalize: 'off',
772
+ autoCorrect: 'off',
773
+ autoComplete: 'off',
774
+ style: {
775
+ fontSize: `${fontSize}px`,
776
+ // IME composition text would be invisible through the transparent
777
+ // layer; show it plainly while the candidate window is open.
778
+ color: composing ? 'var(--dsw-alias-label-primary)' : 'transparent',
779
+ },
780
+ onChange: (event) => onChange(event.target.value),
781
+ onScroll: syncScroll,
782
+ onCompositionStart: () => setComposing(true),
783
+ onCompositionEnd: () => setComposing(false),
784
+ onKeyDown: (event) => {
785
+ if (readOnly || event.key !== 'Tab') return;
786
+ event.preventDefault();
787
+ const area = event.currentTarget;
788
+ const start = area.selectionStart;
789
+ const end = area.selectionEnd;
790
+ onChange(`${value.slice(0, start)} ${value.slice(end)}`);
791
+ window.requestAnimationFrame(() => {
792
+ area.selectionStart = start + 2;
793
+ area.selectionEnd = start + 2;
794
+ });
795
+ },
796
+ }),
797
+ );
798
+ }
799
+
800
+ /**
801
+ * One per-file editor tab. Saving (or discarding) swaps this tab for the
802
+ * file's preview tab, so the pair behave as one page with two modes.
803
+ */
804
+ function FileEditorTab(props) {
805
+ const { useTabInfo, sessionId, inputActions } = props;
806
+ const { tab } = useTabInfo();
807
+ const settingsSnapshot = useSettings();
808
+ const params = tab.navigation?.params;
809
+ // Navigation params carry the file on first open; a restored layout only
810
+ // has the tab's contentId, which is the editor address.
811
+ const rel =
812
+ typeof params?.rel === 'string' && params.rel !== '' ? params.rel : parseEditorAddress(tab.contentId)?.rel ?? '';
813
+ const [root, setRoot] = React.useState('');
814
+ const [error, setError] = React.useState('');
815
+ const [editor, setEditor] = React.useState(null);
816
+ const [draft, setDraft] = React.useState('');
817
+ const [saving, setSaving] = React.useState(false);
818
+ const [discardOpen, setDiscardOpen] = React.useState(false);
819
+ const draftRef = React.useRef('');
820
+ const savingRef = React.useRef(false);
821
+ const saveRef = React.useRef(null);
822
+ draftRef.current = draft;
823
+ const dirty = editor !== null && draft !== editor.original;
824
+
825
+ // The tab chip shows the unsaved marker through this flag.
826
+ React.useEffect(() => {
827
+ setTabDirty(tab.id, dirty);
828
+ return () => setTabDirty(tab.id, false);
829
+ }, [tab.id, dirty]);
830
+
831
+ React.useEffect(() => {
832
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined;
833
+ let dead = false;
834
+ api('workspace', { sessionId })
835
+ .then((data) => {
836
+ if (!dead) setRoot(data.root);
837
+ })
838
+ .catch((failure) => {
839
+ if (!dead) setError(errorText(failure));
840
+ });
841
+ return () => {
842
+ dead = true;
843
+ };
844
+ }, [sessionId]);
845
+
846
+ React.useEffect(() => {
847
+ if (root === '' || rel === '') return undefined;
848
+ let dead = false;
849
+ setEditor(null);
850
+ setDraft('');
851
+ setError('');
852
+ api('read', { root, path: rel, sessionId })
853
+ .then((data) => {
854
+ if (dead) return;
855
+ if (data.binary === true) {
856
+ setError(t('binary'));
857
+ return;
858
+ }
859
+ setEditor({
860
+ rel,
861
+ original: data.text,
862
+ mtimeMs: data.mtimeMs,
863
+ size: data.size,
864
+ truncated: data.truncated === true,
865
+ });
866
+ setDraft(data.text);
867
+ })
868
+ .catch((failure) => {
869
+ if (!dead) setError(errorText(failure));
870
+ });
871
+ return () => {
872
+ dead = true;
873
+ };
874
+ }, [root, rel, sessionId]);
875
+
876
+ const switchToPreview = () => {
877
+ setTabDirty(tab.id, false);
878
+ try {
879
+ tab.actions.openResource(fileAddress(sessionId, rel), { replaceTab: true });
880
+ } catch (failure) {
881
+ showToast(errorText(failure), 'error');
882
+ }
883
+ };
884
+
885
+ const save = async () => {
886
+ const current = editor;
887
+ const text = draftRef.current;
888
+ if (current === null || savingRef.current || current.truncated || text === current.original) return;
889
+ savingRef.current = true;
890
+ setSaving(true);
891
+ try {
892
+ await api('write', { root, path: rel, content: text, expectedMtimeMs: current.mtimeMs, sessionId });
893
+ showToast(t('saved'));
894
+ switchToPreview();
895
+ } catch (failure) {
896
+ setError(errorText(failure));
897
+ showToast(errorText(failure), 'error');
898
+ } finally {
899
+ savingRef.current = false;
900
+ setSaving(false);
901
+ }
902
+ };
903
+ saveRef.current = save;
904
+
905
+ const discard = () => {
906
+ if (!dirty) {
907
+ switchToPreview();
908
+ return;
909
+ }
910
+ setDiscardOpen(true);
911
+ };
912
+
913
+ React.useEffect(() => {
914
+ const onKeyDown = (event) => {
915
+ if (tab.visible !== true) return;
916
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's' && editor !== null) {
917
+ event.preventDefault();
918
+ saveRef.current?.();
919
+ }
920
+ };
921
+ window.addEventListener('keydown', onKeyDown);
922
+ return () => window.removeEventListener('keydown', onKeyDown);
923
+ }, [editor, tab.visible]);
924
+
925
+ const copyRelPath = async () => {
926
+ const ok = await copyText(rel);
927
+ showToast(ok ? t('copiedPath') : t('error.internal'), ok ? 'ok' : 'error');
928
+ };
929
+
930
+ const referenceRel = async () => {
931
+ const text = `@${rel} `;
932
+ if (inputActions !== undefined && typeof inputActions.captureInsertion === 'function' && typeof inputActions.insertText === 'function') {
933
+ try {
934
+ const span = inputActions.captureInsertion();
935
+ if (inputActions.insertText(text, span) === true) {
936
+ inputActions.persistDraft?.();
937
+ showToast(t('referenced'));
938
+ return;
939
+ }
940
+ } catch {
941
+ /* fall back to the clipboard */
942
+ }
943
+ }
944
+ const ok = await copyText(text);
945
+ showToast(ok ? t('referencedCopied') : t('error.internal'), ok ? 'ok' : 'error');
946
+ };
947
+
948
+ if (params === undefined) {
949
+ return h('div', { className: 'dsh-fk-tabstatus' }, h('p', { className: 'dsh-fk-hint' }, t('loading')));
950
+ }
951
+ if (rel === '') {
952
+ return h('div', { className: 'dsh-fk-tabstatus' }, h('p', { className: 'dsh-fk-error' }, t('editorNoFile')));
953
+ }
954
+ if (error !== '' && editor === null) {
955
+ return h(
956
+ 'div',
957
+ { className: 'dsh-fk-tabstatus' },
958
+ h('p', { className: 'dsh-fk-error' }, error),
959
+ h('button', { type: 'button', className: 'dsh-fk-btn', onClick: switchToPreview }, t('openPreview')),
960
+ );
961
+ }
962
+ if (editor === null) {
963
+ return h('div', { className: 'dsh-fk-tabstatus' }, h('p', { className: 'dsh-fk-hint' }, t('loading')));
964
+ }
965
+
966
+ return h(
967
+ 'div',
968
+ { className: 'dsh-fk-tab' },
969
+ h(
970
+ 'div',
971
+ { className: 'dsh-fk-editorhead' },
972
+ h(
973
+ 'div',
974
+ { className: 'dsh-fk-editorpath', title: joinAbs(root, rel) },
975
+ h(Icon, { name: 'file', size: 15 }),
976
+ h('span', { className: 'dsh-fk-editorpathname' }, baseOf(rel)),
977
+ h('span', { className: 'dsh-fk-editorpathdir' }, dirOf(rel)),
978
+ ),
979
+ h('span', { className: 'dsh-fk-spacer' }),
980
+ dirty ? h('span', { className: 'dsh-fk-badge' }, t('dirty')) : null,
981
+ h(
982
+ 'span',
983
+ { className: 'dsh-fk-iconactions' },
984
+ h(IconButton, { icon: 'copy', label: t('copyRel'), onClick: copyRelPath }),
985
+ h(IconButton, { icon: 'link', label: t('reference'), onClick: referenceRel }),
986
+ h(IconButton, { icon: 'file', label: t('openPreview'), onClick: switchToPreview, disabled: saving }),
987
+ h(IconButton, {
988
+ icon: 'close',
989
+ label: t('discard'),
990
+ onClick: discard,
991
+ disabled: saving || !dirty,
992
+ }),
993
+ h(IconButton, {
994
+ icon: 'check',
995
+ label: saving ? t('saving') : t('save'),
996
+ onClick: save,
997
+ disabled: saving || !dirty || editor.truncated,
998
+ }),
999
+ ),
1000
+ ),
1001
+ h(
1002
+ 'div',
1003
+ { className: 'dsh-fk-editor' },
1004
+ editor.truncated ? h('p', { className: 'dsh-fk-warn' }, h(Icon, { name: 'warning', size: 14 }), t('truncated')) : null,
1005
+ h(CodeEditor, {
1006
+ value: draft,
1007
+ onChange: setDraft,
1008
+ rel,
1009
+ fontSize: settingsSnapshot.editorFontSize,
1010
+ readOnly: editor.truncated,
1011
+ }),
1012
+ h(
1013
+ 'div',
1014
+ { className: 'dsh-fk-editor-meta' },
1015
+ h('span', null, `${formatBytes(editor.size)}${dirty ? ` → ${formatBytes(new TextEncoder().encode(draft).length)}` : ''}`),
1016
+ h('span', { className: 'dsh-fk-spacer' }),
1017
+ h('span', null, t('modified')),
1018
+ h('span', null, new Date(editor.mtimeMs).toLocaleString()),
1019
+ ),
1020
+ ),
1021
+ error !== '' ? h('div', { className: 'dsh-fk-status', 'data-kind': 'error' }, error) : null,
1022
+ discardOpen
1023
+ ? h(ConfirmDialog, {
1024
+ key: 'discard',
1025
+ title: t('discardTitle'),
1026
+ text: rel,
1027
+ hint: t('discardHint'),
1028
+ danger: true,
1029
+ confirmText: t('discardConfirm'),
1030
+ onClose: () => setDiscardOpen(false),
1031
+ onConfirm: () => {
1032
+ setTabDirty(tab.id, false);
1033
+ switchToPreview();
1034
+ return true;
1035
+ },
1036
+ })
1037
+ : null,
1038
+ );
1039
+ }
1040
+
1041
+ function EditorTitle({ useTabInfo }) {
1042
+ const { tab } = useTabInfo();
1043
+ const dirty = useTabDirty(tab.id);
1044
+ const rel =
1045
+ typeof tab.navigation?.params?.rel === 'string' && tab.navigation.params.rel !== ''
1046
+ ? tab.navigation.params.rel
1047
+ : parseEditorAddress(tab.contentId)?.rel ?? '';
1048
+ const name = rel === '' ? t('editorTab') : baseOf(rel);
1049
+ return h(
1050
+ 'span',
1051
+ { className: 'dsh-fk-tabtitle', title: rel === '' ? name : rel },
1052
+ h(Icon, { name: 'file', size: 15 }),
1053
+ h('span', { className: 'dsh-fk-tabtitle-name' }, name),
1054
+ dirty ? h('span', { className: 'dsh-fk-tabdot', title: t('dirty') }) : null,
1055
+ );
1056
+ }
1057
+
602
1058
  function createOwnedStyle(css) {
603
1059
  const el = document.createElement('style');
604
1060
  el.setAttribute('data-plugin', OWNER);
@@ -608,15 +1064,69 @@
608
1064
  }
609
1065
 
610
1066
  // ------------------------------------------------------------------
611
- // Dirty tabs (the FileKit page's close guard)
1067
+ // Dirty tabs (close guard + the tab title's unsaved dot)
612
1068
  // ------------------------------------------------------------------
613
1069
 
614
1070
  const dirtyTabs = new Set();
1071
+ const dirtyListeners = new Set();
615
1072
 
616
1073
  function setTabDirty(tabId, dirty) {
617
1074
  if (typeof tabId !== 'string' || tabId === '') return;
1075
+ const before = dirtyTabs.has(tabId);
618
1076
  if (dirty) dirtyTabs.add(tabId);
619
1077
  else dirtyTabs.delete(tabId);
1078
+ if (before === dirtyTabs.has(tabId)) return;
1079
+ for (const listener of dirtyListeners) {
1080
+ try {
1081
+ listener();
1082
+ } catch {
1083
+ /* ignore */
1084
+ }
1085
+ }
1086
+ }
1087
+
1088
+ /** Live dirty flag for one tab, used by its title chip. */
1089
+ function useTabDirty(tabId) {
1090
+ const [dirty, setDirty] = React.useState(() => dirtyTabs.has(tabId));
1091
+ React.useEffect(() => {
1092
+ const listener = () => setDirty(dirtyTabs.has(tabId));
1093
+ dirtyListeners.add(listener);
1094
+ listener();
1095
+ return () => dirtyListeners.delete(listener);
1096
+ }, [tabId]);
1097
+ return dirty;
1098
+ }
1099
+
1100
+ // ------------------------------------------------------------------
1101
+ // Per-file editor tabs
1102
+ // ------------------------------------------------------------------
1103
+
1104
+ /** Find the open tab whose content is one resource address (a preview tab). */
1105
+ function tabIdForContent(sessionId, contentId) {
1106
+ const open = sidebarRightService?.tabsIn?.(sessionId) ?? [];
1107
+ return open.find((item) => item.contentId === contentId)?.id;
1108
+ }
1109
+
1110
+ /**
1111
+ * Open one file's editor tab. The address carries session+file, so the
1112
+ * same file reuses its tab and a restored layout can still resolve it.
1113
+ * An optional `replaceTabId` swaps an existing tab — the preview page's
1114
+ * edit button passes its own tab.
1115
+ */
1116
+ function openEditorTab(sessionId, rel, replaceTabId) {
1117
+ if (typeof rel !== 'string' || rel === '') return;
1118
+ if (sidebarRightService === undefined || typeof sidebarRightService.openResource !== 'function') {
1119
+ showToast(t('error.internal'), 'error');
1120
+ return;
1121
+ }
1122
+ try {
1123
+ sidebarRightService.openResource(editorAddress(sessionId, rel), {
1124
+ params: { rel },
1125
+ ...(typeof replaceTabId === 'string' ? { replaceTab: replaceTabId } : {}),
1126
+ });
1127
+ } catch (failure) {
1128
+ showToast(errorText(failure), 'error');
1129
+ }
620
1130
  }
621
1131
 
622
1132
  // ------------------------------------------------------------------
@@ -676,6 +1186,22 @@
676
1186
  return h('svg', common, h('path', { d: 'M4 20h4L19 9l-4-4L4 16z' }), h('path', { d: 'M13.5 6.5l4 4' }));
677
1187
  case 'copy':
678
1188
  return h('svg', common, h('rect', { x: 9, y: 9, width: 11, height: 11, rx: 2 }), h('path', { d: 'M5 15H4V4h11v1' }));
1189
+ case 'cut':
1190
+ return h(
1191
+ 'svg',
1192
+ common,
1193
+ h('circle', { cx: 6.5, cy: 17.5, r: 2.5 }),
1194
+ h('circle', { cx: 17.5, cy: 17.5, r: 2.5 }),
1195
+ h('path', { d: 'M8.2 15.6L16 3M15.8 15.6L8 3' }),
1196
+ );
1197
+ case 'paste':
1198
+ return h(
1199
+ 'svg',
1200
+ common,
1201
+ h('path', { d: 'M9 4.5h6v3H9z' }),
1202
+ 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' }),
1203
+ h('path', { d: 'M9 12h6M9 16h6' }),
1204
+ );
679
1205
  case 'link':
680
1206
  return h(
681
1207
  'svg',
@@ -749,6 +1275,14 @@
749
1275
  h('rect', { x: 3, y: 4.5, width: 18, height: 15, rx: 2.2 }),
750
1276
  h('path', { d: 'M14.5 4.5v15' }),
751
1277
  );
1278
+ case 'filesPanel':
1279
+ return h(
1280
+ 'svg',
1281
+ common,
1282
+ h('rect', { x: 3, y: 4.5, width: 18, height: 15, rx: 2.2 }),
1283
+ h('path', { d: 'M14.5 4.5v15' }),
1284
+ h('path', { d: 'M6 9h5M6 12h5M6 15h5' }),
1285
+ );
752
1286
  case 'spark':
753
1287
  return h('svg', common, h('path', { d: 'M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9z' }));
754
1288
  default:
@@ -1156,6 +1690,8 @@
1156
1690
  const [dragging, setDragging] = React.useState(null);
1157
1691
  const [dropDir, setDropDir] = React.useState(null);
1158
1692
  const settingsSnapshot = useSettings();
1693
+ const clipboard = useFileClipboard();
1694
+ const canPaste = clipboard !== null && clipboard.root === root;
1159
1695
  const navigation = tab.navigation;
1160
1696
  const editorRef = React.useRef(null);
1161
1697
  const draftRef = React.useRef('');
@@ -1184,30 +1720,12 @@
1184
1720
  }, [notice]);
1185
1721
 
1186
1722
  const loadEditor = React.useCallback(
1187
- async (rel) => {
1188
- if (root === '') return;
1189
- try {
1190
- const data = await api('read', { root, path: rel, sessionId });
1191
- if (data.binary === true) {
1192
- setNotice({ kind: 'error', text: t('binary') });
1193
- return;
1194
- }
1195
- setEditor({
1196
- rel,
1197
- original: data.text,
1198
- mtimeMs: data.mtimeMs,
1199
- size: data.size,
1200
- truncated: data.truncated === true,
1201
- });
1202
- setDraft(data.text);
1203
- setMenu(null);
1204
- setDialog(null);
1205
- setNotice(null);
1206
- } catch (failure) {
1207
- setNotice({ kind: 'error', text: errorText(failure) });
1208
- }
1723
+ (rel) => {
1724
+ setMenu(null);
1725
+ setDialog(null);
1726
+ openEditorTab(sessionId, rel);
1209
1727
  },
1210
- [root, sessionId],
1728
+ [sessionId],
1211
1729
  );
1212
1730
 
1213
1731
  const refresh = React.useCallback(() => setReload((value) => value + 1), []);
@@ -1309,8 +1827,8 @@
1309
1827
  const mutate = async (action, successText) => {
1310
1828
  if (root === '') return false;
1311
1829
  try {
1312
- await action();
1313
- succeed(typeof successText === 'function' ? successText() : successText);
1830
+ const result = await action();
1831
+ succeed(typeof successText === 'function' ? successText(result) : successText);
1314
1832
  return true;
1315
1833
  } catch (failure) {
1316
1834
  setNotice({ kind: 'error', text: errorText(failure) });
@@ -1318,6 +1836,18 @@
1318
1836
  }
1319
1837
  };
1320
1838
 
1839
+ /** Paste the FileKit clipboard into one directory; a taken name gets " (n)". */
1840
+ const pasteInto = async (toDir) => {
1841
+ const clip = fileClipboard;
1842
+ if (clip === null || clip.root !== root) return false;
1843
+ const ok = await mutate(
1844
+ () => api(clip.mode === 'cut' ? 'move' : 'copy', { root, path: clip.rel, toDir, unique: true, sessionId }),
1845
+ (result) => t('pastedTo', { path: result.path }),
1846
+ );
1847
+ if (ok && clip.mode === 'cut') setFileClipboard(null);
1848
+ return ok;
1849
+ };
1850
+
1321
1851
  const copyRelPath = async (rel) => {
1322
1852
  const ok = await copyText(rel);
1323
1853
  showToast(ok ? t('copiedPath') : t('error.internal'), ok ? 'ok' : 'error');
@@ -1679,13 +2209,12 @@
1679
2209
  'div',
1680
2210
  { className: 'dsh-fk-editor' },
1681
2211
  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',
2212
+ h(CodeEditor, {
1684
2213
  value: draft,
1685
- spellCheck: false,
2214
+ onChange: setDraft,
2215
+ rel: editor.rel,
2216
+ fontSize: settingsSnapshot.editorFontSize,
1686
2217
  readOnly: editor.truncated,
1687
- style: { fontSize: `${settingsSnapshot.editorFontSize}px` },
1688
- onChange: (event) => setDraft(event.target.value),
1689
2218
  }),
1690
2219
  h(
1691
2220
  'div',
@@ -1710,7 +2239,7 @@
1710
2239
  key: entry.path,
1711
2240
  className: 'dsh-fk-row',
1712
2241
  'data-kind': entry.kind,
1713
- 'data-selected': selected === entry.path,
2242
+ 'data-menu': menu !== null && menu.rel === entry.path,
1714
2243
  'data-drop': dropDir === entry.path,
1715
2244
  tabIndex: 0,
1716
2245
  draggable: true,
@@ -1744,7 +2273,6 @@
1744
2273
  }
1745
2274
  : undefined,
1746
2275
  onClick: () => {
1747
- setSelected(entry.path);
1748
2276
  if (entry.kind === 'directory') {
1749
2277
  setCwd(entry.path);
1750
2278
  setMenu(null);
@@ -1874,6 +2402,7 @@
1874
2402
  if (menu !== null) {
1875
2403
  if (menu.rel === null) {
1876
2404
  menuItems = [
2405
+ ...(canPaste ? [{ label: t('paste'), icon: 'paste', onSelect: () => pasteInto(cwd) }] : []),
1877
2406
  { label: t('newFile'), onSelect: () => setDialog({ kind: 'create-file' }) },
1878
2407
  { label: t('newDir'), onSelect: () => setDialog({ kind: 'create-dir' }) },
1879
2408
  { separator: true },
@@ -1885,10 +2414,15 @@
1885
2414
  ...(menu.kind === 'file' ? [{ label: t('openPreview'), icon: 'file', onSelect: () => openPreview(menu.rel) }] : []),
1886
2415
  ...(menu.kind === 'directory' ? [{ label: t('newFileHere'), icon: 'filePlus', onSelect: () => setDialog({ kind: 'create-file', dir: menu.rel }) }] : []),
1887
2416
  ...(menu.kind === 'directory' ? [{ label: t('newDirHere'), icon: 'folderPlus', onSelect: () => setDialog({ kind: 'create-dir', dir: menu.rel }) }] : []),
2417
+ ...(menu.kind === 'directory' ? [{ separator: true }] : []),
2418
+ ...(menu.kind === 'directory'
2419
+ ? [{ label: t('pasteHere'), icon: 'paste', disabled: !canPaste, onSelect: () => pasteInto(menu.rel) }]
2420
+ : []),
1888
2421
  { separator: true },
1889
2422
  { label: t('rename'), icon: 'edit', onSelect: () => setDialog({ kind: 'rename', rel: menu.rel }) },
1890
2423
  { 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')) },
2424
+ { label: t('copy'), icon: 'copy', onSelect: () => copyToFileClipboard(root, menu.rel, menu.kind, 'copy') },
2425
+ { label: t('cut'), icon: 'cut', onSelect: () => copyToFileClipboard(root, menu.rel, menu.kind, 'cut') },
1892
2426
  { separator: true },
1893
2427
  { label: t('copyRel'), icon: 'copy', onSelect: () => copyRelPath(menu.rel) },
1894
2428
  { label: t('copyAbs'), icon: 'copy', onSelect: () => copyAbsPath(menu.rel) },
@@ -1924,7 +2458,7 @@
1924
2458
  ),
1925
2459
  h('span', { className: 'dsh-fk-spacer' }),
1926
2460
  h(IconButton, {
1927
- icon: 'panel',
2461
+ icon: 'filesPanel',
1928
2462
  label: t('openFiles'),
1929
2463
  onClick: () => {
1930
2464
  try {
@@ -1952,6 +2486,7 @@
1952
2486
  const { useTabInfo, sessionId, inputActions, useSessions } = props;
1953
2487
  const { tab } = useTabInfo();
1954
2488
  const settingsSnapshot = useSettings();
2489
+ const clipboard = useFileClipboard();
1955
2490
  const [root, setRoot] = React.useState('');
1956
2491
  const [rootError, setRootError] = React.useState('');
1957
2492
  const [levels, setLevels] = React.useState({});
@@ -2142,8 +2677,20 @@
2142
2677
  });
2143
2678
  };
2144
2679
 
2145
- const copyEntry = (rel) =>
2146
- mutate('copy', { path: rel, toDir: dirOf(rel) }, t('copied'), [dirOf(rel)]);
2680
+ /** Paste the shared clipboard into one directory of this workspace. */
2681
+ const pasteInto = (toDir) => {
2682
+ const clip = fileClipboard;
2683
+ if (clip === null || clip.root !== root) return Promise.resolve(false);
2684
+ return mutate(
2685
+ clip.mode === 'cut' ? 'move' : 'copy',
2686
+ { path: clip.rel, toDir, unique: true },
2687
+ t('pasted'),
2688
+ [dirOf(clip.rel), toDir],
2689
+ clip.mode === 'cut' ? () => setFileClipboard(null) : undefined,
2690
+ );
2691
+ };
2692
+
2693
+ const canPaste = clipboard !== null && clipboard.root === root;
2147
2694
 
2148
2695
  const copyRelPath = async (rel) => {
2149
2696
  const ok = await copyText(rel);
@@ -2219,19 +2766,22 @@
2219
2766
  const openEntryMenu = (event, entry) => {
2220
2767
  event.preventDefault();
2221
2768
  event.stopPropagation();
2222
- setSelected(entry.path);
2769
+ // The menu target is highlighted through `data-menu`; closing the menu
2770
+ // must not leave a selection behind (the row was never "selected").
2223
2771
  setMenu({ x: event.clientX, y: event.clientY, rel: entry.path, kind: entry.kind, name: entry.name });
2224
2772
  };
2225
2773
 
2226
2774
  const openBlankMenu = (event) => {
2227
2775
  if (typeof event.target.closest === 'function' && event.target.closest('.dsh-fk-row, .dsh-fk-trow') !== null) return;
2228
2776
  event.preventDefault();
2777
+ setSelected(null);
2229
2778
  setMenu({ x: event.clientX, y: event.clientY, rel: null, kind: 'directory' });
2230
2779
  };
2231
2780
 
2232
2781
  const menuItems = [];
2233
2782
  if (menu !== null) {
2234
2783
  if (menu.rel === null) {
2784
+ if (canPaste) menuItems.push({ label: t('paste'), icon: 'paste', onSelect: () => void pasteInto('') });
2235
2785
  menuItems.push(
2236
2786
  { label: t('newFile'), icon: 'filePlus', onSelect: () => setDialog({ kind: 'create-file', dir: '' }) },
2237
2787
  { label: t('newDir'), icon: 'folderPlus', onSelect: () => setDialog({ kind: 'create-dir', dir: '' }) },
@@ -2245,17 +2795,19 @@
2245
2795
  const isDir = menu.kind === 'directory';
2246
2796
  if (isDir) menuItems.push({ label: t('openDirInFileKit'), icon: 'brand', onSelect: () => openFileKit({ dir: menu.rel }) });
2247
2797
  else menuItems.push({ label: t('openPreview'), icon: 'file', onSelect: () => openPreview(menu.rel) });
2248
- if (!isDir) menuItems.push({ label: t('openEditor'), icon: 'edit', onSelect: () => openFileKit({ rel: menu.rel, action: 'edit' }) });
2798
+ if (!isDir) menuItems.push({ label: t('openEditor'), icon: 'edit', onSelect: () => openEditorTab(sessionId, menu.rel) });
2249
2799
  menuItems.push({ separator: true });
2250
2800
  if (isDir) {
2251
2801
  menuItems.push({ label: t('newFileHere'), icon: 'filePlus', onSelect: () => setDialog({ kind: 'create-file', dir: menu.rel }) });
2252
2802
  menuItems.push({ label: t('newDirHere'), icon: 'folderPlus', onSelect: () => setDialog({ kind: 'create-dir', dir: menu.rel }) });
2803
+ menuItems.push({ label: t('pasteHere'), icon: 'paste', disabled: !canPaste, onSelect: () => void pasteInto(menu.rel) });
2253
2804
  menuItems.push({ separator: true });
2254
2805
  }
2255
2806
  menuItems.push(
2256
2807
  { label: t('rename'), icon: 'edit', onSelect: () => setDialog({ kind: 'rename', rel: menu.rel, entryKind: menu.kind }) },
2257
2808
  { 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) },
2809
+ { label: t('copy'), icon: 'copy', onSelect: () => copyToFileClipboard(root, menu.rel, menu.kind, 'copy') },
2810
+ { label: t('cut'), icon: 'cut', onSelect: () => copyToFileClipboard(root, menu.rel, menu.kind, 'cut') },
2259
2811
  { separator: true },
2260
2812
  { label: t('copyRel'), icon: 'copy', onSelect: () => void copyRelPath(menu.rel) },
2261
2813
  { label: t('copyAbs'), icon: 'copy', onSelect: () => void copyAbsPath(menu.rel) },
@@ -2350,7 +2902,7 @@
2350
2902
  key: entry.path,
2351
2903
  className: 'dsh-fk-trow',
2352
2904
  'data-kind': entry.kind,
2353
- 'data-selected': selected === entry.path,
2905
+ 'data-menu': menu !== null && menu.rel === entry.path,
2354
2906
  'data-drop': dropDir === entry.path,
2355
2907
  role: 'treeitem',
2356
2908
  'aria-expanded': isDir ? open : undefined,
@@ -2360,7 +2912,6 @@
2360
2912
  draggable: true,
2361
2913
  title: entry.path,
2362
2914
  onClick: () => {
2363
- setSelected(entry.path);
2364
2915
  if (isDir) toggleDir(entry.path);
2365
2916
  else openPreview(entry.path);
2366
2917
  },
@@ -2634,6 +3185,14 @@
2634
3185
  const [menu, setMenu] = React.useState(null);
2635
3186
  if (abs === '') return null;
2636
3187
 
3188
+ /** Swap this preview tab for the file's editor tab. */
3189
+ const startEditing = async (target, session) => {
3190
+ const workspace = await api('workspace', { sessionId: session, file: target });
3191
+ const rel = relFromAbs(target, workspace.root);
3192
+ if (rel === null) throw Object.assign(new Error('outside'), { code: 'outside-workspace' });
3193
+ openEditorTab(session, rel, tabIdForContent(session, fileAddress(session, rel)));
3194
+ };
3195
+
2637
3196
  const run = async (label, fn) => {
2638
3197
  try {
2639
3198
  await fn();
@@ -2648,7 +3207,7 @@
2648
3207
  };
2649
3208
 
2650
3209
  const items = [
2651
- { label: t('openEditor'), icon: 'edit', onSelect: () => run(t('openEditor'), () => openFileInKit(abs, sessionId, inputActions, 'edit')) },
3210
+ { label: t('openEditor'), icon: 'edit', onSelect: () => run(t('openEditor'), () => startEditing(abs, sessionId)) },
2652
3211
  { label: t('rename'), icon: 'edit', onSelect: () => run(t('rename'), () => openFileInKit(abs, sessionId, inputActions, 'rename')) },
2653
3212
  { label: t('move'), icon: 'folder', onSelect: () => run(t('move'), () => openFileInKit(abs, sessionId, inputActions, 'move')) },
2654
3213
  { label: t('copyAbs'), icon: 'copy', onSelect: () => run(t('copyAbs'), async () => {
@@ -2675,9 +3234,9 @@
2675
3234
  {
2676
3235
  type: 'button',
2677
3236
  className: 'dsh-fk-tool',
2678
- title: t('edit'),
2679
- 'aria-label': t('edit'),
2680
- onClick: () => run(t('edit'), () => openFileInKit(abs, sessionId, inputActions, 'edit')),
3237
+ title: t('openEditor'),
3238
+ 'aria-label': t('openEditor'),
3239
+ onClick: () => run(t('openEditor'), () => startEditing(abs, sessionId)),
2681
3240
  },
2682
3241
  h(Icon, { name: 'edit', size: 15 }),
2683
3242
  ),
@@ -2853,10 +3412,10 @@
2853
3412
  // ------------------------------------------------------------------
2854
3413
 
2855
3414
  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}
3415
+ :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
3416
  body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
2858
3417
  .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)}
3418
+ .dsh-fk-tool:hover{background:var(--dsh-fk-hover);color:var(--dsh-fk-orange)}
2860
3419
  .dsh-fk-doc-actions{display:inline-flex;align-items:center;gap:1px;flex:none}
2861
3420
  .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
3421
  .dsh-fk-tabstatus{display:flex;flex-direction:column;gap:12px;align-items:flex-start;justify-content:center;height:100%;padding:24px}
@@ -2869,26 +3428,27 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
2869
3428
  .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
3429
  .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
3430
  .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)}
3431
+ .dsh-fk-crumb:hover{background:var(--dsh-fk-hover);color:var(--dsw-alias-label-primary)}
2873
3432
  .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
3433
  .dsh-fk-crumb-current{color:var(--dsw-alias-label-primary);font-weight:500;cursor:default}
2875
3434
  .dsh-fk-crumb-sep{opacity:.5}
2876
3435
  .dsh-fk-toolbar-actions{display:flex;align-items:center;gap:4px;flex:none}
2877
3436
  .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)}
3437
+ .dsh-fk-btn:hover{background:var(--dsh-fk-hover)}
2879
3438
  .dsh-fk-btn:disabled{opacity:.45;cursor:default}
2880
3439
  .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
3440
  .dsh-fk-btn-primary:hover{filter:brightness(1.05);background:var(--dsh-fk-grad)}
2882
3441
  .dsh-fk-btn-danger{background:var(--dsw-alias-state-error-primary);border-color:transparent;color:#fff}
2883
3442
  .dsh-fk-btn-danger:hover{filter:brightness(1.06);background:var(--dsw-alias-state-error-primary)}
2884
3443
  .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)}
3444
+ .dsh-fk-iconbtn:hover{background:var(--dsh-fk-hover);color:var(--dsh-fk-orange)}
2886
3445
  .dsh-fk-iconbtn:disabled{opacity:.4;cursor:default}
2887
3446
  .dsh-fk-iconbtn[data-danger=true]:hover{color:var(--dsw-alias-state-error-primary)}
2888
3447
  .dsh-fk-body{flex:1;min-height:0;overflow:auto;padding:4px 6px 10px}
2889
3448
  .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)}
3449
+ .dsh-fk-row:hover{background:var(--dsh-fk-hover)}
3450
+ .dsh-fk-row[data-menu=true]{background:var(--dsh-fk-hover)}
3451
+ .dsh-fk-row:focus-visible{outline:1.5px solid var(--dsh-fk-orange);outline-offset:-2px}
2892
3452
  .dsh-fk-row[data-drop=true]{outline:1.5px dashed var(--dsh-fk-orange);outline-offset:-2px;background:var(--dsh-fk-soft)}
2893
3453
  .dsh-fk-row-static{cursor:default}
2894
3454
  .dsh-fk-rowicon{display:flex;color:var(--dsw-alias-label-secondary);flex:none}
@@ -2901,8 +3461,18 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
2901
3461
  .dsh-fk-row:hover .dsh-fk-rowactions,.dsh-fk-row:focus-within .dsh-fk-rowactions{opacity:1}
2902
3462
  @media (hover:none){.dsh-fk-rowactions{opacity:1}}
2903
3463
  .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)}
3464
+ .dsh-fk-editorhead{display:flex;align-items:center;gap:8px;padding:6px 8px 6px 12px;border-bottom:1px solid var(--dsw-alias-border-l2);flex:none}
3465
+ .dsh-fk-editorpath{display:flex;align-items:center;gap:6px;min-width:0;color:var(--dsw-alias-label-primary);font-size:12.5px}
3466
+ .dsh-fk-editorpath svg{color:var(--dsh-fk-dir)}
3467
+ .dsh-fk-editorpathname{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3468
+ .dsh-fk-editorpathdir{color:var(--dsw-alias-label-tertiary);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3469
+ .dsh-fk-iconactions{display:flex;align-items:center;gap:2px;flex:none}
3470
+ .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)}
3471
+ .dsh-fk-codeeditor:focus-within{border-color:var(--dsh-fk-orange)}
3472
+ .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)}
3473
+ .dsh-fk-codeeditor-line{min-height:1.62em;white-space:pre}
3474
+ .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}
3475
+ .dsh-fk-codeeditor-input::selection{background:rgb(244 81 30/.24)}
2906
3476
  .dsh-fk-editor-meta{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dsw-alias-label-secondary);flex:none}
2907
3477
  .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
3478
  .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 +3496,11 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
2926
3496
  .dsh-fk-dialog-crumbs{flex:none;border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:6px}
2927
3497
  .dsh-fk-picklist{display:flex;flex-direction:column;gap:2px;max-height:260px;overflow:auto;padding:2px}
2928
3498
  .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)}
3499
+ .dsh-fk-pickrow:hover{background:var(--dsh-fk-hover)}
2930
3500
  .dsh-fk-scrim{position:fixed;inset:0;z-index:1300}
2931
3501
  .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
3502
  .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)}
3503
+ .dsh-fk-menu button:hover{background:var(--dsh-fk-hover)}
2934
3504
  .dsh-fk-menu button:disabled{opacity:.45;cursor:default}
2935
3505
  .dsh-fk-menu button[data-danger=true]{color:var(--dsw-alias-state-error-primary)}
2936
3506
  .dsh-fk-menulabel{overflow:hidden;text-overflow:ellipsis}
@@ -2939,17 +3509,20 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
2939
3509
  .dsh-fk-toast[data-kind=error]{color:var(--dsw-alias-state-error-primary)}
2940
3510
  .dsh-fk-tabtitle{display:inline-flex;align-items:center;gap:5px;min-width:0}
2941
3511
  .dsh-fk-tabtitle svg{color:var(--dsh-fk-dir)}
3512
+ .dsh-fk-tabtitle-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3513
+ .dsh-fk-tabdot{width:7px;height:7px;border-radius:50%;background:var(--dsh-fk-orange);flex:none;margin-left:2px;box-shadow:0 0 0 2px rgb(244 81 30/.18)}
2942
3514
  .dsh-fk-filestab{background:transparent}
2943
3515
  .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
3516
  .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)}
3517
+ .dsh-fk-rootpath:hover{background:var(--dsh-fk-hover);color:var(--dsw-alias-label-primary)}
2946
3518
  .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
3519
  .dsh-fk-rootpathtext{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2948
3520
  .dsh-fk-treebody{position:relative;flex:1;min-height:0;overflow:auto;padding:4px 5px 22px}
2949
3521
  .dsh-fk-tlevel{display:flex;flex-direction:column;gap:0}
2950
3522
  .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)}
3523
+ .dsh-fk-trow:hover{background:var(--dsh-fk-hover)}
3524
+ .dsh-fk-trow[data-menu=true]{background:var(--dsh-fk-hover)}
3525
+ .dsh-fk-trow:focus-visible{outline:1.5px solid var(--dsh-fk-orange);outline-offset:-2px}
2953
3526
  .dsh-fk-trow[data-drop=true]{outline:1.5px dashed var(--dsh-fk-orange);outline-offset:-2px;background:var(--dsh-fk-soft)}
2954
3527
  .dsh-fk-trow[data-kind=directory] .dsh-fk-trowname{font-weight:500}
2955
3528
  .dsh-fk-tchev{display:flex;align-items:center;justify-content:center;width:13px;flex:none;color:var(--dsw-alias-label-tertiary,#8b8b8b)}
@@ -2992,7 +3565,7 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
2992
3565
  .dsh-fk-switch[data-on=true] .dsh-fk-switchknob{left:19px}
2993
3566
  .dsh-fk-segmented{display:inline-flex;gap:2px;padding:2px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;flex:none}
2994
3567
  .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)}
3568
+ .dsh-fk-segment:hover{background:var(--dsh-fk-hover)}
2996
3569
  .dsh-fk-segment[data-on=true]{background:var(--dsh-fk-grad);color:#fff}
2997
3570
  .dsh-fk-fontpicker{display:flex;align-items:center;gap:10px;flex:none}
2998
3571
  .dsh-fk-range{width:130px;accent-color:var(--dsh-fk-orange)}
@@ -3076,24 +3649,49 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
3076
3649
  }),
3077
3650
  'dsh-filekit: filekit type',
3078
3651
  );
3652
+ ctx.effect(
3653
+ () =>
3654
+ tabs.register({
3655
+ id: EDITOR_ID,
3656
+ kind: EDITOR_KIND,
3657
+ priority: 'extension',
3658
+ keepMounted: true,
3659
+ patterns: ['dsh-resource://filekit-edit/[\\s\\S]+'],
3660
+ title: () => t('editorTab'),
3661
+ }),
3662
+ 'dsh-filekit: editor type',
3663
+ );
3079
3664
  } else {
3080
3665
  console.warn('[dsh-filekit] sidebarRightTabs service missing; tabs not registered');
3081
3666
  }
3082
3667
 
3083
3668
  if (sidebarRightService !== undefined && typeof sidebarRightService.registerCloseHandler === 'function') {
3084
3669
  ctx.effect(() => {
3085
- try {
3086
- return sidebarRightService.registerCloseHandler(FILEKIT_KIND, (sessionId, tab) => {
3087
- if (dirtyTabs.has(tab.id)) {
3088
- showToast(t('saveBeforeClose'), 'error');
3089
- throw new Error('dsh-filekit: unsaved changes');
3090
- }
3091
- });
3092
- } catch (error) {
3093
- console.warn('[dsh-filekit] close guard not registered:', error?.message ?? error);
3094
- return undefined;
3670
+ const releases = [];
3671
+ for (const kind of [FILEKIT_KIND, EDITOR_KIND]) {
3672
+ try {
3673
+ releases.push(
3674
+ sidebarRightService.registerCloseHandler(kind, (sessionId, tab) => {
3675
+ if (dirtyTabs.has(tab.id)) {
3676
+ showToast(t('saveBeforeClose'), 'error');
3677
+ throw new Error('dsh-filekit: unsaved changes');
3678
+ }
3679
+ }),
3680
+ );
3681
+ } catch (error) {
3682
+ console.warn(`[dsh-filekit] close guard for ${kind} not registered:`, error?.message ?? error);
3683
+ }
3095
3684
  }
3096
- }, 'dsh-filekit: close guard');
3685
+ return () => {
3686
+ for (const release of releases) {
3687
+ try {
3688
+ release?.();
3689
+ } catch {
3690
+ /* ignore */
3691
+ }
3692
+ }
3693
+ };
3694
+ }, 'dsh-filekit: close guards');
3097
3695
  }
3098
3696
 
3099
3697
  const registerList = (slot, options, Component) => {
@@ -3128,8 +3726,10 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
3128
3726
  FilesBody,
3129
3727
  );
3130
3728
  registerKeyed('sidebar.right.pane.tab', { key: FILEKIT_ID, locale: NS }, FileKitTab);
3729
+ registerKeyed('sidebar.right.pane.tab', { key: EDITOR_ID, locale: NS }, FileEditorTab);
3131
3730
  registerKeyed('sidebar.right.pane.tab.title', { key: FILES_ID }, FilesTitle);
3132
3731
  registerKeyed('sidebar.right.pane.tab.title', { key: FILEKIT_ID }, FileKitTitle);
3732
+ registerKeyed('sidebar.right.pane.tab.title', { key: EDITOR_ID }, EditorTitle);
3133
3733
 
3134
3734
  // The workspace-files guide card gains a FileKit button underneath.
3135
3735
  registerKeyed('sidebar.right.tab.guide.entry', { key: FILES_ID, locale: NS }, FilesGuideCard);
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.3';
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.3",
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",