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: the stored relative path breaks when the imported file is
// moved to another folder. Recover by searching the project for the file.
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
84 changes: 84 additions & 0 deletions loader/src/resolver/server/recoverMovedFileSource.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { mkdirSync, writeFileSync, rmSync } from "fs";
import { join } from "path";
import { assert } from "chai";
import { recoverMovedFileSource } from "./recoverMovedFileSource";

/**
* Fixtures are built on the fly in a temp dir so the spec stays hermetic:
* proj/
* package.json
* flows/main.flyde (only a stub file: the flow content is irrelevant here)
* parts/b.flyde (original location — removed in the "moved" case)
* newparts/b.flyde (location after the user moved the file)
*/
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 untouched 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 the file after it 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 finds 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 });
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);
});
});
109 changes: 109 additions & 0 deletions loader/src/resolver/server/recoverMovedFileSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { existsSync, readdirSync } from "fs";
import { basename, dirname, resolve } from "path";

/**
* Recovery logic for Flyde file-sourced imports whose stored relative path no
* longer resolves (e.g. after the imported file was moved to another folder).
*
* See flyde issue #112: "when I moved the imported file to a different folder,
* the import broke".
*
* Strategy:
* 1. Fast path: if the stored path resolves from the flow's directory, keep it.
* 2. Otherwise, locate the project root (nearest ancestor with package.json or
* .git) and search it for files sharing the stored path's basename.
* 3. Among candidates, prefer the one whose path suffix best matches the
* stored relative path (most segments in common, counted from the end).
*/

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(dir + "/package.json") || existsSync(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; // unreadable dir — ignore
}
for (const entry of entries) {
if (hits.length >= MAX_HITS) return;
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
const full = 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.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. If the stored relative
* path resolves, it is returned untouched. If it is broken (file moved), the
* project tree is searched for a file with the same basename and the best
* suffix match is returned. Returns null when nothing plausible is found.
*/
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;
}