| 1 | import type { ComponentProps } from "react"; |
| 2 | import type { WorkspacePanel } from "../components/WorkspacePanel"; |
| 3 | |
| 4 | export const requestKeys = ["changeRevealRequest", "verificationRevealRequest", "fileListRequest", "changeListRequest"] as const; |
| 5 | export type DockRequests = Pick<ComponentProps<typeof WorkspacePanel>, typeof requestKeys[number]> & { navigationSignal?: AbortSignal }; |
| 6 | export const emptyDockRequests: DockRequests = Object.fromEntries(requestKeys.map(key => [key, null])); |
| 7 | type Occurrence = { controller: AbortController; revision: number; accepted: Partial<Record<typeof requestKeys[number], number>>; snapshot: DockRequests }; |
| 8 | |
| 9 | /** Committed navigation belongs to an open view, independently of its React body. */ |
| 10 | export class DockNavigation { |
| 11 | private scope = ""; |
| 12 | private view: string | null = null; |
| 13 | private source: Partial<Record<typeof requestKeys[number], string>> = {}; |
| 14 | private occurrences = new Map<string, Occurrence>(); |
| 15 | private listeners = new Set<() => void>(); |
| 16 | private snapshot = emptyDockRequests; |
| 17 | private attachment = 0; |
| 18 | // StrictMode reconnects effects synchronously without ending the runtime. |
| 19 | attach(): () => void { |
| 20 | const attachment = ++this.attachment; |
| 21 | return () => queueMicrotask(() => { if (this.attachment === attachment) this.dispose(); }); |
| 22 | } |
| 23 | matches(scope: string, view: string | null): boolean { return this.scope === scope && this.view === view; } |
| 24 | restoredView(scope: string, view: string | null): DockRequests { |
| 25 | const occurrence = this.scope === scope && view ? this.occurrences.get(view) : undefined; |
| 26 | return occurrence ? { ...emptyDockRequests, navigationSignal: occurrence.controller.signal } : emptyDockRequests; |
| 27 | } |
| 28 | subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; |
| 29 | getSnapshot = () => this.snapshot; |
| 30 | |
| 31 | reconcile(openViews: readonly string[]): void { |
| 32 | for (const [id, occurrence] of this.occurrences) { |
| 33 | if (openViews.includes(id)) continue; |
| 34 | occurrence.controller.abort(); |
| 35 | this.occurrences.delete(id); |
| 36 | if (this.view === id) { this.view = null; this.publish(emptyDockRequests); } |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | commit(scope: string, view: string | null, incoming: DockRequests, openViews?: readonly string[]): void { |
| 41 | if (this.scope !== scope) { this.clear(); this.scope = scope; } |
| 42 | if (openViews) this.reconcile(openViews); |
| 43 | const switched = this.view !== view; |
| 44 | this.view = view; |
| 45 | if (!view) { this.publish(emptyDockRequests); return; } |
| 46 | let occurrence = this.occurrences.get(view); |
| 47 | if (!occurrence) { |
| 48 | const controller = new AbortController(); |
| 49 | occurrence = { controller, revision: 0, accepted: {}, snapshot: { ...emptyDockRequests, navigationSignal: controller.signal } }; |
| 50 | this.occurrences.set(view, occurrence); |
| 51 | } |
| 52 | const identity = (key: typeof requestKeys[number]) => { |
| 53 | const value = incoming[key]; |
| 54 | return value ? JSON.stringify([value.navigationSource ?? key, value.id, "tabId" in value ? value.tabId : null]) : undefined; |
| 55 | }; |
| 56 | const changed = requestKeys.filter(key => this.source[key] !== identity(key)); |
| 57 | if (changed.length) { |
| 58 | const next = { ...(switched ? emptyDockRequests : occurrence.snapshot), navigationSignal: occurrence.controller.signal }; |
| 59 | const target = occurrence; |
| 60 | for (const key of changed) { |
| 61 | const value = incoming[key]; |
| 62 | const revision = ++target.revision; |
| 63 | const acceptNavigation = () => { |
| 64 | if (value?.navigationCancellation?.aborted || target.controller.signal.aborted || target.accepted[key] === revision || this.view !== view || this.scope !== scope |
| 65 | || target.snapshot[key]?.acceptNavigation !== acceptNavigation) return false; |
| 66 | target.accepted[key] = revision; |
| 67 | return true; |
| 68 | }; |
| 69 | Object.assign(next, { [key]: value ? { ...value, acceptNavigation } : null }); |
| 70 | this.source[key] = identity(key); |
| 71 | } |
| 72 | occurrence.snapshot = next; |
| 73 | } else if (switched) occurrence.snapshot = { ...emptyDockRequests, navigationSignal: occurrence.controller.signal }; |
| 74 | this.publish(occurrence.snapshot); |
| 75 | } |
| 76 | |
| 77 | private publish(snapshot: DockRequests): void { |
| 78 | if (this.snapshot === snapshot) return; |
| 79 | this.snapshot = snapshot; |
| 80 | for (const listener of this.listeners) listener(); |
| 81 | } |
| 82 | |
| 83 | private clear(): void { |
| 84 | for (const occurrence of this.occurrences.values()) occurrence.controller.abort(); |
| 85 | this.occurrences.clear(); |
| 86 | this.view = null; |
| 87 | } |
| 88 | |
| 89 | dispose(): void { this.clear(); this.publish(emptyDockRequests); } |
| 90 | } |
| 91 |