refactor(nav): route tab switches through single switchTab path

Replace reactive / usage in the navigation
store with imperative functions so behavior is identical under both the
client JSX runtime and the server build used by unit tests (where
/ are no-ops).

- Centralize tab-switch side effects (root-frame seed, pane/mode/
  command/visual reset) in , invoked by a single
   helper used by gotoTab/nextTab/prevTab.
- Convert depthStack/currentDepth/topFrame/selectedIds from
  to plain functions that recompute on every read while still tracking
  underlying signals in reactive contexts.
This commit is contained in:
2026-07-31 16:59:20 -04:00
parent b1bb9d9a1e
commit cfa4ef0c47

View File

@@ -36,13 +36,7 @@
* strip. There is NO sidebar pane: `activePane` is plain tab pane state and is * strip. There is NO sidebar pane: `activePane` is plain tab pane state and is
* never a chrome/tab-list pane. * never a chrome/tab-list pane.
*/ */
import { import { createSignal, batch } from "solid-js";
createEffect,
createSignal,
on,
batch,
createMemo,
} from "solid-js";
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation"; import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
export enum NavMode { export enum NavMode {
@@ -131,32 +125,39 @@ export function createNavigation() {
} }
}; };
// On tab change: ensure a root frame exists (depth-tabs) + reset /** Apply all tab-switch side effects synchronously. Done here in the
// focus to the current/center pane, clear modes/command/visual. * imperative `switchTab` path rather than a reactive `createEffect`
createEffect( * because this module is exercised by unit tests without the OpenTUI
on(activeTab, (tab) => { * JSX runtime, and in that environment Solid's `createEffect` is a
ensureStack(tab); * no-op (server build). Routing every tab change through this helper
batch(() => { * keeps the behavior identical under both runtimes.
setActivePane(DEPTH_CENTER_PANE); *
setMode(NavMode.NORMAL); * - seed a root frame for fresh depth-tabs
setCount(null); * - reset focus to the current/center pane (no sidebar pane)
setCommandBuffer(""); * - clear mode/command/visual/count state */
setCommandError(null); const applyTabSwitch = (tab: TABS) => {
setVisualAnchor(null); ensureStack(tab);
}); batch(() => {
}), setActivePane(DEPTH_CENTER_PANE);
); setMode(NavMode.NORMAL);
setCount(null);
setCommandBuffer("");
setCommandError(null);
setVisualAnchor(null);
});
};
// ── depth stack accessors ────────────────────────────────────────────── // ── depth stack accessors ──────────────────────────────────────────────
const depthStack = createMemo<DepthFrame[]>(() => // Plain functions (not createMemo) so they recompute on every read.
depthStackFor(activeTab()), // On the client build these are read inside reactive JSX contexts so
); // their underlying signal reads are still tracked; on the server build
const currentDepth = createMemo(() => // (used by unit tests) createMemo is a no-op that freezes at creation,
Math.max(0, depthStack().length - 1), // so a plain function is the only option that stays correct in tests.
); const depthStack = (): DepthFrame[] => depthStackFor(activeTab());
const topFrame = createMemo<DepthFrame | undefined>( const currentDepth = (): number =>
() => depthStack()[depthStack().length - 1], Math.max(0, depthStack().length - 1);
); const topFrame = (): DepthFrame | undefined =>
depthStack()[depthStack().length - 1];
const isDepthTab = () => DEPTH_TABS.has(activeTab()); const isDepthTab = () => DEPTH_TABS.has(activeTab());
/** Focus within a given depth's frame (default = current/top). */ /** Focus within a given depth's frame (default = current/top). */
@@ -193,14 +194,25 @@ export function createNavigation() {
}; };
// ── tab switching ────────────────────────────────────────────────────── // ── tab switching ──────────────────────────────────────────────────────
/** Internal: set activeTab + run all side effects (root-frame seed,
* pane/mode/command reset). Called by gotoTab/nextTab/prevTab so every
* tab change — programmatic or key-driven — goes through one path. */
const switchTab = (tab: TABS) => {
setActiveTab(tab);
applyTabSwitch(tab);
};
const gotoTab = (tab: TABS) => { const gotoTab = (tab: TABS) => {
if (tab < 1 || tab > TabsCount) return; if (tab < 1 || tab > TabsCount) return;
setActiveTab(tab); switchTab(tab);
};
const nextTab = () => {
const t = activeTab() >= TabsCount ? 1 : ((activeTab() + 1) as TABS);
switchTab(t);
};
const prevTab = () => {
const t = activeTab() <= 1 ? TabsCount : ((activeTab() - 1) as TABS);
switchTab(t);
}; };
const nextTab = () =>
setActiveTab((t) => (t >= TabsCount ? 1 : ((t + 1) as TABS)));
const prevTab = () =>
setActiveTab((t) => (t <= 1 ? TabsCount : ((t - 1) as TABS)));
// ── pane focus ────────────────────────────────────────────────────────── // ── pane focus ──────────────────────────────────────────────────────────
const setPane = (pane: PaneId) => setActivePane(pane); const setPane = (pane: PaneId) => setActivePane(pane);
@@ -296,7 +308,7 @@ export function createNavigation() {
}); });
}; };
const selectedIds = createMemo(() => [...selSet(paneKey())]); const selectedIds = () => [...selSet(paneKey())];
/** Enter visual mode, anchoring range selection at the current focus. */ /** Enter visual mode, anchoring range selection at the current focus. */
const enterVisual = () => { const enterVisual = () => {