Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { collectOrcadMigrationSourceClientState } from './orcad-source-client-state'
import { ORCAD_MIGRATION_UI_ROUTING_MERGE } from './orcad-destination-client-state'
import { parseMobileSelections } from '../../../shared/orcad-migration/orcad-migration-client-state-parsing'

const source = {
sshTargetId: 'target-1',
Expand Down Expand Up @@ -140,6 +141,47 @@ describe('source client-state migration', () => {
})
})

it("moves a device's tab history with the tabs that move", () => {
const current = state()
current.mobileClientTabSelectionsByDeviceId = {
phone: {
'ssh:target-1|repo-1::/srv/worktree': {
activeTabId: 'tab-2',
activeGroupId: null,
activeTabIdByGroupId: {},
recentTabIds: ['tab-1', 'tab-unmoved', 'tab-2']
}
}
}
const scoped = session({
tabsByWorktree: {
'ssh:target-1|repo-1::/srv/worktree': [
terminalTab('tab-1', 'ssh:target-1|repo-1::/srv/worktree'),
terminalTab('tab-2', 'ssh:target-1|repo-1::/srv/worktree')
]
}
})

const result = collectOrcadMigrationSourceClientState(
current,
source,
catalog,
'environment-1',
scoped
)

const moved = result.payload?.mobileClientTabSelectionsByDeviceId
expect(moved?.phone?.['repo-1::/srv/worktree']?.recentTabIds).toEqual(['tab-1', 'tab-2'])
expect(parseMobileSelections(moved)).toEqual(moved)
expect(() =>
parseMobileSelections({
phone: {
wt: { activeTabId: 'a', activeGroupId: null, activeTabIdByGroupId: {}, recentTabIds: [7] }
}
})
).toThrow('orcad_migration_dormant_mobile_selection_tabs_invalid')
})

