summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout/logic/registry.ts
blob: bcd49e0ce7d685c3903680239c60e075432ca1fe (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
/**
 * The view registry — the map from view id → component that tile leaves mount.
 *
 * Every view the renderer can place (the sidebar views + chat / composer /
 * message-queue today; plugin views from plan 03 later) registers here. The
 * registry is REACTIVE (a Svelte store): plan 03's late registrations re-render
 * open dropdowns, and the tree renderer resolves leaves through it per render.
 *
 * An unknown view id in a leaf is NEVER a crash — backend-owned trees (plan 02)
 * can name views a given client lacks; the renderer shows a fallback tile.
 *
 * This module holds a small amount of reactive state by design (the plan places
 * the registry in `logic/` with Svelte-store reactivity). It is NOT decision
 * logic — no DOM, no effects.
 */

import type { Component } from "svelte";
import { type Readable, writable } from "svelte/store";

/**
 * The props type of a registered view component. The composition root hands
 * every wrapper its `ports` through this bag (see `src/app/view-ports.ts`);
 * fixture/generic components may ignore it.
 *
 * The registry is a HETEROGENEOUS component bag (the plan types it `Component`
 * outright), so its props are opaque `Record<string, any>` — the wrappers
 * re-narrow to their concrete props at the composition-root seam.
 */
export type ViewComponent =
  // biome-ignore lint/suspicious/noExplicitAny: opaque props of a heterogeneous component bag
  Component<Record<string, any>>;

export interface ViewRegistration {
  /** Registry id — the same id a leaf's `view` field names. */
  readonly id: string;
  /** Human label shown in panel dropdowns. */
  readonly label: string;
  /** Svelte 5 component, already props-wired (see `src/app/register-views.ts`). */
  readonly component: ViewComponent;
  /** Whether the view may appear in panel dropdowns (default true). */
  readonly panelEligible?: boolean;
  /** The plugin that contributed the view (plan 03 requires it for those). */
  readonly pluginId?: string;
}

/** A registered view with `panelEligible` normalized (default true). */
export interface RegisteredView {
  readonly id: string;
  readonly label: string;
  readonly component: ViewComponent;
  readonly panelEligible: boolean;
  readonly pluginId: string | undefined;
}

const byId = new Map<string, RegisteredView>();
const store = writable<readonly RegisteredView[]>([]);

function publish(): void {
  store.set([...byId.values()]);
}

/**
 * Register a view. A duplicate id throws LOUDLY at dev time — two components
 * claiming one id is a composition-root bug, never a runtime fallback case.
 */
export function registerView(registration: ViewRegistration): void {
  if (byId.has(registration.id)) {
    throw new Error(`registerView: duplicate view id "${registration.id}"`);
  }
  byId.set(registration.id, {
    id: registration.id,
    label: registration.label,
    component: registration.component,
    panelEligible: registration.panelEligible ?? true,
    pluginId: registration.pluginId,
  });
  publish();
}

/** Remove a view. Idempotent (removing an unknown id is a no-op). */
export function unregisterView(id: string): void {
  if (byId.delete(id)) publish();
}

/**
 * Register a PLUGIN view (plan 03) — the ABI-facing registration: the
 * `pluginId` attribution is FORCED to the importing plugin (plugins cannot
 * attribute views to another plugin or to no plugin), which is what makes
 * `unregisterViewsByPlugin`'s unload sweep exact. Returns the per-view
 * disposer (the plugin's deactivate contract). A duplicate id throws
 * LOUDLY — the driver catches it and marks the plugin's FE half failed.
 */
export function registerPluginView(
  registration: Omit<ViewRegistration, "pluginId">,
  pluginId: string,
): () => void {
  registerView({ ...registration, pluginId });
  return () => unregisterView(registration.id);
}

/**
 * Remove every view attributed to one plugin (the plan-03 unload sweep).
 * Idempotent; the reactive registry re-renders open dropdowns, and tiles
 * holding removed views fall back to `FallbackTile` (never a crash).
 */
export function unregisterViewsByPlugin(pluginId: string): void {
  let changed = false;
  for (const [id, view] of byId) {
    if (view.pluginId === pluginId) {
      byId.delete(id);
      changed = true;
    }
  }
  if (changed) publish();
}

/** Look a view up by id (undefined → the renderer shows its fallback tile). */
export function getView(id: string): RegisteredView | undefined {
  return byId.get(id);
}

/** All registered views, in registration order (snapshot). */
export function listViews(): readonly RegisteredView[] {
  return [...byId.values()];
}

/** The panel-eligible subset (`panelEligible: true`) — panel dropdown options. */
export function listPanelViews(): readonly RegisteredView[] {
  return listViews().filter((v) => v.panelEligible);
}

/**
 * Reactive registry contents (registration order). Subscribe in components —
 * e.g. `$viewRegistry` in a panel dropdown — so late registrations (plan 03)
 * re-render open dropdowns.
 */
export const viewRegistry: Readable<readonly RegisteredView[]> = {
  subscribe: store.subscribe,
};