feat: live lib edit — cmd+s accelerators, editing-context overlay, lib-set realtime (libs 0015)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166YJapMpPB8Ab7wT8Nx51t
This commit is contained in:
Gergő Törcsvári 2026-08-19 13:47:13 +02:00
commit aeacadf603
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
11 changed files with 312 additions and 7 deletions

View file

@ -172,7 +172,12 @@
// (wxWasmYieldUntil inside the load) find a tracked record and get the
// green-region spill-stack discipline. Embind names live on Module WITHOUT
// the underscore prefix, hence the separate installer.
PARKER_NAMES: ["kicadOpenFile", "kicadOpenFiles", "kicadLibsReload"],
PARKER_NAMES: [
"kicadOpenFile",
"kicadOpenFiles",
"kicadLibsReload",
"kicadLibsAddEntry",
],
_wrapParkers: function () {
var wrapped = 0;
for (var i = 0; i < this.PARKER_NAMES.length; i++) {

View file

@ -2104,6 +2104,9 @@ EMSCRIPTEN_BINDINGS(eeschema) {
function("kicadCollabTestClearSelection", &schCollabTestClearSelection);
// Library reload after a remote (synced) lib edit — r2-idb-sync realtime.
function("kicadLibsReload", &pcbjam_libs::reloadLibrary PCBJAM_PARKER_POLICY);
// Runtime lib-table row insert + load (a new team library appeared
// mid-session; the lib set is otherwise frozen at boot).
function("kicadLibsAddEntry", &pcbjam_libs::addLibraryEntry PCBJAM_PARKER_POLICY);
// Placed-instance count for a library symbol (drives the "a symbol you are
// using was updated" toast after a remote lib edit).
function("kicadLibsSymbolUsage", &schLibsSymbolUsage);

View file

@ -596,6 +596,9 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
function("kicadCollabTestClearSelection", &collabTestClearSelection);
// Library reload after a remote (synced) lib edit — r2-idb-sync realtime.
function("kicadLibsReload", &pcbjam_libs::reloadLibrary PCBJAM_PARKER_POLICY);
// Runtime lib-table row insert + load (a new team library appeared
// mid-session; the lib set is otherwise frozen at boot).
function("kicadLibsAddEntry", &pcbjam_libs::addLibraryEntry PCBJAM_PARKER_POLICY);
// Placed-instance count for a library symbol (schematic sessions only —
// 0 from any other frame; drives the "symbol you are using was updated"
// toast after a remote lib edit).

View file

@ -29,6 +29,7 @@
#include <kiway.h>
#include <kiway_player.h>
#include <libraries/library_manager.h>
#include <libraries/library_table.h>
#include <mail_type.h>
#include <pgm_base.h>
@ -76,6 +77,74 @@ inline void reloadLibrary( std::string aKind, std::string aNickname )
} );
}
/**
* Add one PCBJAM lib-table row at RUNTIME and load it the lib SET is
* otherwise frozen at boot (sym/fp-lib-table are written once in preRun).
* Used by the "a new team library appeared" flow: the JS side has already
* created the `/mnt/pcbjam/<id>` MEMFS placeholder; this inserts the row into
* the LIVE in-memory global table (the table FILE is only re-read by
* LoadGlobalTables, which would drop every adapter cache deliberately not
* used), loads the entry through the provider bridge, and re-syncs any open
* editor tree. Idempotent: an existing nickname is a no-op.
*
* @param aKind "symbol" | "footprint" which lib table gets the row.
* @param aNickname the lib-table row name (LibInfo.name on the JS side).
* @param aUri the virtual mount uri ("/mnt/pcbjam/<libId>").
*/
inline void addLibraryEntry( std::string aKind, std::string aNickname, std::string aUri )
{
KIWAY_PLAYER* top =
wxTheApp ? dynamic_cast<KIWAY_PLAYER*>( wxTheApp->GetTopWindow() ) : nullptr;
if( !top )
return;
const bool fp = aKind == "footprint";
const wxString nick = wxString::FromUTF8( aNickname.c_str() );
const wxString uri = wxString::FromUTF8( aUri.c_str() );
pcbjam_collab::runOnCoroutine( top, [top, fp, nick, uri]()
{
LIBRARY_MANAGER& mgr = Pgm().GetLibraryManager();
const LIBRARY_TABLE_TYPE type =
fp ? LIBRARY_TABLE_TYPE::FOOTPRINT : LIBRARY_TABLE_TYPE::SYMBOL;
if( std::optional<LIBRARY_MANAGER_ADAPTER*> adapter = mgr.Adapter( type ); adapter )
{
LIBRARY_TABLE* table = ( *adapter )->GlobalTable();
if( !table )
return;
// Idempotent-cheap: a nickname that already has a row is done —
// content refresh is kicadLibsReload's job, and doing it here
// would let a re-announce trigger a pointless re-fat-load.
if( table->HasRow( nick ) )
return;
LIBRARY_TABLE_ROW& row = table->InsertRow();
row.SetNickname( nick );
row.SetURI( uri );
row.SetType( fp ? wxS( "PCBJAM_FP" ) : wxS( "PCBJAM" ) );
}
else
{
return;
}
// Bring the new row to LOADED through the JS provider bridge (GetRow
// walks the table on a cache miss, so the inserted row is found).
mgr.LoadLibraryEntry( type, nick );
// Open editor frames re-sync their tree; the nickname payload forces
// the named node's rebuild past the pinned-modify-hash gate (same
// reasoning as reloadLibrary above).
std::string payload( nick.utf8_str() );
top->Kiway().ExpressMail( fp ? FRAME_FOOTPRINT_EDITOR : FRAME_SCH_SYMBOL_EDITOR,
MAIL_RELOAD_LIB, payload );
} );
}
} // namespace pcbjam_libs
#endif // __EMSCRIPTEN__

