fix(wsl-mover): carry markdown frontmatter visibility and closed-terminal-tab tombstones into the moved project

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-22 05:26:06 -07:00
co-authored by Claude
parent 4ba818d4e6
commit 76fb1812d5
9 changed files with 246 additions and 118 deletions
@@ -7,8 +7,6 @@ import { isRecord } from '../../moving-wsl-projects/wsl-move-canonical-form'
/** Session fields the real pipeline does not carry yet, with why; each still differs today. */
export const WSL_MOVE_ROUND_TRIP_KNOWN_GAPS: Readonly<Record<string, string>> = {
markdownFrontmatterVisible: 'Dropped by the export (file-keyed by the UNC path); a UI toggle.',
closedTerminalTabTombstonesByTabId: 'Reopen-closed-tab records are not carried.',
activeWorktreeIdsOnShutdown: 'Never seeded: focus belongs to whoever looks at the host.',
terminalSurfaceTombstonesByPaneKey: 'Fences of local PTY incarnations; the host starts its own.'
}
@@ -37,7 +37,10 @@ export function wslMoverProfileScope(
repoIds: new Set(name === 'mixed-windows-wsl' ? ['repo-a'] : repoIds),
folderWorkspaceIds: new Set(name === 'folders-and-automations' ? ['folder-1'] : []),
ownerHostId: 'local',
hostAliases: side === 'source' ? ['local'] : ['local', `runtime:wsl:${WSL_MOVER_DISTRO}`]
hostAliases:
side === 'source'
? ['local']
: ['local', `runtime:wsl:${WSL_MOVER_DISTRO}`, `wsl:${WSL_MOVER_DISTRO}`]
}
}
@@ -9,7 +9,7 @@ import type { Store } from '../loading-store/store'
import { wslMoveAutomationHostFilterUpdate } from './wsl-move-automation-filter-rewrite'
import { registerWslMoveDesktopRewriteStore } from './wsl-move-desktop-rewrite-stores'
import { wslMoveRewriteScope, type WslMoveRewriteScope } from './wsl-move-rewrite-scope'
import { withWslMoveSeededDrafts } from './wsl-move-seeded-drafts'
import { withWslMoveSeededViewState } from './wsl-move-seeded-view-state'
import { wslMoveHostSessionSeed, wslMoveSessionGlobalsPatch } from './wsl-move-session-rewrite'
import { rewriteWslMoveUiRouting } from './wsl-move-ui-routing-rewrite'
@@ -113,7 +113,7 @@ export function applyWslMoveSessionFocus(
/** Seeds the host partition with the carried rows of moved workspaces it lacks; returns their ids. */
export function applyWslMoveHostSessionSeed(
profile: Pick<Store, 'getWorkspaceSession' | 'setWorkspaceSession'>,
cutover: Pick<WslMoveCutover, 'manifest'>,
cutover: Pick<WslMoveCutover, 'manifest' | 'destinationEnvironmentId'>,
scope: WslMoveRewriteScope,
alreadySeeded: ReadonlySet<string>
): string[] {
@@ -131,13 +131,13 @@ export function applyWslMoveHostSessionSeed(
if (!seed) {
return []
}
const drafted = withWslMoveSeededDrafts(
const withViewState = withWslMoveSeededViewState(
seed.session,
profile.getWorkspaceSession(LOCAL_EXECUTION_HOST_ID),
scope,
{ ...scope, destinationEnvironmentId: cutover.destinationEnvironmentId },
new Set(seed.workspaceIds)
)
// A move's one-shot seed, or its redo after a stale window; layout commands refuse runtime hosts.
profile.setWorkspaceSession(drafted ?? seed.session, hostId)
profile.setWorkspaceSession(withViewState ?? seed.session, hostId)
return seed.workspaceIds
}
@@ -65,6 +65,9 @@ const STORE_BY_ROW: Record<string, string> = {
'session.activeWorktreeId': SESSION_GLOBALS,
'session.activeWorkspaceExecutionHostId': SESSION_GLOBALS,
'session.activeWorkspaceKey': SESSION_GLOBALS,
// This desktop's copy, laid over the host partition the seed writes.
'session.markdownFrontmatterVisible': HOST_SESSION_SEED,
'session.closedTerminalTabTombstonesByTabId': HOST_SESSION_SEED,
'ui.lastActiveWorktreeId': UI_ROUTING,
'ui.manualRepoOrder': UI_ROUTING,
'ui.explorerDisplayRootByWorktree': UI_ROUTING,
@@ -1,55 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import type {
PersistedOpenFile,
WorkspaceSessionState
} from '../../../shared/workspace-session-state-types'
import { withWslMoveSeededDrafts } from './wsl-move-seeded-drafts'
const UNC = 'repo-a::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app'
const HOST = 'repo-a::/home/dev/app'
const scope = {
inScope: (owner: string) => owner.startsWith('repo-a::'),
worktreeId: (id: string) => (id === UNC ? HOST : id),
path: (value: string) => value.replace('\\\\wsl.localhost\\Ubuntu', '').replaceAll('\\', '/')
}
const file = (filePath: string, worktreeId: string, draft?: string): PersistedOpenFile => ({
filePath,
relativePath: 'README.md',
worktreeId,
language: 'markdown',
...(draft === undefined ? {} : { dirtyDraftContent: draft, lastKnownDiskSignature: 'sig-1' })
})
const seed = (): WorkspaceSessionState => ({
...getDefaultWorkspaceSession(),
openFilesByWorktree: { [HOST]: [file('/home/dev/app/README.md', HOST)] }
})
const local = {
openFilesByWorktree: {
[UNC]: [file('\\\\wsl.localhost\\Ubuntu\\home\\dev\\app\\README.md', UNC, '# unsaved')]
}
}
describe('withWslMoveSeededDrafts', () => {
it("lays this desktop's draft and its disk baseline over the seeded file", () => {
const next = withWslMoveSeededDrafts(seed(), local, scope, new Set([HOST]))
expect(next?.openFilesByWorktree?.[HOST]).toEqual([
{ ...file('/home/dev/app/README.md', HOST, '# unsaved') }
])
})
it('leaves a workspace this run did not seed alone, so a file closed on the host stays closed', () => {
expect(withWslMoveSeededDrafts(seed(), local, scope, new Set())).toBeNull()
})
it('never replaces a draft the seeded record already has', () => {
const drafted = seed()
drafted.openFilesByWorktree = {
[HOST]: [file('/home/dev/app/README.md', HOST, '# newer')]
}
expect(withWslMoveSeededDrafts(drafted, local, scope, new Set([HOST]))).toBeNull()
})
})
@@ -1,53 +0,0 @@
/**
* Unsaved editor drafts of a moved project. The manifest never carries them (drafts stay on this
* desktop), so the host partition's seed has each drafted file without its contents. This lays
* the desktop's own draft over the seeded record, as the SSH move's draft rename does.
*/
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { isHostBoundOpenFile } from '../migrating-orcad-catalog/orcad-source-draft-rename'
import type { WslMoveRewriteScope } from './wsl-move-rewrite-scope'
/** `seed` with the local drafts of `seededWorkspaceIds` laid over it; null when none applies. */
export function withWslMoveSeededDrafts(
seed: WorkspaceSessionState,
local: Pick<WorkspaceSessionState, 'openFilesByWorktree'>,
scope: Pick<WslMoveRewriteScope, 'inScope' | 'worktreeId' | 'path'>,
seededWorkspaceIds: ReadonlySet<string>
): WorkspaceSessionState | null {
const openFilesByWorktree = { ...seed.openFilesByWorktree }
let changed = false
for (const [owner, files] of Object.entries(local.openFilesByWorktree ?? {})) {
if (!scope.inScope(owner)) {
continue
}
const hostKey = scope.worktreeId(owner)
// Only workspaces this run seeds: a file the user since closed on the host stays closed.
if (!seededWorkspaceIds.has(hostKey)) {
continue
}
for (const file of files) {
if (file.dirtyDraftContent === undefined || isHostBoundOpenFile(file)) {
continue
}
const hostPath = scope.path(file.filePath)
const held = openFilesByWorktree[hostKey] ?? []
const index = held.findIndex(
(entry) => entry.filePath === hostPath && entry.dirtyDraftContent === undefined
)
if (index === -1) {
continue
}
const next = [...held]
next[index] = {
...held[index],
dirtyDraftContent: file.dirtyDraftContent,
...(file.lastKnownDiskSignature !== undefined
? { lastKnownDiskSignature: file.lastKnownDiskSignature }
: {})
}
openFilesByWorktree[hostKey] = next
changed = true
}
}
return changed ? { ...seed, openFilesByWorktree } : null
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import type {
PersistedOpenFile,
WorkspaceSessionState
} from '../../../shared/workspace-session-state-types'
import { withWslMoveSeededViewState } from './wsl-move-seeded-view-state'
const UNC_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\app'
const UNC = `repo-a::${UNC_ROOT}`
const HOST = 'repo-a::/home/dev/app'
const scope = {
destinationEnvironmentId: 'wsl:Ubuntu',
inScope: (owner: string) => owner.startsWith('repo-a::'),
worktreeId: (id: string) => (id === UNC ? HOST : id),
path: (value: string) => value.replace('\\\\wsl.localhost\\Ubuntu', '').replaceAll('\\', '/')
}
const file = (filePath: string, worktreeId: string, draft?: string): PersistedOpenFile => ({
filePath,
relativePath: 'README.md',
worktreeId,
language: 'markdown',
...(draft === undefined ? {} : { dirtyDraftContent: draft, lastKnownDiskSignature: 'sig-1' })
})
const seed = (): WorkspaceSessionState => ({
...getDefaultWorkspaceSession(),
openFilesByWorktree: { [HOST]: [file('/home/dev/app/README.md', HOST)] }
})
const local = {
openFilesByWorktree: { [UNC]: [file(`${UNC_ROOT}\\README.md`, UNC, '# unsaved')] }
}
describe('withWslMoveSeededViewState', () => {
it("lays this desktop's draft and its disk baseline over the seeded file", () => {
const next = withWslMoveSeededViewState(seed(), local, scope, new Set([HOST]))
expect(next?.openFilesByWorktree?.[HOST]).toEqual([
file('/home/dev/app/README.md', HOST, '# unsaved')
])
})
it('re-keys frontmatter visibility to the host file, and moves closed-tab tombstones', () => {
const localId = `editor:${encodeURIComponent(UNC)}:local:${encodeURIComponent(`${UNC_ROOT}\\README.md`)}`
const next = withWslMoveSeededViewState(
seed(),
{
markdownFrontmatterVisible: { [localId]: false, 'editor:other%3A%3Ax:ssh-1:%2Fx': true },
closedTerminalTabTombstonesByTabId: { 'tab-old': { closedAt: 1, worktreeId: UNC } }
},
scope,
new Set([HOST])
)
expect(next?.markdownFrontmatterVisible).toEqual({
[`editor:${encodeURIComponent(HOST)}:wsl%3AUbuntu:${encodeURIComponent('/home/dev/app/README.md')}`]: false
})
expect(next?.closedTerminalTabTombstonesByTabId).toEqual({
'tab-old': { closedAt: 1, worktreeId: HOST }
})
})
it('leaves a workspace this run did not seed alone, so a file closed on the host stays closed', () => {
expect(withWslMoveSeededViewState(seed(), local, scope, new Set())).toBeNull()
})
it('never replaces a draft the seeded record already has', () => {
const drafted = seed()
drafted.openFilesByWorktree = { [HOST]: [file('/home/dev/app/README.md', HOST, '# newer')] }
expect(withWslMoveSeededViewState(drafted, local, scope, new Set([HOST]))).toBeNull()
})
})
@@ -0,0 +1,153 @@
/**
* This desktop's view state of a moved project that the manifest never carries, laid over the
* host partition's seed: unsaved editor drafts (drafts stay on this desktop, as the SSH move's
* draft rename keeps them), markdown frontmatter visibility (keyed by a UNC file id the export
* cannot rewrite) and closed-terminal-tab tombstones. Only for workspaces seeded in this run, so
* anything the user since changed on the host stays as it is.
*/
import type { ClosedTerminalTabTombstone } from '../../../shared/closed-terminal-tab-tombstones'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { ownedEditorFileId } from '../../../shared/workspace-session-pane-ownership'
import { isHostBoundOpenFile } from '../migrating-orcad-catalog/orcad-source-draft-rename'
import type { WslMoveRewriteScope } from './wsl-move-rewrite-scope'
type ViewStateScope = Pick<WslMoveRewriteScope, 'inScope' | 'worktreeId' | 'path'> & {
/** The environment the moved files open under on this desktop: their editor ids name it. */
destinationEnvironmentId: string
}
type LocalViewState = Pick<
WorkspaceSessionState,
'openFilesByWorktree' | 'markdownFrontmatterVisible' | 'closedTerminalTabTombstonesByTabId'
>
/** `seed` with this desktop's view state of `seededWorkspaceIds` laid over it; null if unchanged. */
export function withWslMoveSeededViewState(
seed: WorkspaceSessionState,
local: LocalViewState,
scope: ViewStateScope,
seededWorkspaceIds: ReadonlySet<string>
): WorkspaceSessionState | null {
const seeded = (owner: string): string | null => {
if (!scope.inScope(owner)) {
return null
}
const hostKey = scope.worktreeId(owner)
return seededWorkspaceIds.has(hostKey) ? hostKey : null
}
const drafts = withDrafts(seed, local, scope, seeded)
const frontmatter = withFrontmatter(seed, local, scope, seeded)
const tombstones = withClosedTabTombstones(seed, local, seeded)
if (!drafts && !frontmatter && !tombstones) {
return null
}
return {
...seed,
...(drafts ? { openFilesByWorktree: drafts } : {}),
...(frontmatter ? { markdownFrontmatterVisible: frontmatter } : {}),
...(tombstones ? { closedTerminalTabTombstonesByTabId: tombstones } : {})
}
}
type SeededKey = (owner: string) => string | null
function withDrafts(
seed: WorkspaceSessionState,
local: LocalViewState,
scope: ViewStateScope,
seeded: SeededKey
): WorkspaceSessionState['openFilesByWorktree'] | null {
const openFilesByWorktree = { ...seed.openFilesByWorktree }
let changed = false
for (const [owner, files] of Object.entries(local.openFilesByWorktree ?? {})) {
const hostKey = seeded(owner)
if (!hostKey) {
continue
}
for (const file of files) {
if (file.dirtyDraftContent === undefined || isHostBoundOpenFile(file)) {
continue
}
const hostPath = scope.path(file.filePath)
const held = openFilesByWorktree[hostKey] ?? []
const index = held.findIndex(
(entry) => entry.filePath === hostPath && entry.dirtyDraftContent === undefined
)
if (index === -1) {
continue
}
const next = [...held]
next[index] = {
...held[index],
dirtyDraftContent: file.dirtyDraftContent,
...(file.lastKnownDiskSignature !== undefined
? { lastKnownDiskSignature: file.lastKnownDiskSignature }
: {})
}
openFilesByWorktree[hostKey] = next
changed = true
}
}
return changed ? openFilesByWorktree : null
}
/** `editor:<worktree>:<runtime>:<path>`, each part URI-encoded (ownedEditorFileId). */
function parseEditorFileId(
id: string
): { worktreeId: string; runtimeKey: string; filePath: string } | null {
const parts = id.split(':')
if (parts.length !== 4 || parts[0] !== 'editor') {
return null
}
try {
const [worktreeId, runtimeKey, filePath] = parts.slice(1).map(decodeURIComponent)
return { worktreeId, runtimeKey, filePath }
} catch {
return null
}
}
function withFrontmatter(
seed: WorkspaceSessionState,
local: LocalViewState,
scope: ViewStateScope,
seeded: SeededKey
): Record<string, boolean> | null {
const next = { ...seed.markdownFrontmatterVisible }
let changed = false
for (const [id, visible] of Object.entries(local.markdownFrontmatterVisible ?? {})) {
const parsed = parseEditorFileId(id)
// Another host's file keeps its own id; only this computer's files moved.
const hostKey = parsed?.runtimeKey === 'local' ? seeded(parsed.worktreeId) : null
if (!parsed || !hostKey) {
continue
}
const hostId = ownedEditorFileId(
scope.path(parsed.filePath),
hostKey,
scope.destinationEnvironmentId
)
if (!Object.hasOwn(next, hostId)) {
next[hostId] = visible
changed = true
}
}
return changed ? next : null
}
function withClosedTabTombstones(
seed: WorkspaceSessionState,
local: LocalViewState,
seeded: SeededKey
): Record<string, ClosedTerminalTabTombstone> | null {
const next = { ...seed.closedTerminalTabTombstonesByTabId }
let changed = false
for (const [tabId, tombstone] of Object.entries(local.closedTerminalTabTombstonesByTabId ?? {})) {
const hostKey = seeded(tombstone.worktreeId)
if (hostKey && !Object.hasOwn(next, tabId)) {
next[tabId] = { ...tombstone, worktreeId: hostKey }
changed = true
}
}
return changed ? next : null
}
+8 -2
View File
@@ -143,7 +143,10 @@ export const WORKSPACE_SESSION_DISPOSITIONS = {
localOnlyScrollbackByTabId: moveDisposition(25, { newManifestSection: true }),
remoteSessionIdsByTabId: { disposition: 'drop-pty-binding', item: 36 },
browserPagesByWorkspace: moveDisposition(29),
markdownFrontmatterVisible: moveDisposition(28),
markdownFrontmatterVisible: moveDisposition(28, {
rewriteDesktopCopy: true,
note: 'Keyed by a UNC file id; host-session-seed re-keys this desktop copy to the host file.'
}),
sleepingAgentSessionsByPaneKey: moveDisposition(33),
terminalPtyIncarnationsByPaneKey: {
disposition: 'drop-pty-binding',
@@ -151,7 +154,10 @@ export const WORKSPACE_SESSION_DISPOSITIONS = {
},
terminalTopologyRevisionByRepoId: { disposition: 'rederive', item: 35 },
terminalSurfaceTombstonesByPaneKey: moveDisposition(34),
closedTerminalTabTombstonesByTabId: moveDisposition(34)
closedTerminalTabTombstonesByTabId: moveDisposition(34, {
rewriteDesktopCopy: true,
note: 'Laid over the host partition by host-session-seed, with the host worktree id.'
})
} as const satisfies Record<keyof WorkspaceSessionState, WslMoveFieldDisposition>
/** Nested PTY bindings dropped wherever they appear in moved session rows (item 36). */