Skip to content

Commit 3f2d4ed

Browse files
authored
Merge pull request #282 from pathsim/fix/toolbox-block-resolution
Fix toolbox block resolution and surface installed versions
2 parents 745b663 + e561a43 commit 3f2d4ed

17 files changed

Lines changed: 211 additions & 43 deletions

src/lib/components/ConfirmationModal.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
font-size: var(--font-sm);
9696
color: var(--text-muted);
9797
line-height: 1.5;
98+
white-space: pre-wrap;
9899
}
99100
100101
.dialog-actions {

src/lib/components/dialogs/ToolboxManagerDialog.svelte

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
// Build artifact across the add-toolbox flow
5757
let resolvedSource = $state<ToolboxSource | null>(null);
5858
let resolvedImportPath = $state('');
59+
let resolvedInstalledVersion = $state<string | null>(null);
5960
let resolvedDisplayName = $state('');
6061
let resolvedEventsImportPath = $state<string | undefined>(undefined);
6162
let toolboxId = $state('');
@@ -97,6 +98,7 @@
9798
eventsImportPathInput = '';
9899
resolvedSource = null;
99100
resolvedImportPath = '';
101+
resolvedInstalledVersion = null;
100102
resolvedDisplayName = '';
101103
resolvedEventsImportPath = undefined;
102104
toolboxId = '';
@@ -200,6 +202,7 @@
200202
installMessage = describeInstall(resolvedSource);
201203
const result = await performInstall(resolvedSource, resolvedImportPath || undefined);
202204
resolvedImportPath = result.importPath;
205+
resolvedInstalledVersion = result.installedVersion;
203206
204207
installStatus = 'discovering';
205208
installMessage = `Inspecting ${resolvedImportPath}…`;
@@ -301,6 +304,7 @@
301304
source: resolvedSource,
302305
importPath: resolvedImportPath,
303306
eventsImportPath: resolvedEventsImportPath,
307+
installedVersion: resolvedInstalledVersion,
304308
blocks: blockSelections,
305309
events: eventSelections
306310
};
@@ -387,7 +391,10 @@
387391
{#each installed as t (t.id)}
388392
<div class="installed-row">
389393
<div class="installed-meta">
390-
<div class="installed-name">{t.displayName}</div>
394+
<div class="installed-name">
395+
{t.displayName}
396+
{#if t.installedVersion}<span class="installed-version">v{t.installedVersion}</span>{/if}
397+
</div>
391398
<div class="installed-source">
392399
{#if t.source.type === 'pypi'}
393400
pip · {t.source.pkg}{t.source.version ? `==${t.source.version}` : ''}
@@ -782,6 +789,14 @@
782789
color: var(--text-muted);
783790
}
784791
792+
.installed-version {
793+
margin-left: 6px;
794+
font-family: var(--font-mono);
795+
font-size: var(--font-sm);
796+
font-weight: 400;
797+
color: var(--text-disabled);
798+
}
799+
785800
.installed-source {
786801
font-size: var(--font-sm);
787802
color: var(--text-muted);

src/lib/components/nodes/BaseNode.svelte

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
import { onDestroy } from 'svelte';
33
import { Handle, Position, useUpdateNodeInternals } from '@xyflow/svelte';
4-
import { nodeRegistry, type NodeInstance } from '$lib/nodes';
4+
import { nodeRegistry, registryVersion, type NodeInstance } from '$lib/nodes';
55
import { getShapeCssClass, isSubsystem } from '$lib/nodes/shapes/index';
66
import { NODE_TYPES } from '$lib/constants/nodeTypes';
77
import { openNodeDialog } from '$lib/stores/nodeDialog';
@@ -33,8 +33,16 @@
3333
// Get SvelteFlow hook to trigger re-measurement when node size changes
3434
const updateNodeInternals = useUpdateNodeInternals();
3535
36-
// Get type definition
37-
const typeDef = $derived(nodeRegistry.get(data.type));
36+
// Get type definition. The registry isn't a reactive store on its own,
37+
// so we tick on registryVersion bumps (toolbox install/uninstall) and
38+
// re-read here. Without this, blocks loaded before their toolbox finished
39+
// bootstrapping would stay stuck rendering as (missing).
40+
let registryTick = $state(0);
41+
const unsubRegistry = registryVersion.subscribe((v) => (registryTick = v));
42+
const typeDef = $derived.by(() => {
43+
registryTick; // dependency: re-read whenever the registry version bumps
44+
return nodeRegistry.get(data.type);
45+
});
3846
const category = $derived(typeDef?.category || 'Algebraic');
3947
4048
// Get valid pinned params (filter out any that no longer exist in the type definition)
@@ -109,6 +117,7 @@
109117
});
110118
111119
onDestroy(() => {
120+
unsubRegistry();
112121
unsubscribePinned();
113122
unsubscribePlotData();
114123
unsubscribePortLabels();
@@ -333,7 +342,13 @@
333342
const shapeClass = $derived(() => typeDef ? getShapeCssClass(typeDef) : 'shape-default');
334343
335344
// Custom node color (defaults to pathsim-blue)
336-
const nodeColor = $derived(data.color || 'var(--accent)');
345+
// Missing blocks (type not registered) override any custom color so the
346+
// whole block — name, handles, hover state — picks up the error red.
347+
const nodeColor = $derived(
348+
!typeDef && data.type !== NODE_TYPES.SUBSYSTEM && data.type !== NODE_TYPES.INTERFACE
349+
? 'var(--error)'
350+
: data.color || 'var(--accent)'
351+
);
337352
338353
// Handle pinned param change
339354
function handlePinnedParamChange(paramName: string, value: string) {
@@ -697,6 +712,9 @@
697712
font-size: 8px;
698713
color: var(--text-muted);
699714
margin-top: 2px;
715+
white-space: nowrap;
716+
overflow: hidden;
717+
text-overflow: ellipsis;
700718
}
701719
702720
.node-content.has-icon {
@@ -727,19 +745,28 @@
727745
}
728746
729747
.node-type.missing {
730-
color: var(--warning);
748+
color: var(--error);
749+
font-weight: 500;
731750
}
732751
733752
/* Visual marker for nodes whose block type isn't registered (e.g. file
734-
loaded with a toolbox dependency the user hasn't installed). */
753+
* loaded with a toolbox dependency the user hasn't installed). Same
754+
* shape as a normal block, just dressed in error red so it's obvious
755+
* something is wrong. */
735756
.node.missing-type {
736-
--node-color: var(--warning);
737-
opacity: 0.85;
757+
--node-color: var(--error);
758+
border-color: var(--error);
759+
background: var(--error-bg);
738760
}
739761
740-
.node.missing-type .node-content,
741-
.node.missing-type :global(.node-shape) {
742-
border-style: dashed;
762+
/* Port handles: paint the outer pentagon red so the missing block
763+
* carries its error state out to its connections. The inner cutout
764+
* picks up the red-tinted body so it visually merges with the block. */
765+
:global(.node.missing-type .svelte-flow__handle::before) {
766+
background: var(--error);
767+
}
768+
:global(.node.missing-type .svelte-flow__handle::after) {
769+
background: color-mix(in srgb, var(--error-bg) 60%, var(--surface-raised));
743770
}
744771
745772
/* Pinned parameters - rectangular, clipped by node-clip's overflow:hidden */

src/lib/nodes/defineNode.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ interface DefineNodeOptions {
1111
category: NodeCategory;
1212
description?: string;
1313
blockClass: string; // PathSim class name
14+
/** Python module to import `blockClass` from. Optional — built-ins
15+
* resolve via the static map in `blocks.ts`. Toolbox-registered
16+
* blocks pass their toolbox `importPath` here. */
17+
importPath?: string;
1418

1519
// Port configuration
1620
inputs?: string[]; // Named input ports
@@ -47,6 +51,7 @@ export function defineNode(options: DefineNodeOptions): NodeTypeDefinition {
4751
category,
4852
description = `${name} block`,
4953
blockClass,
54+
importPath,
5055
inputs = ['in 0'],
5156
outputs = ['out 0'],
5257
minInputs = 1,
@@ -75,6 +80,7 @@ export function defineNode(options: DefineNodeOptions): NodeTypeDefinition {
7580
category,
7681
description,
7782
blockClass,
83+
importPath,
7884
shape,
7985

8086
ports: {

src/lib/pyodide/pathsimRunner.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,10 @@ function collectBlockImportGroups(nodes: NodeInstance[]): Map<string, Set<string
163163
const typeDef = nodeRegistry.get(node.type);
164164
if (!typeDef) continue;
165165

166-
const importPath = blockImportPaths[typeDef.blockClass] || 'pathsim.blocks';
166+
// Toolbox-registered blocks carry their own importPath; built-ins
167+
// fall back to the static map. Last fallback is core pathsim.blocks.
168+
const importPath =
169+
typeDef.importPath ?? blockImportPaths[typeDef.blockClass] ?? 'pathsim.blocks';
167170
if (!groups.has(importPath)) groups.set(importPath, new Set());
168171
groups.get(importPath)!.add(typeDef.blockClass);
169172
}

src/lib/schema/fileOps.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
upsertToolbox,
3030
getCatalogEntry
3131
} from '$lib/toolbox';
32+
import { getCachedPathsimVersion } from '$lib/toolbox/pathsimVersion';
3233
import type { ToolboxRequirement } from '$lib/types/schema';
3334
import { requestAssemblyAnimation } from '$lib/animation/assemblyAnimation';
3435
import { downloadJson } from '$lib/utils/download';
@@ -97,13 +98,15 @@ export function createGraphFile(name?: string): GraphFile {
9798
) as SimulationSettings;
9899

99100
const requiredToolboxes = collectRequiredToolboxes(nodes);
101+
const pathsimVersion = getCachedPathsimVersion();
100102

101103
return {
102104
version: GRAPH_FILE_VERSION,
103105
metadata: {
104106
created: new Date().toISOString(),
105107
modified: new Date().toISOString(),
106-
name: name || 'Untitled'
108+
name: name || 'Untitled',
109+
...(pathsimVersion ? { pathsimVersion } : {})
107110
},
108111
graph: {
109112
nodes: cleanedNodes,
@@ -172,7 +175,13 @@ async function installRequiredToolboxes(reqs: ToolboxRequirement[]): Promise<voi
172175
const missing = findMissingRequirements(reqs);
173176
if (missing.length === 0) return;
174177

175-
const list = missing.map((r) => ${r.displayName}`).join('\n');
178+
// List missing toolboxes with the version recorded in the file as info —
179+
// purely transparency so the user knows which version the model was
180+
// authored against. Install resolves to whatever's latest; if a user
181+
// needs to pin, they can do it manually in the toolbox manager.
182+
const list = missing
183+
.map((r) => ${r.displayName}${r.installedVersion ? ` (saved with v${r.installedVersion})` : ''}`)
184+
.join('\n');
176185
const ok = await confirmationStore.show({
177186
title: 'Install required toolboxes?',
178187
message: `This file uses ${missing.length} toolbox${missing.length === 1 ? '' : 'es'} that ${missing.length === 1 ? 'is' : 'are'} not installed:\n\n${list}\n\nInstall now?`,
@@ -204,6 +213,7 @@ async function installRequiredToolboxes(reqs: ToolboxRequirement[]): Promise<voi
204213
source: req.source,
205214
importPath: updated.importPath,
206215
eventsImportPath: updated.eventsImportPath,
216+
installedVersion: installResult.installedVersion,
207217
blocks: discovered.blocks.map((b) => ({ className: b.className, enabled: true })),
208218
events: discovered.events.map((e) => ({ className: e.className, enabled: true }))
209219
};

src/lib/toolbox/bootstrap.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { get } from 'svelte/store';
1515
import { toolboxes, upsertToolbox, seedPreloadedToolboxes } from './store';
1616
import { performInstall, discoverToolbox, registerToolbox } from './register';
1717
import { getCatalogEntry } from './catalog';
18+
import { primePathsimVersion } from './pathsimVersion';
1819
import type { ToolboxConfig } from './types';
1920

2021
let bootstrapped = false;
@@ -23,6 +24,10 @@ export async function bootstrapToolboxes(): Promise<void> {
2324
if (bootstrapped) return;
2425
bootstrapped = true;
2526

27+
// Cache pathsim's version once so createGraphFile (which is sync) can
28+
// stamp it into saved files without needing an async hop.
29+
await primePathsimVersion();
30+
2631
seedPreloadedToolboxes();
2732

2833
const list = get(toolboxes);
@@ -43,6 +48,7 @@ export async function bootstrapToolboxes(): Promise<void> {
4348
const reconciled: ToolboxConfig = {
4449
...config,
4550
importPath: installResult.importPath,
51+
installedVersion: installResult.installedVersion,
4652
blocks: discovered.blocks.map(
4753
(b) =>
4854
config.blocks.find((s) => s.className === b.className) ?? {

src/lib/toolbox/dependencies.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ function toRequirement(t: ToolboxConfig): ToolboxRequirement {
3131
displayName: t.displayName,
3232
source: t.source,
3333
importPath: t.importPath,
34-
eventsImportPath: t.eventsImportPath
34+
eventsImportPath: t.eventsImportPath,
35+
installedVersion: t.installedVersion ?? null
3536
};
3637
}
3738

src/lib/toolbox/installer.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,23 +36,21 @@ export interface IntrospectedEvent {
3636
params: IntrospectedParam[];
3737
}
3838

39-
let helpersLoaded = false;
40-
4139
/**
4240
* Make sure Pyodide is initialized (so `json` and `_to_json` are available
4341
* for `evaluate(...)`) and that our toolbox helpers are defined.
42+
*
43+
* No JS-side cache: pathview wipes Python globals on simulation reset, so
44+
* a cached "loaded" flag goes stale and the next call hits a NameError.
45+
* The sentinel check is a single evaluate — cheap enough to run every time.
4446
*/
4547
async function ensureHelpers(): Promise<void> {
46-
if (helpersLoaded) return;
47-
// initPyodide is idempotent and also runs REPL_SETUP_CODE which imports
48-
// `json` and defines `_to_json` — both needed by evaluate().
4948
await initPyodide();
5049
const present = await evaluate<boolean>(TOOLBOX_HELPERS_SENTINEL);
5150
if (!present) {
5251
await exec(TOOLBOX_PYTHON_HELPERS);
52+
await ensureDocutils();
5353
}
54-
await ensureDocutils();
55-
helpersLoaded = true;
5654
}
5755

5856
/**
@@ -146,6 +144,20 @@ export async function introspectEvents(importPath: string): Promise<Introspected
146144
return result.events;
147145
}
148146

147+
/**
148+
* Best-effort version lookup for an installed module. Reads
149+
* `module.__version__` first, falls back to `importlib.metadata`. Returns
150+
* null when neither is available (typical for inline modules).
151+
*/
152+
export async function getModuleVersion(importPath: string): Promise<string | null> {
153+
await ensureHelpers();
154+
try {
155+
return await evaluate<string | null>(`_pv_module_version(${pyStr(importPath)})`);
156+
} catch {
157+
return null;
158+
}
159+
}
160+
149161
/**
150162
* Drop a module from sys.modules. micropip has no real uninstall, so the
151163
* package files stay cached in the Pyodide FS until reload, but importing

src/lib/toolbox/pathsimVersion.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Cached pathsim version. Read once via `getModuleVersion('pathsim')` after
3+
* bootstrap, then exposed synchronously so `createGraphFile` (which can't
4+
* be async because it's called from autoSave) can stamp the version into
5+
* saved files.
6+
*/
7+
8+
import { getModuleVersion } from './installer';
9+
10+
let cached: string | null = null;
11+
let primed = false;
12+
13+
/** Read pathsim's version from Python and cache it. Call once after the
14+
* Python runtime is up (bootstrap or first save). Idempotent. */
15+
export async function primePathsimVersion(): Promise<void> {
16+
if (primed) return;
17+
try {
18+
cached = await getModuleVersion('pathsim');
19+
} catch {
20+
cached = null;
21+
}
22+
primed = true;
23+
}
24+
25+
/** Synchronous accessor. Returns null until `primePathsimVersion` has run. */
26+
export function getCachedPathsimVersion(): string | null {
27+
return cached;
28+
}

0 commit comments

Comments
 (0)