@restregular/dsh-filekit 0.2.2 → 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.
Files changed (4) hide show
  1. package/README.md +11 -4
  2. package/client.js +419 -47
  3. package/index.js +1 -1
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -40,10 +40,17 @@ dsh plugin --profile web add @restregular/dsh-filekit
40
40
  ### FileKit 文件管理页
41
41
 
42
42
  - 独立侧边栏标签页(`filekit` kind),不是浮层弹窗;
43
- - 面包屑浏览、新建、复制、移动、删除、回收站;
44
- - 内置文本编辑器(与内置预览同一个 Shiki 高亮器、`Ctrl/Cmd+S` 保存、未保存守卫、保存后刷新);
45
- - 右键菜单与拖拽移动;
46
- - 有未保存修改时,关闭标签页会被阻止并提示先保存或放弃。
43
+ - 面包屑浏览、新建、复制/剪切/粘贴、移动、删除、回收站;
44
+ - 右键菜单与拖拽移动。
45
+
46
+ ### 编辑器标签页
47
+
48
+ - 每个文件一个独立标签页(`filekit-edit` kind),和预览页可以互相切换:
49
+ - 预览标题栏的**编辑**按钮把当前预览标签页切换成编辑页;
50
+ - 编辑页的**保存**按钮保存后切回该文件的预览页;**放弃修改**确认后也切回预览。
51
+ - 与内置预览同一套 Shiki 高亮器、行内滚动同步、`Ctrl/Cmd+S` 保存;
52
+ - 未保存时标签页标题右侧显示橙色圆点;关闭未保存的标签页会被阻止并提示;
53
+ - 工具栏全部为图标按钮,悬停显示提示文本(复制相对路径、引用、预览、放弃修改、保存)。
47
54
 
48
55
  ### 设置页
49
56
 
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;
@@ -126,6 +131,9 @@
126
131
  emptyHint: '回收站里的所有条目都会被永久删除,无法恢复。',
127
132
  discardTitle: '放弃未保存的修改?',
128
133
  discardConfirm: '放弃修改',
134
+ discardHint: '放弃后该标签页会切换回文件预览。',
135
+ editorTab: '编辑',
136
+ editorNoFile: '这个标签页没有要编辑的文件。',
129
137
  dirty: '未保存',
130
138
  discard: '放弃修改',
131
139
  binary: '这是二进制文件,不能用文本编辑器打开。',
@@ -269,6 +277,9 @@
269
277
  emptyHint: 'Every entry in the recycle bin is permanently deleted; this cannot be undone.',
270
278
  discardTitle: 'Discard unsaved changes?',
271
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.',
272
283
  dirty: 'Unsaved',
273
284
  discard: 'Discard',
274
285
  binary: 'This is a binary file; the text editor cannot open it.',
@@ -543,6 +554,26 @@
543
554
  return 'dsh-resource://file/session/' + id + '/' + rel.split('/').map((part) => encodeURIComponent(part)).join('/');
544
555
  }
545
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
+
546
577
  function formatBytes(size) {
547
578
  if (typeof size !== 'number' || !Number.isFinite(size)) return '';
548
579
  if (size < 1024) return `${size} B`;
@@ -766,6 +797,264 @@
766
797
  );
767
798
  }
768
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
+
769
1058
  function createOwnedStyle(css) {
770
1059
  const el = document.createElement('style');
771
1060
  el.setAttribute('data-plugin', OWNER);
@@ -775,15 +1064,69 @@
775
1064
  }
776
1065
 
777
1066
  // ------------------------------------------------------------------
778
- // Dirty tabs (the FileKit page's close guard)
1067
+ // Dirty tabs (close guard + the tab title's unsaved dot)
779
1068
  // ------------------------------------------------------------------
780
1069
 
781
1070
  const dirtyTabs = new Set();
1071
+ const dirtyListeners = new Set();
782
1072
 
783
1073
  function setTabDirty(tabId, dirty) {
784
1074
  if (typeof tabId !== 'string' || tabId === '') return;
1075
+ const before = dirtyTabs.has(tabId);
785
1076
  if (dirty) dirtyTabs.add(tabId);
786
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
+ }
787
1130
  }
788
1131
 
789
1132
  // ------------------------------------------------------------------
@@ -932,6 +1275,14 @@
932
1275
  h('rect', { x: 3, y: 4.5, width: 18, height: 15, rx: 2.2 }),
933
1276
  h('path', { d: 'M14.5 4.5v15' }),
934
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
+ );
935
1286
  case 'spark':
936
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' }));
937
1288
  default:
@@ -1369,30 +1720,12 @@
1369
1720
  }, [notice]);
1370
1721
 
1371
1722
  const loadEditor = React.useCallback(
1372
- async (rel) => {
1373
- if (root === '') return;
1374
- try {
1375
- const data = await api('read', { root, path: rel, sessionId });
1376
- if (data.binary === true) {
1377
- setNotice({ kind: 'error', text: t('binary') });
1378
- return;
1379
- }
1380
- setEditor({
1381
- rel,
1382
- original: data.text,
1383
- mtimeMs: data.mtimeMs,
1384
- size: data.size,
1385
- truncated: data.truncated === true,
1386
- });
1387
- setDraft(data.text);
1388
- setMenu(null);
1389
- setDialog(null);
1390
- setNotice(null);
1391
- } catch (failure) {
1392
- setNotice({ kind: 'error', text: errorText(failure) });
1393
- }
1723
+ (rel) => {
1724
+ setMenu(null);
1725
+ setDialog(null);
1726
+ openEditorTab(sessionId, rel);
1394
1727
  },
1395
- [root, sessionId],
1728
+ [sessionId],
1396
1729
  );
1397
1730
 
1398
1731
  const refresh = React.useCallback(() => setReload((value) => value + 1), []);
@@ -1906,7 +2239,6 @@
1906
2239
  key: entry.path,
1907
2240
  className: 'dsh-fk-row',
1908
2241
  'data-kind': entry.kind,
1909
- 'data-selected': selected === entry.path,
1910
2242
  'data-menu': menu !== null && menu.rel === entry.path,
1911
2243
  'data-drop': dropDir === entry.path,
1912
2244
  tabIndex: 0,
@@ -1941,7 +2273,6 @@
1941
2273
  }
1942
2274
  : undefined,
1943
2275
  onClick: () => {
1944
- setSelected(entry.path);
1945
2276
  if (entry.kind === 'directory') {
1946
2277
  setCwd(entry.path);
1947
2278
  setMenu(null);
@@ -2127,7 +2458,7 @@
2127
2458
  ),
2128
2459
  h('span', { className: 'dsh-fk-spacer' }),
2129
2460
  h(IconButton, {
2130
- icon: 'panel',
2461
+ icon: 'filesPanel',
2131
2462
  label: t('openFiles'),
2132
2463
  onClick: () => {
2133
2464
  try {
@@ -2464,7 +2795,7 @@
2464
2795
  const isDir = menu.kind === 'directory';
2465
2796
  if (isDir) menuItems.push({ label: t('openDirInFileKit'), icon: 'brand', onSelect: () => openFileKit({ dir: menu.rel }) });
2466
2797
  else menuItems.push({ label: t('openPreview'), icon: 'file', onSelect: () => openPreview(menu.rel) });
2467
- 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) });
2468
2799
  menuItems.push({ separator: true });
2469
2800
  if (isDir) {
2470
2801
  menuItems.push({ label: t('newFileHere'), icon: 'filePlus', onSelect: () => setDialog({ kind: 'create-file', dir: menu.rel }) });
@@ -2571,7 +2902,6 @@
2571
2902
  key: entry.path,
2572
2903
  className: 'dsh-fk-trow',
2573
2904
  'data-kind': entry.kind,
2574
- 'data-selected': selected === entry.path,
2575
2905
  'data-menu': menu !== null && menu.rel === entry.path,
2576
2906
  'data-drop': dropDir === entry.path,
2577
2907
  role: 'treeitem',
@@ -2582,7 +2912,6 @@
2582
2912
  draggable: true,
2583
2913
  title: entry.path,
2584
2914
  onClick: () => {
2585
- setSelected(entry.path);
2586
2915
  if (isDir) toggleDir(entry.path);
2587
2916
  else openPreview(entry.path);
2588
2917
  },
@@ -2856,6 +3185,14 @@
2856
3185
  const [menu, setMenu] = React.useState(null);
2857
3186
  if (abs === '') return null;
2858
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
+
2859
3196
  const run = async (label, fn) => {
2860
3197
  try {
2861
3198
  await fn();
@@ -2870,7 +3207,7 @@
2870
3207
  };
2871
3208
 
2872
3209
  const items = [
2873
- { 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)) },
2874
3211
  { label: t('rename'), icon: 'edit', onSelect: () => run(t('rename'), () => openFileInKit(abs, sessionId, inputActions, 'rename')) },
2875
3212
  { label: t('move'), icon: 'folder', onSelect: () => run(t('move'), () => openFileInKit(abs, sessionId, inputActions, 'move')) },
2876
3213
  { label: t('copyAbs'), icon: 'copy', onSelect: () => run(t('copyAbs'), async () => {
@@ -2897,9 +3234,9 @@
2897
3234
  {
2898
3235
  type: 'button',
2899
3236
  className: 'dsh-fk-tool',
2900
- title: t('edit'),
2901
- 'aria-label': t('edit'),
2902
- 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)),
2903
3240
  },
2904
3241
  h(Icon, { name: 'edit', size: 15 }),
2905
3242
  ),
@@ -3110,7 +3447,7 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
3110
3447
  .dsh-fk-body{flex:1;min-height:0;overflow:auto;padding:4px 6px 10px}
3111
3448
  .dsh-fk-row{display:flex;align-items:center;gap:8px;min-height:32px;padding:3px 6px;border-radius:7px;cursor:pointer;outline:none}
3112
3449
  .dsh-fk-row:hover{background:var(--dsh-fk-hover)}
3113
- .dsh-fk-row[data-menu=true],.dsh-fk-row[data-selected=true]{background:var(--dsh-fk-active);box-shadow:inset 2px 0 0 var(--dsh-fk-orange)}
3450
+ .dsh-fk-row[data-menu=true]{background:var(--dsh-fk-hover)}
3114
3451
  .dsh-fk-row:focus-visible{outline:1.5px solid var(--dsh-fk-orange);outline-offset:-2px}
3115
3452
  .dsh-fk-row[data-drop=true]{outline:1.5px dashed var(--dsh-fk-orange);outline-offset:-2px;background:var(--dsh-fk-soft)}
3116
3453
  .dsh-fk-row-static{cursor:default}
@@ -3124,6 +3461,12 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
3124
3461
  .dsh-fk-row:hover .dsh-fk-rowactions,.dsh-fk-row:focus-within .dsh-fk-rowactions{opacity:1}
3125
3462
  @media (hover:none){.dsh-fk-rowactions{opacity:1}}
3126
3463
  .dsh-fk-editor{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px 12px 12px}
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}
3127
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)}
3128
3471
  .dsh-fk-codeeditor:focus-within{border-color:var(--dsh-fk-orange)}
