Files
yellowjacket/frontend/src/store/view-visibility-store.ts
T
logan 43d78a731a feat(shell): draw only the destinations the user kept
The navigation reads the resolved map from the backend rather than
holding a copy of the defaults, which would be the copy that shipped in
the binary rather than the one being edited.

Hiding takes away the nav item and nothing else: `navigate` still
resolves a hidden view, which detail views and the launch page depend
on. No special case was needed for the highlight, because #72 moved
that onto `active-view-store` -- the sidebar asks `isActive(id)` per
*rendered* item, so a hidden view lights nothing exactly as a detail
view does.

Downloads is gated at the nav on `downloadStore.available` rather than
in the config, so switching it on in Settings still means what it says
once a client exists, and the tab appears without a restart. `available`
is false until the providers have loaded, which makes the item appear on
a fresh launch rather than appearing and then vanishing.

The tab bar honours the toggles too, and the reason is local rather than
a general rule about phones: "More" opens the *same* `<app-sidebar>`,
which filters, so an unfiltered bar would contradict its own drawer one
tap away. Which four tabs is still plan 016's subset; this only removes
from it, and "More" is never filtered.

`services/view-meta.ts` is the destination list, on `shortcut-meta.ts`'s
pattern, because Settings is now a second reader of the same labels in
the same order.

Two existing sidebar tests had to say which world they describe: eleven
destinations now assumes a configured download client.
2026-08-19 19:33:44 -04:00

120 lines
4.2 KiB
TypeScript

import { EventsOn } from '@runtime/runtime';
import { GetViewVisibility, SetViewVisible } from '@go/config/config.js';
import { dictByName } from '@utils/binding';
import { downloadStore } from './download-store';
import { Events } from '../events';
type Subscriber = () => void;
/**
* Which primary destinations the navigation offers.
*
* Eleven sidebar entries is more than most libraries need, so #25 makes
* them individually toggleable. Three rules about this are load-bearing.
*
* **Hidden is not unreachable.** This decides what the *nav* draws and
* nothing else: `navigate` still resolves a hidden view, which is not a
* nicety — detail views navigate into these, and the shell's launch
* page is one of them. Nothing here needs a special case for the
* highlight either, because #72 moved that onto `active-view-store`:
* `app-sidebar` asks `isActive(id)` per *rendered* item, so a hidden
* view lights nothing exactly as a detail view does.
*
* **The defaults live in Go**, in `backend/config.Views`, and this asks
* for the *resolved* answer rather than the stored map. A config that
* says nothing about a view means "that view's own default", so a copy
* of the defaults here would be a second thing to keep in step — and
* the one that shipped in the artifact, not the one being edited.
*
* **Downloads is a second question**, answered by the download client
* rather than by the config: a destination for a feature that cannot
* work is worse than an absent one. It is gated at `visible()` and not
* in the config, so switching it on in Settings still means what it
* says once a client exists. `available` is false until the providers
* have loaded, which makes the tab *appear* on a fresh launch rather
* than appearing and then vanishing — the less jarring half of a race
* that resolves in one query.
*/
class ViewVisibilityStore {
/** The backend's resolved answer, empty until the first load. */
private configured: Record<string, boolean> = {};
private loaded = false;
private subscribers = new Set<Subscriber>();
constructor() {
EventsOn(Events.GeneralConfigChanged, () => {
void this.refresh();
});
// A client configured later has to add the destination without a
// restart -- #37's rule, one surface over.
downloadStore.subscribe(() => this.notify());
}
/** Loads the visibility map once. Safe to call from every mount. */
async init(): Promise<void> {
if (this.loaded) return;
this.loaded = true;
await Promise.all([
this.refresh(),
downloadStore.ensureProviders(),
]);
}
/**
* Whether the navigation should offer this destination.
*
* An unknown id is visible: the caller is drawing it from its own
* list, and a view this store has not heard of (or has not loaded
* yet) is better shown than silently dropped.
*/
visible(view: string): boolean {
if (view === 'downloads' && !downloadStore.available) return false;
return this.configured[view] ?? true;
}
/**
* What the *config* says, ignoring the download-client gate — which
* is what Settings' own checkbox has to show, or a user with no
* client would see Downloads switched off and be unable to switch
* it on.
*/
enabled(view: string): boolean {
return this.configured[view] ?? true;
}
async setVisible(view: string, visible: boolean): Promise<void> {
await SetViewVisible(view, visible);
// The backend emits GeneralConfigChanged, but the caller is
// owed the new state by the time this resolves.
await this.refresh();
}
subscribe(fn: Subscriber): () => void {
this.subscribers.add(fn);
return () => this.subscribers.delete(fn);
}
private async refresh(): Promise<void> {
try {
this.configured = await dictByName(GetViewVisibility());
this.notify();
} catch (err) {
console.error('Failed to load view visibility:', err);
}
}
private notify(): void {
this.subscribers.forEach((fn) => fn());
}
}
export const viewVisibilityStore = new ViewVisibilityStore();