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,
|
||||
type ToolFile,
|
||||
} from "@/wasm/kicad-runner";
|
||||
import { resolveSheetHierarchy } from "@/wasm/collab/sheet-hierarchy";
|
||||
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
|
||||
import { errorMessage, isTerminalError } from "@/wasm/terminal-error";
|
||||
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
|
||||
|
|
@ -791,9 +792,27 @@ async function startSheetCollab(
|
|||
: 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"))
|
||||
.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.
|
||||
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