3129
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)}
@@ -3166,6 +3509,8 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
3166
3509
  .dsh-fk-toast[data-kind=error]{color:var(--dsw-alias-state-error-primary)}
3167
3510
  .dsh-fk-tabtitle{display:inline-flex;align-items:center;gap:5px;min-width:0}
3168
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)}
3169
3514
  .dsh-fk-filestab{background:transparent}
3170
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}
3171
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}
@@ -3176,7 +3521,7 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
3176
3521
  .dsh-fk-tlevel{display:flex;flex-direction:column;gap:0}
3177
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}
3178
3523
  .dsh-fk-trow:hover{background:var(--dsh-fk-hover)}
3179
- .dsh-fk-trow[data-menu=true],.dsh-fk-trow[data-selected=true]{background:var(--dsh-fk-active);box-shadow:inset 2px 0 0 var(--dsh-fk-orange)}
3524
+ .dsh-fk-trow[data-menu=true]{background:var(--dsh-fk-hover)}
3180
3525
  .dsh-fk-trow:focus-visible{outline:1.5px solid var(--dsh-fk-orange);outline-offset:-2px}
3181
3526
  .dsh-fk-trow[data-drop=true]{outline:1.5px dashed var(--dsh-fk-orange);outline-offset:-2px;background:var(--dsh-fk-soft)}
3182
3527
  .dsh-fk-trow[data-kind=directory] .dsh-fk-trowname{font-weight:500}
@@ -3304,24 +3649,49 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
3304
3649
  }),
3305
3650
  'dsh-filekit: filekit type',
3306
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
+ );
3307
3664
  } else {
3308
3665
  console.warn('[dsh-filekit] sidebarRightTabs service missing; tabs not registered');
3309
3666
  }
3310
3667
 
3311
3668
  if (sidebarRightService !== undefined && typeof sidebarRightService.registerCloseHandler === 'function') {
3312
3669
  ctx.effect(() => {
3313
- try {
3314
- return sidebarRightService.registerCloseHandler(FILEKIT_KIND, (sessionId, tab) => {
3315
- if (dirtyTabs.has(tab.id)) {
3316
- showToast(t('saveBeforeClose'), 'error');
3317
- throw new Error('dsh-filekit: unsaved changes');
3318
- }
3319
- });
3320
- } catch (error) {
3321
- console.warn('[dsh-filekit] close guard not registered:', error?.message ?? error);
3322
- 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
+ }
3323
3684
  }
3324
- }, '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');
3325
3695
  }
3326
3696
 
3327
3697
  const registerList = (slot, options, Component) => {
@@ -3356,8 +3726,10 @@ body[data-ds-dark-theme]{--dsh-fk-dir:#f3bf4d;--dsh-fk-soft:rgb(247 201 72/.14)}
3356
3726
  FilesBody,
3357
3727
  );
3358
3728
  registerKeyed('sidebar.right.pane.tab', { key: FILEKIT_ID, locale: NS }, FileKitTab);
3729
+ registerKeyed('sidebar.right.pane.tab', { key: EDITOR_ID, locale: NS }, FileEditorTab);
3359
3730
  registerKeyed('sidebar.right.pane.tab.title', { key: FILES_ID }, FilesTitle);
3360
3731
  registerKeyed('sidebar.right.pane.tab.title', { key: FILEKIT_ID }, FileKitTitle);
3732
+ registerKeyed('sidebar.right.pane.tab.title', { key: EDITOR_ID }, EditorTitle);
3361
3733
 
3362
3734
  // The workspace-files guide card gains a FileKit button underneath.
3363
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.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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@restregular/dsh-filekit",
3
- "version": "0.2.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",