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
This commit is contained in:
Neil
2026-11-01 00:41:20 -07:00
committed by GitHub
parent 5eac01e224
commit f874a650ff
20 changed files with 421 additions and 93 deletions
+2 -1
View File
@@ -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'))
}
@@ -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<string, Handler>())
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()
})
})
+22 -6
View File
@@ -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<typeof wslHostsOptedIn>[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<WslMovePreview> => {
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<WslMoveResult> => {
const parsed = parseWslMoveRequest(request)
return (
refuseUnlessOpen(event) ??
refuseNewMove(event) ??
(parsed && driverDeps
? runTrackedWslMove(driverDeps(), (deps) => moveProjectsToWslHost(deps, parsed))
: invalidRequest())
@@ -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<void> {
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()
+7 -6
View File
@@ -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 =
+12 -5
View File
@@ -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)
}
+7 -3
View File
@@ -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<WslHostAddResult> {
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)) {
+24 -24
View File
@@ -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([
+6 -14
View File
@@ -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<void> {
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
}
}
@@ -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
}),
[
@@ -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. */
@@ -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))) {
@@ -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)
})
})
@@ -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 }
)
)
}
@@ -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)
})
})
@@ -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(
@@ -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())
})
})
@@ -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<string>()
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
+2 -1
View File
@@ -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": {
+29 -8
View File
@@ -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<boolean> =>
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)