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,10 +159,17 @@ 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 />}> <>
<Show when={depth() === 0}>
<TabListPane muted />
</Show>
<Show when={depth() >= 1}>
<For each={categories()}> <For each={categories()}>
{(cat, index) => { {(cat, index) => {
const lf = () => nav.depthFocus(0); const lf = () => nav.depthFocus(0);
@@ -187,6 +194,7 @@ function DiscoverPage() {
}} }}
</For> </For>
</Show> </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,8 +217,17 @@ 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 />}> <>
<Show when={depth() === 0}>
<TabListPane muted />
</Show>
<Show when={depth() >= 1}>
<box flexDirection="column" gap={1} padding={1}> <box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textSecondary}>Query</text> <text fg={theme.textSecondary}>Query</text>
<text fg={muted()}>{submittedQuery() || "(empty)"}</text> <text fg={muted()}>{submittedQuery() || "(empty)"}</text>
@@ -222,6 +235,7 @@ function SearchPage() {
<text fg={muted()}>h: back to query</text> <text fg={muted()}>h: back to query</text>
</box> </box>
</Show> </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