Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion loader/src/resolver/server/findReferencedNodeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as _Nodes from "@flyde/nodes/dist/all";
import { join } from "path";
import { existsSync, readFileSync } from "fs";
import { resolveImportablePaths } from "./resolveImportablePaths";
import { recoverMovedFileSource } from "./recoverMovedFileSource";
import { deserializeFlowByPath } from "../../serdes";
import { ReferencedNodeFinder } from "./../ReferencedNodeFinder";
import { resolveCodeNodeDependencies } from "./serverUtils";
Expand Down Expand Up @@ -135,7 +136,12 @@ export function createServerReferencedNodeFinder(
return nodeWrapper;
}
case "file": {
const fullFilePath = join(fullFlowPath, "..", instance.source.data);
const directPath = join(fullFlowPath, "..", instance.source.data);
// Issue #112: stored relative paths break after moves. Explorer renames
// are rewritten via onWillRenameFiles; this recovers external/fs moves.
const fullFilePath = existsSync(directPath)
? directPath
: recoverMovedFileSource(fullFlowPath, instance.source.data) ?? directPath;

// Check if the file is a .flyde file (visual flow)
if (fullFilePath.endsWith('.flyde')) {
Expand Down
77 changes: 77 additions & 0 deletions loader/src/resolver/server/recoverMovedFileSource.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { mkdirSync, writeFileSync, rmSync } from "fs";
import { join } from "path";
import { assert } from "chai";
import { recoverMovedFileSource } from "./recoverMovedFileSource";

describe("recoverMovedFileSource", () => {
const base = join(__dirname, "../../../fixture/recover-moved-file-source-tmp");

const B_FLOW = [
"node:",
" id: AddOne",
" inputs:",
" n:",
" mode: required",
" type: number",
" outputs:",
" r:",
" type: number",
" instances: []",
" connections: []",
].join("\n");

beforeEach(() => {
rmSync(base, { recursive: true, force: true });
mkdirSync(join(base, "proj/flows"), { recursive: true });
mkdirSync(join(base, "proj/parts"), { recursive: true });
mkdirSync(join(base, "proj/newparts"), { recursive: true });
writeFileSync(join(base, "proj/package.json"), "{}");
writeFileSync(join(base, "proj/flows/main.flyde"), "node:\n id: Main\n");
writeFileSync(join(base, "proj/parts/b.flyde"), B_FLOW);
writeFileSync(join(base, "proj/newparts/b.flyde"), B_FLOW);
});

afterEach(() => {
rmSync(base, { recursive: true, force: true });
});

const flowPath = () => join(base, "proj/flows/main.flyde");

it("returns the stored path when it still resolves", () => {
const flowDir = join(base, "proj/flows");
const result = recoverMovedFileSource(flowPath(), "../parts/b.flyde");
assert.equal(result, join(flowDir, "../parts/b.flyde"));
});

it("recovers after the imported file was moved to another folder", () => {
rmSync(join(base, "proj/parts"), { recursive: true, force: true });
const result = recoverMovedFileSource(flowPath(), "../parts/b.flyde");
assert.isNotNull(result);
assert.equal(result, join(base, "proj/newparts/b.flyde"));
});

it("still returns a candidate when several share the basename", () => {
rmSync(join(base, "proj/parts"), { recursive: true, force: true });
mkdirSync(join(base, "proj/parts/deep"), { recursive: true });
writeFileSync(join(base, "proj/parts/deep/b.flyde"), B_FLOW);
const result = recoverMovedFileSource(flowPath(), "../parts/b.flyde");
assert.isNotNull(result);
assert.match(String(result), /b\.flyde$/);
});

it("returns null when no plausible candidate exists", () => {
rmSync(join(base, "proj/parts"), { recursive: true, force: true });
rmSync(join(base, "proj/newparts"), { recursive: true, force: true });
const result = recoverMovedFileSource(flowPath(), "../parts/b.flyde");
assert.isNull(result);
});

it("never descends into node_modules or .git", () => {
rmSync(join(base, "proj/parts"), { recursive: true, force: true });
rmSync(join(base, "proj/newparts"), { recursive: true, force: true });
mkdirSync(join(base, "proj/node_modules/somepkg"), { recursive: true });
writeFileSync(join(base, "proj/node_modules/somepkg/b.flyde"), B_FLOW);
const result = recoverMovedFileSource(flowPath(), "../parts/b.flyde");
assert.isNull(result);
});
});
124 changes: 124 additions & 0 deletions loader/src/resolver/server/recoverMovedFileSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { existsSync, readdirSync } from "fs";
import { basename, dirname, resolve } from "path";

/**
* Fallback for file-sourced imports whose stored relative path no longer
* resolves (e.g. the imported file was moved outside VS Code). Primary fix for
* Explorer renames lives in `getReferenceEditsForRename` + onWillRenameFiles.
*
* Strategy:
* 1. If the stored path still exists, return it.
* 2. Otherwise walk the nearest project root for matching basenames.
* 3. Prefer the candidate whose path suffix best matches the stored path.
*/

const SKIP_DIRS = new Set([
"node_modules",
".git",
"dist",
"build",
"out",
".flyde",
"coverage",
]);

const MAX_DEPTH = 8;
const MAX_HITS = 50;

function findProjectRoot(startDir: string): string {
let dir = resolve(startDir);
for (let i = 0; i < MAX_DEPTH; i++) {
if (existsSync(resolve(dir, "package.json")) || existsSync(resolve(dir, ".git"))) {
return dir;
}
const parent = dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return resolve(startDir);
}

function walk(
dir: string,
targetBase: string,
hits: string[],
depth: number
): void {
if (hits.length >= MAX_HITS || depth > MAX_DEPTH) {
return;
}
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (hits.length >= MAX_HITS) {
return;
}
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) {
continue;
}
const full = resolve(dir, entry.name);
if (entry.isDirectory()) {
walk(full, targetBase, hits, depth + 1);
} else if (entry.isFile() && entry.name === targetBase) {
hits.push(full);
}
}
}

function suffixScore(candidatePath: string, storedParts: string[]): number {
const candidateParts = candidatePath.replace(/\\/g, "/").split("/");
let score = 0;
for (let i = 1; i <= storedParts.length; i++) {
const storedSeg = storedParts[storedParts.length - i];
const candSeg = candidateParts[candidateParts.length - i];
if (
storedSeg === undefined ||
candSeg === undefined ||
storedSeg !== candSeg
) {
break;
}
score += i;
}
return score;
}

/**
* Returns an absolute path for a file-sourced import, or null if nothing
* plausible is found after a move outside the rewrite path.
*/
export function recoverMovedFileSource(
fullFlowPath: string,
storedPath: string
): string | null {
const direct = resolve(dirname(fullFlowPath), storedPath);
if (existsSync(direct)) {
return direct;
}

const normalizedStored = storedPath.replace(/\\/g, "/");
const root = findProjectRoot(dirname(fullFlowPath));
const hits: string[] = [];
walk(root, basename(normalizedStored), hits, 0);
if (hits.length === 0) {
return null;
}

const storedParts = normalizedStored.split("/").filter(Boolean);
let best: string | null = null;
let bestScore = -1;
for (const hit of hits) {
const score = suffixScore(hit, storedParts);
if (score > bestScore) {
bestScore = score;
best = hit;
}
}
return best;
}
1 change: 1 addition & 0 deletions loader/src/serdes/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./serialize";
export * from "./deserialize";
export * from "./migrations";
export * from "./rename-references";
Loading