From fe7d00535aa8c89b6293ce799ffce33d236752ed Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 20 Oct 2026 23:05:32 -0700 Subject: [PATCH] feat(wsl-mover): move dialog with preview, blockers, progress and result behind the internal WSL hosts gate (D1b-9) --- .../src/app-shell/AppRootSurfaces.tsx | 3 + .../components/settings/RepositoryPane.tsx | 1 + .../RepositoryWindowsRuntimeSection.tsx | 31 +- .../components/settings/WslHostsSection.tsx | 13 +- .../rows/repo-header-project-actions.tsx | 2 + .../wsl-move/WslMoveBlockerList.tsx | 78 ++++ .../wsl-move/WslMoveDialog.test.tsx | 265 +++++++++++ .../src/components/wsl-move/WslMoveDialog.tsx | 188 ++++++++ .../wsl-move/WslMovePreviewBody.tsx | 186 ++++++++ .../wsl-move/WslMoveProgressBody.tsx | 114 +++++ .../wsl-move/WslMoveProjectMenuItem.tsx | 20 + .../components/wsl-move/WslMoveResultBody.tsx | 171 +++++++ .../components/wsl-move/WslMoveRuntimeRow.tsx | 35 ++ .../wsl-move/WslMoveUnfinishedNotice.tsx | 38 ++ .../src/components/wsl-move/wsl-move-copy.ts | 248 ++++++++++ .../wsl-move/wsl-move-dialog-store.test.ts | 255 +++++++++++ .../wsl-move/wsl-move-dialog-store.ts | 298 ++++++++++++ .../src/components/wsl-move/wsl-move-entry.ts | 28 ++ .../wsl-move/wsl-move-scope-rows.test.ts | 170 +++++++ .../wsl-move/wsl-move-scope-rows.ts | 195 ++++++++ src/renderer/src/i18n/locales/en.json | 122 +++++ src/shared/wsl-move/wsl-move-driver-types.ts | 2 + tests/e2e/wsl-move-dialog.spec.ts | 431 ++++++++++++++++++ 23 files changed, 2883 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/wsl-move/WslMoveBlockerList.tsx create mode 100644 src/renderer/src/components/wsl-move/WslMoveDialog.test.tsx create mode 100644 src/renderer/src/components/wsl-move/WslMoveDialog.tsx create mode 100644 src/renderer/src/components/wsl-move/WslMovePreviewBody.tsx create mode 100644 src/renderer/src/components/wsl-move/WslMoveProgressBody.tsx create mode 100644 src/renderer/src/components/wsl-move/WslMoveProjectMenuItem.tsx create mode 100644 src/renderer/src/components/wsl-move/WslMoveResultBody.tsx create mode 100644 src/renderer/src/components/wsl-move/WslMoveRuntimeRow.tsx create mode 100644 src/renderer/src/components/wsl-move/WslMoveUnfinishedNotice.tsx create mode 100644 src/renderer/src/components/wsl-move/wsl-move-copy.ts create mode 100644 src/renderer/src/components/wsl-move/wsl-move-dialog-store.test.ts create mode 100644 src/renderer/src/components/wsl-move/wsl-move-dialog-store.ts create mode 100644 src/renderer/src/components/wsl-move/wsl-move-entry.ts create mode 100644 src/renderer/src/components/wsl-move/wsl-move-scope-rows.test.ts create mode 100644 src/renderer/src/components/wsl-move/wsl-move-scope-rows.ts create mode 100644 tests/e2e/wsl-move-dialog.spec.ts diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 042b10565f0..9de0ee71dd1 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -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: { ) : null} + {/* Own store, not activeModal: another modal must never evict a running move. */} + {/* Root-hosted: a toast, the SSH host row and a terminal pane all open it. */} {activeModal === 'node-dist-mirror' ? ( diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 04d94b62a01..7a971a1ce9d 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -327,6 +327,7 @@ export function RepositoryPane({ /> repoDisplayName: string project: Project | null settings: Pick | 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)} > - + {wslMoveDistro && repo ? ( + + ) : ( + + )} ) } diff --git a/src/renderer/src/components/settings/WslHostsSection.tsx b/src/renderer/src/components/settings/WslHostsSection.tsx index 29a104c22c8..90cb4e25001 100644 --- a/src/renderer/src/components/settings/WslHostsSection.tsx +++ b/src/renderer/src/components/settings/WslHostsSection.tsx @@ -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')} ) : null} + {unavailableLabel ? null : ( + openWslMoveDialogFor(environment.orcadDeployment.distro)} + > + + {translate('wslMove.entry.hostRow', 'Move projects from this distro…')} + + )} ) : null} + {enabled && !serverFilesMissing ? : null} {removing ? ( ) : null} + actions.onRemoveProject(repo)}> diff --git a/src/renderer/src/components/wsl-move/WslMoveBlockerList.tsx b/src/renderer/src/components/wsl-move/WslMoveBlockerList.tsx new file mode 100644 index 00000000000..50e51ad160e --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveBlockerList.tsx @@ -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 ( +
+
+ {translate('wslMove.blockers.title', 'Before these projects can move')} +
+
    + {blockers.map(({ blocker, projects }) => { + const canSkip = onSkip && blocker.actions.includes('skip-project') && projects.length > 0 + return ( +
  • + +
    +
    + {wslMoveBlockerTitle(blocker.kind, blocker.ids.length)} +
    + {projects.length > 0 ? ( +
    + {translate('wslMove.blockers.inProjects', 'In {{names}}', { + names: projects.map((project) => project.name).join(', ') + })} +
    + ) : null} +
    + {wslMoveBlockerHint(blocker.kind)} +
    + {canSkip ? ( + + ) : null} +
    +
  • + ) + })} +
+
+ ) +} diff --git a/src/renderer/src/components/wsl-move/WslMoveDialog.test.tsx b/src/renderer/src/components/wsl-move/WslMoveDialog.test.tsx new file mode 100644 index 00000000000..3c612064e9a --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveDialog.test.tsx @@ -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 = {}): string { + return renderToStaticMarkup( + + ) +} + +function result(value: WslMoveResult, reload: 'idle' | 'checkpoint-refused' = 'idle'): string { + return renderToStaticMarkup( + + ) +} + +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') + 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( + + ) + 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( + + ) + 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()).toBe('') + vi.doUnmock('../sidebar/use-wsl-hosts-enabled') + vi.doUnmock('@/store') + }) +}) diff --git a/src/renderer/src/components/wsl-move/WslMoveDialog.tsx b/src/renderer/src/components/wsl-move/WslMoveDialog.tsx new file mode 100644 index 00000000000..f476958e480 --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveDialog.tsx @@ -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 ? : 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 = ( +
+ + {translate('wslMove.loading', 'Checking the projects in {{distro}}…', { distro })} +
+ ) + 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 = ( + + 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 = ( + void cancelWslMove()} + /> + ) + break + case 'result': { + const { result } = dialog + title = wslMoveResultTitle(result, distro) + body = ( + void reloadAfterWslMove()} + onReview={() => void reviewWslMoveAgain()} + onClose={closeWslMoveDialog} + /> + ) + break + } + } + return ( + { + if (!open) { + closeWslMoveDialog() + } + }} + > + busy && event.preventDefault()} + onInteractOutside={(event) => busy && event.preventDefault()} + > + + {title} + {title} + + {body} + + + ) +} diff --git a/src/renderer/src/components/wsl-move/WslMovePreviewBody.tsx b/src/renderer/src/components/wsl-move/WslMovePreviewBody.tsx new file mode 100644 index 00000000000..048d01a0e9f --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMovePreviewBody.tsx @@ -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 ( + <> +
+

+ {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.' + )} +

+

{wslMoveChatsStayCopy()}

+ {rows.length > 0 ? ( +
+
+ {translate('wslMove.preview.projects', 'Projects in {{distro}}', { distro })} +
+
    + {rows.map((row) => { + const id = `wsl-move-row-${row.kind}-${row.id}` + return ( +
  • + props.onToggle(row, checked === true)} + /> + + {row.kind === 'folder' ? ( + + {translate('wslMove.preview.folderBadge', 'Folder')} + + ) : null} +
  • + ) + })} +
+ {selected.length > 0 ? ( +
+ {countsLine(props.counts)} +
+ ) : null} +
+ ) : null} + {props.notMoving.length > 0 ? ( +
+
+ {translate('wslMove.preview.notMoving', 'Not moving')} +
+
    + {props.notMoving.map((entry) => ( +
  • + {entry.name} + {' — '} + {entry.why} +
  • + ))} +
+
+ ) : null} + {preview === null && selected.length > 0 ? ( +
+ + {translate('wslMove.preview.checking', 'Checking the selected projects…')} +
+ ) : null} + {preview?.outcome === 'refused' ? ( +
+

{wslMoveRefusalMessage(preview, distro)}

+ {preview.reasons?.length ? ( +
    + {preview.reasons.map((reason) => ( +
  • {reason}
  • + ))} +
+ ) : null} +
+ ) : null} + {blockers.length > 0 ? ( + + ) : null} +
+ + {blockers.length > 0 || preview?.outcome === 'refused' ? ( + + ) : null} + + + + + ) +} + +/** 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) + })) + ] +} diff --git a/src/renderer/src/components/wsl-move/WslMoveProgressBody.tsx b/src/renderer/src/components/wsl-move/WslMoveProgressBody.tsx new file mode 100644 index 00000000000..e5db209f9aa --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveProgressBody.tsx @@ -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 ( + <> +
    + {STEPS.map((step, index) => { + const state = index < current ? 'done' : index === current ? 'active' : 'pending' + return ( +
  1. + {state === 'done' ? ( + + ) : state === 'active' && step !== 'done' ? ( + + ) : state === 'active' ? ( + + ) : ( + + )} + + {step === 'switching' && progress?.step === 'finishing' + ? wslMoveStepLabel('finishing') + : wslMoveStepLabel(step)} + +
  2. + ) + })} +
+ {progress?.step === 'copying' && bytesTotal ? ( +
+ +
+ {translate('wslMove.progress.bytes', '{{sent}} of {{total}}', { + sent: formatWslMoveBytes(progress.bytesSent ?? 0), + total: formatWslMoveBytes(bytesTotal) + })} +
+
+ ) : null} +

+ {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.' + )} +

+ + + + + ) +} diff --git a/src/renderer/src/components/wsl-move/WslMoveProjectMenuItem.tsx b/src/renderer/src/components/wsl-move/WslMoveProjectMenuItem.tsx new file mode 100644 index 00000000000..f15b7272dfa --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveProjectMenuItem.tsx @@ -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 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 ( + openWslMoveDialogFor(distro, { kind: 'repo', id: repo.id })}> + + {translate('wslMove.entry.projectMenu', 'Move to {{distro}} host…', { distro })} + + ) +} diff --git a/src/renderer/src/components/wsl-move/WslMoveResultBody.tsx b/src/renderer/src/components/wsl-move/WslMoveResultBody.tsx new file mode 100644 index 00000000000..9c41eba37f0 --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveResultBody.tsx @@ -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 ( + <> +
+ {result.outcome === 'moved' ? ( + <> +

+ {translate( + 'wslMove.result.moved', + 'Reload this window to see the projects under the {{distro}} host. Orca backs up unsaved files before it reloads.', + { distro } + )} +

+ {result.automationsPending?.length ? ( +

+ {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 } + )} +

+ ) : null} + {result.browserLoginsNotCopiedReason ? ( +

{wslMoveBrowserLoginsNotCopied(result.browserLoginsNotCopiedReason)}

+ ) : null} +

+ {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 } + )} +

+ + ) : null} + {result.outcome === 'reset' ? ( +

+ {translate( + 'wslMove.result.reset', + 'The move stopped and everything was put back. The projects work on this computer as before.' + )} +

+ ) : null} + {result.outcome === 'waiting-for-host' ? ( +

+ {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 } + )} +

+ ) : null} + {result.outcome === 'refused' ? ( +
+

{wslMoveRefusalMessage(result, distro)}

+ {result.reasons?.length ? ( +
    + {result.reasons.map((reason) => ( +
  • {reason}
  • + ))} +
+ ) : null} +
+ ) : null} + {blockers.length > 0 ? : null} + {failure ? ( +

+ {failure} +

+ ) : null} +
+ + {needsReload ? ( + + ) : ( + <> + + {result.outcome === 'refused' || result.outcome === 'reset' ? ( + + ) : null} + + )} + + + ) +} diff --git a/src/renderer/src/components/wsl-move/WslMoveRuntimeRow.tsx b/src/renderer/src/components/wsl-move/WslMoveRuntimeRow.tsx new file mode 100644 index 00000000000..a3d17d1136a --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveRuntimeRow.tsx @@ -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 ( + openWslMoveDialogFor(distro, { kind: 'repo', id: repoId })} + > + {translate('wslMove.runtime.move', 'Move project')} + + } + /> + ) +} diff --git a/src/renderer/src/components/wsl-move/WslMoveUnfinishedNotice.tsx b/src/renderer/src/components/wsl-move/WslMoveUnfinishedNotice.tsx new file mode 100644 index 00000000000..fbd87a27eee --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveUnfinishedNotice.tsx @@ -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 ( +
    + {lines.map((move) => ( +
  • + + + {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 } + )} + +
  • + ))} +
+ ) +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-copy.ts b/src/renderer/src/components/wsl-move/wsl-move-copy.ts new file mode 100644 index 00000000000..db90451576e --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-copy.ts @@ -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` +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-dialog-store.test.ts b/src/renderer/src/components/wsl-move/wsl-move-dialog-store.test.ts new file mode 100644 index 00000000000..b4b55852afa --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-dialog-store.test.ts @@ -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 + start: ReturnType + cancel: ReturnType + listUnfinished: ReturnType + onProgress: ReturnType +} + +let api: Api +let emitProgress: (event: WslMoveProgressEvent) => void +const unsubscribe = vi.fn() + +const dialog = () => useWslMoveDialogStore.getState().dialog + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((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() + 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() + 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() + 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() + 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' } + ]) + }) +}) diff --git a/src/renderer/src/components/wsl-move/wsl-move-dialog-store.ts b/src/renderer/src/components/wsl-move/wsl-move-dialog-store.ts new file mode 100644 index 00000000000..98a319246f5 --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-dialog-store.ts @@ -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()(() => ({ + 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([ + '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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + const unfinished = await window.api.wslMove.listUnfinished() + useWslMoveDialogStore.setState({ unfinished }) + } catch (error) { + console.warn('[wsl-move] Could not list unfinished moves:', error) + } +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-entry.ts b/src/renderer/src/components/wsl-move/wsl-move-entry.ts new file mode 100644 index 00000000000..de1c9ed2d08 --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-entry.ts @@ -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 | null | undefined +): string | null { + const enabled = useWslHostsEnabled() + const environments = useAppStore((s) => s.runtimeEnvironments) + return enabled && repo ? wslMoveTargetForRepo(repo, environments) : null +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-scope-rows.test.ts b/src/renderer/src/components/wsl-move/wsl-move-scope-rows.test.ts new file mode 100644 index 00000000000..0ae23f0722c --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-scope-rows.test.ts @@ -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 { + // 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 { + // 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'] }) + }) +}) diff --git a/src/renderer/src/components/wsl-move/wsl-move-scope-rows.ts b/src/renderer/src/components/wsl-move/wsl-move-scope-rows.ts new file mode 100644 index 00000000000..4b9f20a3b51 --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-scope-rows.ts @@ -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 + unifiedTabsByWorktree: Record[]> +} + +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, + 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() + 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, + 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) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 27282bd0e93..d3fa7363a7e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." + } } } diff --git a/src/shared/wsl-move/wsl-move-driver-types.ts b/src/shared/wsl-move/wsl-move-driver-types.ts index b9fd3a68925..131276af1c1 100644 --- a/src/shared/wsl-move/wsl-move-driver-types.ts +++ b/src/shared/wsl-move/wsl-move-driver-types.ts @@ -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 | { diff --git a/tests/e2e/wsl-move-dialog.spec.ts b/tests/e2e/wsl-move-dialog.spec.ts new file mode 100644 index 00000000000..48823463e40 --- /dev/null +++ b/tests/e2e/wsl-move-dialog.spec.ts @@ -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 } + +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[1], + internalGate: boolean +): Promise { + 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 => { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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[1], + name: string +): Promise { + 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 { + 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() + } +})