@reed-io/dsh-workspace-move 0.0.0-stage → 0.1.1
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 +22 -0
- package/LICENSE +21 -0
- package/README.md +107 -2
- package/README.zh-CN.md +84 -0
- package/cordis.patch.yml +3 -0
- package/icon/icon.svg +7 -0
- package/lib/artifact.js +236 -0
- package/lib/client.js +534 -0
- package/lib/directory-browse.js +98 -0
- package/lib/engine.js +263 -0
- package/lib/fsx.js +250 -0
- package/lib/host-api-client.js +41 -0
- package/lib/host-api.js +159 -0
- package/lib/index.js +7 -0
- package/lib/paths.js +99 -0
- package/lib/plan.js +156 -0
- package/lib/plugin.js +159 -0
- package/lib/registry-ops.js +177 -0
- package/lib/sessions.js +266 -0
- package/locale/en.json +42 -0
- package/locale/zh.json +42 -0
- package/package.json +91 -4
- package/src/client.js +618 -0
package/lib/engine.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executing a move: take the sessions down, move the folder once, put the
|
|
3
|
+
* sessions back where the new path says they live, then re-point every affected
|
|
4
|
+
* registration. Every step records how to reverse itself, and a failure walks
|
|
5
|
+
* that record backwards, so a move either lands completely or leaves nothing
|
|
6
|
+
* behind.
|
|
7
|
+
* @module @reed-io/dsh-workspace-move
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
import { exists, isInside, moveDirectory, removeIfEmpty, samePath } from "./fsx.js";
|
|
12
|
+
import { buildPlan, fail } from "./plan.js";
|
|
13
|
+
import { attachOrdered, retargetWorkspace, snapshotWorkspace } from "./registry-ops.js";
|
|
14
|
+
import { collectSessions, dropBackup, locateSession, persistenceInfo, restoreSession, takeDownSession, writeBackSession } from "./sessions.js";
|
|
15
|
+
|
|
16
|
+
/** Backup folder name — distinct from other plugins' folders beside it. */
|
|
17
|
+
const BACKUP_LEAF = "dshwm-move-backup";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The backup area used while a move is in flight, beside the persistence root so
|
|
21
|
+
* it always shares a volume with the artifacts it holds.
|
|
22
|
+
* @param ctx - Host context.
|
|
23
|
+
* @returns the absolute backup root.
|
|
24
|
+
*/
|
|
25
|
+
export function backupRootOf(ctx) {
|
|
26
|
+
const { root } = persistenceInfo(ctx.sessionPersistence);
|
|
27
|
+
return join(dirname(root), BACKUP_LEAF);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build a unique label for one move attempt.
|
|
32
|
+
* @returns a filesystem-safe tag.
|
|
33
|
+
*/
|
|
34
|
+
function newTag() {
|
|
35
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Move a workspace, its folder tree, nested registrations and sessions.
|
|
40
|
+
* @param ctx - Host context.
|
|
41
|
+
* @param request - workspace id, destination parent, optional folder name and rename flags.
|
|
42
|
+
* @returns the record kept for undo.
|
|
43
|
+
*/
|
|
44
|
+
export async function performMove(ctx, request) {
|
|
45
|
+
const built = await buildPlan(ctx, request);
|
|
46
|
+
if (built.failure !== undefined) throw toError(built.failure);
|
|
47
|
+
const plan = built.plan;
|
|
48
|
+
if (plan.active.length > 0) {
|
|
49
|
+
const failure = fail("sessions-running", "Stop the running sessions before moving this workspace.", { active: plan.active });
|
|
50
|
+
throw toError(failure);
|
|
51
|
+
}
|
|
52
|
+
const registry = ctx.workspaceRegistry;
|
|
53
|
+
const workspace = registry.list().find((entity) => entity.id === plan.workspaceId);
|
|
54
|
+
if (workspace === undefined) throw toError(fail("workspace-unknown", "That workspace is no longer registered."));
|
|
55
|
+
const record = snapshotWorkspace(registry, plan.workspaceId);
|
|
56
|
+
const listedOrder = [...record.sessionIds];
|
|
57
|
+
const sessions = await collectSessions(ctx, { ...record, path: plan.sourceCanonical });
|
|
58
|
+
const backupRoot = backupRootOf(ctx);
|
|
59
|
+
const tag = newTag();
|
|
60
|
+
const undo = {
|
|
61
|
+
tag,
|
|
62
|
+
allowed: request?.allowUndo !== false,
|
|
63
|
+
workspaceId: plan.workspaceId,
|
|
64
|
+
title: record.title,
|
|
65
|
+
source: record.path,
|
|
66
|
+
sourceCanonical: plan.sourceCanonical,
|
|
67
|
+
destination: plan.destination,
|
|
68
|
+
folderName: plan.folderName,
|
|
69
|
+
titleChanged: false,
|
|
70
|
+
nested: plan.nested.map((entry) => ({ ...entry })),
|
|
71
|
+
sessions: sessions.map((session) => ({ id: session.id, dir: session.dir, path: session.path, cwd: session.header.cwd, listed: session.listed }))
|
|
72
|
+
};
|
|
73
|
+
const takenDown = [];
|
|
74
|
+
let moved = undefined;
|
|
75
|
+
try {
|
|
76
|
+
for (const session of sessions) {
|
|
77
|
+
const archived = await takeDownSession(backupRoot, tag, session).catch(async (error) => {
|
|
78
|
+
if (error?.code !== "ENOENT") throw error;
|
|
79
|
+
return undefined;
|
|
80
|
+
});
|
|
81
|
+
if (archived !== undefined) takenDown.push({ session, archived });
|
|
82
|
+
}
|
|
83
|
+
const how = await moveDirectory(plan.sourceCanonical, plan.destination);
|
|
84
|
+
moved = { from: plan.sourceCanonical, to: plan.destination, how };
|
|
85
|
+
const canonicalDestination = await canonicalOf(plan.destination);
|
|
86
|
+
for (const { session, archived } of takenDown) {
|
|
87
|
+
const relative = session.header.cwd.slice(plan.sourceCanonical.length).replace(/^[\\/]+/u, "");
|
|
88
|
+
const cwd = relative === "" ? canonicalDestination : join(canonicalDestination, relative);
|
|
89
|
+
await writeBackSession(session, archived, cwd);
|
|
90
|
+
}
|
|
91
|
+
await applyRegistry(registry, plan, canonicalDestination, workspace, listedOrder, undo);
|
|
92
|
+
await dropBackup(backupRoot, tag);
|
|
93
|
+
await removeIfEmpty(plan.sourceCanonical);
|
|
94
|
+
return undo;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
await rollback(ctx, undo, takenDown, moved);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Repoint several registrations in one durable step, then rebuild the entity
|
|
103
|
+
* cache once so every lookup sees the new paths.
|
|
104
|
+
* @param registry - workspace registry service.
|
|
105
|
+
* @param moves - `{ id, path }` pairs.
|
|
106
|
+
* @returns nothing.
|
|
107
|
+
*/
|
|
108
|
+
async function applyRetargets(registry, moves) {
|
|
109
|
+
for (const move of moves) await retargetWorkspace(registry, move.id, move.path, { rebuild: false });
|
|
110
|
+
registry.rebuildEntities?.();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Canonicalize a directory that exists.
|
|
115
|
+
* @param path - directory path.
|
|
116
|
+
* @returns its real path.
|
|
117
|
+
*/
|
|
118
|
+
async function canonicalOf(path) {
|
|
119
|
+
const { realpath } = await import("node:fs/promises");
|
|
120
|
+
return realpath(path);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Re-point the moved registration, the nested ones, and the session account.
|
|
125
|
+
* @param registry - workspace registry service.
|
|
126
|
+
* @param plan - the executed plan.
|
|
127
|
+
* @param destination - canonical destination folder.
|
|
128
|
+
* @param workspace - the moved entity.
|
|
129
|
+
* @param listedOrder - the workspace's session order before the move.
|
|
130
|
+
* @param undo - the undo record being filled in.
|
|
131
|
+
* @returns nothing.
|
|
132
|
+
*/
|
|
133
|
+
async function applyRegistry(registry, plan, destination, workspace, listedOrder, undo) {
|
|
134
|
+
const moves = [
|
|
135
|
+
{ id: plan.workspaceId, path: destination },
|
|
136
|
+
...plan.nested.map((nested) => ({ id: nested.id, path: join(destination, nested.from.slice(plan.sourceCanonical.length).replace(/^[\\/]+/u, "")) }))
|
|
137
|
+
];
|
|
138
|
+
await applyRetargets(registry, moves);
|
|
139
|
+
const entity = registry.list().find((candidate) => candidate.id === plan.workspaceId) ?? workspace;
|
|
140
|
+
await attachOrdered(registry, entity, listedOrder);
|
|
141
|
+
const members = new Set(listedOrder);
|
|
142
|
+
for (const session of undo.sessions) {
|
|
143
|
+
if (!session.listed && !members.has(session.id)) await entity.attachSession(session.id).catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
const wantTitle = plan.renamed;
|
|
146
|
+
if (wantTitle) {
|
|
147
|
+
const currentTitle = registry.list().find((candidate) => candidate.id === plan.workspaceId)?.title;
|
|
148
|
+
const previousName = basename(plan.source);
|
|
149
|
+
if (currentTitle === undefined || currentTitle === previousName) {
|
|
150
|
+
await entity.setTitle(plan.folderName);
|
|
151
|
+
undo.titleChanged = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reverse a partially applied move.
|
|
158
|
+
* @param ctx - Host context.
|
|
159
|
+
* @param undo - the record describing the move.
|
|
160
|
+
* @param takenDown - sessions already archived.
|
|
161
|
+
* @param moved - folder move description, when it happened.
|
|
162
|
+
* @returns nothing; rollback problems are logged, not thrown.
|
|
163
|
+
*/
|
|
164
|
+
async function rollback(ctx, undo, takenDown, moved) {
|
|
165
|
+
const registry = ctx.workspaceRegistry;
|
|
166
|
+
const problems = [];
|
|
167
|
+
try {
|
|
168
|
+
if (moved !== undefined) {
|
|
169
|
+
if (await exists(moved.to)) {
|
|
170
|
+
if (await exists(moved.from)) await rm(moved.to, { recursive: true, force: true }).catch((error) => problems.push(String(error)));
|
|
171
|
+
else await moveDirectory(moved.to, moved.from).catch((error) => problems.push(String(error)));
|
|
172
|
+
}
|
|
173
|
+
await mkdir(moved.from, { recursive: true }).catch(() => {});
|
|
174
|
+
await applyRetargets(registry, [{ id: undo.workspaceId, path: undo.sourceCanonical }, ...undo.nested.map((nested) => ({ id: nested.id, path: nested.from }))]).catch((error) => problems.push(String(error)));
|
|
175
|
+
if (undo.titleChanged) {
|
|
176
|
+
const entity = registry.list().find((candidate) => candidate.id === undo.workspaceId);
|
|
177
|
+
if (entity !== undefined) await entity.setTitle(undo.title).catch((error) => problems.push(String(error)));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const { session, archived } of [...takenDown].reverse()) {
|
|
181
|
+
await restoreSession(session, session.header.cwd, archived).catch((error) => problems.push(String(error)));
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
if (problems.length > 0) ctx.logger?.warn?.(`workspace-move: rollback left problems: ${problems.join("; ")}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Undo one recorded move: everything returns to where it was.
|
|
190
|
+
* @param ctx - Host context.
|
|
191
|
+
* @param undo - the record kept by {@link performMove}.
|
|
192
|
+
* @returns nothing.
|
|
193
|
+
*/
|
|
194
|
+
export async function undoMove(ctx, undo) {
|
|
195
|
+
const registry = ctx.workspaceRegistry;
|
|
196
|
+
if (undo === undefined) throw toError(fail("nothing-to-undo", "There is no move to undo."));
|
|
197
|
+
const current = registry.list().find((entity) => entity.id === undo.workspaceId);
|
|
198
|
+
if (current === undefined) throw toError(fail("workspace-unknown", "The workspace is no longer registered, so this move cannot be undone."));
|
|
199
|
+
if (!samePath(current.path, undo.destination)) throw toError(fail("undo-stale", "The workspace has moved again since; this move can no longer be undone."));
|
|
200
|
+
if (await exists(undo.source)) throw toError(fail("undo-blocked", `'${undo.source}' is occupied again.`, { path: undo.source }));
|
|
201
|
+
const sessions = [];
|
|
202
|
+
for (const session of undo.sessions) {
|
|
203
|
+
const fallback = { id: session.id, dir: session.dir, path: session.path, header: { id: session.id, cwd: session.cwd, version: 4 }, ...persistenceInfo(ctx.sessionPersistence) };
|
|
204
|
+
const found = await locateSession(ctx, session.id).catch(() => undefined);
|
|
205
|
+
sessions.push({ entry: found ?? fallback, original: session });
|
|
206
|
+
}
|
|
207
|
+
const takenDown = [];
|
|
208
|
+
const backupRoot = backupRootOf(ctx);
|
|
209
|
+
const tag = `${undo.tag}-undo`;
|
|
210
|
+
let moved;
|
|
211
|
+
try {
|
|
212
|
+
for (const { entry } of sessions) {
|
|
213
|
+
const archived = await takeDownSession(backupRoot, tag, entry).catch((error) => {
|
|
214
|
+
if (error?.code === "ENOENT") return undefined;
|
|
215
|
+
throw error;
|
|
216
|
+
});
|
|
217
|
+
takenDown.push({ entry, archived });
|
|
218
|
+
}
|
|
219
|
+
await moveDirectory(undo.destination, undo.source);
|
|
220
|
+
moved = { from: undo.destination, to: undo.source };
|
|
221
|
+
for (const { entry, original } of sessions) {
|
|
222
|
+
const source = takenDown.find((item) => item.entry.id === entry.id)?.archived ?? original.dir;
|
|
223
|
+
await restoreSession({ ...entry, dir: original.dir, path: original.path }, original.cwd, source);
|
|
224
|
+
}
|
|
225
|
+
await applyRetargets(registry, [{ id: undo.workspaceId, path: undo.sourceCanonical }, ...undo.nested.map((nested) => ({ id: nested.id, path: nested.from }))]);
|
|
226
|
+
if (undo.titleChanged) {
|
|
227
|
+
const entity = registry.list().find((candidate) => candidate.id === undo.workspaceId);
|
|
228
|
+
if (entity !== undefined) await entity.setTitle(undo.title).catch(() => {});
|
|
229
|
+
}
|
|
230
|
+
await dropBackup(backupRoot, tag);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (moved !== undefined && !(await exists(undo.destination)) && (await exists(undo.source))) {
|
|
233
|
+
await moveDirectory(undo.source, undo.destination).catch(() => {});
|
|
234
|
+
}
|
|
235
|
+
for (const { entry, archived } of [...takenDown].reverse()) {
|
|
236
|
+
const cwd = entry.header?.cwd ?? entry.cwd;
|
|
237
|
+
if (cwd !== undefined) await restoreSession(entry, cwd, archived).catch(() => {});
|
|
238
|
+
}
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Turn a structured failure into a thrown error carrying its code.
|
|
245
|
+
* @param failure - the structured failure.
|
|
246
|
+
* @returns an Error with `code` and `details`.
|
|
247
|
+
*/
|
|
248
|
+
export function toError(failure) {
|
|
249
|
+
const error = new Error(failure.message);
|
|
250
|
+
error.code = failure.code;
|
|
251
|
+
error.details = failure.details ?? {};
|
|
252
|
+
return error;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Guard: a path that is the workspace itself or an ancestor of it.
|
|
257
|
+
* @param path - candidate path.
|
|
258
|
+
* @param workspacePath - workspace directory.
|
|
259
|
+
* @returns whether path is unsafe as a destination parent.
|
|
260
|
+
*/
|
|
261
|
+
export function isUnsafeParent(path, workspacePath) {
|
|
262
|
+
return samePath(path, workspacePath) || isInside(path, workspacePath);
|
|
263
|
+
}
|
package/lib/fsx.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem primitives shared by the move engine: JSON-friendly errors for the
|
|
3
|
+
* HTTP boundary, existence checks, canonical-path normalization, and same-filesystem
|
|
4
|
+
* rename with a verified copy fallback (the `mv` semantics the feature promises).
|
|
5
|
+
* @module @reed-io/dsh-workspace-move
|
|
6
|
+
*/
|
|
7
|
+
import { constants } from "node:fs";
|
|
8
|
+
import { copyFile, lstat, mkdir, readdir, realpath, rename, rm, stat } from "node:fs/promises";
|
|
9
|
+
import { dirname, isAbsolute, join, parse, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** Failure carrying a stable machine code the Client maps onto localized text. */
|
|
12
|
+
export class MoveError extends Error {
|
|
13
|
+
/**
|
|
14
|
+
* @param code - stable failure code.
|
|
15
|
+
* @param message - operator-facing description.
|
|
16
|
+
* @param details - optional structured context for the Client.
|
|
17
|
+
*/
|
|
18
|
+
constructor(code, message, details = {}) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "MoveError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.details = details;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Is the failure a missing-path rejection?
|
|
28
|
+
* @param error - any rejection value.
|
|
29
|
+
* @returns whether the error code is ENOENT.
|
|
30
|
+
*/
|
|
31
|
+
export function isMissing(error) {
|
|
32
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve whether a path exists, without following a dangling link into an error.
|
|
37
|
+
* @param path - absolute path.
|
|
38
|
+
* @returns whether anything (file, directory, or link) occupies the path.
|
|
39
|
+
*/
|
|
40
|
+
export async function exists(path) {
|
|
41
|
+
try {
|
|
42
|
+
await lstat(path);
|
|
43
|
+
return true;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (isMissing(error)) return false;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve one path to its canonical form: the real path when it exists, and the
|
|
52
|
+
* canonical form of the deepest existing ancestor joined with the remainder when
|
|
53
|
+
* it does not (a target that is about to be created).
|
|
54
|
+
* @param path - absolute path to canonicalize.
|
|
55
|
+
* @returns canonical absolute path.
|
|
56
|
+
*/
|
|
57
|
+
export async function canonicalPath(path) {
|
|
58
|
+
const absolute = resolve(path);
|
|
59
|
+
try {
|
|
60
|
+
return await realpath(absolute);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!isMissing(error)) throw error;
|
|
63
|
+
}
|
|
64
|
+
const missing = [];
|
|
65
|
+
let cursor = absolute;
|
|
66
|
+
for (;;) {
|
|
67
|
+
const parent = dirname(cursor);
|
|
68
|
+
if (parent === cursor) return absolute;
|
|
69
|
+
missing.unshift(cursor.slice(parent.length).replace(/^[\\/]+/, ""));
|
|
70
|
+
cursor = parent;
|
|
71
|
+
try {
|
|
72
|
+
return join(await realpath(cursor), ...missing);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (!isMissing(error)) throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compare two paths after canonicalization, case-insensitively on Windows.
|
|
81
|
+
* @param left - first path.
|
|
82
|
+
* @param right - second path.
|
|
83
|
+
* @returns whether they name the same location.
|
|
84
|
+
*/
|
|
85
|
+
export function samePath(left, right) {
|
|
86
|
+
const normalize = (value) => {
|
|
87
|
+
const absolute = resolve(value);
|
|
88
|
+
return process.platform === "win32" ? absolute.toLowerCase() : absolute;
|
|
89
|
+
};
|
|
90
|
+
return normalize(left) === normalize(right);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Is `child` strictly inside `parent` (case-insensitive on Windows)? */
|
|
94
|
+
export function isInside(child, parent) {
|
|
95
|
+
const norm = (value) => {
|
|
96
|
+
const absolute = resolve(value);
|
|
97
|
+
return (process.platform === "win32" ? absolute.toLowerCase() : absolute).replace(/[\\/]+$/, "");
|
|
98
|
+
};
|
|
99
|
+
const inner = norm(child);
|
|
100
|
+
const outer = norm(parent).replace(/^[\/]+/u, "");
|
|
101
|
+
if (inner === outer) return false;
|
|
102
|
+
if (outer === "") return true;
|
|
103
|
+
return inner.length > outer.length && inner.startsWith(outer) && /[\\/]/.test(inner.charAt(outer.length));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether two paths live on the same filesystem volume, where a rename is atomic.
|
|
108
|
+
* Falls back to "same volume" on platforms without a dev field.
|
|
109
|
+
* @param from - source path.
|
|
110
|
+
* @param to - destination path.
|
|
111
|
+
* @returns whether the paths share a device.
|
|
112
|
+
*/
|
|
113
|
+
export async function sameDevice(from, to) {
|
|
114
|
+
const nearest = async (path) => {
|
|
115
|
+
let cursor = path;
|
|
116
|
+
for (;;) {
|
|
117
|
+
try {
|
|
118
|
+
const info = await stat(cursor);
|
|
119
|
+
return info;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (!isMissing(error)) throw error;
|
|
122
|
+
}
|
|
123
|
+
const parent = dirname(cursor);
|
|
124
|
+
if (parent === cursor) return undefined;
|
|
125
|
+
cursor = parent;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const [left, right] = await Promise.all([nearest(from), nearest(dirname(to))]);
|
|
129
|
+
if (left === undefined || right === undefined) return true;
|
|
130
|
+
if (typeof left.dev === "number" && typeof right.dev === "number") return left.dev === right.dev;
|
|
131
|
+
return parse(resolve(from)).root.toLowerCase() === parse(resolve(to)).root.toLowerCase();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Recursively copy a tree, preserving symlinks as links instead of following them.
|
|
136
|
+
* @param from - source root.
|
|
137
|
+
* @param to - destination root (must not exist).
|
|
138
|
+
* @param progress - optional callback receiving the number of entries copied.
|
|
139
|
+
*/
|
|
140
|
+
export async function copyTree(from, to, progress) {
|
|
141
|
+
let copied = 0;
|
|
142
|
+
const walk = async (source, target) => {
|
|
143
|
+
const info = await lstat(source);
|
|
144
|
+
if (info.isSymbolicLink()) return; // links are reported by the caller, never followed
|
|
145
|
+
if (info.isDirectory()) {
|
|
146
|
+
await mkdir(target, { recursive: false });
|
|
147
|
+
const entries = await readdir(source, { withFileTypes: true });
|
|
148
|
+
for (const entry of entries) await walk(join(source, entry.name), join(target, entry.name));
|
|
149
|
+
copied += 1;
|
|
150
|
+
progress?.(copied);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
await copyFile(source, target, constants.COPYFILE_EXCL);
|
|
154
|
+
copied += 1;
|
|
155
|
+
if (copied % 64 === 0) progress?.(copied);
|
|
156
|
+
};
|
|
157
|
+
await walk(from, to);
|
|
158
|
+
return copied;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Compare two trees entry by entry (names, kinds, sizes, link targets) without
|
|
163
|
+
* reading file contents — the verification a cross-volume `mv` performs before
|
|
164
|
+
* the source is removed.
|
|
165
|
+
* @param left - original root.
|
|
166
|
+
* @param right - copied root.
|
|
167
|
+
* @returns the first mismatch description, or undefined when the trees agree.
|
|
168
|
+
*/
|
|
169
|
+
export async function compareTree(left, right) {
|
|
170
|
+
const walk = async (source, target) => {
|
|
171
|
+
const info = await lstat(source);
|
|
172
|
+
const mirror = await lstat(target).catch(() => undefined);
|
|
173
|
+
if (mirror === undefined) return `missing at destination: ${target}`;
|
|
174
|
+
if (info.isSymbolicLink() || mirror.isSymbolicLink()) {
|
|
175
|
+
if (!info.isSymbolicLink() || !mirror.isSymbolicLink()) return `entry type differs: ${source}`;
|
|
176
|
+
const [leftTarget, rightTarget] = await Promise.all([readlinkOf(source), readlinkOf(target)]);
|
|
177
|
+
return leftTarget === rightTarget ? undefined : `link target differs: ${source}`;
|
|
178
|
+
}
|
|
179
|
+
if (info.isDirectory() !== mirror.isDirectory()) return `entry type differs: ${source}`;
|
|
180
|
+
if (!info.isDirectory()) return info.size === mirror.size ? undefined : `size differs: ${source}`;
|
|
181
|
+
const entries = await readdir(source, { withFileTypes: true });
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
const mismatch = await walk(join(source, entry.name), join(target, entry.name));
|
|
184
|
+
if (mismatch !== undefined) return mismatch;
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
};
|
|
188
|
+
return walk(left, right);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function readlinkOf(path) {
|
|
192
|
+
const { readlink } = await import("node:fs/promises");
|
|
193
|
+
return readlink(path);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Move a directory: a same-volume rename when possible, otherwise copy, verify,
|
|
198
|
+
* then remove the source. The destination must not exist.
|
|
199
|
+
* @param from - source directory.
|
|
200
|
+
* @param to - destination directory.
|
|
201
|
+
* @param progress - optional progress callback.
|
|
202
|
+
* @returns how the directory travelled.
|
|
203
|
+
*/
|
|
204
|
+
export async function moveDirectory(from, to, progress) {
|
|
205
|
+
await mkdir(dirname(to), { recursive: true });
|
|
206
|
+
if (await sameDevice(from, to)) {
|
|
207
|
+
await rename(from, to);
|
|
208
|
+
return "rename";
|
|
209
|
+
}
|
|
210
|
+
await copyTree(from, to, progress);
|
|
211
|
+
const mismatch = await compareTree(from, to);
|
|
212
|
+
if (mismatch !== undefined) {
|
|
213
|
+
await rm(to, { recursive: true, force: true }).catch(() => {});
|
|
214
|
+
throw new MoveError("copy-mismatch", `copied tree did not match the source (${mismatch})`);
|
|
215
|
+
}
|
|
216
|
+
await rm(from, { recursive: true, force: true });
|
|
217
|
+
return "copy";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Remove a directory only when it is empty; a parent or a non-empty folder is
|
|
222
|
+
* never touched, so whatever else lives there survives.
|
|
223
|
+
* @param path - directory to remove if empty.
|
|
224
|
+
* @returns whether it was removed.
|
|
225
|
+
*/
|
|
226
|
+
export async function removeIfEmpty(path) {
|
|
227
|
+
try {
|
|
228
|
+
const entries = await readdir(path);
|
|
229
|
+
if (entries.length > 0) return false;
|
|
230
|
+
await rm(path, { recursive: false });
|
|
231
|
+
return true;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (isMissing(error)) return true;
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Validate a single folder-name segment the way the OS would refuse it.
|
|
240
|
+
* @param name - candidate folder name.
|
|
241
|
+
* @returns undefined when valid, otherwise a stable reason code.
|
|
242
|
+
*/
|
|
243
|
+
export function folderNameProblem(name) {
|
|
244
|
+
const trimmed = name.trim();
|
|
245
|
+
if (trimmed === "" || trimmed === "." || trimmed === "..") return "name-invalid";
|
|
246
|
+
if (/[\\/:*?"<>|\u0000-\u001f]/u.test(trimmed)) return "name-invalid";
|
|
247
|
+
if (trimmed.endsWith(".") || trimmed.endsWith(" ")) return "name-invalid";
|
|
248
|
+
if (isAbsolute(trimmed)) return "name-invalid";
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Host API transport contract: the route prefix, the header every call
|
|
3
|
+
* carries, and the one call helper. Host code imports the constants only; the
|
|
4
|
+
* Client half carries its own copy of both the constants and the helper, because
|
|
5
|
+
* it is bundled for the browser and cannot import this module.
|
|
6
|
+
* @module @reed-io/dsh-workspace-move
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Stable route prefix of the Host API. */
|
|
10
|
+
export const API_PREFIX = "/dsh-workspace-move/api";
|
|
11
|
+
|
|
12
|
+
/** Header every Client call carries. */
|
|
13
|
+
export const API_HEADER = "x-dsh-workspace-move";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* POST one action to the Host and unwrap its envelope.
|
|
17
|
+
* @param action - action name.
|
|
18
|
+
* @param body - JSON body.
|
|
19
|
+
* @returns the action result.
|
|
20
|
+
*/
|
|
21
|
+
export async function callHost(action, body = {}) {
|
|
22
|
+
const response = await fetch(`${API_PREFIX}/${action}`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": "application/json", [API_HEADER]: "1" },
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
credentials: "same-origin"
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const error = new Error(`request failed with status ${response.status}`);
|
|
30
|
+
error.code = `http-${response.status}`;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
const envelope = await response.json();
|
|
34
|
+
if (envelope.ok !== true) {
|
|
35
|
+
const error = new Error(envelope.message ?? envelope.code ?? "request failed");
|
|
36
|
+
error.code = envelope.code;
|
|
37
|
+
error.details = envelope.details ?? {};
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
return envelope.result;
|
|
41
|
+
}
|