From f874a650ff2afe5e5d82ece8ab0dbd80fd2e48b9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:41:20 -0700 Subject: [PATCH] fix(wsl): turning the WSL hosts setting off keeps saved hosts serving; it stops only new hosts and moves (#36783) * fix(wsl): turning the WSL hosts setting off keeps saved hosts serving; it stops only new hosts and moves * chore(wsl): drop the stale run-refusal doc comment --- src/main/ipc/wsl-move-driver-wiring.ts | 3 +- .../ipc/wsl-move-handlers-setting-off.test.ts | 97 +++++++++++++++++++ src/main/ipc/wsl-move-handlers.ts | 28 ++++-- .../ipc/wsl-move-orchestrator-wiring.test.ts | 59 ++++++++++- src/main/ipc/wsl-move-orchestrator-wiring.ts | 13 +-- src/main/wsl/wsl-host-add-admission.ts | 17 +++- src/main/wsl/wsl-host-registration.ts | 10 +- src/main/wsl/wsl-host-service.test.ts | 48 ++++----- src/main/wsl/wsl-host-service.ts | 20 ++-- .../sidebar/use-sidebar-host-scope-options.ts | 3 +- .../sidebar/use-wsl-hosts-enabled.ts | 35 +++++-- .../worktree-list/listing/use-section-rows.ts | 5 +- .../sidebar/wsl-host-offer-move.test.ts | 26 ++++- .../components/sidebar/wsl-host-offer-move.ts | 24 ++++- .../use-wsl-host-unavailable-reason.test.ts | 56 +++++++++++ .../use-wsl-host-unavailable-reason.ts | 4 +- .../wsl-host-ipc-bridge-focus.test.ts | 21 ++-- .../hooks/ipc-events/wsl-host-ipc-bridge.ts | 5 +- src/renderer/src/i18n/locales/en.json | 3 +- tests/e2e/wsl-host-fake-transport.spec.ts | 37 +++++-- 20 files changed, 421 insertions(+), 93 deletions(-) create mode 100644 src/main/ipc/wsl-move-handlers-setting-off.test.ts create mode 100644 src/renderer/src/components/terminal-pane/use-wsl-host-unavailable-reason.test.ts diff --git a/src/main/ipc/wsl-move-driver-wiring.ts b/src/main/ipc/wsl-move-driver-wiring.ts index b13d4391e60..6e3a33b5e31 100644 --- a/src/main/ipc/wsl-move-driver-wiring.ts +++ b/src/main/ipc/wsl-move-driver-wiring.ts @@ -92,7 +92,8 @@ export function registerWslMoveWiring(store: Store, runtime: OrcaRuntimeService) () => app.getPath('userData'), (senderId) => getTrustedUIRendererWebContents()?.id === senderId, () => createWslMoveDriverDeps(store, runtime, 'user'), - () => createWslMoveUndoPorts(store, app.getPath('userData')) + () => createWslMoveUndoPorts(store, app.getPath('userData')), + () => store.getSettings() ) registerWslMoveOrchestrator(store, () => createWslMoveDriverDeps(store, runtime, 'automatic')) } diff --git a/src/main/ipc/wsl-move-handlers-setting-off.test.ts b/src/main/ipc/wsl-move-handlers-setting-off.test.ts new file mode 100644 index 00000000000..6036cd1d4da --- /dev/null +++ b/src/main/ipc/wsl-move-handlers-setting-off.test.ts @@ -0,0 +1,97 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate' +import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { listWslMoveCutovers } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { + loadWslMoveFence, + resetWslMoveFenceForTests +} from '../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { setWslMoveReloadableWindow } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold' + +type Handler = (event: unknown, request?: unknown) => unknown + +const handlers = vi.hoisted(() => new Map()) +vi.mock('electron', () => ({ + ipcMain: { + removeHandler: (channel: string) => handlers.delete(channel), + handle: (channel: string, handler: Handler) => handlers.set(channel, handler) + } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) + +import { registerWslMoveHandlers } from './wsl-move-handlers' + +const MAIN_WINDOW_ID = 7 +const sender = { id: MAIN_WINDOW_ID, session: { flushStorageData: vi.fn() } } +const invoke = (channel: string, request?: unknown) => handlers.get(channel)?.({ sender }, request) + +// Stage 1: the WSL hosts setting governs new work only; with it off, a moved project still serves. +describe('WSL move IPC with the WSL hosts setting off', () => { + let userDataPath: string + const driverDeps = vi.fn(() => { + throw new Error('a new move must not build the driver') + }) + const stopHostCopies = vi.fn(async () => 'stopped' as const) + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-setting-off-')) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + loadWslMoveFence(userDataPath) + driverDeps.mockClear() + stopHostCopies.mockClear() + registerWslMoveHandlers( + () => userDataPath, + (senderId) => senderId === MAIN_WINDOW_ID, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a refused move never calls it; the stub throws if one does. + driverDeps as never, + () => ({ stopHostCopies }), + () => ({ experimentalWslHosts: false }) + ) + }) + afterEach(() => { + vi.unstubAllEnvs() + resetWslMoveFenceForTests() + setWslMoveReloadableWindow(() => null) + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('refuses a new move and its preview, building nothing', async () => { + for (const channel of ['wslMove:preview', 'wslMove:start']) { + expect(await invoke(channel, { distro: 'Ubuntu', repoIds: ['repo-1'] })).toEqual({ + outcome: 'refused', + code: 'wsl_move_unavailable', + detail: 'wsl-hosts-off' + }) + } + expect(driverDeps).not.toHaveBeenCalled() + }) + + it('still brings a moved project back ("Use on this computer again")', async () => { + const page = { loaded: () => {} } + const mainWindow = { + id: MAIN_WINDOW_ID, + isDestroyed: () => false, + reload: vi.fn(), + allowUnload: () => () => {}, + onLoadedOrGone: (listener: () => void) => { + page.loaded = listener + return () => {} + } + } + setWslMoveReloadableWindow(() => mainWindow) + const result = await handlers.get('wslMove:useProjectHereAgain')?.( + { sender: mainWindow }, + { kind: 'repo', id: 'repo-1' } + ) + expect(result).toMatchObject({ released: true }) + expect(stopHostCopies).toHaveBeenCalledOnce() + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + await new Promise((resolve) => setImmediate(resolve)) + page.loaded() + }) +}) diff --git a/src/main/ipc/wsl-move-handlers.ts b/src/main/ipc/wsl-move-handlers.ts index fa650104038..b6185956403 100644 --- a/src/main/ipc/wsl-move-handlers.ts +++ b/src/main/ipc/wsl-move-handlers.ts @@ -30,7 +30,14 @@ import { listUnfinishedWslMoves } from '../persistence/moving-wsl-projects/wsl-m import { runTrackedWslMove } from '../telemetry/wsl-move-telemetry' import { runTrackedWslMoveUndo } from '../telemetry/wsl-move-undo-telemetry' import { isAnyWslMoveRunning } from '../persistence/moving-wsl-projects/wsl-move-running-moves' -import { wslHostsAcceptNewWork, wslHostsGateOpen, wslHostsServing } from '../wsl/wsl-host-gate' +import { + wslHostsAcceptNewWork, + wslHostsGateOpen, + wslHostsOptedIn, + wslHostsServing +} from '../wsl/wsl-host-gate' + +type WslHostsSetting = Parameters[0] import type { WslMoveUndoPorts } from './wsl-move-undo-wiring' import { releaseWslMovedProjectHere } from './wsl-move-release-here' @@ -54,7 +61,9 @@ export function registerWslMoveHandlers( /** Built per call, only once the gate is open. */ driverDeps?: () => WslMoveDriverDeps, /** Undo's reach into the host; absent, there is no host to stop. */ - undoPorts: () => WslMoveUndoPorts = () => ({ stopHostCopies: async () => 'stopped' }) + undoPorts: () => WslMoveUndoPorts = () => ({ stopHostCopies: async () => 'stopped' }), + /** The user's settings: the WSL hosts setting governs new moves; with it off, moved projects keep serving. */ + readSettings: () => WslHostsSetting = () => ({ experimentalWslHosts: true }) ): void { for (const channel of WSL_MOVE_HANDLER_CHANNELS) { ipcMain.removeHandler(channel) @@ -87,14 +96,15 @@ export function registerWslMoveHandlers( ) } ) - registerWslMoveDriverHandlers(getUserDataPath, isMainWindowRenderer, driverDeps) + registerWslMoveDriverHandlers(getUserDataPath, isMainWindowRenderer, driverDeps, readSettings) registerWslMoveUseHereHandler(getUserDataPath, isMainWindowRenderer, undoPorts) } function registerWslMoveDriverHandlers( getUserDataPath: () => string, isMainWindowRenderer: (senderId: number) => boolean, - driverDeps: (() => WslMoveDriverDeps) | undefined + driverDeps: (() => WslMoveDriverDeps) | undefined, + readSettings: () => WslHostsSetting ): void { // Gate first: with it closed, no dependency is built and nothing is read. A new move also needs // no block learned this run; cancelling a running one never does. @@ -110,12 +120,18 @@ function registerWslMoveDriverHandlers( ? null : { outcome: 'refused', code: 'wsl_move_unavailable', detail: 'not-main-window' } } + // A new move is new work: with the setting off it is refused, as serve-only refuses it. + const refuseNewMove = (event: RendererEvent): WslMoveRefusal | null => + refuseUnlessOpen(event) ?? + (wslHostsOptedIn(readSettings()) + ? null + : { outcome: 'refused', code: 'wsl_move_unavailable', detail: 'wsl-hosts-off' }) ipcMain.handle( 'wslMove:preview', async (event: RendererEvent, request: unknown): Promise => { const parsed = parseWslMoveRequest(request) return ( - refuseUnlessOpen(event) ?? + refuseNewMove(event) ?? (parsed && driverDeps ? previewWslMove(driverDeps(), parsed) : invalidRequest()) ) } @@ -125,7 +141,7 @@ function registerWslMoveDriverHandlers( async (event: RendererEvent, request: unknown): Promise => { const parsed = parseWslMoveRequest(request) return ( - refuseUnlessOpen(event) ?? + refuseNewMove(event) ?? (parsed && driverDeps ? runTrackedWslMove(driverDeps(), (deps) => moveProjectsToWslHost(deps, parsed)) : invalidRequest()) diff --git a/src/main/ipc/wsl-move-orchestrator-wiring.test.ts b/src/main/ipc/wsl-move-orchestrator-wiring.test.ts index 0836fcf98b5..76c86e88162 100644 --- a/src/main/ipc/wsl-move-orchestrator-wiring.test.ts +++ b/src/main/ipc/wsl-move-orchestrator-wiring.test.ts @@ -163,10 +163,17 @@ const profile = vi.hoisted(() => ({ repos: [{ id: 'r1', path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\app', connectionId: null }] })) +// The user's WSL hosts setting: moves are new work, so they need it on. +const setting = { on: true } + async function register(): Promise { const { registerWslMoveOrchestrator } = await import('./wsl-move-orchestrator-wiring') - const store = { getRepos: () => profile.repos, getFolderWorkspaces: () => [] } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The wiring reads only the projects of the store, and the driver is mocked. + const store = { + getRepos: () => profile.repos, + getFolderWorkspaces: () => [], + getSettings: () => ({ experimentalWslHosts: setting.on }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The wiring reads only the projects and the WSL hosts setting of the store, and the driver is mocked. registerWslMoveOrchestrator(store as never, () => ({ now: () => new Date(0) }) as never) } @@ -367,6 +374,54 @@ describe('WSL move orchestrator wiring', () => { } }) + // Setting off is like serve-only: moved projects keep serving, but nothing moves or is added. + it('moves nothing and adds no host with the WSL hosts setting off', async () => { + setting.on = false + try { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + await register() + await handlers.get('wslMoveOrchestrator:projectOpened')?.( + { sender: { id: MAIN_WINDOW_ID } }, + 'Ubuntu' + ) + const retry = await handlers.get('wslMoveOrchestrator:userAction')?.( + { sender: { id: MAIN_WINDOW_ID } }, + { distro: 'Ubuntu', action: 'retry' } + ) + await answerWindow() + expect(retry).toBeNull() + expect(host.added).toEqual([]) + expect(driver.moveProjectsToWslHost).not.toHaveBeenCalled() + } finally { + setting.on = true + } + }) + + // Like a live block: a move already under way stays listed and can still be settled. + it('keeps listing a recorded move and accepts Not now with the setting off', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + await register() + for (const listener of releases.listeners) { + listener('Ubuntu') + } + setting.on = false + try { + expect(await handlers.get('wslMoveOrchestrator:list')?.({})).toEqual([ + expect.objectContaining({ distro: 'Ubuntu' }) + ]) + const notNow = await handlers.get('wslMoveOrchestrator:userAction')?.( + { sender: { id: MAIN_WINDOW_ID } }, + { distro: 'Ubuntu', action: 'not-now' } + ) + expect(notNow).toEqual({ record: expect.objectContaining({ distro: 'Ubuntu' }) }) + await answerWindow() + expect(host.added).toEqual([]) + expect(driver.moveProjectsToWslHost).not.toHaveBeenCalled() + } finally { + setting.on = true + } + }) + it('T2: never adds a host for a stopped distro or one whose state cannot be read', async () => { vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') await register() diff --git a/src/main/ipc/wsl-move-orchestrator-wiring.ts b/src/main/ipc/wsl-move-orchestrator-wiring.ts index 60d8d8746dc..15a754d5fd4 100644 --- a/src/main/ipc/wsl-move-orchestrator-wiring.ts +++ b/src/main/ipc/wsl-move-orchestrator-wiring.ts @@ -36,7 +36,7 @@ import { } from '../telemetry/wsl-move-orchestrator-telemetry' import { runTrackedWslMove } from '../telemetry/wsl-move-telemetry' import { readWslDistroRegistrations } from '../wsl/wsl-distro-registry-id' -import { wslHostsAcceptNewWork, wslHostsGateOpen } from '../wsl/wsl-host-gate' +import { wslHostsAcceptNewWork, wslHostsGateOpen, wslHostsOptedIn } from '../wsl/wsl-host-gate' import { wslHostAddRefusal } from '../wsl/wsl-host-add-admission' import { runAsWslUserAct } from '../wsl/wsl-unc-access-gate' import { getWslHostService } from '../wsl/wsl-host-service-instance' @@ -127,9 +127,9 @@ function createOrchestrator( return new WslMoveOrchestrator({ now: () => Date.now(), appVersion: app.getVersion(), - // A block learned this run stops automatic moves at once, like the opt-out env. + // A block learned this run, or the setting turned off, stops automatic moves at once. autoMoveEnabled: () => - process.env[WSL_MOVE_ORCHESTRATOR_AUTO_ENV] !== '0' && wslHostsAcceptNewWork(), + process.env[WSL_MOVE_ORCHESTRATOR_AUTO_ENV] !== '0' && wslHostsOptedIn(store.getSettings()), autoMovesBridgeProjects: wslMoveAutoMovesBridgeProjects, prepareHost: (distro, stillWanted) => prepareHost(userDataPath, distro, stillWanted), isDistroInUse: (distro) => isDistroInUse(store, userDataPath, distro), @@ -214,6 +214,7 @@ export function registerWslMoveOrchestrator( } const isMainWindow = (sender: WebContents | undefined): boolean => !!sender && getTrustedUIRendererWebContents()?.id === sender.id + // Not setting-gated: a move already in flight when the setting turns off stays visible. ipcMain.handle('wslMoveOrchestrator:list', (): WslMoveOrchestratorRecord[] => orchestrator && wslHostsGateOpen() ? orchestrator.list() : [] ) @@ -223,7 +224,7 @@ export function registerWslMoveOrchestrator( (event: { sender?: WebContents }, distro: unknown): void => { if ( orchestrator && - wslHostsAcceptNewWork() && + wslHostsOptedIn(store.getSettings()) && isMainWindow(event.sender) && typeof distro === 'string' ) { @@ -247,8 +248,8 @@ export function registerWslMoveOrchestrator( return null } const action = USER_ACTIONS.find((candidate) => candidate === request.action) - // Retry starts a move; Restore and Not now stay available after a block learned this run. - if (!action || (action === 'retry' && !wslHostsAcceptNewWork())) { + // Retry starts a move; Restore and Not now stay available after a block or with the setting off. + if (!action || (action === 'retry' && !wslHostsOptedIn(store.getSettings()))) { return null } const unsaved = diff --git a/src/main/wsl/wsl-host-add-admission.ts b/src/main/wsl/wsl-host-add-admission.ts index a43ba0f41f0..bc5990759f0 100644 --- a/src/main/wsl/wsl-host-add-admission.ts +++ b/src/main/wsl/wsl-host-add-admission.ts @@ -51,20 +51,27 @@ export function wslHostRemovalPendingMessage(distro: string): string { return `Orca didn’t finish removing the WSL host ${distro}. Remove it again in Settings → Remote Orca Servers, or connect it to keep it.` } -/** Why no WSL host may run at all right now; null when they may. */ +/** + * Why a saved host can't run now. The WSL hosts setting governs new work only (Stage 1): with it + * off, saved hosts keep serving their projects, as in serve-only. + */ export function wslHostRunRefusal(gate: { - isEnabled: () => boolean hasServerFiles: () => boolean }): WslHostAddRefusal | null { - if (!gate.isEnabled()) { - return wslHostAddRefusal('disabled', 'turnedOff') - } // Reject missing packaged templates before C4 deploy emits an opaque deep failure. return gate.hasServerFiles() ? null : wslHostAddRefusal('server-files-missing', 'serverFilesMissing') } +/** Why a new host can't be added now: the setting is off, or no saved host could run either. */ +export function wslHostNewWorkRefusal(gate: { + isEnabled: () => boolean + hasServerFiles: () => boolean +}): WslHostAddRefusal | null { + return gate.isEnabled() ? wslHostRunRefusal(gate) : wslHostAddRefusal('disabled', 'turnedOff') +} + export function sameWslDistro(left: string, right: string): boolean { return wslDistroHolderKey(left) === wslDistroHolderKey(right) } diff --git a/src/main/wsl/wsl-host-registration.ts b/src/main/wsl/wsl-host-registration.ts index d95be3b5c99..9a0dfaa17e2 100644 --- a/src/main/wsl/wsl-host-registration.ts +++ b/src/main/wsl/wsl-host-registration.ts @@ -3,7 +3,11 @@ import { errorMessage } from '../../shared/error-message' import { addWslHostEnvironment } from '../../shared/runtime-environment-wsl-host-store' import type { WslHostDeploymentRecord } from '../../shared/wsl-host-deployment-store' import { wslHostEnvironmentLabel, type WslHostAddResult } from '../../shared/wsl-host-types' -import { admitWslHostDistro, wslHostAddRefusal, wslHostRunRefusal } from './wsl-host-add-admission' +import { + admitWslHostDistro, + wslHostAddRefusal, + wslHostNewWorkRefusal +} from './wsl-host-add-admission' import { abandonWslHostAdd, connectForWslHostAdd } from './wsl-host-first-connect' import type { WslHostConnector, WslHostServiceDependencies } from './wsl-host-service' import { wslHostSshManagedRefusal } from './wsl-host-ssh-orcad-conflict' @@ -26,7 +30,7 @@ export async function registerWslHost( context: WslHostRegistrationContext, args: { distro: string; startIfStopped?: boolean } ): Promise { - const refusal = wslHostRunRefusal(context.deps) + const refusal = wslHostNewWorkRefusal(context.deps) if (refusal) { return refusal } @@ -51,7 +55,7 @@ export async function registerWslHost( const environmentId = defaultWslOrcadEnvironmentId(distro.name) const connected = await connectForWslHostAdd(context.wsl(), distro.name, () => generation === context.disableGeneration() - ? wslHostRunRefusal(context.deps) + ? wslHostNewWorkRefusal(context.deps) : wslHostAddRefusal('disabled', 'turnedOff') ) if (!('connection' in connected)) { diff --git a/src/main/wsl/wsl-host-service.test.ts b/src/main/wsl/wsl-host-service.test.ts index a2d4d368ab1..2851fdc2e15 100644 --- a/src/main/wsl/wsl-host-service.test.ts +++ b/src/main/wsl/wsl-host-service.test.ts @@ -281,7 +281,8 @@ describe('WslHostService', () => { expect(connector.connect).toHaveBeenCalledTimes(3) }) - it('refuses a connect while the experiment is off, but still removes the host', async () => { + // Stage 1: the setting governs new work only; a saved host keeps serving, as in serve-only. + it('keeps serving a saved host with the setting off: connect and Remove still work', async () => { const connector = makeConnector([UBUNTU]) const service = makeService(connector) await service.add({ distro: 'Ubuntu' }) @@ -289,17 +290,24 @@ describe('WslHostService', () => { enabled = false await service.enabledChanged(false) - await expect(service.connect('wsl:ubuntu')).rejects.toThrow('turned off in Settings') - service.setVisibleWork(['wsl:ubuntu']) - await expect(service.ensureForRuntimeCall('wsl:ubuntu')).rejects.toThrow( - 'turned off in Settings' - ) - expect(connector.connect).not.toHaveBeenCalled() + await service.connect('wsl:ubuntu') + expect(connector.connect).toHaveBeenCalledExactlyOnceWith('Ubuntu', expect.anything()) await service.remove('wsl:ubuntu') expect(listEnvironments(userDataPath)).toEqual([]) }) + it('refuses a new host while the setting is off', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + enabled = false + await expect(service.add({ distro: 'Ubuntu' })).resolves.toMatchObject({ + ok: false, + kind: 'disabled' + }) + expect(listEnvironments(userDataPath)).toEqual([]) + }) + it('refuses to add or connect, before any deploy work, when this build lacks the server files', async () => { const connector = makeConnector([UBUNTU]) const service = makeService(connector) @@ -358,7 +366,7 @@ describe('WslHostService', () => { expect(removed).toHaveBeenCalledWith('wsl:ubuntu') }) - it('turning the experiment off closes every connection and releases holders; on resumes lazily', async () => { + it('turning the setting off keeps every connection and holder; turning it on boots nothing', async () => { const connector = makeConnector([UBUNTU]) const service = makeService(connector) await service.add({ distro: 'Ubuntu' }) @@ -367,21 +375,16 @@ describe('WslHostService', () => { enabled = false await service.enabledChanged(false) - expect(connector.disconnect).toHaveBeenCalledWith('Ubuntu') - expect(connector.releaseHolder).toHaveBeenCalledWith('Ubuntu') - expect(published.at(-1)).toMatchObject({ environmentId: 'wsl:ubuntu', state: 'closed' }) - // Disabled experiments cannot relaunch for visible work. - expect(service.hasUserVisibleWork('Ubuntu')).toBe(false) - service.setVisibleWork(['wsl:ubuntu']) - expect(connector.connect).not.toHaveBeenCalled() + expect(connector.disconnect).not.toHaveBeenCalled() + expect(connector.releaseHolder).not.toHaveBeenCalled() + expect(published.at(-1)).not.toMatchObject({ state: 'closed' }) + // A moved project's work keeps its host up. + expect(service.hasUserVisibleWork('Ubuntu')).toBe(true) enabled = true await service.enabledChanged(true) // Nothing boots on enable; the window re-reports its focused host, which may. expect(connector.connect).not.toHaveBeenCalled() - expect(service.hasUserVisibleWork('Ubuntu')).toBe(true) - service.focusHost('wsl:ubuntu') - await vi.waitFor(() => expect(connector.connect).toHaveBeenCalledTimes(1)) }) it('turning the experiment on with nothing open connects nothing', async () => { @@ -596,7 +599,7 @@ describe('WslHostService', () => { expect(connector.connect).toHaveBeenCalledWith('Ubuntu', { noBoot: true }) }) - it('a due automation boots a parked host once, and never with the experiment off', async () => { + it('a due automation boots a parked host once, with the setting off too (it serves saved hosts)', async () => { const connector = makeConnector([UBUNTU]) const service = makeService(connector) await service.add({ distro: 'Ubuntu' }) @@ -609,9 +612,6 @@ describe('WslHostService', () => { connector.connect.mockClear() enabled = false await service.wakeForAutomation('wsl:ubuntu') - expect(connector.connect).not.toHaveBeenCalled() - enabled = true - await service.wakeForAutomation('wsl:ubuntu') expect(connector.connect).toHaveBeenCalledExactlyOnceWith('Ubuntu') await expect(service.isConnected('wsl:ubuntu')).resolves.toBe(true) }) @@ -764,14 +764,14 @@ describe('WslHostService', () => { expect(isWanted('Debian')).toBe(true) }) - it('releases every startup holder while WSL hosts are off', async () => { + it("keeps a saved host's startup holder while the setting is off", async () => { const connector = makeConnector([UBUNTU]) const service = makeService(connector) await service.add({ distro: 'Ubuntu' }) enabled = false await service.start() const [isWanted] = connector.adoptHolders.mock.calls[0] - expect(isWanted('Ubuntu')).toBe(false) + expect(isWanted('Ubuntu')).toBe(true) }) it.each([ diff --git a/src/main/wsl/wsl-host-service.ts b/src/main/wsl/wsl-host-service.ts index 51646e9f001..86b472670a7 100644 --- a/src/main/wsl/wsl-host-service.ts +++ b/src/main/wsl/wsl-host-service.ts @@ -158,21 +158,13 @@ export class WslHostService { return removal.serverStop } - /** Disable closes connections/watches and releases holders; reenable resumes only hosts needed by visible work. */ + /** + * The setting governs new work only (Stage 1): turning it off refuses an add still in flight, + * but saved hosts keep serving their projects, as in serve-only. Turning it on boots nothing. + */ async enabledChanged(enabled: boolean): Promise { - const connector = this.connector - if (!connector) { - return - } - if (enabled) { - // Nothing boots here: the window re-reports its focused host, which connects only if running (E9). - return - } - this.disableGeneration += 1 - for (const record of this.records()) { - await connector.disconnect(record.distro) - await connector.releaseHolder(record.distro) - this.setStatus(record, { state: 'closed' }) + if (!enabled) { + this.disableGeneration += 1 } } diff --git a/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts b/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts index f091d5cf88a..09684ef9684 100644 --- a/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts +++ b/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts @@ -1,3 +1,4 @@ +import { selectWslHostsServing } from './use-wsl-hosts-enabled' import { useMemo } from 'react' import { useAppStore } from '@/store' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' @@ -34,7 +35,7 @@ export function useSidebarHostScopeOptions(): { runtimeStatusByEnvironmentId, hostLabelOverrides, wslHostStatuses, - wslHostsEnabled: settings?.experimentalWslHosts === true, + wslHostsEnabled: selectWslHostsServing({ settings, runtimeEnvironments }), wslHostServerFilesMissing }), [ diff --git a/src/renderer/src/components/sidebar/use-wsl-hosts-enabled.ts b/src/renderer/src/components/sidebar/use-wsl-hosts-enabled.ts index bc2a85c7dbe..001f1c3d7ec 100644 --- a/src/renderer/src/components/sidebar/use-wsl-hosts-enabled.ts +++ b/src/renderer/src/components/sidebar/use-wsl-hosts-enabled.ts @@ -1,4 +1,6 @@ import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { isWslHostRuntimeEnvironment } from '../../../../shared/runtime-environments' /** Preload exposes WSL-host APIs only when this desktop launch supports them. */ export function wslHostsAvailable(): boolean { @@ -10,16 +12,37 @@ export function useWslHostServerFilesMissing(): boolean { return useAppStore((s) => s.wslHostServerFiles === 'missing') } -/** Available, and the user-level Experimental switch (shown only behind the gate) is on. */ -export function useWslHostsEnabled(): boolean { - const enabled = useAppStore((s) => s.settings?.experimentalWslHosts === true) - return enabled && wslHostsAvailable() +/** + * Saved hosts serve their projects whatever the setting: it governs new work only (Stage 1), so + * with it off a moved project stays reachable, as in serve-only. + */ +export function selectWslHostsServing(state: { + settings: AppState['settings'] + /** Optional: some list builders run before the environments load. */ + runtimeEnvironments?: AppState['runtimeEnvironments'] +}): boolean { + return ( + state.settings?.experimentalWslHosts === true || + (state.runtimeEnvironments ?? []).some(isWslHostRuntimeEnvironment) + ) } -/** Enabled, and not serve-only: the entry points that add a host or move a project. */ +/** Available, and serving: the setting is on, or saved hosts keep serving with it off. */ +export function useWslHostsEnabled(): boolean { + const serving = useAppStore(selectWslHostsServing) + return serving && wslHostsAvailable() +} + +/** Available, and the user's WSL hosts setting is on. */ +export function useWslHostsSettingOn(): boolean { + const settingOn = useAppStore((s) => s.settings?.experimentalWslHosts === true) + return settingOn && wslHostsAvailable() +} + +/** The setting on, and not serve-only: the entry points that add a host or move a project. */ export function useWslHostsAcceptNewWork(): boolean { const accepts = useAppStore((s) => s.wslHostsAcceptNewWork) - return useWslHostsEnabled() && accepts + return useWslHostsSettingOn() && accepts } /** Serve-only: saved hosts work, but adding hosts and moving projects is paused. */ diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts index a9f9aa83979..0c9390437a0 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts @@ -1,3 +1,4 @@ +import { selectWslHostsServing } from '../../use-wsl-hosts-enabled' import { useCallback, useMemo, useState } from 'react' import { shallow } from 'zustand/shallow' import { persistedUIValuesEqual } from '../../../../../../shared/persisted-ui-equality' @@ -56,7 +57,7 @@ function createSidebarHostOptionsSelector() { state.runtimeEnvironments, hostLabelOverrides, state.wslHostStatuses, - settings?.experimentalWslHosts === true, + selectWslHostsServing(state), state.wslHostServerFiles === 'missing' ] if (shallow(inputs, nextInputs)) { @@ -72,7 +73,7 @@ function createSidebarHostOptionsSelector() { runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId, hostLabelOverrides, wslHostStatuses: state.wslHostStatuses, - wslHostsEnabled: settings?.experimentalWslHosts === true, + wslHostsEnabled: selectWslHostsServing(state), wslHostServerFilesMissing: state.wslHostServerFiles === 'missing' }) if (!persistedUIValuesEqual(result.map(sidebarHostSemantics), next.map(sidebarHostSemantics))) { diff --git a/src/renderer/src/components/sidebar/wsl-host-offer-move.test.ts b/src/renderer/src/components/sidebar/wsl-host-offer-move.test.ts index af27653142f..11169775796 100644 --- a/src/renderer/src/components/sidebar/wsl-host-offer-move.test.ts +++ b/src/renderer/src/components/sidebar/wsl-host-offer-move.test.ts @@ -6,6 +6,8 @@ const state = vi.hoisted(() => { return { repos, folders } }) const openDialog = vi.hoisted(() => vi.fn()) +const toastError = vi.hoisted(() => vi.fn()) +vi.mock('sonner', () => ({ toast: { error: toastError } })) vi.mock('@/store', () => ({ useAppStore: { getState: () => state } })) vi.mock('@/store/workspace-record-selectors', () => ({ selectFolderWorkspaces: (current: typeof state) => current.folders @@ -21,6 +23,7 @@ describe("the offer's move of the project it added (B3)", () => { state.repos = [] state.folders = [] openDialog.mockReset() + toastError.mockReset() }) it('finds the added repo by its path, however its slashes are written', () => { @@ -40,8 +43,29 @@ describe("the offer's move of the project it added (B3)", () => { expect(openDialog).toHaveBeenCalledWith('Ubuntu', { kind: 'repo', id: 'r1' }) }) - it('opens nothing when the add made no project', () => { + it('finds the innermost stored repo that contains an added subfolder', () => { + state.repos = [ + { id: 'outer', path: '\\\\wsl.localhost\\Ubuntu\\home\\me' }, + { id: 'r1', path: PATH }, + { id: 'sibling', path: `${PATH}-two` } + ] + moveWslHostOfferProject({ + path: `${PATH}\\packages\\app`, + distro: 'Ubuntu', + source: 'local_folder_picker' + }) + expect(openDialog).toHaveBeenCalledWith('Ubuntu', { kind: 'repo', id: 'r1' }) + expect(toastError).not.toHaveBeenCalled() + }) + + it('does not take a sibling whose path only shares a prefix', () => { + state.repos = [{ id: 'sibling', path: `${PATH}-two` }] + expect(wslHostOfferProjectRef(PATH)).toBeNull() + }) + + it('says so instead of doing nothing when no stored project matches', () => { moveWslHostOfferProject({ path: PATH, distro: 'Ubuntu', source: 'local_folder_picker' }) expect(openDialog).not.toHaveBeenCalled() + expect(toastError).toHaveBeenCalledTimes(1) }) }) diff --git a/src/renderer/src/components/sidebar/wsl-host-offer-move.ts b/src/renderer/src/components/sidebar/wsl-host-offer-move.ts index 103b3962f43..2dd3c0e08fe 100644 --- a/src/renderer/src/components/sidebar/wsl-host-offer-move.ts +++ b/src/renderer/src/components/sidebar/wsl-host-offer-move.ts @@ -1,6 +1,11 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' -import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../../../../shared/cross-platform-path' import type { WslMoveRowRef } from '../../../../shared/wsl-move/wsl-move-driver-types' import { openWslMoveDialogFor } from '../wsl-move/wsl-move-entry' import type { WslHostOffer } from './wsl-host-offer-actions' @@ -16,7 +21,14 @@ export function wslHostOfferProjectRef(path: string): WslMoveRowRef | null { const folder = selectFolderWorkspaces(state).find( (entry) => normalizeRuntimePathForComparison(entry.folderPath) === wanted ) - return folder ? { kind: 'folder', id: folder.id } : null + if (folder) { + return { kind: 'folder', id: folder.id } + } + // A subfolder add is stored as its git toplevel; the innermost enclosing repo is the one added. + const enclosing = state.repos + .filter((entry) => isPathInsideOrEqual(entry.path, path)) + .sort((a, b) => b.path.length - a.path.length)[0] + return enclosing ? { kind: 'repo', id: enclosing.id } : null } /** Opens the move with only the offered project checked; the user's Move is what moves it. */ @@ -24,5 +36,13 @@ export function moveWslHostOfferProject(offer: WslHostOffer): void { const only = wslHostOfferProjectRef(offer.path) if (only) { openWslMoveDialogFor(offer.distro, only) + return } + toast.error( + translate( + 'sidebar.wslHostOffer.projectNotFound', + 'Could not find the added project to move. Use "Move to {{distro}} host…" on the project in the sidebar.', + { distro: offer.distro } + ) + ) } diff --git a/src/renderer/src/components/terminal-pane/use-wsl-host-unavailable-reason.test.ts b/src/renderer/src/components/terminal-pane/use-wsl-host-unavailable-reason.test.ts new file mode 100644 index 00000000000..8a39af54e9e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-wsl-host-unavailable-reason.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { AppState } from '@/store/types' +import { selectWslHostUnavailableReason } from './use-wsl-host-unavailable-reason' +import { selectWslHostsServing } from '../sidebar/use-wsl-hosts-enabled' + +const WSL_ENV = 'wsl:ubuntu' +const WORKTREE = 'wsl-repo::/home/me/app' + +function state(experimentalWslHosts: boolean, withHost = true): AppState { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the selectors read only these fields. + return { + settings: { ...getDefaultSettings('/tmp'), experimentalWslHosts }, + wslHostServerFiles: 'present', + folderWorkspaces: [], + projectGroups: [], + repos: [ + { + id: 'wsl-repo', + path: '/home/me/app', + displayName: 'app', + badgeColor: '', + addedAt: 0, + executionHostId: `runtime:${WSL_ENV}` + } + ], + runtimeEnvironments: withHost + ? [ + { + id: WSL_ENV, + name: 'Ubuntu (WSL)', + createdAt: 0, + updatedAt: 0, + lastUsedAt: null, + runtimeId: null, + preferredEndpointId: 'ws', + endpoints: [], + orcadDeployment: { kind: 'wsl', distro: 'Ubuntu', localPort: 42000, remotePort: 6768 } + } + ] + : [] + } as unknown as AppState +} + +// Stage 1: the setting governs new work only; a moved project on a saved host stays reachable. +describe('a WSL host project with the WSL hosts setting off', () => { + it('stays reachable: its panes and explorer show no "turned off" state', () => { + expect(selectWslHostUnavailableReason(state(false), WORKTREE)).toBeNull() + expect(selectWslHostsServing(state(false))).toBe(true) + }) + + it('serves nothing when there is no saved host and the setting is off', () => { + expect(selectWslHostsServing(state(false, false))).toBe(false) + expect(selectWslHostsServing(state(true, false))).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-wsl-host-unavailable-reason.ts b/src/renderer/src/components/terminal-pane/use-wsl-host-unavailable-reason.ts index a1dcd96848c..9239985c132 100644 --- a/src/renderer/src/components/terminal-pane/use-wsl-host-unavailable-reason.ts +++ b/src/renderer/src/components/terminal-pane/use-wsl-host-unavailable-reason.ts @@ -1,3 +1,4 @@ +import { selectWslHostsServing } from '../sidebar/use-wsl-hosts-enabled' import { parseExecutionHostId } from '../../../../shared/execution-host' import { isWslHostRuntimeEnvironment } from '../../../../shared/runtime-environments' import { workspaceOwnerHostId } from '@/lib/workspace-row-owner-host' @@ -25,7 +26,8 @@ export function selectWslHostUnavailableReason( if (state.wslHostServerFiles === 'missing') { return 'server-files-missing' } - return state.settings?.experimentalWslHosts === true ? null : 'turned-off' + // A saved host keeps serving with the setting off (it governs new work only), as in serve-only. + return selectWslHostsServing(state) ? null : 'turned-off' } export function useWslHostUnavailableReason( diff --git a/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge-focus.test.ts b/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge-focus.test.ts index 3e4b757ab5a..c7c5256cef9 100644 --- a/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge-focus.test.ts +++ b/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge-focus.test.ts @@ -80,19 +80,24 @@ describe('WSL host focus entries (E9)', () => { unsubs.forEach((unsub) => unsub()) }) - it('records no focus while WSL hosts are off, and reports the focused host when turned on', () => { - seed(false, WSL_WORKTREE) + // Stage 1: the setting governs new work only, so a saved host keeps serving with it off. + it('reports the focused saved host even with the WSL hosts setting off', () => { + seed(false, LOCAL_WORKTREE) + const unsubs: (() => void)[] = [] + registerWslHostIpcBridge(unsubs) + useAppStore.setState({ activeWorktreeId: WSL_WORKTREE }) + expect(api.focusHost).toHaveBeenCalledWith({ environmentId: WSL_ENV }) + unsubs.forEach((unsub) => unsub()) + }) + + it('records no focus with the setting off and no saved host to serve', () => { + seed(false, LOCAL_WORKTREE) + useAppStore.setState({ runtimeEnvironments: [] }) const unsubs: (() => void)[] = [] registerWslHostIpcBridge(unsubs) - useAppStore.setState({ activeWorktreeId: LOCAL_WORKTREE }) useAppStore.setState({ activeWorktreeId: WSL_WORKTREE }) expect(api.focusHost).not.toHaveBeenCalled() expect(api.setVisibleWork).not.toHaveBeenCalled() - - useAppStore.setState({ - settings: { ...getDefaultSettings('/tmp'), experimentalWslHosts: true } - }) - expect(api.focusHost).toHaveBeenCalledExactlyOnceWith({ environmentId: WSL_ENV }) unsubs.forEach((unsub) => unsub()) }) }) diff --git a/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge.ts index c863c1eef28..fc7a6c1fe62 100644 --- a/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge.ts @@ -1,3 +1,4 @@ +import { selectWslHostsServing } from '@/components/sidebar/use-wsl-hosts-enabled' import type { WslHostCapability, WslHostStatus } from '../../../../shared/wsl-host-types' import { isWslHostRuntimeEnvironment } from '../../../../shared/runtime-environments' import { parseExecutionHostId } from '../../../../shared/execution-host' @@ -84,8 +85,8 @@ export function registerWslHostIpcBridge(unsubs: (() => void)[]): void { let focusEntry = '' const focusedThisSession = new Set() const report = (state: AppState): void => { - if (state.settings?.experimentalWslHosts !== true) { - // Focus while WSL hosts are off is not an act on a host; turning them on re-reports. + if (!selectWslHostsServing(state)) { + // With no host to serve, focus is not an act on one; turning WSL hosts on re-reports. inputs = [] focusEntry = '' return diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 173686de53a..94ced538c56 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -214,7 +214,8 @@ "wslHostOffer": { "title": "Run {{distro}} as a host (recommended)", "why": "Git, files and terminals then run natively in Linux instead of across the Windows file share.", - "accept": "Run as host" + "accept": "Run as host", + "projectNotFound": "Could not find the added project to move. Use \"Move to {{distro}} host…\" on the project in the sidebar." } }, "app": { diff --git a/tests/e2e/wsl-host-fake-transport.spec.ts b/tests/e2e/wsl-host-fake-transport.spec.ts index 94b039d1669..2d82c8942ac 100644 --- a/tests/e2e/wsl-host-fake-transport.spec.ts +++ b/tests/e2e/wsl-host-fake-transport.spec.ts @@ -355,19 +355,38 @@ test('adds a WSL distro as a host, adds a project on it, and runs a terminal the await hubRuntime.call('terminal.create', { worktree: `path:${hostOnlyPath}` }) expect(await hubTerminalsIn(hostOnlyPath)).toBeGreaterThan(0) - // Experiment off: the host stays listed so its projects never seem to vanish, but turned off. + // Setting off (Stage 1): it governs new work only, so the host keeps serving its projects, + // as in serve-only. Only adding a host and moving projects stop. await setWslHostsEnabled(page, false) + // Through the desktop's own host connection, not the hub directly: the host still serves. + const callHostFromDesktop = (method: string, params: unknown): Promise => + page.evaluate(async (args) => (await window.api.runtimeEnvironments.call(args)).ok, { + selector: WSL_ENVIRONMENT_ID, + method, + params, + timeoutMs: 30_000 + }) + expect(await callHostFromDesktop('terminal.create', { worktree: `path:${hostOnlyPath}` })).toBe( + true + ) + await expect.poll(() => hubTerminalsIn(hostOnlyPath), { timeout: 30_000 }).toBeGreaterThan(1) + expect(await callHostFromDesktop('terminal.list', {})).toBe(true) + expect( + await page.evaluate(() => window.api.runtimeEnvironments.wslHosts?.add({ distro: 'Ubuntu' })) + ).toMatchObject({ ok: false, kind: 'disabled' }) + expect( + await page.evaluate(() => window.api.wslMove.start({ distro: 'Ubuntu', repoIds: [] })) + ).toMatchObject({ outcome: 'refused', code: 'wsl_move_unavailable' }) await openSidebarProjectDialog(page) await page .getByRole('dialog', { name: /Add a project/i }) .getByRole('combobox') .click() const offRow = page.locator('[cmdk-item]').filter({ hasText: WSL_HOST_LABEL }) - await expect(offRow).toContainText('Turned off in Settings → Experimental') - await expect(offRow.getByRole('button', { name: 'Turn on' })).toBeVisible() - await expect(offRow.getByRole('button', { name: 'Connect' })).toHaveCount(0) + await expect(offRow).toBeVisible() + await expect(offRow).not.toContainText('Turned off in Settings → Experimental') await settleAnimations(page) - await page.screenshot({ path: testInfo.outputPath('wsl-host-row-turned-off.png') }) + await page.screenshot({ path: testInfo.outputPath('wsl-host-row-setting-off.png') }) await page.keyboard.press('Escape') const addProjectAgain = page.getByRole('dialog', { name: /Add a project/i }) await expect(offRow).toBeHidden() @@ -383,10 +402,12 @@ test('adds a WSL distro as a host, adds a project on it, and runs a terminal the await expect(page.getByText('No saved servers', { exact: true })).toBeVisible() const section = page.locator('[data-settings-section="wsl-hosts"]') await section.scrollIntoViewIfNeeded() - await expect(section).toContainText('WSL hosts are turned off in Settings → Experimental') - await expect(section.getByRole('button', { name: 'Connect' })).toBeDisabled() + await expect(section).toContainText(WSL_HOST_LABEL) + await expect(section).not.toContainText('WSL hosts are turned off in Settings → Experimental') + // No new host while the setting is off. + await expect(section.getByRole('button', { name: 'Add WSL distro' })).toHaveCount(0) await settleAnimations(page) - await section.screenshot({ path: testInfo.outputPath('wsl-hosts-settings-turned-off.png') }) + await section.screenshot({ path: testInfo.outputPath('wsl-hosts-settings-setting-off.png') }) // Remove asks first, with WSL-specific copy, then drops the host. await setWslHostsEnabled(page, true)