fix: opentui subtree disposal on depth swap; theme inputs; recent-click

This commit is contained in:
2026-08-10 15:46:53 -04:00
parent 8eaca82ce9
commit fd689aba04
5 changed files with 77 additions and 38 deletions

View File

@@ -159,34 +159,42 @@ function DiscoverPage() {
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`; : `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ───────────── // ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// Stable <Show> gate (not a ternary root swap) so the parent list // Sibling <Show> blocks per depth (the known-good opentui disposal
// mounts/unmounts cleanly on depth change. // pattern, mirrors Settings): a STABLE fragment root whose inner <Show>
// children toggle on depth change, so the old subtree is disposed instead
// of left orphaned next to the new one (single <Show with fallback> and
// ternary root swaps both leak the previous root).
const parentContent = () => ( const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}> <>
<For each={categories()}> <Show when={depth() === 0}>
{(cat, index) => { <TabListPane muted />
const lf = () => nav.depthFocus(0); </Show>
const ref = useScrollIntoView(() => index() === lf()); <Show when={depth() >= 1}>
return ( <For each={categories()}>
<box {(cat, index) => {
ref={ref} const lf = () => nav.depthFocus(0);
flexDirection="row" const ref = useScrollIntoView(() => index() === lf());
gap={1} return (
paddingLeft={1} <box
paddingRight={1} ref={ref}
backgroundColor={focusBg(index(), lf(), false)} flexDirection="row"
> gap={1}
<text fg={focusFg(index(), nav.depthFocus(0), false)}> paddingLeft={1}
{index() === nav.depthFocus(0) ? "" : " "} paddingRight={1}
</text> backgroundColor={focusBg(index(), lf(), false)}
<text fg={focusFg(index(), nav.depthFocus(0), false)}> >
{cat.name} <text fg={focusFg(index(), nav.depthFocus(0), false)}>
</text> {index() === nav.depthFocus(0) ? "" : " "}
</box> </text>
); <text fg={focusFg(index(), nav.depthFocus(0), false)}>
}} {cat.name}
</For> </text>
</Show> </box>
);
}}
</For>
</Show>
</>
); );
// ── current pane ─────────────────────────────────────────────────────────── // ── current pane ───────────────────────────────────────────────────────────

View File

@@ -74,12 +74,16 @@ function SearchPage() {
// j/k (yielding to a non-existent input) and only the scrollbox's native // j/k (yielding to a non-existent input) and only the scrollbox's native
// scroll responds. // scroll responds.
// //
// The effect only re-runs on a depth transition, so Escape (defocus) and // The depth STACK signal is also written by focus moves (setDepthFocus),
// `s` (refocus) at the same depth are not clobbered. // so gate the sync on the depth VALUE via a memo: the effect must re-run
// only on an actual depth transition. Without the memo every j/k at the
// query depth re-focuses the input (undoing Escape), which keeps the
// recents list unreachable by keyboard.
onMount(() => nav.setInputFocused(depth() === 0)); onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false)); onCleanup(() => nav.setInputFocused(false));
const isQueryDepth = createMemo(() => depth() === 0);
createEffect(() => { createEffect(() => {
nav.setInputFocused(depth() === 0); nav.setInputFocused(isQueryDepth());
}); });
// ── results (depth 1) ───────────────────────────────────────────────────── // ── results (depth 1) ─────────────────────────────────────────────────────
@@ -213,15 +217,25 @@ function SearchPage() {
: theme.text; : theme.text;
// ── parent pane: previous-depth content (tab list at depth 0) ────────────── // ── parent pane: previous-depth content (tab list at depth 0) ──────────────
// Sibling <Show> blocks per depth (the known-good opentui disposal
// pattern, mirrors Settings): a STABLE fragment root whose inner <Show>
// children toggle on depth change, so the old subtree is disposed instead
// of left orphaned next to the new one (single <Show with fallback> and
// ternary root swaps both leak the previous root).
const parentContent = () => ( const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}> <>
<box flexDirection="column" gap={1} padding={1}> <Show when={depth() === 0}>
<text fg={theme.textSecondary}>Query</text> <TabListPane muted />
<text fg={muted()}>{submittedQuery() || "(empty)"}</text> </Show>
<box height={1} /> <Show when={depth() >= 1}>
<text fg={muted()}>h: back to query</text> <box flexDirection="column" gap={1} padding={1}>
</box> <text fg={theme.textSecondary}>Query</text>
</Show> <text fg={muted()}>{submittedQuery() || "(empty)"}</text>
<box height={1} />
<text fg={muted()}>h: back to query</text>
</box>
</Show>
</>
); );
// ── current pane ──────────────────────────────────────────────────────────── // ── current pane ────────────────────────────────────────────────────────────
@@ -239,6 +253,9 @@ function SearchPage() {
placeholder="Enter podcast name..." placeholder="Enter podcast name..."
focused={inputActive()} focused={inputActive()}
width={28} width={28}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/> />
</box> </box>
<Show when={searchStore.isSearching()}> <Show when={searchStore.isSearching()}>
@@ -274,6 +291,10 @@ function SearchPage() {
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0); nav.setDepthFocus(index(), 0);
// A recent is an action, not an item: clicking
// it re-runs that search (focus-only would be
// invisible — the input still owns the keys).
selectRecent(query);
}} }}
> >
<text fg={focusFg(index(), lf(), isActive())}> <text fg={focusFg(index(), lf(), isActive())}>

View File

@@ -32,6 +32,9 @@ export function ExportDialog() {
value={filename[0]()} value={filename[0]()}
onInput={filename[1]} onInput={filename[1]}
style={{ width: 30 }} style={{ width: 30 }}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/> />
</box> </box>
<box style={{ flexDirection: "row", gap: 1 }}> <box style={{ flexDirection: "row", gap: 1 }}>

View File

@@ -21,6 +21,9 @@ export function FilePicker(props: FilePickerProps) {
onInput={props.onChange} onInput={props.onChange}
placeholder="/path/to/sync-file.json" placeholder="/path/to/sync-file.json"
style={{ width: 40 }} style={{ width: 40 }}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/> />
<text fg={theme.text}>Format: {format}</text> <text fg={theme.text}>Format: {format}</text>
</box> </box>

View File

@@ -103,6 +103,8 @@ function AddSourceForm() {
onInput={setName} onInput={setName}
placeholder="My Custom Feed" placeholder="My Custom Feed"
width={25} width={25}
textColor={theme.text}
focusedTextColor={theme.accent}
/> />
</box> </box>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
@@ -116,6 +118,8 @@ function AddSourceForm() {
}} }}
placeholder="https://example.com/feed.rss" placeholder="https://example.com/feed.rss"
width={35} width={35}
textColor={theme.text}
focusedTextColor={theme.accent}
/> />
</box> </box>
<box <box