kicad-validity B3+C+S: revert toast, corpus lint, .kicad_mod tier
B3 (editor): kicad-binding observes kdoc_meta.revertNonce (deduped, like seedNonce) → DOC_REVERTED_EVENT → WasmTool doc-reverted-toast (15 s, data-testid for the deferred e2e check). C: tests/tools/corpus-lint.ts (npm run corpus:lint) — kicad_tools --lint over the fixture corpora raw + shared-codec round-trips (docToFile(fileToDoc(x))); the E3/wrapInBoardEnvelope-class gate. Skips when output/kicad_tools.js isn't built; CI step lands when kicad_tools joins the CI app set. 7 fixtures / 7 round-trips green. S: --lint + --resave gain the .kicad_mod tier (pcbToolsLintFootprint / pcbToolsResaveFootprint; writer needs CTL_FOR_LIBRARY or the board flags strip the (version) header). Verified: qa footprint round-trip lints clean, resave stamps 20260206, garbage exits 1. Also bumps pcbjam-shared (mergeYUpdates for the bisect pass). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HLua64PCVwkQ1hpWdaf1Gm
This commit is contained in:
parent
b18b0a6a08
commit
fb2aa2759d
8 changed files with 323 additions and 7 deletions
|
|
@ -44,6 +44,7 @@
|
||||||
"test:asyncify:chrome": "playwright test --config=playwright-asyncify.config.ts --project=chromium --headed",
|
"test:asyncify:chrome": "playwright test --config=playwright-asyncify.config.ts --project=chromium --headed",
|
||||||
"test:asyncify:safari": "playwright test --config=playwright-asyncify.config.ts --project=webkit",
|
"test:asyncify:safari": "playwright test --config=playwright-asyncify.config.ts --project=webkit",
|
||||||
"test:asyncify:all": "npm run test:asyncify:firefox && npm run test:asyncify:safari && npm run test:asyncify:chrome",
|
"test:asyncify:all": "npm run test:asyncify:firefox && npm run test:asyncify:safari && npm run test:asyncify:chrome",
|
||||||
|
"corpus:lint": "tsx tools/corpus-lint.ts",
|
||||||
"lint:determinism": "tsx tools/lint-determinism.ts",
|
"lint:determinism": "tsx tools/lint-determinism.ts",
|
||||||
"screenshots:check": "tsx tools/screenshots/compare.ts",
|
"screenshots:check": "tsx tools/screenshots/compare.ts",
|
||||||
"screenshots:promote": "tsx tools/screenshots/promote.ts",
|
"screenshots:promote": "tsx tools/screenshots/promote.ts",
|
||||||
|
|
|
||||||
115
tests/tools/corpus-lint.ts
Normal file
115
tests/tools/corpus-lint.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* Corpus lint (kicad-validity 0001 C) — the E3 gate: our own s-expr PRODUCERS
|
||||||
|
* must agree with KiCad's parser. Runs `kicad_tools --lint` over:
|
||||||
|
*
|
||||||
|
* 1. the fixture corpus RAW (tests/fixtures + pcbjam-shared/test/fixtures)
|
||||||
|
* — guards fixtures themselves going stale vs the current parser;
|
||||||
|
* 2. every fixture ROUND-TRIPPED through the shared codec
|
||||||
|
* (docToFile(fileToDoc(text))) — the exact writer path the backend's
|
||||||
|
* ydoc materialization uses. A codec output KiCad rejects is the
|
||||||
|
* wrapInBoardEnvelope class of bug (pcbjam f07b997), caught pre-merge.
|
||||||
|
*
|
||||||
|
* Needs pcbjam/output/kicad_tools.js (docker/build.sh kicad_tools). When the
|
||||||
|
* artifact is absent the script SKIPS with exit 0 so it can sit in any CI leg;
|
||||||
|
* it becomes a hard gate once kicad_tools joins the CI build set (follow-up in
|
||||||
|
* docs/features/kicad-validity/0001 §5).
|
||||||
|
*
|
||||||
|
* Run: cd tests && npm run corpus:lint
|
||||||
|
*/
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { docToFile, fileToDoc } from "../../web/pcbjam-shared/src/index.js";
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repo = path.resolve(here, "../..");
|
||||||
|
const cli = path.join(repo, "output/kicad_tools.js");
|
||||||
|
|
||||||
|
if (!existsSync(cli)) {
|
||||||
|
console.log("corpus-lint: SKIP — output/kicad_tools.js not built");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORPORA = [
|
||||||
|
path.join(repo, "tests/fixtures"),
|
||||||
|
path.join(repo, "web/pcbjam-shared/test/fixtures"),
|
||||||
|
];
|
||||||
|
const LINTABLE = /\.(kicad_pcb|kicad_sch|kicad_sym|kicad_mod)$/;
|
||||||
|
// The codec round-trips the doc formats it materializes (not symbol libs).
|
||||||
|
const CODEC = /\.(kicad_pcb|kicad_sch)$/;
|
||||||
|
|
||||||
|
function collect(dir: string): string[] {
|
||||||
|
if (!existsSync(dir)) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) {
|
||||||
|
const p = path.join(entry.parentPath ?? (entry as { path: string }).path, entry.name);
|
||||||
|
if (entry.isFile() && LINTABLE.test(entry.name)) out.push(p);
|
||||||
|
}
|
||||||
|
return out.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lint(file: string): { ok: boolean; detail: string } {
|
||||||
|
try {
|
||||||
|
execFileSync("node", [cli, "--lint", file], { stdio: ["ignore", "ignore", "pipe"] });
|
||||||
|
return { ok: true, detail: "" };
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as { status?: number; stderr?: Buffer };
|
||||||
|
return { ok: false, detail: err.stderr?.toString().trim() ?? String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = CORPORA.flatMap(collect);
|
||||||
|
if (!files.length) {
|
||||||
|
console.error("corpus-lint: no fixtures found — corpus roots moved?");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmp = mkdtempSync(path.join(tmpdir(), "corpus-lint-"));
|
||||||
|
let failures = 0;
|
||||||
|
let roundTripped = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const raw = lint(file);
|
||||||
|
if (!raw.ok) {
|
||||||
|
failures++;
|
||||||
|
console.error(`RAW FAIL ${path.relative(repo, file)}\n${raw.detail}`);
|
||||||
|
continue; // an invalid source can't meaningfully round-trip
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CODEC.test(file)) continue;
|
||||||
|
|
||||||
|
// Hierarchical sub-sheets don't parse standalone through the codec the
|
||||||
|
// same way; fileToDoc throwing marks "codec can't represent it" — that is
|
||||||
|
// a finding too, but a soft one: report, don't fail (multi-file sheets
|
||||||
|
// are exercised by the shared vitest suite with full projects).
|
||||||
|
let out: string;
|
||||||
|
try {
|
||||||
|
out = docToFile(fileToDoc(readFileSync(file, "utf8")));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`codec skip ${path.relative(repo, file)}: ${e instanceof Error ? e.message : e}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rt = path.join(tmp, `rt-${roundTripped++}${path.extname(file)}`);
|
||||||
|
writeFileSync(rt, out, "utf8");
|
||||||
|
const verdict = lint(rt);
|
||||||
|
if (!verdict.ok) {
|
||||||
|
failures++;
|
||||||
|
console.error(
|
||||||
|
`ROUND-TRIP FAIL ${path.relative(repo, file)} — codec output rejected by KiCad:\n${verdict.detail}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`corpus-lint: ${files.length} fixture(s), ${roundTripped} round-trip(s), ${failures} failure(s)`,
|
||||||
|
);
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
|
@ -14,9 +14,9 @@
|
||||||
* eeschema side (sym_convert_main.cpp) — everything else:
|
* eeschema side (sym_convert_main.cpp) — everything else:
|
||||||
* kicad_tools --convert-lib <input.lib> <output.kicad_sym>
|
* kicad_tools --convert-lib <input.lib> <output.kicad_sym>
|
||||||
* kicad_tools --lint [--strict] <file> [<file>...]
|
* kicad_tools --lint [--strict] <file> [<file>...]
|
||||||
* (.kicad_pcb files get a FULL parse here — the pcbnew
|
* (.kicad_pcb/.kicad_mod get a FULL parse here — the
|
||||||
* parser is linked; the lint driver calls back into
|
* pcbnew parser is linked; the lint driver calls back
|
||||||
* pcbToolsLintBoard on the pcb side)
|
* into pcbToolsLintBoard/pcbToolsLintFootprint)
|
||||||
* kicad_tools --resave <file> <outdir>
|
* kicad_tools --resave <file> <outdir>
|
||||||
* (full parse + rewrite in the current file-format
|
* (full parse + rewrite in the current file-format
|
||||||
* version — kicad-validity 0001; .kicad_pcb via the
|
* version — kicad-validity 0001; .kicad_pcb via the
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,11 @@
|
||||||
#include <layer_ids.h>
|
#include <layer_ids.h>
|
||||||
#include <lset.h>
|
#include <lset.h>
|
||||||
#include <libraries/library_manager.h>
|
#include <libraries/library_manager.h>
|
||||||
|
#include <footprint.h>
|
||||||
|
#include <pcb_io/kicad_sexpr/pcb_io_kicad_sexpr.h>
|
||||||
#include <pcb_io/pcb_io_mgr.h>
|
#include <pcb_io/pcb_io_mgr.h>
|
||||||
#include <pcb_marker.h>
|
#include <pcb_marker.h>
|
||||||
|
#include <richio.h>
|
||||||
#include <pcb_plotter.h>
|
#include <pcb_plotter.h>
|
||||||
#include <pcbplot.h>
|
#include <pcbplot.h>
|
||||||
#include <pgm_base.h>
|
#include <pgm_base.h>
|
||||||
|
|
@ -681,6 +684,120 @@ int pcbToolsLintBoard( const char* aInPath, std::string& aError )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Full-parse footprint lint for the merged image's --lint driver
|
||||||
|
// (kicad-validity 0001 S): parse one .kicad_mod via the s-expr plugin's
|
||||||
|
// ImportFootprint. Returns the pad count, or -1 with aError filled.
|
||||||
|
int pcbToolsLintFootprint( const char* aInPath, std::string& aError )
|
||||||
|
{
|
||||||
|
wxFileName fn( wxString::FromUTF8( aInPath ) );
|
||||||
|
fn.MakeAbsolute();
|
||||||
|
|
||||||
|
kiRuntime();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PCB_IO_KICAD_SEXPR plugin;
|
||||||
|
wxString name;
|
||||||
|
std::unique_ptr<FOOTPRINT> fp( plugin.ImportFootprint( fn.GetFullPath(), name ) );
|
||||||
|
|
||||||
|
if( !fp )
|
||||||
|
{
|
||||||
|
aError = std::string( aInPath ) + ": error: not a footprint file";
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) fp->Pads().size();
|
||||||
|
}
|
||||||
|
catch( PARSE_ERROR& pe )
|
||||||
|
{
|
||||||
|
char buf[1024];
|
||||||
|
std::snprintf( buf, sizeof( buf ), "%s:%d:%d: error: %s", aInPath, pe.lineNumber,
|
||||||
|
pe.byteIndex, (const char*) pe.ParseProblem().ToUTF8() );
|
||||||
|
aError = buf;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
catch( const IO_ERROR& ioe )
|
||||||
|
{
|
||||||
|
aError = std::string( aInPath ) + ": error: " + std::string( ioe.Problem().ToUTF8() );
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
catch( const std::exception& e )
|
||||||
|
{
|
||||||
|
aError = std::string( aInPath ) + ": error: " + e.what();
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Footprint resave for --resave (kicad-validity 0001 S): parse + rewrite one
|
||||||
|
// .kicad_mod in the current format, the exact FP_CACHE::Save incantation
|
||||||
|
// (PRETTIFIED formatter + plugin Format). 0 / 4 load failed / 5 write failed.
|
||||||
|
int pcbToolsResaveFootprint( const char* aInPath, const char* aOutPath, std::string& aError )
|
||||||
|
{
|
||||||
|
wxFileName fn( wxString::FromUTF8( aInPath ) );
|
||||||
|
fn.MakeAbsolute();
|
||||||
|
|
||||||
|
kiRuntime();
|
||||||
|
|
||||||
|
std::unique_ptr<FOOTPRINT> fp;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PCB_IO_KICAD_SEXPR plugin;
|
||||||
|
wxString name;
|
||||||
|
fp.reset( plugin.ImportFootprint( fn.GetFullPath(), name ) );
|
||||||
|
|
||||||
|
if( !fp )
|
||||||
|
{
|
||||||
|
aError = std::string( aInPath ) + ": error: not a footprint file";
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch( PARSE_ERROR& pe )
|
||||||
|
{
|
||||||
|
char buf[1024];
|
||||||
|
std::snprintf( buf, sizeof( buf ), "%s:%d:%d: error: %s", aInPath, pe.lineNumber,
|
||||||
|
pe.byteIndex, (const char*) pe.ParseProblem().ToUTF8() );
|
||||||
|
aError = buf;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
catch( const IO_ERROR& ioe )
|
||||||
|
{
|
||||||
|
aError = std::string( aInPath ) + ": error: " + std::string( ioe.Problem().ToUTF8() );
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
catch( const std::exception& e )
|
||||||
|
{
|
||||||
|
aError = std::string( aInPath ) + ": error: " + e.what();
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// CTL_FOR_LIBRARY, NOT the default CTL_FOR_BOARD — the board flags
|
||||||
|
// include CTL_OMIT_FOOTPRINT_VERSION, which would strip the (version)
|
||||||
|
// header a standalone .kicad_mod must carry (FootprintSave sets the
|
||||||
|
// same flags before formatting).
|
||||||
|
PCB_IO_KICAD_SEXPR writer( CTL_FOR_LIBRARY );
|
||||||
|
PRETTIFIED_FILE_OUTPUTFORMATTER formatter( wxString::FromUTF8( aOutPath ) );
|
||||||
|
writer.SetOutputFormatter( &formatter );
|
||||||
|
writer.Format( fp.get() );
|
||||||
|
}
|
||||||
|
catch( const IO_ERROR& ioe )
|
||||||
|
{
|
||||||
|
aError = std::string( aOutPath ) + ": error: " + std::string( ioe.Problem().ToUTF8() );
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
catch( const std::exception& e )
|
||||||
|
{
|
||||||
|
aError = std::string( aOutPath ) + ": error: " + e.what();
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Full-parse board resave for the merged image's --resave driver (also on the
|
// Full-parse board resave for the merged image's --resave driver (also on the
|
||||||
// eeschema side): a bare load like pcbToolsLintBoard, then the s-expr writer
|
// eeschema side): a bare load like pcbToolsLintBoard, then the s-expr writer
|
||||||
// rewrites the board in the current format version. Zones are written
|
// rewrites the board in the current format version. Zones are written
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,10 @@ int pcbToolsLintBoard( const char* aInPath, std::string& aError );
|
||||||
// Merged image only: board load + s-expr rewrite for --resave (kicad-validity
|
// Merged image only: board load + s-expr rewrite for --resave (kicad-validity
|
||||||
// 0001). 0 resaved / 4 load failed / 5 write failed.
|
// 0001). 0 resaved / 4 load failed / 5 write failed.
|
||||||
int pcbToolsResaveBoard( const char* aInPath, const char* aOutPath, std::string& aError );
|
int pcbToolsResaveBoard( const char* aInPath, const char* aOutPath, std::string& aError );
|
||||||
|
// Merged image only: .kicad_mod full parse (returns pad count or -1) and
|
||||||
|
// resave (0/4/5) — kicad-validity 0001 S.
|
||||||
|
int pcbToolsLintFootprint( const char* aInPath, std::string& aError );
|
||||||
|
int pcbToolsResaveFootprint( const char* aInPath, const char* aOutPath, std::string& aError );
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include <connection_graph.h>
|
#include <connection_graph.h>
|
||||||
|
|
@ -539,6 +543,15 @@ bool lintOneFile( const char* aPath, bool aStrict )
|
||||||
else
|
else
|
||||||
tier = "full parse";
|
tier = "full parse";
|
||||||
}
|
}
|
||||||
|
else if( ext == wxS( "kicad_mod" ) )
|
||||||
|
{
|
||||||
|
std::string fpError;
|
||||||
|
|
||||||
|
if( pcbToolsLintFootprint( aPath, fpError ) < 0 )
|
||||||
|
report.errors.push_back( fpError );
|
||||||
|
else
|
||||||
|
tier = "full parse";
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
// Anything else (kicad_wks, kicad_pro …— and kicad_pcb outside
|
// Anything else (kicad_wks, kicad_pro …— and kicad_pcb outside
|
||||||
// the merged kicad_tools image) stays structure-only.
|
// the merged kicad_tools image) stays structure-only.
|
||||||
|
|
@ -813,12 +826,14 @@ int runResave( const char* aInPath, const char* aOutDir )
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if( ext == wxS( "kicad_pcb" ) )
|
if( ext == wxS( "kicad_pcb" ) || ext == wxS( "kicad_mod" ) )
|
||||||
{
|
{
|
||||||
#ifdef KICAD_TOOLS_COMBINED
|
#ifdef KICAD_TOOLS_COMBINED
|
||||||
const wxString outPath = outDir + inFn.GetFullName();
|
const wxString outPath = outDir + inFn.GetFullName();
|
||||||
std::string error;
|
std::string error;
|
||||||
const int rc = pcbToolsResaveBoard( aInPath, (const char*) outPath.ToUTF8(), error );
|
const int rc = ext == wxS( "kicad_pcb" )
|
||||||
|
? pcbToolsResaveBoard( aInPath, (const char*) outPath.ToUTF8(), error )
|
||||||
|
: pcbToolsResaveFootprint( aInPath, (const char*) outPath.ToUTF8(), error );
|
||||||
|
|
||||||
if( rc != 0 )
|
if( rc != 0 )
|
||||||
std::fprintf( stderr, "%s\n", error.c_str() );
|
std::fprintf( stderr, "%s\n", error.c_str() );
|
||||||
|
|
@ -828,7 +843,8 @@ int runResave( const char* aInPath, const char* aOutDir )
|
||||||
|
|
||||||
return rc;
|
return rc;
|
||||||
#else
|
#else
|
||||||
std::fprintf( stderr, "%s: error: board resave requires the merged kicad_tools build\n",
|
std::fprintf( stderr, "%s: error: board/footprint resave requires the merged "
|
||||||
|
"kicad_tools build\n",
|
||||||
aInPath );
|
aInPath );
|
||||||
return 2;
|
return 2;
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 3c04b71a6008a7071ce7b58535cf0e35dba7d110
|
Subproject commit ed67f0f454a43c672fab8460b2d88b5a1a1ef6f9
|
||||||
|
|
@ -67,6 +67,7 @@ import {
|
||||||
type FollowTarget,
|
type FollowTarget,
|
||||||
} from "@/wasm/collab/follow-user";
|
} from "@/wasm/collab/follow-user";
|
||||||
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
|
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
|
||||||
|
import { DOC_REVERTED_EVENT } from "@/wasm/collab/kicad-binding";
|
||||||
import {
|
import {
|
||||||
createComments,
|
createComments,
|
||||||
hasCommentsBridge,
|
hasCommentsBridge,
|
||||||
|
|
@ -790,6 +791,9 @@ export function WasmTool({
|
||||||
// A collaborator updated library items that are PLACED in the open document
|
// A collaborator updated library items that are PLACED in the open document
|
||||||
// (LIB_ITEM_UPDATED_EVENT) — placed copies keep the previous version, so warn.
|
// (LIB_ITEM_UPDATED_EVENT) — placed copies keep the previous version, so warn.
|
||||||
const [libUpdate, setLibUpdate] = React.useState<string | null>(null);
|
const [libUpdate, setLibUpdate] = React.useState<string | null>(null);
|
||||||
|
// 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);
|
||||||
// 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
|
||||||
|
|
@ -856,14 +860,23 @@ export function WasmTool({
|
||||||
`placed copies keep the previous version until updated from the library.`,
|
`placed copies keep the previous version until updated from the library.`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const onDocReverted = (e: Event) => {
|
||||||
|
const d = (e as CustomEvent<{ reason?: string; at?: string }>).detail;
|
||||||
|
setDocReverted(
|
||||||
|
`This document was rolled back to its last valid state — invalid content ` +
|
||||||
|
`was detected${d?.reason ? ` (${d.reason})` : ""}. Recent edits may have been undone.`,
|
||||||
|
);
|
||||||
|
};
|
||||||
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(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(DOC_REVERTED_EVENT, onDocReverted);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -881,6 +894,13 @@ export function WasmTool({
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [libUpdate]);
|
}, [libUpdate]);
|
||||||
|
|
||||||
|
// Auto-dismiss the doc-reverted toast (longest — the user should see it).
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!docReverted) return;
|
||||||
|
const t = setTimeout(() => setDocReverted(null), 15_000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [docReverted]);
|
||||||
|
|
||||||
// Full-library eager load overlay. The fat-load fires one loading:true/false
|
// Full-library eager load overlay. The fat-load fires one loading:true/false
|
||||||
// pair PER library (222 on the full set), and between them the C++ side parses
|
// pair PER library (222 on the full set), and between them the C++ side parses
|
||||||
// with the main thread blocked. Show immediately on `true`, and only hide after
|
// with the main thread blocked. Show immediately on `true`, and only hide after
|
||||||
|
|
@ -1647,6 +1667,18 @@ export function WasmTool({
|
||||||
</button>
|
</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"
|
||||||
|
onClick={() => setDocReverted(null)}
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
{docReverted}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{!effectiveChromeHidden && (
|
{!effectiveChromeHidden && (
|
||||||
<div className="absolute bottom-0 left-0 right-0 z-20">
|
<div className="absolute bottom-0 left-0 right-0 z-20">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import {
|
||||||
wireItemUuids,
|
wireItemUuids,
|
||||||
wireLibSymbols,
|
wireLibSymbols,
|
||||||
Y_KDOC_META,
|
Y_KDOC_META,
|
||||||
|
Y_KDOC_REVERT_AT,
|
||||||
|
Y_KDOC_REVERT_NONCE,
|
||||||
|
Y_KDOC_REVERT_REASON,
|
||||||
Y_KDOC_SEED_NONCE,
|
Y_KDOC_SEED_NONCE,
|
||||||
ydocHasState,
|
ydocHasState,
|
||||||
ydocSexprVersion,
|
ydocSexprVersion,
|
||||||
|
|
@ -44,6 +47,12 @@ import { clog, cwarn } from "./debug";
|
||||||
* fake editor bridge (kicad-binding.test.ts).
|
* fake editor bridge (kicad-binding.test.ts).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Window event fired when the backend rolled this doc back to its last valid
|
||||||
|
* state (kicad-validity 0001 B3). detail: { reason?: string; at?: string }.
|
||||||
|
*/
|
||||||
|
export const DOC_REVERTED_EVENT = "pcbjam:doc-reverted";
|
||||||
|
|
||||||
/** The v2 per-item s-expr bridge (Stage C C++ contract), runtime-adapted. */
|
/** The v2 per-item s-expr bridge (Stage C C++ contract), runtime-adapted. */
|
||||||
export interface KicadItemsBridge {
|
export interface KicadItemsBridge {
|
||||||
/** Full current model as an all-`added` ItemsWireDelta JSON. */
|
/** Full current model as an all-`added` ItemsWireDelta JSON. */
|
||||||
|
|
@ -192,6 +201,31 @@ export function bindKicadCollab(
|
||||||
};
|
};
|
||||||
items.observeDeep(observer);
|
items.observeDeep(observer);
|
||||||
|
|
||||||
|
// Validity-revert notice (kicad-validity 0001 B3): the backend stamps
|
||||||
|
// kdoc_meta.revertNonce when it rolls the doc back to the last valid state
|
||||||
|
// (the content itself arrives through the normal item sync above). Watched
|
||||||
|
// like seedNonce; surfaced as a window event for the shell's toast. The
|
||||||
|
// nonce is deduped so a reconnect replaying the same marker stays silent.
|
||||||
|
const revMeta = doc.getMap(Y_KDOC_META);
|
||||||
|
let lastRevertNonce = revMeta.get(Y_KDOC_REVERT_NONCE);
|
||||||
|
const onRevertMeta = () => {
|
||||||
|
const nonce = revMeta.get(Y_KDOC_REVERT_NONCE);
|
||||||
|
if (nonce === undefined || nonce === lastRevertNonce) return;
|
||||||
|
lastRevertNonce = nonce;
|
||||||
|
clog("doc reverted by backend:", revMeta.get(Y_KDOC_REVERT_REASON));
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(DOC_REVERTED_EVENT, {
|
||||||
|
detail: {
|
||||||
|
reason: revMeta.get(Y_KDOC_REVERT_REASON),
|
||||||
|
at: revMeta.get(Y_KDOC_REVERT_AT),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
revMeta.observe(onRevertMeta);
|
||||||
|
|
||||||
function seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void {
|
function seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void {
|
||||||
seeded = true; // open the UP gate; everything below runs synchronously
|
seeded = true; // open the UP gate; everything below runs synchronously
|
||||||
// `ydocHasState` (meta + layout + items), NOT `items.size`: a populated
|
// `ydocHasState` (meta + layout + items), NOT `items.size`: a populated
|
||||||
|
|
@ -344,6 +378,7 @@ export function bindKicadCollab(
|
||||||
detachSeedArbitration?.();
|
detachSeedArbitration?.();
|
||||||
detachSeedArbitration = undefined;
|
detachSeedArbitration = undefined;
|
||||||
items.unobserveDeep(observer);
|
items.unobserveDeep(observer);
|
||||||
|
revMeta.unobserve(onRevertMeta);
|
||||||
},
|
},
|
||||||
items,
|
items,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue