dsh-plugin-workbench 0.0.4 → 0.0.5
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 +34 -2
- package/README.md +14 -5
- package/lib/client.js +462 -53
- package/lib/client.js.map +1 -1
- package/lib/index.js +169 -6
- package/package.json +1 -1
- package/src/client/FileExplorer.tsx +232 -4
- package/src/client/FilePreview.tsx +97 -11
- package/src/client/files.module.css +110 -3
- package/src/client/index.ts +5 -1
- package/src/client/locales.ts +28 -0
- package/src/client/store.ts +25 -0
- package/src/dsh.d.ts +9 -0
- package/src/index.ts +204 -6
package/lib/index.js
CHANGED
|
@@ -1,10 +1,40 @@
|
|
|
1
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
1
2
|
//#region src/index.ts
|
|
2
3
|
const name = "dsh-plugin-workbench";
|
|
3
|
-
const inject = [
|
|
4
|
+
const inject = [
|
|
5
|
+
"fs",
|
|
6
|
+
"connection",
|
|
7
|
+
"webServer"
|
|
8
|
+
];
|
|
4
9
|
/** Loopback-only logical RPC channel. */
|
|
5
10
|
const CHANNEL = "/dsh-plugin-files";
|
|
6
11
|
/** Files larger than this are never read for preview (client shows size + hint). */
|
|
7
12
|
const MAX_PREVIEW_BYTES = 524288;
|
|
13
|
+
/** Same-origin route serving raw bytes for image files (see module doc). */
|
|
14
|
+
const RAW_PREFIX = "/dsh-plugin-files/raw";
|
|
15
|
+
/** Images larger than this are never served to the preview (browser shows a hint). */
|
|
16
|
+
const MAX_IMAGE_BYTES = 20971520;
|
|
17
|
+
const IMAGE_MIME = {
|
|
18
|
+
png: "image/png",
|
|
19
|
+
jpg: "image/jpeg",
|
|
20
|
+
jpeg: "image/jpeg",
|
|
21
|
+
gif: "image/gif",
|
|
22
|
+
webp: "image/webp",
|
|
23
|
+
avif: "image/avif",
|
|
24
|
+
bmp: "image/bmp",
|
|
25
|
+
ico: "image/x-icon",
|
|
26
|
+
svg: "image/svg+xml"
|
|
27
|
+
};
|
|
28
|
+
/** MIME type for a path's extension when it names a previewable image; undefined otherwise. */
|
|
29
|
+
function imageMimeOf(path) {
|
|
30
|
+
const idx = path.lastIndexOf(".");
|
|
31
|
+
if (idx < 0 || idx === path.length - 1) return void 0;
|
|
32
|
+
return IMAGE_MIME[path.slice(idx + 1).toLowerCase()];
|
|
33
|
+
}
|
|
34
|
+
/** True when the path names a previewable image file. */
|
|
35
|
+
function isImagePath(path) {
|
|
36
|
+
return imageMimeOf(path) !== void 0;
|
|
37
|
+
}
|
|
8
38
|
/** Map an fs entry type onto the wire `kind` union. */
|
|
9
39
|
function kindOf(type) {
|
|
10
40
|
if (type === "directory") return "dir";
|
|
@@ -42,7 +72,15 @@ const FS_ERROR_MESSAGES = {
|
|
|
42
72
|
FS_PERMISSION_DENIED: "permission denied",
|
|
43
73
|
FS_SANDBOX_DENIED: "sandbox denied",
|
|
44
74
|
FS_ABORTED: "aborted",
|
|
45
|
-
FS_IO_ERROR: "io error"
|
|
75
|
+
FS_IO_ERROR: "io error",
|
|
76
|
+
EEXIST: "file or folder already exists",
|
|
77
|
+
ENOENT: "path does not exist",
|
|
78
|
+
ENOTEMPTY: "folder is not empty",
|
|
79
|
+
EPERM: "permission denied",
|
|
80
|
+
EACCES: "permission denied",
|
|
81
|
+
ENOTDIR: "not a directory",
|
|
82
|
+
EISDIR: "is a directory",
|
|
83
|
+
EBUSY: "file is in use"
|
|
46
84
|
};
|
|
47
85
|
/** Human-readable message for a thrown value, honoring the fs error code taxonomy. */
|
|
48
86
|
function mapError(error) {
|
|
@@ -80,10 +118,65 @@ function apply(ctx) {
|
|
|
80
118
|
const handler = async (endpoint, payload, signal) => {
|
|
81
119
|
if (endpoint === "list") return listDir(ctx, payload, signal);
|
|
82
120
|
if (endpoint === "read") return readFile(ctx, payload, signal);
|
|
83
|
-
if (endpoint === "write") return writeFile(ctx, payload, signal);
|
|
121
|
+
if (endpoint === "write") return writeFile$1(ctx, payload, signal);
|
|
122
|
+
if (endpoint === "createFile") return createFile(ctx, payload, signal);
|
|
123
|
+
if (endpoint === "createDir") return createDir(ctx, payload, signal);
|
|
124
|
+
if (endpoint === "rename") return renameEntry(ctx, payload, signal);
|
|
125
|
+
if (endpoint === "delete") return deleteEntry(ctx, payload, signal);
|
|
84
126
|
return fail(`unknown endpoint: ${endpoint}`);
|
|
85
127
|
};
|
|
86
|
-
ctx.connection.rpc.handle(CHANNEL, handler, { authority: "loopback" });
|
|
128
|
+
ctx.effect(() => ctx.connection.rpc.handle(CHANNEL, handler, { authority: "loopback" }), "dsh-plugin-workbench: files rpc channel");
|
|
129
|
+
ctx.effect(() => ctx.webServer.register({
|
|
130
|
+
kind: "prefix",
|
|
131
|
+
path: RAW_PREFIX,
|
|
132
|
+
handler: (req, res) => {
|
|
133
|
+
serveRaw(ctx, req, res);
|
|
134
|
+
}
|
|
135
|
+
}), "dsh-plugin-workbench: raw image route");
|
|
136
|
+
}
|
|
137
|
+
async function serveRaw(ctx, req, res) {
|
|
138
|
+
const text = (code, body) => {
|
|
139
|
+
res.writeHead(code, { "content-type": "text/plain; charset=utf-8" });
|
|
140
|
+
res.end(body);
|
|
141
|
+
};
|
|
142
|
+
try {
|
|
143
|
+
if ((req.method ?? "GET").toUpperCase() !== "GET") {
|
|
144
|
+
text(405, "method not allowed");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const rest = new URL(req.url ?? "/", "http://dsh.internal").pathname.slice(21).replace(/^\/+/, "");
|
|
148
|
+
if (rest.length === 0) {
|
|
149
|
+
text(404, "not found");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const path = decodeURIComponent(rest);
|
|
153
|
+
const mime = imageMimeOf(path);
|
|
154
|
+
if (mime === void 0) {
|
|
155
|
+
text(404, "not an image");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const target = await ctx.fs.resolve(path);
|
|
159
|
+
const info = await ctx.fs.stat(target);
|
|
160
|
+
if (info === void 0 || info.type !== "file") {
|
|
161
|
+
text(404, "not found");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if ((info.size ?? 0) > 20971520) {
|
|
165
|
+
text(413, "image too large");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const bytes = await ctx.fs.readBytes(target, void 0, MAX_IMAGE_BYTES);
|
|
169
|
+
res.writeHead(200, {
|
|
170
|
+
"content-type": mime,
|
|
171
|
+
"content-length": bytes.byteLength,
|
|
172
|
+
"cache-control": "private, max-age=300",
|
|
173
|
+
"x-content-type-options": "nosniff"
|
|
174
|
+
});
|
|
175
|
+
res.end(Buffer.from(bytes));
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!res.headersSent) text(500, "internal error");
|
|
178
|
+
else res.destroy();
|
|
179
|
+
}
|
|
87
180
|
}
|
|
88
181
|
async function listDir(ctx, payload, signal) {
|
|
89
182
|
const path = pathOf(payload);
|
|
@@ -153,7 +246,7 @@ async function readFile(ctx, payload, signal) {
|
|
|
153
246
|
return fail(mapError(error));
|
|
154
247
|
}
|
|
155
248
|
}
|
|
156
|
-
async function writeFile(ctx, payload, signal) {
|
|
249
|
+
async function writeFile$1(ctx, payload, signal) {
|
|
157
250
|
const path = pathOf(payload);
|
|
158
251
|
const content = typeof payload === "object" && payload !== null ? payload.content : void 0;
|
|
159
252
|
if (path === void 0) return fail("write: payload.path must be a non-empty string");
|
|
@@ -175,5 +268,75 @@ async function writeFile(ctx, payload, signal) {
|
|
|
175
268
|
return fail(mapError(error));
|
|
176
269
|
}
|
|
177
270
|
}
|
|
271
|
+
async function createFile(ctx, payload, signal) {
|
|
272
|
+
const path = pathOf(payload);
|
|
273
|
+
if (path === void 0) return fail("createFile: payload.path must be a non-empty string");
|
|
274
|
+
try {
|
|
275
|
+
const target = await ctx.fs.resolve(path, { signal });
|
|
276
|
+
const osPath = ctx.fs.processPath(target);
|
|
277
|
+
await writeFile(osPath, "", { flag: "wx" });
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
value: { path: osPath }
|
|
281
|
+
};
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return fail(mapError(error));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function createDir(ctx, payload, signal) {
|
|
287
|
+
const path = pathOf(payload);
|
|
288
|
+
if (path === void 0) return fail("createDir: payload.path must be a non-empty string");
|
|
289
|
+
try {
|
|
290
|
+
const target = await ctx.fs.resolve(path, { signal });
|
|
291
|
+
const osPath = ctx.fs.processPath(target);
|
|
292
|
+
await mkdir(osPath);
|
|
293
|
+
return {
|
|
294
|
+
ok: true,
|
|
295
|
+
value: { path: osPath }
|
|
296
|
+
};
|
|
297
|
+
} catch (error) {
|
|
298
|
+
return fail(mapError(error));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function renameEntry(ctx, payload, signal) {
|
|
302
|
+
const path = pathOf(payload);
|
|
303
|
+
const to = typeof payload === "object" && payload !== null ? payload.to : void 0;
|
|
304
|
+
if (path === void 0) return fail("rename: payload.path must be a non-empty string");
|
|
305
|
+
if (typeof to !== "string" || to.trim().length === 0) return fail("rename: payload.to must be a non-empty string");
|
|
306
|
+
try {
|
|
307
|
+
const target = await ctx.fs.resolve(path, { signal });
|
|
308
|
+
const osPath = ctx.fs.processPath(target);
|
|
309
|
+
const toTarget = await ctx.fs.resolve(to, { signal });
|
|
310
|
+
const toOsPath = ctx.fs.processPath(toTarget);
|
|
311
|
+
if (await ctx.fs.stat(toTarget, signal) !== void 0) return fail("rename: destination already exists");
|
|
312
|
+
await rename(osPath, toOsPath);
|
|
313
|
+
return {
|
|
314
|
+
ok: true,
|
|
315
|
+
value: { path: toOsPath }
|
|
316
|
+
};
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return fail(mapError(error));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async function deleteEntry(ctx, payload, signal) {
|
|
322
|
+
const path = pathOf(payload);
|
|
323
|
+
if (path === void 0) return fail("delete: payload.path must be a non-empty string");
|
|
324
|
+
try {
|
|
325
|
+
const target = await ctx.fs.resolve(path, { signal });
|
|
326
|
+
const osPath = ctx.fs.processPath(target);
|
|
327
|
+
const info = await ctx.fs.stat(target, signal);
|
|
328
|
+
if (info === void 0) return fail(`path not found: ${path}`);
|
|
329
|
+
await rm(osPath, {
|
|
330
|
+
recursive: info.type === "directory",
|
|
331
|
+
force: true
|
|
332
|
+
});
|
|
333
|
+
return {
|
|
334
|
+
ok: true,
|
|
335
|
+
value: { path: osPath }
|
|
336
|
+
};
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return fail(mapError(error));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
178
341
|
//#endregion
|
|
179
|
-
export { CHANNEL, MAX_PREVIEW_BYTES, apply, inject, kindOf, mapDirEntry, mapError, name, sortEntries };
|
|
342
|
+
export { CHANNEL, MAX_IMAGE_BYTES, MAX_PREVIEW_BYTES, RAW_PREFIX, apply, imageMimeOf, inject, isImagePath, kindOf, mapDirEntry, mapError, name, sortEntries };
|
package/package.json
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* (patched) ui-layout AppFrame. File selection is pushed into the shared
|
|
4
4
|
* selection store so the `explorer.preview` slot can render the split view.
|
|
5
5
|
*/
|
|
6
|
-
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
|
6
|
+
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
7
|
+
import type { MouseEvent as ReactMouseEvent } from 'react'
|
|
7
8
|
import styles from './files.module.css'
|
|
8
9
|
import { FileIcon } from './fileIcons'
|
|
9
10
|
import type { FilesKey } from './locales'
|
|
10
|
-
import { expandPreview, openFile, setCwd, toggleTheme, useTabsState } from './store'
|
|
11
|
+
import { closeFilesUnder, expandPreview, openFile, retargetFile, setCwd, toggleTheme, useTabsState } from './store'
|
|
11
12
|
|
|
12
13
|
export interface FsListEntry {
|
|
13
14
|
name: string
|
|
@@ -29,6 +30,11 @@ export interface FsReadResult {
|
|
|
29
30
|
truncated: boolean
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** Result of a context-menu mutation (create/rename/delete). */
|
|
34
|
+
export interface FsMutationResult {
|
|
35
|
+
path: string
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
interface SessionSummary {
|
|
33
39
|
id: string
|
|
34
40
|
cwd?: string
|
|
@@ -45,6 +51,10 @@ export interface FileExplorerProps {
|
|
|
45
51
|
t: (key: FilesKey, params?: Record<string, unknown>) => string
|
|
46
52
|
listDir: (path: string, signal?: AbortSignal) => Promise<FsListResult>
|
|
47
53
|
openPath: (path: string) => Promise<void>
|
|
54
|
+
createFile: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
|
|
55
|
+
createDir: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
|
|
56
|
+
renameFile: (path: string, to: string, signal?: AbortSignal) => Promise<FsMutationResult>
|
|
57
|
+
removePath: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
function basenameOf(path: string): string {
|
|
@@ -53,6 +63,18 @@ function basenameOf(path: string): string {
|
|
|
53
63
|
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
|
|
54
64
|
}
|
|
55
65
|
|
|
66
|
+
/** Parent directory of a path ('' for a bare drive root — never used for such). */
|
|
67
|
+
function parentOf(path: string): string {
|
|
68
|
+
const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
|
69
|
+
return idx > 0 ? path.slice(0, idx) : path
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Append a child name to a directory path, honoring its separator style. */
|
|
73
|
+
function joinPath(dir: string, name: string): string {
|
|
74
|
+
const sep = dir.includes('\\') ? '\\' : '/'
|
|
75
|
+
return dir.endsWith('\\') || dir.endsWith('/') ? dir + name : dir + sep + name
|
|
76
|
+
}
|
|
77
|
+
|
|
56
78
|
function formatSize(bytes: number): string {
|
|
57
79
|
if (!Number.isFinite(bytes) || bytes < 0) return ''
|
|
58
80
|
if (bytes < 1024) return `${bytes} B`
|
|
@@ -91,6 +113,7 @@ interface TreeRowProps {
|
|
|
91
113
|
onSelect: (entry: FsListEntry) => void
|
|
92
114
|
onDoubleClick: (entry: FsListEntry) => void
|
|
93
115
|
onOpenExternal: (path: string) => Promise<void>
|
|
116
|
+
onContextMenu: (e: ReactMouseEvent, entry: FsListEntry) => void
|
|
94
117
|
t: (key: FilesKey, params?: Record<string, unknown>) => string
|
|
95
118
|
}
|
|
96
119
|
|
|
@@ -110,6 +133,7 @@ const TreeRow = memo(function TreeRow({
|
|
|
110
133
|
onSelect,
|
|
111
134
|
onDoubleClick,
|
|
112
135
|
onOpenExternal,
|
|
136
|
+
onContextMenu,
|
|
113
137
|
t,
|
|
114
138
|
}: TreeRowProps) {
|
|
115
139
|
const isDir = entry.kind === 'dir'
|
|
@@ -119,6 +143,7 @@ const TreeRow = memo(function TreeRow({
|
|
|
119
143
|
style={{ paddingLeft: 8 + depth * 14 }}
|
|
120
144
|
onClick={() => onSelect(entry)}
|
|
121
145
|
onDoubleClick={() => onDoubleClick(entry)}
|
|
146
|
+
onContextMenu={(e) => onContextMenu(e, entry)}
|
|
122
147
|
role="treeitem"
|
|
123
148
|
aria-selected={isActive}
|
|
124
149
|
aria-expanded={isDir ? isExpanded : undefined}
|
|
@@ -170,7 +195,17 @@ const TreeRow = memo(function TreeRow({
|
|
|
170
195
|
)
|
|
171
196
|
})
|
|
172
197
|
|
|
173
|
-
export function FileExplorer({
|
|
198
|
+
export function FileExplorer({
|
|
199
|
+
width,
|
|
200
|
+
useSessions,
|
|
201
|
+
t,
|
|
202
|
+
listDir,
|
|
203
|
+
openPath,
|
|
204
|
+
createFile,
|
|
205
|
+
createDir,
|
|
206
|
+
renameFile,
|
|
207
|
+
removePath,
|
|
208
|
+
}: FileExplorerProps) {
|
|
174
209
|
const sessionList = useSessions((s) => s)
|
|
175
210
|
const currentId = sessionList.current
|
|
176
211
|
const cwd = currentId !== undefined ? sessionList.byId[currentId]?.cwd : undefined
|
|
@@ -190,6 +225,57 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
190
225
|
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set())
|
|
191
226
|
const [dirErrors, setDirErrors] = useState<Record<string, string>>({})
|
|
192
227
|
|
|
228
|
+
// ---- context menu ----
|
|
229
|
+
interface MenuState {
|
|
230
|
+
kind: 'file' | 'dir' | 'root'
|
|
231
|
+
path: string
|
|
232
|
+
x: number
|
|
233
|
+
y: number
|
|
234
|
+
}
|
|
235
|
+
const [menu, setMenu] = useState<MenuState | undefined>(undefined)
|
|
236
|
+
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | undefined>(undefined)
|
|
237
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
238
|
+
|
|
239
|
+
// Measure and clamp the menu into the viewport before the browser paints
|
|
240
|
+
// (layout effects run pre-paint, so the raw position never shows).
|
|
241
|
+
useLayoutEffect(() => {
|
|
242
|
+
if (menu === undefined) {
|
|
243
|
+
setMenuPos(undefined)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
const el = menuRef.current
|
|
247
|
+
if (el === null) return
|
|
248
|
+
const rect = el.getBoundingClientRect()
|
|
249
|
+
setMenuPos({
|
|
250
|
+
x: Math.max(4, Math.min(menu.x, window.innerWidth - rect.width - 4)),
|
|
251
|
+
y: Math.max(4, Math.min(menu.y, window.innerHeight - rect.height - 4)),
|
|
252
|
+
})
|
|
253
|
+
}, [menu])
|
|
254
|
+
|
|
255
|
+
// Close the menu on outside click / Escape / window blur.
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (menu === undefined) return undefined
|
|
258
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
259
|
+
if (e.key === 'Escape') setMenu(undefined)
|
|
260
|
+
}
|
|
261
|
+
const onMouseDown = (e: MouseEvent) => {
|
|
262
|
+
const el = menuRef.current
|
|
263
|
+
if (el !== null && e.target instanceof Node && el.contains(e.target)) return
|
|
264
|
+
setMenu(undefined)
|
|
265
|
+
}
|
|
266
|
+
const onBlur = () => setMenu(undefined)
|
|
267
|
+
document.addEventListener('keydown', onKeyDown)
|
|
268
|
+
// mousedown (not click): closing before a menu item's click still lets the
|
|
269
|
+
// click dispatch on the item, and closes when clicking anywhere else.
|
|
270
|
+
document.addEventListener('mousedown', onMouseDown)
|
|
271
|
+
window.addEventListener('blur', onBlur)
|
|
272
|
+
return () => {
|
|
273
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
274
|
+
document.removeEventListener('mousedown', onMouseDown)
|
|
275
|
+
window.removeEventListener('blur', onBlur)
|
|
276
|
+
}
|
|
277
|
+
}, [menu])
|
|
278
|
+
|
|
193
279
|
// Latest tree snapshot for the polling tick (avoids stale closures).
|
|
194
280
|
const treeRef = useRef({ root, children, expanded, rootLoading })
|
|
195
281
|
treeRef.current = { root, children, expanded, rootLoading }
|
|
@@ -358,6 +444,81 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
358
444
|
void loadDir(dirPath)
|
|
359
445
|
}, [loadDir])
|
|
360
446
|
|
|
447
|
+
// ---- context-menu actions ----
|
|
448
|
+
|
|
449
|
+
const openContextMenu = useCallback((e: ReactMouseEvent, entry: FsListEntry) => {
|
|
450
|
+
e.preventDefault()
|
|
451
|
+
e.stopPropagation()
|
|
452
|
+
setMenu({ kind: entry.kind === 'dir' ? 'dir' : 'file', path: entry.path, x: e.clientX, y: e.clientY })
|
|
453
|
+
}, [])
|
|
454
|
+
|
|
455
|
+
const runAction = useCallback((action: () => void) => {
|
|
456
|
+
setMenu(undefined)
|
|
457
|
+
action()
|
|
458
|
+
}, [])
|
|
459
|
+
|
|
460
|
+
// One mutation pipeline shared by New File / New Folder / Rename / Delete:
|
|
461
|
+
// run the op, refresh the touched directory, and surface failures inline in
|
|
462
|
+
// the tree (under the directory row, or at the column top for the root).
|
|
463
|
+
const applyMutation = useCallback(async (op: () => Promise<unknown>, refreshPath: string) => {
|
|
464
|
+
try {
|
|
465
|
+
await op()
|
|
466
|
+
refreshDir(refreshPath)
|
|
467
|
+
} catch (error) {
|
|
468
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
469
|
+
if (refreshPath === root) setRootError(message)
|
|
470
|
+
else setDirErrors((prev) => ({ ...prev, [refreshPath]: message }))
|
|
471
|
+
}
|
|
472
|
+
}, [refreshDir, root])
|
|
473
|
+
|
|
474
|
+
const onNewFile = useCallback((dirPath: string) => {
|
|
475
|
+
const name = window.prompt(`${t('prompt.newFileName')}:`, '')
|
|
476
|
+
if (name === null) return
|
|
477
|
+
const trimmed = name.trim()
|
|
478
|
+
if (trimmed === '') return
|
|
479
|
+
void applyMutation(() => createFile(joinPath(dirPath, trimmed)), dirPath)
|
|
480
|
+
}, [applyMutation, createFile, t])
|
|
481
|
+
|
|
482
|
+
const onNewFolder = useCallback((dirPath: string) => {
|
|
483
|
+
const name = window.prompt(`${t('prompt.newFolderName')}:`, '')
|
|
484
|
+
if (name === null) return
|
|
485
|
+
const trimmed = name.trim()
|
|
486
|
+
if (trimmed === '') return
|
|
487
|
+
void applyMutation(() => createDir(joinPath(dirPath, trimmed)), dirPath)
|
|
488
|
+
}, [applyMutation, createDir, t])
|
|
489
|
+
|
|
490
|
+
const onRename = useCallback((path: string) => {
|
|
491
|
+
const current = basenameOf(path)
|
|
492
|
+
const name = window.prompt(`${t('prompt.renameTo')}:`, current)
|
|
493
|
+
if (name === null) return
|
|
494
|
+
const trimmed = name.trim()
|
|
495
|
+
if (trimmed === '' || trimmed === current) return
|
|
496
|
+
const parent = parentOf(path)
|
|
497
|
+
const to = joinPath(parent, trimmed)
|
|
498
|
+
void applyMutation(async () => {
|
|
499
|
+
await renameFile(path, to)
|
|
500
|
+
// Keep any open tab pointing at the moved file.
|
|
501
|
+
retargetFile(path, to)
|
|
502
|
+
}, parent)
|
|
503
|
+
}, [applyMutation, renameFile, t])
|
|
504
|
+
|
|
505
|
+
const onDelete = useCallback((path: string, kind: 'file' | 'dir') => {
|
|
506
|
+
const name = basenameOf(path)
|
|
507
|
+
const message = kind === 'dir' ? t('confirm.deleteDir', { name }) : t('confirm.deleteFile', { name })
|
|
508
|
+
if (!window.confirm(message)) return
|
|
509
|
+
void applyMutation(async () => {
|
|
510
|
+
await removePath(path)
|
|
511
|
+
// Drop tabs for the deleted file (or everything under a deleted folder).
|
|
512
|
+
closeFilesUnder(path)
|
|
513
|
+
}, parentOf(path))
|
|
514
|
+
}, [applyMutation, removePath, t])
|
|
515
|
+
|
|
516
|
+
const onCopyPath = useCallback((path: string) => {
|
|
517
|
+
void navigator.clipboard.writeText(path).catch(() => {
|
|
518
|
+
// clipboard unavailable (permissions) — nothing else to do
|
|
519
|
+
})
|
|
520
|
+
}, [])
|
|
521
|
+
|
|
361
522
|
const onRowClick = useCallback((entry: FsListEntry) => {
|
|
362
523
|
if (entry.kind === 'dir') {
|
|
363
524
|
toggleDir(entry.path)
|
|
@@ -401,6 +562,7 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
401
562
|
onSelect={onRowClick}
|
|
402
563
|
onDoubleClick={onRowDoubleClick}
|
|
403
564
|
onOpenExternal={openPath}
|
|
565
|
+
onContextMenu={openContextMenu}
|
|
404
566
|
t={t}
|
|
405
567
|
/>
|
|
406
568
|
{isDir && isExpanded && (
|
|
@@ -417,6 +579,41 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
417
579
|
)
|
|
418
580
|
})
|
|
419
581
|
|
|
582
|
+
const menuItems = (m: MenuState): Array<{ label: string; danger?: boolean; onClick: () => void } | 'divider'> => {
|
|
583
|
+
if (m.kind === 'file') {
|
|
584
|
+
return [
|
|
585
|
+
{ label: t('menu.open'), onClick: () => openFile(m.path) },
|
|
586
|
+
'divider',
|
|
587
|
+
{ label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
|
|
588
|
+
{ label: t('menu.openSystem'), onClick: () => void openPath(m.path) },
|
|
589
|
+
'divider',
|
|
590
|
+
{ label: t('menu.rename'), onClick: () => onRename(m.path) },
|
|
591
|
+
{ label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, 'file') },
|
|
592
|
+
]
|
|
593
|
+
}
|
|
594
|
+
if (m.kind === 'dir') {
|
|
595
|
+
return [
|
|
596
|
+
{ label: t('menu.newFile'), onClick: () => onNewFile(m.path) },
|
|
597
|
+
{ label: t('menu.newFolder'), onClick: () => onNewFolder(m.path) },
|
|
598
|
+
'divider',
|
|
599
|
+
{ label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
|
|
600
|
+
{ label: t('menu.openSystem'), onClick: () => void openPath(m.path) },
|
|
601
|
+
{ label: t('menu.refresh'), onClick: () => refreshDir(m.path) },
|
|
602
|
+
'divider',
|
|
603
|
+
{ label: t('menu.rename'), onClick: () => onRename(m.path) },
|
|
604
|
+
{ label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, 'dir') },
|
|
605
|
+
]
|
|
606
|
+
}
|
|
607
|
+
// Empty tree area (the workspace root).
|
|
608
|
+
return [
|
|
609
|
+
{ label: t('menu.newFile'), onClick: () => onNewFile(m.path) },
|
|
610
|
+
{ label: t('menu.newFolder'), onClick: () => onNewFolder(m.path) },
|
|
611
|
+
'divider',
|
|
612
|
+
{ label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
|
|
613
|
+
{ label: t('menu.refresh'), onClick: () => refreshDir(m.path) },
|
|
614
|
+
]
|
|
615
|
+
}
|
|
616
|
+
|
|
420
617
|
return (
|
|
421
618
|
<div className={styles.column} style={{ width: width > 0 ? width : undefined }} data-pane="explorer" data-fe-theme={theme}>
|
|
422
619
|
<div className={styles.header}>
|
|
@@ -429,7 +626,15 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
429
626
|
<button type="button" className={styles.action} title={t('tab.expand')} onClick={expandPreview}>{'>'}</button>
|
|
430
627
|
</span>
|
|
431
628
|
</div>
|
|
432
|
-
<div
|
|
629
|
+
<div
|
|
630
|
+
className={styles.treeArea}
|
|
631
|
+
onContextMenu={(e) => {
|
|
632
|
+
e.preventDefault()
|
|
633
|
+
const target = root ?? cwd
|
|
634
|
+
if (target === undefined) return
|
|
635
|
+
setMenu({ kind: 'root', path: target, x: e.clientX, y: e.clientY })
|
|
636
|
+
}}
|
|
637
|
+
>
|
|
433
638
|
{rootLoading && <div className={styles.rowHint}>{t('preview.loading')}</div>}
|
|
434
639
|
{rootError !== undefined && <div className={styles.rowError}>{rootError}</div>}
|
|
435
640
|
{!rootLoading && rootError === undefined && root !== undefined && children[root] !== undefined && children[root].length === 0 && (
|
|
@@ -437,6 +642,29 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
437
642
|
)}
|
|
438
643
|
{root !== undefined && children[root] !== undefined && renderEntries(children[root], 0)}
|
|
439
644
|
</div>
|
|
645
|
+
{menu !== undefined && (
|
|
646
|
+
<div
|
|
647
|
+
ref={menuRef}
|
|
648
|
+
className={styles.contextMenu}
|
|
649
|
+
role="menu"
|
|
650
|
+
style={menuPos !== undefined ? { left: menuPos.x, top: menuPos.y } : { left: menu.x, top: menu.y, visibility: 'hidden' }}
|
|
651
|
+
>
|
|
652
|
+
{menuItems(menu).map((item, index) =>
|
|
653
|
+
item === 'divider' ? (
|
|
654
|
+
<div key={index} className={styles.contextMenuDivider} />
|
|
655
|
+
) : (
|
|
656
|
+
<div
|
|
657
|
+
key={index}
|
|
658
|
+
role="menuitem"
|
|
659
|
+
className={`${styles.contextMenuItem}${item.danger ? ` ${styles.contextMenuItemDanger}` : ''}`}
|
|
660
|
+
onClick={() => runAction(item.onClick)}
|
|
661
|
+
>
|
|
662
|
+
{item.label}
|
|
663
|
+
</div>
|
|
664
|
+
),
|
|
665
|
+
)}
|
|
666
|
+
</div>
|
|
667
|
+
)}
|
|
440
668
|
</div>
|
|
441
669
|
)
|
|
442
670
|
}
|