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:
parent
4e089ae455
commit
aeacadf603
11 changed files with 312 additions and 7 deletions
|
|
@ -172,7 +172,12 @@
|
||||||
// (wxWasmYieldUntil inside the load) find a tracked record and get the
|
// (wxWasmYieldUntil inside the load) find a tracked record and get the
|
||||||
// green-region spill-stack discipline. Embind names live on Module WITHOUT
|
// green-region spill-stack discipline. Embind names live on Module WITHOUT
|
||||||
// the underscore prefix, hence the separate installer.
|
// the underscore prefix, hence the separate installer.
|
||||||
PARKER_NAMES: ["kicadOpenFile", "kicadOpenFiles", "kicadLibsReload"],
|
PARKER_NAMES: [
|
||||||
|
"kicadOpenFile",
|
||||||
|
"kicadOpenFiles",
|
||||||
|
"kicadLibsReload",
|
||||||
|
"kicadLibsAddEntry",
|
||||||
|
],
|
||||||
_wrapParkers: function () {
|
_wrapParkers: function () {
|
||||||
var wrapped = 0;
|
var wrapped = 0;
|
||||||
for (var i = 0; i < this.PARKER_NAMES.length; i++) {
|
for (var i = 0; i < this.PARKER_NAMES.length; i++) {
|
||||||
|
|
|
||||||
|
|
@ -2104,6 +2104,9 @@ EMSCRIPTEN_BINDINGS(eeschema) {
|
||||||
function("kicadCollabTestClearSelection", &schCollabTestClearSelection);
|
function("kicadCollabTestClearSelection", &schCollabTestClearSelection);
|
||||||
// Library reload after a remote (synced) lib edit — r2-idb-sync realtime.
|
// Library reload after a remote (synced) lib edit — r2-idb-sync realtime.
|
||||||
function("kicadLibsReload", &pcbjam_libs::reloadLibrary PCBJAM_PARKER_POLICY);
|
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
|
// Placed-instance count for a library symbol (drives the "a symbol you are
|
||||||
// using was updated" toast after a remote lib edit).
|
// using was updated" toast after a remote lib edit).
|
||||||
function("kicadLibsSymbolUsage", &schLibsSymbolUsage);
|
function("kicadLibsSymbolUsage", &schLibsSymbolUsage);
|
||||||
|
|
|
||||||
|
|
@ -596,6 +596,9 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
|
||||||
function("kicadCollabTestClearSelection", &collabTestClearSelection);
|
function("kicadCollabTestClearSelection", &collabTestClearSelection);
|
||||||
// Library reload after a remote (synced) lib edit — r2-idb-sync realtime.
|
// Library reload after a remote (synced) lib edit — r2-idb-sync realtime.
|
||||||
function("kicadLibsReload", &pcbjam_libs::reloadLibrary PCBJAM_PARKER_POLICY);
|
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 —
|
// Placed-instance count for a library symbol (schematic sessions only —
|
||||||
// 0 from any other frame; drives the "symbol you are using was updated"
|
// 0 from any other frame; drives the "symbol you are using was updated"
|
||||||
// toast after a remote lib edit).
|
// toast after a remote lib edit).
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
#include <kiway.h>
|
#include <kiway.h>
|
||||||
#include <kiway_player.h>
|
#include <kiway_player.h>
|
||||||
#include <libraries/library_manager.h>
|
#include <libraries/library_manager.h>
|
||||||
|
#include <libraries/library_table.h>
|
||||||
#include <mail_type.h>
|
#include <mail_type.h>
|
||||||
#include <pgm_base.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
|
} // namespace pcbjam_libs
|
||||||
|
|
||||||
#endif // __EMSCRIPTEN__
|
#endif // __EMSCRIPTEN__
|
||||||
|
|
|
||||||
|
|
@ -2451,6 +2451,9 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
||||||
function("kicadCollabTestClearSelection", &pcbCollabTestClearSelection);
|
function("kicadCollabTestClearSelection", &pcbCollabTestClearSelection);
|
||||||
// Library reload after a remote (synced) lib edit — r2-idb-sync realtime.
|
// Library reload after a remote (synced) lib edit — r2-idb-sync realtime.
|
||||||
function("kicadLibsReload", &pcbjam_libs::reloadLibrary PCBJAM_PARKER_POLICY);
|
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 // !KICAD_MERGED_EMBIND
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 0230fde538a4b6f2bf07b2e7856c3f8d440205fa
|
Subproject commit b2af70c0001c52aaa8a408cc3c9ed63d3eeb4806
|
||||||
|
|
@ -48,13 +48,22 @@ import {
|
||||||
LIB_ERROR_EVENT,
|
LIB_ERROR_EVENT,
|
||||||
LIB_ITEM_UPDATED_EVENT,
|
LIB_ITEM_UPDATED_EVENT,
|
||||||
LIB_LOADING_EVENT,
|
LIB_LOADING_EVENT,
|
||||||
|
LIB_SET_CHANGED_EVENT,
|
||||||
type LibBusyDetail,
|
type LibBusyDetail,
|
||||||
type LibErrorDetail,
|
type LibErrorDetail,
|
||||||
type LibItemUpdatedDetail,
|
type LibItemUpdatedDetail,
|
||||||
type LibLoadingDetail,
|
type LibLoadingDetail,
|
||||||
|
type LibSetChangedDetail,
|
||||||
type LibsSource,
|
type LibsSource,
|
||||||
type LibsSyncState,
|
type LibsSyncState,
|
||||||
} from "@/wasm/libs/source";
|
} 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 {
|
import {
|
||||||
MODELS_LOADING_EVENT,
|
MODELS_LOADING_EVENT,
|
||||||
type ModelsLoadingDetail,
|
type ModelsLoadingDetail,
|
||||||
|
|
@ -69,7 +78,7 @@ import {
|
||||||
import { resolveSheetHierarchy } from "@/wasm/collab/sheet-hierarchy";
|
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 SaveBlock, type SaveBytes } from "@/wasm/save-flow";
|
||||||
import type {
|
import type {
|
||||||
KicadCollabHandle,
|
KicadCollabHandle,
|
||||||
KicadDocSession,
|
KicadDocSession,
|
||||||
|
|
@ -1110,6 +1119,24 @@ export function WasmTool({
|
||||||
// The backend rolled this document back to its last valid state
|
// The backend rolled this document back to its last valid state
|
||||||
// (kicad-validity 0001 — DOC_REVERTED_EVENT from the collab binding).
|
// (kicad-validity 0001 — DOC_REVERTED_EVENT from the collab binding).
|
||||||
const [docReverted, setDocReverted] = React.useState<string | null>(null);
|
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
|
// 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
|
// 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
|
// "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 onItemUpdated = (e: Event) => {
|
||||||
const d = (e as CustomEvent<LibItemUpdatedDetail>).detail;
|
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
|
// Only warn when the update touches something PLACED here — the library
|
||||||
// tree already reflects updates to everything else.
|
// tree already reflects updates to everything else.
|
||||||
if (d.usedNames.length === 0) return;
|
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.`,
|
`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_BUSY_EVENT, onBusy);
|
||||||
window.addEventListener(LIB_ERROR_EVENT, onError);
|
window.addEventListener(LIB_ERROR_EVENT, onError);
|
||||||
window.addEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
|
window.addEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
|
||||||
|
window.addEventListener(LIB_SET_CHANGED_EVENT, onLibSet);
|
||||||
window.addEventListener(DOC_REVERTED_EVENT, onDocReverted);
|
window.addEventListener(DOC_REVERTED_EVENT, onDocReverted);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(busyTimer);
|
clearTimeout(busyTimer);
|
||||||
window.removeEventListener(LIB_BUSY_EVENT, onBusy);
|
window.removeEventListener(LIB_BUSY_EVENT, onBusy);
|
||||||
window.removeEventListener(LIB_ERROR_EVENT, onError);
|
window.removeEventListener(LIB_ERROR_EVENT, onError);
|
||||||
window.removeEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
|
window.removeEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
|
||||||
|
window.removeEventListener(LIB_SET_CHANGED_EVENT, onLibSet);
|
||||||
window.removeEventListener(DOC_REVERTED_EVENT, onDocReverted);
|
window.removeEventListener(DOC_REVERTED_EVENT, onDocReverted);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -1306,6 +1361,13 @@ export function WasmTool({
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [libUpdate]);
|
}, [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).
|
// Auto-dismiss the doc-reverted toast (longest — the user should see it).
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!docReverted) return;
|
if (!docReverted) return;
|
||||||
|
|
@ -1679,6 +1741,7 @@ export function WasmTool({
|
||||||
boot ? { libs: boot.libs, stacks: boot.stacks } : undefined,
|
boot ? { libs: boot.libs, stacks: boot.stacks } : undefined,
|
||||||
);
|
);
|
||||||
if (libsSource === undefined) ownedLibsSource = source;
|
if (libsSource === undefined) ownedLibsSource = source;
|
||||||
|
activeLibsSourceRef.current = source;
|
||||||
// Download-consent gate (standalone-load-ux 0001): before pulling the
|
// Download-consent gate (standalone-load-ux 0001): before pulling the
|
||||||
// (large) cold wasm + lib bundles, say how many MB and wait for the OK.
|
// (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
|
// Runs only on versioned CDN deploys (`meta.ver` — flat dev roots and
|
||||||
|
|
@ -1870,6 +1933,9 @@ export function WasmTool({
|
||||||
...(readOnly
|
...(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
|
// 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.
|
// page-load file list can't contain — warm its collab room so it stays in sync.
|
||||||
onSaved: (relPath: string) => {
|
onSaved: (relPath: string) => {
|
||||||
|
|
@ -2625,10 +2691,26 @@ export function WasmTool({
|
||||||
</div>
|
</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. */}
|
{/* Library error (e.g. a backend 404 on open) — auto-dismisses. */}
|
||||||
{libError && (
|
{libError && (
|
||||||
<button
|
<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)}
|
onClick={() => setLibError(null)}
|
||||||
title="Dismiss"
|
title="Dismiss"
|
||||||
>
|
>
|
||||||
|
|
@ -2640,7 +2722,7 @@ export function WasmTool({
|
||||||
{libUpdate && (
|
{libUpdate && (
|
||||||
<button
|
<button
|
||||||
data-testid="lib-update-toast"
|
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)}
|
onClick={() => setLibUpdate(null)}
|
||||||
title="Dismiss"
|
title="Dismiss"
|
||||||
>
|
>
|
||||||
|
|
@ -2648,11 +2730,47 @@ export function WasmTool({
|
||||||
</button>
|
</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). */}
|
{/* Backend rolled this doc back to the last valid state (kicad-validity). */}
|
||||||
{docReverted && (
|
{docReverted && (
|
||||||
<button
|
<button
|
||||||
data-testid="doc-reverted-toast"
|
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)}
|
onClick={() => setDocReverted(null)}
|
||||||
title="Dismiss"
|
title="Dismiss"
|
||||||
>
|
>
|
||||||
|
|
@ -2660,6 +2778,8 @@ export function WasmTool({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</WasmErrorBoundary>
|
</WasmErrorBoundary>
|
||||||
|
|
||||||
{/* Terminal failure — z-35, ABOVE the boot overlay but below the console
|
{/* Terminal failure — z-35, ABOVE the boot overlay but below the console
|
||||||
|
|
|
||||||
69
web/standalone/src/wasm/libs/runtime-add.ts
Normal file
69
web/standalone/src/wasm/libs/runtime-add.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -203,6 +203,13 @@ export const LIB_LOADING_EVENT = "pcbjam:lib-loading";
|
||||||
* updated" (placed copies keep the previous version until updated explicitly).
|
* updated" (placed copies keep the previous version until updated explicitly).
|
||||||
*/
|
*/
|
||||||
export const LIB_ITEM_UPDATED_EVENT = "pcbjam:lib-item-updated";
|
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 {
|
export interface LibBusyDetail {
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
|
@ -230,6 +237,11 @@ export interface LibItemUpdatedDetail {
|
||||||
/** The subset placed in the open document (empty ⇒ informational only). */
|
/** The subset placed in the open document (empty ⇒ informational only). */
|
||||||
usedNames: string[];
|
usedNames: string[];
|
||||||
}
|
}
|
||||||
|
export interface LibSetChangedDetail {
|
||||||
|
op: "add" | "remove" | "update";
|
||||||
|
libId: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function emitLibBusy(detail: LibBusyDetail): void {
|
function emitLibBusy(detail: LibBusyDetail): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
USER_HEADER,
|
USER_HEADER,
|
||||||
} from "@pcbjam/shared";
|
} from "@pcbjam/shared";
|
||||||
import {
|
import {
|
||||||
|
onSyncRoomFrame,
|
||||||
peekNamespaces,
|
peekNamespaces,
|
||||||
SyncRoomMovedError,
|
SyncRoomMovedError,
|
||||||
SyncStack,
|
SyncStack,
|
||||||
|
|
@ -16,9 +17,11 @@ import {
|
||||||
} from "@pcbjam/sync-client";
|
} from "@pcbjam/sync-client";
|
||||||
import {
|
import {
|
||||||
LIB_ITEM_UPDATED_EVENT,
|
LIB_ITEM_UPDATED_EVENT,
|
||||||
|
LIB_SET_CHANGED_EVENT,
|
||||||
type LibInfo,
|
type LibInfo,
|
||||||
type LibItemInfo,
|
type LibItemInfo,
|
||||||
type LibItemUpdatedDetail,
|
type LibItemUpdatedDetail,
|
||||||
|
type LibSetChangedDetail,
|
||||||
type LibsSource,
|
type LibsSource,
|
||||||
type LibsSyncState,
|
type LibsSyncState,
|
||||||
} from "./source";
|
} from "./source";
|
||||||
|
|
@ -372,6 +375,23 @@ export function syncedScopeLibsSource(
|
||||||
},
|
},
|
||||||
): LibsSource {
|
): LibsSource {
|
||||||
const perLib = new Map<string, 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
|
// Stacks resolved in bulk by `prefetchStacks`. A hit means the per-lib source
|
||||||
// makes NO resolve request; `null` records "backend says unresolvable" so a
|
// 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
|
// stale pin isn't retried one-by-one. Misses simply fall back per-lib, which
|
||||||
|
|
@ -539,6 +559,7 @@ export function syncedScopeLibsSource(
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
offRoomFrames();
|
||||||
for (const src of perLib.values()) src.dispose?.();
|
for (const src of perLib.values()) src.dispose?.();
|
||||||
perLib.clear();
|
perLib.clear();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit ae28fab038ed54e3fb65fb7d4bc0dc7e123ae99f
|
Subproject commit 304ee266b4437a8b9c8bd49704f1b0a9006c54dc
|
||||||
Loading…
Reference in a new issue