@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 +14 -5
- package/client.js +674 -74
- 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`,可恢复 |
|
|
@@ -39,10 +40,17 @@ dsh plugin --profile web add @restregular/dsh-filekit
|
|
|
39
40
|
### FileKit 文件管理页
|
|
40
41
|
|
|
41
42
|
- 独立侧边栏标签页(`filekit` kind),不是浮层弹窗;
|
|
42
|
-
-
|
|
43
|
-
-
|
|
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: '
|
|
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
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
[
|
|
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(
|
|
1683
|
-
className: 'dsh-fk-textarea',
|
|
2212
|
+
h(CodeEditor, {
|
|
1684
2213
|
value: draft,
|
|
1685
|
-
|
|
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-
|
|
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: () =>
|
|
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: '
|
|
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
|
-
|
|
2146
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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: () =>
|
|
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-
|
|
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'), () =>
|
|
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('
|
|
2679
|
-
'aria-label': t('
|
|
2680
|
-
onClick: () => run(t('
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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
|
|
2891
|
-
.dsh-fk-row[data-
|
|
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-
|
|
2905
|
-
.dsh-fk-
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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
|
|
2952
|
-
.dsh-fk-trow[data-
|
|
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(--
|
|
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
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.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",
|