Files
orca/src/main/runtime/client-session-tab-selection.ts
T

221 lines
8.0 KiB
TypeScript

import {
CLIENT_NAVIGATION_PUBLICATION_EPOCH_SUFFIX,
type RuntimeMobileSessionTabsResult
} from '../../shared/runtime-types'
import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types'
import {
forgetClosedClientSessionTabsByWorktree,
projectWithoutClosedClientSessionTabs
} from './client-session-tab-closed-selection'
import type { StoredClientSessionTabSelection } from './client-session-tab-closed-selection'
import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence'
import {
deriveClientSessionTabSelection,
emptyClientSessionTabSelection,
projectClientSessionTabSelection,
activateClientSessionTabSelection,
topLevelTabId,
type ClientSessionTabSelection
} from './client-session-tab-selection-projection'
export class ClientSessionTabSelectionStore {
private statesByClient = new Map<string, Map<string, StoredClientSessionTabSelection>>()
private persistListener: ((state: PersistedMobileClientTabSelections) => void) | null = null
// Why: selections previously died with the process, so a host restart snapped every phone back to the first tab (deterministic-topology fallback).
hydrate(persisted: PersistedMobileClientTabSelections): void {
for (const [clientNavigationId, selectionsByWorktree] of Object.entries(
normalizePersistedMobileClientTabSelections(persisted)
)) {
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) {
statesByWorktree.set(worktreeId, {
selection,
revision: 0,
shouldPersist: true,
closedTabIds: new Set()
})
}
}
}
setPersistListener(listener: (state: PersistedMobileClientTabSelections) => void): void {
this.persistListener = listener
}
serialize(): PersistedMobileClientTabSelections {
const persisted: PersistedMobileClientTabSelections = {}
for (const [clientNavigationId, statesByWorktree] of this.statesByClient) {
const entries: Record<string, ClientSessionTabSelection> = {}
for (const [worktreeId, state] of statesByWorktree) {
if (state.shouldPersist) {
entries[worktreeId] = state.selection
}
}
if (Object.keys(entries).length > 0) {
persisted[clientNavigationId] = entries
}
}
return persisted
}
private persistNow(): void {
this.persistListener?.(this.serialize())
}
private getStatesByWorktree(
clientNavigationId: string
): Map<string, StoredClientSessionTabSelection> {
let statesByWorktree = this.statesByClient.get(clientNavigationId)
if (!statesByWorktree) {
statesByWorktree = new Map()
this.statesByClient.set(clientNavigationId, statesByWorktree)
}
return statesByWorktree
}
project(
snapshot: RuntimeMobileSessionTabsResult,
clientNavigationId?: string
): RuntimeMobileSessionTabsResult {
if (!clientNavigationId) {
return snapshot
}
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
const existingState = statesByWorktree.get(snapshot.worktree)
const state = existingState ?? {
selection: emptyClientSessionTabSelection(),
revision: 0,
shouldPersist: false,
closedTabIds: new Set<string>()
}
const closed = projectWithoutClosedClientSessionTabs(snapshot, state.closedTabIds)
if (closed.snapshot.tabs.length === 0) {
if (existingState) {
statesByWorktree.set(snapshot.worktree, {
...state,
closedTabIds: closed.retainedClosedTabIds
})
}
// Why: an empty snapshot has no topology to project; writing it back would wipe a restart-hydrated selection before tabs arrive.
return {
...closed.snapshot,
publicationEpoch: `${snapshot.publicationEpoch}${CLIENT_NAVIGATION_PUBLICATION_EPOCH_SUFFIX}`,
snapshotVersion: snapshot.snapshotVersion + state.revision
}
}
// Why: until the device picks a tab here, opening the worktree lands on what the host shows;
// clients keep their own tab while open, so this never moves a device mid-visit.
const selection = state.shouldPersist
? state.selection
: deriveClientSessionTabSelection(closed.snapshot)
const projected = projectClientSessionTabSelection(
closed.snapshot,
selection,
state.topologyBeforeClose
)
// Why: a browser guest process swap drops its tab for one snapshot; the topology fallback
// must not overwrite (and persist) the device's explicit pick, or focus never returns.
const selectionSurvived =
!selection.activeTabId || closed.snapshot.tabs.some((tab) => tab.id === selection.activeTabId)
statesByWorktree.set(snapshot.worktree, {
selection: selectionSurvived ? projected.selection : selection,
revision: state.revision,
shouldPersist: state.shouldPersist,
closedTabIds: closed.retainedClosedTabIds,
// Why: frozen while the pick is gone, so a close's fallback holds across snapshots.
topologyBeforeClose: selectionSurvived
? { tabGroups: closed.snapshot.tabGroups, tabGroupLayout: closed.snapshot.tabGroupLayout }
: state.topologyBeforeClose
})
return {
...projected.snapshot,
publicationEpoch: `${snapshot.publicationEpoch}${CLIENT_NAVIGATION_PUBLICATION_EPOCH_SUFFIX}`,
snapshotVersion: snapshot.snapshotVersion + state.revision
}
}
activate(
snapshot: RuntimeMobileSessionTabsResult,
clientNavigationId: string,
activeTabId: string
): RuntimeMobileSessionTabsResult {
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
const state = statesByWorktree.get(snapshot.worktree) ?? {
selection: emptyClientSessionTabSelection(),
revision: 0,
shouldPersist: false,
closedTabIds: new Set<string>()
}
const nextSelection = activateClientSessionTabSelection(snapshot, state.selection, activeTabId)
const activeTab = snapshot.tabs.find((tab) => tab.id === activeTabId)
const closedTabIds = new Set(state.closedTabIds)
closedTabIds.delete(activeTabId)
if (activeTab) {
closedTabIds.delete(topLevelTabId(activeTab))
}
statesByWorktree.set(snapshot.worktree, {
...state,
selection: nextSelection,
revision: state.revision + 1,
shouldPersist: true,
closedTabIds
})
this.persistNow()
return this.project(snapshot, clientNavigationId)
}
forgetTabs(worktreeId: string, tabIds: readonly string[]): void {
if (tabIds.length === 0) {
return
}
if (forgetClosedClientSessionTabsByWorktree(this.statesByClient, worktreeId, tabIds)) {
this.persistNow()
}
}
forgetClient(clientNavigationId: string): void {
const statesByWorktree = this.statesByClient.get(clientNavigationId)
const hadPersistedState = [...(statesByWorktree?.values() ?? [])].some(
(state) => state.shouldPersist
)
if (this.statesByClient.delete(clientNavigationId) && hadPersistedState) {
this.persistNow()
}
}
migrateWorktree(oldWorktreeId: string, newWorktreeId: string): void {
if (oldWorktreeId === newWorktreeId) {
return
}
let changed = false
for (const statesByWorktree of this.statesByClient.values()) {
const state = statesByWorktree.get(oldWorktreeId)
if (!state) {
continue
}
statesByWorktree.set(newWorktreeId, state)
statesByWorktree.delete(oldWorktreeId)
changed = state.shouldPersist || changed
}
if (changed) {
this.persistNow()
}
}
forgetWorktree(worktreeId: string): void {
let changed = false
for (const [clientNavigationId, statesByWorktree] of this.statesByClient) {
const state = statesByWorktree.get(worktreeId)
changed = Boolean(state?.shouldPersist) || changed
statesByWorktree.delete(worktreeId)
if (statesByWorktree.size === 0) {
this.statesByClient.delete(clientNavigationId)
}
}
if (changed) {
this.persistNow()
}
}
}