it("never sends the desktop's sidebar filters, which an older server refuses when they differ", () => {
const current = state()
current.ui.workspaceHostScope = 'ssh:target-1'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ function representableMobileSelection(
): PersistedMobileClientTabSelection | null {
const tabIds = eligible?.tabIds ?? new Set<string>()
const groupIds = eligible?.groupIds ?? new Set<string>()
const recentTabIds = selection.recentTabIds?.filter((tabId) => tabIds.has(tabId)) ?? []
const next: PersistedMobileClientTabSelection = {
activeTabId:
selection.activeTabId !== null && movedMobileTab(selection.activeTabId, tabIds)
Expand All @@ -234,7 +235,8 @@ function representableMobileSelection(
Object.entries(selection.activeTabIdByGroupId).filter(
([groupId, tabId]) => groupIds.has(groupId) && tabIds.has(tabId)
)
)
),
...(recentTabIds.length > 0 ? { recentTabIds } : {})
}
return next.activeTabId === null &&
next.activeGroupId === null &&
Expand Down
22 changes: 10 additions & 12 deletions src/main/runtime/client-session-tab-closed-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ import type {
RuntimeMobileSessionClientTab,
RuntimeMobileSessionTabsResult
} from '../../shared/runtime-types'
import type { ClientSessionTabSelection } from './client-session-tab-selection'
import type {
ClientSessionTabSelection,
ClientSessionTabTopology
} from './client-session-tab-selection-projection'

export type StoredClientSessionTabSelection = {
selection: ClientSessionTabSelection
revision: number
shouldPersist: boolean
closedTabIds: ReadonlySet<string>
/** What this client was shown while its pick was still there; not persisted. */
topologyBeforeClose?: ClientSessionTabTopology
}

function topLevelTabId(tab: RuntimeMobileSessionClientTab): string {
Expand Down Expand Up @@ -61,18 +66,11 @@ export function forgetClosedClientSessionSelection(
const closedTabIds = new Set([...existingClosedTabIds, ...forgotten])
const activeTabId =
selection.activeTabId && forgotten.has(selection.activeTabId) ? null : selection.activeTabId
const activeTabIdByGroupId = Object.fromEntries(
Object.entries(selection.activeTabIdByGroupId).filter(
([, selectedTabId]) => !forgotten.has(selectedTabId)
)
)
// Why: the group picks stay so the next projection moves them by the close rule.
return {
selection: { ...selection, activeTabId, activeTabIdByGroupId },
selection: { ...selection, activeTabId },
closedTabIds,
selectionChanged:
activeTabId !== selection.activeTabId ||
Object.keys(activeTabIdByGroupId).length !==
Object.keys(selection.activeTabIdByGroupId).length,
selectionChanged: activeTabId !== selection.activeTabId,
closureChanged: closedTabIds.size !== existingClosedTabIds.size
}
}
Expand All @@ -93,9 +91,9 @@ export function forgetClosedClientSessionTabsByWorktree(
continue
}
statesByWorktree.set(worktreeId, {
...state,
selection: closed.selection,
revision: state.revision + 1,
shouldPersist: state.shouldPersist,
closedTabIds: closed.closedTabIds
})
persistedSelectionChanged ||= state.shouldPersist && closed.selectionChanged
Expand Down
10 changes: 9 additions & 1 deletion src/main/runtime/client-session-tab-selection-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ function normalizeClientSessionTabSelection(
if (!activeTabId && !activeGroupId && Object.keys(activeTabIdByGroupId).length === 0) {
return null
}
return { activeTabId, activeGroupId, activeTabIdByGroupId }
const recentTabIds = Array.isArray(candidate.recentTabIds)
? candidate.recentTabIds.filter((tabId): tabId is string => typeof tabId === 'string')
: []
return {
activeTabId,
activeGroupId,
activeTabIdByGroupId,
...(recentTabIds.length > 0 ? { recentTabIds } : {})
}
}

// Why: this state comes off disk (and, for remote runtimes, another machine); a bad payload must degrade to "no selection", not throw.
Expand Down
161 changes: 161 additions & 0 deletions src/main/runtime/client-session-tab-selection-projection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import type {
RuntimeMobileSessionClientTab,
RuntimeMobileSessionTabsResult
} from '../../shared/runtime-types'
import { pickNextActiveTabAfterClose } from '../../shared/workspace-session-terminal-tab-close'
import { findSiblingGroupId } from '../../shared/workspace-layout/tab-group-layout-tree'

export type ClientSessionTabSelection = {
activeTabId: string | null
activeGroupId: string | null
activeTabIdByGroupId: Readonly<Record<string, string>>
/** Top-level tabs this client showed, most recent last; picks its next tab when a shown one closes. */
recentTabIds?: string[]
}

/** The groups and split a client was shown; a close reads where its tab and group sat. */
export type ClientSessionTabTopology = Pick<
RuntimeMobileSessionTabsResult,
'tabGroups' | 'tabGroupLayout'
>

export function emptyClientSessionTabSelection(): ClientSessionTabSelection {
return { activeTabId: null, activeGroupId: null, activeTabIdByGroupId: {} }
}

export function topLevelTabId(tab: RuntimeMobileSessionClientTab): string {
if (tab.type === 'terminal') {
return tab.parentTabId
}
return tab.id
}

function findTabByTopLevelId(
snapshot: RuntimeMobileSessionTabsResult,
topLevelId: string | null | undefined
): RuntimeMobileSessionClientTab | null {
if (!topLevelId) {
return null
}
return snapshot.tabs.find((tab) => topLevelTabId(tab) === topLevelId) ?? null
}

export function deriveClientSessionTabSelection(
snapshot: RuntimeMobileSessionTabsResult
): ClientSessionTabSelection {
return {
activeTabId: snapshot.activeTabId,
activeGroupId: snapshot.activeGroupId,
activeTabIdByGroupId: Object.fromEntries(
snapshot.tabGroups?.flatMap((group) =>
group.activeTabId ? [[group.id, group.activeTabId] as const] : []
) ?? []
),
// Why: until the client picks a tab, it has shown what the host showed.
recentTabIds: snapshot.tabGroups?.flatMap((group) => group.recentTabIds ?? []) ?? []
}
}

export function activateClientSessionTabSelection(
snapshot: RuntimeMobileSessionTabsResult,
selection: ClientSessionTabSelection,
activeTabId: string
): ClientSessionTabSelection {
const activeTab = snapshot.tabs.find((tab) => tab.id === activeTabId)
if (!activeTab) {
return selection
}
const activeTopLevelTabId = topLevelTabId(activeTab)
const activeGroup = snapshot.tabGroups?.find((group) =>
group.tabOrder.includes(activeTopLevelTabId)
)
const presentTabIds = new Set(snapshot.tabs.map(topLevelTabId))
return {
activeTabId,
activeGroupId: activeGroup?.id ?? selection.activeGroupId,
activeTabIdByGroupId: activeGroup
? { ...selection.activeTabIdByGroupId, [activeGroup.id]: activeTopLevelTabId }
: selection.activeTabIdByGroupId,
recentTabIds: [
...(selection.recentTabIds ?? []).filter(
(id) => id !== activeTopLevelTabId && presentTabIds.has(id)
),
activeTopLevelTabId
]
}
}

export function projectClientSessionTabSelection(
snapshot: RuntimeMobileSessionTabsResult,
selection: ClientSessionTabSelection,
before?: ClientSessionTabTopology
): { snapshot: RuntimeMobileSessionTabsResult; selection: ClientSessionTabSelection } {
const activeTabIdByGroupId: Record<string, string> = {}
const tabGroups = snapshot.tabGroups?.map((group) => {
const selected = selection.activeTabIdByGroupId[group.id]
// Why: a pick that left its group moves on by this client's recency, as desktop close and move do.
const activeTabId =
selected && !group.tabOrder.includes(selected)
? pickNextActiveTabAfterClose(
{
tabOrder:
before?.tabGroups?.find((candidate) => candidate.id === group.id)?.tabOrder ??
group.tabOrder,
// Why: a selection saved before clients kept recency falls back to the host's, as desktop's.
recentTabIds: selection.recentTabIds ?? group.recentTabIds
},
new Set([selected]),
group.tabOrder,
new Set(group.tabOrder)
)
: (selected ?? group.tabOrder[0] ?? null)
if (activeTabId) {
activeTabIdByGroupId[group.id] = activeTabId
}
return { ...group, activeTabId }
})
const shownGroups = tabGroups?.filter((group) => group.tabOrder.length > 0)
// Why: as the desktop's collapseGroupLayout, an emptied group hands over to its split sibling.
const siblingGroupId =
selection.activeGroupId && before?.tabGroupLayout
? findSiblingGroupId(before.tabGroupLayout, selection.activeGroupId)
: null
const selectedGroup =
shownGroups?.find((group) => group.id === selection.activeGroupId) ??
shownGroups?.find((group) => group.id === siblingGroupId) ??
shownGroups?.[0]
// Why: preserve the client's leaf and group choices before falling back to shared snapshot order.
const activeTab =
snapshot.tabs.find((tab) => tab.id === selection.activeTabId) ??
findTabByTopLevelId(snapshot, selectedGroup?.activeTabId) ??
findTabByTopLevelId(snapshot, selectedGroup?.tabOrder[0]) ??
snapshot.tabs[0] ??
null
const activeTopLevelTabId = activeTab ? topLevelTabId(activeTab) : null
const activeGroupId =
tabGroups?.find((group) =>
activeTopLevelTabId ? group.tabOrder.includes(activeTopLevelTabId) : false
)?.id ??
(selection.activeGroupId && tabGroups?.some((group) => group.id === selection.activeGroupId)
? selection.activeGroupId
: null) ??
tabGroups?.[0]?.id ??
null
const nextSelection: ClientSessionTabSelection = {
activeTabId: activeTab?.id ?? null,
activeGroupId,
activeTabIdByGroupId,
...(selection.recentTabIds ? { recentTabIds: selection.recentTabIds } : {})
}
return {
selection: nextSelection,
snapshot: {
...snapshot,
activeGroupId,
activeTabId: activeTab?.id ?? null,
activeTabType: activeTab?.type ?? null,
...(tabGroups ? { tabGroups } : {}),
tabs: snapshot.tabs.map((tab) => ({ ...tab, isActive: tab.id === activeTab?.id }))
}
}
}
Loading
Loading