mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
fix(wsl-mover): finish the main rewrite before the window loads, keep one copy of each moved tab, and copy instead of move (D1b-7c)
This commit is contained in:
@@ -157,7 +157,8 @@ function assertWslMoveUpdate(
|
||||
}
|
||||
|
||||
/**
|
||||
* The rewrite ledger only grows, and only while the desktop rewrites (`destination-committed`):
|
||||
* The rewrite ledger, and the seeded host workspaces beside it, only grow, and only while the
|
||||
* desktop rewrites (`destination-committed`):
|
||||
* append-only and without duplicates, so a stale writer cannot drop another's entry. Leaving that
|
||||
* phase needs a complete registry and every registered store recorded.
|
||||
*/
|
||||
@@ -174,6 +175,15 @@ function assertWslMoveLedgerUpdate(existing: WslMoveCutover, next: WslMoveCutove
|
||||
) {
|
||||
throw new Error('wsl_move_ledger_changed')
|
||||
}
|
||||
const seededBefore = existing.seededHostWorkspaceIds ?? []
|
||||
const seededAfter = new Set(next.seededHostWorkspaceIds ?? [])
|
||||
if (
|
||||
!seededBefore.every((id) => seededAfter.has(id)) ||
|
||||
(seededAfter.size > seededBefore.length &&
|
||||
(existing.phase !== 'destination-committed' || next.phase !== 'destination-committed'))
|
||||
) {
|
||||
throw new Error('wsl_move_ledger_changed')
|
||||
}
|
||||
if (
|
||||
existing.phase === 'destination-committed' &&
|
||||
next.phase === 'client-rewritten' &&
|
||||
|
||||
@@ -12,6 +12,8 @@ export type WslMoveDesktopRewriteContext = {
|
||||
rewritePath: OrcadMigrationPathRewriter
|
||||
/** The execution host the moved projects now belong to. */
|
||||
destinationHostId: `runtime:${string}`
|
||||
/** Durable before it returns; `cutover.seededHostWorkspaceIds` holds what earlier runs recorded. */
|
||||
recordSeededHostWorkspaceIds: (workspaceIds: readonly string[]) => void
|
||||
}
|
||||
|
||||
export type WslMoveMainRewriteStore = {
|
||||
|
||||
@@ -30,14 +30,41 @@ export type WslMoveDesktopRewritePassOptions = {
|
||||
warn?: (message: string) => void
|
||||
}
|
||||
|
||||
function rewriteContext(cutover: Readonly<WslMoveCutover>): WslMoveDesktopRewriteContext {
|
||||
function rewriteContext(
|
||||
userDataPath: string,
|
||||
cutover: Readonly<WslMoveCutover>
|
||||
): WslMoveDesktopRewriteContext {
|
||||
return {
|
||||
cutover,
|
||||
rewritePath: createUncToLinuxPathRewriter(cutover.manifest.source.distro),
|
||||
destinationHostId: toRuntimeExecutionHostId(cutover.destinationEnvironmentId)
|
||||
destinationHostId: toRuntimeExecutionHostId(cutover.destinationEnvironmentId),
|
||||
recordSeededHostWorkspaceIds: (workspaceIds) =>
|
||||
recordSeededHostWorkspaces(userDataPath, cutover.migrationId, workspaceIds)
|
||||
}
|
||||
}
|
||||
|
||||
/** Through the journal's compare-and-swap, like a ledger entry: the record only grows. */
|
||||
function recordSeededHostWorkspaces(
|
||||
userDataPath: string,
|
||||
migrationId: string,
|
||||
workspaceIds: readonly string[]
|
||||
): void {
|
||||
const current = readCommittedCutover(userDataPath, migrationId)
|
||||
if (!current) {
|
||||
throw new Error('wsl_move_record_missing')
|
||||
}
|
||||
const recorded = current.seededHostWorkspaceIds ?? []
|
||||
const added = workspaceIds.filter((id) => !recorded.includes(id))
|
||||
if (added.length === 0) {
|
||||
return
|
||||
}
|
||||
writeWslMoveCutover(userDataPath, {
|
||||
...current,
|
||||
seededHostWorkspaceIds: [...recorded, ...added],
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/** Fresh from disk: the ledger write must extend what is there now, not what a pass started with. */
|
||||
function readCommittedCutover(userDataPath: string, migrationId: string): WslMoveCutover | null {
|
||||
const cutover = listWslMoveCutovers(userDataPath).find(
|
||||
@@ -106,7 +133,11 @@ async function rewriteMainStores(
|
||||
continue
|
||||
}
|
||||
const timeoutMs = options.storeTimeoutMs ?? WSL_MOVE_REWRITE_STORE_TIMEOUT_MS
|
||||
await withStoreTimeout(() => store.run(rewriteContext(before)), timeoutMs, `${store.id} run`)
|
||||
await withStoreTimeout(
|
||||
() => store.run(rewriteContext(userDataPath, before)),
|
||||
timeoutMs,
|
||||
`${store.id} run`
|
||||
)
|
||||
await withStoreTimeout(() => store.flush(), timeoutMs, `${store.id} flush`)
|
||||
const current = readCommittedCutover(userDataPath, migrationId)
|
||||
if (!current) {
|
||||
@@ -151,7 +182,10 @@ export function runWslMoveDesktopRewritePass(
|
||||
return next
|
||||
}
|
||||
|
||||
/** Startup, and after a move commits: a no-op unless the internal WSL hosts gate is open. */
|
||||
/**
|
||||
* After a move commits: a no-op unless the internal WSL hosts gate is open. Its caller must run it
|
||||
* with no window open, or after the renderer stores (7d) stop writing the old routing back.
|
||||
*/
|
||||
export function runGatedWslMoveDesktopRewritePass(
|
||||
options: WslMoveDesktopRewritePassOptions
|
||||
): Promise<void> {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
resetWslMoveDesktopRewriteStoresForTests
|
||||
} from './wsl-move-desktop-rewrite-stores'
|
||||
import { registerWslMoveMainRewriteStores } from './wsl-move-main-rewrite-stores'
|
||||
import { findCrossHostPaneTabIds } from '../restoring-sessions/cross-host-pane-tab-ids'
|
||||
import { loadWslMoveFence, resetWslMoveFenceForTests } from './wsl-move-scope-guard'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -137,13 +138,19 @@ describe('WSL move main rewrite stores', () => {
|
||||
const [record] = listWslMoveCutovers(testState.dir)
|
||||
expect(record?.rewrittenStores).toEqual(STORE_IDS)
|
||||
expect(record?.phase).toBe('destination-committed')
|
||||
expect(record?.seededHostWorkspaceIds).toEqual([MOVED_ON_HOST])
|
||||
// Read back from the profile database: each store's flush made its write durable.
|
||||
const saved = persisted()
|
||||
expect(saved.ui.lastActiveWorktreeId).toBe(MOVED_ON_HOST)
|
||||
expect(saved.ui.explorerDisplayRootByWorktree).toEqual(
|
||||
destination.ui?.explorerDisplayRootByWorktree
|
||||
)
|
||||
expect(saved.ui.manualRepoOrder).toEqual([{ hostId: HOST, repoId: 'repo-a' }])
|
||||
// The local forms stay beside the host's, for a downgrade.
|
||||
expect(saved.ui.explorerDisplayRootByWorktree).toEqual({
|
||||
...source.ui?.explorerDisplayRootByWorktree,
|
||||
...destination.ui?.explorerDisplayRootByWorktree
|
||||
})
|
||||
expect(saved.ui.manualRepoOrder).toEqual([
|
||||
{ hostId: 'local', repoId: 'repo-a' },
|
||||
{ hostId: HOST, repoId: 'repo-a' }
|
||||
])
|
||||
expect(saved.ui.visibleWorkspaceHostIds).toEqual(['local', HOST])
|
||||
expect(saved.workspaceSession.activeWorktreeId).toBe(MOVED_ON_HOST)
|
||||
expect(saved.workspaceSession.activeWorkspaceExecutionHostId).toBe(HOST)
|
||||
@@ -157,6 +164,10 @@ describe('WSL move main rewrite stores', () => {
|
||||
// The repo that stays on this computer keeps its local rows and gets no host rows.
|
||||
expect(hostSession?.tabsByWorktree['repo-b::/home/dev/lib']).toBeUndefined()
|
||||
expect(saved.workspaceSession.tabsByWorktree[MOVED]?.map((tab) => tab.id)).toEqual(['tab-1'])
|
||||
// Both partitions hold tab-1, but the local one is the moved project's dormant copy.
|
||||
expect(saved.workspaceSession.terminalLayoutsByTabId['tab-1']).toBeDefined()
|
||||
expect(hostSession?.terminalLayoutsByTabId['tab-1']).toBeDefined()
|
||||
expect(findCrossHostPaneTabIds(saved)).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('re-runs a store whose ledger entry a crash lost, changing nothing', async () => {
|
||||
@@ -184,6 +195,33 @@ describe('WSL move main rewrite stores', () => {
|
||||
expect(after.workspaceSessionsByHostId).toEqual(before.workspaceSessionsByHostId)
|
||||
})
|
||||
|
||||
it('never re-seeds a tab the user closed after a seed whose flush timed out', async () => {
|
||||
seedWslMoveCutover(testState.dir, committedMove())
|
||||
loadWslMoveFence(testState.dir)
|
||||
const flush = store.flushPendingOrThrowAsync.bind(store)
|
||||
const hangThirdFlush = vi
|
||||
.spyOn(store, 'flushPendingOrThrowAsync')
|
||||
.mockImplementationOnce(flush)
|
||||
.mockImplementationOnce(flush)
|
||||
.mockImplementationOnce(() => new Promise<void>(() => {}))
|
||||
// Long enough that only the hung flush runs out, even on a loaded machine.
|
||||
await runWslMoveDesktopRewritePass({ userDataPath: testState.dir, storeTimeoutMs: 2_000 })
|
||||
|
||||
const afterTimeout = listWslMoveCutovers(testState.dir)[0]
|
||||
expect(afterTimeout?.rewrittenStores).toEqual(['ui-routing', 'session-globals'])
|
||||
expect(afterTimeout?.seededHostWorkspaceIds).toEqual([MOVED_ON_HOST])
|
||||
// The user closes the seeded tabs before the next pass.
|
||||
store.setWorkspaceSession(getDefaultWorkspaceSession(), HOST)
|
||||
hangThirdFlush.mockRestore()
|
||||
const seed = vi.spyOn(store, 'setWorkspaceSession')
|
||||
|
||||
await runWslMoveDesktopRewritePass({ userDataPath: testState.dir })
|
||||
|
||||
expect(seed).not.toHaveBeenCalled()
|
||||
expect(listWslMoveCutovers(testState.dir)[0]?.rewrittenStores).toEqual(STORE_IDS)
|
||||
expect(store.getWorkspaceSession(HOST).tabsByWorktree[MOVED_ON_HOST]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does nothing for a move that has not committed', async () => {
|
||||
seedWslMoveCutover(testState.dir, {
|
||||
...committedMove(),
|
||||
|
||||
@@ -61,11 +61,14 @@ export function registerWslMoveMainRewriteStores(profile: WslMoveMainRewriteProf
|
||||
const seed = wslMoveHostSessionSeed(
|
||||
carried,
|
||||
profile.getWorkspaceSession(hostId),
|
||||
wslMoveRewriteScope(context)
|
||||
wslMoveRewriteScope(context),
|
||||
new Set(context.cutover.seededHostWorkspaceIds)
|
||||
)
|
||||
if (seed) {
|
||||
// One-shot, ledgered move rewrite; layout commands refuse runtime hosts' partitions.
|
||||
profile.setWorkspaceSession(seed, hostId)
|
||||
profile.setWorkspaceSession(seed.session, hostId)
|
||||
// Recorded before the flush: a crash may then lose the seed, but never revives a closed tab.
|
||||
context.recordSeededHostWorkspaceIds(seed.workspaceIds)
|
||||
}
|
||||
},
|
||||
flush
|
||||
|
||||
@@ -24,6 +24,22 @@ const committed = (rewrittenStores: string[]) =>
|
||||
cutover('move-1', 'destination-committed', { rewrittenStores })
|
||||
|
||||
describe('WSL move rewrite ledger', () => {
|
||||
it('lets the seeded host workspaces only grow, and only while the desktop rewrites', () => {
|
||||
const seeded = (ids: string[] | undefined) =>
|
||||
cutover('move-1', 'destination-committed', { seededHostWorkspaceIds: ids })
|
||||
seedWslMoveCutover(userDataPath, committed([]))
|
||||
writeWslMoveCutover(userDataPath, seeded(['w1']))
|
||||
writeWslMoveCutover(userDataPath, seeded(['w1', 'w2']))
|
||||
|
||||
expect(() => writeWslMoveCutover(userDataPath, seeded(['w2']))).toThrow(
|
||||
'wsl_move_ledger_changed'
|
||||
)
|
||||
expect(() => writeWslMoveCutover(userDataPath, seeded(undefined))).toThrow(
|
||||
'wsl_move_ledger_changed'
|
||||
)
|
||||
expect(listWslMoveCutovers(userDataPath)[0]?.seededHostWorkspaceIds).toEqual(['w1', 'w2'])
|
||||
})
|
||||
|
||||
it('grows append-only while the desktop rewrites', () => {
|
||||
seedWslMoveCutover(userDataPath, committed([]))
|
||||
writeWslMoveCutover(userDataPath, committed(['a']))
|
||||
|
||||
@@ -12,9 +12,15 @@ import { wslMoveHostSessionSeed, wslMoveSessionGlobalsPatch } from './wsl-move-s
|
||||
|
||||
const HOST = toRuntimeExecutionHostId('wsl:Ubuntu')
|
||||
const scope = wslMoveRewriteScope({
|
||||
cutover: wslMoveCutoverFixture('move-1', 'destination-committed', {}, { repoIds: ['repo-a'] }),
|
||||
cutover: wslMoveCutoverFixture(
|
||||
'move-1',
|
||||
'destination-committed',
|
||||
{},
|
||||
{ repoIds: ['repo-a'], folderWorkspaceIds: ['folder-1'] }
|
||||
),
|
||||
rewritePath: createUncToLinuxPathRewriter('Ubuntu'),
|
||||
destinationHostId: HOST
|
||||
destinationHostId: HOST,
|
||||
recordSeededHostWorkspaceIds: () => {}
|
||||
})
|
||||
|
||||
const source = wslMoverProfile('mixed-windows-wsl', 'source')
|
||||
@@ -84,6 +90,25 @@ describe('WSL move session globals', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("moves focus on a moved folder workspace to the host, keeping the folder's key", () => {
|
||||
expect(
|
||||
wslMoveSessionGlobalsPatch(
|
||||
{
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceKey: 'folder:folder-1',
|
||||
activeWorkspaceExecutionHostId: 'local'
|
||||
},
|
||||
scope
|
||||
)
|
||||
).toEqual({ activeWorkspaceExecutionHostId: HOST })
|
||||
expect(
|
||||
wslMoveSessionGlobalsPatch(
|
||||
{ activeWorktreeId: null, activeWorkspaceKey: 'folder:folder-2' },
|
||||
scope
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves focus on another project, or already on a host, alone', () => {
|
||||
const windows = 'repo-win::C:\\src\\win'
|
||||
expect(
|
||||
@@ -109,9 +134,18 @@ describe('WSL move session globals', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** The seeded partition alone. */
|
||||
function seedSession(
|
||||
carried: WorkspaceSessionState,
|
||||
existing: WorkspaceSessionState,
|
||||
seedScope: typeof scope
|
||||
): WorkspaceSessionState | null {
|
||||
return wslMoveHostSessionSeed(carried, existing, seedScope)?.session ?? null
|
||||
}
|
||||
|
||||
describe('WSL move host session seed', () => {
|
||||
it("seeds only the moved project's rows, without chats or PTY bindings", () => {
|
||||
const seed = wslMoveHostSessionSeed(carriedSession(), getDefaultWorkspaceSession(), scope)
|
||||
const seed = seedSession(carriedSession(), getDefaultWorkspaceSession(), scope)
|
||||
|
||||
expect(Object.keys(seed?.tabsByWorktree ?? {}).sort()).toEqual([MOVED_ON_HOST, FEATURE_ON_HOST])
|
||||
expect(seed?.tabsByWorktree[MOVED_ON_HOST]?.map((tab) => tab.ptyId)).toEqual([null])
|
||||
@@ -152,7 +186,7 @@ describe('WSL move host session seed', () => {
|
||||
},
|
||||
tabGroups: { [MOVED_ON_HOST]: [tabGroup('host-group', MOVED_ON_HOST, ['host-tab'])] }
|
||||
}
|
||||
const seed = wslMoveHostSessionSeed(carriedSession(), published, scope)
|
||||
const seed = seedSession(carriedSession(), published, scope)
|
||||
|
||||
expect(seed?.tabsByWorktree[MOVED_ON_HOST]).toEqual(published.tabsByWorktree[MOVED_ON_HOST])
|
||||
expect(seed?.tabGroups).toEqual(published.tabGroups)
|
||||
@@ -162,11 +196,66 @@ describe('WSL move host session seed', () => {
|
||||
})
|
||||
|
||||
it('changes nothing on a second run', () => {
|
||||
const seed = wslMoveHostSessionSeed(carriedSession(), getDefaultWorkspaceSession(), scope)
|
||||
const seed = seedSession(carriedSession(), getDefaultWorkspaceSession(), scope)
|
||||
|
||||
if (!seed) {
|
||||
throw new Error('expected a seed')
|
||||
}
|
||||
expect(wslMoveHostSessionSeed(carriedSession(), seed, scope)).toBeNull()
|
||||
})
|
||||
|
||||
it('never seeds a workspace an earlier run recorded, even once its tabs are closed', () => {
|
||||
const first = wslMoveHostSessionSeed(carriedSession(), getDefaultWorkspaceSession(), scope)
|
||||
|
||||
expect(first?.workspaceIds.sort()).toEqual([MOVED_ON_HOST, FEATURE_ON_HOST])
|
||||
expect(
|
||||
wslMoveHostSessionSeed(
|
||||
carriedSession(),
|
||||
getDefaultWorkspaceSession(),
|
||||
scope,
|
||||
new Set(first?.workspaceIds)
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a group that held only chats, with the layout and focus that named it', () => {
|
||||
const carried = carriedSession()
|
||||
const workGroup = carried.tabGroups?.[MOVED_ON_HOST]?.[0]
|
||||
const chatGroup = tabGroup('chat-group', MOVED_ON_HOST, [chatTab.id])
|
||||
const seed = seedSession(
|
||||
{
|
||||
...carried,
|
||||
unifiedTabs: {
|
||||
...carried.unifiedTabs,
|
||||
[MOVED_ON_HOST]: [
|
||||
...(carried.unifiedTabs?.[MOVED_ON_HOST] ?? []).filter((tab) => tab.id !== chatTab.id),
|
||||
{ ...chatTab, groupId: chatGroup.id }
|
||||
]
|
||||
},
|
||||
tabGroups: {
|
||||
[MOVED_ON_HOST]: [
|
||||
...(workGroup
|
||||
? [{ ...workGroup, tabOrder: workGroup.tabOrder.filter((id) => id !== chatTab.id) }]
|
||||
: []),
|
||||
chatGroup
|
||||
]
|
||||
},
|
||||
tabGroupLayouts: {
|
||||
[MOVED_ON_HOST]: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-1' },
|
||||
second: { type: 'leaf', groupId: chatGroup.id }
|
||||
}
|
||||
},
|
||||
activeGroupIdByWorktree: { [MOVED_ON_HOST]: chatGroup.id }
|
||||
},
|
||||
getDefaultWorkspaceSession(),
|
||||
scope
|
||||
)
|
||||
|
||||
expect(seed?.tabGroups?.[MOVED_ON_HOST]?.map((group) => group.id)).toEqual(['group-1'])
|
||||
expect(seed?.tabGroupLayouts?.[MOVED_ON_HOST]).toEqual({ type: 'leaf', groupId: 'group-1' })
|
||||
expect(seed?.activeGroupIdByWorktree?.[MOVED_ON_HOST]).toBe('group-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* decision, option C), and terminals restart on the host, so neither is seeded.
|
||||
*/
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import type { Tab, TabGroup } from '../../../shared/tab-types'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import { filterWorkspaceSessionTabs } from '../../../shared/workspace-session-tab-filter'
|
||||
import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership'
|
||||
import {
|
||||
mergeWorkspaceSessionArrayField,
|
||||
@@ -59,20 +60,28 @@ export function wslMoveSessionGlobalsPatch(
|
||||
// Restart hints for local PTYs; the host decides what to start for its own terminals.
|
||||
const UNSEEDED_FIELDS: ReadonlySet<string> = new Set(['activeWorktreeIdsOnShutdown'])
|
||||
|
||||
export type WslMoveHostSessionSeed = {
|
||||
session: WorkspaceSessionState
|
||||
workspaceIds: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* `existing` plus the carried rows of every moved workspace it names nothing for, or null when
|
||||
* there is none: what the host already published is never replaced.
|
||||
* `existing` plus the carried rows of every moved workspace it names nothing for and no earlier
|
||||
* run seeded, or null when there is none: what the host published, and a seeded tab the user
|
||||
* has since closed, are never replaced.
|
||||
*/
|
||||
export function wslMoveHostSessionSeed(
|
||||
carried: WorkspaceSessionState,
|
||||
existing: WorkspaceSessionState,
|
||||
scope: WslMoveRewriteScope
|
||||
): WorkspaceSessionState | null {
|
||||
scope: WslMoveRewriteScope,
|
||||
alreadySeeded: ReadonlySet<string> = new Set()
|
||||
): WslMoveHostSessionSeed | null {
|
||||
const published = workspaceIdsNamedByPartition(existing)
|
||||
const stripped = withoutChatTabs(withoutPtyBindings(carried))
|
||||
const seeded = new Set(
|
||||
[...workspaceIdsNamedByPartition(stripped)].filter(
|
||||
(workspaceId) => scope.inScope(workspaceId) && !published.has(workspaceId)
|
||||
(workspaceId) =>
|
||||
scope.inScope(workspaceId) && !published.has(workspaceId) && !alreadySeeded.has(workspaceId)
|
||||
)
|
||||
)
|
||||
if (seeded.size === 0) {
|
||||
@@ -95,7 +104,7 @@ export function wslMoveHostSessionSeed(
|
||||
mergeWorkspaceSessionRecordField(next, field, rows)
|
||||
}
|
||||
}
|
||||
return next
|
||||
return { session: next, workspaceIds: [...seeded] }
|
||||
}
|
||||
|
||||
function isSessionField(field: string): field is keyof WorkspaceSessionState {
|
||||
@@ -130,7 +139,7 @@ function isChatTab(tab: Pick<Tab, 'contentType'>): boolean {
|
||||
return tab.contentType === 'agent-session'
|
||||
}
|
||||
|
||||
/** Chat tabs, and the focus and groups that name them; they stay listed on the local entry. */
|
||||
/** Chat tabs, and the groups, focus and sleeping records that name them; they stay local. */
|
||||
function withoutChatTabs(session: WorkspaceSessionState): WorkspaceSessionState {
|
||||
const chatIds = new Set(
|
||||
Object.values(session.unifiedTabs ?? {}).flatMap((tabs) =>
|
||||
@@ -140,52 +149,17 @@ function withoutChatTabs(session: WorkspaceSessionState): WorkspaceSessionState
|
||||
if (chatIds.size === 0) {
|
||||
return session
|
||||
}
|
||||
const unifiedTabs = Object.fromEntries(
|
||||
Object.entries(session.unifiedTabs ?? {}).map(([owner, tabs]) => [
|
||||
owner,
|
||||
tabs.filter((tab) => !isChatTab(tab))
|
||||
])
|
||||
)
|
||||
const tabGroups = Object.fromEntries(
|
||||
Object.entries(session.tabGroups ?? {}).map(([owner, groups]) => [
|
||||
owner,
|
||||
groups.map((group) => withoutTabs(group, chatIds))
|
||||
])
|
||||
)
|
||||
const notChat = (value: string | null | undefined): boolean => !value || !chatIds.has(value)
|
||||
const filtered = filterWorkspaceSessionTabs(session, (_owner, tab) => !isChatTab(tab))
|
||||
return {
|
||||
...session,
|
||||
unifiedTabs,
|
||||
tabGroups,
|
||||
activeTabIdByWorktree: pickValues(session.activeTabIdByWorktree, notChat),
|
||||
activeTabTypeByWorktree: pickValues(
|
||||
session.activeTabTypeByWorktree,
|
||||
(type) => type !== 'agent-session'
|
||||
),
|
||||
sleepingAgentSessionsByPaneKey: pickValues(session.sleepingAgentSessionsByPaneKey, (record) =>
|
||||
notChat(record.tabId)
|
||||
)
|
||||
...filtered,
|
||||
...(session.sleepingAgentSessionsByPaneKey
|
||||
? {
|
||||
sleepingAgentSessionsByPaneKey: Object.fromEntries(
|
||||
Object.entries(session.sleepingAgentSessionsByPaneKey).filter(
|
||||
([, record]) => !record.tabId || !chatIds.has(record.tabId)
|
||||
)
|
||||
)
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
function withoutTabs(group: TabGroup, tabIds: ReadonlySet<string>): TabGroup {
|
||||
const tabOrder = group.tabOrder.filter((id) => !tabIds.has(id))
|
||||
const recentTabIds = group.recentTabIds?.filter((id) => !tabIds.has(id))
|
||||
const activeTabId =
|
||||
group.activeTabId && !tabIds.has(group.activeTabId)
|
||||
? group.activeTabId
|
||||
: (recentTabIds?.at(-1) ?? tabOrder.at(-1) ?? null)
|
||||
return {
|
||||
...group,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
...(recentTabIds ? { recentTabIds } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function pickValues<T>(
|
||||
record: Record<string, T> | undefined,
|
||||
keep: (value: T) => boolean
|
||||
): Record<string, T> | undefined {
|
||||
return record && Object.fromEntries(Object.entries(record).filter(([, value]) => keep(value)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate'
|
||||
|
||||
const pass = vi.hoisted(() => ({ run: vi.fn<() => Promise<void>>() }))
|
||||
vi.mock('./wsl-move-desktop-rewrite', () => ({ runWslMoveDesktopRewritePass: pass.run }))
|
||||
|
||||
const { runWslMoveStartupRewrite, WSL_MOVE_STARTUP_REWRITE_WAIT_MS } =
|
||||
await import('./wsl-move-startup-rewrite')
|
||||
|
||||
beforeEach(() => {
|
||||
pass.run.mockReset()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('WSL move startup rewrite', () => {
|
||||
it('neither runs the pass nor waits with the gate closed', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '')
|
||||
const recover = vi.fn(async () => {})
|
||||
await runWslMoveStartupRewrite({ userDataPath: '/data' }, recover)
|
||||
expect(recover).not.toHaveBeenCalled()
|
||||
expect(pass.run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets an interrupted move before the pass, and still runs the pass if that fails', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
const order: string[] = []
|
||||
pass.run.mockImplementation(async () => {
|
||||
order.push('pass')
|
||||
})
|
||||
await runWslMoveStartupRewrite({ userDataPath: '/data' }, async () => {
|
||||
order.push('recover')
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
await runWslMoveStartupRewrite({ userDataPath: '/data' }, async () => {
|
||||
order.push('recover-failed')
|
||||
throw new Error('journal unreadable')
|
||||
})
|
||||
expect(order).toEqual(['recover', 'pass', 'recover-failed', 'pass'])
|
||||
})
|
||||
|
||||
it('resolves once the pass finishes', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
let finish: () => void = () => {}
|
||||
pass.run.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
let done = false
|
||||
const waiting = runWslMoveStartupRewrite({ userDataPath: '/data' }).then(() => {
|
||||
done = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(done).toBe(false)
|
||||
finish()
|
||||
await waiting
|
||||
expect(pass.run).toHaveBeenCalledWith({ userDataPath: '/data' })
|
||||
})
|
||||
|
||||
it('stops waiting for a hung pass at the bound', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
pass.run.mockReturnValue(new Promise<void>(() => {}))
|
||||
let done = false
|
||||
void runWslMoveStartupRewrite({ userDataPath: '/data' }).then(() => {
|
||||
done = true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(WSL_MOVE_STARTUP_REWRITE_WAIT_MS - 1)
|
||||
expect(done).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(done).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The startup run of the desktop rewrite stage. The window's first `session:get` and UI read must
|
||||
* see a committed move's rewrite, or the renderer hydrates the old routing and later writes it
|
||||
* back over the rewrite. So startup waits for the pass, but never long.
|
||||
*/
|
||||
import { wslHostsGateOpen } from '../../wsl/wsl-host-gate'
|
||||
import {
|
||||
runWslMoveDesktopRewritePass,
|
||||
type WslMoveDesktopRewritePassOptions
|
||||
} from './wsl-move-desktop-rewrite'
|
||||
|
||||
// A pass is a few profile writes and flushes; past this the window opens and the pass finishes
|
||||
// behind it, with the stale-hydration risk this wait otherwise closes.
|
||||
export const WSL_MOVE_STARTUP_REWRITE_WAIT_MS = 5_000
|
||||
|
||||
/**
|
||||
* Resolves when the pass finishes or the wait runs out; at once with the gate closed. `before`
|
||||
* (the reset of an interrupted move) runs first, inside the same wait, and its failure never
|
||||
* skips the pass.
|
||||
*/
|
||||
export function runWslMoveStartupRewrite(
|
||||
options: WslMoveDesktopRewritePassOptions,
|
||||
before: () => Promise<unknown> = () => Promise.resolve(),
|
||||
waitMs: number = WSL_MOVE_STARTUP_REWRITE_WAIT_MS
|
||||
): Promise<void> {
|
||||
if (!wslHostsGateOpen()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const waited = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, waitMs)
|
||||
timer.unref?.()
|
||||
})
|
||||
const pass = Promise.resolve()
|
||||
.then(before)
|
||||
.catch((error: unknown) => {
|
||||
console.warn('[wsl-move] Startup recovery failed before the desktop rewrite:', error)
|
||||
})
|
||||
.then(() => runWslMoveDesktopRewritePass(options))
|
||||
return Promise.race([pass, waited]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
@@ -11,7 +11,8 @@ const HOST = toRuntimeExecutionHostId('wsl:Ubuntu')
|
||||
const scope = wslMoveRewriteScope({
|
||||
cutover: wslMoveCutoverFixture('move-1', 'destination-committed', {}, { repoIds: ['repo-a'] }),
|
||||
rewritePath: createUncToLinuxPathRewriter('Ubuntu'),
|
||||
destinationHostId: HOST
|
||||
destinationHostId: HOST,
|
||||
recordSeededHostWorkspaceIds: () => {}
|
||||
})
|
||||
|
||||
// The mixed profile: repo-a moves; repo-b (same distro), repo-win and repo-deb stay.
|
||||
@@ -26,6 +27,7 @@ const WINDOWS = `repo-win::${sourceRepo('repo-win')}`
|
||||
const SSH = 'ssh:box' as const
|
||||
const sourceUi = source.ui ?? {}
|
||||
const destinationUi = destination.ui ?? {}
|
||||
const EMPTY_UI: WslMoveUiRoutingState = { lastActiveWorktreeId: null, collapsedGroups: [] }
|
||||
|
||||
function profileUi(): WslMoveUiRoutingState {
|
||||
const ui = sourceUi
|
||||
@@ -84,34 +86,102 @@ function dismissal(
|
||||
}
|
||||
|
||||
describe('WSL move UI routing rewrite', () => {
|
||||
it("routes only the moved project's rows to the host, as the host holds them", () => {
|
||||
const updates = rewriteWslMoveUiRouting(profileUi(), scope)
|
||||
it("copies only the moved project's rows to the host's form, keeping the local ones", () => {
|
||||
const ui = profileUi()
|
||||
const updates = rewriteWslMoveUiRouting(ui, scope)
|
||||
|
||||
expect(updates.lastActiveWorktreeId).toBe(destinationUi.lastActiveWorktreeId)
|
||||
expect(updates.showDotfilesByWorktree).toEqual({
|
||||
...destinationUi.showDotfilesByWorktree,
|
||||
[SAME_DISTRO]: false
|
||||
...ui.showDotfilesByWorktree,
|
||||
...destinationUi.showDotfilesByWorktree
|
||||
})
|
||||
expect(updates.explorerDisplayRootByWorktree).toEqual({
|
||||
...destinationUi.explorerDisplayRootByWorktree,
|
||||
[WINDOWS]: 'C:\\src\\win\\lib'
|
||||
...ui.explorerDisplayRootByWorktree,
|
||||
...destinationUi.explorerDisplayRootByWorktree
|
||||
})
|
||||
expect(updates.workspaceCleanup?.dismissals).toEqual({
|
||||
[`${HOST}\0${MOVED_ON_HOST}`]: {
|
||||
...dismissal(MOVED_ON_HOST),
|
||||
executionHostId: HOST
|
||||
},
|
||||
[`${SSH}\0${MOVED}`]: dismissal(MOVED, SSH),
|
||||
[SAME_DISTRO]: dismissal(SAME_DISTRO)
|
||||
...ui.workspaceCleanup?.dismissals,
|
||||
[`${HOST}\0${MOVED_ON_HOST}`]: dismissal(MOVED_ON_HOST, HOST)
|
||||
})
|
||||
expect(updates.manualRepoOrder).toEqual([
|
||||
{ hostId: 'local', repoId: 'repo-win' },
|
||||
{ hostId: 'local', repoId: 'repo-a' },
|
||||
{ hostId: HOST, repoId: 'repo-a' },
|
||||
{ hostId: 'local', repoId: 'repo-b' },
|
||||
{ hostId: SSH, repoId: 'repo-a' }
|
||||
])
|
||||
})
|
||||
|
||||
it('copies a legacy bare dismissal key, and one with no host, in their own form', () => {
|
||||
const updates = rewriteWslMoveUiRouting(
|
||||
{ ...EMPTY_UI, workspaceCleanup: { dismissals: { [MOVED]: dismissal(MOVED) } } },
|
||||
scope
|
||||
)
|
||||
|
||||
expect(updates.workspaceCleanup?.dismissals).toEqual({
|
||||
[MOVED]: dismissal(MOVED),
|
||||
[MOVED_ON_HOST]: dismissal(MOVED_ON_HOST)
|
||||
})
|
||||
})
|
||||
|
||||
it("maps an in-scope folder workspace's explorer root in place, its key unchanged", () => {
|
||||
const folderScope = wslMoveRewriteScope({
|
||||
cutover: wslMoveCutoverFixture(
|
||||
'move-1',
|
||||
'destination-committed',
|
||||
{},
|
||||
{ repoIds: [], folderWorkspaceIds: ['folder-1'] }
|
||||
),
|
||||
rewritePath: createUncToLinuxPathRewriter('Ubuntu'),
|
||||
destinationHostId: HOST,
|
||||
recordSeededHostWorkspaceIds: () => {}
|
||||
})
|
||||
const other = '\\\\wsl.localhost\\Ubuntu\\srv\\other'
|
||||
const updates = rewriteWslMoveUiRouting(
|
||||
{
|
||||
...EMPTY_UI,
|
||||
explorerDisplayRootByWorktree: {
|
||||
'folder:folder-1': '\\\\wsl.localhost\\Ubuntu\\srv\\notes\\src',
|
||||
'folder:folder-2': other
|
||||
}
|
||||
},
|
||||
folderScope
|
||||
)
|
||||
|
||||
expect(updates.explorerDisplayRootByWorktree).toEqual({
|
||||
'folder:folder-1': '/srv/notes/src',
|
||||
'folder:folder-2': other
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the older wsl$ spelling and a lowercase distro as the moved distro', () => {
|
||||
const bothScope = wslMoveRewriteScope({
|
||||
cutover: wslMoveCutoverFixture(
|
||||
'move-1',
|
||||
'destination-committed',
|
||||
{},
|
||||
{ repoIds: ['repo-a', 'repo-b'] }
|
||||
),
|
||||
rewritePath: createUncToLinuxPathRewriter('Ubuntu'),
|
||||
destinationHostId: HOST,
|
||||
recordSeededHostWorkspaceIds: () => {}
|
||||
})
|
||||
const lowercase = 'repo-a::\\\\wsl.localhost\\ubuntu\\home\\dev\\app'
|
||||
const updates = rewriteWslMoveUiRouting(
|
||||
// SAME_DISTRO is spelled `\\wsl$\Ubuntu\...` by the fixture.
|
||||
{ ...EMPTY_UI, showDotfilesByWorktree: { [SAME_DISTRO]: true, [lowercase]: false } },
|
||||
bothScope
|
||||
)
|
||||
|
||||
expect(SAME_DISTRO).toContain('wsl$')
|
||||
expect(updates.showDotfilesByWorktree).toEqual({
|
||||
[SAME_DISTRO]: true,
|
||||
[lowercase]: false,
|
||||
'repo-b::/home/dev/lib': true,
|
||||
[MOVED_ON_HOST]: false
|
||||
})
|
||||
})
|
||||
|
||||
it('adds the host wherever local is shown, and never removes local', () => {
|
||||
const updates = rewriteWslMoveUiRouting(profileUi(), scope)
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* The persisted UI's routing to a moved project, rewritten to the WSL host after a move commits:
|
||||
* worktree-keyed preferences take the host's ids, and every host filter that shows `local` also
|
||||
* shows the host. Nothing is removed, so the retained local entry keeps its own preferences, and a
|
||||
* second run finds nothing left to change.
|
||||
* The persisted UI's routing to a moved project, extended to the WSL host after a move commits:
|
||||
* each worktree-keyed preference, cleanup dismissal and manual-order row gains a host copy beside
|
||||
* the local one, and every host filter that shows `local` also shows the host. The local forms
|
||||
* stay for a lossless downgrade; only the last-active worktree, a single value, moves. A second
|
||||
* run finds every copy present and changes nothing.
|
||||
*/
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import type { PersistedUIState } from '../../../shared/persisted-ui-state-types'
|
||||
@@ -55,18 +56,29 @@ export function rewriteWslMoveUiRouting(
|
||||
}
|
||||
const hostWorktreeKey = (key: string): string =>
|
||||
scope.inScope(key) ? scope.worktreeId(key) : key
|
||||
const dotfiles = rekey(ui.showDotfilesByWorktree, hostWorktreeKey, (value) => value)
|
||||
const dotfiles = withHostKeys(
|
||||
ui.showDotfilesByWorktree,
|
||||
hostWorktreeKey,
|
||||
(value) => value,
|
||||
scope.inScope
|
||||
)
|
||||
if (dotfiles) {
|
||||
updates.showDotfilesByWorktree = dotfiles
|
||||
}
|
||||
const roots = rekey(ui.explorerDisplayRootByWorktree, hostWorktreeKey, scope.path)
|
||||
const roots = withHostKeys(
|
||||
ui.explorerDisplayRootByWorktree,
|
||||
hostWorktreeKey,
|
||||
scope.path,
|
||||
scope.inScope
|
||||
)
|
||||
if (roots) {
|
||||
updates.explorerDisplayRootByWorktree = roots
|
||||
}
|
||||
const dismissals = rekey(
|
||||
const dismissals = withHostKeys(
|
||||
ui.workspaceCleanup?.dismissals,
|
||||
(key) => hostDismissalKey(key, scope),
|
||||
(dismissal) => rewriteDismissal(dismissal, scope)
|
||||
(dismissal) => rewriteDismissal(dismissal, scope),
|
||||
() => false
|
||||
)
|
||||
if (ui.workspaceCleanup && dismissals) {
|
||||
updates.workspaceCleanup = { ...ui.workspaceCleanup, dismissals }
|
||||
@@ -102,28 +114,34 @@ export function rewriteWslMoveUiRouting(
|
||||
return updates
|
||||
}
|
||||
|
||||
/** Moved keys take the host's form; a key the host form already holds keeps that row. */
|
||||
function rekey<T>(
|
||||
/**
|
||||
* The host form of each moved key beside the local one, which a downgrade still reads. A moved
|
||||
* key that keeps its form (a folder workspace's) has only its value mapped.
|
||||
*/
|
||||
function withHostKeys<T>(
|
||||
record: Record<string, T> | undefined,
|
||||
hostKeyOf: (key: string) => string,
|
||||
mapValue: (value: T) => T
|
||||
mapValue: (value: T) => T,
|
||||
inScope: (key: string) => boolean
|
||||
): Record<string, T> | null {
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
let changed = false
|
||||
const next: Record<string, T> = {}
|
||||
const next: Record<string, T> = { ...record }
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const hostKey = hostKeyOf(key)
|
||||
if (hostKey === key) {
|
||||
if (!Object.hasOwn(next, key)) {
|
||||
next[key] = value
|
||||
if (hostKey !== key) {
|
||||
if (!Object.hasOwn(next, hostKey)) {
|
||||
next[hostKey] = mapValue(value)
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
if (!Object.hasOwn(record, hostKey)) {
|
||||
next[hostKey] = mapValue(value)
|
||||
const mapped = inScope(key) ? mapValue(value) : value
|
||||
if (mapped !== value) {
|
||||
next[key] = mapped
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : null
|
||||
@@ -168,17 +186,17 @@ function rewriteManualRepoOrder(
|
||||
const onHost = new Set(
|
||||
order.filter((entry) => entry.hostId === scope.destinationHostId).map((entry) => entry.repoId)
|
||||
)
|
||||
return order.flatMap((entry) => {
|
||||
if (!isMovedLocalRepo(entry, scope)) {
|
||||
let changed = false
|
||||
const next = order.flatMap((entry) => {
|
||||
// One row per repo and host: a host row already listed keeps its own position.
|
||||
if (!isMovedLocalRepo(entry, scope) || onHost.has(entry.repoId)) {
|
||||
return [entry]
|
||||
}
|
||||
// The host already lists it: one row per repo and host, at the earlier position.
|
||||
if (onHost.has(entry.repoId)) {
|
||||
return []
|
||||
}
|
||||
onHost.add(entry.repoId)
|
||||
return [{ hostId: scope.destinationHostId, repoId: entry.repoId }]
|
||||
changed = true
|
||||
return [entry, { hostId: scope.destinationHostId, repoId: entry.repoId }]
|
||||
})
|
||||
return changed ? next : null
|
||||
}
|
||||
|
||||
function isMovedLocalRepo(entry: ManualRepoOrderEntry, scope: WslMoveRewriteScope): boolean {
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { buildWorktreeIdByTabId } from '../../../shared/workspace-session-host-records'
|
||||
import { isWslMoveWorkspaceHostOwned } from '../moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
/**
|
||||
* Tab ids owned by more than one host partition. Legacy alias keys (`tabId:1`) and acknowledgement
|
||||
* keys (`tabId:leafId`) carry no host segment, so such a tab id resolves to whichever partition was
|
||||
* migrated last — routing one host's pane at another host's terminal. Rewrite neither instead.
|
||||
* A WSL-moved workspace's local rows are a dormant downgrade copy of its host's, not a rival.
|
||||
*/
|
||||
export function findCrossHostPaneTabIds(state: PersistedState): ReadonlySet<string> {
|
||||
export function findCrossHostPaneTabIds(
|
||||
state: PersistedState,
|
||||
isRetainedLocalWorkspace: (workspaceId: string) => boolean = isWslMoveWorkspaceHostOwned
|
||||
): ReadonlySet<string> {
|
||||
const seenTabIds = new Set<string>()
|
||||
const collidingTabIds = new Set<string>()
|
||||
for (const session of [
|
||||
state.workspaceSession,
|
||||
...Object.values(state.workspaceSessionsByHostId ?? {})
|
||||
]) {
|
||||
const localWorkspaceByTabId = buildWorktreeIdByTabId(state.workspaceSession ?? {})
|
||||
const sessions = [
|
||||
{ session: state.workspaceSession, local: true },
|
||||
...Object.values(state.workspaceSessionsByHostId ?? {}).map((session) => ({
|
||||
session,
|
||||
local: false
|
||||
}))
|
||||
]
|
||||
for (const { session, local } of sessions) {
|
||||
for (const tabId of Object.keys(session?.terminalLayoutsByTabId ?? {})) {
|
||||
const workspaceId = local ? localWorkspaceByTabId.get(tabId) : undefined
|
||||
if (workspaceId && isRetainedLocalWorkspace(workspaceId)) {
|
||||
continue
|
||||
}
|
||||
if (seenTabIds.has(tabId)) {
|
||||
collidingTabIds.add(tabId)
|
||||
continue
|
||||
|
||||
@@ -13,8 +13,8 @@ import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
|
||||
import { getCanonicalUserDataPath } from '../persistence'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import { loadWslMoveFence } from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { runGatedWslMoveDesktopRewritePass } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite'
|
||||
import { recoverInterruptedWslMoves } from '../persistence/moving-wsl-projects/wsl-move-reset'
|
||||
import { runWslMoveStartupRewrite } from '../persistence/moving-wsl-projects/wsl-move-startup-rewrite'
|
||||
import { registerWslMoveMainRewriteStores } from '../persistence/moving-wsl-projects/wsl-move-main-rewrite-stores'
|
||||
import { createProfileStateStoreForStartup } from '../persistence/profile-state/profile-state-startup-authority'
|
||||
import { initializeBrowserClientHostId } from '../browser/browser-client-host-id'
|
||||
@@ -135,6 +135,9 @@ export async function initializeReadyFoundation(): Promise<void> {
|
||||
// Why this early: the first window stamps the hosting id into its renderer's argv, so the durable
|
||||
// read has to have happened by then or the renderer and the browser-host lease disagree.
|
||||
initializeBrowserClientHostId(profile.profileDirectory)
|
||||
// Why before the store loads: its pane normalization tells a moved project's retained rows from
|
||||
// a rival host's, and no handler or runtime may give a moving project a new terminal.
|
||||
loadWslMoveFence(getAppEnvironment().getPath('userData'))
|
||||
const profileState = await createProfileStateStoreForStartup({
|
||||
dataFile: profile.dataFile,
|
||||
databaseFile: profile.stateDatabaseFile,
|
||||
@@ -154,12 +157,11 @@ export async function initializeReadyFoundation(): Promise<void> {
|
||||
state.store = store
|
||||
setNodeDistMirrorSettingReader(() => store.getSettings().nodeDistMirrorUrl)
|
||||
const userDataPath = getAppEnvironment().getPath('userData')
|
||||
// Why before any handler or runtime: a project a WSL move is taking must never get a new terminal.
|
||||
loadWslMoveFence(userDataPath)
|
||||
registerWslMoveMainRewriteStores(store)
|
||||
// Resets a move interrupted before commit, then finishes one that committed; gated off, no-ops.
|
||||
void recoverInterruptedWslMoves(userDataPath).then(() =>
|
||||
runGatedWslMoveDesktopRewritePass({ userDataPath })
|
||||
// Resets a move interrupted before commit, then finishes one that committed; main-process-ready
|
||||
// waits on both, bounded. Gated off, neither runs.
|
||||
state.wslMoveStartupRewriteReady = runWslMoveStartupRewrite({ userDataPath }, () =>
|
||||
recoverInterruptedWslMoves(userDataPath)
|
||||
)
|
||||
// Why: create pending readiness before the guard can observe the default session.
|
||||
// Why parked on state instead of awaited here: Dock/Launchpad launches don't inherit shell
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock('./main-process-runtime-launch', () => ({
|
||||
}))
|
||||
|
||||
const { initializeMainProcessReady } = await import('./main-process-ready')
|
||||
const { mainProcessState } = await import('./main-process-state')
|
||||
|
||||
describe('ready-phase concurrency', () => {
|
||||
beforeEach(() => {
|
||||
@@ -86,4 +87,37 @@ describe('ready-phase concurrency', () => {
|
||||
await ready
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('waits for the startup WSL move rewrite before runtime services and the window', async () => {
|
||||
let finishRewrite: () => void = () => {}
|
||||
mainProcessState.wslMoveStartupRewriteReady = new Promise<void>((resolve) => {
|
||||
finishRewrite = () => {
|
||||
phaseEvents.push('wsl-move-rewrite-done')
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
const options = {
|
||||
openMainWindow: vi.fn(),
|
||||
handleMacAppActivation: vi.fn()
|
||||
} as unknown as Parameters<typeof initializeMainProcessReady>[0]
|
||||
|
||||
const ready = initializeMainProcessReady(options)
|
||||
for (let tick = 0; tick < 8; tick += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(phaseEvents).toEqual(['foundation'])
|
||||
|
||||
finishRewrite()
|
||||
for (let tick = 0; tick < 8; tick += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
releaseI18n?.()
|
||||
await ready
|
||||
mainProcessState.wslMoveStartupRewriteReady = Promise.resolve()
|
||||
expect(phaseEvents.slice(0, 3)).toEqual([
|
||||
'foundation',
|
||||
'wsl-move-rewrite-done',
|
||||
'runtime-services'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function initializeMainProcessReady(
|
||||
}
|
||||
try {
|
||||
await initializeReadyFoundation()
|
||||
await state.wslMoveStartupRewriteReady
|
||||
await initializeReadyRuntimeServices()
|
||||
// Window creation can proceed while translations and the native menu initialize.
|
||||
const i18nAndMenuReady = initializeMainProcessI18nAndMenu()
|
||||
|
||||
@@ -127,6 +127,9 @@ export const mainProcessState = {
|
||||
// Why published: the default-session proxy must be applied before the first app-owned fetcher,
|
||||
// but window creation has no reason to queue behind it (the request guard already fences it).
|
||||
initialProxyApplicationReady: Promise.resolve(),
|
||||
// Why awaited before runtime services: the first window's session and UI reads must see a
|
||||
// committed WSL move's rewrite. Bounded, never rejects, and resolved at once with the gate off.
|
||||
wslMoveStartupRewriteReady: Promise.resolve(),
|
||||
// Why published: i18n/menu init no longer precedes the launch phase, so the one launch-phase
|
||||
// path that reads a translated string (the runtime-RPC startup failure dialog) waits on this.
|
||||
// Never rejects: the phase's own failure is surfaced by initializeMainProcessReady.
|
||||
|
||||
@@ -120,6 +120,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
|
||||
await timeRendererStartupStep('repo-catalog-settlement', () =>
|
||||
actions.awaitLocalRepoCatalogSettlement()
|
||||
)
|
||||
// Before the session read: a moved project's local rows must leave before the host's merge in.
|
||||
const wslMovedProjectsPromise = useAppStore.getState().fetchWslMovedProjects()
|
||||
// Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts),
|
||||
// so keep this chain ordered while overlapping it with session-scoped hydration.
|
||||
const localCatalogChain = (async () => {
|
||||
@@ -129,20 +131,18 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
|
||||
await timeRendererStartupStep('fetch-folder-workspaces-local', () =>
|
||||
actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })
|
||||
)
|
||||
// Before hydration: a moved project's chats hydrate only once it is known to be moved.
|
||||
await timeRendererStartupStep('fetch-wsl-moved-projects', () =>
|
||||
useAppStore.getState().fetchWslMovedProjects()
|
||||
)
|
||||
})()
|
||||
const sessionReadPromise = runtimeHostsPromise.then((startupRuntimeHostIds) =>
|
||||
// Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip.
|
||||
timeRendererStartupStep('session-get', () =>
|
||||
fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
window.api.session,
|
||||
useAppStore.getState().repos,
|
||||
startupRuntimeHostIds
|
||||
const sessionReadPromise = Promise.all([runtimeHostsPromise, wslMovedProjectsPromise]).then(
|
||||
([startupRuntimeHostIds]) =>
|
||||
// Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip.
|
||||
timeRendererStartupStep('session-get', () =>
|
||||
fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
window.api.session,
|
||||
useAppStore.getState().repos,
|
||||
startupRuntimeHostIds,
|
||||
useAppStore.getState().wslMovedProjects
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
const hydrationSessionChain = sessionReadPromise.then(async (sessionRead) => {
|
||||
const hydrationRepoIds = collectWorktreeHydrationRepoIdsFromSession(
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
normalizeWorkspaceSessionKeyToWorktreeId
|
||||
} from './workspace-session-host-contention'
|
||||
import { nonLocalHostSessionEntries, type HostSessionSlices } from './workspace-session-host-split'
|
||||
import type { WslMovedProject } from '../../../shared/wsl-move/wsl-moved-project'
|
||||
import { withoutWslMovedLocalRows } from '../store/repos/wsl-moved-local-session-rows'
|
||||
|
||||
type SessionReadApi = {
|
||||
get: (hostId?: ExecutionHostId) => Promise<WorkspaceSessionState>
|
||||
@@ -161,7 +163,8 @@ export async function fetchWorkspaceSessionFromHosts(
|
||||
export async function fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
api: SessionReadApi,
|
||||
repos: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[],
|
||||
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
|
||||
additionalRuntimeHostIds: readonly ExecutionHostId[] = [],
|
||||
wslMovedProjects: readonly WslMovedProject[] = []
|
||||
): Promise<WorkspaceSessionHostRead> {
|
||||
const readPartition = async (hostId: ExecutionHostId): Promise<WorkspaceSessionState | null> => {
|
||||
try {
|
||||
@@ -172,7 +175,8 @@ export async function fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
}
|
||||
}
|
||||
const slices: HostSessionSlices = {
|
||||
[LOCAL_EXECUTION_HOST_ID]: await api.get()
|
||||
// Before the merge: a moved workspace's retained rows share tab ids with its host's live ones.
|
||||
[LOCAL_EXECUTION_HOST_ID]: withoutWslMovedLocalRows(await api.get(), wslMovedProjects)
|
||||
}
|
||||
// Why: startup can know saved runtime session hosts before their repo
|
||||
// catalogs hydrate, so include those partitions in the first read.
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { Tab, TabGroup } from '../../../../shared/tab-types'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
|
||||
import type { WslMovedProject } from '../../../../shared/wsl-move/wsl-moved-project'
|
||||
import { fetchWorkspaceSessionWithRuntimeHostOwners } from '@/lib/workspace-session-host-hydration'
|
||||
import { withoutWslMovedLocalRows } from './wsl-moved-local-session-rows'
|
||||
|
||||
const HOST: ExecutionHostId = 'runtime:wsl%3AUbuntu'
|
||||
const LOCAL_ID = 'repo-a::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app'
|
||||
const HOST_ID = 'repo-a::/home/dev/app'
|
||||
const STAYING_ID = 'repo-b::C:\\src\\b'
|
||||
const MOVED: WslMovedProject[] = [
|
||||
{
|
||||
kind: 'repo',
|
||||
id: 'repo-a',
|
||||
displayName: 'app',
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\app',
|
||||
distro: 'Ubuntu'
|
||||
}
|
||||
]
|
||||
|
||||
function terminal(id: string, worktreeId: string, title: string): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function tab(id: string, worktreeId: string, contentType: Tab['contentType']): Tab {
|
||||
return {
|
||||
id,
|
||||
entityId: id,
|
||||
groupId: 'group-1',
|
||||
worktreeId,
|
||||
contentType,
|
||||
label: id,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function group(worktreeId: string, tabOrder: string[]): TabGroup {
|
||||
return { id: 'group-1', worktreeId, activeTabId: tabOrder[0] ?? null, tabOrder }
|
||||
}
|
||||
|
||||
/** One workspace's terminal `tab-1` (titled by partition) plus any extra tab-bar tabs. */
|
||||
function workspaceSession(
|
||||
worktreeId: string,
|
||||
title: string,
|
||||
extra: Tab[] = []
|
||||
): WorkspaceSessionState {
|
||||
const tabs = [tab('tab-1', worktreeId, 'terminal'), ...extra]
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: { [worktreeId]: [terminal('tab-1', worktreeId, title)] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: `${title}-leaf` },
|
||||
activeLeafId: `${title}-leaf`,
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
unifiedTabs: { [worktreeId]: tabs },
|
||||
tabGroups: {
|
||||
[worktreeId]: [
|
||||
group(
|
||||
worktreeId,
|
||||
tabs.map((entry) => entry.id)
|
||||
)
|
||||
]
|
||||
},
|
||||
activeTabIdByWorktree: { [worktreeId]: 'tab-1' }
|
||||
}
|
||||
}
|
||||
|
||||
function localPartition(): WorkspaceSessionState {
|
||||
const moved = workspaceSession(LOCAL_ID, 'local', [tab('chat-1', LOCAL_ID, 'agent-session')])
|
||||
const staying = workspaceSession(STAYING_ID, 'staying')
|
||||
return {
|
||||
...moved,
|
||||
tabsByWorktree: {
|
||||
...moved.tabsByWorktree,
|
||||
[STAYING_ID]: [terminal('tab-9', STAYING_ID, 'staying')]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
...moved.terminalLayoutsByTabId,
|
||||
'tab-9': staying.terminalLayoutsByTabId['tab-1']
|
||||
},
|
||||
unifiedTabs: { ...moved.unifiedTabs, [STAYING_ID]: [tab('tab-9', STAYING_ID, 'terminal')] },
|
||||
tabGroups: { ...moved.tabGroups, [STAYING_ID]: [group(STAYING_ID, ['tab-9'])] }
|
||||
}
|
||||
}
|
||||
|
||||
describe('WSL moved local session rows', () => {
|
||||
it("keeps only a moved workspace's chats, and every other workspace whole", () => {
|
||||
const local = withoutWslMovedLocalRows(localPartition(), MOVED)
|
||||
|
||||
expect(local.tabsByWorktree).toEqual({
|
||||
[STAYING_ID]: [terminal('tab-9', STAYING_ID, 'staying')]
|
||||
})
|
||||
expect(Object.keys(local.terminalLayoutsByTabId)).toEqual(['tab-9'])
|
||||
expect(local.unifiedTabs?.[LOCAL_ID]?.map((entry) => entry.id)).toEqual(['chat-1'])
|
||||
expect(local.tabGroups?.[LOCAL_ID]?.[0]).toMatchObject({
|
||||
tabOrder: ['chat-1'],
|
||||
activeTabId: 'chat-1'
|
||||
})
|
||||
expect(local.activeTabIdByWorktree?.[LOCAL_ID]).toBeUndefined()
|
||||
expect(local.unifiedTabs?.[STAYING_ID]?.map((entry) => entry.id)).toEqual(['tab-9'])
|
||||
})
|
||||
|
||||
it('leaves the partition alone when nothing moved', () => {
|
||||
const local = localPartition()
|
||||
|
||||
expect(withoutWslMovedLocalRows(local, [])).toBe(local)
|
||||
})
|
||||
|
||||
it("hydrates one tab-1, the host's, when both partitions hold it; the local chat stays", async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
{
|
||||
get: vi.fn(async (hostId?: ExecutionHostId) =>
|
||||
hostId === HOST ? workspaceSession(HOST_ID, 'host') : localPartition()
|
||||
)
|
||||
},
|
||||
[],
|
||||
[HOST],
|
||||
MOVED
|
||||
)
|
||||
const { session } = read
|
||||
|
||||
expect(session.terminalLayoutsByTabId['tab-1']?.activeLeafId).toBe('host-leaf')
|
||||
expect(session.tabsByWorktree[HOST_ID]?.map((entry) => entry.title)).toEqual(['host'])
|
||||
expect(session.tabsByWorktree[LOCAL_ID]).toBeUndefined()
|
||||
const tabOneOwners = Object.entries(session.unifiedTabs ?? {}).flatMap(([owner, tabs]) =>
|
||||
tabs.some((entry) => entry.id === 'tab-1') ? [owner] : []
|
||||
)
|
||||
expect(tabOneOwners).toEqual([HOST_ID])
|
||||
expect(session.unifiedTabs?.[LOCAL_ID]?.map((entry) => entry.id)).toEqual(['chat-1'])
|
||||
expect(read.runtimeHostIdByWorkspaceSessionKey[HOST_ID]).toBe(HOST)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* The local partition as startup reads it once WSL hosts own some of its projects: a moved
|
||||
* workspace keeps only its native chat tabs (owner decision, option C). Its terminals, editors and
|
||||
* browser tabs are retained on disk for a downgrade, but the host's partition now holds the live
|
||||
* copies under the same tab ids, so they must leave before the host partitions merge in.
|
||||
*/
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
|
||||
import { normalizeWorkspaceSessionKeyToWorkspaceId } from '../../../../shared/workspace-scope'
|
||||
import {
|
||||
filterWorkspaceRows,
|
||||
workspaceIdsNamedByPartition
|
||||
} from '../../../../shared/workspace-session-stranded-partition-adoption'
|
||||
import { filterWorkspaceSessionTabs } from '../../../../shared/workspace-session-tab-filter'
|
||||
import type { WslMovedProject } from '../../../../shared/wsl-move/wsl-moved-project'
|
||||
import { mergeWorkspaceSessionRecordField } from '../../../../shared/workspace-session-host-records'
|
||||
import { isWslMovedChatTab, movedProjectForKey } from './wsl-moved-projects'
|
||||
|
||||
// The tab bar alone: hydration picks a chat-only workspace's focus from its groups.
|
||||
const CHAT_TAB_FIELDS = [
|
||||
'unifiedTabs',
|
||||
'tabGroups',
|
||||
'tabGroupLayouts'
|
||||
] as const satisfies readonly (keyof WorkspaceSessionState)[]
|
||||
|
||||
export function withoutWslMovedLocalRows(
|
||||
local: WorkspaceSessionState,
|
||||
projects: readonly WslMovedProject[]
|
||||
): WorkspaceSessionState {
|
||||
if (projects.length === 0) {
|
||||
return local
|
||||
}
|
||||
const moved = new Set(
|
||||
[...workspaceIdsNamedByPartition(local)].filter(
|
||||
(workspaceId) => movedProjectForKey(projects, workspaceId) !== null
|
||||
)
|
||||
)
|
||||
if (moved.size === 0) {
|
||||
return local
|
||||
}
|
||||
const isMoved = (owner: string): boolean =>
|
||||
moved.has(normalizeWorkspaceSessionKeyToWorkspaceId(owner))
|
||||
const chats = filterWorkspaceSessionTabs(
|
||||
local,
|
||||
(owner, tab) => !isMoved(owner) || isWslMovedChatTab(tab)
|
||||
)
|
||||
const rest = structuredClone(filterWorkspaceRows(chats, (workspaceId) => !moved.has(workspaceId)))
|
||||
const withChats = new Set(
|
||||
Object.entries(chats.unifiedTabs ?? {}).flatMap(([owner, tabs]) =>
|
||||
isMoved(owner) && tabs.length > 0 ? [normalizeWorkspaceSessionKeyToWorkspaceId(owner)] : []
|
||||
)
|
||||
)
|
||||
const chatRows = filterWorkspaceRows(chats, (workspaceId) => withChats.has(workspaceId))
|
||||
for (const field of CHAT_TAB_FIELDS) {
|
||||
mergeWorkspaceSessionRecordField(rest, field, chatRows)
|
||||
}
|
||||
return rest
|
||||
}
|
||||
@@ -59,7 +59,8 @@ function sameMovedProjects(
|
||||
)
|
||||
}
|
||||
|
||||
function movedProjectForKey(
|
||||
/** The moved project a session key names by id alone, before the catalog says whose it is. */
|
||||
export function movedProjectForKey(
|
||||
projects: readonly WslMovedProject[],
|
||||
workspaceKey: string
|
||||
): WslMovedProject | null {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* A saved session with some tab-bar tabs removed, and everything that named them made consistent:
|
||||
* groups lose them (a group they emptied goes, with its layout leaf), and per-workspace focus on a
|
||||
* removed tab or group moves to what is left. Terminal rows and layouts are left to the caller.
|
||||
*/
|
||||
import type { Tab, TabGroup } from './tab-types'
|
||||
import { toVisibleTabType } from './tab-types'
|
||||
import type { WorkspaceSessionState } from './workspace-session-state-types'
|
||||
import { pruneGroupLayout } from './workspace-session-terminal-tab-close'
|
||||
|
||||
export function filterWorkspaceSessionTabs(
|
||||
session: WorkspaceSessionState,
|
||||
keepTab: (owner: string, tab: Tab) => boolean
|
||||
): WorkspaceSessionState {
|
||||
const removedByOwner = new Map<string, Set<string>>()
|
||||
const keptTypesByOwner = new Map<string, Set<string>>()
|
||||
const unifiedTabs: Record<string, Tab[]> = {}
|
||||
for (const [owner, tabs] of Object.entries(session.unifiedTabs ?? {})) {
|
||||
const kept = tabs.filter((tab) => keepTab(owner, tab))
|
||||
unifiedTabs[owner] = kept.length === tabs.length ? tabs : kept
|
||||
if (kept.length !== tabs.length) {
|
||||
const keptIds = new Set(kept.map((tab) => tab.id))
|
||||
removedByOwner.set(
|
||||
owner,
|
||||
new Set(tabs.filter((tab) => !keptIds.has(tab.id)).map((tab) => tab.id))
|
||||
)
|
||||
keptTypesByOwner.set(owner, new Set(kept.map((tab) => toVisibleTabType(tab.contentType))))
|
||||
}
|
||||
}
|
||||
if (removedByOwner.size === 0) {
|
||||
return session
|
||||
}
|
||||
const tabGroups = mapOwners(session.tabGroups, removedByOwner, (groups, removed) =>
|
||||
groups.flatMap((group) => {
|
||||
const kept = withoutTabs(group, removed)
|
||||
return kept.tabOrder.length === 0 && group.tabOrder.length > 0 ? [] : [kept]
|
||||
})
|
||||
)
|
||||
const groupIds = (owner: string): Set<string> =>
|
||||
new Set((tabGroups?.[owner] ?? []).map((group) => group.id))
|
||||
return {
|
||||
...session,
|
||||
unifiedTabs,
|
||||
...(tabGroups ? { tabGroups } : {}),
|
||||
...(session.tabGroupLayouts
|
||||
? {
|
||||
tabGroupLayouts: filterOwners(session.tabGroupLayouts, removedByOwner, (layout, owner) =>
|
||||
pruneGroupLayout(layout, groupIds(owner))
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
...(session.activeGroupIdByWorktree
|
||||
? {
|
||||
activeGroupIdByWorktree: filterOwners(
|
||||
session.activeGroupIdByWorktree,
|
||||
removedByOwner,
|
||||
(groupId, owner) => {
|
||||
const ids = groupIds(owner)
|
||||
return ids.has(groupId) ? groupId : ids.values().next().value
|
||||
}
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
...(session.activeTabIdByWorktree
|
||||
? {
|
||||
activeTabIdByWorktree: filterOwners(
|
||||
session.activeTabIdByWorktree,
|
||||
removedByOwner,
|
||||
(tabId, owner) => (tabId && removedByOwner.get(owner)?.has(tabId) ? undefined : tabId)
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
...(session.activeTabTypeByWorktree
|
||||
? {
|
||||
activeTabTypeByWorktree: filterOwners(
|
||||
session.activeTabTypeByWorktree,
|
||||
removedByOwner,
|
||||
(type, owner) => (keptTypesByOwner.get(owner)?.has(type) ? type : undefined)
|
||||
)
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
function withoutTabs(group: TabGroup, tabIds: ReadonlySet<string>): TabGroup {
|
||||
const tabOrder = group.tabOrder.filter((id) => !tabIds.has(id))
|
||||
const recentTabIds = group.recentTabIds?.filter((id) => !tabIds.has(id))
|
||||
const activeTabId =
|
||||
group.activeTabId && !tabIds.has(group.activeTabId)
|
||||
? group.activeTabId
|
||||
: (recentTabIds?.at(-1) ?? tabOrder.at(-1) ?? null)
|
||||
return { ...group, tabOrder, activeTabId, ...(recentTabIds ? { recentTabIds } : {}) }
|
||||
}
|
||||
|
||||
/** Each owner that lost tabs mapped; the rest as they were. */
|
||||
function mapOwners<T>(
|
||||
record: Record<string, T> | undefined,
|
||||
removedByOwner: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
map: (value: T, removed: ReadonlySet<string>) => T
|
||||
): Record<string, T> | undefined {
|
||||
if (!record) {
|
||||
return undefined
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).map(([owner, value]) => {
|
||||
const removed = removedByOwner.get(owner)
|
||||
return [owner, removed ? map(value, removed) : value]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Like `mapOwners`, but an owner mapped to nothing is dropped. */
|
||||
function filterOwners<T>(
|
||||
record: Record<string, T>,
|
||||
removedByOwner: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
map: (value: T, owner: string) => T | null | undefined
|
||||
): Record<string, T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).flatMap(([owner, value]) => {
|
||||
if (!removedByOwner.has(owner) || value === null) {
|
||||
return [[owner, value]]
|
||||
}
|
||||
const mapped = map(value, owner)
|
||||
return mapped === undefined || mapped === null ? [] : [[owner, mapped]]
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,8 @@ const WslMoveCutoverSchema = z.object({
|
||||
provenExitedSessionIds: z.array(z.string().min(1).max(1_024)).max(10_000),
|
||||
// Desktop stores already rewritten after commit, so a crash redoes only the rest.
|
||||
rewrittenStores: z.array(z.string().min(1).max(128)).max(256),
|
||||
// Workspaces the host partition was seeded with, so a re-run never revives a closed tab.
|
||||
seededHostWorkspaceIds: z.array(z.string().min(1).max(4_096)).max(10_000).optional(),
|
||||
supersedesMigrationId: z.string().min(1).max(128).optional(),
|
||||
manifest: z.unknown()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user