Skip to content

Commit 1168bb7

Browse files
fix(studio-server): a rename rewrites only references that name the renamed path (#4915)
* fix(studio-server): a rename rewrites only references that name the renamed path * fix(studio-server): rename keeps root leads and reads escaped separators * fix(studio-server): rename matches whole references only and cannot backtrack * fix(studio-server): rename leaves paths that exist alone, whatever their names hold * fix(studio-server): rename checks every existing path once and reads escaped spellings * fix(studio-server): rename finds existing longer paths by lookup, on normalised text * fix(studio-server): rename inventories every path and indexes lengths per suffix * fix(studio-server): rename inventory follows linked folders * fix(studio-server): rename classifies its source before moving it
1 parent 5c52f72 commit 1168bb7

2 files changed

Lines changed: 340 additions & 8 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { Hono } from "hono";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import { referenceRewriter, registerFileRoutes } from "./files";
7+
import type { StudioApiAdapter } from "../types";
8+
9+
const rewrite = (text: string, from: string, to: string, folder: boolean) =>
10+
referenceRewriter(from, to, folder)(text);
11+
12+
describe("rename references", () => {
13+
it("rewrites a folder where files under it are named, in every form a project writes them", () => {
14+
const text = [
15+
'<img src="assets/a.png">',
16+
"url(./assets/a.png)",
17+
'"../assets/a.png"',
18+
'{"poster":"assets/b/c.png"}',
19+
].join("\n");
20+
expect(rewrite(text, "assets", "brand", true)).toBe(
21+
[
22+
'<img src="brand/a.png">',
23+
"url(./brand/a.png)",
24+
'"../brand/a.png"',
25+
'{"poster":"brand/b/c.png"}',
26+
].join("\n"),
27+
);
28+
});
29+
30+
it("leaves a sibling folder with the same beginning, another folder's path and prose alone", () => {
31+
const text = 'assets-backup/b.png my-assets/x.png other/assets/y.png "The assets folder"';
32+
expect(rewrite(text, "assets", "brand", true)).toBe(text);
33+
});
34+
35+
it("rewrites a file's path whole, not a longer name that starts with it", () => {
36+
const text = '"assets/a.png" "assets/a.png2" "assets/a.png-old"';
37+
expect(rewrite(text, "assets/a.png", "assets/b.png", false)).toBe(
38+
'"assets/b.png" "assets/a.png2" "assets/a.png-old"',
39+
);
40+
expect(
41+
rewrite('url(assets/a.png) srcset="assets/a.png 2x"', "assets/a.png", "x/b.png", false),
42+
).toBe('url(x/b.png) srcset="x/b.png 2x"');
43+
});
44+
45+
it("keeps a root-relative lead, and reads JSON-escaped and Windows separators", () => {
46+
const text = [
47+
'<img src="/assets/a.png">',
48+
"url(/assets/a.png)",
49+
'{"src":"assets\\/a.png"}',
50+
'{"path":"assets\\\\a.png"}',
51+
].join("\n");
52+
expect(rewrite(text, "assets", "brand", true)).toBe(
53+
[
54+
'<img src="/brand/a.png">',
55+
"url(/brand/a.png)",
56+
'{"src":"brand\\/a.png"}',
57+
'{"path":"brand\\\\a.png"}',
58+
].join("\n"),
59+
);
60+
});
61+
62+
it("leaves a longer path that exists alone, whatever characters its name holds", () => {
63+
const rewriteWith = (
64+
text: string,
65+
from: string,
66+
to: string,
67+
folder: boolean,
68+
existing: string[],
69+
) => referenceRewriter(from, to, folder, existing)(text);
70+
const folders = '<img src="other assets/a.png"> <img src="other,assets/a.png">';
71+
expect(
72+
rewriteWith(folders, "assets", "brand", true, ["other assets", "other,assets", "assets"]),
73+
).toBe(folders);
74+
const files =
75+
'<img src="assets/a.png&backup.png"><img src="assets/a.png 2x.png"><img src="assets/a.png).png">';
76+
expect(
77+
rewriteWith(files, "assets/a.png", "assets/b.png", false, [
78+
"assets/a.png&backup.png",
79+
"assets/a.png 2x.png",
80+
"assets/a.png).png",
81+
]),
82+
).toBe(files);
83+
});
84+
85+
it("sees an existing path the match sits in the middle of, and in escaped spellings", () => {
86+
const middle = '<img src="other a.png&backup.png">';
87+
expect(referenceRewriter("a.png", "b.png", false, ["other a.png&backup.png"])(middle)).toBe(
88+
middle,
89+
);
90+
const escaped = String.raw`{"path":"assets\\a.png&backup.png"} {"path":"assets\/a.png&backup.png"}`;
91+
expect(
92+
referenceRewriter("assets/a.png", "assets/b.png", false, ["assets/a.png&backup.png"])(
93+
escaped,
94+
),
95+
).toBe(escaped);
96+
});
97+
98+
it("compares escaped spellings by their text, not by offsets", () => {
99+
const text = String.raw`"dir\/other a.png&backup.png" "dir\\other a.png&backup.png"`;
100+
expect(referenceRewriter("a.png", "b.png", false, ["dir/other a.png&backup.png"])(text)).toBe(
101+
text,
102+
);
103+
});
104+
105+
it("stays fast with thousands of existing paths and references", () => {
106+
const existing = Array.from({ length: 5000 }, (_, i) => `archive/assets/image-${i}.png`);
107+
const text = Array.from(
108+
{ length: 5000 },
109+
(_, i) => `"assets/a${i}.png" "archive/assets/image-${i}.png"`,
110+
).join("\n");
111+
const started = Date.now();
112+
const out = referenceRewriter("assets", "brand", true, existing)(text);
113+
expect(Date.now() - started).toBeLessThan(2000);
114+
expect(out).toContain('"brand/a0.png" "archive/assets/image-0.png"');
115+
});
116+
117+
it("does not multiply suffix and prefix lengths", () => {
118+
const existing = Array.from(
119+
{ length: 200 },
120+
(_, i) => `${"z".repeat(i + 1)} a/${"x".repeat(i + 1)}`,
121+
);
122+
const text = Array.from({ length: 3000 }, () => `"a/${"x".repeat(210)}.png"`).join("\n");
123+
const started = Date.now();
124+
referenceRewriter("a", "brand", true, existing)(text);
125+
expect(Date.now() - started).toBeLessThan(3000);
126+
});
127+
128+
it("rewrites references a bare scan could mistake: unquoted attributes, a space before a paren, srcset", () => {
129+
const text =
130+
'<img src=assets/a.png alt="x"> url(assets/a.png ) srcset="assets/a.png 1x, assets/a.png 2x"';
131+
expect(rewrite(text, "assets/a.png", "x/b.png", false)).toBe(
132+
'<img src=x/b.png alt="x"> url(x/b.png ) srcset="x/b.png 1x, x/b.png 2x"',
133+
);
134+
});
135+
136+
it("takes no path that is part of a longer one by its start: another root, a plus, a backslash", () => {
137+
const text = String.raw`other\assets\a.png other\/assets\/a.png my+assets/a.png`;
138+
expect(rewrite(text, "assets", "brand", true)).toBe(text);
139+
});
140+
141+
it("does not stall on a long run of backslashes", () => {
142+
const text = `${"\\".repeat(200)}unrelated`;
143+
const started = Date.now();
144+
expect(rewrite(text, "assets", "brand", true)).toBe(text);
145+
expect(Date.now() - started).toBeLessThan(500);
146+
});
147+
148+
it("does not take another folder's path whose middle matches", () => {
149+
const text = "other/./assets/a.png other/../assets/a.png";
150+
expect(rewrite(text, "assets", "brand", true)).toBe(text);
151+
});
152+
});
153+
154+
describe("renaming a folder over the route", () => {
155+
const dirs: string[] = [];
156+
afterEach(() => dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })));
157+
158+
it("rewrites references under it and leaves a sibling folder's, and prose, alone", async () => {
159+
const project = mkdtempSync(join(tmpdir(), "hf-rename-refs-"));
160+
dirs.push(project);
161+
mkdirSync(join(project, "assets"));
162+
mkdirSync(join(project, "assets-backup"));
163+
mkdirSync(join(project, "other assets"));
164+
mkdirSync(join(project, "empty assets"));
165+
writeFileSync(join(project, "other assets", "a.png"), "x");
166+
writeFileSync(join(project, "assets", "a.png"), "x");
167+
const html =
168+
'<img src="assets/a.png"><img src="assets-backup/a.png"><img src="other assets/a.png"><a href="empty assets/">x</a><p>The assets folder</p>';
169+
writeFileSync(join(project, "index.html"), html);
170+
const adapter = {
171+
resolveProject: async (id: string) => ({ id, dir: project }),
172+
} as unknown as StudioApiAdapter;
173+
const app = new Hono();
174+
registerFileRoutes(app, adapter);
175+
176+
const response = await app.request("/projects/p/files/assets", {
177+
method: "PATCH",
178+
body: JSON.stringify({ newPath: "brand" }),
179+
});
180+
181+
expect(response.status).toBe(200);
182+
expect(readFileSync(join(project, "index.html"), "utf8")).toBe(
183+
'<img src="brand/a.png"><img src="assets-backup/a.png"><img src="other assets/a.png"><a href="empty assets/">x</a><p>The assets folder</p>',
184+
);
185+
});
186+
187+
it("leaves a path reached through a linked folder alone", async () => {
188+
const project = mkdtempSync(join(tmpdir(), "hf-rename-link-"));
189+
dirs.push(project);
190+
mkdirSync(join(project, "assets"));
191+
mkdirSync(join(project, "shared"));
192+
writeFileSync(join(project, "assets", "a.png"), "x");
193+
writeFileSync(join(project, "shared", "a.png"), "y");
194+
symlinkSync(join(project, "shared"), join(project, "other assets"), "dir");
195+
writeFileSync(
196+
join(project, "index.html"),
197+
'<img src="assets/a.png"><img src="other assets/a.png">',
198+
);
199+
const adapter = {
200+
resolveProject: async (id: string) => ({ id, dir: project }),
201+
} as unknown as StudioApiAdapter;
202+
const app = new Hono();
203+
registerFileRoutes(app, adapter);
204+
205+
await app.request("/projects/p/files/assets/a.png", {
206+
method: "PATCH",
207+
body: JSON.stringify({ newPath: "assets/b.png" }),
208+
});
209+
210+
expect(readFileSync(join(project, "index.html"), "utf8")).toBe(
211+
'<img src="assets/b.png"><img src="other assets/a.png">',
212+
);
213+
});
214+
215+
it("moves a relative link whose target the move leaves behind, and still rewrites references", async () => {
216+
const project = mkdtempSync(join(tmpdir(), "hf-rename-relink-"));
217+
dirs.push(project);
218+
mkdirSync(join(project, "assets"));
219+
mkdirSync(join(project, "shared"));
220+
writeFileSync(join(project, "shared", "logo.png"), "x");
221+
symlinkSync("../shared/logo.png", join(project, "assets", "logo.png"), "file");
222+
writeFileSync(join(project, "index.html"), '<img src="assets/logo.png">');
223+
const adapter = {
224+
resolveProject: async (id: string) => ({ id, dir: project }),
225+
} as unknown as StudioApiAdapter;
226+
const app = new Hono();
227+
registerFileRoutes(app, adapter);
228+
229+
const response = await app.request("/projects/p/files/assets/logo.png", {
230+
method: "PATCH",
231+
body: JSON.stringify({ newPath: "logo.png" }),
232+
});
233+
234+
expect(response.status).toBe(200);
235+
expect(readFileSync(join(project, "index.html"), "utf8")).toBe('<img src="logo.png">');
236+
});
237+
});

