mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
feat(wsl-mover): move dialog with preview, blockers, progress and result behind the internal WSL hosts gate (D1b-9)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
| {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user