mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user