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.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from 'lit';
|
||||
import { viewVisibilityStore } from '../view-visibility-store';
|
||||
|
||||
/**
|
||||
* ViewVisibilityController connects a Lit component to the
|
||||
* ViewVisibilityStore.
|
||||
*
|
||||
* It reads through to the store rather than copying the map into a
|
||||
* `@state()` field, for the reason `ActiveViewController` does: there
|
||||
* are two live `<app-sidebar>` instances the moment `bottom-nav`'s
|
||||
* "More" drawer opens, and two components holding their own idea of
|
||||
* which destinations exist is how they come to disagree.
|
||||
*/
|
||||
export class ViewVisibilityController implements ReactiveController {
|
||||
private host: ReactiveControllerHost;
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this.host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
this.unsubscribe = viewVisibilityStore.subscribe(() => {
|
||||
this.host.requestUpdate();
|
||||
});
|
||||
|
||||
void viewVisibilityStore.init();
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
/** Whether the navigation should offer this destination. */
|
||||
visible(view: string): boolean {
|
||||
return viewVisibilityStore.visible(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the config says, ignoring the download-client gate — the
|
||||
* state Settings' own checkbox shows.
|
||||
*/
|
||||
enabled(view: string): boolean {
|
||||
return viewVisibilityStore.enabled(view);
|
||||
}
|
||||
|
||||
setVisible(view: string, visible: boolean): Promise<void> {
|
||||
return viewVisibilityStore.setVisible(view, visible);
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,8 @@ class DownloadStore {
|
||||
|
||||
private initialized = false;
|
||||
|
||||
private providersLoaded = false;
|
||||
|
||||
constructor() {
|
||||
EventsOn(Events.DownloadProvidersChanged, () => {
|
||||
void this.refreshProviders();
|
||||
@@ -285,6 +287,25 @@ class DownloadStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the providers, and only those, once.
|
||||
*
|
||||
* `init()` additionally fetches the descriptors, the downloads and
|
||||
* the request list, which is right for a page about downloading and
|
||||
* wrong for the sidebar: it only needs `available`, to decide
|
||||
* whether the Downloads destination exists at all (#25), and that
|
||||
* is one query. `DownloadProvidersChanged` keeps it current
|
||||
* afterwards, so configuring a client makes the tab appear without
|
||||
* a restart.
|
||||
*/
|
||||
async ensureProviders(): Promise<void> {
|
||||
if (this.providersLoaded) return;
|
||||
|
||||
this.providersLoaded = true;
|
||||
|
||||
await this.refreshProviders();
|
||||
}
|
||||
|
||||
async refreshProviders(): Promise<void> {
|
||||
try {
|
||||
this.providersValue = (await ListProviders()) ?? [];
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user