@reed-io/dsh-workspace-move 0.0.0-stage → 0.1.0

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/src/client.js ADDED
@@ -0,0 +1,618 @@
1
+ /**
2
+ * Browser half of the workspace move plugin: the workspace context-menu entries
3
+ * and the destination dialog. Every operation is delegated to the Host over the
4
+ * plugin's own route, so the Client never touches the registry or the session
5
+ * store itself.
6
+ * @module @reed-io/dsh-workspace-move/client
7
+ */
8
+ import { createElement as h, useCallback, useEffect, useRef, useState } from "react";
9
+
10
+ const translations = {
11
+ "zh": {
12
+ "meta.title": "移动工作区",
13
+ "meta.description": "把工作区目录、其子目录、其下的子工作区和其中的会话整体搬到新位置,支持重命名、失败回滚与撤销。",
14
+ "menu.move": "移动工作区…",
15
+ "menu.undo": "撤销上次移动",
16
+ "dialog.title": "移动工作区",
17
+ "dialog.source": "当前位置",
18
+ "dialog.target": "目标上级文件夹",
19
+ "dialog.folderName": "文件夹名称",
20
+ "dialog.folderHint": "工作区会以此名称放进所选文件夹;改名即重命名工作区。",
21
+ "dialog.destination": "移动后位置",
22
+ "dialog.up": "上一层",
23
+ "dialog.home": "主目录",
24
+ "dialog.newFolder": "新建文件夹",
25
+ "dialog.newFolderPrompt": "新文件夹名称",
26
+ "dialog.pickHere": "选择此文件夹",
27
+ "dialog.confirm": "移动",
28
+ "dialog.cancel": "取消",
29
+ "dialog.moving": "正在移动…",
30
+ "dialog.loading": "加载中…",
31
+ "dialog.emptyFolder": "这里没有子文件夹",
32
+ "dialog.invalidName": "请填写不含斜杠的文件夹名称。",
33
+ "dialog.targetExists": "目标文件夹里已有同名项且非空,请换个名字。",
34
+ "dialog.sameLocation": "工作区已经在这个位置了,换个目标或改个名字。",
35
+ "dialog.insideSelf": "不能把工作区搬到它自己的文件夹里面。",
36
+ "dialog.ancestor": "目标在工作区当前路径的上层,请换一个位置。",
37
+ "dialog.missingFolder": "这个文件夹不存在,请刷新后重试。",
38
+ "dialog.unknownWorkspace": "这个工作区已经不在列表里了,请重新打开菜单。",
39
+ "toast.moved": "工作区已移动。",
40
+ "toast.undone": "已撤销移动。",
41
+ "toast.failed": "操作失败。",
42
+ "running.title": "请先停止这些会话",
43
+ "running.hint": "有正在运行的会话不能移动,停止后再试。",
44
+ "undo.title": "撤销上次移动",
45
+ "undo.body": "将把工作区及其会话恢复到原来的位置。",
46
+ "undo.confirm": "撤销",
47
+ "undo.none": "没有可撤销的移动。"
48
+ },
49
+ "en": {
50
+ "meta.title": "Workspace Move",
51
+ "meta.description": "Move a workspace directory — its subfolders, nested workspaces and sessions — to a new location, with rename, rollback and undo.",
52
+ "menu.move": "Move workspace…",
53
+ "menu.undo": "Undo last move",
54
+ "dialog.title": "Move workspace",
55
+ "dialog.source": "Current location",
56
+ "dialog.target": "Destination parent folder",
57
+ "dialog.folderName": "Folder name",
58
+ "dialog.folderHint": "The workspace lands in the chosen folder under this name. Changing the name renames the workspace.",
59
+ "dialog.destination": "New location",
60
+ "dialog.up": "Up",
61
+ "dialog.home": "Home",
62
+ "dialog.newFolder": "New folder",
63
+ "dialog.newFolderPrompt": "New folder name",
64
+ "dialog.pickHere": "Use this folder",
65
+ "dialog.confirm": "Move",
66
+ "dialog.cancel": "Cancel",
67
+ "dialog.moving": "Moving…",
68
+ "dialog.loading": "Loading…",
69
+ "dialog.emptyFolder": "No subfolders here",
70
+ "dialog.invalidName": "Enter a folder name without slashes.",
71
+ "dialog.targetExists": "That name already exists there and is not empty. Pick another name.",
72
+ "dialog.sameLocation": "The workspace is already there. Pick another place or change the name.",
73
+ "dialog.insideSelf": "A workspace cannot be moved inside its own folder.",
74
+ "dialog.ancestor": "That place is above the workspace's current path. Pick another one.",
75
+ "dialog.missingFolder": "That folder does not exist. Refresh and try again.",
76
+ "dialog.unknownWorkspace": "That workspace is no longer listed. Reopen the menu.",
77
+ "toast.moved": "Workspace moved.",
78
+ "toast.undone": "Move undone.",
79
+ "toast.failed": "The action failed.",
80
+ "running.title": "Stop these sessions first",
81
+ "running.hint": "Sessions with running work cannot be moved. Stop them and try again.",
82
+ "undo.title": "Undo last move",
83
+ "undo.body": "Restores the workspace and its sessions to the previous location.",
84
+ "undo.confirm": "Undo",
85
+ "undo.none": "Nothing to undo."
86
+ }
87
+ };
88
+
89
+ /** Stable route prefix of the Host API. */
90
+ const API_PREFIX = "/dsh-workspace-move/api";
91
+
92
+ /** Header every Host call carries; the Host rejects anything without it. */
93
+ const API_HEADER = "x-dsh-workspace-move";
94
+
95
+ /**
96
+ * POST one action to the Host and unwrap its envelope.
97
+ * @param action - action name.
98
+ * @param body - JSON body.
99
+ * @returns the action result.
100
+ */
101
+ async function callHost(action, body = {}) {
102
+ const response = await fetch(`${API_PREFIX}/${action}`, {
103
+ method: "POST",
104
+ headers: { "content-type": "application/json", [API_HEADER]: "1" },
105
+ body: JSON.stringify(body),
106
+ credentials: "same-origin"
107
+ });
108
+ if (!response.ok) {
109
+ const error = new Error(`request failed with status ${response.status}`);
110
+ error.code = `http-${response.status}`;
111
+ throw error;
112
+ }
113
+ const envelope = await response.json();
114
+ if (envelope.ok !== true) {
115
+ const error = new Error(envelope.message ?? envelope.code ?? "request failed");
116
+ error.code = envelope.code;
117
+ error.details = envelope.details ?? {};
118
+ throw error;
119
+ }
120
+ return envelope.result;
121
+ }
122
+
123
+ /**
124
+ * Translate a failure into something a person can act on.
125
+ * @param t - translator.
126
+ * @param error - the rejected call.
127
+ * @returns the message to show.
128
+ */
129
+ function describeFailure(t, error) {
130
+ switch (error?.code) {
131
+ case "sessions-running": {
132
+ const running = error.details?.active ?? [];
133
+ const names = running.map((item) => `${item.sessionId} (${item.kinds?.join(", ") ?? "busy"})`).join("\n");
134
+ return `${t("running.title")}\n${names}\n${t("running.hint")}`;
135
+ }
136
+ case "target-exists":
137
+ return t("dialog.targetExists");
138
+ case "target-same":
139
+ return t("dialog.sameLocation");
140
+ case "target-inside":
141
+ return t("dialog.insideSelf");
142
+ case "target-ancestor":
143
+ return t("dialog.ancestor");
144
+ case "target-missing":
145
+ return t("dialog.missingFolder");
146
+ case "workspace-unknown":
147
+ return t("dialog.unknownWorkspace");
148
+ case "name-invalid":
149
+ case "name-empty":
150
+ case "name-padded":
151
+ case "name-relative":
152
+ case "name-trailing":
153
+ return t("dialog.invalidName");
154
+ case "nothing-to-undo":
155
+ return t("undo.none");
156
+ default:
157
+ return `${t("toast.failed")} ${error?.message ?? ""}`.trim();
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Dialog styling. Every colour, radius and shadow is a harness design token, so
163
+ * the dialog follows the surrounding theme instead of imposing a palette; the
164
+ * only literals left are spacing and type sizes.
165
+ */
166
+ const CSS = `
167
+ .dshwm-backdrop{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.42));backdrop-filter:var(--dsw-mask-blur,blur(2px))}
168
+ .dshwm-panel{display:flex;flex-direction:column;gap:14px;width:min(760px,calc(100vw - 48px));max-height:calc(100vh - 96px);padding:20px 22px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-xl,14px);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-panel);font-family:var(--dsw-font-family);overflow:hidden}
169
+ .dshwm-title{margin:0;font-size:16px;font-weight:600;line-height:24px}
170
+ .dshwm-field{display:flex;flex-direction:column;gap:6px;min-width:0}
171
+ .dshwm-label{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
172
+ .dshwm-value{padding:7px 10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-module-platform);font-size:12.5px;word-break:break-all}
173
+ .dshwm-browser{display:flex;flex-direction:column;gap:8px;padding:10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-lg,10px)}
174
+ .dshwm-crumbs{display:flex;flex-wrap:wrap;align-items:center;gap:2px;min-height:22px;font-size:12px}
175
+ .dshwm-crumb{padding:2px 6px;border:0;border-radius:var(--dsw-radius-sm,6px);background:0 0;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:12px;cursor:pointer}
176
+ .dshwm-crumb:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
177
+ .dshwm-crumb[data-active=true]{color:var(--dsw-alias-label-primary);font-weight:600}
178
+ .dshwm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:6px;max-height:220px;overflow-y:auto;padding:2px}
179
+ .dshwm-entry{display:flex;align-items:center;gap:6px;padding:6px 8px;border:0;border-radius:var(--dsw-radius-sm,6px);background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;text-align:left;overflow:hidden;white-space:nowrap;cursor:pointer}
180
+ .dshwm-entry:hover{background:var(--dsw-alias-interactive-bg-hover)}
181
+ .dshwm-entry>span:last-child{overflow:hidden;text-overflow:ellipsis}
182
+ .dshwm-input{padding:7px 10px;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md,8px);background:var(--dsw-specific-input-major,#fff);color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px}
183
+ .dshwm-input:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
184
+ .dshwm-hint{margin:0;font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-tertiary)}
185
+ .dshwm-footer{display:flex;justify-content:flex-end;gap:8px;margin-top:2px}
186
+ .dshwm-button{padding:7px 14px;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md,8px);background:0 0;color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;cursor:pointer}
187
+ .dshwm-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
188
+ .dshwm-button:disabled{opacity:.45;cursor:default}
189
+ .dshwm-button:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
190
+ .dshwm-primary{border-color:transparent;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);font-weight:600}
191
+ .dshwm-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}
192
+ .dshwm-error{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary);white-space:pre-wrap}
193
+ `;
194
+
195
+ /** Identifier of the stylesheet, so a reload replaces it instead of stacking copies. */
196
+ const CSS_ID = "@reed-io/dsh-workspace-move/dialog.css";
197
+
198
+ /**
199
+ * Put the dialog stylesheet into the document exactly once.
200
+ * @returns nothing.
201
+ */
202
+ function installStyles() {
203
+ if (typeof document === "undefined") return;
204
+ if (document.querySelector(`style[data-plugin-css=${JSON.stringify(CSS_ID)}]`) !== null) return;
205
+ const tag = document.createElement("style");
206
+ tag.dataset.plugin = "@reed-io/dsh-workspace-move";
207
+ tag.dataset.pluginCss = CSS_ID;
208
+ tag.textContent = CSS;
209
+ document.head.appendChild(tag);
210
+ }
211
+
212
+ /**
213
+ * One row of the destination picker.
214
+ * @param props - entry data and the click handler.
215
+ * @returns the row.
216
+ */
217
+ function FolderRow({ entry, onOpen }) {
218
+ return h(
219
+ "button",
220
+ {
221
+ type: "button",
222
+ title: entry.path,
223
+ className: "dshwm-entry",
224
+ onClick: () => onOpen(entry.path)
225
+ },
226
+ h("span", { "aria-hidden": "true" }, entry.isSymbolicLink ? "🔗" : "📁"),
227
+ h("span", null, entry.name)
228
+ );
229
+ }
230
+
231
+ /**
232
+ * The destination dialog: browse to a parent folder, name the workspace folder,
233
+ * review what travels with it, then move.
234
+ * @param props - the request and how to finish.
235
+ * @returns the dialog.
236
+ */
237
+ function MoveDialog({ ctx, t, request, onClose, onDone }) {
238
+ const [listing, setListing] = useState(undefined);
239
+ const [target, setTarget] = useState("");
240
+ const [current, setCurrent] = useState("");
241
+ const [folderName, setFolderName] = useState(request.currentName ?? "");
242
+ const [plan, setPlan] = useState(undefined);
243
+ const [error, setError] = useState(undefined);
244
+ const [busy, setBusy] = useState(false);
245
+ const alive = useRef(true);
246
+
247
+ useEffect(() => {
248
+ alive.current = true;
249
+ return () => {
250
+ alive.current = false;
251
+ };
252
+ }, []);
253
+
254
+ const open = useCallback(
255
+ async (path) => {
256
+ setError(undefined);
257
+ try {
258
+ const next = await callHost("browse", { path });
259
+ if (!alive.current) return;
260
+ setListing(next);
261
+ setCurrent(next.path);
262
+ setTarget(next.path);
263
+ } catch (failure) {
264
+ if (alive.current) setError(describeFailure(t, failure));
265
+ }
266
+ },
267
+ [t]
268
+ );
269
+
270
+ useEffect(() => {
271
+ void open(request.startAt);
272
+ }, [open, request.startAt]);
273
+
274
+ useEffect(() => {
275
+ if (target === "") return;
276
+ let cancelled = false;
277
+ callHost("plan", { workspaceId: request.workspaceId, targetParent: target, folderName })
278
+ .then((next) => {
279
+ if (!cancelled) setPlan(next);
280
+ })
281
+ .catch((failure) => {
282
+ if (!cancelled) setPlan({ failure: describeFailure(t, failure), failureCode: failure.code });
283
+ });
284
+ return () => {
285
+ cancelled = true;
286
+ };
287
+ }, [target, folderName, request.workspaceId, t]);
288
+
289
+ const createFolder = useCallback(async () => {
290
+ const name = window.prompt(t("dialog.newFolderPrompt"), "");
291
+ if (name === null) return;
292
+ try {
293
+ const made = await callHost("createFolder", { parent: current, name });
294
+ await open(made.path);
295
+ } catch (failure) {
296
+ setError(describeFailure(t, failure));
297
+ }
298
+ }, [current, open, t]);
299
+
300
+ const confirm = useCallback(async () => {
301
+ setBusy(true);
302
+ setError(undefined);
303
+ try {
304
+ const result = await callHost("move", { workspaceId: request.workspaceId, targetParent: target, folderName });
305
+ onDone(result);
306
+ } catch (failure) {
307
+ setError(describeFailure(t, failure));
308
+ setBusy(false);
309
+ }
310
+ }, [folderName, onDone, request.workspaceId, t, target]);
311
+
312
+ const crumbs = listing?.crumbs ?? [];
313
+ const entries = listing?.entries ?? [];
314
+ const blocked = plan?.failure !== undefined;
315
+ return h(
316
+ "div",
317
+ {
318
+ className: "dshwm-backdrop",
319
+ role: "presentation",
320
+ onMouseDown: (event) => {
321
+ if (event.target === event.currentTarget && !busy) onClose();
322
+ }
323
+ },
324
+ h(
325
+ "div",
326
+ { role: "dialog", "aria-modal": "true", "aria-label": t("dialog.title"), className: "dshwm-panel" },
327
+ h("h2", { className: "dshwm-title" }, t("dialog.title")),
328
+ h(
329
+ "div",
330
+ { className: "dshwm-field" },
331
+ h("span", { className: "dshwm-label" }, t("dialog.source")),
332
+ h("div", { className: "dshwm-value" }, request.source)
333
+ ),
334
+ h(
335
+ "div",
336
+ { className: "dshwm-field" },
337
+ h("span", { className: "dshwm-label" }, t("dialog.target")),
338
+ h(
339
+ "div",
340
+ { className: "dshwm-browser" },
341
+ h(
342
+ "div",
343
+ { className: "dshwm-crumbs" },
344
+ crumbs.map((crumb, index) =>
345
+ h(
346
+ "button",
347
+ {
348
+ key: crumb.path,
349
+ type: "button",
350
+ className: "dshwm-crumb",
351
+ "data-active": index === crumbs.length - 1 ? "true" : "false",
352
+ onClick: () => void open(crumb.path)
353
+ },
354
+ crumb.name
355
+ )
356
+ )
357
+ ),
358
+ h(
359
+ "div",
360
+ { className: "dshwm-list" },
361
+ listing === undefined
362
+ ? h("span", { className: "dshwm-hint" }, t("dialog.loading"))
363
+ : entries.length === 0
364
+ ? h("span", { className: "dshwm-hint" }, t("dialog.emptyFolder"))
365
+ : entries.map((entry) => h(FolderRow, { key: entry.path, entry, onOpen: (path) => void open(path) }))
366
+ ),
367
+ h(
368
+ "div",
369
+ { className: "dshwm-footer" },
370
+ h("button", { type: "button", className: "dshwm-button", onClick: () => void createFolder() }, t("dialog.newFolder")),
371
+ h("button", { type: "button", className: "dshwm-button", onClick: () => void open(current) }, t("dialog.pickHere"))
372
+ )
373
+ )
374
+ ),
375
+ h(
376
+ "div",
377
+ { className: "dshwm-field" },
378
+ h("span", { className: "dshwm-label" }, t("dialog.folderName")),
379
+ h("input", {
380
+ className: "dshwm-input",
381
+ value: folderName,
382
+ spellCheck: false,
383
+ onChange: (event) => setFolderName(event.target.value)
384
+ }),
385
+ h("p", { className: "dshwm-hint" }, t("dialog.folderHint"))
386
+ ),
387
+ h(
388
+ "div",
389
+ { className: "dshwm-field" },
390
+ h("span", { className: "dshwm-label" }, t("dialog.destination")),
391
+ blocked
392
+ ? h("p", { className: "dshwm-hint" }, plan.failure)
393
+ : h("div", { className: "dshwm-value" }, plan?.destination ?? t("dialog.loading"))
394
+ ),
395
+ error === undefined ? null : h("p", { className: "dshwm-error" }, error),
396
+ h(
397
+ "div",
398
+ { className: "dshwm-footer" },
399
+ h("button", { type: "button", className: "dshwm-button", disabled: busy, onClick: () => onClose() }, t("dialog.cancel")),
400
+ h(
401
+ "button",
402
+ {
403
+ type: "button",
404
+ className: "dshwm-button dshwm-primary",
405
+ disabled: busy || blocked || target === "",
406
+ onClick: () => void confirm()
407
+ },
408
+ busy ? t("dialog.moving") : t("dialog.confirm")
409
+ )
410
+ )
411
+ )
412
+ );
413
+ }
414
+
415
+ /**
416
+ * Icons drawn here rather than borrowed, so the entries keep their own visual
417
+ * identity inside the Host menu.
418
+ * @param props - drawing size.
419
+ * @returns the "move" glyph: a folder with an arrow leaving it.
420
+ */
421
+ function MoveIcon({ size = 16 }) {
422
+ return h(
423
+ "svg",
424
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
425
+ h("path", {
426
+ d: "M2 4.25c0-.69.56-1.25 1.25-1.25h2.6c.35 0 .68.15.9.41l.62.74h4.38c.69 0 1.25.56 1.25 1.25v.6H2v-1.75Z",
427
+ fill: "currentColor",
428
+ opacity: 0.55
429
+ }),
430
+ h("rect", { x: 2, y: 5.25, width: 8.2, height: 6.5, rx: 1.25, fill: "currentColor", opacity: 0.9 }),
431
+ h("path", {
432
+ d: "M9.6 8.5h3.6m0 0-1.7-1.7m1.7 1.7-1.7 1.7",
433
+ stroke: "currentColor",
434
+ strokeWidth: 1.35,
435
+ strokeLinecap: "round",
436
+ strokeLinejoin: "round"
437
+ })
438
+ );
439
+ }
440
+
441
+ /**
442
+ * The "undo" glyph: a counter-clockwise arrow loop.
443
+ * @param props - drawing size.
444
+ * @returns the undo icon.
445
+ */
446
+ function UndoIcon({ size = 16 }) {
447
+ return h(
448
+ "svg",
449
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
450
+ h("path", {
451
+ d: "M4.2 6.1V3.3L1.7 5.8l2.5 2.5V6.1Z",
452
+ fill: "currentColor",
453
+ opacity: 0.85
454
+ }),
455
+ h("path", {
456
+ d: "M2.4 6.6a5.4 5.4 0 1 1 .9 5.3",
457
+ stroke: "currentColor",
458
+ strokeWidth: 1.4,
459
+ strokeLinecap: "round",
460
+ fill: "none"
461
+ })
462
+ );
463
+ }
464
+
465
+ /** Services the Client half needs before it can contribute menu entries. */
466
+ export const inject = ["slots", "locale", "contextMenus"];
467
+
468
+ /**
469
+ * Mount the Client half: the two workspace menu entries, the icons they draw,
470
+ * and the one dialog the move entry opens.
471
+ * @param ctx - Client context.
472
+ */
473
+ function apply(ctx) {
474
+ try {
475
+ mount(ctx);
476
+ } catch (error) {
477
+ // This half only contributes menu entries. If the shell is not shaped the way
478
+ // it expects, losing the entries is the correct outcome; failing here would
479
+ // abort the whole Web boot and take every other plugin down with it.
480
+ console.error("workspace-move: menu contributions unavailable", error);
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Register the entries, their icons and the dialog behind a fully usable shell.
486
+ * @param ctx - Client context.
487
+ */
488
+ function mount(ctx) {
489
+ installStyles();
490
+ const namespace = "workspaceMove";
491
+ ctx.effect(() => ctx.locale.register(namespace, translations), "workspace-move: dictionaries");
492
+ const t = ctx.locale.bind(namespace);
493
+
494
+ /** What the Host last reported about undo; refreshed at mount and after every move. */
495
+ let undo = { available: false };
496
+ const refreshUndo = () =>
497
+ callHost("undoInfo")
498
+ .then((info) => {
499
+ undo = info ?? { available: false };
500
+ return undo;
501
+ })
502
+ .catch(() => undo);
503
+
504
+ /**
505
+ * Open the destination dialog over the shell.
506
+ * @param request - the workspace being moved and where browsing starts.
507
+ * @returns nothing.
508
+ */
509
+ const openDialog = (request) => {
510
+ const slot = `workspace-move-dialog-${request.workspaceId}`;
511
+ let dispose;
512
+ const close = () => {
513
+ if (dispose === undefined) return;
514
+ dispose();
515
+ dispose = undefined;
516
+ };
517
+ dispose = ctx.slots.register(
518
+ { name: "shell.overlay", id: slot, order: 40, inject: () => ({}) },
519
+ () =>
520
+ h(MoveDialog, {
521
+ ctx,
522
+ t,
523
+ request,
524
+ onClose: close,
525
+ onDone: async (result) => {
526
+ close();
527
+ await refreshUndo();
528
+ ctx.contextMenus.notify(
529
+ `${t("toast.moved")} ${result.destination}`,
530
+ true,
531
+ result.undoable
532
+ ? {
533
+ label: t("menu.undo"),
534
+ run: async () => {
535
+ await callHost("undo");
536
+ undo = { available: false };
537
+ ctx.contextMenus.notify(t("toast.undone"), true);
538
+ },
539
+ failureLabel: t("toast.failed")
540
+ }
541
+ : undefined
542
+ );
543
+ }
544
+ })
545
+ );
546
+ };
547
+
548
+ ctx.effect(
549
+ () =>
550
+ ctx.contextMenus.register({
551
+ id: "workspace-move.move",
552
+ target: "workspace",
553
+ order: 200,
554
+ resolve: (value) => {
555
+ const workspaceId = typeof value?.workspaceId === "string" ? value.workspaceId : "";
556
+ const path = typeof value?.path === "string" ? value.path : "";
557
+ if (workspaceId === "" || path === "") return [];
558
+ const trimmed = path.replace(/[\\/]+$/u, "");
559
+ const name = trimmed.split(/[\\/]/u).pop() ?? "";
560
+ const cut = trimmed.length - name.length;
561
+ const startAt = cut > 1 ? trimmed.slice(0, cut - 1) : trimmed;
562
+ return [
563
+ {
564
+ id: "workspace-move.move",
565
+ label: t("menu.move"),
566
+ icon: { key: "workspaceMove.move" },
567
+ failureLabel: t("toast.failed"),
568
+ run: () => {
569
+ openDialog({ workspaceId, source: path, currentName: name, startAt });
570
+ }
571
+ }
572
+ ];
573
+ }
574
+ }),
575
+ "workspace-move: move entry"
576
+ );
577
+
578
+ ctx.effect(
579
+ () =>
580
+ ctx.contextMenus.register({
581
+ id: "workspace-move.undo",
582
+ target: "workspace",
583
+ order: 210,
584
+ resolve: () =>
585
+ undo.available !== true
586
+ ? []
587
+ : [
588
+ {
589
+ id: "workspace-move.undo",
590
+ label: t("menu.undo"),
591
+ icon: { key: "workspaceMove.undo" },
592
+ separatorBefore: true,
593
+ failureLabel: t("toast.failed"),
594
+ run: async () => {
595
+ try {
596
+ await callHost("undo");
597
+ undo = { available: false };
598
+ ctx.contextMenus.notify(t("toast.undone"), true);
599
+ } catch (failure) {
600
+ await refreshUndo();
601
+ ctx.contextMenus.notify(describeFailure(t, failure));
602
+ }
603
+ }
604
+ }
605
+ ]
606
+ }),
607
+ "workspace-move: undo entry"
608
+ );
609
+
610
+ ctx.slots.inject("contextMenu.icon", () => [
611
+ ctx.slots.register({ name: "contextMenu.icon", key: "workspaceMove.move" }, MoveIcon),
612
+ ctx.slots.register({ name: "contextMenu.icon", key: "workspaceMove.undo" }, UndoIcon)
613
+ ]);
614
+
615
+ void refreshUndo();
616
+ }
617
+
618
+ export { apply };