Skip to content

Commit 54af616

Browse files
authored
Merge pull request #275 from pathsim/feature/subsystem-tree
subsystem tree for better subsystem navigation
2 parents 7457cef + c3a56b1 commit 54af616

7 files changed

Lines changed: 570 additions & 27 deletions

File tree

src/lib/components/dialogs/KeyboardShortcutsDialog.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
{ keys: ['S'], description: 'Simulation' },
6969
{ keys: ['B'], description: 'Blocks' },
7070
{ keys: ['N'], description: 'Events' },
71+
{ keys: ['R'], description: 'Subsystems' },
7172
{ keys: ['E'], description: 'Editor' },
7273
{ keys: ['V'], description: 'Results' },
7374
{ keys: ['C'], description: 'Console' },

src/lib/components/icons/Icon.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -429,8 +429,8 @@
429429
</svg>
430430
{:else if name === 'codegen'}
431431
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
432-
<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/>
433-
<path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>
432+
<path d="M7 3H6a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/>
433+
<path d="M17 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>
434434
<text x="12" y="16" text-anchor="middle" fill="currentColor" stroke="none" font-size="12" font-weight="700" font-family="var(--font-mono), monospace">C</text>
435435
</svg>
436436
{:else if name === 'font-size-increase'}
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
<script lang="ts">
2+
import { onDestroy } from 'svelte';
3+
import { graphStore } from '$lib/stores/graph';
4+
import type { SubsystemTreeNode } from '$lib/stores/graph';
5+
import Icon from '$lib/components/icons/Icon.svelte';
6+
import SubsystemTreeRow from './SubsystemTreeRow.svelte';
7+
8+
let tree = $state<SubsystemTreeNode[]>([]);
9+
let currentPath = $state<string[]>([]);
10+
let filter = $state('');
11+
12+
const unsubTree = graphStore.subsystemTree.subscribe((t) => (tree = t));
13+
const unsubPath = graphStore.currentPath.subscribe((p) => (currentPath = p));
14+
onDestroy(() => {
15+
unsubTree();
16+
unsubPath();
17+
});
18+
19+
function pathKey(path: string[]): string {
20+
return path.join('/');
21+
}
22+
23+
function handleNavigate(path: string[]) {
24+
graphStore.navigateToPath(path);
25+
}
26+
27+
const totalCount = $derived.by(() => {
28+
let n = 0;
29+
const walk = (nodes: SubsystemTreeNode[]) => {
30+
for (const node of nodes) {
31+
n++;
32+
walk(node.children);
33+
}
34+
};
35+
walk(tree);
36+
return n;
37+
});
38+
39+
// Set of pathKeys whose row should remain visible under the filter.
40+
// A node is kept if its name matches OR any descendant matches; matching
41+
// nodes also pull all ancestors into visibility (including the synthetic
42+
// Root row, key '').
43+
const filterMatch = $derived.by((): Set<string> | null => {
44+
const query = filter.trim().toLowerCase();
45+
if (!query) return null;
46+
const visible = new Set<string>();
47+
const walk = (nodes: SubsystemTreeNode[]): boolean => {
48+
let any = false;
49+
for (const n of nodes) {
50+
const selfMatch = n.name.toLowerCase().includes(query);
51+
const childMatch = walk(n.children);
52+
if (selfMatch || childMatch) {
53+
visible.add(pathKey(n.path));
54+
any = true;
55+
}
56+
}
57+
return any;
58+
};
59+
const anyMatch = walk(tree);
60+
if (anyMatch) visible.add(''); // Root visible whenever anything matches
61+
return visible;
62+
});
63+
64+
function clearFilter() {
65+
filter = '';
66+
}
67+
68+
const hasAnyTree = $derived(tree.length > 0);
69+
const filterHasResults = $derived(!filterMatch || filterMatch.size > 0);
70+
71+
// Synthetic root entry so the hierarchy renders as a single tree.
72+
const rootEntry = $derived<SubsystemTreeNode>({
73+
id: '__root__',
74+
name: 'Root',
75+
path: [],
76+
children: tree
77+
});
78+
</script>
79+
80+
<div class="panel">
81+
<div class="search-container">
82+
<span class="search-icon"><Icon name="search" size={14} /></span>
83+
<input
84+
class="search-input"
85+
type="text"
86+
placeholder="Filter subsystems…"
87+
bind:value={filter}
88+
/>
89+
{#if filter}
90+
<button class="clear-btn" onclick={clearFilter} aria-label="Clear filter">
91+
<Icon name="x" size={12} />
92+
</button>
93+
{/if}
94+
</div>
95+
96+
<div class="tree-scroll">
97+
{#if !hasAnyTree && !filter}
98+
<div class="root-only">
99+
<SubsystemTreeRow
100+
node={rootEntry}
101+
depth={0}
102+
siblingContinuesAt={[]}
103+
isRoot
104+
{currentPath}
105+
filterMatch={null}
106+
onNavigate={handleNavigate}
107+
/>
108+
<div class="hint">No subsystems — add one in the canvas.</div>
109+
</div>
110+
{:else if !filterHasResults}
111+
<div class="empty">
112+
<span>No subsystems found</span>
113+
<span class="hint">Try a different filter</span>
114+
</div>
115+
{:else}
116+
<div class="tree" role="tree">
117+
<SubsystemTreeRow
118+
node={rootEntry}
119+
depth={0}
120+
siblingContinuesAt={[]}
121+
isRoot
122+
{currentPath}
123+
{filterMatch}
124+
onNavigate={handleNavigate}
125+
/>
126+
</div>
127+
{/if}
128+
</div>
129+
130+
<div class="footer">
131+
{totalCount} subsystem{totalCount === 1 ? '' : 's'}
132+
{#if filterMatch}
133+
· {filterMatch.size > 0 ? filterMatch.size - 1 : 0} match{(filterMatch.size > 0 ? filterMatch.size - 1 : 0) === 1 ? '' : 'es'}
134+
{/if}
135+
</div>
136+
</div>
137+
138+
<style>
139+
.panel {
140+
display: flex;
141+
flex-direction: column;
142+
height: 100%;
143+
min-height: 0;
144+
}
145+
146+
.search-container {
147+
flex-shrink: 0;
148+
display: flex;
149+
align-items: center;
150+
gap: var(--space-sm);
151+
height: var(--header-height);
152+
padding: 0 var(--space-md);
153+
border-bottom: 1px solid var(--border);
154+
}
155+
156+
.search-icon {
157+
color: var(--text-muted);
158+
flex-shrink: 0;
159+
display: inline-flex;
160+
}
161+
162+
.search-input {
163+
flex: 1;
164+
background: transparent;
165+
border: none;
166+
border-radius: 0;
167+
font-size: var(--font-base);
168+
color: var(--text);
169+
outline: none;
170+
box-shadow: none;
171+
padding: 0;
172+
}
173+
174+
.search-input::placeholder {
175+
color: var(--text-muted);
176+
}
177+
178+
.clear-btn {
179+
display: flex;
180+
align-items: center;
181+
justify-content: center;
182+
background: none;
183+
border: none;
184+
color: var(--text-muted);
185+
padding: 2px;
186+
cursor: pointer;
187+
}
188+
189+
.clear-btn:hover {
190+
color: var(--text);
191+
}
192+
193+
.tree-scroll {
194+
flex: 1;
195+
overflow-y: auto;
196+
min-height: 0;
197+
background: var(--surface);
198+
padding: 4px 0;
199+
}
200+
201+
.tree {
202+
display: flex;
203+
flex-direction: column;
204+
}
205+
206+
.root-only {
207+
display: flex;
208+
flex-direction: column;
209+
}
210+
211+
.hint {
212+
padding: 8px 16px;
213+
color: var(--text-disabled);
214+
font-size: 11px;
215+
}
216+
217+
.empty {
218+
display: flex;
219+
flex-direction: column;
220+
gap: var(--space-xs);
221+
padding: var(--space-xl);
222+
text-align: center;
223+
color: var(--text-muted);
224+
font-size: 12px;
225+
}
226+
227+
.empty .hint {
228+
font-size: 10px;
229+
color: var(--text-disabled);
230+
}
231+
232+
.footer {
233+
flex-shrink: 0;
234+
padding: var(--space-sm) var(--space-md);
235+
background: var(--surface-raised);
236+
border-top: 1px solid var(--border);
237+
font-size: 10px;
238+
color: var(--text-disabled);
239+
}
240+
</style>

0 commit comments

Comments
 (0)