From 6eebdbbaf851cb3aa9bf6af1a06f05f78ecc6a92 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:54:51 -0700 Subject: [PATCH] fix(wsl): keep a stopped distro's worktrees and re-list once it runs (#35733) * fix(wsl): keep a stopped distro's worktrees and re-list once it runs After a relaunch with a WSL distro stopped, the E9 gate refused the worktree scan and the sidebar showed "Worktree scan failed" with no rows until the user pressed Retry. The listing now answers with the stored rows (non-authoritative, so nothing is pruned), marks the distro stopped or unverifiable in a new optional field, and re-lists once a wake or a settled running read shows the distro running. The re-scan is held once per repo and dropped when the repo is removed. The sidebar shows a neutral state instead of a failure. * fix(wsl): make the stopped-distro Retry a user act that may start the distro The indicator's click now runs the scan inside runAsWslUserAct for the repo's distro (new worktrees:scanStartingWslDistro IPC), then re-lists, so clicking it can start the distro instead of re-running the gated background scan that never could. --- ...ktrees-create-metadata-persistence.test.ts | 1 + src/main/ipc/worktrees.ts | 1 + .../listing/detected-provider-listing.ts | 28 +- .../register-detected-worktree-handlers.ts | 15 + .../listing/wsl-distro-starting-scan.ts | 25 ++ .../wsl-stopped-worktree-listing.test.ts | 261 ++++++++++++++++++ .../listing/wsl-stopped-worktree-listing.ts | 88 ++++++ .../repo-lifecycle-operations.ts | 3 + ...nce-invalid-workspace-layout-purge.test.ts | 9 + .../persistence-repo-lifecycle.test.ts | 16 ++ .../wsl-stopped-worktree-rescan-registry.ts | 30 ++ src/preload/api/worktree-api.ts | 2 + src/preload/api/worktrees-bridge.ts | 2 + .../RepoScanUnavailableIndicator.test.tsx | 47 ++++ .../rows/RepoScanUnavailableIndicator.tsx | 4 + .../rows/RepoWslDistroStoppedIndicator.tsx | 98 +++++++ src/renderer/src/i18n/locales/de.json | 7 +- src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/fr.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/pt.json | 7 +- src/renderer/src/i18n/locales/ru.json | 7 +- src/renderer/src/i18n/locales/tr.json | 7 +- src/renderer/src/i18n/locales/zh-Hant.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- .../worktrees-fetch-listing-merge.test.ts | 40 +++ .../listing/detected-worktree-host-merge.ts | 2 + .../listing/fetched-worktree-merge.ts | 4 +- .../listing/worktree-catalog-visibility.ts | 2 + src/shared/worktree/types.ts | 2 + 32 files changed, 742 insertions(+), 15 deletions(-) create mode 100644 src/main/ipc/worktrees/listing/wsl-distro-starting-scan.ts create mode 100644 src/main/ipc/worktrees/listing/wsl-stopped-worktree-listing.test.ts create mode 100644 src/main/ipc/worktrees/listing/wsl-stopped-worktree-listing.ts create mode 100644 src/main/wsl-stopped-worktree-rescan-registry.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/rows/RepoWslDistroStoppedIndicator.tsx diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index c23371569fd..ca8f820ab3d 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -28,6 +28,7 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:listKnownForExecutionHost', 'worktrees:forgetRemovedForExecutionHost', 'worktrees:cancelListDetected', + 'worktrees:scanStartingWslDistro', 'worktrees:create', 'worktrees:adoptProvisionedRoot', 'worktrees:prefetchCreateBase', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 902996934a2..d2cc8ce61b6 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -33,6 +33,7 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:listKnownForExecutionHost', 'worktrees:forgetRemovedForExecutionHost', 'worktrees:cancelListDetected', + 'worktrees:scanStartingWslDistro', 'worktrees:create', 'worktrees:adoptProvisionedRoot', 'worktrees:prefetchCreateBase', diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index d5167eb447f..1a1f7ea16ab 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -39,6 +39,12 @@ import { readAllWorktreeMetaForRepo } from '../../../persistence/host-qualified- import { classifyWorktreeScanFailure } from '../../../../shared/worktree-scan-failure' import { scanUntilNotOvertaken } from './overtaken-scan-rerun' import { withoutInFlightWorktreeCreates } from '../../../worktree-create-in-flight' +import { releaseWslStoppedWorktreeRescan } from '../../../wsl-stopped-worktree-rescan-registry' +import { + listWslStoppedWorktrees, + rescanWorktreesWhenWslDistroRuns, + wslDistroNotRunningForScanFailure +} from './wsl-stopped-worktree-listing' export async function listDetectedWorktreesForCapturedRepo( store: Store, @@ -153,6 +159,7 @@ export async function listDetectedWorktreesForCapturedRepo( } } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + releaseWslStoppedWorktreeRescan(repo.id) // Rows describe scan-start generation, which clients order against applied create/remove replies. return { repoId: repo.id, @@ -174,15 +181,30 @@ export async function listDetectedWorktreesForCapturedRepo( if (!isCurrent()) { return null } + // Retained rows need their scan-failure cause to explain why they are unavailable. + const unavailableReason = describeWorktreeScanFailure(err) + const failureKind = classifyWorktreeScanFailure(unavailableReason) + // A distro the gate would not start (E9) is expected: keep stored rows and re-list once it runs. + const wslDistroStopped = wslDistroNotRunningForScanFailure(err) + if (wslDistroStopped) { + rescanWorktreesWhenWslDistroRuns(repo.id, wslDistroStopped.distro) + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: listWslStoppedWorktrees(store, repo, allMeta), + unavailableReason, + failureKind, + wslDistroStopped, + catalogVersion: getLocalWorktreeCatalogVersion(repo.id) + } + } warnOnce( loggedWorktreeListFailures, `${repo.id}:${repo.path}`, `[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, err ) - // Retained rows need their scan-failure cause to explain why they are unavailable. - const unavailableReason = describeWorktreeScanFailure(err) - const failureKind = classifyWorktreeScanFailure(unavailableReason) return { repoId: repo.id, authoritative: false, diff --git a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts index edd8089cee4..ba2ea62e92e 100644 --- a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts @@ -21,11 +21,17 @@ import { findExactRepoOwner, isCapturedRepoCurrent } from './worktree-host-owner import type { WorktreeIpcContext } from '../worktree-ipc-context' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { dropWorkspacesTheListingInvalidates } from '../../../persistence/restoring-sessions/invalid-workspace-layout-purge' +import { notifyWatchedWorktreeCatalogChanged } from '../../watched-worktree-catalog-notification' +import { setWslStoppedWorktreeRescanNotifier } from './wsl-stopped-worktree-listing' +import { scanWorktreesStartingWslDistro } from './wsl-distro-starting-scan' export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000 export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): void { const { store, detectedWorktreeCancellations } = context + setWslStoppedWorktreeRescanNotifier((repoId) => + notifyWatchedWorktreeCatalogChanged(context.mainWindow, repoId) + ) ipcMain.handle( 'worktrees:listDetected', @@ -125,6 +131,15 @@ export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): v } ) + ipcMain.handle( + 'worktrees:scanStartingWslDistro', + async (_event, args: { repoId: string }): Promise => { + if (typeof args?.repoId === 'string') { + await scanWorktreesStartingWslDistro(store, args.repoId) + } + } + ) + ipcMain.handle( 'worktrees:cancelListDetected', (event, args: { providerRequestId: ProviderRequestId }): void => { diff --git a/src/main/ipc/worktrees/listing/wsl-distro-starting-scan.ts b/src/main/ipc/worktrees/listing/wsl-distro-starting-scan.ts new file mode 100644 index 00000000000..bf48765019a --- /dev/null +++ b/src/main/ipc/worktrees/listing/wsl-distro-starting-scan.ts @@ -0,0 +1,25 @@ +import type { Store } from '../../../persistence/loading-store/store' +import { getRepoDialableSshTargetId } from '../../../../shared/execution-host' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { runAsWslUserAct, wslDistroForPath } from '../../../wsl/wsl-unc-access-gate' +import { listDetectedWorktreesForCapturedRepo } from './detected-provider-listing' +import { findExactRepoOwner, isCapturedRepoCurrent } from './worktree-host-ownership' + +/** A clicked Retry is a user act (E9): the scan may start the repo's stopped distro. Callers re-list after. */ +export async function scanWorktreesStartingWslDistro(store: Store, repoId: string): Promise { + const repo = findExactRepoOwner(store, repoId) + if (!repo || isFolderRepo(repo) || getRepoDialableSshTargetId(repo)) { + return + } + const distro = wslDistroForPath( + repo.path, + getLocalProjectWorktreeGitOptions(store, repo).wslDistro + ) + if (!distro) { + return + } + await runAsWslUserAct(distro, () => + listDetectedWorktreesForCapturedRepo(store, repo, () => isCapturedRepoCurrent(store, repo)) + ) +} diff --git a/src/main/ipc/worktrees/listing/wsl-stopped-worktree-listing.test.ts b/src/main/ipc/worktrees/listing/wsl-stopped-worktree-listing.test.ts new file mode 100644 index 00000000000..6812728c857 --- /dev/null +++ b/src/main/ipc/worktrees/listing/wsl-stopped-worktree-listing.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types' +import type { Store } from '../../../persistence/loading-store/store' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' + +const mocks = vi.hoisted(() => ({ listDetectedGitWorktrees: vi.fn(), runProcess: vi.fn() })) + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn(), removeHandler: vi.fn() }, + app: { getPath: () => '/tmp/orca-test' } +})) +vi.mock('./detected-worktree-scan-cache', async (importOriginal) => ({ + ...(await importOriginal()), + listDetectedGitWorktrees: mocks.listDetectedGitWorktrees +})) +// Any `wsl --list --running` probe would land here; a deferred re-scan must never make one. +vi.mock('@orca/process-host', async (importOriginal) => ({ + ...(await importOriginal()), + runProcess: mocks.runProcess +})) + +const { listDetectedWorktreesForCapturedRepo } = await import('./detected-provider-listing') +const { setWslStoppedWorktreeRescanNotifier } = await import('./wsl-stopped-worktree-listing') +const { WslDistroStoppedError, WslDistroUnverifiableError } = + await import('../../../wsl/wsl-unc-access-gate') +const { _resetWslBackgroundDistroGateForTests, noteWslDistroWoken, noteWslDistrosRunning } = + await import('../../../wsl-background-distro-gate') +const { scanWorktreesStartingWslDistro } = await import('./wsl-distro-starting-scan') +const { isWslUserActFor } = await import('../../../wsl/wsl-user-act-scope') +const { _pendingWslStoppedWorktreeRescanCount, _resetWslStoppedWorktreeRescansForTests } = + await import('../../../wsl-stopped-worktree-rescan-registry') + +const REPO_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' +const FEATURE_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\me\\feature' + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: REPO_PATH, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + ...overrides + } +} + +function createStore(repo: Repo, meta: Record> = {}): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the listing reads only these Store members. + return { + getRepo: () => repo, + getRepos: () => [repo], + getProjects: () => [], + getSettings: () => ({}), + getAllWorktreeMeta: () => meta, + getProjectHostSetups: () => [], + getWorktreeMeta: (id: string) => meta[id], + setWorktreeMeta: vi.fn((_target: unknown, updates: Partial) => updates), + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}), + removeWorktreeLineage: vi.fn() + } as unknown as Store +} + +async function list(store: Store, repo: Repo): Promise { + const result = await listDetectedWorktreesForCapturedRepo(store, repo, () => true) + if (!result || 'providerAbortStatus' in result) { + throw new Error('expected a listing') + } + return result +} + +const notify = vi.fn() + +describe('worktree listing for a stopped WSL distro (E9)', () => { + beforeEach(() => { + mocks.listDetectedGitWorktrees.mockReset() + mocks.runProcess.mockReset() + notify.mockReset() + setWslStoppedWorktreeRescanNotifier(notify) + }) + + afterEach(() => { + setWslStoppedWorktreeRescanNotifier(null) + _resetWslStoppedWorktreeRescansForTests() + _resetWslBackgroundDistroGateForTests() + }) + + it('keeps the stored rows, non-authoritative, and says the distro is stopped', async () => { + const repo = makeRepo() + mocks.listDetectedGitWorktrees.mockRejectedValue(new WslDistroStoppedError('Ubuntu')) + + const result = await list( + createStore(repo, { [`${repo.id}::${FEATURE_PATH}`]: { displayName: 'feature' } }), + repo + ) + + expect(result.authoritative).toBe(false) + expect(result.source).toBe('metadata-fallback') + expect(result.wslDistroStopped).toEqual({ distro: 'Ubuntu', state: 'stopped' }) + expect(result.unavailableReason).toContain("WSL distro 'Ubuntu' is not running") + // The main checkout always exists, stored or not. + expect(result.worktrees.map((worktree) => [worktree.path, worktree.isMainWorktree])).toEqual([ + [REPO_PATH, true], + [FEATURE_PATH, false] + ]) + }) + + it('names an unverifiable distro as such', async () => { + const repo = makeRepo() + mocks.listDetectedGitWorktrees.mockRejectedValue(new WslDistroUnverifiableError('Ubuntu')) + + const result = await list(createStore(repo), repo) + + expect(result.authoritative).toBe(false) + expect(result.wslDistroStopped).toEqual({ distro: 'Ubuntu', state: 'unverifiable' }) + expect(result.worktrees.map((worktree) => worktree.path)).toEqual([REPO_PATH]) + }) + + it('leaves other scan failures as they were', async () => { + const repo = makeRepo() + mocks.listDetectedGitWorktrees.mockRejectedValue(new Error('Command failed: wsl.exe')) + + const result = await list(createStore(repo), repo) + + expect(result.wslDistroStopped).toBeUndefined() + expect(result.worktrees).toEqual([]) + expect(_pendingWslStoppedWorktreeRescanCount()).toBe(0) + }) + + it('re-lists once on the next settled running read, without probing', async () => { + const repo = makeRepo() + const store = createStore(repo) + mocks.listDetectedGitWorktrees.mockRejectedValue(new WslDistroStoppedError('Ubuntu')) + + await list(store, repo) + await list(store, repo) + expect(_pendingWslStoppedWorktreeRescanCount()).toBe(1) + + noteWslDistrosRunning(['Debian']) + await Promise.resolve() + expect(notify).not.toHaveBeenCalled() + + noteWslDistrosRunning(['ubuntu']) + await Promise.resolve() + expect(notify).toHaveBeenCalledExactlyOnceWith(repo.id) + expect(_pendingWslStoppedWorktreeRescanCount()).toBe(0) + expect(mocks.runProcess).not.toHaveBeenCalled() + + // Drained: a later wake does not re-list again. + noteWslDistroWoken('Ubuntu') + expect(notify).toHaveBeenCalledOnce() + }) + + it('re-lists when a user act wakes the distro', async () => { + const repo = makeRepo() + mocks.listDetectedGitWorktrees.mockRejectedValue(new WslDistroStoppedError('Ubuntu')) + await list(createStore(repo), repo) + + noteWslDistroWoken('Debian') + expect(notify).not.toHaveBeenCalled() + noteWslDistroWoken('Ubuntu') + + expect(notify).toHaveBeenCalledExactlyOnceWith(repo.id) + expect(_pendingWslStoppedWorktreeRescanCount()).toBe(0) + noteWslDistrosRunning(['Ubuntu']) + await Promise.resolve() + expect(notify).toHaveBeenCalledOnce() + expect(mocks.runProcess).not.toHaveBeenCalled() + }) + + it('drops the pending re-scan once a scan succeeds', async () => { + const repo = makeRepo() + const store = createStore(repo) + mocks.listDetectedGitWorktrees.mockRejectedValueOnce(new WslDistroStoppedError('Ubuntu')) + await list(store, repo) + mocks.listDetectedGitWorktrees.mockResolvedValueOnce({ + gitWorktrees: [ + { + path: REPO_PATH, + head: 'abc', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ], + fresh: false, + superseded: false, + generation: 1 + }) + + const result = await list(store, repo) + + expect(result.authoritative).toBe(true) + expect(result.wslDistroStopped).toBeUndefined() + expect(_pendingWslStoppedWorktreeRescanCount()).toBe(0) + }) + + it('lists a folder workspace on a WSL path as before', async () => { + const repo = makeRepo({ kind: 'folder' }) + + const result = await list(createStore(repo), repo) + + expect(result.authoritative).toBe(true) + expect(result.wslDistroStopped).toBeUndefined() + expect(mocks.listDetectedGitWorktrees).not.toHaveBeenCalled() + expect(_pendingWslStoppedWorktreeRescanCount()).toBe(0) + }) +}) + +describe('Retry on a stopped WSL distro (E9)', () => { + let originalPlatform: NodeJS.Platform + + beforeEach(() => { + originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mocks.listDetectedGitWorktrees.mockReset() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + _resetWslStoppedWorktreeRescansForTests() + _resetWslBackgroundDistroGateForTests() + }) + + it("scans as a user act on the repo's distro, so the scan may start it", async () => { + const repo = makeRepo() + const insideAct: boolean[] = [] + mocks.listDetectedGitWorktrees.mockImplementation(async () => { + insideAct.push(isWslUserActFor('Ubuntu')) + return { + gitWorktrees: [ + { + path: REPO_PATH, + head: 'abc', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ], + fresh: false, + superseded: false, + generation: 1 + } + }) + + await scanWorktreesStartingWslDistro(createStore(repo), repo.id) + + expect(insideAct).toEqual([true]) + expect(isWslUserActFor('Ubuntu')).toBe(false) + }) + + it('does nothing for a folder workspace or a repo outside WSL', async () => { + for (const repo of [makeRepo({ kind: 'folder' }), makeRepo({ path: 'C:\\work\\repo' })]) { + await scanWorktreesStartingWslDistro(createStore(repo), repo.id) + } + await scanWorktreesStartingWslDistro(createStore(makeRepo()), 'unknown-repo') + + expect(mocks.listDetectedGitWorktrees).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/worktrees/listing/wsl-stopped-worktree-listing.ts b/src/main/ipc/worktrees/listing/wsl-stopped-worktree-listing.ts new file mode 100644 index 00000000000..e06feedb7d8 --- /dev/null +++ b/src/main/ipc/worktrees/listing/wsl-stopped-worktree-listing.ts @@ -0,0 +1,88 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { + DetectedWorktree, + DetectedWorktreeListResult +} from '../../../../shared/worktree/types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { Store } from '../../../persistence/loading-store/store' +import { areWorktreePathsEqual } from '../../../git/worktree-path-comparison' +import { + deferUntilWslDistroRunning, + onWslDistroGateNote +} from '../../../wsl-background-distro-gate' +import { WslDistroStoppedError, WslDistroUnverifiableError } from '../../../wsl/wsl-unc-access-gate' +import { + holdWslStoppedWorktreeRescan, + releaseWslStoppedWorktreeRescan +} from '../../../wsl-stopped-worktree-rescan-registry' +import { mergeWorktree } from '../../worktree-metadata-merge' +import { buildDisconnectedDetectedWorktrees } from './folder-workspace-catalog' +import { + createSshWorktreeMetaIndexForRepo, + listDisconnectedSshWorktrees +} from './ssh-worktree-fallback' + +export type WslDistroNotRunning = NonNullable + +let notifyRepoWorktreesChanged: ((repoId: string) => void) | null = null + +export function setWslStoppedWorktreeRescanNotifier( + notify: ((repoId: string) => void) | null +): void { + notifyRepoWorktreesChanged = notify +} + +export function wslDistroNotRunningForScanFailure(error: unknown): WslDistroNotRunning | null { + if (error instanceof WslDistroStoppedError) { + return { distro: error.distro, state: 'stopped' } + } + if (error instanceof WslDistroUnverifiableError) { + return { distro: error.distro, state: 'unverifiable' } + } + return null +} + +/** Stored rows for a repo whose distro the gate refused to start; the main checkout always exists. */ +export function listWslStoppedWorktrees( + store: Store, + repo: Repo, + allMeta: Record | undefined +): DetectedWorktree[] { + const known = listDisconnectedSshWorktrees( + store, + repo, + createSshWorktreeMetaIndexForRepo(allMeta ?? {}, repo.id) + ) + const worktrees = known.some((worktree) => areWorktreePathsEqual(worktree.path, repo.path)) + ? known + : [ + mergeWorktree( + repo.id, + { path: repo.path, head: '', branch: '', isBare: false, isMainWorktree: true }, + undefined, + repo.displayName + ), + ...known + ] + return buildDisconnectedDetectedWorktrees(store, repo, worktrees) +} + +/** Re-list on a wake or a settled running read; never probes, and holds one pending re-scan per repo. */ +export function rescanWorktreesWhenWslDistroRuns(repoId: string, distro: string): void { + const wanted = distro.trim().toLowerCase() + const rescan = (): void => { + releaseWslStoppedWorktreeRescan(repoId, cancel) + notifyRepoWorktreesChanged?.(repoId) + } + const cancelDeferred = deferUntilWslDistroRunning(distro, `worktree-scan:${repoId}`, rescan) + const unsubscribeNotes = onWslDistroGateNote((note) => { + if (note.kind === 'woken' && note.distro.trim().toLowerCase() === wanted) { + rescan() + } + }) + const cancel = (): void => { + cancelDeferred() + unsubscribeNotes() + } + holdWslStoppedWorktreeRescan(repoId, cancel) +} diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index c95aa92ba74..d3c49c0b953 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -17,6 +17,7 @@ import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-ope import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' import { collectDeregisteredRepoIds } from '../tracking-repos/deregistered-repo-residue' import { retireLocalWorktreeMetadataPruneStateForRepo } from '../../local-worktree-metadata-prune-gate' +import { releaseWslStoppedWorktreeRescan } from '../../wsl-stopped-worktree-rescan-registry' import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' @@ -100,6 +101,7 @@ export class RepoLifecycleOperations { ) if (!idStillPresent) { retireLocalWorktreeScanGeneration(id) + releaseWslStoppedWorktreeRescan(id) dropRepoRestoreHints(this, id) delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] @@ -140,6 +142,7 @@ export class RepoLifecycleOperations { } for (const repoId of orphanRepoIds) { retireLocalWorktreeScanGeneration(repoId) + releaseWslStoppedWorktreeRescan(repoId) dropRepoRestoreHints(this, repoId) pruneWorktreeStateForRepo(this, repoId, null) state.workspaceSession = removeLocalRepoSession(state.workspaceSession, repoId) diff --git a/src/main/persistence/tracking-repos/persistence-invalid-workspace-layout-purge.test.ts b/src/main/persistence/tracking-repos/persistence-invalid-workspace-layout-purge.test.ts index 2b44717bec2..0980de7882d 100644 --- a/src/main/persistence/tracking-repos/persistence-invalid-workspace-layout-purge.test.ts +++ b/src/main/persistence/tracking-repos/persistence-invalid-workspace-layout-purge.test.ts @@ -194,6 +194,15 @@ describe('main drops the layout of workspaces the catalog no longer validates', store, listing([KEPT], { authoritative: false, source: 'metadata-fallback' }) ) + // Stored rows from a stopped WSL distro are not a scan either. + dropWorkspacesTheListingInvalidates( + store, + listing([KEPT], { + authoritative: false, + source: 'metadata-fallback', + wslDistroStopped: { distro: 'Ubuntu', state: 'stopped' } + }) + ) dropWorkspacesTheListingInvalidates(store, null) dropWorkspacesTheListingInvalidates(store, { providerAbortStatus: 'timed-out' }) diff --git a/src/main/persistence/tracking-repos/persistence-repo-lifecycle.test.ts b/src/main/persistence/tracking-repos/persistence-repo-lifecycle.test.ts index 050e7ab3559..adb0604b207 100644 --- a/src/main/persistence/tracking-repos/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence/tracking-repos/persistence-repo-lifecycle.test.ts @@ -29,6 +29,10 @@ import { isLocalWorktreeScanGenerationCurrent } from '../../local-worktree-scan-generation' import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' +import { + _pendingWslStoppedWorktreeRescanCount, + holdWslStoppedWorktreeRescan +} from '../../wsl-stopped-worktree-rescan-registry' import type { ExecutionHostId } from '../../../shared/execution-host' const at = (id: string, hostId: ExecutionHostId) => hostWorktreeTarget({ id, hostId }) @@ -124,6 +128,18 @@ describe('Store', () => { expect(_getLocalWorktreeScanGenerationCacheSize()).toBe(initialCacheSize) }) + it('drops a pending stopped-WSL worktree re-scan when its repo is removed', async () => { + const store = await createStore() + const cancel = vi.fn() + store.addRepo(makeRepo({ id: 'wsl-repo' })) + holdWslStoppedWorktreeRescan('wsl-repo', cancel) + + store.removeProjectForHost({ id: 'wsl-repo', executionHostId: 'local' }) + + expect(cancel).toHaveBeenCalledOnce() + expect(_pendingWslStoppedWorktreeRescanCount()).toBe(0) + }) + it('setResolvedRepoGitUsername persists the enriched username for hydration', async () => { const store = await createStore() store.addRepo(makeRepo()) diff --git a/src/main/wsl-stopped-worktree-rescan-registry.ts b/src/main/wsl-stopped-worktree-rescan-registry.ts new file mode 100644 index 00000000000..dca36beb19d --- /dev/null +++ b/src/main/wsl-stopped-worktree-rescan-registry.ts @@ -0,0 +1,30 @@ +// Dependency-free so repo removal in persistence can drop a pending re-scan without loading the WSL gate. +const pendingByRepoId = new Map void>() + +/** One pending re-scan per repo: a newer hold cancels the older one. */ +export function holdWslStoppedWorktreeRescan(repoId: string, cancel: () => void): void { + const previous = pendingByRepoId.get(repoId) + pendingByRepoId.set(repoId, cancel) + previous?.() +} + +/** `cancel` releases only that hold, so a stale callback cannot drop a newer one. */ +export function releaseWslStoppedWorktreeRescan(repoId: string, cancel?: () => void): void { + const current = pendingByRepoId.get(repoId) + if (!current || (cancel && current !== cancel)) { + return + } + pendingByRepoId.delete(repoId) + current() +} + +export function _pendingWslStoppedWorktreeRescanCount(): number { + return pendingByRepoId.size +} + +export function _resetWslStoppedWorktreeRescansForTests(): void { + for (const cancel of pendingByRepoId.values()) { + cancel() + } + pendingByRepoId.clear() +} diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index d0fc10a746f..62c18ce713a 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -59,6 +59,8 @@ export type WorktreeApi = { args: ForgetRemovedWorktreesForExecutionHostArgs ) => Promise cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise + /** User act: scan a local repo inside its WSL distro's user-act scope, which may start it. */ + scanStartingWslDistro?: (args: { repoId: string }) => Promise create: (args: CreateWorktreeArgs) => Promise adoptProvisionedRoot: (args: AdoptProvisionedRootArgs) => Promise /** Local creates report progress by creationId; remote/runtime creates emit nothing. */ diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts index 44692d18209..eea50c704d0 100644 --- a/src/preload/api/worktrees-bridge.ts +++ b/src/preload/api/worktrees-bridge.ts @@ -23,6 +23,8 @@ export const worktreesApi = { cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), + scanStartingWslDistro: (args) => ipcRenderer.invoke('worktrees:scanStartingWslDistro', args), + create: (args) => ipcRenderer.invoke('worktrees:create', args), adoptProvisionedRoot: (args) => ipcRenderer.invoke('worktrees:adoptProvisionedRoot', args), diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.test.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.test.tsx index 9245e0e8842..234cb6c86ec 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.test.tsx @@ -98,4 +98,51 @@ describe('RepoScanUnavailableIndicator', () => { }) expect(fetchWorktrees).toHaveBeenCalledWith(repo.id, { executionHostId: 'local' }) }) + + it.each([ + ['stopped', 'WSL distro Ubuntu is stopped'], + ['unverifiable', "Couldn't confirm WSL distro Ubuntu is running"] + ] as const)( + 'shows a %s WSL distro as a neutral state whose click starts it and scans', + async (state, title) => { + const calls: string[] = [] + const fetchWorktrees = vi.fn(async () => { + calls.push('fetch') + return true + }) + const scanStartingWslDistro = vi.fn(async () => { + calls.push('start') + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { worktrees: { scanStartingWslDistro } } + }) + useAppStore.setState({ + fetchWorktrees: fetchWorktrees as never, + detectedWorktreesByRepo: { + [repo.id]: makeDetectedResult(repo.id, [], { + authoritative: false, + source: 'metadata-fallback', + unavailableReason: "WSL distro 'Ubuntu' is not running.", + wslDistroStopped: { distro: 'Ubuntu', state } + }) + } + }) + + const container = await render() + const button = container.querySelector('button') + + expect(button?.getAttribute('aria-label')).toBe(`${title}. Start distro and scan`) + expect(button?.className).toContain('text-muted-foreground') + expect(button?.className).not.toContain('text-destructive') + await act(async () => { + button?.click() + }) + // The click is the user act that may start the distro; the re-list follows it. + expect(scanStartingWslDistro).toHaveBeenCalledWith({ repoId: repo.id }) + expect(fetchWorktrees).toHaveBeenCalledWith(repo.id, { executionHostId: 'local' }) + expect(calls).toEqual(['start', 'fetch']) + Reflect.deleteProperty(window, 'api') + } + ) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.tsx index 21b3b236f46..589093d1560 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.tsx @@ -12,6 +12,7 @@ import { stopRepoHeaderKeyboardToggle } from './header-event-guards' import { selectDetectedWorktreesByRepo } from '@/store/workspace-record-selectors' +import { RepoWslDistroStoppedIndicator } from './RepoWslDistroStoppedIndicator' /** Retain failed-scan rows with a warning and explicit retry instead of waiting for an incidental refresh. */ export function RepoScanUnavailableIndicator({ repo }: { repo: Repo }): React.JSX.Element | null { @@ -19,6 +20,9 @@ export function RepoScanUnavailableIndicator({ repo }: { repo: Repo }): React.JS const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) const [pending, setPending] = React.useState(false) const failure = resolveRepoScanFailure(repo, detected) + if (detected && !detected.authoritative && detected.wslDistroStopped) { + return + } // Why: machine-wide failures are explained once by the sidebar banner, not on every repo. if (!failure || isLocalToolchainFailure(failure)) { return null diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/RepoWslDistroStoppedIndicator.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/RepoWslDistroStoppedIndicator.tsx new file mode 100644 index 00000000000..924f5d2d918 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/RepoWslDistroStoppedIndicator.tsx @@ -0,0 +1,98 @@ +import React from 'react' +import { PowerOff } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import type { Repo } from '../../../../../../shared/repo-types' +import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' +import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' +import { + handleRepoHeaderActionPointerDown, + stopRepoHeaderKeyboardToggle +} from './header-event-guards' + +type WslDistroNotRunning = NonNullable + +/** A stopped distro is a state, not a failure: neutral, with stored rows and a click that may start it. */ +export function RepoWslDistroStoppedIndicator({ + repo, + notRunning +}: { + repo: Repo + notRunning: WslDistroNotRunning +}): React.JSX.Element { + const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const [pending, setPending] = React.useState(false) + const title = + notRunning.state === 'stopped' + ? translate( + 'auto.components.sidebar.RepoScanUnavailableIndicator.wslStoppedTitle', + 'WSL distro {{value0}} is stopped', + { value0: notRunning.distro } + ) + : translate( + 'auto.components.sidebar.RepoScanUnavailableIndicator.wslUnverifiableTitle', + "Couldn't confirm WSL distro {{value0}} is running", + { value0: notRunning.distro } + ) + // The click is a user act, so this scan may start the distro; a failed start still re-lists. + const startAndScan = async (): Promise => { + try { + await window.api.worktrees.scanStartingWslDistro?.({ repoId: repo.id }) + } catch { + // The re-list below reports the distro's state. + } + await fetchWorktrees(repo.id, { executionHostId: getRepoExecutionHostId(repo) }) + } + const startAndScanLabel = translate( + 'auto.components.sidebar.RepoScanUnavailableIndicator.wslStartAndScan', + 'Start distro and scan' + ) + return ( + + + + + + +
+
{title}
+
+ {translate( + 'auto.components.sidebar.RepoScanUnavailableIndicator.wslRetained', + 'Showing the worktrees Orca already knew about. They refresh when Orca sees the distro running.' + )} +
+
+ {translate( + 'auto.components.sidebar.RepoScanUnavailableIndicator.wslStartAndScanHint', + 'Click the icon to start the distro and scan again.' + )} +
+
+
+
+
+ ) +} diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index c2ddf2a471d..0fa959a399d 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -7076,7 +7076,12 @@ "xcodeLicense": "Die Apple-Entwicklerwerkzeuge erfordern die Annahme der Lizenz, bevor Git ausgeführt werden kann.", "developerTools": "Die Apple-Entwicklerwerkzeuge für die Befehlszeile fehlen oder sind nicht verfügbar.", "architectureMismatch": "Eine zu Git gehörende ausführbare Datei konnte nicht ausgeführt werden, da ihre CPU-Architektur mit diesem Ausführungshost inkompatibel ist. Git und zugehörige Werkzeuge für die Architektur des Hosts installieren.", - "copyDiagnostics": "Diagnosedaten kopieren" + "copyDiagnostics": "Diagnosedaten kopieren", + "wslStoppedTitle": "WSL-Distribution {{value0}} ist gestoppt", + "wslUnverifiableTitle": "Konnte nicht bestätigen, dass die WSL-Distribution {{value0}} läuft", + "wslRetained": "Angezeigt werden die Worktrees, die Orca bereits kannte. Sie werden aktualisiert, sobald Orca die Distribution laufen sieht.", + "wslStartAndScan": "Distribution starten und scannen", + "wslStartAndScanHint": "Klicke auf das Symbol, um die Distribution zu starten und erneut zu scannen." }, "LocalGitToolchainScanBanner": { "xcodeLicenseTitle": "Xcode-Lizenz akzeptieren, um Git zu verwenden", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b250a0bb90e..8f771f265d5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7126,7 +7126,12 @@ "xcodeLicense": "Apple developer tools require license acceptance before Git can run.", "developerTools": "Apple command-line developer tools are missing or unavailable.", "architectureMismatch": "A Git-related executable could not run because its CPU architecture is incompatible with this execution host. Install Git and related tools for the host architecture.", - "copyDiagnostics": "Copy diagnostics" + "copyDiagnostics": "Copy diagnostics", + "wslStoppedTitle": "WSL distro {{value0}} is stopped", + "wslUnverifiableTitle": "Couldn't confirm WSL distro {{value0}} is running", + "wslRetained": "Showing the worktrees Orca already knew about. They refresh when Orca sees the distro running.", + "wslStartAndScan": "Start distro and scan", + "wslStartAndScanHint": "Click the icon to start the distro and scan again." }, "LocalGitToolchainScanBanner": { "xcodeLicenseTitle": "Accept the Xcode license to use Git", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 78e092680bc..cefec94110f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6708,7 +6708,12 @@ "xcodeLicense": "Las herramientas de desarrollo de Apple requieren la aceptación de la licencia antes de que Git pueda ejecutarse.", "developerTools": "Las herramientas de desarrollo de línea de comandos de Apple faltan o no están disponibles.", "architectureMismatch": "No se pudo ejecutar un ejecutable relacionado con Git porque su arquitectura de CPU es incompatible con este host de ejecución. Instale Git y herramientas relacionadas para la arquitectura del host.", - "copyDiagnostics": "Copiar diagnóstico" + "copyDiagnostics": "Copiar diagnóstico", + "wslStoppedTitle": "La distribución WSL {{value0}} está detenida", + "wslUnverifiableTitle": "No se pudo confirmar que la distribución WSL {{value0}} esté en ejecución", + "wslRetained": "Se muestran los worktrees que Orca ya conocía. Se actualizan cuando Orca detecta la distribución en ejecución.", + "wslStartAndScan": "Iniciar distribución y escanear", + "wslStartAndScanHint": "Haz clic en el icono para iniciar la distribución y volver a escanear." }, "manualSortSwitch": { "title": "Orden cambiado a Manual", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 43b585c934b..3c225f85307 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -6918,7 +6918,12 @@ "xcodeLicense": "Les outils de développement Apple nécessitent l’acceptation d’une licence avant que Git puisse s’exécuter.", "developerTools": "Les outils de développement en ligne de commande Apple sont manquants ou indisponibles.", "architectureMismatch": "Un exécutable lié à Git n'a pas pu s'exécuter car son architecture CPU est incompatible avec cet hôte d'exécution. Installez Git et les outils associés pour l'architecture hôte.", - "copyDiagnostics": "Copier les diagnostics" + "copyDiagnostics": "Copier les diagnostics", + "wslStoppedTitle": "La distribution WSL {{value0}} est arrêtée", + "wslUnverifiableTitle": "Impossible de confirmer que la distribution WSL {{value0}} est en cours d'exécution", + "wslRetained": "Affichage des worktrees déjà connus d'Orca. Ils sont actualisés dès qu'Orca voit la distribution en cours d'exécution.", + "wslStartAndScan": "Démarrer la distribution et analyser", + "wslStartAndScanHint": "Cliquez sur l'icône pour démarrer la distribution et relancer l'analyse." }, "LocalGitToolchainScanBanner": { "xcodeLicenseTitle": "Acceptez la licence Xcode pour utiliser Git", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a113a54b423..6710ac8e5c0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6440,7 +6440,12 @@ "xcodeLicense": "Apple 開発者ツールは、Git を実行する前にライセンスへの同意が必要です。", "developerTools": "Apple コマンドライン開発者ツールが見つからないか、使用できません。", "architectureMismatch": "Git 関連の実行可能ファイルは、CPU アーキテクチャがこの実行ホストと互換性がないため、実行できませんでした。ホストアーキテクチャ用の Git と関連ツールをインストールします。", - "copyDiagnostics": "コピー診断" + "copyDiagnostics": "コピー診断", + "wslStoppedTitle": "WSL ディストリビューション {{value0}} は停止しています", + "wslUnverifiableTitle": "WSL ディストリビューション {{value0}} が実行中か確認できませんでした", + "wslRetained": "Orca が把握済みのワークツリーを表示しています。ディストリビューションの実行を Orca が確認すると更新されます。", + "wslStartAndScan": "ディストリビューションを起動してスキャン", + "wslStartAndScanHint": "アイコンをクリックすると、ディストリビューションを起動して再スキャンします。" }, "LocalGitToolchainScanBanner": { "xcodeLicenseTitle": "Git を使うには Xcode ライセンスに同意してください", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8075b7fff3e..4e6c654293d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6440,7 +6440,12 @@ "xcodeLicense": "Apple 개발자 도구를 사용하려면 Git을 실행하기 전에 라이선스 동의가 필요합니다.", "developerTools": "Apple 명령줄 개발자 도구가 없거나 사용할 수 없습니다.", "architectureMismatch": "CPU 아키텍처가 이 실행 호스트와 호환되지 않기 때문에 Git 관련 실행 파일을 실행할 수 없습니다. 호스트 아키텍처용 Git 및 관련 도구를 설치합니다.", - "copyDiagnostics": "진단 복사" + "copyDiagnostics": "진단 복사", + "wslStoppedTitle": "WSL 배포판 {{value0}}이(가) 중지되었습니다", + "wslUnverifiableTitle": "WSL 배포판 {{value0}}이(가) 실행 중인지 확인할 수 없습니다", + "wslRetained": "Orca가 이미 알고 있던 워크트리를 표시합니다. Orca가 배포판 실행을 확인하면 새로 고쳐집니다.", + "wslStartAndScan": "배포판 시작 및 스캔", + "wslStartAndScanHint": "아이콘을 클릭하면 배포판을 시작하고 다시 스캔합니다." }, "LocalGitToolchainScanBanner": { "xcodeLicenseTitle": "Git을 사용하려면 Xcode 라이선스에 동의하세요", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 2e8eac2a3ea..f822508d3e4 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -11873,7 +11873,12 @@ "xcodeLicense": "As ferramentas de desenvolvedor da Apple exigem aceitação de licença antes que o Git possa ser executado.", "developerTools": "As ferramentas de linha de comando para desenvolvedores da Apple estão faltando ou indisponíveis.", "architectureMismatch": "Um executável relacionado ao Git não pôde ser executado porque sua arquitetura de CPU é incompatível com este host de execução. Instale o Git e as ferramentas relacionadas para a arquitetura do host.", - "copyDiagnostics": "Copiar diagnósticos" + "copyDiagnostics": "Copiar diagnósticos", + "wslStoppedTitle": "A distribuição WSL {{value0}} está parada", + "wslUnverifiableTitle": "Não foi possível confirmar que a distribuição WSL {{value0}} está em execução", + "wslRetained": "Mostrando as worktrees que o Orca já conhecia. Elas são atualizadas quando o Orca vê a distribuição em execução.", + "wslStartAndScan": "Iniciar distribuição e verificar", + "wslStartAndScanHint": "Clique no ícone para iniciar a distribuição e verificar novamente." }, "AutoRenameFailedDialog": { "aed1623b1e": "Fechar", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index bee849a6bd4..2a99bbe225b 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -7404,7 +7404,12 @@ "xcodeLicense": "Для работы Git нужно принять лицензию инструментов разработки Apple.", "developerTools": "Инструменты командной строки Apple для разработки отсутствуют или недоступны.", "architectureMismatch": "Не удалось запустить исполняемый файл, связанный с Git: его архитектура несовместима с архитектурой процессора этого сервера. Установите Git и связанные инструменты для архитектуры этого сервера.", - "copyDiagnostics": "Копировать диагностику" + "copyDiagnostics": "Копировать диагностику", + "wslStoppedTitle": "Дистрибутив WSL {{value0}} остановлен", + "wslUnverifiableTitle": "Не удалось подтвердить, что дистрибутив WSL {{value0}} запущен", + "wslRetained": "Показаны рабочие деревья, уже известные Orca. Они обновятся, когда Orca увидит, что дистрибутив запущен.", + "wslStartAndScan": "Запустить дистрибутив и сканировать", + "wslStartAndScanHint": "Нажмите на значок, чтобы запустить дистрибутив и выполнить сканирование снова." }, "LocalGitToolchainScanBanner": { "xcodeLicenseTitle": "Примите лицензию Xcode для работы Git", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index f7063e33d34..a779100a05f 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -7062,7 +7062,12 @@ "xcodeLicense": "Git'in çalışabilmesi için önce Apple geliştirici araçlarının lisansını kabul etmeniz gerekir.", "developerTools": "Apple komut satırı geliştirici araçları eksik veya kullanılamıyor.", "architectureMismatch": "Bir Git ile ilgili çalıştırılabilir dosya, CPU mimarisi bu çalıştırma host'uyla uyumsuz olduğu için çalıştırılamadı. Git ve ilgili araçları host mimarisi için yükleyin.", - "copyDiagnostics": "Tanılamayı kopyala" + "copyDiagnostics": "Tanılamayı kopyala", + "wslStoppedTitle": "WSL dağıtımı {{value0}} durduruldu", + "wslUnverifiableTitle": "WSL dağıtımı {{value0}} öğesinin çalıştığı doğrulanamadı", + "wslRetained": "Orca'nın zaten bildiği worktree'ler gösteriliyor. Orca dağıtımın çalıştığını gördüğünde yenilenirler.", + "wslStartAndScan": "Dağıtımı başlat ve tara", + "wslStartAndScanHint": "Dağıtımı başlatıp yeniden taramak için simgeye tıklayın." }, "LocalGitToolchainScanBanner": { "xcodeLicenseTitle": "Git'i kullanmak için Xcode lisansını kabul edin", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 241ad34cc05..fc508932a45 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -6902,7 +6902,12 @@ "xcodeLicense": "Apple 開發者工具需要接受授權才能執行 Git。", "developerTools": "Apple 命令列開發人員工具遺失或不可用。", "architectureMismatch": "與 Git 相關的可執行檔無法執行,因為其 CPU 架構與此執行主機不相容。為主機架構安裝 Git 和相關工具。", - "copyDiagnostics": "複製診斷" + "copyDiagnostics": "複製診斷", + "wslStoppedTitle": "WSL 發行版 {{value0}} 已停止", + "wslUnverifiableTitle": "無法確認 WSL 發行版 {{value0}} 正在執行", + "wslRetained": "正在顯示 Orca 已知的工作樹。Orca 偵測到該發行版執行後會重新整理。", + "wslStartAndScan": "啟動發行版並掃描", + "wslStartAndScanHint": "點擊圖示啟動該發行版並重新掃描。" }, "LocalGitToolchainScanBanner": { "xcodeLicenseTitle": "接受 Xcode 授權條款以使用 Git", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cc9618aa98a..b273a9bfb26 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6440,7 +6440,12 @@ "xcodeLicense": "Apple 开发者工具需要接受许可证才能运行 Git。", "developerTools": "Apple 命令行开发人员工具丢失或不可用。", "architectureMismatch": "与 Git 相关的可执行文件无法运行,因为其 CPU 架构与此执行主机不兼容。为主机架构安装 Git 和相关工具。", - "copyDiagnostics": "复制诊断" + "copyDiagnostics": "复制诊断", + "wslStoppedTitle": "WSL 发行版 {{value0}} 已停止", + "wslUnverifiableTitle": "无法确认 WSL 发行版 {{value0}} 正在运行", + "wslRetained": "正在显示 Orca 已知的工作树。Orca 检测到该发行版运行后会刷新。", + "wslStartAndScan": "启动发行版并扫描", + "wslStartAndScanHint": "点击图标启动该发行版并重新扫描。" }, "LocalGitToolchainScanBanner": { "xcodeLicenseTitle": "接受 Xcode 许可协议以使用 Git", diff --git a/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts b/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts index eeff53ebfbf..78989bd3ee7 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts @@ -597,6 +597,46 @@ describe('fetchWorktrees', () => { expect(result).toBe(false) }) + it('keeps the scanned rows and their tabs when a stopped WSL distro returns stored rows', async () => { + const store = createTestStore() + const scanned = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/feature' + }) + const notStored = makeWorktree({ id: 'repo1::/path/wt2', repoId: 'repo1', path: '/path/wt2' }) + const stored = { ...scanned, branch: '', head: '' } + + mockApi.worktrees.listDetected.mockResolvedValueOnce( + makeDetectedResult('repo1', [stored], { + authoritative: false, + source: 'metadata-fallback', + unavailableReason: "WSL distro 'Ubuntu' is not running.", + wslDistroStopped: { distro: 'Ubuntu', state: 'stopped' } + }) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the merge reads only these tab fields. + store.setState({ + worktreesByRepo: { repo1: [scanned, notStored] }, + sortEpoch: 7, + tabsByWorktree: { [notStored.id]: [{ id: 'tab-2', worktreeId: notStored.id }] } + } as unknown as Partial) + + const result = await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreesByRepo.repo1).toEqual([scanned, notStored]) + expect(store.getState().tabsByWorktree[notStored.id]).toEqual([ + { id: 'tab-2', worktreeId: notStored.id } + ]) + expect(store.getState().detectedWorktreesByRepo.repo1?.wslDistroStopped).toEqual({ + distro: 'Ubuntu', + state: 'stopped' + }) + expect(store.getState().sortEpoch).toBe(7) + expect(result).toBe(false) + }) + it('reports unchanged non-authoritative refreshes as not fully refreshed', async () => { const store = createTestStore() const existing = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-host-merge.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-host-merge.ts index 9a337c488a3..88206900dde 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-host-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-host-merge.ts @@ -25,6 +25,8 @@ export function mergeDetectedWorktreesForHost( current.source === refreshed.source && current.unavailableReason === refreshed.unavailableReason && current.failureKind === refreshed.failureKind && + current.wslDistroStopped?.distro === refreshed.wslDistroStopped?.distro && + current.wslDistroStopped?.state === refreshed.wslDistroStopped?.state && current.worktrees === worktrees ) { return current diff --git a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts index 87166365303..84f95063826 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts @@ -212,7 +212,9 @@ export function mergeFetchedWorktrees( args.setup, matchOptions ) - if (!args.refresh.result.authoritative && worktrees.length === 0 && currentForHost.length > 0) { + // Stored rows from a stopped WSL distro lack branch state the scanned rows already have. + const keepCurrent = worktrees.length === 0 || !!args.refresh.result.wslDistroStopped + if (!args.refresh.result.authoritative && keepCurrent && currentForHost.length > 0) { return areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[args.repoId], mergedDetected) ? s : { diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-catalog-visibility.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-catalog-visibility.ts index a3fb2ef3ac2..6733150ebb3 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-catalog-visibility.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-catalog-visibility.ts @@ -38,6 +38,8 @@ export function areDetectedWorktreeResultsEqual( current.source === next.source && current.unavailableReason === next.unavailableReason && current.failureKind === next.failureKind && + current.wslDistroStopped?.distro === next.wslDistroStopped?.distro && + current.wslDistroStopped?.state === next.wslDistroStopped?.state && catalogRowsEqual(current.worktrees, next.worktrees) ) } diff --git a/src/shared/worktree/types.ts b/src/shared/worktree/types.ts index 6e00467e66c..6ce655fdde1 100644 --- a/src/shared/worktree/types.ts +++ b/src/shared/worktree/types.ts @@ -250,4 +250,6 @@ export type DetectedWorktreeListResult = { failureKind?: WorktreeScanFailureKind /** Which catalog this listing describes; additive, older hosts omit it. */ catalogVersion?: WorktreeCatalogVersion + /** The repo's WSL distro is stopped or unverifiable, so rows are stored ones; additive, older hosts omit it. */ + wslDistroStopped?: { distro: string; state: 'stopped' | 'unverifiable' } }