|
| 1 | +// #487 phase 3 — the desktop left navigation's controller seam. The pure mode |
| 2 | +// machine lives in `core/left-nav-layout.ts`; this module is the thin async/ |
| 3 | +// stateful glue that reads `AppState`'s scattered fields into one |
| 4 | +// `LeftNavigationLayout`, drives the reducers, and writes the result back — |
| 5 | +// plus the one persistence gap phase 3 exists to close (see |
| 6 | +// `selectSectionInExistingPane` below). |
| 7 | +// |
| 8 | +// Typed against a narrow structural interface, not `App`/`AppState` from |
| 9 | +// `src/ui/`: `src/application/**` must never import `src/ui/**` or |
| 10 | +// `src/editor/**` (build/check-boundaries.mjs), and a real `App` satisfies the |
| 11 | +// shape below directly — the same convention `library-assignment-service.ts` |
| 12 | +// and `app-preferences.ts` use. |
| 13 | + |
| 14 | +import { batch } from '@preact/signals-core'; |
| 15 | +import type { Signal } from '@preact/signals-core'; |
| 16 | +import { |
| 17 | + resolveRailActivation, resolveRailOpen, sidePanelKeyFor, |
| 18 | +} from '../core/left-nav-layout.js'; |
| 19 | +import type { |
| 20 | + LeftNavigationLayout, LeftNavigationMode, LeftNavigationSection, SidePanelKey, |
| 21 | +} from '../core/left-nav-layout.js'; |
| 22 | + |
| 23 | +/** The `AppState` fields the left navigation reads and writes, named exactly as |
| 24 | + * `state.ts` names them. Some are signals (repainted/observed reactively), |
| 25 | + * some are plain numbers (written like any other splitter width, persisted |
| 26 | + * only on a later resize-session commit) — that split matches `state.ts` |
| 27 | + * exactly and matters for every write below. */ |
| 28 | +export interface LeftNavStateSlice { |
| 29 | + sidebarPx: number; |
| 30 | + leftNavDrawerPx: number; |
| 31 | + readonly leftNavMode: Signal<LeftNavigationMode>; |
| 32 | + readonly leftNavSection: Signal<LeftNavigationSection | null>; |
| 33 | + readonly upperRole: Signal<'databases' | 'dashboards'>; |
| 34 | + readonly sidePanel: Signal<SidePanelKey>; |
| 35 | +} |
| 36 | + |
| 37 | +/** The one persistence call this module makes — `app.prefs.save`'s real |
| 38 | + * signature (`AppPreferences.save`) takes any `PreferenceKey`; this module |
| 39 | + * only ever names `'sidePanel'`, so the seam is narrowed to that one key |
| 40 | + * rather than importing the full `PreferenceKey` union from |
| 41 | + * `application/app-preferences.ts`. */ |
| 42 | +export interface LeftNavApp { |
| 43 | + readonly state: LeftNavStateSlice; |
| 44 | + readonly prefs: { save(name: 'sidePanel', value: SidePanelKey): void }; |
| 45 | + /** |
| 46 | + * #487 phase-3 review, major issue 2 — called BEFORE either function below |
| 47 | + * runs its own batched write. An active pointer resize session |
| 48 | + * (`ui/left-nav-separator.ts`) keeps its own uncommitted layout snapshot |
| 49 | + * independent of `state`; without this, a semantic command that runs while |
| 50 | + * a drag is still live (Escape closing a drawer, a rail click, a |
| 51 | + * programmatic reveal) writes `state` correctly, but the drag's eventual |
| 52 | + * `mouseup`/`blur` commit still fires from its OWN stale snapshot and can |
| 53 | + * silently overwrite (or resurrect) exactly what this command just did. |
| 54 | + * `app-shell.ts` wires this to cancel the active session (no commit) and |
| 55 | + * repaint from the now-current committed layout, so there is nothing left |
| 56 | + * to fight this write once it runs. This is the ONE choke point every |
| 57 | + * caller of `openFocusedSection`/`toggleFocusedSection` gets it through — |
| 58 | + * optional, so a caller with no separator session to preempt (a test, or a |
| 59 | + * call before the shell has mounted one) simply omits it and gets a no-op. |
| 60 | + */ |
| 61 | + preemptActiveResize?(): void; |
| 62 | +} |
| 63 | + |
| 64 | +/** |
| 65 | + * Project the scattered `AppState` fields this module reads into one |
| 66 | + * `LeftNavigationLayout` — the shape every reducer in `core/left-nav-layout.ts` |
| 67 | + * takes and returns. Exported: later phase-3 steps (the resize separator, the |
| 68 | + * app-shell repaint effect) need the same projection. |
| 69 | + */ |
| 70 | +export function readLeftNavigationLayout(state: LeftNavStateSlice): LeftNavigationLayout { |
| 71 | + return { |
| 72 | + mode: state.leftNavMode.value, |
| 73 | + wideWidthPx: state.sidebarPx, |
| 74 | + drawerWidthPx: state.leftNavDrawerPx, |
| 75 | + focusedSection: state.leftNavSection.value, |
| 76 | + }; |
| 77 | +} |
| 78 | + |
| 79 | +/** |
| 80 | + * Drive the pane that ALREADY shows this section when there is no drawer to |
| 81 | + * open for it — the wide sidebar's upper role switch, or its lower |
| 82 | + * library/history tab. Not exported: it is only ever the one-signal half of |
| 83 | + * `openFocusedSection`/`toggleFocusedSection`'s single batched write, never a |
| 84 | + * standalone operation (see those functions' own comments for why they must |
| 85 | + * not each get their own `batch()`). |
| 86 | + * |
| 87 | + * The lower-section branch is the fix phase 3 exists to make: today only the |
| 88 | + * wide sidebar's own tab click (`ui/saved-history.ts`'s `switchTo`) persists |
| 89 | + * `sidePanel`, so a rail/drawer selection of the same section left the |
| 90 | + * signal's NEW value unpersisted — a reload would silently revert to |
| 91 | + * whichever pane was last chosen through the wide tabs. Persisting BEFORE |
| 92 | + * writing the signal mirrors `switchTo` exactly. Per-section filters |
| 93 | + * (`state.lowerNavigationFilters`, phase 3 step 3) are already implemented |
| 94 | + * elsewhere and this module still correctly never touches them. |
| 95 | + * |
| 96 | + * Both branches are guarded to a no-op when the target value is already |
| 97 | + * current: `openFocusedSection`'s documented caller is #428's bounded |
| 98 | + * drag-hover, which can re-assert the SAME section repeatedly on every hover |
| 99 | + * notification, and `sidePanel`'s write has a real synchronous side effect |
| 100 | + * (`app.prefs.save`) that must not fire on every one of those. |
| 101 | + */ |
| 102 | +function selectSectionInExistingPane(app: LeftNavApp, section: LeftNavigationSection): void { |
| 103 | + if (section === 'databases' || section === 'dashboards') { |
| 104 | + // Session-only, like `switchTo`'s counterpart for the upper pane: `upperRole` |
| 105 | + // is never persisted (state.ts), so there is nothing to save here. |
| 106 | + if (app.state.upperRole.value !== section) app.state.upperRole.value = section; |
| 107 | + return; |
| 108 | + } |
| 109 | + const panel = sidePanelKeyFor(section); |
| 110 | + if (app.state.sidePanel.value !== panel) { |
| 111 | + app.prefs.save('sidePanel', panel); |
| 112 | + app.state.sidePanel.value = panel; |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +/** |
| 117 | + * Write a resolved `LeftNavigationLayout` back onto the scattered signals/ |
| 118 | + * fields it was read from. Not exported, for the same reason as |
| 119 | + * `selectSectionInExistingPane`: it is only ever the other half of one batched |
| 120 | + * write. |
| 121 | + * |
| 122 | + * Every caller only ever hands this the result of `resolveRailOpen` / |
| 123 | + * `resolveRailActivation`, and neither reducer changes `mode` or either width |
| 124 | + * in practice (rail-only reducers) — so writing all four fields |
| 125 | + * unconditionally is harmless, and simpler than special-casing which changed. |
| 126 | + * It does NOT call `prefs.save` for `leftNavMode`/`leftNavDrawerPx`: that |
| 127 | + * persistence belongs to a later step's resize-session commit, not to opening |
| 128 | + * a section. |
| 129 | + */ |
| 130 | +function writeLeftNavigationLayout(state: LeftNavStateSlice, layout: LeftNavigationLayout): void { |
| 131 | + state.leftNavMode.value = layout.mode; |
| 132 | + state.sidebarPx = layout.wideWidthPx; |
| 133 | + state.leftNavDrawerPx = layout.drawerWidthPx; |
| 134 | + state.leftNavSection.value = layout.focusedSection; |
| 135 | +} |
| 136 | + |
| 137 | +/** |
| 138 | + * Open a section IDEMPOTENTLY (`core/left-nav-layout.ts`'s `resolveRailOpen`) — |
| 139 | + * the deterministic seam #487 asks the left navigation to expose for #428's |
| 140 | + * bounded drag-hover, and the one a plain rail-icon click also uses. |
| 141 | + * |
| 142 | + * In rail mode this opens (or keeps open) the focused drawer; in wide mode |
| 143 | + * `resolveRailOpen` returns the layout unchanged (both panes already show, so |
| 144 | + * `selectSectionInExistingPane` is the only effect), and for a lower section it |
| 145 | + * ALSO drives the existing wide-mode pane switch, so calling this while wide |
| 146 | + * still selects the right lower tab. Both halves are wrapped in exactly ONE |
| 147 | + * `batch()` call: composing two independently-`batch()`-wrapped functions is |
| 148 | + * NOT one atomic transition, since Preact signals' `batch()` flushes on each |
| 149 | + * top-level call's own exit — two separate calls would let an effect observe |
| 150 | + * an intermediate, mismatched combination (e.g. `sidePanel` updated but |
| 151 | + * `leftNavSection` still stale) on the frame between them. |
| 152 | + */ |
| 153 | +export function openFocusedSection(app: LeftNavApp, section: LeftNavigationSection): void { |
| 154 | + app.preemptActiveResize?.(); |
| 155 | + batch(() => { |
| 156 | + selectSectionInExistingPane(app, section); |
| 157 | + writeLeftNavigationLayout(app.state, resolveRailOpen(readLeftNavigationLayout(app.state), section)); |
| 158 | + }); |
| 159 | +} |
| 160 | + |
| 161 | +/** |
| 162 | + * Toggle a section (`core/left-nav-layout.ts`'s `resolveRailActivation`) — |
| 163 | + * the same shape as `openFocusedSection`, but a second activation of the SAME |
| 164 | + * already-open section closes the drawer instead of re-asserting it open. |
| 165 | + * Same single-`batch()` requirement and rationale as `openFocusedSection`. |
| 166 | + */ |
| 167 | +export function toggleFocusedSection(app: LeftNavApp, section: LeftNavigationSection): void { |
| 168 | + app.preemptActiveResize?.(); |
| 169 | + batch(() => { |
| 170 | + selectSectionInExistingPane(app, section); |
| 171 | + writeLeftNavigationLayout( |
| 172 | + app.state, resolveRailActivation(readLeftNavigationLayout(app.state), section), |
| 173 | + ); |
| 174 | + }); |
| 175 | +} |
0 commit comments