fix(eeschema): warm collab rooms for the OPENED hierarchy only, not every project schematic
connectAll warmed a room for every .kicad_sch in the project — a
repo-as-project upload (N boards × sheets) opened dozens of sockets for
schematics the wasm never loads. Only the opened root plus its transitive
(property "Sheetfile" …) closure is in memory, so only those need rooms:
no in-memory copy, no divergence risk, no clobber, and C++ sheet navigation
can only reach hierarchy members anyway (same reasoning as pcbnew's
directory-scoped sibling restage).
resolveSheetHierarchy: regex closure over staged MEMFS text, refs resolved
against the referencing sheet's directory (../ and ${KIPRJMOD}/ handled),
non-project refs ignored, unreadable sheets kept warmed but unexpanded, and
an unscopable root (fileless boot) falls back to all project sheets —
over-warming costs sockets, under-warming would cost collab. In-editor
"Add Sheet" children keep their created-hook warm-up.
Verified on the Arduino repo-as-project (27 schematics): 26 board-room
sockets → 5 (root + 3 hierarchy children + presence).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
This commit is contained in:
parent
2355e03888
commit
f9deee60a7
3 changed files with 189 additions and 1 deletions
|
|
@ -66,6 +66,7 @@ import {
|
||||||
usedLibNicknames,
|
usedLibNicknames,
|
||||||
type ToolFile,
|
type ToolFile,
|
||||||
} from "@/wasm/kicad-runner";
|
} from "@/wasm/kicad-runner";
|
||||||
|
import { resolveSheetHierarchy } from "@/wasm/collab/sheet-hierarchy";
|
||||||
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
|
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
|
||||||
import { errorMessage, isTerminalError } from "@/wasm/terminal-error";
|
import { errorMessage, isTerminalError } from "@/wasm/terminal-error";
|
||||||
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
|
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
|
||||||
|
|
@ -791,9 +792,27 @@ async function startSheetCollab(
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sheetPaths = opts.files
|
// Warm ONLY the opened hierarchy (root + transitive Sheetfile references),
|
||||||
|
// not every schematic in the project: a repo-as-project upload can hold
|
||||||
|
// dozens of unrelated boards' schematics that the wasm never loads — no
|
||||||
|
// in-memory copy, no divergence risk, no room needed (sheet-hierarchy.ts).
|
||||||
|
// A root we can't scope (fileless boot, unreadable staging) falls back to
|
||||||
|
// all project sheets — over-warming costs sockets, under-warming would cost
|
||||||
|
// collab. In-editor "Add Sheet" children are warmed by the created hook.
|
||||||
|
const allSheets = opts.files
|
||||||
.filter((f) => f.path.endsWith(".kicad_sch"))
|
.filter((f) => f.path.endsWith(".kicad_sch"))
|
||||||
.map((f) => f.path);
|
.map((f) => f.path);
|
||||||
|
const sheetPaths =
|
||||||
|
opts.targetPath?.endsWith(".kicad_sch") && allSheets.includes(opts.targetPath)
|
||||||
|
? resolveSheetHierarchy(
|
||||||
|
opts.targetPath,
|
||||||
|
(p) => {
|
||||||
|
const bytes = readStagedFile(win, opts.slug, p);
|
||||||
|
return bytes ? new TextDecoder().decode(bytes) : null;
|
||||||
|
},
|
||||||
|
allSheets,
|
||||||
|
)
|
||||||
|
: allSheets;
|
||||||
|
|
||||||
// C++ navigation → rebind the active room to the now-shown sheet.
|
// C++ navigation → rebind the active room to the now-shown sheet.
|
||||||
registerSheetChangedHook(win as unknown as SheetChangedWindow, (abs) => {
|
registerSheetChangedHook(win as unknown as SheetChangedWindow, (abs) => {
|
||||||
|
|
|
||||||
88
web/standalone/src/wasm/collab/sheet-hierarchy.test.ts
Normal file
88
web/standalone/src/wasm/collab/sheet-hierarchy.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveSheetHierarchy } from "./sheet-hierarchy";
|
||||||
|
|
||||||
|
const sheetRef = (file: string) =>
|
||||||
|
`(sheet (at 10 10) (property "Sheetfile" "${file}") (property "Sheetname" "x"))`;
|
||||||
|
|
||||||
|
function reader(files: Record<string, string>) {
|
||||||
|
return (p: string): string | null => files[p] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveSheetHierarchy", () => {
|
||||||
|
it("returns the root plus its transitive Sheetfile closure only", () => {
|
||||||
|
// A repo-as-project: two boards' schematics side by side. Opening
|
||||||
|
// Leonardo must warm ITS hierarchy, never the Mega's.
|
||||||
|
const files = {
|
||||||
|
"boards/leonardo/root.kicad_sch":
|
||||||
|
sheetRef("cpu.kicad_sch") + sheetRef("power.kicad_sch"),
|
||||||
|
"boards/leonardo/cpu.kicad_sch": "(kicad_sch)",
|
||||||
|
"boards/leonardo/power.kicad_sch": sheetRef("regulator.kicad_sch"),
|
||||||
|
"boards/leonardo/regulator.kicad_sch": "(kicad_sch)",
|
||||||
|
"boards/mega/root.kicad_sch": sheetRef("cpu.kicad_sch"),
|
||||||
|
"boards/mega/cpu.kicad_sch": "(kicad_sch)",
|
||||||
|
};
|
||||||
|
const all = Object.keys(files);
|
||||||
|
expect(
|
||||||
|
resolveSheetHierarchy("boards/leonardo/root.kicad_sch", reader(files), all),
|
||||||
|
).toEqual([
|
||||||
|
"boards/leonardo/root.kicad_sch",
|
||||||
|
"boards/leonardo/cpu.kicad_sch",
|
||||||
|
"boards/leonardo/power.kicad_sch",
|
||||||
|
"boards/leonardo/regulator.kicad_sch",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves ../ and ${KIPRJMOD}/ references and ignores non-project ones", () => {
|
||||||
|
const files = {
|
||||||
|
"a/root.kicad_sch":
|
||||||
|
sheetRef("../shared/common.kicad_sch") +
|
||||||
|
sheetRef("${KIPRJMOD}/b/top.kicad_sch") +
|
||||||
|
sheetRef("missing.kicad_sch"),
|
||||||
|
"shared/common.kicad_sch": "(kicad_sch)",
|
||||||
|
"b/top.kicad_sch": "(kicad_sch)",
|
||||||
|
};
|
||||||
|
const all = Object.keys(files);
|
||||||
|
expect(resolveSheetHierarchy("a/root.kicad_sch", reader(files), all)).toEqual([
|
||||||
|
"a/root.kicad_sch",
|
||||||
|
"shared/common.kicad_sch",
|
||||||
|
"b/top.kicad_sch",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives reference cycles and duplicate references", () => {
|
||||||
|
const files = {
|
||||||
|
"root.kicad_sch": sheetRef("child.kicad_sch") + sheetRef("child.kicad_sch"),
|
||||||
|
"child.kicad_sch": sheetRef("root.kicad_sch"),
|
||||||
|
};
|
||||||
|
const all = Object.keys(files);
|
||||||
|
expect(resolveSheetHierarchy("root.kicad_sch", reader(files), all)).toEqual([
|
||||||
|
"root.kicad_sch",
|
||||||
|
"child.kicad_sch",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an unreadable sheet warmed without expanding it", () => {
|
||||||
|
// Staging failed for the child: still give it a room (erring toward
|
||||||
|
// inclusion), but its own references are unknowable.
|
||||||
|
const files = {
|
||||||
|
"root.kicad_sch": sheetRef("child.kicad_sch"),
|
||||||
|
};
|
||||||
|
const all = ["root.kicad_sch", "child.kicad_sch", "orphan.kicad_sch"];
|
||||||
|
expect(resolveSheetHierarchy("root.kicad_sch", reader(files), all)).toEqual([
|
||||||
|
"root.kicad_sch",
|
||||||
|
"child.kicad_sch",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles escaped quotes in the Sheetfile value", () => {
|
||||||
|
const files = {
|
||||||
|
'root.kicad_sch': '(property "Sheetfile" "my \\"quoted\\" sheet.kicad_sch")',
|
||||||
|
'my "quoted" sheet.kicad_sch': "(kicad_sch)",
|
||||||
|
};
|
||||||
|
const all = Object.keys(files);
|
||||||
|
expect(resolveSheetHierarchy("root.kicad_sch", reader(files), all)).toEqual([
|
||||||
|
"root.kicad_sch",
|
||||||
|
'my "quoted" sheet.kicad_sch',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
web/standalone/src/wasm/collab/sheet-hierarchy.ts
Normal file
81
web/standalone/src/wasm/collab/sheet-hierarchy.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* Which schematic files does the OPENED root actually load? KiCad's eeschema
|
||||||
|
* loads the root plus the transitive closure of its `(property "Sheetfile"
|
||||||
|
* "child.kicad_sch")` references — and nothing else. Only those in-memory
|
||||||
|
* sheets need collab rooms: a schematic outside the hierarchy is never loaded,
|
||||||
|
* so this session can neither diverge from nor clobber it, and C++ sheet
|
||||||
|
* navigation can only reach hierarchy members anyway.
|
||||||
|
*
|
||||||
|
* This matters for repo-as-project uploads: a repository with N boards holds
|
||||||
|
* N×sheets `.kicad_sch` files, and warming a room for every one of them (the
|
||||||
|
* pre-scoping behavior) opened dozens of sockets per session for schematics
|
||||||
|
* the wasm never even parsed. Same reasoning as pcbnew's directory-scoped
|
||||||
|
* sibling restage.
|
||||||
|
*
|
||||||
|
* Heuristic parser (regex over s-expr text) with a SAFE fallback: any file
|
||||||
|
* that cannot be read stays in the set unexpanded, and the caller falls back
|
||||||
|
* to all project sheets when the closure cannot be computed at all — the cost
|
||||||
|
* of over-warming is sockets, the cost of under-warming would be missed
|
||||||
|
* collab, so unknowns err toward inclusion.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `(property "Sheetfile" "…")` — value may contain escaped quotes. */
|
||||||
|
const SHEETFILE_RE = /\(property\s+"Sheetfile"\s+"((?:[^"\\]|\\.)*)"/g;
|
||||||
|
|
||||||
|
function unescapeSexpr(value: string): string {
|
||||||
|
return value.replace(/\\(.)/g, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a Sheetfile reference against the REFERENCING sheet's directory.
|
||||||
|
* KiCad writes plain relative paths (`Power.kicad_sch`, `../shared/x.kicad_sch`);
|
||||||
|
* a `${KIPRJMOD}/` prefix (project root) appears in some hand-edited files.
|
||||||
|
*/
|
||||||
|
function resolveRef(parentPath: string, ref: string): string {
|
||||||
|
let r = unescapeSexpr(ref).trim().replace(/\\/g, "/");
|
||||||
|
const projRelative = r.startsWith("${KIPRJMOD}/");
|
||||||
|
if (projRelative) r = r.slice("${KIPRJMOD}/".length);
|
||||||
|
const baseDir = projRelative
|
||||||
|
? []
|
||||||
|
: parentPath.split("/").slice(0, -1);
|
||||||
|
const out = [...baseDir];
|
||||||
|
for (const seg of r.split("/")) {
|
||||||
|
if (seg === "" || seg === ".") continue;
|
||||||
|
if (seg === "..") {
|
||||||
|
out.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(seg);
|
||||||
|
}
|
||||||
|
return out.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opened hierarchy: `rootPath` plus every transitively referenced sheet
|
||||||
|
* that exists in the project. Returns them in discovery order (root first).
|
||||||
|
*/
|
||||||
|
export function resolveSheetHierarchy(
|
||||||
|
rootPath: string,
|
||||||
|
readText: (relPath: string) => string | null,
|
||||||
|
allSheets: readonly string[],
|
||||||
|
): string[] {
|
||||||
|
const known = new Set(allSheets);
|
||||||
|
const visited = new Set<string>([rootPath]);
|
||||||
|
const order: string[] = [rootPath];
|
||||||
|
const queue: string[] = [rootPath];
|
||||||
|
while (queue.length) {
|
||||||
|
const parent = queue.shift()!;
|
||||||
|
const text = readText(parent);
|
||||||
|
if (text === null) continue; // unreadable: keep it warmed, don't expand
|
||||||
|
for (const match of text.matchAll(SHEETFILE_RE)) {
|
||||||
|
const child = resolveRef(parent, match[1]!);
|
||||||
|
// Only project members get rooms — a reference outside the file list
|
||||||
|
// has nothing to collaborate on (missing file, external path).
|
||||||
|
if (!known.has(child) || visited.has(child)) continue;
|
||||||
|
visited.add(child);
|
||||||
|
order.push(child);
|
||||||
|
queue.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue