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' } }