View file

@ -2451,6 +2451,9 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
function("kicadCollabTestClearSelection", &pcbCollabTestClearSelection);
// Library reload after a remote (synced) lib edit — r2-idb-sync realtime.
function("kicadLibsReload", &pcbjam_libs::reloadLibrary PCBJAM_PARKER_POLICY);
// Runtime lib-table row insert + load (a new team library appeared
// mid-session; the lib set is otherwise frozen at boot).
function("kicadLibsAddEntry", &pcbjam_libs::addLibraryEntry PCBJAM_PARKER_POLICY);
#endif // !KICAD_MERGED_EMBIND
}
#endif

@ -1 +1 @@
Subproject commit 0230fde538a4b6f2bf07b2e7856c3f8d440205fa
Subproject commit b2af70c0001c52aaa8a408cc3c9ed63d3eeb4806

View file

@ -48,13 +48,22 @@ import {
LIB_ERROR_EVENT,
LIB_ITEM_UPDATED_EVENT,
LIB_LOADING_EVENT,
LIB_SET_CHANGED_EVENT,
type LibBusyDetail,
type LibErrorDetail,
type LibItemUpdatedDetail,
type LibLoadingDetail,
type LibSetChangedDetail,
type LibsSource,
type LibsSyncState,
} from "@/wasm/libs/source";
import { addAnnouncedLib } from "@/wasm/libs/runtime-add";
/** The libset toast's message once live-loading failed and reload is the offer. */
function reloadFallbackMsg(notice: { detail: LibSetChangedDetail }): string {
const label = notice.detail.name ? `"${notice.detail.name}"` : "the new library";
return `Couldn't load ${label} into the running session — click to reload the editor.`;
}
import {
MODELS_LOADING_EVENT,
type ModelsLoadingDetail,
@ -69,7 +78,7 @@ import {
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";
import { registerSaveHook, type SaveBlock, type SaveBytes } from "@/wasm/save-flow";
import type {
KicadCollabHandle,
KicadDocSession,
@ -1110,6 +1119,24 @@ export function WasmTool({
// The backend rolled this document back to its last valid state
// (kicad-validity 0001 — DOC_REVERTED_EVENT from the collab binding).
const [docReverted, setDocReverted] = React.useState<string | null>(null);
// A peer changed the team's lib SET mid-session (LIB_SET_CHANGED_EVENT —
// the scope room's `libset` broadcast). The lib table is frozen at boot, so
// the toast's click action loads the new lib live (addAnnouncedLib), with a
// reload fallback when the runtime bridge is missing.
const [libSetNotice, setLibSetNotice] = React.useState<{
message: string;
detail: LibSetChangedDetail;
mode: "load" | "reload";
} | null>(null);
// The one libs source instance the running editor uses (set by the boot
// effect) — the libset toast's action needs it to re-list and load.
const activeLibsSourceRef = React.useRef<LibsSource | null>(null);
// A save path entered the DURABLE blocked state (409 conflict / unknown
// commit state — save-flow's absorbing blockedPaths). Rendered as a
// persistent banner, never auto-dismissed: further Ctrl+S on the path is
// silently absorbed, so without this surface the user would keep "saving"
// into the void.
const [saveBlocked, setSaveBlocked] = React.useState<SaveBlock | null>(null);
// Eager whole-library idb→wasm load in flight (the ~tens-of-seconds fat-load on
// first chooser/editor open). Drives a full-cover overlay so the freeze reads as
// "loading, just slow" rather than a hang. Null when idle; `done/total` count the
@ -1262,6 +1289,19 @@ export function WasmTool({
};
const onItemUpdated = (e: Event) => {
const d = (e as CustomEvent<LibItemUpdatedDetail>).detail;
// Footprints have no placed-usage bridge (kicadLibsSymbolUsage is
// symbol-only), so every applied peer edit is announced — silently
// refreshing the lib under the user was the worse failure mode.
if (d.kind === "footprint") {
if (d.names.length === 0) return;
const names = d.names.map((n) => `"${n}"`).join(", ");
setLibUpdate(
`${d.names.length === 1 ? "Footprint" : "Footprints"} ${names} in "${d.lib}" ` +
`${d.names.length === 1 ? "was" : "were"} updated by a collaborator — ` +
`placed copies keep the previous version until updated from the library.`,
);
return;
}
// Only warn when the update touches something PLACED here — the library
// tree already reflects updates to everything else.
if (d.usedNames.length === 0) return;
@ -1279,15 +1319,30 @@ export function WasmTool({
`was detected${d?.reason ? ` (${d.reason})` : ""}. Recent edits may have been undone.`,
);
};
const onLibSet = (e: Event) => {
const d = (e as CustomEvent<LibSetChangedDetail>).detail;
// Only additions get a call to action — a removed lib's table row is
// inert until the next boot and needs no interruption.
if (d.op !== "add") return;
setLibSetNotice({
message: d.name
? `A collaborator added library "${d.name}" — click to load it into this session.`
: `A collaborator added a new library — click to load it into this session.`,
detail: d,
mode: "load",
});
};
window.addEventListener(LIB_BUSY_EVENT, onBusy);
window.addEventListener(LIB_ERROR_EVENT, onError);
window.addEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
window.addEventListener(LIB_SET_CHANGED_EVENT, onLibSet);
window.addEventListener(DOC_REVERTED_EVENT, onDocReverted);
return () => {
clearTimeout(busyTimer);
window.removeEventListener(LIB_BUSY_EVENT, onBusy);
window.removeEventListener(LIB_ERROR_EVENT, onError);
window.removeEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
window.removeEventListener(LIB_SET_CHANGED_EVENT, onLibSet);
window.removeEventListener(DOC_REVERTED_EVENT, onDocReverted);
};
}, []);
@ -1306,6 +1361,13 @@ export function WasmTool({
return () => clearTimeout(t);
}, [libUpdate]);
// Auto-dismiss the lib-set toast (long — it carries a click action).
React.useEffect(() => {
if (!libSetNotice) return;
const t = setTimeout(() => setLibSetNotice(null), 30_000);
return () => clearTimeout(t);
}, [libSetNotice]);
// Auto-dismiss the doc-reverted toast (longest — the user should see it).
React.useEffect(() => {
if (!docReverted) return;
@ -1679,6 +1741,7 @@ export function WasmTool({
boot ? { libs: boot.libs, stacks: boot.stacks } : undefined,
);
if (libsSource === undefined) ownedLibsSource = source;
activeLibsSourceRef.current = source;
// Download-consent gate (standalone-load-ux 0001): before pulling the
// (large) cold wasm + lib bundles, say how many MB and wait for the OK.
// Runs only on versioned CDN deploys (`meta.ver` — flat dev roots and
@ -1870,6 +1933,9 @@ export function WasmTool({
...(readOnly
? {}
: {
// Durable per-path block (409 conflict / unknown commit
// state): surface it as the persistent save-blocked banner.
onBlocked: (block: SaveBlock) => setSaveBlocked(block),
// A sheet created mid-session ("Add Sheet") saves to a new .kicad_sch path the
// page-load file list can't contain — warm its collab room so it stays in sync.
onSaved: (relPath: string) => {
@ -2625,10 +2691,26 @@ export function WasmTool({
</div>
)}
{/* A save path is durably BLOCKED (CAS conflict / unknown commit state)
persistent full-width banner, no auto-dismiss: subsequent Ctrl+S on
the path is absorbed by the save lane, so this must stay visible. */}
{saveBlocked && (
<div
data-testid="save-blocked-banner"
className="absolute inset-x-0 top-0 z-40 bg-red-900/95 px-4 py-2 text-center text-xs font-medium text-red-100 shadow-lg"
>
{saveBlocked.message}
</div>
)}
{/* Top-center toast column: simultaneous notices stack instead of
overlapping (they all used to render at the same absolute spot). */}
<div className="absolute left-1/2 top-3 z-40 flex -translate-x-1/2 flex-col items-center gap-2">
{/* Library error (e.g. a backend 404 on open) — auto-dismisses. */}
{libError && (
<button
className="absolute left-1/2 top-3 z-40 max-w-md -translate-x-1/2 rounded bg-red-950/95 px-3 py-2 text-center text-xs text-red-100 shadow-lg ring-1 ring-red-500/40"
className="max-w-md rounded bg-red-950/95 px-3 py-2 text-center text-xs text-red-100 shadow-lg ring-1 ring-red-500/40"
onClick={() => setLibError(null)}
title="Dismiss"
>
@ -2640,7 +2722,7 @@ export function WasmTool({
{libUpdate && (
<button
data-testid="lib-update-toast"
className="absolute left-1/2 top-3 z-40 max-w-md -translate-x-1/2 rounded bg-amber-950/95 px-3 py-2 text-center text-xs text-amber-100 shadow-lg ring-1 ring-amber-500/40"
className="max-w-md rounded bg-amber-950/95 px-3 py-2 text-center text-xs text-amber-100 shadow-lg ring-1 ring-amber-500/40"
onClick={() => setLibUpdate(null)}
title="Dismiss"
>
@ -2648,11 +2730,47 @@ export function WasmTool({
</button>
)}
{/* A peer changed the team's lib set click loads the new lib live
(kicadLibsAddEntry bridge), falling back to a reload offer. */}
{libSetNotice && (
<button
data-testid="lib-set-toast"
className="max-w-md rounded bg-sky-950/95 px-3 py-2 text-center text-xs text-sky-100 shadow-lg ring-1 ring-sky-500/40"
onClick={() => {
const notice = libSetNotice;
if (notice.mode === "reload") {
window.location.reload();
return;
}
const source = activeLibsSourceRef.current;
if (!source) {
setLibSetNotice({ ...notice, mode: "reload", message: reloadFallbackMsg(notice) });
return;
}
setLibSetNotice(null);
void addAnnouncedLib(source, notice.detail, (m) => console.log(m)).then(
(ok) => {
if (!ok) {
setLibSetNotice({
...notice,
mode: "reload",
message: reloadFallbackMsg(notice),
});
}
},
);
}}
title={libSetNotice.mode === "reload" ? "Reload" : "Load the new library"}
>
{libSetNotice.message}
</button>
)}
{/* Backend rolled this doc back to the last valid state (kicad-validity). */}
{docReverted && (
<button
data-testid="doc-reverted-toast"
className="absolute left-1/2 top-3 z-40 max-w-md -translate-x-1/2 rounded bg-orange-950/95 px-3 py-2 text-center text-xs text-orange-100 shadow-lg ring-1 ring-orange-500/40"
className="max-w-md rounded bg-orange-950/95 px-3 py-2 text-center text-xs text-orange-100 shadow-lg ring-1 ring-orange-500/40"
onClick={() => setDocReverted(null)}
title="Dismiss"
>
@ -2660,6 +2778,8 @@ export function WasmTool({
</button>
)}
</div>
</WasmErrorBoundary>
{/* Terminal failure z-35, ABOVE the boot overlay but below the console

View file

@ -0,0 +1,69 @@
import type { LibsSource } from "./source";
import type { LibSetChangedDetail } from "./source";
import { libUri } from "./uri";
/**
* Load a mid-session-announced library into the RUNNING editor the action
* behind the LIB_SET_CHANGED_EVENT toast. The lib set is otherwise frozen at
* boot (sym/fp-lib-table written once in preRun), so this:
* 1. re-lists the source per kind to find the announced lib (and its
* display name the announce payload's name is advisory),
* 2. creates the `/mnt/pcbjam/<id>` MEMFS placeholder the post-save stat
* path needs (boot creates these for boot-time libs only),
* 3. calls the `kicadLibsAddEntry` bridge, which inserts the lib-table row
* into the live in-memory table, loads the lib through the provider and
* re-syncs any open editor tree.
* Returns true when at least one kind actually added the lib. False the
* bridge is missing (pre-addEntry wasm build) or the lib isn't listed for
* this session the caller falls back to suggesting a reload.
*/
export async function addAnnouncedLib(
source: LibsSource,
detail: LibSetChangedDetail,
log: (msg: string) => void = () => {},
): Promise<boolean> {
const win = globalThis as {
Module?: Record<string, unknown>;
FS?: { writeFile?: (path: string, data: string) => void; analyzePath?: (p: string) => { exists: boolean } };
};
const addEntry = win.Module?.kicadLibsAddEntry as
| ((kind: string, nickname: string, uri: string) => void)
| undefined;
if (typeof addEntry !== "function") {
log(`[libs] addAnnouncedLib: kicadLibsAddEntry bridge missing`);
return false;
}
let added = false;
for (const kind of ["symbol", "footprint"]) {
let libs;
try {
libs = await source.listLibs(kind);
} catch (e) {
log(`[libs] addAnnouncedLib: listLibs(${kind}) failed: ${String(e)}`);
continue;
}
const lib = libs.find((l) => l.id === detail.libId);
if (!lib) continue;
const uri = libUri(lib.id);
try {
// The empty placeholder file the save flow's stat()s need; harmless if
// another kind's pass (or a same-session retry) already wrote it.
if (!win.FS?.analyzePath?.(uri)?.exists) {
win.FS?.writeFile?.(uri, "");
}
} catch (e) {
log(`[libs] addAnnouncedLib: placeholder write failed: ${String(e)}`);
}
try {
addEntry(kind, lib.name, uri);
log(`[libs] added ${kind} lib "${lib.name}" at runtime`);
added = true;
} catch (e) {
log(`[libs] addAnnouncedLib: bridge failed for ${kind}: ${String(e)}`);
}
}
return added;
}

View file

@ -203,6 +203,13 @@ export const LIB_LOADING_EVENT = "pcbjam:lib-loading";
* updated" (placed copies keep the previous version until updated explicitly).
*/
export const LIB_ITEM_UPDATED_EVENT = "pcbjam:lib-item-updated";
/**
* Fired when the TEAM'S LIB SET changed mid-session (a peer created an org
* lib, pinned/unpinned one the scope room's `libset` broadcast). The lib
* table is frozen at boot, so the chrome offers a "load the new library"
* action (`addAnnouncedLib`) instead of requiring a reload.
*/
export const LIB_SET_CHANGED_EVENT = "pcbjam:lib-set-changed";
export interface LibBusyDetail {
busy: boolean;
@ -230,6 +237,11 @@ export interface LibItemUpdatedDetail {
/** The subset placed in the open document (empty ⇒ informational only). */
usedNames: string[];
}
export interface LibSetChangedDetail {
op: "add" | "remove" | "update";
libId: string;
name?: string;
}
function emitLibBusy(detail: LibBusyDetail): void {
if (typeof window === "undefined") return;

View file

@ -7,6 +7,7 @@ import {
USER_HEADER,
} from "@pcbjam/shared";
import {
onSyncRoomFrame,
peekNamespaces,
SyncRoomMovedError,
SyncStack,
@ -16,9 +17,11 @@ import {
} from "@pcbjam/sync-client";
import {
LIB_ITEM_UPDATED_EVENT,
LIB_SET_CHANGED_EVENT,
type LibInfo,
type LibItemInfo,
type LibItemUpdatedDetail,
type LibSetChangedDetail,
type LibsSource,
type LibsSyncState,
} from "./source";
@ -372,6 +375,23 @@ export function syncedScopeLibsSource(
},
): LibsSource {
const perLib = new Map<string, LibsSource>();
// Room-level `libset` frames (a peer created an org lib / changed pins —
// the scope room's announce broadcast): surface them to the chrome as
// LIB_SET_CHANGED_EVENT so it can offer "load the new library" without a
// reload. The session only ever dials its own team's scope room, so no
// URL filtering is needed; frames are advisory (the action re-lists).
const offRoomFrames = onSyncRoomFrame((_roomUrl, msg) => {
if (msg.t !== "libset" || typeof window === "undefined") return;
const detail: LibSetChangedDetail = {
op: msg.op,
libId: msg.libId,
name: msg.name,
};
opts.log?.(`[synced] lib set changed: ${msg.op} ${msg.name ?? msg.libId}`);
window.dispatchEvent(new CustomEvent(LIB_SET_CHANGED_EVENT, { detail }));
});
// Stacks resolved in bulk by `prefetchStacks`. A hit means the per-lib source
// makes NO resolve request; `null` records "backend says unresolvable" so a
// stale pin isn't retried one-by-one. Misses simply fall back per-lib, which
@ -539,6 +559,7 @@ export function syncedScopeLibsSource(
);
},
dispose(): void {
offRoomFrames();
for (const src of perLib.values()) src.dispose?.();
perLib.clear();
},

@ -1 +1 @@
Subproject commit ae28fab038ed54e3fb65fb7d4bc0dc7e123ae99f
Subproject commit 304ee266b4437a8b9c8bd49704f1b0a9006c54dc