dsh-plugin-workbench 0.0.8 → 0.0.10
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/CHANGELOG.md +39 -0
- package/README.md +20 -6
- package/lib/client.js +444 -18
- package/lib/client.js.map +1 -1
- package/lib/index.js +95 -7
- package/package.json +1 -1
- package/src/client/FileExplorer.tsx +106 -25
- package/src/client/composer.ts +139 -0
- package/src/client/index.ts +7 -0
- package/src/client/locales.ts +6 -0
- package/src/client/mentions.ts +207 -0
- package/src/index.ts +101 -8
package/lib/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { watch } from "node:fs";
|
|
3
|
-
import { basename, dirname } from "node:path";
|
|
2
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { cp, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { homedir } from "node:os";
|
|
5
7
|
//#region src/index.ts
|
|
6
8
|
const name = "dsh-plugin-workbench";
|
|
7
9
|
const inject = [
|
|
@@ -154,10 +156,84 @@ function pathOf(payload) {
|
|
|
154
156
|
}
|
|
155
157
|
}
|
|
156
158
|
/**
|
|
159
|
+
* The explorer-column patch markers that scripts/patch-layout.mjs injects into
|
|
160
|
+
* the compiled dsh-client-ui-layout client bundle. A dsh upgrade (or a
|
|
161
|
+
* `pnpm install` that refreshes the ui-layout package) silently reverts that
|
|
162
|
+
* bundle, which makes the workbench column vanish even though this plugin is
|
|
163
|
+
* fine — this is the exact failure this auto-heal guards against.
|
|
164
|
+
*/
|
|
165
|
+
const LAYOUT_PATCH_MARKERS = [
|
|
166
|
+
"\"explorerCol\": \"",
|
|
167
|
+
"setExplorer: (d, px) => {",
|
|
168
|
+
"renderSlot(\"explorer\"",
|
|
169
|
+
"conversationSeat"
|
|
170
|
+
];
|
|
171
|
+
/** Resolve the installed dsh-client-ui-layout client bundle (profile node_modules junction). */
|
|
172
|
+
function layoutClientPath() {
|
|
173
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
174
|
+
return join(dshHome, "profiles", "node_modules", "@deepseek-ai", "dsh-client-ui-layout", "lib", "client.js");
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* True when the ui-layout bundle already carries the explorer-column patch.
|
|
178
|
+
* A missing bundle (non-standard install) is treated as "nothing to patch" so
|
|
179
|
+
* the check never blocks a boot; an unreadable file likewise bails out to the
|
|
180
|
+
* caller rather than throwing.
|
|
181
|
+
*/
|
|
182
|
+
function layoutIsPatched() {
|
|
183
|
+
try {
|
|
184
|
+
const target = layoutClientPath();
|
|
185
|
+
if (!existsSync(target)) return true;
|
|
186
|
+
const text = readFileSync(target, "utf8");
|
|
187
|
+
return LAYOUT_PATCH_MARKERS.every((marker) => text.includes(marker));
|
|
188
|
+
} catch {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Module-level guard so a HMR/re-apply burst never spawns the patch twice concurrently. */
|
|
193
|
+
let layoutPatchScheduled = false;
|
|
194
|
+
/**
|
|
195
|
+
* Re-apply the ui-layout explorer-column patch when it is missing.
|
|
196
|
+
*
|
|
197
|
+
* The workbench column renders into a fourth `explorer` slot that is added to
|
|
198
|
+
* the compiled dsh-client-ui-layout bundle by scripts/patch-layout.mjs. A dsh
|
|
199
|
+
* upgrade silently reverts that bundle, so this re-runs the same
|
|
200
|
+
* version-checked script: anchors that no longer match abort the script
|
|
201
|
+
* WITHOUT writing, so an incompatible dsh version never corrupts the bundle —
|
|
202
|
+
* it only logs a warning and the plugin still boots. Idempotent and
|
|
203
|
+
* non-blocking (spawned fire-and-forget), so it never delays a boot.
|
|
204
|
+
*/
|
|
205
|
+
function ensureLayoutPatch() {
|
|
206
|
+
if (layoutPatchScheduled) return;
|
|
207
|
+
layoutPatchScheduled = true;
|
|
208
|
+
try {
|
|
209
|
+
if (layoutIsPatched()) return;
|
|
210
|
+
const script = join(dirname(dirname(fileURLToPath(import.meta.url))), "scripts", "patch-layout.mjs");
|
|
211
|
+
const child = spawn(process.execPath, [script], {
|
|
212
|
+
stdio: "inherit",
|
|
213
|
+
windowsHide: true
|
|
214
|
+
});
|
|
215
|
+
child.on("error", (err) => {
|
|
216
|
+
console.warn("[dsh-plugin-workbench] re-applying ui-layout explorer patch failed:", err.message);
|
|
217
|
+
layoutPatchScheduled = false;
|
|
218
|
+
});
|
|
219
|
+
child.on("close", (code) => {
|
|
220
|
+
if (code === 0) console.log("[dsh-plugin-workbench] re-applied the missing dsh-client-ui-layout explorer patch (likely reverted by a dsh upgrade).");
|
|
221
|
+
else {
|
|
222
|
+
console.warn(`[dsh-plugin-workbench] ui-layout patch exited ${code}; the dsh version may have changed — run scripts/patch-layout.mjs manually.`);
|
|
223
|
+
layoutPatchScheduled = false;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.warn("[dsh-plugin-workbench] ui-layout patch check failed:", err);
|
|
228
|
+
layoutPatchScheduled = false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
157
232
|
* One filesystem-backed RPC endpoint pair. Reads never mutate; `signal`
|
|
158
233
|
* cancels the underlying fs call (or aborts between steps).
|
|
159
234
|
*/
|
|
160
235
|
function apply(ctx) {
|
|
236
|
+
ensureLayoutPatch();
|
|
161
237
|
const watchState = {
|
|
162
238
|
dirs: /* @__PURE__ */ new Map(),
|
|
163
239
|
files: /* @__PURE__ */ new Map(),
|
|
@@ -594,13 +670,25 @@ async function copyEntry(ctx, payload, signal) {
|
|
|
594
670
|
return fail(mapError(error));
|
|
595
671
|
}
|
|
596
672
|
}
|
|
597
|
-
/**
|
|
598
|
-
|
|
673
|
+
/**
|
|
674
|
+
* Spawn one short-lived desktop command; resolves once the process launched.
|
|
675
|
+
*
|
|
676
|
+
* `windowsHide` defaults to true (no console flash for console-subsystem
|
|
677
|
+
* helpers). EXPLORER.EXE IS THE ONE EXCEPTION: spawning it with
|
|
678
|
+
* `windowsHide: true` sets CREATE_NO_WINDOW, and the new shell folder window
|
|
679
|
+
* is then created HIDDEN — the folder opens on screen but stays invisible, so
|
|
680
|
+
* the user sees "nothing happened". (Verified empirically: the CabinetWClass
|
|
681
|
+
* window exists with `visible=False`; dropping the flag makes it visible.)
|
|
682
|
+
* explorer.exe is a GUI-subsystem app, so `windowsHide: false` never flashes
|
|
683
|
+
* a console — pass false on every Windows explorer.exe spawn.
|
|
684
|
+
* @param args - argv (never a shell string).
|
|
685
|
+
*/
|
|
686
|
+
function runDesktop(command, args, windowsHide = true) {
|
|
599
687
|
return new Promise((resolve, reject) => {
|
|
600
688
|
const child = spawn(command, args, {
|
|
601
689
|
detached: true,
|
|
602
690
|
stdio: "ignore",
|
|
603
|
-
windowsHide
|
|
691
|
+
windowsHide
|
|
604
692
|
});
|
|
605
693
|
child.once("error", reject);
|
|
606
694
|
child.once("spawn", () => {
|
|
@@ -629,7 +717,7 @@ async function revealNative(osPath, isDir, signal) {
|
|
|
629
717
|
signal.throwIfAborted();
|
|
630
718
|
const platform = process.platform;
|
|
631
719
|
if (platform === "win32") {
|
|
632
|
-
await runDesktop("explorer.exe", isDir ? [osPath] : ["/select,", osPath]);
|
|
720
|
+
await runDesktop("explorer.exe", isDir ? [osPath] : ["/select,", osPath], false);
|
|
633
721
|
return;
|
|
634
722
|
}
|
|
635
723
|
if (platform === "darwin") {
|
|
@@ -641,7 +729,7 @@ async function revealNative(osPath, isDir, signal) {
|
|
|
641
729
|
if (env.WSL_DISTRO_NAME !== void 0 || env.WSL_INTEROP !== void 0) {
|
|
642
730
|
const windowsPath = (await execCapture("wslpath", ["-w", osPath])).replace(/[\r\n]+$/, "");
|
|
643
731
|
if (windowsPath === "") throw new Error("wslpath returned no Windows path");
|
|
644
|
-
await runDesktop("explorer.exe", isDir ? [windowsPath] : ["/select,", windowsPath]);
|
|
732
|
+
await runDesktop("explorer.exe", isDir ? [windowsPath] : ["/select,", windowsPath], false);
|
|
645
733
|
return;
|
|
646
734
|
}
|
|
647
735
|
await runDesktop("xdg-open", [isDir ? osPath : dirname(osPath)]);
|
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import { FileIcon } from './fileIcons'
|
|
|
10
10
|
import type { FilesKey } from './locales'
|
|
11
11
|
import { cancelCut, clearClipboard, closeFilesUnder, copyToClipboard, expandPreview, openFile, popUndo, pushUndo, retargetFile, setCwd, toggleTheme, useClipboard, useTabsState } from './store'
|
|
12
12
|
import type { ClipboardItem, ClipboardMode, UndoEntry } from './store'
|
|
13
|
+
import { DRAG_TYPE, insertIntoComposer, relPathOf } from './composer'
|
|
13
14
|
|
|
14
15
|
export interface FsListEntry {
|
|
15
16
|
name: string
|
|
@@ -286,9 +287,10 @@ export function FileExplorer({
|
|
|
286
287
|
const dragPathsRef = useRef<string[] | null>(null)
|
|
287
288
|
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
|
288
289
|
|
|
289
|
-
// ---- context menu (rows
|
|
290
|
+
// ---- context menu (rows, blank tree space, header and clipboard bar —
|
|
291
|
+
// the whole column carries a menu; right-clicking a row keeps its own) ----
|
|
290
292
|
interface MenuState {
|
|
291
|
-
kind: 'file' | 'dir'
|
|
293
|
+
kind: 'file' | 'dir' | 'blank'
|
|
292
294
|
path: string
|
|
293
295
|
x: number
|
|
294
296
|
y: number
|
|
@@ -337,6 +339,24 @@ export function FileExplorer({
|
|
|
337
339
|
}
|
|
338
340
|
}, [menu])
|
|
339
341
|
|
|
342
|
+
// Explorer semantics: clicking (or right-clicking) ANYWHERE outside the
|
|
343
|
+
// tree — the chat, the header, other columns — clears the selection. Clicks
|
|
344
|
+
// on the tree rows and inside the open menu keep it (the menu's batch
|
|
345
|
+
// actions operate on the selection).
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
const onDocMouseDown = (e: MouseEvent) => {
|
|
348
|
+
const target = e.target
|
|
349
|
+
if (!(target instanceof Node)) return
|
|
350
|
+
const tree = treeAreaRef.current
|
|
351
|
+
if (tree !== null && tree.contains(target)) return
|
|
352
|
+
const m = menuRef.current
|
|
353
|
+
if (m !== null && m.contains(target)) return
|
|
354
|
+
setSelected({})
|
|
355
|
+
}
|
|
356
|
+
document.addEventListener('mousedown', onDocMouseDown)
|
|
357
|
+
return () => document.removeEventListener('mousedown', onDocMouseDown)
|
|
358
|
+
}, [])
|
|
359
|
+
|
|
340
360
|
// Latest tree snapshot for the polling tick (avoids stale closures).
|
|
341
361
|
const treeRef = useRef({ root, children, expanded, rootLoading })
|
|
342
362
|
treeRef.current = { root, children, expanded, rootLoading }
|
|
@@ -517,6 +537,16 @@ export function FileExplorer({
|
|
|
517
537
|
setMenu({ kind: entry.kind === 'dir' ? 'dir' : 'file', path: entry.path, x: e.clientX, y: e.clientY })
|
|
518
538
|
}, [])
|
|
519
539
|
|
|
540
|
+
/** Right-click anywhere else in the column (header, clipboard bar, blank tree space): folder menu for the cwd. */
|
|
541
|
+
const onColumnContextMenu = useCallback((e: ReactMouseEvent) => {
|
|
542
|
+
e.preventDefault()
|
|
543
|
+
// Right-clicking the open menu itself keeps it (no browser menu either).
|
|
544
|
+
const el = menuRef.current
|
|
545
|
+
if (el !== null && e.target instanceof Node && el.contains(e.target)) return
|
|
546
|
+
if (cwd === undefined) return
|
|
547
|
+
setMenu({ kind: 'blank', path: cwd, x: e.clientX, y: e.clientY })
|
|
548
|
+
}, [cwd])
|
|
549
|
+
|
|
520
550
|
const runAction = useCallback((action: () => void) => {
|
|
521
551
|
setMenu(undefined)
|
|
522
552
|
action()
|
|
@@ -809,11 +839,17 @@ export function FileExplorer({
|
|
|
809
839
|
const paths = selected[entry.path] !== undefined ? Object.keys(selected) : [entry.path]
|
|
810
840
|
dragPathsRef.current = paths
|
|
811
841
|
try {
|
|
812
|
-
|
|
842
|
+
// text/plain: natural browser fallback (a native textarea drop inserts
|
|
843
|
+
// the paths); the custom type carries the machine-readable list for the
|
|
844
|
+
// workbench's own chat-drop integration (composer.ts).
|
|
845
|
+
e.dataTransfer.setData('text/plain', paths.join('\n'))
|
|
846
|
+
e.dataTransfer.setData(DRAG_TYPE, JSON.stringify(paths))
|
|
813
847
|
} catch {
|
|
814
848
|
// dataTransfer may be unavailable — the ref still carries the paths
|
|
815
849
|
}
|
|
816
|
-
|
|
850
|
+
// copyMove: dropping on a folder row moves (tree), dropping on the chat
|
|
851
|
+
// copies the path text in.
|
|
852
|
+
e.dataTransfer.effectAllowed = 'copyMove'
|
|
817
853
|
}, [selected])
|
|
818
854
|
|
|
819
855
|
const onRowDragEnd = useCallback(() => {
|
|
@@ -897,6 +933,30 @@ export function FileExplorer({
|
|
|
897
933
|
void openPath(entry.path)
|
|
898
934
|
}, [openPath])
|
|
899
935
|
|
|
936
|
+
/** Explorer-style "Select All": every visible (non-trash) entry at any depth. */
|
|
937
|
+
const selectAllEntries = useCallback(() => {
|
|
938
|
+
const all: Record<string, FsListEntry['kind']> = {}
|
|
939
|
+
const collect = (entries: FsListEntry[]) => {
|
|
940
|
+
for (const entry of entries) {
|
|
941
|
+
if (entry.name === TRASH_NAME) continue
|
|
942
|
+
all[entry.path] = entry.kind
|
|
943
|
+
if (entry.kind === 'dir' && expanded.has(entry.path) && children[entry.path] !== undefined) {
|
|
944
|
+
collect(children[entry.path] as FsListEntry[])
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
const rootEntries = root !== undefined ? children[root] : undefined
|
|
949
|
+
if (rootEntries !== undefined) collect(rootEntries)
|
|
950
|
+
setSelected(all)
|
|
951
|
+
}, [children, expanded, root])
|
|
952
|
+
|
|
953
|
+
/** Insert `@<relative-workspace-path>` into the composer (Claude Code-style mention). */
|
|
954
|
+
const onMention = useCallback((path: string) => {
|
|
955
|
+
const mention = relPathOf(path, cwd)
|
|
956
|
+
if (mention.length === 0) return
|
|
957
|
+
insertIntoComposer(`@${mention} `)
|
|
958
|
+
}, [cwd])
|
|
959
|
+
|
|
900
960
|
// Explorer-style keyboard: Ctrl/Cmd+C/X copy-cut, Ctrl/Cmd+V paste,
|
|
901
961
|
// Ctrl/Cmd+A select-all, Escape clears the selection (and cancels a cut).
|
|
902
962
|
const onTreeKeyDown = useCallback((e: ReactKeyboardEvent) => {
|
|
@@ -935,19 +995,7 @@ export function FileExplorer({
|
|
|
935
995
|
}
|
|
936
996
|
if (mod && key === 'a') {
|
|
937
997
|
e.preventDefault()
|
|
938
|
-
|
|
939
|
-
const collect = (entries: FsListEntry[]) => {
|
|
940
|
-
for (const entry of entries) {
|
|
941
|
-
if (entry.name === TRASH_NAME) continue
|
|
942
|
-
all[entry.path] = entry.kind
|
|
943
|
-
if (entry.kind === 'dir' && expanded.has(entry.path) && children[entry.path] !== undefined) {
|
|
944
|
-
collect(children[entry.path] as FsListEntry[])
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
const rootEntries = root !== undefined ? children[root] : undefined
|
|
949
|
-
if (rootEntries !== undefined) collect(rootEntries)
|
|
950
|
-
setSelected(all)
|
|
998
|
+
selectAllEntries()
|
|
951
999
|
return
|
|
952
1000
|
}
|
|
953
1001
|
if (mod && key === 'z' && !e.shiftKey) {
|
|
@@ -966,7 +1014,7 @@ export function FileExplorer({
|
|
|
966
1014
|
setSelected({})
|
|
967
1015
|
cancelCut()
|
|
968
1016
|
}
|
|
969
|
-
}, [cancelCut,
|
|
1017
|
+
}, [cancelCut, clipboard.items.length, deleteSelection, onPaste, performUndo, selectAllEntries, selectedItems, undoEntries.length])
|
|
970
1018
|
|
|
971
1019
|
// ---- placeholder: no session / no cwd ----
|
|
972
1020
|
if (cwd === undefined) {
|
|
@@ -1043,9 +1091,12 @@ export function FileExplorer({
|
|
|
1043
1091
|
const selectionCount = Object.keys(selected).length
|
|
1044
1092
|
const deleteItem: MenuItem = selectionCount > 1
|
|
1045
1093
|
? { label: t('menu.deleteSelected', { count: selectionCount }), danger: true, onClick: () => void deleteSelection() }
|
|
1046
|
-
|
|
1094
|
+
// 'blank' never renders deleteItem (the blank branch returns earlier);
|
|
1095
|
+
// the fallback only satisfies the union type.
|
|
1096
|
+
: { label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, m.kind === 'blank' ? 'dir' : m.kind) }
|
|
1047
1097
|
if (m.kind === 'file') {
|
|
1048
1098
|
return [
|
|
1099
|
+
{ label: t('menu.atFile'), onClick: () => onMention(m.path) },
|
|
1049
1100
|
{ label: t('menu.open'), onClick: () => openFile(m.path) },
|
|
1050
1101
|
'divider',
|
|
1051
1102
|
{ label: t('menu.copy'), onClick: () => onCopySelection('copy') },
|
|
@@ -1077,16 +1128,38 @@ export function FileExplorer({
|
|
|
1077
1128
|
deleteItem,
|
|
1078
1129
|
]
|
|
1079
1130
|
}
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1131
|
+
// Blank space / header / clipboard bar: operations against the current
|
|
1132
|
+
// workspace folder (right-clicking blank space acts on the cwd, VS Code
|
|
1133
|
+
// file-explorer style).
|
|
1134
|
+
if (m.kind === 'blank') {
|
|
1135
|
+
const dirPath = m.path
|
|
1136
|
+
return [
|
|
1137
|
+
{ label: t('menu.newFile'), onClick: () => onNewFile(dirPath) },
|
|
1138
|
+
{ label: t('menu.newFolder'), onClick: () => onNewFolder(dirPath) },
|
|
1139
|
+
{ label: t('menu.paste'), disabled: clipboard.items.length === 0, onClick: () => void onPaste() },
|
|
1140
|
+
'divider',
|
|
1141
|
+
{ label: t('menu.selectAll'), onClick: selectAllEntries },
|
|
1142
|
+
{ label: t('menu.undo'), disabled: undoEntries.length === 0, onClick: () => void performUndo() },
|
|
1143
|
+
'divider',
|
|
1144
|
+
{ label: t('menu.copyPath'), onClick: () => onCopyPath(dirPath) },
|
|
1145
|
+
{ label: t('menu.revealInExplorer'), onClick: () => onReveal(dirPath, 'dir') },
|
|
1146
|
+
{ label: t('menu.openSystem'), onClick: () => void openPath(dirPath) },
|
|
1147
|
+
{ label: t('menu.refresh'), onClick: () => refreshDir(dirPath) },
|
|
1148
|
+
]
|
|
1149
|
+
}
|
|
1083
1150
|
return [
|
|
1084
1151
|
{ label: t('menu.paste'), disabled: clipboard.items.length === 0, onClick: () => void onPaste() },
|
|
1085
1152
|
]
|
|
1086
1153
|
}
|
|
1087
1154
|
|
|
1088
1155
|
return (
|
|
1089
|
-
<div
|
|
1156
|
+
<div
|
|
1157
|
+
className={styles.column}
|
|
1158
|
+
style={{ width: width > 0 ? width : undefined }}
|
|
1159
|
+
data-pane="explorer"
|
|
1160
|
+
data-fe-theme={theme}
|
|
1161
|
+
onContextMenu={onColumnContextMenu}
|
|
1162
|
+
>
|
|
1090
1163
|
<div className={styles.header}>
|
|
1091
1164
|
<span className={styles.headerTitle} title={root ?? cwd}>{basenameOf(root ?? cwd)}</span>
|
|
1092
1165
|
<span className={styles.headerActions}>
|
|
@@ -1133,8 +1206,10 @@ export function FileExplorer({
|
|
|
1133
1206
|
className={styles.treeArea}
|
|
1134
1207
|
onKeyDown={onTreeKeyDown}
|
|
1135
1208
|
onClick={(e) => {
|
|
1136
|
-
// Clicking blank space
|
|
1137
|
-
|
|
1209
|
+
// Clicking blank space (or a hint/error line — anything that is not
|
|
1210
|
+
// a tree row) deselects, Explorer behavior.
|
|
1211
|
+
const target = e.target as HTMLElement
|
|
1212
|
+
if (target.closest('[role="treeitem"]') === null) setSelected({})
|
|
1138
1213
|
}}
|
|
1139
1214
|
onDragOver={(e) => {
|
|
1140
1215
|
// Blank tree area also accepts a drop: move into the current folder.
|
|
@@ -1158,6 +1233,12 @@ export function FileExplorer({
|
|
|
1158
1233
|
ref={menuRef}
|
|
1159
1234
|
className={styles.contextMenu}
|
|
1160
1235
|
role="menu"
|
|
1236
|
+
onContextMenu={(e) => {
|
|
1237
|
+
// Right-clicking the open menu keeps it (and never shows the
|
|
1238
|
+
// browser menu).
|
|
1239
|
+
e.preventDefault()
|
|
1240
|
+
e.stopPropagation()
|
|
1241
|
+
}}
|
|
1161
1242
|
style={menuPos !== undefined ? { left: menuPos.x, top: menuPos.y } : { left: menu.x, top: menu.y, visibility: 'hidden' }}
|
|
1162
1243
|
>
|
|
1163
1244
|
{menuItems(menu).map((item, index) =>
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composer integration for the workbench file column.
|
|
3
|
+
*
|
|
4
|
+
* Two gestures land text in the chat composer without touching the core:
|
|
5
|
+
*
|
|
6
|
+
* 1. Drag & drop — dragging one or more tree rows and dropping ANYWHERE
|
|
7
|
+
* outside the file column (the chat, the composer, the message list)
|
|
8
|
+
* inserts the dragged paths into the composer. Dropping INSIDE the file
|
|
9
|
+
* column still performs the tree's own move operation — the tree's drop
|
|
10
|
+
* handler runs first and stops propagation, so this document-level
|
|
11
|
+
* listener never sees those drops.
|
|
12
|
+
*
|
|
13
|
+
* 2. Context-menu "@引用" — inserts `@<relative-workspace-path>` at the
|
|
14
|
+
* composer caret.
|
|
15
|
+
*
|
|
16
|
+
* The composer is a controlled React textarea, so the value is updated
|
|
17
|
+
* through the native `value` setter + a bubbling `input` event (the standard
|
|
18
|
+
* trick that makes React's onChange see the change), and the caret is
|
|
19
|
+
* restored on the next frame because the React re-render may reset it.
|
|
20
|
+
*/
|
|
21
|
+
import { getTabsState, openFile } from './store'
|
|
22
|
+
|
|
23
|
+
/** Custom dataTransfer type carrying the dragged workspace paths (JSON array). */
|
|
24
|
+
export const DRAG_TYPE = 'application/x-dsh-workbench-files'
|
|
25
|
+
|
|
26
|
+
/** One-time install guard (the client bundle re-applies on HMR). */
|
|
27
|
+
let dropsInstalled = false
|
|
28
|
+
|
|
29
|
+
/** Start the document-level drop listener (idempotent). */
|
|
30
|
+
export function installComposerDrops(): void {
|
|
31
|
+
if (dropsInstalled || typeof document === 'undefined') return
|
|
32
|
+
dropsInstalled = true
|
|
33
|
+
// Bubble phase: the tree's own drop handlers (React, attached at the root
|
|
34
|
+
// container) run first and stop the event, so drops inside the file column
|
|
35
|
+
// never reach this listener.
|
|
36
|
+
document.addEventListener('drop', onDocumentDrop)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function onDocumentDrop(e: DragEvent): void {
|
|
40
|
+
const dt = e.dataTransfer
|
|
41
|
+
if (dt === null || !dt.types.includes(DRAG_TYPE)) return
|
|
42
|
+
const paths = readDraggedPaths(dt)
|
|
43
|
+
if (paths.length === 0) return
|
|
44
|
+
if (!insertIntoComposer(paths.join('\n'))) return
|
|
45
|
+
e.preventDefault()
|
|
46
|
+
e.stopPropagation()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read the JSON paths from the custom type; falls back to raw text. */
|
|
50
|
+
export function readDraggedPaths(dt: DataTransfer | null): string[] {
|
|
51
|
+
if (dt === null) return []
|
|
52
|
+
let raw = ''
|
|
53
|
+
try {
|
|
54
|
+
raw = dt.getData(DRAG_TYPE)
|
|
55
|
+
} catch {
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const parsed: unknown = JSON.parse(raw)
|
|
60
|
+
if (Array.isArray(parsed) && parsed.every((p) => typeof p === 'string')) return parsed as string[]
|
|
61
|
+
} catch {
|
|
62
|
+
// Not JSON — fall through to the raw-text fallback.
|
|
63
|
+
}
|
|
64
|
+
return raw.trim().length > 0 ? [raw] : []
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The visible composer textarea, or null when none is usable. */
|
|
68
|
+
function composerTextarea(): HTMLTextAreaElement | null {
|
|
69
|
+
if (typeof document === 'undefined') return null
|
|
70
|
+
const seat = document.querySelector('[data-composer-seat]')
|
|
71
|
+
if (seat === null) return null
|
|
72
|
+
const input = seat.querySelector('textarea')
|
|
73
|
+
if (!(input instanceof HTMLTextAreaElement)) return null
|
|
74
|
+
// A disabled input has no usable draft; a read-only one is either the hero
|
|
75
|
+
// workspace picker or a transient submitting state — skip both.
|
|
76
|
+
if (input.disabled || input.readOnly) return null
|
|
77
|
+
return input
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Insert `text` into the composer. If the composer textarea has focus, the
|
|
82
|
+
* text goes at the caret; otherwise it is appended to the draft. Returns
|
|
83
|
+
* false when no usable composer is present.
|
|
84
|
+
*/
|
|
85
|
+
export function insertIntoComposer(text: string): boolean {
|
|
86
|
+
const textarea = composerTextarea()
|
|
87
|
+
if (textarea === null) return false
|
|
88
|
+
const value = textarea.value
|
|
89
|
+
const atCaret = document.activeElement === textarea
|
|
90
|
+
const start = atCaret ? textarea.selectionStart : value.length
|
|
91
|
+
const end = atCaret ? textarea.selectionEnd : value.length
|
|
92
|
+
const next = value.slice(0, start) + text + value.slice(end)
|
|
93
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
|
|
94
|
+
if (setter !== undefined) setter.call(textarea, next)
|
|
95
|
+
else textarea.value = next
|
|
96
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
97
|
+
// The React re-render may reset the selection, so restore on the next frame.
|
|
98
|
+
const caret = start + text.length
|
|
99
|
+
requestAnimationFrame(() => {
|
|
100
|
+
try {
|
|
101
|
+
textarea.setSelectionRange(caret, caret)
|
|
102
|
+
} catch {
|
|
103
|
+
// Selection state unavailable — the text is in the draft either way.
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Relative workspace path of `path` under `cwd` ('' when it IS the cwd). */
|
|
110
|
+
export function relPathOf(path: string, cwd: string | undefined): string {
|
|
111
|
+
if (cwd === undefined || cwd.length === 0) return path
|
|
112
|
+
const sep = cwd.includes('\\') ? '\\' : '/'
|
|
113
|
+
const prefix = cwd.endsWith('\\') || cwd.endsWith('/') ? cwd : cwd + sep
|
|
114
|
+
const lowerPath = path.toLowerCase()
|
|
115
|
+
if (lowerPath.startsWith(prefix.toLowerCase())) return path.slice(prefix.length)
|
|
116
|
+
if (lowerPath === cwd.toLowerCase()) return ''
|
|
117
|
+
return path
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve an @-mention path (workspace-relative or absolute) against the
|
|
122
|
+
* current workspace cwd; returns the absolute OS path, or undefined when the
|
|
123
|
+
* mention cannot be resolved (no cwd and the path is not absolute).
|
|
124
|
+
*/
|
|
125
|
+
export function resolveMentionPath(mention: string): string | undefined {
|
|
126
|
+
const absolute = /^[A-Za-z]:[\\/]/.test(mention) || mention.startsWith('/') || mention.startsWith('\\')
|
|
127
|
+
const { cwd } = getTabsState()
|
|
128
|
+
if (absolute) return mention
|
|
129
|
+
if (cwd === undefined) return undefined
|
|
130
|
+
const sep = cwd.includes('\\') ? '\\' : '/'
|
|
131
|
+
return cwd.endsWith('\\') || cwd.endsWith('/') ? cwd + mention : cwd + sep + mention
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Open an @-mention's file in the workbench preview (used by the linkifier). */
|
|
135
|
+
export function openMention(mention: string): void {
|
|
136
|
+
const abs = resolveMentionPath(mention)
|
|
137
|
+
if (abs === undefined) return
|
|
138
|
+
openFile(abs)
|
|
139
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
11
11
|
import { FileExplorer } from './FileExplorer'
|
|
12
12
|
import { FilePreview } from './FilePreview'
|
|
13
13
|
import { NS, zh, en } from './locales'
|
|
14
|
+
import { installComposerDrops } from './composer'
|
|
15
|
+
import { installMentionLinkifier } from './mentions'
|
|
14
16
|
|
|
15
17
|
const CHANNEL = '/dsh-plugin-files'
|
|
16
18
|
|
|
@@ -20,6 +22,11 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'
|
|
|
20
22
|
export function apply(ctx: Context): void {
|
|
21
23
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'files-explorer: dictionaries')
|
|
22
24
|
|
|
25
|
+
// Drag-to-chat + @-mention linkifier: DOM-level integrations, one-time
|
|
26
|
+
// install (module guards make re-applies on HMR idempotent).
|
|
27
|
+
installComposerDrops()
|
|
28
|
+
ctx.effect(() => installMentionLinkifier(), 'files-explorer: @mention linkifier')
|
|
29
|
+
|
|
23
30
|
const listDir = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'list', { path }, signal))
|
|
24
31
|
const readFile = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'read', { path }, signal))
|
|
25
32
|
const writeFile = (path: string, content: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'write', { path, content }, signal))
|
package/src/client/locales.ts
CHANGED
|
@@ -18,6 +18,7 @@ export const zh = {
|
|
|
18
18
|
'tab.expand': '弹出文件详情',
|
|
19
19
|
'tab.diskChanged': '文件已在磁盘上被修改,点击重新加载(会放弃未保存的编辑)',
|
|
20
20
|
'menu.open': '打开预览',
|
|
21
|
+
'menu.atFile': '@ 在消息中引用',
|
|
21
22
|
'menu.newFile': '新建文件',
|
|
22
23
|
'menu.newFolder': '新建文件夹',
|
|
23
24
|
'menu.rename': '重命名',
|
|
@@ -30,6 +31,8 @@ export const zh = {
|
|
|
30
31
|
'menu.openSystem': '在系统中打开',
|
|
31
32
|
'menu.revealInExplorer': '在资源管理器打开',
|
|
32
33
|
'menu.deleteSelected': '删除选中的 {count} 项',
|
|
34
|
+
'menu.selectAll': '全选',
|
|
35
|
+
'menu.undo': '撤销',
|
|
33
36
|
'action.undo': '撤销(Ctrl+Z)',
|
|
34
37
|
'undo.copy': '复制「{name}」',
|
|
35
38
|
'undo.move': '移动「{name}」',
|
|
@@ -76,6 +79,7 @@ export const en: Record<FilesKey, string> = {
|
|
|
76
79
|
'tab.expand': 'Expand file details',
|
|
77
80
|
'tab.diskChanged': 'File changed on disk — click to reload (discards unsaved edits)',
|
|
78
81
|
'menu.open': 'Open preview',
|
|
82
|
+
'menu.atFile': '@ Mention in message',
|
|
79
83
|
'menu.newFile': 'New File',
|
|
80
84
|
'menu.newFolder': 'New Folder',
|
|
81
85
|
'menu.rename': 'Rename',
|
|
@@ -88,6 +92,8 @@ export const en: Record<FilesKey, string> = {
|
|
|
88
92
|
'menu.openSystem': 'Open in System',
|
|
89
93
|
'menu.revealInExplorer': 'Reveal in Explorer',
|
|
90
94
|
'menu.deleteSelected': 'Delete {count} selected item(s)',
|
|
95
|
+
'menu.selectAll': 'Select All',
|
|
96
|
+
'menu.undo': 'Undo',
|
|
91
97
|
'action.undo': 'Undo (Ctrl+Z)',
|
|
92
98
|
'undo.copy': 'Copy "{name}"',
|
|
93
99
|
'undo.move': 'Move "{name}"',
|