From 8f683e89d58af6b4c28ac904ccc514c647b89452 Mon Sep 17 00:00:00 2001 From: sukuwc Date: Wed, 23 Sep 2026 15:11:21 +0200 Subject: [PATCH] Make folders and profile-embedded presets selectable in the tree Folders and nested profile presets could only toggle expand/collapse, so there was no way to see what was selected other than a real, previewable config. selected_config now stays reserved for that, with a new selected_node_id/selected_node_label pair driving row highlighting generically - and the detail pane falls back to a "no preview available" placeholder naming the selection instead of trying to render one. Selecting a profile-embedded preset also now notifies the editor via provideSelectedConfigForEditor so it shows the correct module overlay on connected hardware. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01619XN25FUTzqut4YbzZPQr --- src/lib/components/tree/ConfigTree.svelte | 72 +++++++++++++++++++---- src/lib/components/tree/TreeFolder.svelte | 19 +++++- src/routes/ConfigCardDisplay.svelte | 3 +- src/routes/EditorLayout.svelte | 13 ++++ src/routes/EditorLayout.ts | 14 +++++ 5 files changed, 108 insertions(+), 13 deletions(-) diff --git a/src/lib/components/tree/ConfigTree.svelte b/src/lib/components/tree/ConfigTree.svelte index de76a1c..dfdf085 100644 --- a/src/lib/components/tree/ConfigTree.svelte +++ b/src/lib/components/tree/ConfigTree.svelte @@ -9,6 +9,8 @@ } from "./../../../routes/Filter"; import { selected_config, + selected_node_id, + selected_node_label, show_supported_only, hide_community_configs, config_manager, @@ -98,26 +100,33 @@ } $: if (treeRoot) { - selectClosestMatch($selected_config, filteredConfigs); - const selected = get(selected_config); + // selectClosestMatch auto-selects a config when nothing at all is + // selected yet (startup, or the previous selection got deleted/filtered + // out). Skip it once a folder or nested preset is the active selection - + // those intentionally leave selected_config unset, and re-running this + // would keep clobbering that choice with configs[0]. + if ($selected_node_id === undefined) { + selectClosestMatch($selected_config, filteredConfigs); + } + const currentNodeId = get(selected_node_id); // A fresh selection should reveal its location, overriding any folder - // the user previously closed by hand. Re-selecting the same config + // the user previously closed by hand. Re-selecting the same node // (e.g. toggling a folder it lives in) must not reset that choice. - if (selected?.id !== previousSelectedId) { + if (currentNodeId !== previousSelectedId) { manuallyClosed.clear(); - previousSelectedId = selected?.id; + previousSelectedId = currentNodeId; } - // getIncludingNodes forces every ancestor of the selected config open so + // getIncludingNodes forces every ancestor of the selected node open so // the selection stays visible. Drop any node the user explicitly closed // (including a top-level category) so that choice sticks instead of // snapping back open. - const includingNodes = treeRoot.getIncludingNodes(selected?.id); + const includingNodes = treeRoot.getIncludingNodes(currentNodeId); const forcedNodes = includingNodes.filter((e) => !manuallyClosed.has(e)); treeProps = { root: treeRoot, - selected: selected?.id, + selected: currentNodeId, expanded: Array.from(new Set([...forcedNodes, ...manuallyExpanded])), scrollBehaviour: { scrollToIndex: scrollToSelectionTrigger > 0, @@ -258,14 +267,36 @@ dragTarget.set(undefined); } + // generateVirtualPresets (ConfigTree.ts) stamps a displayName ("Profile / + // Preset") on every synthetic preset/snippet node it fabricates for a + // profile's nested elements. Real, saved configs never set displayName, so + // it doubles as a reliable "this is a virtual node, not a loadable config" + // marker. + function isVirtualPreset(config: Config) { + return config.displayName !== undefined; + } + async function handleClick(node: AbstractTreeNode) { const config = (get(node).data as Tree.ItemData).item; - selected_config.set(config); + selected_node_id.set(config.id); + selected_node_label.set(config.displayName ?? config.name); + + const isPreset = isVirtualPreset(config); + selected_config.set(isPreset ? undefined : config); + + // provideSelectedConfigForEditor's handler (grid-editor side) switches on + // config.configType to pick the right module overlay - PRESET_LOAD for a + // preset, PROFILE_LOAD for a profile, or moduleOverlay.close() otherwise. + // It only updates that overlay + grid-editor's own selection store, it + // doesn't write anything to the device, so it's safe to send for a + // virtual/nested preset too even though there's nothing to preview here. await parentIframeCommunication({ windowPostMessageName: "provideSelectedConfigForEditor", dataForParent: { config: config }, }); + if (isPreset) return; + await parentIframeCommunication({ windowPostMessageName: "showOverlay", dataForParent: { value: true }, @@ -274,7 +305,23 @@ function handleContextMenu(node: AbstractTreeNode) { const config = (get(node).data as Tree.ItemData).item; - selected_config.set(config); + selected_node_id.set(config.id); + selected_node_label.set(config.displayName ?? config.name); + selected_config.set(isVirtualPreset(config) ? undefined : config); + } + + function selectFolder(node: AbstractTreeNode) { + const { title } = get(node).data as AbstractFolderData; + selected_node_id.set(get(node).id); + selected_node_label.set(title); + selected_config.set(undefined); + // config: undefined falls into provideSelectedConfigForEditor's default + // case (moduleOverlay.close()), so a folder selection clears any + // PRESET_LOAD/PROFILE_LOAD overlay left over from the previous selection. + parentIframeCommunication({ + windowPostMessageName: "provideSelectedConfigForEditor", + dataForParent: { config: undefined }, + }); } // ── Inline rename ────────────────────────────────────────────────────────── @@ -599,7 +646,10 @@ {item} {level} {expanded} + selected={get(item).id === $selected_node_id} ctxOptions={getfolderCtxOptions(level, item)} + on:click={() => selectFolder(item)} + on:contextmenu={() => selectFolder(item)} > {@html `${highlightMatches(data.title, $filter_value)} (${getItemCount(item)})`}; export let expanded: boolean; + export let selected: boolean = false; export let ctxOptions: ContextMenuOptions = { items: [] }; export let level: number; @@ -43,7 +47,14 @@ $: data = $item.data as AbstractFolderData; -
+
dispatch("click")} + on:contextmenu={() => dispatch("contextmenu")} +>
@@ -61,6 +72,8 @@ width: 100%; align-items: center; padding: 0.25rem; + border-width: 1px; + border-color: transparent; color: var(--foreground-muted); } .header:hover { @@ -73,6 +86,10 @@ font-weight: bolder; } + .selected { + border-color: #10b981; + } + .title { display: flex; flex-grow: 1; diff --git a/src/routes/ConfigCardDisplay.svelte b/src/routes/ConfigCardDisplay.svelte index 4a4e80f..676ccb9 100644 --- a/src/routes/ConfigCardDisplay.svelte +++ b/src/routes/ConfigCardDisplay.svelte @@ -19,6 +19,7 @@ const dispatchEvent = createEventDispatcher(); export let data: Config | undefined; + export let emptyStateMessage: string = "No configuration is selected"; let configOwner: string = ""; @@ -188,7 +189,7 @@ style="color: var(--foreground-muted); background: var(--background)" class="empty-state" > - No configuration is selected + {emptyStateMessage}
{/if}
diff --git a/src/routes/EditorLayout.svelte b/src/routes/EditorLayout.svelte index 2a21997..b9f2293 100644 --- a/src/routes/EditorLayout.svelte +++ b/src/routes/EditorLayout.svelte @@ -4,6 +4,8 @@ import { filter_value, FilterValue } from "./Filter"; import { selected_config, + selected_node_id, + selected_node_label, hide_community_configs, show_supported_only, config_manager, @@ -138,6 +140,10 @@ // Select the config and notify the editor selected_config.set(matchedConfig); + selected_node_id.set(matchedConfig.id); + selected_node_label.set( + matchedConfig.displayName ?? matchedConfig.name, + ); scrollToSelectedConfigTrigger += 1; await provideSelectedConfigForEditor(matchedConfig); } @@ -306,6 +312,8 @@ if (configs.length == 0) return; configs.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime()); selected_config.set(configs[0]); + selected_node_id.set(configs[0].id); + selected_node_label.set(configs[0].displayName ?? configs[0].name); scrollToSelectedConfigTrigger += 1; // trigger scrolling into view on ConfigTree } @@ -328,6 +336,8 @@ )[0]; if (newEntry) { selected_config.set(newEntry); + selected_node_id.set(newEntry.id); + selected_node_label.set(newEntry.displayName ?? newEntry.name); scrollToSelectedConfigTrigger += 1; } } @@ -754,6 +764,9 @@ on:path-change={handlePathChange} on:overwrite-profile={handleOverwriteProfile} data={$selected_config} + emptyStateMessage={$selected_node_id !== undefined + ? `No preview available for "${$selected_node_label}"` + : "No configuration is selected"} > {@const config = configs.find( diff --git a/src/routes/EditorLayout.ts b/src/routes/EditorLayout.ts index ab92b66..68a3860 100644 --- a/src/routes/EditorLayout.ts +++ b/src/routes/EditorLayout.ts @@ -9,6 +9,18 @@ export const compatible_config_types: Writable< export const selected_config: Writable = writable(); +// Tracks which tree row is highlighted - a folder, a real top-level config, +// or a virtual preset/snippet nested under a profile. selected_config only +// ever holds a real, previewable config, so this is the only store that can +// represent "a folder/nested item is selected" for row highlighting and +// scroll-into-view purposes. +export const selected_node_id: Writable = writable(); + +// Human-readable name for whatever selected_node_id points at (a folder's +// title, or a config's displayName/name), so the "no preview" placeholder +// can say what's selected instead of a generic message. +export const selected_node_label: Writable = writable(); + export function selectClosestMatch( target: Config | undefined, configs: Config[], @@ -18,6 +30,8 @@ export function selectClosestMatch( if (typeof current === "undefined") { const first = configs[0]; selected_config.set(first); + selected_node_id.set(first?.id); + selected_node_label.set(first?.name); } }