‎packages/studio-server/src/routes/files.ts‎

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
fstatSync,
1818
renameSync,
1919
readdirSync,
20+
realpathSync,
2021
type Dirent,
2122
} from "node:fs";
2223
import { resolve, dirname, join } from "node:path";
@@ -665,26 +666,119 @@ function readableText(file: string): string | null {
665666
}
666667
}
667668

669+
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
670+
671+
const SEPARATOR = String.raw`\\{0,2}[\\/]`;
672+
const REFERENCE_START = String.raw`(?<![\w./\\+-])`;
673+
const FILE_END = String.raw`(?![\w-]|\.\w)`;
674+
675+
function referencePattern(oldPath: string, isDirectory: boolean): RegExp {
676+
const name = oldPath.split("/").map(escapeRegExp).join(SEPARATOR);
677+
const end = isDirectory ? `(?=${SEPARATOR})` : FILE_END;
678+
return new RegExp(
679+
String.raw`${REFERENCE_START}(?<lead>(?:\.{1,2}${SEPARATOR}|${SEPARATOR}){0,4})${name}${end}`,
680+
"g",
681+
);
682+
}
683+
684+
// A match inside the longer path of a file or folder that exists (`a.png&b.png`, `other assets/`) is that path's.
685+
// Existing paths are indexed by the text after the old path, then the text before it, so a match costs a few lookups.
686+
export function referenceRewriter(
687+
oldPath: string,
688+
newPath: string,
689+
isDirectory: boolean,
690+
existing: readonly string[] = [],
691+
): (text: string) => string {
692+
const pattern = referencePattern(oldPath, isDirectory);
693+
const around = new Map<string, Map<number, Set<string>>>();
694+
const afterLengths = new Set<number>();
695+
let window = 0;
696+
for (const path of existing) {
697+
if (path.length <= oldPath.length) continue;
698+
for (const start of occurrences(path, oldPath)) {
699+
const [before, after] = [path.slice(0, start), path.slice(start + oldPath.length)];
700+
const befores = around.get(after) ?? around.set(after, new Map()).get(after)!;
701+
(befores.get(before.length) ?? befores.set(before.length, new Set()).get(before.length)!).add(
702+
before,
703+
);
704+
afterLengths.add(after.length);
705+
window = Math.max(window, path.length * 3);
706+
}
707+
}
708+
const normalized = (text: string) => text.replace(/\\{0,2}[\\/]/g, "/");
709+
return (text) =>
710+
text.replace(pattern, (...args) => {
711+
const [match, offset] = [args[0] as string, args.at(-3) as number];
712+
const { lead } = args.at(-1) as { lead: string };
713+
const at = offset + lead.length;
714+
const head = normalized(text.slice(Math.max(0, at - window), at));
715+
const tail = normalized(text.slice(offset + match.length, offset + match.length + window));
716+
const inLonger = [...afterLengths].some((after) => {
717+
const befores = tail.length >= after && around.get(tail.slice(0, after));
718+
return (
719+
befores &&
720+
[...befores].some(
721+
([length, set]) => head.length >= length && set.has(head.slice(head.length - length)),
722+
)
723+
);
724+
});
725+
return inLonger ? match : `${lead}${newPath}`;
726+
});
727+
}
728+
729+
function occurrences(text: string, part: string): number[] {
730+
const at: number[] = [];
731+
for (let i = text.indexOf(part); i >= 0; i = text.indexOf(part, i + 1)) at.push(i);
732+
return at;
733+
}
734+
735+
function projectPaths(
736+
root: string,
737+
dir = root,
738+
prefix = "",
739+
ancestors: ReadonlySet<string> = new Set(),
740+
): string[] {
741+
return readableEntries(dir).flatMap((entry) => {
742+
const [path, full] = [`${prefix}${entry.name}`, join(dir, entry.name)];
743+
const real = entry.name === "node_modules" ? null : directoryReal(entry, full);
744+
if (real === null || ancestors.has(real) || !isSafePath(root, full)) return [path];
745+
return [path, ...projectPaths(root, full, `${path}/`, new Set([...ancestors, real]))];
746+
});
747+
}
748+
749+
// A directory's real path, through a link, or null for anything else: aliases are paths too.
750+
function directoryReal(entry: Dirent, full: string): string | null {
751+
try {
752+
return entry.isDirectory() || (entry.isSymbolicLink() && statSync(full).isDirectory())
753+
? realpathSync(full)
754+
: null;
755+
} catch {
756+
return null;
757+
}
758+
}
759+
668760
/**
669761
* After a rename, update all references to the old path in project files.
670-
* Scans HTML, CSS, JS, and JSON files for the old filename/path and replaces.
762+
* Scans HTML, CSS, JS, and JSON files for the old path and replaces it where it names that path.
671763
*/
672-
function updateReferences(projectDir: string, oldPath: string, newPath: string): number {
764+
function updateReferences(
765+
projectDir: string,
766+
oldPath: string,
767+
newPath: string,
768+
isDirectory: boolean,
769+
): number {
673770
const textFiles = walkFiles(projectDir, (name) =>
674771
/\.(html|css|js|jsx|ts|tsx|json|mjs|cjs|md|mdx)$/i.test(name),
675772
);
676773

774+
const rewrite = referenceRewriter(oldPath, newPath, isDirectory, projectPaths(projectDir));
677775
let updatedCount = 0;
678776
for (const file of textFiles) {
679777
if (!isSafePath(projectDir, file)) continue;
680778
const content = readableText(file);
681779
if (content === null) continue;
682780

683-
// Only replace full relative paths — never bare filenames, which can
684-
// corrupt unrelated content (e.g. "logo.png" inside "my-logo.png").
685-
if (!content.includes(oldPath)) continue;
686-
687-
const updated = content.split(oldPath).join(newPath);
781+
const updated = rewrite(content);
688782
if (updated !== content) {
689783
replaceFileAtomically(file, updated, statSync(file).mode);
690784
updatedCount++;
@@ -3230,11 +3324,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
32303324
return c.json({ error: "already exists" }, 409);
32313325
}
32323326

3327+
const isDirectory = statSync(res.absPath, { throwIfNoEntry: false })?.isDirectory() ?? false;
32333328
ensureDir(res.project.dir, newAbs);
32343329
renameSync(res.absPath, newAbs);
32353330

32363331
// Update references to the old path across all project files
3237-
const updatedFiles = updateReferences(res.project.dir, res.filePath, body.newPath);
3332+
const updatedFiles = updateReferences(res.project.dir, res.filePath, body.newPath, isDirectory);
32383333

32393334
return c.json({ ok: true, path: body.newPath, updatedReferences: updatedFiles });
32403335
});

0 commit comments

Comments
 (0)