feat(wsl-mover): move dialog with preview, blockers, progress and result behind the internal WSL hosts gate (D1b-9)

This commit is contained in:
Neil
2026-10-20 23:33:28 -07:00
parent b5cb7b5655
commit fe7d00535a
23 changed files with 2883 additions and 11 deletions
@@ -14,6 +14,7 @@ import { StarNagAgentValueMomentObserver } from '../components/star-nag/StarNagA
import { StarNagToastHost } from '../components/star-nag/StarNagToastHost'
import { TelemetryFirstLaunchSurface } from '../components/TelemetryFirstLaunchSurface'
import { ZoomOverlay } from '../components/ZoomOverlay'
import { WslMoveDialogHost } from '../components/wsl-move/WslMoveDialog'
import { shouldRenderPetOverlay } from '../components/pet/pet-overlay-visibility'
import { useAppStore } from '../store'
import type { UpdateStatus } from '../../../shared/update-status-types'
@@ -232,6 +233,8 @@ export function AppRootSurfaces(props: {
<AiVaultSessionHistoryDialog />
</ModalBoundary>
) : null}
{/* Own store, not activeModal: another modal must never evict a running move. */}
<WslMoveDialogHost />
{/* Root-hosted: a toast, the SSH host row and a terminal pane all open it. */}
{activeModal === 'node-dist-mirror' ? (
<ModalBoundary boundaryId="modal.node-dist-mirror" resetKey>
@@ -327,6 +327,7 @@ export function RepositoryPane({
/>
<RepositoryWindowsRuntimeSection
repo={repo}
repoDisplayName={repo.displayName}
project={project}
settings={settings}
@@ -1,13 +1,17 @@
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Project, ProjectUpdateArgs } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import { SearchableSetting } from './SearchableSetting'
import type { SettingsSearchEntry } from './settings-search'
import { matchesSettingsSearch } from './settings-search'
import { ProjectWindowsRuntimeSetting } from './ProjectWindowsRuntimeSetting'
import type { ProjectRuntimeSessionSummary } from './repository-runtime-session-summary'
import { translate } from '@/i18n/i18n'
import { useWslMoveTargetForRepo } from '../wsl-move/wsl-move-entry'
import { WslMoveRuntimeRow } from '../wsl-move/WslMoveRuntimeRow'
type RepositoryWindowsRuntimeSectionProps = {
repo?: Pick<Repo, 'id' | 'path' | 'connectionId' | 'executionHostId'>
repoDisplayName: string
project: Project | null
settings: Pick<GlobalSettings, 'localWindowsRuntimeDefault'> | null
@@ -26,6 +30,7 @@ type RepositoryWindowsRuntimeSectionProps = {
}
export function RepositoryWindowsRuntimeSection({
repo,
repoDisplayName,
project,
settings,
@@ -39,6 +44,8 @@ export function RepositoryWindowsRuntimeSection({
searchQuery,
searchEntries
}: RepositoryWindowsRuntimeSectionProps): React.JSX.Element | null {
// Owner decision: for a WSL project with a host, the runtime toggle becomes "Move project".
const wslMoveDistro = useWslMoveTargetForRepo(repo)
if (!settings || !project || !updateProject || !isLocalWindowsProject) {
return null
}
@@ -63,16 +70,20 @@ export function RepositoryWindowsRuntimeSection({
className="space-y-3"
forceVisible={forceVisible || matchesSettingsSearch(searchQuery, searchEntries)}
>
<ProjectWindowsRuntimeSetting
project={project}
settings={settings}
isLocalWindowsProject={isLocalWindowsProject}
wslAvailable={wslAvailable}
wslDistros={wslDistros}
wslCapabilitiesLoading={wslCapabilitiesLoading}
runtimeSessionSummary={runtimeSessionSummary}
updateProject={updateProject}
/>
{wslMoveDistro && repo ? (
<WslMoveRuntimeRow distro={wslMoveDistro} repoId={repo.id} />
) : (
<ProjectWindowsRuntimeSetting
project={project}
settings={settings}
isLocalWindowsProject={isLocalWindowsProject}
wslAvailable={wslAvailable}
wslDistros={wslDistros}
wslCapabilitiesLoading={wslCapabilitiesLoading}
runtimeSessionSummary={runtimeSessionSummary}
updateProject={updateProject}
/>
)}
</SearchableSetting>
)
}
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Plus, TerminalSquare, Trash2 } from 'lucide-react'
import { FolderInput, Plus, TerminalSquare, Trash2 } from 'lucide-react'
import type { WslHostStatus } from '../../../../shared/wsl-host-types'
import { toast } from 'sonner'
import {
@@ -29,6 +29,8 @@ import { Button } from '../ui/button'
import { DropdownMenuItem } from '../ui/dropdown-menu'
import { managedServerOutcomeLabel } from './managed-server-copy'
import { ServerListRow, ServerRowChip, type ServerRowStatus } from './runtime-server-list-row'
import { openWslMoveDialogFor } from '../wsl-move/wsl-move-entry'
import { WslMoveUnfinishedNotice } from '../wsl-move/WslMoveUnfinishedNotice'
function getWslHostRowStatus(
status: WslHostStatus | undefined,
@@ -223,6 +225,14 @@ export function WslHostsSection({
{translate('auto.components.settings.wslHosts.connect', 'Connect')}
</DropdownMenuItem>
) : null}
{unavailableLabel ? null : (
<DropdownMenuItem
onSelect={() => openWslMoveDialogFor(environment.orcadDeployment.distro)}
>
<FolderInput />
{translate('wslMove.entry.hostRow', 'Move projects from this distro…')}
</DropdownMenuItem>
)}
<DropdownMenuItem
variant="destructive"
disabled={connectingId !== null}
@@ -238,6 +248,7 @@ export function WslHostsSection({
})}
</div>
) : null}
{enabled && !serverFilesMissing ? <WslMoveUnfinishedNotice /> : null}
{removing ? (
<HostRemoveDialog
open
@@ -26,6 +26,7 @@ import {
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets'
import { WslMoveProjectMenuItem } from '@/components/wsl-move/WslMoveProjectMenuItem'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { WorktreeVisibilityDefaults } from '../../../../../../shared/global-settings-types'
@@ -165,6 +166,7 @@ export function RepoHeaderProjectActionsMenu({
{translate('auto.components.sidebar.WorktreeList.64e55f7f01', 'Remove from group')}
</DropdownMenuItem>
) : null}
<WslMoveProjectMenuItem repo={repo} />
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => actions.onRemoveProject(repo)}>
<Trash2 className="size-3.5" />
@@ -0,0 +1,78 @@
import React from 'react'
import { CircleAlert } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import type {
WslMoveBlocker,
WslMoveRowRef
} from '../../../../shared/wsl-move/wsl-move-driver-types'
import { wslMoveBlockerHint, wslMoveBlockerTitle } from './wsl-move-copy'
/** One blocker kind, with the projects it names that the user can leave out of the move. */
export type WslMoveBlockerView = {
blocker: WslMoveBlocker
/** The projects holding it, when known; "Skip" is offered only where main allows it. */
projects: (WslMoveRowRef & { name: string })[]
}
export function WslMoveBlockerList({
blockers,
onSkip
}: {
blockers: WslMoveBlockerView[]
onSkip?: (refs: WslMoveRowRef[]) => void
}): React.JSX.Element {
return (
<div className="space-y-2" data-testid="wsl-move-blockers">
<div className="text-xs font-medium text-muted-foreground">
{translate('wslMove.blockers.title', 'Before these projects can move')}
</div>
<ul className="space-y-2">
{blockers.map(({ blocker, projects }) => {
const canSkip = onSkip && blocker.actions.includes('skip-project') && projects.length > 0
return (
<li
key={blocker.kind}
className="flex gap-2.5 rounded-md border border-border px-3 py-2"
>
<CircleAlert className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="min-w-0 flex-1 space-y-1">
<div className="font-medium">
{wslMoveBlockerTitle(blocker.kind, blocker.ids.length)}
</div>
{projects.length > 0 ? (
<div className="text-xs text-muted-foreground">
{translate('wslMove.blockers.inProjects', 'In {{names}}', {
names: projects.map((project) => project.name).join(', ')
})}
</div>
) : null}
<div className="text-xs text-muted-foreground">
{wslMoveBlockerHint(blocker.kind)}
</div>
{canSkip ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={() => onSkip(projects)}
>
{projects.length === 1
? translate('wslMove.blockers.skipOne', 'Leave {{name}} out of this move', {
name: projects[0].name
})
: translate(
'wslMove.blockers.skipMany',
'Leave these {{n}} projects out of this move',
{ n: projects.length }
)}
</Button>
) : null}
</div>
</li>
)
})}
</ul>
</div>
)
}
@@ -0,0 +1,265 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type {
WslMoveRefusalCode,
WslMoveResult
} from '../../../../shared/wsl-move/wsl-move-driver-types'
import { wslMoveRefusalMessage } from './wsl-move-copy'
import { WslMovePreviewBody, type WslMovePreviewBodyProps } from './WslMovePreviewBody'
import { WslMoveProgressBody, wslMoveCancellable } from './WslMoveProgressBody'
import { WslMoveResultBody, wslMoveResultTitle } from './WslMoveResultBody'
const noop = (): void => undefined
const READY = {
outcome: 'ready' as const,
distro: 'Ubuntu',
repoIds: ['api'],
folderWorkspaceIds: ['notes'],
changed: [],
heldByHost: [],
refused: [],
blockers: []
}
function preview(overrides: Partial<WslMovePreviewBodyProps> = {}): string {
return renderToStaticMarkup(
<WslMovePreviewBody
distro="Ubuntu"
rows={[
{ kind: 'repo', id: 'api', name: 'api' },
{ kind: 'folder', id: 'notes', name: 'notes' }
]}
selected={[{ kind: 'repo', id: 'api' }]}
counts={{ projects: 1, workspaces: 2, terminals: 3, chats: 4 }}
preview={READY}
notMoving={[]}
blockers={[]}
onToggle={noop}
onSkip={noop}
onRecheck={noop}
onStart={noop}
onClose={noop}
{...overrides}
/>
)
}
function result(value: WslMoveResult, reload: 'idle' | 'checkpoint-refused' = 'idle'): string {
return renderToStaticMarkup(
<WslMoveResultBody
distro="Ubuntu"
result={value}
blockers={[]}
reload={reload}
onReload={noop}
onReview={noop}
onClose={noop}
/>
)
}
describe('preview', () => {
it('says verbatim that chats stay, lists every project with a checkbox, and counts the selection', () => {
const markup = preview()
expect(markup).toContain(
'Existing chats stay on this computer. New chats in this project start on the WSL host.'
)
expect(markup.match(/role="checkbox"/g)).toHaveLength(2)
expect(markup).toContain('Folder')
expect(markup).toContain('Projects: 1 · Workspaces: 2 · Terminals: 3 · Chats: 4')
expect(markup).toContain('>Move 1 project</button>')
expect(markup).not.toMatch(/disabled=""[^>]*>Move 1 project/)
})
it('cannot start while checking, with nothing selected, or with blockers', () => {
expect(preview({ preview: null })).toContain('Checking the selected projects')
expect(preview({ preview: null })).toMatch(/disabled=""[^>]*>Move 1 project/)
expect(preview({ selected: [] })).toMatch(/disabled=""[^>]*>Move 0 projects/)
const blocked = preview({
blockers: [
{
blocker: {
kind: 'agent-working',
ids: ['t:1'],
actions: ['wait', 'end', 'skip-project']
},
projects: [{ kind: 'repo', id: 'api', name: 'api' }]
}
]
})
expect(blocked).toMatch(/disabled=""[^>]*>Move 1 project/)
expect(blocked).toContain('An agent is working or waiting for an answer')
expect(blocked).toContain('Leave api out of this move')
expect(blocked).toContain('Check again')
})
it('offers no skip where main does not allow it', () => {
const markup = preview({
blockers: [
{
blocker: {
kind: 'scrollback-too-large',
ids: ['t:1', 't:2'],
actions: ['clear-scrollback']
},
projects: [{ kind: 'repo', id: 'api', name: 'api' }]
}
]
})
expect(markup).toContain('2 terminals have too much output history to move')
expect(markup).not.toContain('out of this move')
})
it('explains what is not moving and shows a refusal with its reasons', () => {
const markup = preview({
notMoving: [{ name: 'old', why: 'Already on the Ubuntu host.' }],
preview: {
outcome: 'refused',
code: 'wsl_move_export_incomplete',
reasons: ['1 draft can’t move with this project yet.']
}
})
expect(markup).toContain('Already on the Ubuntu host.')
expect(markup).toContain('cannot move yet')
expect(markup).toContain('1 draft can’t move with this project yet.')
})
})
describe('progress', () => {
it('shows bytes while copying and allows cancel until the switch', () => {
const markup = renderToStaticMarkup(
<WslMoveProgressBody
progress={{
distro: 'Ubuntu',
step: 'copying',
bytesSent: 5 * 1024 * 1024,
bytesTotal: 20 * 1024 * 1024
}}
cancel="idle"
onCancel={noop}
/>
)
expect(markup).toContain('5.0 MB of 20 MB')
expect(markup).toContain('data-state="active"')
expect(markup).not.toMatch(/disabled=""[^>]*>Cancel move/)
})
it('disables cancel from the switch on, and says so', () => {
expect(wslMoveCancellable(null)).toBe(true)
expect(wslMoveCancellable({ distro: 'Ubuntu', step: 'copying' })).toBe(true)
expect(wslMoveCancellable({ distro: 'Ubuntu', step: 'switching' })).toBe(false)
expect(wslMoveCancellable({ distro: 'Ubuntu', step: 'finishing' })).toBe(false)
const markup = renderToStaticMarkup(
<WslMoveProgressBody
progress={{ distro: 'Ubuntu', step: 'finishing' }}
cancel="idle"
onCancel={noop}
/>
)
expect(markup).toMatch(/disabled=""[^>]*>Cancel move/)
expect(markup).toContain('Finishing')
expect(markup).toContain('cannot be cancelled')
})
})
describe('result', () => {
it('asks for the reload, names automations still off, and points at the way back', () => {
const moved: WslMoveResult = {
outcome: 'moved',
migrationId: 'm1',
phase: 'retained',
reloadRequired: true,
automationsPending: ['a1', 'a2'],
browserLoginsNotCopiedReason: 'no-stable-partition'
}
const markup = result(moved)
expect(wslMoveResultTitle(moved, 'Ubuntu')).toBe('Moved to the Ubuntu host')
expect(markup).toContain('Reload now')
expect(markup).toContain('2 automations are off on the host for now')
expect(markup).toContain('Browser logins were not copied to the host (no-stable-partition)')
expect(markup).toContain('Use on this computer again')
expect(markup).not.toContain('Close')
})
it('shows a refused reload', () => {
const markup = result(
{ outcome: 'moved', migrationId: 'm1', phase: 'retained', reloadRequired: true },
'checkpoint-refused'
)
expect(markup).toContain('could not back up unsaved files')
})
it('says nothing was moved after a reset, and waits for the host when it may have committed', () => {
const reset = result({ outcome: 'reset', migrationId: 'm1', detail: 'stage failed' })
expect(wslMoveResultTitle({ outcome: 'reset', migrationId: 'm', detail: '' }, 'U')).toBe(
'Nothing was moved'
)
expect(reset).toContain('everything was put back')
expect(reset).toContain('Back to projects')
const waiting = result({ outcome: 'waiting-for-host', migrationId: 'm1', detail: 'x' })
expect(waiting).toContain('stay read-only here until it does')
expect(waiting).not.toContain('Reload now')
})
it('has a plain message for every refusal code', () => {
const codes: WslMoveRefusalCode[] = [
'wsl_move_unavailable',
'wsl_move_already_running',
'wsl_move_already_in_flight',
'wsl_move_nothing_new',
'wsl_move_host_unavailable',
'wsl_move_host_update_required',
'wsl_move_terminals_blocked',
'wsl_move_export_incomplete',
'wsl_move_manifest_too_large',
'wsl_move_pause_failed',
'wsl_move_scope_changed',
'wsl_move_cancelled',
'wsl_move_finished_earlier_move'
]
const messages = codes.map((code) =>
wslMoveRefusalMessage({ outcome: 'refused', code }, 'Ubuntu')
)
expect(new Set(messages).size).toBe(codes.length)
for (const message of messages) {
expect(message).not.toMatch(/wsl_move|manifest|orcad|UNC/i)
}
expect(
wslMoveRefusalMessage(
{ outcome: 'refused', code: 'wsl_move_manifest_too_large', message: 'Move fewer.' },
'Ubuntu'
)
).toBe('Move fewer.')
})
it('reloads, not closes, after an earlier move finished first', () => {
const markup = result({ outcome: 'refused', code: 'wsl_move_finished_earlier_move' })
expect(markup).toContain('Reload now')
expect(markup).not.toContain('Back to projects')
})
})
describe('entry points with the gate closed', () => {
it('render nothing', async () => {
vi.resetModules()
vi.doMock('../sidebar/use-wsl-hosts-enabled', () => ({ useWslHostsEnabled: () => false }))
vi.doMock('@/store', () => ({
useAppStore: (selector: (state: { runtimeEnvironments: unknown[] }) => unknown) =>
selector({
runtimeEnvironments: [
{
id: 'wsl:ubuntu',
name: 'Ubuntu (WSL)',
orcadDeployment: { kind: 'wsl', distro: 'Ubuntu', localPort: 1, remotePort: 2 }
}
]
})
}))
const { WslMoveProjectMenuItem } = await import('./WslMoveProjectMenuItem')
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the item reads only path and the host fields.
const repo = { id: 'api', path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\api' } as never
expect(renderToStaticMarkup(<WslMoveProjectMenuItem repo={repo} />)).toBe('')
vi.doUnmock('../sidebar/use-wsl-hosts-enabled')
vi.doUnmock('@/store')
})
})
@@ -0,0 +1,188 @@
import React from 'react'
import { useShallow } from 'zustand/react/shallow'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import type {
WslMoveBlocker,
WslMoveRowRef
} from '../../../../shared/wsl-move/wsl-move-driver-types'
import {
cancelWslMove,
closeWslMoveDialog,
mustReload,
recheckWslMove,
reloadAfterWslMove,
reviewWslMoveAgain,
setWslMoveSelection,
skipWslMoveProjects,
startWslMove,
useWslMoveDialogStore
} from './wsl-move-dialog-store'
import {
wslMoveBlockerProjects,
wslMoveCounts,
wslMoveProjectRows,
type WslMoveCatalog
} from './wsl-move-scope-rows'
import type { WslMoveBlockerView } from './WslMoveBlockerList'
import { WslMovePreviewBody, wslMoveNotMovingLines } from './WslMovePreviewBody'
import { WslMoveProgressBody } from './WslMoveProgressBody'
import { WslMoveResultBody, wslMoveResultTitle } from './WslMoveResultBody'
function useMoveCatalog(): WslMoveCatalog {
return useAppStore(
useShallow((state) => ({
repos: state.repos,
folderWorkspaces: state.folderWorkspaces,
worktreesByRepo: state.worktreesByRepo,
unifiedTabsByWorktree: state.unifiedTabsByWorktree
}))
)
}
function blockerViews(
catalog: WslMoveCatalog,
blockers: readonly WslMoveBlocker[],
selected: readonly WslMoveRowRef[]
): WslMoveBlockerView[] {
return blockers.map((blocker) => {
const refs = wslMoveBlockerProjects(catalog, blocker, selected)
return {
blocker,
projects: wslMoveProjectRows(
catalog,
refs.filter((ref) => ref.kind === 'repo').map((ref) => ref.id),
refs.filter((ref) => ref.kind === 'folder').map((ref) => ref.id)
)
}
})
}
/** Root-hosted: entry points in Settings, the sidebar and project settings all open it. */
export function WslMoveDialogHost(): React.JSX.Element | null {
const open = useWslMoveDialogStore((s) => s.dialog.stage !== 'closed')
return open ? <WslMoveDialog /> : null
}
function WslMoveDialog(): React.JSX.Element | null {
const dialog = useWslMoveDialogStore((s) => s.dialog)
const catalog = useMoveCatalog()
if (dialog.stage === 'closed') {
return null
}
const { distro } = dialog
const busy =
dialog.stage === 'running' || (dialog.stage === 'result' && mustReload(dialog.result))
let title: string
let body: React.JSX.Element
switch (dialog.stage) {
case 'loading':
title = translate('wslMove.title', 'Move projects to the {{distro}} host', { distro })
body = (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoadingSpinner className="size-4" />
{translate('wslMove.loading', 'Checking the projects in {{distro}}…', { distro })}
</div>
)
break
case 'preview': {
const { candidates, selected, preview } = dialog
const rows = wslMoveProjectRows(catalog, candidates.repoIds, candidates.folderWorkspaceIds)
const nameOf = (ref: WslMoveRowRef): string =>
wslMoveProjectRows(
catalog,
ref.kind === 'repo' ? [ref.id] : [],
ref.kind === 'folder' ? [ref.id] : []
)[0].name
title = translate('wslMove.title', 'Move projects to the {{distro}} host', { distro })
body = (
<WslMovePreviewBody
distro={distro}
rows={rows}
selected={selected}
counts={wslMoveCounts(catalog, selected)}
preview={preview}
notMoving={wslMoveNotMovingLines(dialog.distroPreview, nameOf, distro)}
blockers={
preview?.outcome === 'ready' ? blockerViews(catalog, preview.blockers, selected) : []
}
onToggle={(ref, checked) =>
setWslMoveSelection(
checked
? [...selected, ref]
: selected.filter((s) => s.kind !== ref.kind || s.id !== ref.id)
)
}
onSkip={skipWslMoveProjects}
onRecheck={() => void recheckWslMove()}
onStart={() => void startWslMove()}
onClose={closeWslMoveDialog}
/>
)
break
}
case 'running':
title = translate('wslMove.runningTitle', 'Moving to the {{distro}} host', { distro })
body = (
<WslMoveProgressBody
progress={dialog.progress}
cancel={dialog.cancel}
onCancel={() => void cancelWslMove()}
/>
)
break
case 'result': {
const { result } = dialog
title = wslMoveResultTitle(result, distro)
body = (
<WslMoveResultBody
distro={distro}
result={result}
blockers={
result.outcome === 'refused' && result.blockers
? blockerViews(catalog, result.blockers, dialog.selected)
: []
}
reload={dialog.reload}
onReload={() => void reloadAfterWslMove()}
onReview={() => void reviewWslMoveAgain()}
onClose={closeWslMoveDialog}
/>
)
break
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) {
closeWslMoveDialog()
}
}}
>
<DialogContent
className="sm:max-w-lg"
showCloseButton={!busy}
data-testid="wsl-move-dialog"
// A running move or a pending reload must not be dismissed by a stray click or Esc.
onEscapeKeyDown={(event) => busy && event.preventDefault()}
onInteractOutside={(event) => busy && event.preventDefault()}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only">{title}</DialogDescription>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,186 @@
import React from 'react'
import { translate } from '@/i18n/i18n'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { DialogFooter } from '@/components/ui/dialog'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import type {
WslMovePreview,
WslMoveRowRef
} from '../../../../shared/wsl-move/wsl-move-driver-types'
import {
wslMoveChatsStayCopy,
wslMoveRefusalMessage,
wslMoveScopeRefusalReason
} from './wsl-move-copy'
import type { WslMoveCounts, WslMoveProjectRow } from './wsl-move-scope-rows'
import { WslMoveBlockerList, type WslMoveBlockerView } from './WslMoveBlockerList'
export type WslMovePreviewBodyProps = {
distro: string
rows: WslMoveProjectRow[]
selected: readonly WslMoveRowRef[]
counts: WslMoveCounts
/** The selection's preview; null while it is being checked. */
preview: WslMovePreview | null
/** Listed by the whole-distro preview but not moved, each with why. */
notMoving: { name: string; why: string }[]
blockers: WslMoveBlockerView[]
onToggle: (ref: WslMoveRowRef, checked: boolean) => void
onSkip: (refs: WslMoveRowRef[]) => void
onRecheck: () => void
onStart: () => void
onClose: () => void
}
const isSelected = (selected: readonly WslMoveRowRef[], row: WslMoveRowRef): boolean =>
selected.some((ref) => ref.kind === row.kind && ref.id === row.id)
function countsLine(counts: WslMoveCounts): string {
return translate(
'wslMove.preview.counts',
'Projects: {{projects}} · Workspaces: {{workspaces}} · Terminals: {{terminals}} · Chats: {{chats}}',
{ ...counts }
)
}
export function WslMovePreviewBody(props: WslMovePreviewBodyProps): React.JSX.Element {
const { distro, rows, selected, preview, blockers } = props
const ready = preview?.outcome === 'ready' && blockers.length === 0 && selected.length > 0
return (
<>
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">
{translate(
'wslMove.preview.whatHappens',
'Your files stay where they are. Terminals restart in the same folder; their output, shell history and agent conversations are kept.'
)}
</p>
<p data-testid="wsl-move-chats-stay">{wslMoveChatsStayCopy()}</p>
{rows.length > 0 ? (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
{translate('wslMove.preview.projects', 'Projects in {{distro}}', { distro })}
</div>
<ul className="max-h-48 divide-y divide-border overflow-y-auto rounded-md border border-border scrollbar-sleek">
{rows.map((row) => {
const id = `wsl-move-row-${row.kind}-${row.id}`
return (
<li key={id} className="flex items-center gap-2.5 px-3 py-2">
<Checkbox
id={id}
checked={isSelected(selected, row)}
onCheckedChange={(checked) => props.onToggle(row, checked === true)}
/>
<label htmlFor={id} className="min-w-0 flex-1 truncate">
{row.name}
</label>
{row.kind === 'folder' ? (
<Badge variant="outline">
{translate('wslMove.preview.folderBadge', 'Folder')}
</Badge>
) : null}
</li>
)
})}
</ul>
{selected.length > 0 ? (
<div className="text-xs text-muted-foreground" data-testid="wsl-move-counts">
{countsLine(props.counts)}
</div>
) : null}
</div>
) : null}
{props.notMoving.length > 0 ? (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">
{translate('wslMove.preview.notMoving', 'Not moving')}
</div>
<ul className="space-y-0.5 text-xs">
{props.notMoving.map((entry) => (
<li key={entry.name} className="text-muted-foreground">
<span className="text-foreground">{entry.name}</span>
{' — '}
{entry.why}
</li>
))}
</ul>
</div>
) : null}
{preview === null && selected.length > 0 ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<LoadingSpinner className="size-3.5" />
{translate('wslMove.preview.checking', 'Checking the selected projects…')}
</div>
) : null}
{preview?.outcome === 'refused' ? (
<div className="space-y-1 text-destructive" role="alert">
<p>{wslMoveRefusalMessage(preview, distro)}</p>
{preview.reasons?.length ? (
<ul className="list-disc space-y-0.5 pl-5 text-xs">
{preview.reasons.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
) : null}
</div>
) : null}
{blockers.length > 0 ? (
<WslMoveBlockerList blockers={blockers} onSkip={props.onSkip} />
) : null}
</div>
<DialogFooter>
{blockers.length > 0 || preview?.outcome === 'refused' ? (
<Button
type="button"
variant="outline"
className="sm:mr-auto"
disabled={preview === null}
onClick={props.onRecheck}
>
{translate('wslMove.preview.checkAgain', 'Check again')}
</Button>
) : null}
<Button type="button" variant="ghost" onClick={props.onClose}>
{translate('wslMove.cancel', 'Cancel')}
</Button>
<Button type="button" disabled={!ready} onClick={props.onStart}>
{selected.length === 1
? translate('wslMove.preview.moveOne', 'Move 1 project')
: translate('wslMove.preview.moveMany', 'Move {{n}} projects', {
n: selected.length
})}
</Button>
</DialogFooter>
</>
)
}
/** Plain-words lines for what the whole-distro preview lists but will not move. */
export function wslMoveNotMovingLines(
preview: WslMovePreview,
nameOf: (ref: WslMoveRowRef) => string,
distro: string
): { name: string; why: string }[] {
if (preview.outcome !== 'ready') {
return []
}
return [
...preview.heldByHost.map((ref) => ({
name: nameOf(ref),
why: translate('wslMove.preview.heldByHost', 'Already on the {{distro}} host.', { distro })
})),
...preview.changed.map((ref) => ({
name: nameOf(ref),
why: translate(
'wslMove.preview.changed',
'Moved earlier, then changed here by an older version of Orca. It is not moved again.'
)
})),
...preview.refused.map((entry) => ({
name: nameOf({ kind: entry.kind === 'repo' ? 'repo' : 'folder', id: entry.id }),
why: wslMoveScopeRefusalReason(entry.reason)
}))
]
}
@@ -0,0 +1,114 @@
import React from 'react'
import { Check, Circle } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { DialogFooter } from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import type {
WslMoveProgressEvent,
WslMoveProgressStep
} from '../../../../shared/wsl-move/wsl-move-driver-types'
import { formatWslMoveBytes, wslMoveStepLabel } from './wsl-move-copy'
// 'finishing' is the tail of the switch on this desktop; the stepper shows it under Switching.
const STEPS: WslMoveProgressStep[] = [
'preparing',
'pausing-terminals',
'copying',
'switching',
'done'
]
function stepIndex(step: WslMoveProgressStep): number {
return step === 'finishing' ? STEPS.indexOf('switching') : STEPS.indexOf(step)
}
/** Main honours a cancel only until it asks the host to commit, which follows copying. */
export function wslMoveCancellable(progress: WslMoveProgressEvent | null): boolean {
return progress === null || stepIndex(progress.step) < STEPS.indexOf('switching')
}
export function WslMoveProgressBody({
progress,
cancel,
onCancel
}: {
progress: WslMoveProgressEvent | null
cancel: 'idle' | 'requested' | 'too-late'
onCancel: () => void
}): React.JSX.Element {
const current = progress ? stepIndex(progress.step) : 0
const cancellable = wslMoveCancellable(progress) && cancel !== 'too-late'
const bytesTotal = progress?.bytesTotal
return (
<>
<ol className="space-y-2 text-sm" data-testid="wsl-move-progress">
{STEPS.map((step, index) => {
const state = index < current ? 'done' : index === current ? 'active' : 'pending'
return (
<li
key={step}
className="flex items-center gap-2.5 data-[state=pending]:text-muted-foreground"
data-state={state}
>
{state === 'done' ? (
<Check className="size-4 text-muted-foreground" />
) : state === 'active' && step !== 'done' ? (
<LoadingSpinner className="size-4" />
) : state === 'active' ? (
<Check className="size-4" />
) : (
<Circle className="size-4 text-muted-foreground" />
)}
<span>
{step === 'switching' && progress?.step === 'finishing'
? wslMoveStepLabel('finishing')
: wslMoveStepLabel(step)}
</span>
</li>
)
})}
</ol>
{progress?.step === 'copying' && bytesTotal ? (
<div className="space-y-1">
<Progress value={Math.min(100, ((progress.bytesSent ?? 0) / bytesTotal) * 100)} />
<div className="text-xs text-muted-foreground">
{translate('wslMove.progress.bytes', '{{sent}} of {{total}}', {
sent: formatWslMoveBytes(progress.bytesSent ?? 0),
total: formatWslMoveBytes(bytesTotal)
})}
</div>
</div>
) : null}
<p className="text-xs text-muted-foreground">
{cancel === 'too-late'
? translate(
'wslMove.progress.tooLate',
'Too late to cancel: the host is already switching over. Keep Orca open until it finishes.'
)
: cancellable
? translate(
'wslMove.progress.readOnly',
'These projects are read-only while they move. You can cancel until the switch starts.'
)
: translate(
'wslMove.progress.switching',
'The switch cannot be cancelled. Keep Orca open until it finishes.'
)}
</p>
<DialogFooter>
<Button
type="button"
variant="ghost"
disabled={!cancellable || cancel !== 'idle'}
onClick={onCancel}
>
{cancel === 'requested'
? translate('wslMove.progress.cancelling', 'Cancelling…')
: translate('wslMove.progress.cancel', 'Cancel move')}
</Button>
</DialogFooter>
</>
)
}
@@ -0,0 +1,20 @@
import React from 'react'
import { FolderInput } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import type { Repo } from '../../../../shared/repo-types'
import { openWslMoveDialogFor, useWslMoveTargetForRepo } from './wsl-move-entry'
/** "Move to <distro> host…" for a local WSL project; nothing where no move is offered. */
export function WslMoveProjectMenuItem({ repo }: { repo: Repo }): React.JSX.Element | null {
const distro = useWslMoveTargetForRepo(repo)
if (!distro) {
return null
}
return (
<DropdownMenuItem onSelect={() => openWslMoveDialogFor(distro, { kind: 'repo', id: repo.id })}>
<FolderInput className="size-3.5" />
{translate('wslMove.entry.projectMenu', 'Move to {{distro}} host…', { distro })}
</DropdownMenuItem>
)
}
@@ -0,0 +1,171 @@
import React from 'react'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { DialogFooter } from '@/components/ui/dialog'
import type { WslMoveResult } from '../../../../shared/wsl-move/wsl-move-driver-types'
import type { LazyChunkRecoveryReloadOutcome } from '@/lib/lazy-chunk-recovery-reload'
import { wslMoveBrowserLoginsNotCopied, wslMoveRefusalMessage } from './wsl-move-copy'
import { WslMoveBlockerList, type WslMoveBlockerView } from './WslMoveBlockerList'
export type WslMoveReloadState = 'idle' | 'reloading' | LazyChunkRecoveryReloadOutcome
export function wslMoveResultTitle(result: WslMoveResult, distro: string): string {
switch (result.outcome) {
case 'moved':
return translate('wslMove.result.movedTitle', 'Moved to the {{distro}} host', { distro })
case 'reset':
return translate('wslMove.result.resetTitle', 'Nothing was moved')
case 'waiting-for-host':
return translate('wslMove.result.waitingTitle', 'Waiting for the {{distro}} host', {
distro
})
case 'refused':
return result.code === 'wsl_move_finished_earlier_move'
? translate('wslMove.result.earlierMoveTitle', 'An earlier move finished first')
: translate('wslMove.result.refusedTitle', 'Projects not moved')
}
}
function reloadFailure(reload: WslMoveReloadState): string | null {
switch (reload) {
case 'idle':
case 'reloading':
return null
case 'checkpoint-refused':
return translate(
'wslMove.result.reloadCheckpointRefused',
'Orca could not back up unsaved files, so the window did not reload. Save or close them, then reload.'
)
case 'unload-vetoed':
return translate(
'wslMove.result.reloadVetoed',
'Something kept the window from reloading. Save your work, then reload.'
)
case 'never-landed':
case 'request-failed':
return translate('wslMove.result.reloadFailed', 'The window did not reload. Try again.')
}
}
export function WslMoveResultBody({
distro,
result,
blockers,
reload,
onReload,
onReview,
onClose
}: {
distro: string
result: WslMoveResult
blockers: WslMoveBlockerView[]
reload: WslMoveReloadState
onReload: () => void
onReview: () => void
onClose: () => void
}): React.JSX.Element {
const needsReload =
result.outcome === 'moved' ||
(result.outcome === 'refused' && result.code === 'wsl_move_finished_earlier_move')
const failure = reloadFailure(reload)
return (
<>
<div
className="space-y-3 text-sm"
data-testid="wsl-move-result"
data-outcome={result.outcome}
>
{result.outcome === 'moved' ? (
<>
<p>
{translate(
'wslMove.result.moved',
'Reload this window to see the projects under the {{distro}} host. Orca backs up unsaved files before it reloads.',
{ distro }
)}
</p>
{result.automationsPending?.length ? (
<p data-testid="wsl-move-automations-pending">
{result.automationsPending.length === 1
? translate(
'wslMove.result.automationsPendingOne',
'One automation is off on the host for now. Orca keeps asking the host to turn it back on.'
)
: translate(
'wslMove.result.automationsPendingMany',
'{{n}} automations are off on the host for now. Orca keeps asking the host to turn them back on.',
{ n: result.automationsPending.length }
)}
</p>
) : null}
{result.browserLoginsNotCopiedReason ? (
<p>{wslMoveBrowserLoginsNotCopied(result.browserLoginsNotCopiedReason)}</p>
) : null}
<p className="text-muted-foreground">
{translate(
'wslMove.result.undoHint',
'Changed your mind? After the reload, the sidebar lists these projects as “Moved to {{distro}} host”. Choose “Use on this computer again” there to bring them back as they were before the move.',
{ distro }
)}
</p>
</>
) : null}
{result.outcome === 'reset' ? (
<p>
{translate(
'wslMove.result.reset',
'The move stopped and everything was put back. The projects work on this computer as before.'
)}
</p>
) : null}
{result.outcome === 'waiting-for-host' ? (
<p>
{translate(
'wslMove.result.waiting',
'The move may have reached the {{distro}} host, but the host is not answering. These projects stay read-only here until it does. Connect the host in Settings, then start the move again to finish it.',
{ distro }
)}
</p>
) : null}
{result.outcome === 'refused' ? (
<div className="space-y-1">
<p>{wslMoveRefusalMessage(result, distro)}</p>
{result.reasons?.length ? (
<ul className="list-disc space-y-0.5 pl-5 text-xs text-muted-foreground">
{result.reasons.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
) : null}
</div>
) : null}
{blockers.length > 0 ? <WslMoveBlockerList blockers={blockers} /> : null}
{failure ? (
<p className="text-destructive" role="alert">
{failure}
</p>
) : null}
</div>
<DialogFooter>
{needsReload ? (
<Button type="button" disabled={reload === 'reloading'} onClick={onReload}>
{reload === 'reloading'
? translate('wslMove.result.reloading', 'Reloading…')
: translate('wslMove.result.reload', 'Reload now')}
</Button>
) : (
<>
<Button type="button" variant="ghost" onClick={onClose}>
{translate('wslMove.result.close', 'Close')}
</Button>
{result.outcome === 'refused' || result.outcome === 'reset' ? (
<Button type="button" variant="outline" onClick={onReview}>
{translate('wslMove.result.review', 'Back to projects')}
</Button>
) : null}
</>
)}
</DialogFooter>
</>
)
}
@@ -0,0 +1,35 @@
import React from 'react'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { SettingsRow } from '../settings/SettingsFormControls'
import { openWslMoveDialogFor } from './wsl-move-entry'
/** The project runtime setting of a WSL project whose distro has a host: a move, not a toggle. */
export function WslMoveRuntimeRow({
distro,
repoId
}: {
distro: string
repoId: string
}): React.JSX.Element {
return (
<SettingsRow
label={translate('wslMove.runtime.label', 'Project runtime')}
description={translate(
'wslMove.runtime.description',
'This project runs in {{distro}} through this computer. Move it to the {{distro}} host to run it there.',
{ distro }
)}
control={
<Button
type="button"
variant="outline"
size="sm"
onClick={() => openWslMoveDialogFor(distro, { kind: 'repo', id: repoId })}
>
{translate('wslMove.runtime.move', 'Move project')}
</Button>
}
/>
)
}
@@ -0,0 +1,38 @@
import React, { useEffect } from 'react'
import { Info } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { refreshWslMoveUnfinished, useWslMoveDialogStore } from './wsl-move-dialog-store'
/** Committed moves whose automations the host has not turned back on yet; never left unsaid. */
export function WslMoveUnfinishedNotice(): React.JSX.Element | null {
const unfinished = useWslMoveDialogStore((s) => s.unfinished)
useEffect(() => {
void refreshWslMoveUnfinished()
}, [])
const lines = unfinished.filter((move) => move.automationsPending.length > 0)
if (lines.length === 0) {
return null
}
return (
<ul className="space-y-1" data-testid="wsl-move-unfinished">
{lines.map((move) => (
<li key={move.migrationId} className="flex gap-2 text-xs text-muted-foreground">
<Info className="mt-0.5 size-3.5 shrink-0" />
<span>
{move.automationsPending.length === 1
? translate(
'wslMove.unfinished.one',
'One automation moved to the {{distro}} host is still off. Orca keeps asking the host to turn it back on.',
{ distro: move.distro }
)
: translate(
'wslMove.unfinished.many',
'{{n}} automations moved to the {{distro}} host are still off. Orca keeps asking the host to turn them back on.',
{ n: move.automationsPending.length, distro: move.distro }
)}
</span>
</li>
))}
</ul>
)
}
@@ -0,0 +1,248 @@
/** Plain-language copy for the WSL move dialog: no "manifest", "orcad" or "UNC". */
import { translate } from '@/i18n/i18n'
import type {
WslMoveBlockerKind,
WslMoveProgressStep,
WslMoveRefusal
} from '../../../../shared/wsl-move/wsl-move-driver-types'
/** Decision C, verbatim from the design. */
export function wslMoveChatsStayCopy(): string {
return translate(
'wslMove.preview.chatsStay',
'Existing chats stay on this computer. New chats in this project start on the WSL host.'
)
}
export function wslMoveRefusalMessage(refusal: WslMoveRefusal, distro: string): string {
switch (refusal.code) {
case 'wsl_move_unavailable':
return translate(
'wslMove.refusal.unavailable',
'Moving projects to a WSL host is not available in this window.'
)
case 'wsl_move_already_running':
return translate(
'wslMove.refusal.alreadyRunning',
'A move into {{distro}} is already running. Wait for it to finish, then try again.',
{ distro }
)
case 'wsl_move_already_in_flight':
return translate(
'wslMove.refusal.alreadyInFlight',
'An earlier move into {{distro}} has not finished, and the {{distro}} host is not answering about it. Connect the host in Settings, then try again.',
{ distro }
)
case 'wsl_move_nothing_new':
return translate(
'wslMove.refusal.nothingNew',
'Every selected project is already on the {{distro}} host.',
{ distro }
)
case 'wsl_move_host_unavailable':
return refusal.detail === 'destination_changed'
? translate(
'wslMove.refusal.destinationChanged',
'Earlier projects from {{distro}} moved to a different WSL host. Connect that host to move more.',
{ distro }
)
: translate(
'wslMove.refusal.hostUnavailable',
'The {{distro}} host is not connected. Connect it in Settings, then try again.',
{ distro }
)
case 'wsl_move_host_update_required':
return translate(
'wslMove.refusal.hostUpdateRequired',
'The {{distro}} host runs an older version of Orca. Update it in Settings, then try again.',
{ distro }
)
case 'wsl_move_terminals_blocked':
return translate(
'wslMove.refusal.terminalsBlocked',
'Some terminals, agents or automations in these projects are busy. Nothing was moved.'
)
case 'wsl_move_export_incomplete':
return translate(
'wslMove.refusal.exportIncomplete',
'Some saved state in these projects cannot move yet, so nothing was moved:'
)
case 'wsl_move_manifest_too_large':
return (
refusal.message ??
translate(
'wslMove.refusal.tooLarge',
'These projects have too much saved state to move in one step. Move fewer projects at a time.'
)
)
case 'wsl_move_pause_failed':
return translate(
'wslMove.refusal.pauseFailed',
'Orca could not pause the terminals in these projects, so nothing was moved. Try again.'
)
case 'wsl_move_scope_changed':
return translate(
'wslMove.refusal.scopeChanged',
'A project changed while the move was getting ready, so nothing was moved. Check the projects and try again.'
)
case 'wsl_move_cancelled':
return translate('wslMove.refusal.cancelled', 'Move cancelled. Nothing was moved.')
case 'wsl_move_finished_earlier_move':
return translate(
'wslMove.refusal.finishedEarlierMove',
'Orca first finished an earlier move into {{distro}}. Reload the window, then start this move again.',
{ distro }
)
}
}
export function wslMoveStepLabel(step: WslMoveProgressStep): string {
switch (step) {
case 'preparing':
return translate('wslMove.step.preparing', 'Preparing')
case 'pausing-terminals':
return translate('wslMove.step.pausingTerminals', 'Pausing terminals')
case 'copying':
return translate('wslMove.step.copying', 'Copying')
case 'switching':
return translate('wslMove.step.switching', 'Switching')
case 'finishing':
return translate('wslMove.step.finishing', 'Finishing')
case 'done':
return translate('wslMove.step.done', 'Done')
}
}
export function wslMoveBlockerTitle(kind: WslMoveBlockerKind, n: number): string {
const one = n === 1
switch (kind) {
case 'agent-working':
return one
? translate(
'wslMove.blocker.agentWorkingOne',
'An agent is working or waiting for an answer'
)
: translate(
'wslMove.blocker.agentWorkingMany',
'{{n}} agents are working or waiting for an answer',
{ n }
)
case 'process-running':
return one
? translate('wslMove.blocker.processRunningOne', 'A terminal is running a program')
: translate('wslMove.blocker.processRunningMany', '{{n}} terminals are running a program', {
n
})
case 'agent-not-resumable':
return one
? translate(
'wslMove.blocker.agentNotResumableOne',
'An agent cannot be resumed after the move'
)
: translate(
'wslMove.blocker.agentNotResumableMany',
'{{n}} agents cannot be resumed after the move',
{ n }
)
case 'unverifiable':
return one
? translate('wslMove.blocker.unverifiableOne', 'Orca cannot check a terminal right now')
: translate(
'wslMove.blocker.unverifiableMany',
'Orca cannot check {{n}} terminals right now',
{
n
}
)
case 'automation-running':
return one
? translate('wslMove.blocker.automationRunningOne', 'An automation run is active')
: translate('wslMove.blocker.automationRunningMany', '{{n}} automation runs are active', {
n
})
case 'dispatch-running':
return one
? translate('wslMove.blocker.dispatchRunningOne', 'An agent task is in progress')
: translate('wslMove.blocker.dispatchRunningMany', '{{n}} agent tasks are in progress', {
n
})
case 'scrollback-too-large':
return one
? translate(
'wslMove.blocker.scrollbackTooLargeOne',
'A terminal has too much output history to move'
)
: translate(
'wslMove.blocker.scrollbackTooLargeMany',
'{{n}} terminals have too much output history to move',
{ n }
)
}
}
/** What the user can do about a blocker kind, given the actions main offers for it. */
export function wslMoveBlockerHint(kind: WslMoveBlockerKind): string {
switch (kind) {
case 'agent-working':
return translate(
'wslMove.blockerHint.agentWorking',
'Let it finish, or stop it in its terminal, then check again.'
)
case 'process-running':
return translate(
'wslMove.blockerHint.processRunning',
'Stop the program (for example a dev server or editor), then check again.'
)
case 'agent-not-resumable':
return translate(
'wslMove.blockerHint.agentNotResumable',
'Exit the agent in its terminal, then check again.'
)
case 'unverifiable':
return translate(
'wslMove.blockerHint.unverifiable',
'Wait a moment, then check again. Nothing is stopped to make the move happen.'
)
case 'automation-running':
case 'dispatch-running':
return translate('wslMove.blockerHint.wait', 'Wait for it to finish, then check again.')
case 'scrollback-too-large':
return translate(
'wslMove.blockerHint.scrollbackTooLarge',
'Clear the terminal output in those terminals, then check again.'
)
}
}
export function wslMoveScopeRefusalReason(reason: string): string {
switch (reason) {
case 'windows-drive':
return translate(
'wslMove.scopeRefused.windowsDrive',
'Its folder is on a Windows drive, so it stays on this computer.'
)
case 'mixed-locations':
return translate(
'wslMove.scopeRefused.mixedLocations',
'Some of its folders are outside this distro, so it stays on this computer.'
)
case 'not-local':
return translate('wslMove.scopeRefused.notLocal', 'It already runs on another host.')
default:
return translate('wslMove.scopeRefused.other', 'It cannot move.')
}
}
/** 7f's reasons for leaving the host's browser on a fresh partition; unknown ones read generically. */
export function wslMoveBrowserLoginsNotCopied(reason: string): string {
return translate(
'wslMove.result.browserLoginsNotCopied',
'Browser logins were not copied to the host ({{reason}}). Sign in again in the host’s browser tabs.',
{ reason }
)
}
export function formatWslMoveBytes(bytes: number): string {
const megabytes = bytes / (1024 * 1024)
return megabytes >= 10 ? `${Math.round(megabytes)} MB` : `${megabytes.toFixed(1)} MB`
}
@@ -0,0 +1,255 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
WslMovePreview,
WslMoveProgressEvent,
WslMoveResult
} from '../../../../shared/wsl-move/wsl-move-driver-types'
const reload = vi.fn()
vi.mock('@/lib/lazy-chunk-recovery-reload', () => ({
requestLazyChunkRecoveryReload: (...args: unknown[]) => reload(...args)
}))
const store = await import('./wsl-move-dialog-store')
const {
cancelWslMove,
closeWslMoveDialog,
openWslMoveDialog,
reloadAfterWslMove,
reviewWslMoveAgain,
setWslMoveSelection,
skipWslMoveProjects,
startWslMove,
useWslMoveDialogStore
} = store
const READY: WslMovePreview = {
outcome: 'ready',
distro: 'Ubuntu',
repoIds: ['api', 'web'],
folderWorkspaceIds: ['notes'],
changed: [],
heldByHost: [],
refused: [],
blockers: []
}
type Api = {
preview: ReturnType<typeof vi.fn>
start: ReturnType<typeof vi.fn>
cancel: ReturnType<typeof vi.fn>
listUnfinished: ReturnType<typeof vi.fn>
onProgress: ReturnType<typeof vi.fn>
}
let api: Api
let emitProgress: (event: WslMoveProgressEvent) => void
const unsubscribe = vi.fn()
const dialog = () => useWslMoveDialogStore.getState().dialog
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => undefined
const promise = new Promise<T>((r) => {
resolve = r
})
return { promise, resolve }
}
beforeEach(() => {
useWslMoveDialogStore.setState({ dialog: { stage: 'closed' }, unfinished: [] })
emitProgress = () => undefined
api = {
preview: vi.fn(async () => READY),
start: vi.fn(),
cancel: vi.fn(async () => true),
listUnfinished: vi.fn(async () => []),
onProgress: vi.fn((listener: (event: WslMoveProgressEvent) => void) => {
emitProgress = listener
return unsubscribe
})
}
vi.stubGlobal('window', { api: { wslMove: api } })
reload.mockReset()
unsubscribe.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('preview', () => {
it('selects the whole distro by default and reuses its preview', async () => {
await openWslMoveDialog({ distro: 'Ubuntu' })
const state = dialog()
expect(state.stage).toBe('preview')
expect(state.stage === 'preview' && state.selected).toHaveLength(3)
expect(state.stage === 'preview' && state.preview).toBe(READY)
expect(api.preview).toHaveBeenCalledTimes(1)
expect(api.preview).toHaveBeenCalledWith({ distro: 'Ubuntu' })
})
it('opened from one project, checks only it and previews that selection', async () => {
await openWslMoveDialog({ distro: 'Ubuntu', only: { kind: 'repo', id: 'web' } })
expect(api.preview).toHaveBeenLastCalledWith({
distro: 'Ubuntu',
repoIds: ['web'],
folderWorkspaceIds: []
})
const state = dialog()
expect(state.stage === 'preview' && state.selected).toEqual([{ kind: 'repo', id: 'web' }])
})
it('drops a stale preview reply when the selection changed meanwhile', async () => {
await openWslMoveDialog({ distro: 'Ubuntu' })
const slow = deferred<WslMovePreview>()
api.preview.mockReturnValueOnce(slow.promise)
setWslMoveSelection([{ kind: 'repo', id: 'api' }])
const fresh: WslMovePreview = { ...READY, repoIds: ['web'] }
api.preview.mockResolvedValueOnce(fresh)
setWslMoveSelection([{ kind: 'repo', id: 'web' }])
await vi.waitFor(() => {
const state = dialog()
expect(state.stage === 'preview' && state.preview).toBe(fresh)
})
slow.resolve(READY)
await Promise.resolve()
const state = dialog()
expect(state.stage === 'preview' && state.preview).toBe(fresh)
})
it('skips the projects a blocker names', async () => {
await openWslMoveDialog({ distro: 'Ubuntu' })
skipWslMoveProjects([{ kind: 'repo', id: 'api' }])
const state = dialog()
expect(state.stage === 'preview' && state.selected).toEqual([
{ kind: 'repo', id: 'web' },
{ kind: 'folder', id: 'notes' }
])
})
it('lists catalog projects when a smaller selection may avoid the refusal', async () => {
api.preview.mockResolvedValueOnce({
outcome: 'refused',
code: 'wsl_move_export_incomplete',
reasons: ['1 draft can’t move with this project yet.']
})
await openWslMoveDialog({
distro: 'Ubuntu',
catalogCandidates: () => ({ repoIds: ['api'], folderWorkspaceIds: [] })
})
const state = dialog()
expect(state.stage === 'preview' && state.candidates.repoIds).toEqual(['api'])
})
it('keeps no checkboxes for a refusal no selection can fix', async () => {
api.preview.mockResolvedValueOnce({ outcome: 'refused', code: 'wsl_move_host_unavailable' })
await openWslMoveDialog({
distro: 'Ubuntu',
catalogCandidates: () => ({ repoIds: ['api'], folderWorkspaceIds: [] })
})
const state = dialog()
expect(state.stage === 'preview' && state.selected).toEqual([])
expect(state.stage === 'preview' && state.preview?.outcome).toBe('refused')
})
it('turns a failed preview call into a refusal, never a stuck spinner', async () => {
api.preview.mockRejectedValueOnce(new Error('ipc'))
await openWslMoveDialog({ distro: 'Ubuntu' })
const state = dialog()
expect(state.stage === 'preview' && state.preview?.outcome).toBe('refused')
})
})
describe('run', () => {
it('follows progress for its distro, then shows the result and refreshes unfinished moves', async () => {
await openWslMoveDialog({ distro: 'Ubuntu' })
const run = deferred<WslMoveResult>()
api.start.mockReturnValueOnce(run.promise)
const started = startWslMove()
expect(dialog().stage).toBe('running')
emitProgress({ distro: 'Debian', step: 'switching' })
emitProgress({ distro: 'Ubuntu', step: 'copying', bytesSent: 5, bytesTotal: 10 })
const running = dialog()
expect(running.stage === 'running' && running.progress?.step).toBe('copying')
run.resolve({
outcome: 'moved',
migrationId: 'm1',
phase: 'retained',
reloadRequired: true,
automationsPending: ['a1']
})
await started
expect(unsubscribe).toHaveBeenCalled()
const result = dialog()
expect(result.stage === 'result' && result.result.outcome).toBe('moved')
expect(api.listUnfinished).toHaveBeenCalled()
})
it('cannot be closed or replaced while running, nor before a moved window reloads', async () => {
await openWslMoveDialog({ distro: 'Ubuntu' })
const run = deferred<WslMoveResult>()
api.start.mockReturnValueOnce(run.promise)
const started = startWslMove()
closeWslMoveDialog()
await openWslMoveDialog({ distro: 'Debian' })
expect(dialog().stage).toBe('running')
run.resolve({ outcome: 'moved', migrationId: 'm1', phase: 'retained', reloadRequired: true })
await started
closeWslMoveDialog()
expect(dialog().stage).toBe('result')
})
it('reports a cancel that came too late', async () => {
await openWslMoveDialog({ distro: 'Ubuntu' })
const run = deferred<WslMoveResult>()
api.start.mockReturnValueOnce(run.promise)
void startWslMove()
api.cancel.mockResolvedValueOnce(false)
await cancelWslMove()
const state = dialog()
expect(state.stage === 'running' && state.cancel).toBe('too-late')
expect(api.cancel).toHaveBeenCalledWith('Ubuntu')
})
it('turns a failed start call into a refusal', async () => {
await openWslMoveDialog({ distro: 'Ubuntu' })
api.start.mockRejectedValueOnce(new Error('ipc'))
await startWslMove()
const state = dialog()
expect(state.stage === 'result' && state.result.outcome).toBe('refused')
closeWslMoveDialog()
expect(dialog().stage).toBe('closed')
})
it('reloads through restart preparation and shows a refused reload', async () => {
await openWslMoveDialog({ distro: 'Ubuntu' })
api.start.mockResolvedValueOnce({
outcome: 'refused',
code: 'wsl_move_finished_earlier_move'
})
await startWslMove()
reload.mockResolvedValueOnce('checkpoint-refused')
await reloadAfterWslMove()
expect(reload).toHaveBeenCalledTimes(1)
const state = dialog()
expect(state.stage === 'result' && state.reload).toBe('checkpoint-refused')
})
it('goes back to the same projects after a refusal', async () => {
await openWslMoveDialog({ distro: 'Ubuntu' })
setWslMoveSelection([
{ kind: 'repo', id: 'api' },
{ kind: 'folder', id: 'notes' }
])
await vi.waitFor(() => expect(dialog().stage === 'preview').toBe(true))
api.start.mockResolvedValueOnce({ outcome: 'refused', code: 'wsl_move_scope_changed' })
await startWslMove()
await reviewWslMoveAgain()
const state = dialog()
expect(state.stage === 'preview' && state.selected).toEqual([
{ kind: 'repo', id: 'api' },
{ kind: 'folder', id: 'notes' }
])
})
})
@@ -0,0 +1,298 @@
/**
* The WSL move dialog's state: preview → running → result. Main runs the move and owns every
* decision; this only follows it.
*
* Why a standalone store, not `activeModal`: any `openModal` would evict a running move's dialog,
* and with it the reload prompt a committed move needs.
*/
import { create } from 'zustand'
import type {
WslMovePreview,
WslMoveProgressEvent,
WslMoveRequest,
WslMoveResult,
WslMoveRowRef,
WslMoveUnfinished
} from '../../../../shared/wsl-move/wsl-move-driver-types'
import {
requestLazyChunkRecoveryReload,
type LazyChunkRecoveryReloadOutcome
} from '@/lib/lazy-chunk-recovery-reload'
/** Projects listed with a checkbox; from main's whole-distro preview, or the catalog fallback. */
export type WslMoveCandidates = { repoIds: string[]; folderWorkspaceIds: string[] }
export type WslMoveDialogState =
| { stage: 'closed' }
| { stage: 'loading'; distro: string }
| {
stage: 'preview'
distro: string
candidates: WslMoveCandidates
/** The whole-distro preview, for what it lists beside the candidates. */
distroPreview: WslMovePreview
selected: WslMoveRowRef[]
/** The preview of `selected`; null while it is being checked. */
preview: WslMovePreview | null
}
| {
stage: 'running'
distro: string
selected: WslMoveRowRef[]
progress: WslMoveProgressEvent | null
cancel: 'idle' | 'requested' | 'too-late'
}
| {
stage: 'result'
distro: string
selected: WslMoveRowRef[]
result: WslMoveResult
reload: 'idle' | 'reloading' | LazyChunkRecoveryReloadOutcome
}
export type WslMoveOpenRequest = {
distro: string
/** Opened from one project: only it starts checked. Otherwise every project does. */
only?: WslMoveRowRef
/** Fallback list when main refuses the whole-distro preview. */
catalogCandidates?: () => WslMoveCandidates
}
type Store = {
dialog: WslMoveDialogState
unfinished: WslMoveUnfinished[]
}
export const useWslMoveDialogStore = create<Store>()(() => ({
dialog: { stage: 'closed' },
unfinished: []
}))
const setDialog = (dialog: WslMoveDialogState): void => {
useWslMoveDialogStore.setState({ dialog })
}
const getDialog = (): WslMoveDialogState => useWslMoveDialogStore.getState().dialog
// Each preview request bumps this; a reply to an older one is dropped.
let previewToken = 0
// A refusal a smaller selection may avoid; the checkboxes stay so the user can narrow it.
const SCOPE_DEPENDENT_REFUSALS = new Set<string>([
'wsl_move_export_incomplete',
'wsl_move_manifest_too_large',
'wsl_move_scope_changed'
])
function requestOf(distro: string, selected: readonly WslMoveRowRef[]): WslMoveRequest {
return {
distro,
repoIds: selected.filter((ref) => ref.kind === 'repo').map((ref) => ref.id),
folderWorkspaceIds: selected.filter((ref) => ref.kind === 'folder').map((ref) => ref.id)
}
}
function refsOf(candidates: WslMoveCandidates): WslMoveRowRef[] {
return [
...candidates.repoIds.map((id) => ({ kind: 'repo' as const, id })),
...candidates.folderWorkspaceIds.map((id) => ({ kind: 'folder' as const, id }))
]
}
function refusedPreview(detail: string): WslMovePreview {
return { outcome: 'refused', code: 'wsl_move_unavailable', detail }
}
async function previewOf(request: WslMoveRequest): Promise<WslMovePreview> {
try {
return await window.api.wslMove.preview(request)
} catch (error) {
console.warn('[wsl-move] Preview failed:', error)
return refusedPreview('preview-failed')
}
}
function isBusy(dialog: WslMoveDialogState): boolean {
return dialog.stage === 'running' || (dialog.stage === 'result' && mustReload(dialog.result))
}
/** A result this window must reload after: its stores were rewritten under it. */
export function mustReload(result: WslMoveResult): boolean {
return (
result.outcome === 'moved' ||
(result.outcome === 'refused' && result.code === 'wsl_move_finished_earlier_move')
)
}
export async function openWslMoveDialog(request: WslMoveOpenRequest): Promise<void> {
// A running move, or one waiting on its reload, keeps the dialog until it is done.
if (isBusy(getDialog())) {
return
}
const token = ++previewToken
setDialog({ stage: 'loading', distro: request.distro })
const distroPreview = await previewOf({ distro: request.distro })
if (token !== previewToken || getDialog().stage !== 'loading') {
return
}
let candidates: WslMoveCandidates = { repoIds: [], folderWorkspaceIds: [] }
if (distroPreview.outcome === 'ready') {
candidates = {
repoIds: distroPreview.repoIds,
folderWorkspaceIds: distroPreview.folderWorkspaceIds
}
} else if (SCOPE_DEPENDENT_REFUSALS.has(distroPreview.code) && request.catalogCandidates) {
candidates = request.catalogCandidates()
}
const all = refsOf(candidates)
const only = request.only
const selected =
only && all.some((ref) => ref.kind === only.kind && ref.id === only.id) ? [only] : all
const wholeDistro = selected.length === all.length && distroPreview.outcome === 'ready'
setDialog({
stage: 'preview',
distro: request.distro,
candidates,
distroPreview,
selected,
preview: wholeDistro || all.length === 0 ? distroPreview : null
})
if (!wholeDistro && all.length > 0) {
await recheckWslMove()
}
}
/** Previews the current selection again, e.g. after a blocker cleared or the selection changed. */
export async function recheckWslMove(): Promise<void> {
const dialog = getDialog()
if (dialog.stage !== 'preview') {
return
}
const token = ++previewToken
setDialog({ ...dialog, preview: null })
if (dialog.selected.length === 0) {
return
}
const preview = await previewOf(requestOf(dialog.distro, dialog.selected))
const now = getDialog()
if (token === previewToken && now.stage === 'preview') {
setDialog({ ...now, preview })
}
}
export function setWslMoveSelection(selected: WslMoveRowRef[]): void {
const dialog = getDialog()
if (dialog.stage !== 'preview') {
return
}
setDialog({ ...dialog, selected })
void recheckWslMove()
}
export function skipWslMoveProjects(skipped: readonly WslMoveRowRef[]): void {
const dialog = getDialog()
if (dialog.stage !== 'preview') {
return
}
setWslMoveSelection(
dialog.selected.filter((ref) => !skipped.some((s) => s.kind === ref.kind && s.id === ref.id))
)
}
export async function startWslMove(): Promise<void> {
const dialog = getDialog()
if (dialog.stage !== 'preview' || dialog.selected.length === 0) {
return
}
const { distro, selected } = dialog
++previewToken
setDialog({ stage: 'running', distro, selected, progress: null, cancel: 'idle' })
const unsubscribe = window.api.wslMove.onProgress((progress) => {
const now = getDialog()
if (now.stage === 'running' && progress.distro === distro) {
setDialog({ ...now, progress })
}
})
let result: WslMoveResult
try {
result = await window.api.wslMove.start(requestOf(distro, selected))
} catch (error) {
// The IPC itself failed; main's own failures arrive as results.
console.warn('[wsl-move] Start failed:', error)
result = { outcome: 'refused', code: 'wsl_move_unavailable', detail: 'start-failed' }
} finally {
unsubscribe()
}
if (result.outcome === 'reset' || result.outcome === 'waiting-for-host') {
console.warn(`[wsl-move] ${result.outcome}: ${result.detail}`)
}
setDialog({ stage: 'result', distro, selected, result, reload: 'idle' })
void refreshWslMoveUnfinished()
}
/** Main honours a cancel only until it asks the host to commit. */
export async function cancelWslMove(): Promise<void> {
const dialog = getDialog()
if (dialog.stage !== 'running' || dialog.cancel !== 'idle') {
return
}
setDialog({ ...dialog, cancel: 'requested' })
const stopped = await window.api.wslMove.cancel(dialog.distro).catch(() => false)
const now = getDialog()
if (!stopped && now.stage === 'running') {
setDialog({ ...now, cancel: 'too-late' })
}
}
/** Reload through restart preparation, which backs up unsaved editors first. */
export async function reloadAfterWslMove(): Promise<void> {
const dialog = getDialog()
if (dialog.stage !== 'result' || dialog.reload === 'reloading') {
return
}
setDialog({ ...dialog, reload: 'reloading' })
// Resolves only when the reload was refused; a landed reload replaces this document.
const outcome = await requestLazyChunkRecoveryReload(window)
const now = getDialog()
if (now.stage === 'result') {
setDialog({ ...now, reload: outcome })
}
}
/** Back to the projects list with the same selection, e.g. after a refusal. */
export async function reviewWslMoveAgain(): Promise<void> {
const dialog = getDialog()
if (dialog.stage !== 'result' || mustReload(dialog.result)) {
return
}
const only = dialog.selected.length === 1 ? dialog.selected[0] : undefined
await openWslMoveDialog({ distro: dialog.distro, only })
const reopened = getDialog()
// Keep a multi-project selection the user made, within what is still listed.
if (reopened.stage === 'preview' && !only) {
const listed = refsOf(reopened.candidates)
const kept = dialog.selected.filter((ref) =>
listed.some((c) => c.kind === ref.kind && c.id === ref.id)
)
if (kept.length !== reopened.selected.length) {
setWslMoveSelection(kept)
}
}
}
export function closeWslMoveDialog(): void {
if (isBusy(getDialog())) {
return
}
++previewToken
setDialog({ stage: 'closed' })
}
/** Committed moves whose automations the host has not turned back on yet. */
export async function refreshWslMoveUnfinished(): Promise<void> {
try {
const unfinished = await window.api.wslMove.listUnfinished()
useWslMoveDialogStore.setState({ unfinished })
} catch (error) {
console.warn('[wsl-move] Could not list unfinished moves:', error)
}
}
@@ -0,0 +1,28 @@
/**
* The move dialog's entry points (host row, project menu, project runtime setting). Each is
* offered only where WSL hosts are: the internal gate open (the preload omits the API otherwise)
* and the Experimental switch on. Main refuses every call with the gate closed regardless.
*/
import { useAppStore } from '@/store'
import type { Repo } from '../../../../shared/repo-types'
import type { WslMoveRowRef } from '../../../../shared/wsl-move/wsl-move-driver-types'
import { useWslHostsEnabled } from '../sidebar/use-wsl-hosts-enabled'
import { openWslMoveDialog } from './wsl-move-dialog-store'
import { wslMoveCatalogCandidates, wslMoveTargetForRepo } from './wsl-move-scope-rows'
export function openWslMoveDialogFor(distro: string, only?: WslMoveRowRef): void {
void openWslMoveDialog({
distro,
only,
catalogCandidates: () => wslMoveCatalogCandidates(useAppStore.getState(), distro)
})
}
/** The distro host a local WSL repo can move to, or null where no move is offered. */
export function useWslMoveTargetForRepo(
repo: Pick<Repo, 'path' | 'connectionId' | 'executionHostId'> | null | undefined
): string | null {
const enabled = useWslHostsEnabled()
const environments = useAppStore((s) => s.runtimeEnvironments)
return enabled && repo ? wslMoveTargetForRepo(repo, environments) : null
}
@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { Repo } from '../../../../shared/repo-types'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { Worktree } from '../../../../shared/worktree/types'
import {
findWslHostForDistro,
wslMoveBlockerProjects,
wslMoveCatalogCandidates,
wslMoveCounts,
wslMoveDistroOfPath,
wslMoveProjectRows,
wslMoveTargetForRepo,
type WslMoveCatalog
} from './wsl-move-scope-rows'
const UNC = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\api'
const LEAF = '11111111-2222-4333-8444-555555555555'
function repo(id: string, path: string, extra: Partial<Repo> = {}): Repo {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these tests read only id, path, displayName and the host fields.
return { id, path, displayName: `Repo ${id}`, ...extra } as Repo
}
function folder(
id: string,
folderPath: string,
extra: Partial<FolderWorkspace> = {}
): FolderWorkspace {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these tests read only id, name, folderPath and the host fields.
return { id, name: `Folder ${id}`, folderPath, ...extra } as FolderWorkspace
}
function worktree(id: string, hostId?: string): Worktree {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: counts read only id and hostId.
return { id, hostId } as Worktree
}
function wslHost(distro: string): PublicKnownRuntimeEnvironment {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lookup reads only name and orcadDeployment.
return {
id: `wsl:${distro.toLowerCase()}`,
name: `${distro} (WSL)`,
orcadDeployment: { kind: 'wsl', distro, localPort: 1, remotePort: 2 }
} as PublicKnownRuntimeEnvironment
}
describe('wslMoveDistroOfPath', () => {
it('names the distro of a WSL folder, in either share spelling', () => {
expect(wslMoveDistroOfPath(UNC)).toBe('Ubuntu')
expect(wslMoveDistroOfPath('//wsl$/Debian/home/x')).toBe('Debian')
})
it('keeps Windows-drive folders and plain Windows paths on this computer', () => {
expect(wslMoveDistroOfPath('\\\\wsl.localhost\\Ubuntu\\mnt\\c\\src\\api')).toBeNull()
expect(wslMoveDistroOfPath('C:\\src\\api')).toBeNull()
})
})
describe('wslMoveTargetForRepo', () => {
it('offers the host, spelled as the host names its distro', () => {
expect(
wslMoveTargetForRepo(repo('r', UNC.replace('Ubuntu', 'ubuntu')), [wslHost('Ubuntu')])
).toBe('Ubuntu')
})
it('offers nothing without a host, for a Windows drive, or for a repo on another host', () => {
expect(wslMoveTargetForRepo(repo('r', UNC), [])).toBeNull()
expect(wslMoveTargetForRepo(repo('r', UNC), [wslHost('Debian')])).toBeNull()
expect(
wslMoveTargetForRepo(repo('r', '\\\\wsl.localhost\\Ubuntu\\mnt\\d\\x'), [wslHost('Ubuntu')])
).toBeNull()
expect(
wslMoveTargetForRepo(repo('r', UNC, { connectionId: 'ssh-1' }), [wslHost('Ubuntu')])
).toBeNull()
})
it('ignores non-WSL environments', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a runtime without a deployment link.
const plain = { id: 'r1', name: 'Ubuntu' } as PublicKnownRuntimeEnvironment
expect(findWslHostForDistro([plain], 'Ubuntu')).toBeNull()
})
})
describe('rows, counts and blockers', () => {
const api = `api::${UNC}`
const catalog: WslMoveCatalog = {
repos: [
repo('api', UNC),
// Same id on another host: never the name or the count of the local project.
repo('api', '/home/dev/api', {
displayName: 'Host copy',
executionHostId: 'runtime:wsl:Ubuntu'
})
],
folderWorkspaces: [folder('notes', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\notes')],
worktreesByRepo: {
api: [
worktree(api, 'local'),
worktree(`api::${UNC}\\feature`),
worktree('api::/x', 'runtime:wsl:Ubuntu')
]
},
unifiedTabsByWorktree: {
[api]: [
{ id: 't1', entityId: 'term-1', contentType: 'terminal' },
{ id: 'agent-session:s', entityId: 's', contentType: 'agent-session' },
{ id: '/a.ts', entityId: '/a.ts', contentType: 'editor' }
],
'folder:notes': [{ id: 't2', entityId: 'term-2', contentType: 'terminal' }]
}
}
it('names local rows and falls back to the id', () => {
expect(wslMoveProjectRows(catalog, ['api', 'gone'], ['notes'])).toEqual([
{ kind: 'repo', id: 'api', name: 'Repo api' },
{ kind: 'repo', id: 'gone', name: 'gone' },
{ kind: 'folder', id: 'notes', name: 'Folder notes' }
])
})
it('counts local workspaces, terminals and chats of the selection', () => {
expect(
wslMoveCounts(catalog, [
{ kind: 'repo', id: 'api' },
{ kind: 'folder', id: 'notes' }
])
).toEqual({ projects: 2, workspaces: 3, terminals: 2, chats: 1 })
})
it('maps a pane blocker to the selected project that holds it, and ignores run ids', () => {
const selected = [
{ kind: 'repo' as const, id: 'api' },
{ kind: 'folder' as const, id: 'notes' }
]
expect(
wslMoveBlockerProjects(
catalog,
{
kind: 'process-running',
ids: [`term-1:${LEAF}`, `term-2:${LEAF}`, 'run-7'],
actions: ['end', 'skip-project']
},
selected
)
).toEqual(selected)
expect(
wslMoveBlockerProjects(
catalog,
{ kind: 'automation-running', ids: ['run-7'], actions: ['wait', 'skip-project'] },
selected
)
).toEqual([])
})
it('lists the distro catalog as the fallback candidates', () => {
expect(
wslMoveCatalogCandidates(
{
repos: [...catalog.repos, repo('win', 'C:\\src\\win'), repo('deb', '//wsl$/Debian/x')],
folderWorkspaces: [
...catalog.folderWorkspaces,
folder('remote', '\\\\wsl.localhost\\Ubuntu\\r', { connectionId: 'ssh-1' })
]
},
'ubuntu'
)
).toEqual({ repoIds: ['api'], folderWorkspaceIds: ['notes'] })
})
})
@@ -0,0 +1,195 @@
/**
* What the move dialog shows about its projects, read from the renderer catalog. Main decides what
* moves; this only names rows, counts what they hold, and maps blockers back to their project.
*/
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import type { Repo } from '../../../../shared/repo-types'
import {
isWslHostRuntimeEnvironment,
type PublicKnownRuntimeEnvironment
} from '../../../../shared/runtime-environments'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { Tab } from '../../../../shared/tab-types'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import type { Worktree } from '../../../../shared/worktree/types'
import { parseWslUncPath } from '../../../../shared/wsl-paths'
import type {
WslMoveBlocker,
WslMoveRowRef
} from '../../../../shared/wsl-move/wsl-move-driver-types'
export type WslMoveCatalog = {
repos: readonly Repo[]
folderWorkspaces: readonly FolderWorkspace[]
worktreesByRepo: Record<string, readonly Worktree[]>
unifiedTabsByWorktree: Record<string, readonly Pick<Tab, 'id' | 'entityId' | 'contentType'>[]>
}
export type WslMoveProjectRow = WslMoveRowRef & { name: string }
export type WslMoveCounts = {
projects: number
workspaces: number
terminals: number
chats: number
}
// Rule D7: a repo on a Windows drive reached through the distro stays a Windows project.
const WINDOWS_DRIVE_MOUNT = /^\/mnt\/[a-z](\/|$)/i
/** The distro a local folder lives in when it could move to that distro's host; else null. */
export function wslMoveDistroOfPath(path: string): string | null {
const parsed = parseWslUncPath(path)
return parsed && !WINDOWS_DRIVE_MOUNT.test(parsed.linuxPath) ? parsed.distro : null
}
/** The added WSL host of a distro, matched as WSL matches names: case-insensitively. */
export function findWslHostForDistro(
environments: readonly PublicKnownRuntimeEnvironment[],
distro: string
): { distro: string; name: string } | null {
const wanted = distro.toLowerCase()
for (const environment of environments) {
if (
isWslHostRuntimeEnvironment(environment) &&
environment.orcadDeployment.distro.toLowerCase() === wanted
) {
return { distro: environment.orcadDeployment.distro, name: environment.name }
}
}
return null
}
/** The host a local WSL repo could move to, spelled as the host names its distro. */
export function wslMoveTargetForRepo(
repo: Pick<Repo, 'path' | 'connectionId' | 'executionHostId'>,
environments: readonly PublicKnownRuntimeEnvironment[]
): string | null {
if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) {
return null
}
const distro = wslMoveDistroOfPath(repo.path)
return distro ? (findWslHostForDistro(environments, distro)?.distro ?? null) : null
}
function localRepo(catalog: WslMoveCatalog, id: string): Repo | undefined {
return catalog.repos.find(
(repo) => repo.id === id && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID
)
}
export function wslMoveProjectRows(
catalog: WslMoveCatalog,
repoIds: readonly string[],
folderWorkspaceIds: readonly string[]
): WslMoveProjectRow[] {
return [
...repoIds.map((id) => ({
kind: 'repo' as const,
id,
name: localRepo(catalog, id)?.displayName ?? id
})),
...folderWorkspaceIds.map((id) => ({
kind: 'folder' as const,
id,
name: catalog.folderWorkspaces.find((folder) => folder.id === id)?.name ?? id
}))
]
}
function localWorkspaceKeys(catalog: WslMoveCatalog, ref: WslMoveRowRef): string[] {
if (ref.kind === 'folder') {
return [folderWorkspaceKey(ref.id)]
}
return (catalog.worktreesByRepo[ref.id] ?? [])
.filter((worktree) => (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID)
.map((worktree) => worktree.id)
}
/** Counts of what the selected projects hold here, for the preview's summary line. */
export function wslMoveCounts(
catalog: WslMoveCatalog,
selected: readonly WslMoveRowRef[]
): WslMoveCounts {
const counts: WslMoveCounts = { projects: selected.length, workspaces: 0, terminals: 0, chats: 0 }
for (const ref of selected) {
for (const key of localWorkspaceKeys(catalog, ref)) {
counts.workspaces += 1
for (const tab of catalog.unifiedTabsByWorktree[key] ?? []) {
if (tab.contentType === 'terminal') {
counts.terminals += 1
} else if (tab.contentType === 'agent-session') {
counts.chats += 1
}
}
}
}
return counts
}
function projectOfWorkspaceKey(key: string): WslMoveRowRef {
const scope = parseWorkspaceKey(key)
if (scope?.type === 'folder') {
return { kind: 'folder', id: scope.folderWorkspaceId }
}
return {
kind: 'repo',
id: getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : key)
}
}
/**
* The projects a blocker names, for "Skip project". Pane keys resolve through the tab that holds
* them; automation runs and dispatches name no tab, so they resolve to nothing here.
*/
export function wslMoveBlockerProjects(
catalog: WslMoveCatalog,
blocker: WslMoveBlocker,
candidates: readonly WslMoveRowRef[]
): WslMoveRowRef[] {
const found = new Map<string, WslMoveRowRef>()
for (const id of blocker.ids) {
const tabId = parsePaneKey(id)?.tabId
if (!tabId) {
continue
}
for (const [key, tabs] of Object.entries(catalog.unifiedTabsByWorktree)) {
if (tabs.some((tab) => tab.entityId === tabId || tab.id === tabId)) {
const ref = projectOfWorkspaceKey(key)
if (candidates.some((c) => c.kind === ref.kind && c.id === ref.id)) {
found.set(`${ref.kind}:${ref.id}`, ref)
}
}
}
}
return [...found.values()]
}
/**
* Projects of the distro as this window's catalog lists them. Only the fallback for listing
* checkboxes when main refused the whole-distro preview for a reason a smaller selection may fix.
*/
export function wslMoveCatalogCandidates(
catalog: Pick<WslMoveCatalog, 'repos' | 'folderWorkspaces'>,
distro: string
): { repoIds: string[]; folderWorkspaceIds: string[] } {
const inDistro = (path: string): boolean =>
wslMoveDistroOfPath(path)?.toLowerCase() === distro.toLowerCase()
return {
repoIds: catalog.repos
.filter(
(repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID && inDistro(repo.path)
)
.map((repo) => repo.id),
folderWorkspaceIds: catalog.folderWorkspaces
.filter(
(folder) =>
(folder.executionHostId ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID &&
!folder.connectionId &&
inDistro(folder.folderPath)
)
.map((folder) => folder.id)
}
}
+122
View File
@@ -20822,5 +20822,127 @@
"unavailable": "This workspace is protected or unavailable."
},
"rowActions": "Actions for {{name}}"
},
"wslMove": {
"entry": {
"hostRow": "Move projects from this distro…",
"projectMenu": "Move to {{distro}} host…"
},
"blockers": {
"title": "Before these projects can move",
"inProjects": "In {{names}}",
"skipOne": "Leave {{name}} out of this move",
"skipMany": "Leave these {{n}} projects out of this move"
},
"title": "Move projects to the {{distro}} host",
"loading": "Checking the projects in {{distro}}…",
"runningTitle": "Moving to the {{distro}} host",
"preview": {
"counts": "Projects: {{projects}} · Workspaces: {{workspaces}} · Terminals: {{terminals}} · Chats: {{chats}}",
"whatHappens": "Your files stay where they are. Terminals restart in the same folder; their output, shell history and agent conversations are kept.",
"projects": "Projects in {{distro}}",
"folderBadge": "Folder",
"notMoving": "Not moving",
"checking": "Checking the selected projects…",
"checkAgain": "Check again",
"moveOne": "Move 1 project",
"moveMany": "Move {{n}} projects",
"heldByHost": "Already on the {{distro}} host.",
"changed": "Moved earlier, then changed here by an older version of Orca. It is not moved again.",
"chatsStay": "Existing chats stay on this computer. New chats in this project start on the WSL host."
},
"cancel": "Cancel",
"progress": {
"bytes": "{{sent}} of {{total}}",
"tooLate": "Too late to cancel: the host is already switching over. Keep Orca open until it finishes.",
"readOnly": "These projects are read-only while they move. You can cancel until the switch starts.",
"switching": "The switch cannot be cancelled. Keep Orca open until it finishes.",
"cancelling": "Cancelling…",
"cancel": "Cancel move"
},
"result": {
"movedTitle": "Moved to the {{distro}} host",
"resetTitle": "Nothing was moved",
"waitingTitle": "Waiting for the {{distro}} host",
"earlierMoveTitle": "An earlier move finished first",
"refusedTitle": "Projects not moved",
"reloadCheckpointRefused": "Orca could not back up unsaved files, so the window did not reload. Save or close them, then reload.",
"reloadVetoed": "Something kept the window from reloading. Save your work, then reload.",
"reloadFailed": "The window did not reload. Try again.",
"moved": "Reload this window to see the projects under the {{distro}} host. Orca backs up unsaved files before it reloads.",
"automationsPendingOne": "One automation is off on the host for now. Orca keeps asking the host to turn it back on.",
"automationsPendingMany": "{{n}} automations are off on the host for now. Orca keeps asking the host to turn them back on.",
"undoHint": "Changed your mind? After the reload, the sidebar lists these projects as “Moved to {{distro}} host”. Choose “Use on this computer again” there to bring them back as they were before the move.",
"reset": "The move stopped and everything was put back. The projects work on this computer as before.",
"waiting": "The move may have reached the {{distro}} host, but the host is not answering. These projects stay read-only here until it does. Connect the host in Settings, then start the move again to finish it.",
"reloading": "Reloading…",
"reload": "Reload now",
"close": "Close",
"review": "Back to projects",
"browserLoginsNotCopied": "Browser logins were not copied to the host ({{reason}}). Sign in again in the host’s browser tabs."
},
"runtime": {
"label": "Project runtime",
"description": "This project runs in {{distro}} through this computer. Move it to the {{distro}} host to run it there.",
"move": "Move project"
},
"unfinished": {
"one": "One automation moved to the {{distro}} host is still off. Orca keeps asking the host to turn it back on.",
"many": "{{n}} automations moved to the {{distro}} host are still off. Orca keeps asking the host to turn them back on."
},
"refusal": {
"unavailable": "Moving projects to a WSL host is not available in this window.",
"alreadyRunning": "A move into {{distro}} is already running. Wait for it to finish, then try again.",
"alreadyInFlight": "An earlier move into {{distro}} has not finished, and the {{distro}} host is not answering about it. Connect the host in Settings, then try again.",
"nothingNew": "Every selected project is already on the {{distro}} host.",
"destinationChanged": "Earlier projects from {{distro}} moved to a different WSL host. Connect that host to move more.",
"hostUnavailable": "The {{distro}} host is not connected. Connect it in Settings, then try again.",
"hostUpdateRequired": "The {{distro}} host runs an older version of Orca. Update it in Settings, then try again.",
"terminalsBlocked": "Some terminals, agents or automations in these projects are busy. Nothing was moved.",
"exportIncomplete": "Some saved state in these projects cannot move yet, so nothing was moved:",
"tooLarge": "These projects have too much saved state to move in one step. Move fewer projects at a time.",
"pauseFailed": "Orca could not pause the terminals in these projects, so nothing was moved. Try again.",
"scopeChanged": "A project changed while the move was getting ready, so nothing was moved. Check the projects and try again.",
"cancelled": "Move cancelled. Nothing was moved.",
"finishedEarlierMove": "Orca first finished an earlier move into {{distro}}. Reload the window, then start this move again."
},
"step": {
"preparing": "Preparing",
"pausingTerminals": "Pausing terminals",
"copying": "Copying",
"switching": "Switching",
"finishing": "Finishing",
"done": "Done"
},
"blocker": {
"agentWorkingOne": "An agent is working or waiting for an answer",
"agentWorkingMany": "{{n}} agents are working or waiting for an answer",
"processRunningOne": "A terminal is running a program",
"processRunningMany": "{{n}} terminals are running a program",
"agentNotResumableOne": "An agent cannot be resumed after the move",
"agentNotResumableMany": "{{n}} agents cannot be resumed after the move",
"unverifiableOne": "Orca cannot check a terminal right now",
"unverifiableMany": "Orca cannot check {{n}} terminals right now",
"automationRunningOne": "An automation run is active",
"automationRunningMany": "{{n}} automation runs are active",
"dispatchRunningOne": "An agent task is in progress",
"dispatchRunningMany": "{{n}} agent tasks are in progress",
"scrollbackTooLargeOne": "A terminal has too much output history to move",
"scrollbackTooLargeMany": "{{n}} terminals have too much output history to move"
},
"blockerHint": {
"agentWorking": "Let it finish, or stop it in its terminal, then check again.",
"processRunning": "Stop the program (for example a dev server or editor), then check again.",
"agentNotResumable": "Exit the agent in its terminal, then check again.",
"unverifiable": "Wait a moment, then check again. Nothing is stopped to make the move happen.",
"wait": "Wait for it to finish, then check again.",
"scrollbackTooLarge": "Clear the terminal output in those terminals, then check again."
},
"scopeRefused": {
"windowsDrive": "Its folder is on a Windows drive, so it stays on this computer.",
"mixedLocations": "Some of its folders are outside this distro, so it stays on this computer.",
"notLocal": "It already runs on another host.",
"other": "It cannot move."
}
}
}
@@ -106,6 +106,8 @@ export type WslMoveResult =
* dialog must say they are off on the host for now, never hide it.
*/
automationsPending?: string[]
/** Set by the browser-login copy (D1b-7f) when the host's browser starts signed out. */
browserLoginsNotCopiedReason?: string
}
| WslMoveRefusal
| {
+431
View File
@@ -0,0 +1,431 @@
/**
* The WSL move dialog (D1b-9) against a fake move transport: no wsl.exe or distro in CI. Main's
* `wslMove:*` handlers are swapped for a scripted host, so the real dialog runs open preview →
* blockers → skip → start → progress → refusal → start again → moved → reload. A second test
* proves that with the internal gate closed none of it shows and main refuses.
*/
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
_electron as electron,
type ElectronApplication,
type Page
} from '@stablyai/playwright-test'
import { getE2ECompletedOnboardingProfile } from './helpers/e2e-completed-onboarding-profile'
import { getOrcaElectronLaunchArgs } from './helpers/electron-launch-args'
import { cleanupE2EDaemons, closeElectronAppForE2E } from './helpers/electron-process-shutdown'
import { createElectronHomeIsolation } from './helpers/electron-home-isolation'
import { expect, forwardElectronProcessLogs, test } from './helpers/orca-app'
import { ensureTerminalVisible, waitForSessionReady } from './helpers/store'
import { encodePairingOffer } from '../../src/shared/pairing'
import { addWslHostEnvironment } from '../../src/shared/runtime-environment-wsl-host-store'
import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../src/shared/wsl-host-internal-gate'
import type {
WslMovePreview,
WslMoveProgressEvent,
WslMoveResult
} from '../../src/shared/wsl-move/wsl-move-driver-types'
test.use({ seedTestRepo: false })
const DISTRO = 'Ubuntu'
const LEAF = '6f1c1d2e-3a4b-4c5d-8e9f-0a1b2c3d4e5f'
// Visual proof for the PR; outside the repo on purpose.
const PROOF_DIR = process.env.ORCA_D1B9_PROOF_DIR
type Desktop = { app: ElectronApplication; page: Page; dispose: () => Promise<void> }
function createGitProject(name: string): string {
const dir = path.join(mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-wsl-move-')), name)
execFileSync('git', ['init', '-q', '-b', 'main', dir])
writeFileSync(path.join(dir, 'README.md'), `# ${name}\n`)
execFileSync('git', ['-C', dir, 'add', '.'])
execFileSync(
'git',
['-C', dir, '-c', 'user.name=e2e', '-c', 'user.email=e2e@example.com', 'commit', '-qm', 'init'],
{ stdio: 'ignore' }
)
return dir
}
async function launch(
testInfo: Parameters<typeof forwardElectronProcessLogs>[1],
internalGate: boolean
): Promise<Desktop> {
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-wsl-move-ui-'))
writeFileSync(
path.join(userDataDir, 'orca-data.json'),
`${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n`
)
// A saved host for the distro; it never connects here, which the dialog does not need.
addWslHostEnvironment(userDataDir, {
id: 'wsl:ubuntu',
name: `${DISTRO} (WSL)`,
pairingCode: encodePairingOffer({
v: 2,
endpoint: 'ws://127.0.0.1:9',
deviceToken: 'move-ui-token',
publicKeyB64: Buffer.alloc(32, 7).toString('base64')
}),
record: {
kind: 'wsl',
distro: DISTRO,
distroId: '0e3a7f2c-1b4d-4c5e-9f60-7a8b9c0d1e2f',
localPort: 9,
remotePort: 6768
}
})
const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env
void _unused
const isolation = createElectronHomeIsolation({
inheritedEnv: cleanEnv,
launchEnv: {},
extraEnv: {
...(internalGate ? { [WSL_HOSTS_INTERNAL_GATE_ENV]: '1' } : {}),
ORCA_E2E_FAKE_WSL_HOSTS: JSON.stringify({
distros: [{ name: DISTRO, state: 'stopped', version: 2 }],
pairingCode: 'unused'
})
},
userDataDir
})
const app = await electron.launch({
args: getOrcaElectronLaunchArgs(path.join(process.cwd(), 'out', 'main', 'index.js'), false),
env: { ...isolation.env, NODE_ENV: 'development', ORCA_E2E_HEADLESS: '1' }
})
forwardElectronProcessLogs(app, testInfo)
const dispose = async (): Promise<void> => {
await closeElectronAppForE2E(app)
await cleanupE2EDaemons(userDataDir)
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
}
try {
const page = await app.firstWindow({ timeout: 120_000 })
await page.waitForLoadState('domcontentloaded')
await waitForSessionReady(page)
await page.evaluate(async () => {
const settings = await window.api.settings.set({ experimentalWslHosts: true })
window.__store?.setState({ settings })
})
return { app, page, dispose }
} catch (error) {
await dispose()
throw error
}
}
async function addProject(page: Page, projectPath: string): Promise<string> {
const repoId = await page.evaluate(async (repoPath) => {
const result = await window.api.repos.add({ path: repoPath })
if ('error' in result) {
throw new Error(result.error)
}
return result.repo.id
}, projectPath)
await expect
.poll(() =>
page.evaluate(async (id) => {
const store = window.__store
await store?.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true })
return Boolean(store?.getState().repos.some((repo) => repo.id === id))
}, repoId)
)
.toBe(true)
return repoId
}
/** The terminal tab of a project's main workspace, for a blocker that names one of its panes. */
async function terminalTabOf(page: Page, repoId: string): Promise<string> {
await expect
.poll(() =>
page.evaluate(async (id) => {
const state = window.__store?.getState()
await state?.fetchWorktrees(id)
const worktree = window.__store?.getState().worktreesByRepo[id]?.[0]
if (worktree) {
window.__store?.getState().setActiveWorktree(worktree.id)
}
return Boolean(worktree)
}, repoId)
)
.toBe(true)
await ensureTerminalVisible(page, 30_000)
return page.evaluate(() => {
const state = window.__store!.getState()
const tab = state.tabsByWorktree[state.activeWorktreeId!]?.[0]
if (!tab) {
throw new Error('Expected a terminal tab')
}
return tab.id
})
}
type FakeScript = {
distroPreview: WslMovePreview
selectionPreview: WslMovePreview
results: WslMoveResult[]
}
/** Swaps main's move handlers for a scripted host; the renderer and its bridge stay real. */
async function installFakeMove(app: ElectronApplication, script: FakeScript): Promise<void> {
await app.evaluate(({ ipcMain }, fake) => {
const state = {
starts: [] as unknown[],
pending: [] as ((result: unknown) => void)[],
results: [...fake.results],
unfinished: [] as unknown[],
cancelAllowed: true
}
Object.assign(globalThis, { __wslMoveFake: state })
for (const channel of [
'wslMove:preview',
'wslMove:start',
'wslMove:cancel',
'wslMove:listUnfinished'
]) {
ipcMain.removeHandler(channel)
}
ipcMain.handle('wslMove:preview', (_event, request: { repoIds?: string[] }) =>
request.repoIds ? fake.selectionPreview : fake.distroPreview
)
ipcMain.handle('wslMove:start', (_event, request: unknown) => {
state.starts.push(request)
return new Promise((resolve) => state.pending.push(resolve))
})
ipcMain.handle('wslMove:cancel', () => state.cancelAllowed)
ipcMain.handle('wslMove:listUnfinished', () => state.unfinished)
}, script)
}
async function sendProgress(app: ElectronApplication, event: WslMoveProgressEvent): Promise<void> {
await app.evaluate(({ BrowserWindow }, progress) => {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('wslMove:progress', progress)
}
}, event)
}
/** Ends the running start with the next scripted result. */
async function finishStart(app: ElectronApplication, unfinished: unknown[] = []): Promise<void> {
await app.evaluate((_electron, nextUnfinished) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: installFakeMove set this global just above in the same main process.
const state = (
globalThis as unknown as {
__wslMoveFake: {
pending: ((result: unknown) => void)[]
results: unknown[]
unfinished: unknown[]
}
}
).__wslMoveFake
state.unfinished = nextUnfinished
state.pending.shift()?.(state.results.shift())
}, unfinished)
}
async function settle(page: Page): Promise<void> {
// Finite animations only: spinners run forever; dialog fades do not.
await page.waitForFunction(() =>
document
.getAnimations()
.every(
(animation) =>
animation.playState !== 'running' ||
animation.effect?.getComputedTiming().iterations === Infinity
)
)
}
async function proof(
page: Page,
testInfo: Parameters<typeof forwardElectronProcessLogs>[1],
name: string
): Promise<void> {
await settle(page)
const file = `wsl-move-${name}.png`
await page.screenshot({ path: testInfo.outputPath(file) })
if (PROOF_DIR) {
await page.screenshot({ path: path.join(PROOF_DIR, file) })
}
}
async function openServersPane(page: Page): Promise<void> {
await page.evaluate(() => {
const state = window.__store?.getState()
state?.openSettingsPage()
state?.openSettingsTarget({ pane: 'servers', repoId: null })
})
await expect(page.locator('[data-settings-section="wsl-hosts"]')).toBeVisible({ timeout: 30_000 })
}
// oxlint-disable-next-line no-empty-pattern -- Playwright passes fixtures before testInfo.
test('moves projects through preview, blockers, progress and result', async ({}, testInfo) => {
test.setTimeout(240_000)
const apiPath = createGitProject('payments-api')
const webPath = createGitProject('web-app')
const desktop = await launch(testInfo, true)
const { app, page } = desktop
try {
const apiId = await addProject(page, apiPath)
const webId = await addProject(page, webPath)
const webTab = await terminalTabOf(page, webId)
const ready = {
outcome: 'ready' as const,
distro: DISTRO,
changed: [],
heldByHost: [],
refused: [{ kind: 'repo' as const, id: 'drive-repo', reason: 'windows-drive' }]
}
await installFakeMove(app, {
distroPreview: {
...ready,
repoIds: [apiId, webId],
folderWorkspaceIds: [],
blockers: [
{ kind: 'process-running', ids: [`${webTab}:${LEAF}`], actions: ['end', 'skip-project'] }
]
},
selectionPreview: { ...ready, repoIds: [apiId], folderWorkspaceIds: [], blockers: [] },
results: [
{ outcome: 'refused', code: 'wsl_move_pause_failed', detail: 'sleep' },
{
outcome: 'moved',
migrationId: 'move-1',
phase: 'retained',
reloadRequired: true,
automationsPending: ['nightly-triage']
}
]
})
// Entry: the WSL host row's menu in Settings → Servers.
await openServersPane(page)
const hosts = page.locator('[data-settings-section="wsl-hosts"]')
await hosts.getByRole('button', { name: `More actions for ${DISTRO} (WSL)` }).click()
const entry = page.getByRole('menuitem', { name: 'Move projects from this distro…' })
await expect(entry).toBeVisible()
await proof(page, testInfo, 'host-row-menu')
await entry.click()
const dialog = page.getByTestId('wsl-move-dialog')
await expect(
dialog.getByRole('heading', { name: `Move projects to the ${DISTRO} host` })
).toBeVisible()
await expect(dialog.getByTestId('wsl-move-chats-stay')).toHaveText(
'Existing chats stay on this computer. New chats in this project start on the WSL host.'
)
await expect(dialog.getByRole('checkbox')).toHaveCount(2)
await expect(
dialog.getByText('Its folder is on a Windows drive, so it stays on this computer.')
).toBeVisible()
const blockers = dialog.getByTestId('wsl-move-blockers')
await expect(blockers.getByText('A terminal is running a program')).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Move 2 projects' })).toBeDisabled()
await proof(page, testInfo, 'blockers')
// Skip the blocked project: the selection narrows and its preview is clear.
await blockers.getByRole('button', { name: 'Leave web-app out of this move' }).click()
await expect(blockers).toHaveCount(0)
const move = dialog.getByRole('button', { name: 'Move 1 project' })
await expect(move).toBeEnabled()
await expect(dialog.getByTestId('wsl-move-counts')).toContainText('Projects: 1')
await proof(page, testInfo, 'preview')
// First run: refused, with a plain message and a way back.
await move.click()
await expect(dialog.getByTestId('wsl-move-progress')).toBeVisible()
await finishStart(app)
await expect(dialog.getByTestId('wsl-move-result')).toHaveAttribute('data-outcome', 'refused')
await expect(dialog.getByText('could not pause the terminals')).toBeVisible()
await proof(page, testInfo, 'refusal')
await dialog.getByRole('button', { name: 'Back to projects' }).click()
await expect(dialog.getByRole('button', { name: 'Move 1 project' })).toBeEnabled()
// Second run: progress steps and bytes, cancel only before the switch.
await dialog.getByRole('button', { name: 'Move 1 project' }).click()
await sendProgress(app, { distro: DISTRO, step: 'pausing-terminals', migrationId: 'move-1' })
await sendProgress(app, {
distro: DISTRO,
step: 'copying',
migrationId: 'move-1',
bytesSent: 14 * 1024 * 1024,
bytesTotal: 34 * 1024 * 1024
})
const progress = dialog.getByTestId('wsl-move-progress')
await expect(progress.locator('[data-state="active"]')).toHaveText('Copying')
await expect(dialog.getByText('14 MB of 34 MB')).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Cancel move' })).toBeEnabled()
await proof(page, testInfo, 'progress')
// Neither Esc nor another dialog can dismiss a running move.
await page.keyboard.press('Escape')
await expect(dialog).toBeVisible()
await sendProgress(app, { distro: DISTRO, step: 'switching', migrationId: 'move-1' })
await expect(dialog.getByRole('button', { name: 'Cancel move' })).toBeDisabled()
await finishStart(app, [
{ migrationId: 'move-1', distro: DISTRO, automationsPending: ['nightly-triage'] }
])
await expect(dialog.getByTestId('wsl-move-result')).toHaveAttribute('data-outcome', 'moved')
await expect(dialog.getByTestId('wsl-move-automations-pending')).toBeVisible()
await page.keyboard.press('Escape')
await expect(dialog).toBeVisible()
await proof(page, testInfo, 'success')
expect(
await app.evaluate(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: installFakeMove set this global in the same main process.
() =>
(globalThis as unknown as { __wslMoveFake: { starts: unknown[] } }).__wslMoveFake.starts
)
).toEqual([
{ distro: DISTRO, repoIds: [apiId], folderWorkspaceIds: [] },
{ distro: DISTRO, repoIds: [apiId], folderWorkspaceIds: [] }
])
// Reload goes through restart preparation and lands a fresh page.
const navigated = page.waitForEvent('framenavigated', { timeout: 60_000 })
await dialog.getByRole('button', { name: 'Reload now' }).click()
await navigated
await page.waitForLoadState('domcontentloaded')
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 60_000 })
await waitForSessionReady(page)
await expect(page.getByTestId('wsl-move-dialog')).toHaveCount(0)
// The automations still off show under the host until it confirms.
await openServersPane(page)
await expect(page.getByTestId('wsl-move-unfinished')).toContainText(
`One automation moved to the ${DISTRO} host is still off.`
)
await proof(page, testInfo, 'unfinished-notice')
} finally {
await desktop.dispose()
rmSync(path.dirname(apiPath), { recursive: true, force: true })
rmSync(path.dirname(webPath), { recursive: true, force: true })
}
})
// oxlint-disable-next-line no-empty-pattern -- Playwright passes fixtures before testInfo.
test('with the internal gate closed, no move entry shows and main refuses', async ({}, testInfo) => {
test.setTimeout(180_000)
const desktop = await launch(testInfo, false)
const { page } = desktop
try {
expect(
await page.evaluate((distro) => window.api.wslMove.preview({ distro }), DISTRO)
).toMatchObject({ outcome: 'refused', code: 'wsl_move_unavailable' })
expect(await page.evaluate(() => window.api.wslMove.listUnfinished())).toEqual([])
await page.evaluate(() => {
const state = window.__store?.getState()
state?.openSettingsPage()
state?.openSettingsTarget({ pane: 'servers', repoId: null })
})
await expect(page.getByText('No saved servers', { exact: true })).toBeVisible({
timeout: 30_000
})
await expect(page.locator('[data-settings-section="wsl-hosts"]')).toHaveCount(0)
await expect(page.getByText('Move projects from this distro…')).toHaveCount(0)
await expect(page.getByTestId('wsl-move-dialog')).toHaveCount(0)
} finally {
await desktop.dispose()
}
})