From b9627e91a9188f2f999690c05a5744c2fa120e0c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:11:24 -0700 Subject: [PATCH 01/34] perf(renderer): parallelize runtime catalog and worktree refresh (#14138) Co-authored-by: Orca --- src/renderer/src/hooks/useIpcEvents.test.ts | 72 +++++++++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 22 ++++--- 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 392750fe861..e02ff8a9b34 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -10328,6 +10328,78 @@ describe('runtime host catalog refresh on reposChanged', () => { vi.useRealTimers() } }) + + // Why: the catalog fetches carry 15s RPC timeouts; serializing them ahead of the + // worktree refresh stalled lineage convergence for 30s on a connected-but-wedged host. + it('refreshes worktrees and lineage without waiting on a stalled catalog fetch', async () => { + vi.resetModules() + vi.useFakeTimers() + try { + const fetchRuntimeEnvironmentRepos = vi.fn(() => Promise.resolve([{ id: 'repo-1' }])) + // Never settles, standing in for a host that accepted the connection but stopped answering. + const fetchProjectGroups = vi.fn(() => new Promise(() => {})) + const fetchFolderWorkspaces = vi.fn(() => Promise.resolve()) + const fetchWorktrees = vi.fn(() => Promise.resolve()) + const fetchWorktreeLineage = vi.fn(() => Promise.resolve()) + const state = { + settings: { activeRuntimeEnvironmentId: 'env-1' as string | null }, + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + runtimeEnvironments: [], + runtimeStatusByEnvironmentId: new Map(), + tabsByWorktree: {}, + ptyIdsByTabId: {}, + remountTerminalTabForRecovery: vi.fn(), + markEnvironmentSshStateStale: vi.fn(), + fetchRepos: vi.fn(() => Promise.resolve()), + fetchRuntimeEnvironmentRepos, + fetchProjectGroups, + fetchFolderWorkspaces, + fetchWorktrees, + fetchWorktreeLineage + } + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { ...actual, useEffect: (effect: () => void | (() => void)) => void effect() } + }) + vi.doMock('../store', () => ({ + useAppStore: { subscribe: vi.fn(() => () => {}), getState: () => state } + })) + const noopListener = (): (() => void) => () => {} + const autoStubNamespace = new Proxy( + {}, + { + get: + () => + (...args: unknown[]) => { + if (typeof args[0] === 'function') { + return noopListener() + } + return new Promise(() => {}) + } + } + ) + const api = new Proxy({} as Record, { + get: (target, prop: string) => target[prop] ?? autoStubNamespace + }) + vi.stubGlobal('window', { api }) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + await vi.advanceTimersByTimeAsync(300) + + expect(fetchProjectGroups).toHaveBeenCalled() + // Folder workspaces still wait on their groups; worktrees and lineage must not. + expect(fetchFolderWorkspaces).not.toHaveBeenCalled() + expect(fetchWorktrees).toHaveBeenCalledWith('repo-1', expect.anything()) + expect(fetchWorktreeLineage).toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) }) describe('repo catalog refresh on repos:changed', () => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index d9f3727232d..b91a1372430 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -940,14 +940,20 @@ export function useIpcEvents(): void { // Why: the host emits one reposChanged for group/folder-workspace edits too, so those // catalogs go stale without this; groups first because folder workspaces resolve owners from them. const runtimeOwner = { runtimeEnvironmentId: environmentId } - await useAppStore.getState().fetchProjectGroups(runtimeOwner) - await useAppStore.getState().fetchFolderWorkspaces(runtimeOwner) - await refreshRuntimeProjectWorktreesAndLineage( - environmentId, - repos, - (repoId, options) => useAppStore.getState().fetchWorktrees(repoId, options), - (options) => useAppStore.getState().fetchWorktreeLineage(options) - ) + // Why: catalogs and worktrees are independent; serializing them put two 15s RPC + // timeouts ahead of worktree/lineage convergence on a wedged host. + await Promise.all([ + (async () => { + await useAppStore.getState().fetchProjectGroups(runtimeOwner) + await useAppStore.getState().fetchFolderWorkspaces(runtimeOwner) + })(), + refreshRuntimeProjectWorktreesAndLineage( + environmentId, + repos, + (repoId, options) => useAppStore.getState().fetchWorktrees(repoId, options), + (options) => useAppStore.getState().fetchWorktreeLineage(options) + ) + ]) }, onError: (error) => { console.error('Failed to refresh runtime projects:', error) From 3035aa92110cdcc383ac197661ca3bd70cbbaba6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:15:21 -0700 Subject: [PATCH 02/34] fix(browser): restore replaced cookies through CDP identities (#14383) * fix(browser): restore replaced cookies through CDP identities Both remaining callers of the imported-domain replacement rolled back by rebuilding cookies with cookies.set, which silently drops partitionKey. The rollback in importValidatedCookies puts back the user's ORIGINAL cookies that the import already deleted, so a CHIPS cookie came back as an ordinary one and no restart recovered it. Snapshot CDP identities before the first removal and undo through them, the same machinery removeTransplantableCookies already uses. The store type omits 'set' so the lossy reconstruction cannot be reintroduced, and restoreImportedDomainCookies is deleted now that both callers are gone. * fix(browser): skip the CDP rollback when nothing was replaced restoreClearIdentities attaches the debugger before it iterates, so an empty restore set would spin up a hidden BrowserWindow to put nothing back. The old cookies.set restore was a no-op loop in that case. --- .../browser-cookie-import-policy.test.ts | 172 ++++++---- .../browser/browser-cookie-import-policy.ts | 109 ++++--- ...eplace-partition-rollback.electron.test.ts | 293 ++++++++++++++++++ .../browser-cookie-import-replacement.test.ts | 78 ++++- src/main/browser/browser-cookie-import.ts | 193 ++++++------ 5 files changed, 643 insertions(+), 202 deletions(-) create mode 100644 src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts diff --git a/src/main/browser/browser-cookie-import-policy.test.ts b/src/main/browser/browser-cookie-import-policy.test.ts index 980c5f78ddb..25204fcd1c7 100644 --- a/src/main/browser/browser-cookie-import-policy.test.ts +++ b/src/main/browser/browser-cookie-import-policy.test.ts @@ -3,7 +3,8 @@ import { DatabaseSync } from 'node:sqlite' import type { Cookie } from 'electron' import { identitiesFromClearCookies, - removeTransplantableCookies + removeTransplantableCookies, + type CookieClearIdentity } from './browser-cookie-import-clear' import { isGoogleSourceBoundCookie, @@ -49,6 +50,29 @@ describe('isGoogleSourceBoundCookie', () => { }) }) +// Why: mirrors what openCookieClearStore returns — get/remove plus the CDP identity pair, and +// deliberately no 'set', so a partition-dropping reconstruction cannot be written against it. +function replaceStore( + existing: Cookie[], + overrides: { + remove?: Mock + snapshot?: Mock + restore?: Mock + } = {} +) { + const get = vi.fn().mockResolvedValue(existing) + const remove = overrides.remove ?? vi.fn().mockResolvedValue(undefined) + const snapshotClearIdentities = + overrides.snapshot ?? + vi + .fn() + .mockImplementation(async (cookies: readonly { cookie: Cookie; url: string }[]) => + identitiesFromClearCookies(cookies) + ) + const restoreClearIdentities = overrides.restore ?? vi.fn().mockResolvedValue(undefined) + return { get, remove, snapshotClearIdentities, restoreClearIdentities } +} + describe('replaceCookiesForImportedDomains', () => { it('removes parent, exact, and child-domain cookies while preserving unrelated sites', async () => { const existing = [ @@ -59,99 +83,129 @@ describe('replaceCookiesForImportedDomains', () => { cookie('.google.com.evil.example', 'suffix-confusion'), cookie('.example.com', 'unrelated') ] - const get = vi.fn().mockResolvedValue(existing) - const remove = vi.fn().mockResolvedValue(undefined) - const set = vi.fn().mockResolvedValue(undefined) + const store = replaceStore(existing) - const removed = await replaceCookiesForImportedDomains({ get, remove, set }, [ - 'accounts.google.com' - ]) + const { removed } = await replaceCookiesForImportedDomains(store, ['accounts.google.com']) expect(removed).toHaveLength(3) - expect(get).toHaveBeenCalledWith({}) - expect(remove.mock.calls).toEqual([ + expect(store.get).toHaveBeenCalledWith({}) + expect(store.remove.mock.calls).toEqual([ ['https://google.com/', 'parent'], ['https://accounts.google.com/signin', 'exact'], ['http://child.accounts.google.com/nested', 'child'] ]) - expect(set).not.toHaveBeenCalled() + // Why: the snapshot must cover the whole removal plan before the first removal runs. + expect(store.snapshotClearIdentities).toHaveBeenCalledOnce() + expect( + store.snapshotClearIdentities.mock.calls[0]?.[0].map( + ({ cookie: entry }: { cookie: Cookie }) => entry.name + ) + ).toEqual(['parent', 'exact', 'child']) + expect(store.restoreClearIdentities).not.toHaveBeenCalled() }) it('does not replace a private-suffix host cookie for a tenant import', async () => { - const get = vi - .fn() - .mockResolvedValue([ - { ...cookie('github.io', 'host-only-suffix'), hostOnly: true }, - cookie('.user.github.io', 'tenant') - ]) - const remove = vi.fn().mockResolvedValue(undefined) - const set = vi.fn().mockResolvedValue(undefined) + const store = replaceStore([ + { ...cookie('github.io', 'host-only-suffix'), hostOnly: true }, + cookie('.user.github.io', 'tenant') + ]) - const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['user.github.io']) + const { removed } = await replaceCookiesForImportedDomains(store, ['user.github.io']) expect(removed.map(({ name }) => name)).toEqual(['tenant']) - expect(remove).toHaveBeenCalledWith('https://user.github.io/', 'tenant') + expect(store.remove).toHaveBeenCalledWith('https://user.github.io/', 'tenant') }) it('does not read or mutate the store when no valid domain scope exists', async () => { - const get = vi.fn() - const remove = vi.fn() - const set = vi.fn() + const store = replaceStore([]) await expect( - replaceCookiesForImportedDomains({ get, remove, set }, [ - '', - '...', - 'com', - 'co.uk', - 'github.io' - ]) - ).resolves.toEqual([]) - expect(get).not.toHaveBeenCalled() - expect(remove).not.toHaveBeenCalled() - expect(set).not.toHaveBeenCalled() + replaceCookiesForImportedDomains(store, ['', '...', 'com', 'co.uk', 'github.io']) + ).resolves.toEqual({ removed: [], identities: [] }) + expect(store.get).not.toHaveBeenCalled() + expect(store.remove).not.toHaveBeenCalled() + expect(store.snapshotClearIdentities).not.toHaveBeenCalled() + expect(store.restoreClearIdentities).not.toHaveBeenCalled() }) it('keeps single-label intranet scopes from selecting descendant hosts', async () => { - const get = vi - .fn() - .mockResolvedValue([cookie('local', 'exact'), cookie('.service.local', 'descendant')]) - const remove = vi.fn().mockResolvedValue(undefined) - const set = vi.fn().mockResolvedValue(undefined) + const store = replaceStore([cookie('local', 'exact'), cookie('.service.local', 'descendant')]) - const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['local']) + const { removed } = await replaceCookiesForImportedDomains(store, ['local']) expect(removed.map(({ name }) => name)).toEqual(['exact']) - expect(remove).toHaveBeenCalledOnce() - expect(remove).toHaveBeenCalledWith('https://local/', 'exact') + expect(store.remove).toHaveBeenCalledOnce() + expect(store.remove).toHaveBeenCalledWith('https://local/', 'exact') }) - it('restores cookies removed before a later removal fails', async () => { + // Why (STA-4097): cookies.get strips partitionKey and cookies.set ignores it, so a rollback + // that rebuilds cookies through the Electron API silently downgrades CHIPS cookies. The undo + // has to travel back through the CDP identities that actually carry the partition. + it('restores removed cookies through CDP identities, keeping partition identity', async () => { const existing = [ cookie('.example.com', 'first', '/one'), cookie('.example.com', 'second', '/two') ] - const get = vi.fn().mockResolvedValue(existing) + const partitionKey = { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true } + const snapshot = vi + .fn() + .mockImplementation(async (cookies: readonly { cookie: Cookie; url: string }[]) => + identitiesFromClearCookies(cookies).map((identity) => + identity.name === 'first' ? { ...identity, partitionKey } : identity + ) + ) const remove = vi .fn() .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error('cookie store unavailable')) - const set = vi.fn().mockResolvedValue(undefined) + const store = replaceStore(existing, { remove, snapshot }) - await expect( - replaceCookiesForImportedDomains({ get, remove, set }, ['example.com']) - ).rejects.toThrow('cookie store unavailable') - expect(set).toHaveBeenCalledOnce() - expect(set).toHaveBeenCalledWith({ - url: 'https://example.com/one', - name: 'first', - value: 'secret', - domain: '.example.com', - path: '/one', - secure: true, - httpOnly: undefined, - sameSite: 'unspecified' - }) + await expect(replaceCookiesForImportedDomains(store, ['example.com'])).rejects.toThrow( + 'cookie store unavailable' + ) + + expect(store.restoreClearIdentities).toHaveBeenCalledOnce() + const restored = store.restoreClearIdentities.mock.calls[0]?.[0] as CookieClearIdentity[] + // Why: the failing coordinate is restored too — a rejected remove cannot prove it survived. + expect(restored.map(({ name }) => name)).toEqual(['second', 'first']) + expect(restored.find(({ name }) => name === 'first')?.partitionKey).toEqual(partitionKey) + expect(restored.find(({ name }) => name === 'first')?.url).toBe('https://example.com/one') + }) + + it('aborts without removing anything when the snapshot cannot cover the removal plan', async () => { + const existing = [ + cookie('.example.com', 'first', '/one'), + cookie('.example.com', 'second', '/two') + ] + const snapshot = vi + .fn() + .mockImplementation(async (cookies: readonly { cookie: Cookie; url: string }[]) => + identitiesFromClearCookies(cookies).filter((identity) => identity.name !== 'second') + ) + const store = replaceStore(existing, { snapshot }) + + await expect(replaceCookiesForImportedDomains(store, ['example.com'])).rejects.toThrow( + 'the session was left unchanged' + ) + expect(store.remove).not.toHaveBeenCalled() + expect(store.restoreClearIdentities).not.toHaveBeenCalled() + }) + + it('reports both failures when the CDP rollback itself fails', async () => { + const remove = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('cookie store unavailable')) + const restore = vi.fn().mockRejectedValue(new Error('debugger detached')) + const store = replaceStore( + [cookie('.example.com', 'first', '/one'), cookie('.example.com', 'second', '/two')], + { remove, restore } + ) + + await expect(replaceCookiesForImportedDomains(store, ['example.com'])).rejects.toThrow( + 'Cookie replacement and rollback failed' + ) + expect(restore).toHaveBeenCalledOnce() }) }) diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts index 9fa3bc5f9b7..4ca88ed6579 100644 --- a/src/main/browser/browser-cookie-import-policy.ts +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -1,5 +1,7 @@ import type { Cookie, Cookies } from 'electron' import { parse as parseDomain } from 'psl' +// Why: type-only, so this does not create a runtime cycle with the clear module. +import type { CookieClearIdentity } from './browser-cookie-import-clear' const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([ 'SIDCC', @@ -167,69 +169,102 @@ export function cookieRemovalUrl(cookie: Cookie, domain: string): string | null } } -export async function restoreImportedDomainCookies( - store: Pick, - cookies: readonly Cookie[] -): Promise { - const failures: unknown[] = [] - for (const cookie of cookies) { - try { - const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null - const url = domain ? cookieRemovalUrl(cookie, domain) : null - if (!url) { - continue - } - await store.set({ - url, - name: cookie.name, - value: cookie.value, - ...(cookie.hostOnly ? {} : { domain: cookie.domain }), - ...(cookie.path ? { path: cookie.path } : {}), - secure: cookie.secure, - httpOnly: cookie.httpOnly, - sameSite: cookie.sameSite, - ...(cookie.expirationDate ? { expirationDate: cookie.expirationDate } : {}) - }) - } catch (err) { - failures.push(err) +// Why (STA-4097): 'set' stays out so the partition-dropping reconstruction cannot return. +// Undoing a removal is only possible through CDP identities, which carry partitionKey. +export type ImportedDomainReplaceStore = Pick & { + snapshotClearIdentities( + cookies: readonly { cookie: Cookie; url: string }[] + ): Promise + restoreClearIdentities(identities: readonly CookieClearIdentity[]): Promise +} + +export type ReplacedImportedDomainCookies = { + removed: Cookie[] + identities: CookieClearIdentity[] +} + +function replaceRemovalKey(url: string, name: string): string { + return JSON.stringify([url, name]) +} + +function assertIdentitiesCoverRemovable( + removable: readonly { cookie: Cookie; url: string }[], + identities: readonly CookieClearIdentity[] +): void { + const covered = new Set( + identities.map((identity) => replaceRemovalKey(identity.url, identity.name)) + ) + for (const item of removable) { + if (!covered.has(replaceRemovalKey(item.url, item.cookie.name))) { + throw new Error('Could not replace existing cookies; the session was left unchanged') } } - if (failures.length > 0) { - throw new AggregateError(failures, 'Could not restore replaced cookies') - } } export async function replaceCookiesForImportedDomains( - store: Pick, + store: ImportedDomainReplaceStore, importedDomains: readonly string[] -): Promise { +): Promise { const scopes = importedDomainScopes(importedDomains) if (scopes.exact.size === 0) { - return [] + return { removed: [], identities: [] } } + // Why (STA-4170): the removal plan is fixed here, beside the identities that can undo it, so + // the restorable set always equals the mutated set. Re-reading the jar later would widen the + // removal past what the snapshot can restore. const existingCookies = await store.get({}) - const removedCookies: Cookie[] = [] + const removable: { cookie: Cookie; url: string }[] = [] for (const cookie of existingCookies) { const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null if (!domain || !overlapsImportedDomain(cookie, domain, scopes)) { continue } const url = cookieRemovalUrl(cookie, domain) - if (!url) { - continue + if (url) { + removable.push({ cookie, url }) + } + } + if (removable.length === 0) { + return { removed: [], identities: [] } + } + + // Why: snapshotting before the first removal is what makes the rollback lossless; an + // incomplete snapshot aborts while the session is still untouched. + const identities = await store.snapshotClearIdentities(removable) + assertIdentitiesCoverRemovable(removable, identities) + const identitiesByKey = new Map() + for (const identity of identities) { + const key = replaceRemovalKey(identity.url, identity.name) + const group = identitiesByKey.get(key) ?? [] + group.push(identity) + identitiesByKey.set(key, group) + } + + const removed: Cookie[] = [] + // Why: one remove(url, name) deletes every cookie at that coordinate, partitioned twins + // included, so the rollback set is tracked per coordinate rather than per cookie. + const attemptedKeys = new Set() + const attemptedIdentities: CookieClearIdentity[] = [] + for (const { cookie, url } of removable) { + const key = replaceRemovalKey(url, cookie.name) + if (!attemptedKeys.has(key)) { + attemptedKeys.add(key) + attemptedIdentities.push(...(identitiesByKey.get(key) ?? [])) } try { await store.remove(url, cookie.name) - removedCookies.push(cookie) + removed.push(cookie) } catch (err) { try { - await restoreImportedDomainCookies(store, removedCookies) + // Why: the failing coordinate is included because a rejected remove cannot prove the + // cookie survived; restoring a live cookie rewrites the value it was snapshotted with. + await store.restoreClearIdentities(attemptedIdentities.toReversed()) } catch (restoreError) { throw new AggregateError([err, restoreError], 'Cookie replacement and rollback failed') } throw err } } - return removedCookies + return { removed, identities } } diff --git a/src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts b/src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts new file mode 100644 index 00000000000..bcb16ad9cfe --- /dev/null +++ b/src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts @@ -0,0 +1,293 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { build as buildVite } from 'vite' + +const electronBinary = createRequire(import.meta.url)('electron') as string +const fixtureRoots: string[] = [] + +afterAll(() => { + for (const root of fixtureRoots) { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +type CdpCookie = { name: string; value: string; partitionKey?: Record } + +type FixtureResult = { + beforePartitionKey: Record | undefined + electronGetSawPartitionKey: boolean + removeCalls: number + removeCallsBeforeThrow: number + snapshotCalls: number + restoreCalls: number + importedRemoveCalls: number + replaceError: string | null + chipsAfterReplace: CdpCookie[] + chipsAfter: CdpCookie[] + plainAfter: CdpCookie[] +} + +const EXPECTED_PARTITION_KEY = { + topLevelSite: 'https://top.example', + hasCrossSiteAncestor: true +} + +type FixtureMode = 'site-a' | 'site-b' + +function buildFixtureMain(bundlePath: string, resultPath: string, mode: FixtureMode): string { + return ` +const { app, BrowserWindow, session } = require('electron') +const { writeFileSync } = require('node:fs') +const { openCookieClearStore, replaceCookiesForImportedDomains } = require(${JSON.stringify(bundlePath)}) +const resultPath = ${JSON.stringify(resultPath)} +const mode = ${JSON.stringify(mode)} +const expectedPartitionKey = ${JSON.stringify(EXPECTED_PARTITION_KEY)} +let currentStep = 'starting' +const mark = (step) => { + currentStep = step + writeFileSync(resultPath, JSON.stringify({ step })) +} + +const project = (cookies, name) => cookies + .filter((cookie) => cookie.name === name) + .map((cookie) => ({ name: cookie.name, value: cookie.value, partitionKey: cookie.partitionKey })) + +async function run() { + const timeout = setTimeout(() => { + writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep })) + app.exit(1) + }, 20000) + await app.whenReady() + mark('ready') + const partition = 'persist:replace-partition-rollback-' + mode + const targetSession = session.fromPartition(partition) + const window = new BrowserWindow({ show: false, webPreferences: { partition } }) + mark('window created') + await window.loadURL('data:text/html,replace partition rollback fixture') + mark('window loaded') + const debug = window.webContents.debugger + debug.attach('1.3') + mark('debugger attached') + + // Only CDP can create a partitioned cookie; Electron's cookies API has no partitionKey. + await debug.sendCommand('Network.setCookie', { + url: 'https://app.acme-chips.test/', + name: 'chips-auth', + value: 'keep-me', + secure: true, + sameSite: 'None', + partitionKey: expectedPartitionKey + }) + const beforeChips = (await debug.sendCommand('Network.getAllCookies')).cookies + .find((cookie) => cookie.name === 'chips-auth') + if (!beforeChips || !beforeChips.partitionKey) { + throw new Error('CHIPS fixture cookie was not stored partitioned') + } + mark('partitioned cookie set') + + await targetSession.cookies.set({ + url: 'https://plain.acme-chips.test/', + name: 'plain', + value: 'original', + secure: true + }) + await targetSession.cookies.set({ + url: 'https://victim.acme-chips.test/', + name: 'victim', + value: 'original', + secure: true + }) + mark('ordinary cookies set') + + let removeCalls = 0 + let removeCallsBeforeThrow = 0 + let snapshotCalls = 0 + let restoreCalls = 0 + let importedRemoveCalls = 0 + let chipsRemoved = false + let electronGetSawPartitionKey = false + let replaceError = null + let chipsAfterReplace = [] + + const cookieClearStore = openCookieClearStore(targetSession) + const store = { + get: async (filter) => { + const cookies = await targetSession.cookies.get(filter) + // Why: proves the Electron API cannot see the partition it is about to destroy. + electronGetSawPartitionKey = cookies.some( + (cookie) => cookie.name === 'chips-auth' && cookie.partitionKey !== undefined + ) + // Why: cookies.get order is not a contract; put CHIPS first so it is provably already + // removed by the time a later removal rejects. + return [...cookies].sort((left, right) => { + if (left.name === 'chips-auth') return -1 + if (right.name === 'chips-auth') return 1 + return 0 + }) + }, + remove: async (url, name) => { + removeCalls++ + if (mode === 'site-a' && chipsRemoved) { + removeCallsBeforeThrow = removeCalls - 1 + throw new Error('forced removal failure after chips was removed') + } + await targetSession.cookies.remove(url, name) + if (name === 'chips-auth') chipsRemoved = true + }, + snapshotClearIdentities: async (cookies) => { + snapshotCalls++ + return cookieClearStore.snapshotClearIdentities(cookies) + }, + restoreClearIdentities: async (identities) => { + restoreCalls++ + return cookieClearStore.restoreClearIdentities(identities) + } + } + + try { + if (mode === 'site-a') { + try { + await replaceCookiesForImportedDomains(store, ['acme-chips.test']) + } catch (error) { + replaceError = String(error && error.message ? error.message : error) + } + mark('site-a replace finished') + } else { + const replaced = await replaceCookiesForImportedDomains(store, ['acme-chips.test']) + chipsAfterReplace = project((await debug.sendCommand('Network.getAllCookies')).cookies, 'chips-auth') + mark('site-b replace finished') + // Why: stands in for importValidatedCookies' mid-import cookies.set rejection — the + // imported cookie is removed (lossless) and the user's originals go back through CDP. + await targetSession.cookies.set({ + url: 'https://imported.acme-chips.test/', + name: 'imported-new', + value: 'new', + secure: true + }) + await targetSession.cookies.remove('https://imported.acme-chips.test/', 'imported-new') + importedRemoveCalls = 1 + mark('site-b imported cookies rolled back') + restoreCalls++ + await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed()) + mark('site-b restore finished') + } + } finally { + cookieClearStore.dispose() + } + + const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies + clearTimeout(timeout) + writeFileSync(resultPath, JSON.stringify({ + beforePartitionKey: beforeChips.partitionKey, + electronGetSawPartitionKey, + removeCalls, + removeCallsBeforeThrow, + snapshotCalls, + restoreCalls, + importedRemoveCalls, + replaceError, + chipsAfterReplace, + chipsAfter: project(afterCookies, 'chips-auth'), + plainAfter: project(afterCookies, 'plain') + })) + debug.detach() + window.destroy() + app.exit(0) +} + +run().catch((error) => { + writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error && error.stack ? error.stack : error) })) + app.exit(1) +}) +` +} + +async function runFixture(mode: FixtureMode): Promise { + const root = mkdtempSync(join(tmpdir(), `orca-replace-partition-rollback-${mode}-`)) + fixtureRoots.push(root) + const bundlePath = join(root, 'cookie-replace-rollback.cjs') + const bundleEntryPath = join(root, 'cookie-replace-rollback.ts') + const resultPath = join(root, 'result.json') + const fixturePath = join(root, 'main.cjs') + writeFileSync( + bundleEntryPath, + [ + `export { openCookieClearStore } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-clear-store.ts'))}`, + `export { replaceCookiesForImportedDomains } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import-policy.ts'))}` + ].join('\n') + ) + await buildVite({ + configFile: false, + logLevel: 'silent', + build: { + emptyOutDir: false, + lib: { + entry: bundleEntryPath, + formats: ['cjs'], + fileName: () => 'cookie-replace-rollback.cjs' + }, + outDir: root, + target: 'node20', + rollupOptions: { external: ['electron', /^node:/] } + } + }) + writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath, mode)) + const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env + const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`] + const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary + const args = + process.platform === 'linux' + ? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox'] + : electronArgs + const run = spawnSync(executable, args, { + encoding: 'utf8', + env, + timeout: 60_000 + }) + const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' + expect(run.error).toBeUndefined() + expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0) + return JSON.parse(fixtureResult) as FixtureResult +} + +describe('partitioned cookies under a failed replace-imported-domains import', () => { + // Why (STA-4097): this rollback undoes deletions the import already made to the user's own + // cookies. Rebuilding them with cookies.set dropped partitionKey silently, so a CHIPS cookie + // came back as an ordinary one and no restart recovered it. + it('site A: restores a CHIPS cookie with its partition after a later removal rejects', async () => { + const result = await runFixture('site-a') + + expect(result.beforePartitionKey).toEqual(EXPECTED_PARTITION_KEY) + // Why: without this the fixture could pass on a cookie that was never partitioned. + expect(result.electronGetSawPartitionKey).toBe(false) + // Why: assert the rollback was reached, not just that the jar looks right at the end. + expect(result.snapshotCalls).toBe(1) + expect(result.removeCallsBeforeThrow).toBeGreaterThanOrEqual(1) + expect(result.restoreCalls).toBe(1) + expect(result.replaceError).toContain('forced removal failure after chips was removed') + expect(result.chipsAfter).toEqual([ + { name: 'chips-auth', value: 'keep-me', partitionKey: EXPECTED_PARTITION_KEY } + ]) + expect(result.plainAfter).toEqual([{ name: 'plain', value: 'original' }]) + }, 90_000) + + it('site B: restores a CHIPS cookie with its partition after a mid-import set failure', async () => { + const result = await runFixture('site-b') + + expect(result.beforePartitionKey).toEqual(EXPECTED_PARTITION_KEY) + expect(result.electronGetSawPartitionKey).toBe(false) + expect(result.snapshotCalls).toBe(1) + // Why: the replace really emptied the scope, so the restore below is doing the work. + expect(result.chipsAfterReplace).toEqual([]) + expect(result.importedRemoveCalls).toBe(1) + expect(result.restoreCalls).toBe(1) + expect(result.chipsAfter).toEqual([ + { name: 'chips-auth', value: 'keep-me', partitionKey: EXPECTED_PARTITION_KEY } + ]) + expect(result.plainAfter).toEqual([{ name: 'plain', value: 'original' }]) + }, 90_000) +}) diff --git a/src/main/browser/browser-cookie-import-replacement.test.ts b/src/main/browser/browser-cookie-import-replacement.test.ts index a727a8275f5..d0e79ec1c7f 100644 --- a/src/main/browser/browser-cookie-import-replacement.test.ts +++ b/src/main/browser/browser-cookie-import-replacement.test.ts @@ -3,15 +3,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { appGetPathMock, clearPendingCookieImportMock, + disposeClearStoreMock, execFileSyncMock, + restoreClearIdentitiesMock, sessionFromPartitionMock, - setPendingCookieImportMock + setPendingCookieImportMock, + snapshotClearIdentitiesMock } = vi.hoisted(() => ({ appGetPathMock: vi.fn(), clearPendingCookieImportMock: vi.fn(), + disposeClearStoreMock: vi.fn(), execFileSyncMock: vi.fn(), + restoreClearIdentitiesMock: vi.fn(), sessionFromPartitionMock: vi.fn(), - setPendingCookieImportMock: vi.fn() + setPendingCookieImportMock: vi.fn(), + snapshotClearIdentitiesMock: vi.fn() })) vi.mock('./browser-session-registry', () => ({ @@ -26,6 +32,7 @@ vi.mock('electron', () => ({ dialog: { showOpenDialog: vi.fn() }, session: { fromPartition: sessionFromPartitionMock } })) +// Why: snapshot/restore are spies, not no-ops, so a rollback that never ran cannot pass as one. vi.mock('./browser-cookie-clear-store', () => ({ openCookieClearStore: (targetSession: { cookies: { @@ -35,10 +42,9 @@ vi.mock('./browser-cookie-clear-store', () => ({ }) => ({ get: (filter: object) => targetSession.cookies.get(filter), remove: (url: string, name: string) => targetSession.cookies.remove(url, name), - snapshotClearIdentities: async (items: { cookie: Record; url: string }[]) => - items.map(({ cookie, url }) => ({ url, ...cookie })), - restoreClearIdentities: async () => undefined, - dispose: () => undefined + snapshotClearIdentities: snapshotClearIdentitiesMock, + restoreClearIdentities: restoreClearIdentitiesMock, + dispose: disposeClearStoreMock }) })) @@ -48,6 +54,16 @@ import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +beforeEach(() => { + snapshotClearIdentitiesMock + .mockReset() + .mockImplementation(async (items: { cookie: Record; url: string }[]) => + items.map(({ cookie: entry, url }) => ({ url, ...entry })) + ) + restoreClearIdentitiesMock.mockReset().mockResolvedValue(undefined) + disposeClearStoreMock.mockReset() +}) + describe('validated cookie replacement', () => { let cookiesGetMock: ReturnType let cookiesRemoveMock: ReturnType @@ -139,8 +155,17 @@ describe('validated cookie replacement', () => { expect(cookiesSetMock).not.toHaveBeenCalled() }) - it('restores the previous snapshot when an incoming cookie is rejected', async () => { + // Why (STA-4097): this rollback puts back the user's ORIGINAL cookies, which the import had + // already deleted. cookies.get drops partitionKey and cookies.set ignores it, so rebuilding + // them through the Electron API resurrected CHIPS cookies unpartitioned. The undo now travels + // back through the CDP identities, which are the only thing that carries the partition. + it('restores the previous cookies through CDP identities when an incoming cookie is rejected', async () => { + const partitionKey = { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true } cookiesGetMock.mockResolvedValue([cookie('.example.com', 'existing')]) + snapshotClearIdentitiesMock.mockImplementation( + async (items: { cookie: Record; url: string }[]) => + items.map(({ cookie: entry, url }) => ({ url, ...entry, partitionKey })) + ) cookiesSetMock .mockResolvedValue(undefined) .mockResolvedValueOnce(undefined) @@ -157,16 +182,35 @@ describe('validated cookie replacement', () => { ['https://example.com/', 'existing'], ['https://example.com/', 'first'] ]) - expect(cookiesSetMock).toHaveBeenLastCalledWith({ - url: 'https://example.com/', - name: 'existing', - value: 'old', - domain: '.example.com', - path: '/', - secure: true, - httpOnly: undefined, - sameSite: 'unspecified' - }) + expect(restoreClearIdentitiesMock).toHaveBeenCalledOnce() + expect(restoreClearIdentitiesMock.mock.calls[0]?.[0]).toEqual([ + { url: 'https://example.com/', ...cookie('.example.com', 'existing'), partitionKey } + ]) + // Why: cookies.set is only ever the two imported cookies — a third call would mean the + // partition-dropping reconstruction came back. + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['first', 'second']) + expect(disposeClearStoreMock).toHaveBeenCalledOnce() + }) + + // Why: restoreClearIdentities attaches a debugger before it iterates, so calling it with an + // empty restore set would create a hidden BrowserWindow to put nothing back. + it('does not reach for CDP when the rollback has nothing to restore', async () => { + cookiesGetMock.mockResolvedValue([]) + cookiesSetMock + .mockResolvedValue(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('cookie rejected')) + const filePath = writeCookies([ + { domain: '.example.com', name: 'first', value: 'new', secure: true }, + { domain: '.example.com', name: 'second', value: 'new', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok).toBe(false) + // Why: the imported cookie is still removed — that half of the rollback is lossless. + expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'first']]) + expect(restoreClearIdentitiesMock).not.toHaveBeenCalled() }) it('fails closed when existing cookies cannot be replaced', async () => { diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index b54badd0593..2e2f50dbdd1 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */ -import { app, type BrowserWindow, type Cookie, dialog, session } from 'electron' +import { app, type BrowserWindow, dialog, session } from 'electron' import { execFileSync } from 'node:child_process' import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto' import { @@ -81,8 +81,8 @@ import { normalizeCookieDomain, normalizeCookieImportDomain, replaceCookiesForImportedDomains, - restoreImportedDomainCookies, - type CookieImportMode + type CookieImportMode, + type ReplacedImportedDomainCookies } from './browser-cookie-import-policy' import { removeTransplantableCookies, withCookieClearLock } from './browser-cookie-import-clear' import { openCookieClearStore } from './browser-cookie-clear-store' @@ -594,97 +594,112 @@ async function importValidatedCookies( let importedCount = 0 let skipped = totalInput - importableCookies.length const domainSet = new Set() - let replacedCookies: Cookie[] | null = null + let replaced: ReplacedImportedDomainCookies | null = null + // Why (STA-4097): the rollback below has to put back cookies this import already deleted, and + // only CDP identities carry partitionKey — rebuilding them with cookies.set drops it silently. + const cookieClearStore = + mode === 'replace-imported-domains' && importableCookies.length > 0 + ? openCookieClearStore(targetSession) + : null - if (mode === 'replace-imported-domains' && importableCookies.length > 0) { - try { - replacedCookies = await replaceCookiesForImportedDomains( - targetSession.cookies, - importableCookies.map((cookie) => cookie.domain) - ) - diag(` removed ${replacedCookies.length} existing cookies in imported domain scopes`) - } catch (err) { - diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) + try { + if (cookieClearStore) { + try { + replaced = await replaceCookiesForImportedDomains( + cookieClearStore, + importableCookies.map((cookie) => cookie.domain) + ) + diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`) + } catch (err) { + diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) + return { + ok: false, + reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') + } + } + } + + // Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net. + const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') + const importedCookieKeys: { url: string; name: string }[] = [] + let setFailure: unknown = null + + for (const cookie of importableCookies) { + try { + // Why: Chromium rejects __Host- cookies unless they omit domain and use path=/. + const isHostPrefixed = cookie.name.startsWith('__Host-') + const path = isHostPrefixed ? '/' : cookie.path + await targetSession.cookies.set({ + url: cookie.url, + name: cookie.name, + value: stripNonPrintable(cookie.value), + ...(isHostPrefixed ? {} : { domain: cookie.domain }), + path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + expirationDate: cookie.expirationDate + }) + const removalUrl = new URL(cookie.url) + removalUrl.pathname = path.startsWith('/') ? path : '/' + importedCookieKeys.push({ url: removalUrl.toString(), name: cookie.name }) + importedCount++ + // Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data. + const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain + domainSet.add(cleanDomain) + } catch (err) { + skipped++ + setFailure = err + if (skipped <= 5) { + // Find the exact offending character position and code + const val = cookie.value + let badInfo = 'none found' + for (let i = 0; i < val.length; i++) { + const code = val.charCodeAt(i) + if (code < 0x20 || code > 0x7e) { + badInfo = `pos=${i} char=U+${code.toString(16).padStart(4, '0')}` + break + } + } + diag( + ` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}` + ) + } + if (replaced) { + break + } + } + } + + // Why: replaced is only ever set alongside cookieClearStore, which owns the CDP restore. + if (setFailure && replaced && cookieClearStore) { + const rollbackFailures: unknown[] = [] + for (const cookie of importedCookieKeys.toReversed()) { + try { + await targetSession.cookies.remove(cookie.url, cookie.name) + } catch (err) { + rollbackFailures.push(err) + } + } + // Why: restoreClearIdentities attaches the debugger before it iterates, so an empty + // restore set would spin up a hidden BrowserWindow to put nothing back. + if (replaced.identities.length > 0) { + try { + await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed()) + } catch (err) { + rollbackFailures.push(err) + } + } + if (rollbackFailures.length > 0) { + diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) + } return { ok: false, - reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') + reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') } } - } - - // Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net. - const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') - const importedCookieKeys: { url: string; name: string }[] = [] - let setFailure: unknown = null - - for (const cookie of importableCookies) { - try { - // Why: Chromium rejects __Host- cookies unless they omit domain and use path=/. - const isHostPrefixed = cookie.name.startsWith('__Host-') - const path = isHostPrefixed ? '/' : cookie.path - await targetSession.cookies.set({ - url: cookie.url, - name: cookie.name, - value: stripNonPrintable(cookie.value), - ...(isHostPrefixed ? {} : { domain: cookie.domain }), - path, - secure: cookie.secure, - httpOnly: cookie.httpOnly, - sameSite: cookie.sameSite, - expirationDate: cookie.expirationDate - }) - const removalUrl = new URL(cookie.url) - removalUrl.pathname = path.startsWith('/') ? path : '/' - importedCookieKeys.push({ url: removalUrl.toString(), name: cookie.name }) - importedCount++ - // Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data. - const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain - domainSet.add(cleanDomain) - } catch (err) { - skipped++ - setFailure = err - if (skipped <= 5) { - // Find the exact offending character position and code - const val = cookie.value - let badInfo = 'none found' - for (let i = 0; i < val.length; i++) { - const code = val.charCodeAt(i) - if (code < 0x20 || code > 0x7e) { - badInfo = `pos=${i} char=U+${code.toString(16).padStart(4, '0')}` - break - } - } - diag( - ` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}` - ) - } - if (replacedCookies) { - break - } - } - } - - if (setFailure && replacedCookies) { - const rollbackFailures: unknown[] = [] - for (const cookie of importedCookieKeys.toReversed()) { - try { - await targetSession.cookies.remove(cookie.url, cookie.name) - } catch (err) { - rollbackFailures.push(err) - } - } - try { - await restoreImportedDomainCookies(targetSession.cookies, replacedCookies) - } catch (err) { - rollbackFailures.push(err) - } - if (rollbackFailures.length > 0) { - diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) - } - return { - ok: false, - reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') - } + } finally { + cookieClearStore?.dispose() } diag( From e2e7768cc819ee4690f6eb61175e75de6633d2ed Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:18:08 -0700 Subject: [PATCH 03/34] fix(terminal): deliver Ctrl+C when a non-Latin input source rewrites the key (#14462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a non-Latin input source the OS reports the layout's own glyph for `key` — a Hangul jamo on Korean 2-Set, Cyrillic es on Russian — while `code` stays KeyC. The interrupt policy read `key` for identity and only consulted `code` when `key` was empty or Unidentified, so a jamo short-circuited it to false: the press missed the ETX path and was CSI-u encoded instead, leaving a TUI running. Trust `key` only when it is a Latin letter, which keeps a Dvorak remap of C authoritative. Otherwise ask the layout map what the physical key produces unmodified: an IME layered over a Latin layout answers 'c', and over a Dvorak base answers 'j', which correctly declines. When the map is itself non-Latin it cannot answer either, so fall back to physical position — how terminals have always resolved control chords. Fixes #14460 --- .../xterm-bypass-policy-interrupt.test.ts | 90 ++++++++++++++++++- .../terminal-pane/xterm-bypass-policy.ts | 28 +++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts index 4223ccf13f8..328ffc95739 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' +import { _setLayoutMapForTests } from '../../lib/keyboard-layout/layout-base-character' import { shouldHandleTerminalInterruptKeyboardEvent, shouldSuppressTerminalInterruptKeyup, @@ -21,6 +22,9 @@ function event(overrides: Partial): XtermBypassEvent { } } +// The layout map is module-level cache; leaving one set would leak into later cases. +afterEach(() => _setLayoutMapForTests(null)) + describe('shouldHandleTerminalInterruptKeyboardEvent', () => { it('exports the ETX byte used for terminal interrupts', () => { expect(TERMINAL_INTERRUPT_INPUT).toBe('\x03') @@ -103,6 +107,90 @@ describe('shouldHandleTerminalInterruptKeyboardEvent', () => { ).toBe(false) }) + // #14460: with a non-Latin input source the OS reports the layout's own glyph for `key` + // (Hangul jamo on Korean 2-Set, Cyrillic es on Russian), while `code` stays KeyC. A + // non-Latin script cannot express a control chord in `key`, so `code` is the only signal + // that survives — and without this, Ctrl+C misses the ETX path and gets CSI-u encoded + // instead, leaving a TUI running. + it.each([ + ['Korean 2-Set', 'ㅊ'], + ['Russian', 'с'], + ['Greek', 'ψ'], + ['Hangul syllable', '차'] + ])('handles Ctrl+C when %s reports a non-Latin logical key', (_layout, key) => { + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key, code: 'KeyC', ctrlKey: true }), { + isMac: true, + hasSelection: false + }) + ).toBe(true) + }) + + it('suppresses the matching keyup for a non-Latin Ctrl+C', () => { + expect( + shouldSuppressTerminalInterruptKeyup( + event({ type: 'keyup', key: 'ㅊ', code: 'KeyC', ctrlKey: true }) + ) + ).toBe(true) + }) + + // The paired negative, and the reason this cannot simply prefer `code`: a Latin layout that + // moves letters around (Dvorak) reports a real ASCII letter, and that letter is authoritative. + it('still ignores a Latin layout that maps KeyC to a different ASCII letter', () => { + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'j', code: 'KeyC', ctrlKey: true }), { + isMac: true, + hasSelection: false + }) + ).toBe(false) + }) + + // And a non-Latin key on some other physical key must not become an interrupt. + it('does not handle a non-Latin logical key on a physical key other than KeyC', () => { + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'ㅁ', code: 'KeyA', ctrlKey: true }), { + isMac: true, + hasSelection: false + }) + ).toBe(false) + }) + + // An IME sits on top of a Latin layout, so the layout map still answers for the physical key. + // Consulting it is what keeps the non-Latin path precise rather than merely positional. + it('uses the layout map when an IME is layered over a Latin layout', () => { + _setLayoutMapForTests(new Map([['KeyC', 'c']])) + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }), { + isMac: true, + hasSelection: false + }) + ).toBe(true) + }) + + // The case positional matching alone would get wrong: Korean layered over Dvorak, where the + // physical KeyC is not the user's C. The layout map says so, and the interrupt declines. + it('declines when the layout map shows the physical key is not C', () => { + _setLayoutMapForTests(new Map([['KeyC', 'j']])) + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }), { + isMac: true, + hasSelection: false + }) + ).toBe(false) + }) + + // A true non-Latin *layout* (not an IME) has a non-Latin map too, so it cannot answer either. + // Fall back to physical position, which is how terminals resolve control chords. + it('falls back to the physical key when the layout map is itself non-Latin', () => { + _setLayoutMapForTests(new Map([['KeyC', 'с']])) + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'с', code: 'KeyC', ctrlKey: true }), { + isMac: true, + hasSelection: false + }) + ).toBe(true) + }) + it('does not handle modified Ctrl+C chords', () => { expect( shouldHandleTerminalInterruptKeyboardEvent( diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index 18a50129e9e..d09ffd4b039 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -1,4 +1,5 @@ import { keybindingMatchesInput } from '../../../../shared/keybindings' +import { getLayoutBaseCharacterForCode } from '../../lib/keyboard-layout/layout-base-character' import { isTerminalImeCandidateDigitKeyEvent, isTerminalImeCandidateSelectionKeyEvent @@ -151,10 +152,33 @@ export function shouldPreventDefaultTerminalImeCandidateKey( ) } +/** + * A logical key a Latin layout could have produced. Only then is `key` authoritative: + * Dvorak moving `c` elsewhere is a real remap and must be honoured. + */ +function isLatinLetterKey(normalizedKey: string): boolean { + return normalizedKey.length === 1 && normalizedKey >= 'a' && normalizedKey <= 'z' +} + function isTerminalInterruptCKey(event: XtermBypassEvent): boolean { const normalizedKey = event.key.toLowerCase() - const logicalKeyAvailable = normalizedKey !== '' && normalizedKey !== 'unidentified' - return logicalKeyAvailable ? normalizedKey === 'c' : event.code === 'KeyC' || event.keyCode === 67 + if (isLatinLetterKey(normalizedKey)) { + return normalizedKey === 'c' + } + // A non-Latin input source reports its own glyph here — a Hangul jamo on Korean 2-Set, + // Cyrillic es on Russian — and cannot express a control chord in `key` at all. Ask the + // layout map what this physical key produces unmodified: for an IME layered over a Latin + // layout that answers `c`, and for a Dvorak base it answers `j`, which correctly declines. + const layoutBaseKey = event.code + ? getLayoutBaseCharacterForCode(event.code)?.toLowerCase() + : undefined + if (layoutBaseKey !== undefined && isLatinLetterKey(layoutBaseKey)) { + return layoutBaseKey === 'c' + } + // Why the physical fallback: on a true non-Latin *layout* the map is non-Latin too, so it + // cannot answer the question either. Terminals resolve control chords by physical position, + // so KeyC is the interrupt. Empty and Unidentified land here as they always did. + return event.code === 'KeyC' || event.keyCode === 67 } function isPlainCtrlC(event: XtermBypassEvent): boolean { From 08027d0d237608acccce793ed460fe97e94c6202 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:35:41 -0700 Subject: [PATCH 04/34] refactor(sidebar): split WorktreeList into a worktree-list module (#14465) WorktreeList.tsx was 6.8k lines behind an `eslint-disable max-lines`. Break it into `sidebar/worktree-list/`: the container keeps store wiring and composition, the virtualized viewport keeps layout, and the drag, reveal, virtualization, row-model, and row-render concerns each get their own file. Every file now fits the oxlint budget, so the suppression and its baseline entry are gone. Behaviour-preserving. The only deliberate cleanups are duplicate branches folded into shared helpers (drop-preview state updates, status-hover fallback, the two identical scroll-to-index reveal branches) and a dead sticky-header-index ref. Tests that asserted on WorktreeList.tsx source text or imported its named helpers now point at the module that owns them. --- config/max-lines-baseline.txt | 1 - .../WorktreeList.lineage-child-card.test.ts | 4 +- .../src/components/sidebar/WorktreeList.tsx | 6848 +---------------- .../sidebar/project-group-header-dom.test.ts | 14 +- .../sidebar/worktree-keyboard-cycle.test.ts | 4 +- .../sidebar/worktree-list-groups.test.ts | 11 +- .../worktree-list-imported-rows.test.ts | 11 +- .../worktree-list-scroll-adjustment.test.ts | 8 +- .../worktree-list-visible-refresh.test.ts | 2 +- .../FolderPathStatusIndicator.tsx | 47 + .../worktree-list/HostSectionHeader.tsx | 161 + .../SidebarWorktreeListDialogs.tsx | 118 + .../SidebarWorktreeListEmptyState.tsx | 36 + .../VirtualizedWorktreeViewport.tsx | 411 + .../worktree-list/active-descendant-option.ts | 67 + .../folder-workspace-virtual-row.tsx | 129 + .../imported-worktrees-card-visibility.ts | 9 + .../worktree-list/mounted-row-reveal.ts | 35 + .../pending-sidebar-reveal-inputs.ts | 116 + .../pointer-drop-status-target.ts | 56 + .../project-group-header-actions.tsx | 132 + .../worktree-list/render-row-item-rows.ts | 13 + .../render-row-worktree-lookup.ts | 79 + .../sidebar/worktree-list/renderable-rows.ts | 40 + .../worktree-list/repo-header-event-guards.ts | 31 + .../repo-header-project-actions.tsx | 227 + .../worktree-list/sidebar-editable-target.ts | 19 + .../sidebar-row-reveal-ancestors.ts | 112 + .../stable-empty-store-slices.ts | 9 + .../use-agent-send-target-worktree.ts | 63 + .../use-effective-collapsed-groups.ts | 90 + .../use-folder-workspace-path-status-rows.ts | 128 + .../use-group-toggle-with-scroll-anchor.ts | 30 + .../use-pending-sidebar-reveal.ts | 288 + .../use-primary-active-worktree-row.ts | 70 + .../use-project-group-dialogs.ts | 172 + .../use-reused-array-identity.ts | 10 + .../use-sidebar-external-worktree-cards.ts | 187 + .../use-sidebar-host-visible-scope.ts | 69 + .../use-sidebar-reveal-highlight.ts | 63 + .../use-sidebar-reveal-requests.ts | 125 + .../worktree-list/use-sidebar-section-rows.ts | 260 + .../use-sidebar-worktree-filters.ts | 106 + .../use-sidebar-worktree-selection.ts | 140 + .../use-sidebar-worktree-sort-order.ts | 207 + .../use-virtual-row-measurement-sync.ts | 116 + .../use-visible-pr-refresh-reporting.ts | 130 + .../use-visible-sidebar-worktrees.ts | 134 + .../use-workspace-status-row-drag.ts | 147 + .../use-worktree-document-drop.ts | 115 + .../use-worktree-drag-runtime.ts | 123 + .../use-worktree-drag-session.ts | 244 + .../use-worktree-lineage-drop-commit.ts | 127 + .../use-worktree-list-keyboard-navigation.ts | 120 + .../use-worktree-list-virtualizer.ts | 159 + .../use-worktree-native-drag-autoscroll.ts | 108 + .../worktree-list/use-worktree-native-drag.ts | 231 + .../use-worktree-pointer-drag-autoscroll.ts | 79 + ...use-worktree-pointer-drag-window-events.ts | 95 + .../use-worktree-pointer-drag.ts | 259 + .../use-worktree-sidebar-header-drag.ts | 222 + ...use-worktree-sidebar-scroll-suppression.ts | 83 + .../use-worktree-status-mutations.ts | 201 + .../virtual-row-dom-attributes.ts | 15 + .../virtualized-worktree-viewport-props.ts | 116 + .../worktree-list/worktree-drag-groups.ts | 72 + .../worktree-drop-commit-context.ts | 43 + .../worktree-list/worktree-list-item-row.tsx | 251 + .../worktree-notice-virtual-rows.tsx | 116 + .../worktree-list/worktree-option-dom.ts | 58 + .../worktree-pointer-drag-commit.ts | 159 + .../worktree-pointer-drag-flush.ts | 172 + .../worktree-list/worktree-row-drag-state.ts | 132 + .../worktree-section-header-row.tsx | 393 + .../worktree-sidebar-scroll-tuning.ts | 42 + .../worktree-virtual-row-context.ts | 149 + .../worktree-virtual-row-dispatch.tsx | 262 + .../worktree-visibility-menu-label.ts | 13 + .../worktree-visible-refresh-listener.ts | 4 + .../worktree-scroll-to-current-button.test.ts | 2 +- .../xterm-bypass-policy-interrupt.test.ts | 33 +- 81 files changed, 8767 insertions(+), 6716 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/FolderPathStatusIndicator.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/HostSectionHeader.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/VirtualizedWorktreeViewport.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/active-descendant-option.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/folder-workspace-virtual-row.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/imported-worktrees-card-visibility.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/mounted-row-reveal.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/pending-sidebar-reveal-inputs.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/pointer-drop-status-target.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/project-group-header-actions.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/render-row-item-rows.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/render-row-worktree-lookup.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/renderable-rows.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/repo-header-event-guards.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/repo-header-project-actions.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/sidebar-editable-target.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/sidebar-row-reveal-ancestors.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/stable-empty-store-slices.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-folder-workspace-path-status-rows.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-group-toggle-with-scroll-anchor.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-highlight.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-requests.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-virtual-row-measurement-sync.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-visible-pr-refresh-reporting.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-workspace-status-row-drag.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-document-drop.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-runtime.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-session.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-lineage-drop-commit.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-list-keyboard-navigation.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-list-virtualizer.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag-autoscroll.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-autoscroll.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-window-events.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-header-drag.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-scroll-suppression.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-status-mutations.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/virtual-row-dom-attributes.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/virtualized-worktree-viewport-props.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-drag-groups.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-drop-commit-context.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-list-item-row.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-notice-virtual-rows.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-option-dom.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-commit.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-flush.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-row-drag-state.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-section-header-row.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-sidebar-scroll-tuning.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-context.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-dispatch.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-visibility-menu-label.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-visible-refresh-listener.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 4375803952e..839595375eb 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -234,7 +234,6 @@ inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts -inline src/renderer/src/components/sidebar/WorktreeList.tsx inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts inline src/renderer/src/components/sidebar/worktree-list-groups.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index 756c99d8522..a8c6045463d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -8,7 +8,7 @@ import type { Repo } from '../../../../shared/repo-types' import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import type * as WorktreeListModule from './WorktreeList' +import { getPinnedWorktreeRevealCollapsedGroupKeys } from './worktree-list/sidebar-row-reveal-ancestors' import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' const mockStore = vi.hoisted(() => ({ @@ -21,7 +21,6 @@ type WorktreeListComponent = React.ComponentType<{ }> let WorktreeList: WorktreeListComponent -let getPinnedWorktreeRevealCollapsedGroupKeys: typeof WorktreeListModule.getPinnedWorktreeRevealCollapsedGroupKeys function makeFolderWorkspacePathStatusMockState(): Record { return { @@ -828,7 +827,6 @@ describe('WorktreeList lineage child card renderer', () => { beforeAll(async () => { const module = await import('./WorktreeList') WorktreeList = module.default as WorktreeListComponent - getPinnedWorktreeRevealCollapsedGroupKeys = module.getPinnedWorktreeRevealCollapsedGroupKeys }, 60_000) it('renders project group headers when repos import before worktree rows load', async () => { diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 138f36a3a2f..dbbee941a4f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -1,5208 +1,42 @@ -/* eslint-disable max-lines */ -import React, { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffect } from 'react' -import { toast } from 'sonner' -import { - measureElement as measureVirtualElementSize, - useVirtualizer -} from '@tanstack/react-virtual' -import type { Range } from '@tanstack/react-virtual' -import { - AlertTriangle, - ChevronDown, - CircleX, - Ellipsis, - Eye, - FolderInput, - FolderPlus, - FolderX, - Loader2, - Plus, - Server, - ServerOff, - Shapes, - SlidersHorizontal, - Trash2 -} from 'lucide-react' +import React, { useCallback, useMemo } from 'react' import { useAppStore } from '@/store' -import { createLineageToggleHandlerCache } from './worktree-lineage-toggle-handler-cache' -import { reuseArrayIfEqual } from './worktree-agent-row-selectors' import { useShallow } from 'zustand/react/shallow' -import type { AppState } from '@/store/types' import { - getAllWorktreesFromState, useAllWorktrees, useProjectHostSetupProjection, useRepoMap, useWorktreeMap } from '@/store/selectors' -import WorktreeCard, { type ActiveSurfaceVariant } from './WorktreeCard' -import { WorktreeSidebarDropIndicator } from './WorktreeSidebarDropIndicator' -import { - getProjectGroupHeaderSectionEndByGroupId, - getRepoHeaderSectionEndByRepoId -} from './worktree-header-section-boundaries' -import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' -import { PendingWorktreeRow } from './PendingWorktreeRow' -import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { cn } from '@/lib/utils' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { ProjectGroup } from '../../../../shared/project-group-types' import type { Repo } from '../../../../shared/repo-types' -import type { ProjectOrderBy } from '../../../../shared/ui-chrome-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' -import type { - WorkspaceStatus, - WorkspaceStatusDefinition, - Worktree -} from '../../../../shared/worktree/types' -import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' -import { buildWorktreeComparator, compareWorktreeSortLabel } from './smart-sort' -import { - buildAttentionByWorktree, - hasFreshAttributedAgentStatus, - type SmartClass, - type WorktreeAttention -} from './smart-attention' -import { track } from '@/lib/telemetry' -import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' -import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' -import { - type Row, - type ProjectGroupingModel, - type WorktreeGroupBy, - ALL_GROUP_KEY, - PINNED_GROUP_KEY, - buildRows, - getProjectGroupHeaderKey, - getGroupKeysForWorktree, - getLineageGroupKey, - getPinnedWorktreeDisplayPolicy, - type PinnedWorktreeDisplayPolicy -} from './worktree-list-groups' -import { - buildLineageRowRekeyMap, - estimateRenderRowSize, - extractWorktreeVirtualRowIndexes, - getActiveStickyIndexesForScroll, - getRenderRowKey, - getStickyHeaderIndexes, - getVirtualRowTransform, - pruneStaleVirtualRowElementCache, - shouldUseHeaderTopSpacing, - WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, - type RenderRow -} from './worktree-list-virtual-rows' -import { - revealElementInScrollContainer, - WORKTREE_SIDEBAR_REVEAL_TOP_INSET -} from './worktree-sidebar-reveal' -import { - createPendingRevealScroll, - isRevealScrollSettling, - type PendingRevealScroll -} from './worktree-sidebar-reveal-scroll-settle' -import { - getWorkspaceStatus, - getWorkspaceStatusFromGroupKey, - getWorkspaceStatusGroupKey, - hasWorkspaceDragData, - readWorkspaceDragDataIds -} from './workspace-status' -import { useWorkspaceStatusDocumentDrop } from './use-workspace-status-drop' -import { - computeClearFilterActions, - computeVisibleWorktreeIds, - setVisibleWorktreeIds, - sidebarHasActiveFilters -} from './visible-worktrees' -import { - EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, - filterFolderWorkspacesFromOtherDevices, - getPairedDeviceIdsByEnvironment -} from './workspace-creator-visibility' -import { - getCyclicProjectedWorktreeLineageIds, - getWorktreeLineageAncestors -} from './worktree-lineage-projection' -import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' -import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos' -import { - getVisibleWorktreeBrowserActivityTabs, - getVisibleWorktreeTerminalActivityTabs -} from './visible-worktree-activity-inputs' -import { selectWorktreeListReviewCacheInputs } from './worktree-list-review-cache-inputs' -import { - VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, - useVirtualizedScrollAnchor, - type VirtualizedScrollAnchor -} from '@/hooks/useVirtualizedScrollAnchor' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry' -import { useWorktreeListScrollToTop } from './use-worktree-list-scroll-to-top' -import { WorktreeListScrollToTopButton } from './WorktreeListScrollToTopButton' -import { - getFolderWorkspacePathStatusDescription, - getFolderWorkspacePathStatusTitle -} from '@/lib/folder-workspace-path-status' -import { getShortcutPlatform } from '@/lib/shortcut-platform' -import { - SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, - type ScrollToCurrentWorkspaceRevealRequestDetail -} from '@/lib/scroll-to-current-workspace-status' -import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' -import { - getLogicalRepoOrderRankById, - getSidebarOrderedRepoHeaderIdsByBucket -} from './project-header-drop' -import { useProjectGroupHeaderDrag } from './project-group-header-drag' -import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop' -import { - buildManualOrderUpdatesForGroupDrop, - buildManualOrderUpdatesForVisibleGroups, - expandDraggedWorktreeIdsForVisibleLineage, - shouldWriteManualOrderForGroupDrop, - type WorktreeDragGroup -} from './worktree-manual-order' -import { - buildWorkspaceKanbanSidebarDropUpdates, - clearWorkspaceKanbanSidebarDropTargetVisual, - getWorkspaceKanbanSidebarDropGroups, - getWorkspaceKanbanSidebarDropTarget, - hasWorkspaceKanbanSidebarDropBoard, - isWorkspaceKanbanSidebarDropPointInBoard, - resolveWorkspaceKanbanSidebarFullLaneDropIndex, - updateWorkspaceKanbanSidebarDropTargetVisual -} from './workspace-kanban-sidebar-drop' -import { - resolveWorkspaceKanbanCardDropCommitTarget, - type WorkspaceKanbanCardTrackedDropTarget -} from './workspace-kanban-card-pointer-drag-dom' -import { - getFullDropIndexForWorktreeDragUnit, - getWorktreeDragUnitGroups -} from './worktree-drag-units' -import { - createSidebarDragPreview, - isSidebarPointerDragBlocked, - setSidebarPointerDragDocumentStyles, - updateSidebarDragPreviewPosition -} from './worktree-sidebar-pointer-drag-dom' -import { - getWorktreeSidebarDragAutoscroll, - getWorktreeSidebarDragRectsForGroup, - refreshWorktreeSidebarDragSession, - type WorktreeSidebarDragRect, - type WorktreeSidebarDragSession, - type WorktreeSidebarDragPoint -} from './worktree-sidebar-drag-autoscroll' -import { - getWorktreeSidebarDragGrab, - shouldReevaluateWorktreeSidebarDropAnchor, - type WorktreeSidebarDragGrab, - type WorktreeSidebarDropAnchor -} from './worktree-sidebar-drag-geometry' -import { - computeWorktreeSidebarDropPreview, - resolveWorktreeSidebarStatusDropCommitTarget, - type WorktreeSidebarStatusDropTarget, - type WorktreeSidebarTrackedStatusDropTarget, - type WorktreeSidebarDropPreview -} from './worktree-sidebar-drop-preview' -import { - getReorderedWorktreeIdsToUnnest, - getWorktreeLineageDropTargetId -} from './worktree-lineage-drag-drop' -import { resolveProjectGroupHeaderColor } from './project-header-color' -import { - REPO_HEADER_ACTION_BUTTON_CLASS, - REPO_HEADER_ACTION_REVEAL_CLASS -} from './repo-header-action-button-class' -import { - areWorktreeSelectionsEqual, - getWorktreeSelectionIntent, - pruneWorktreeSelection, - updateWorktreeSelection -} from './worktree-multi-selection' -import { persistWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-persistence' import { getRepoExecutionHostId, - getSettingsFocusedExecutionHostId, - getWorktreeExecutionHostId, - type ExecutionHostId + getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' -import { getRepoHeaderCreateState } from './repo-header-create-state' -import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' -import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets' -import { keybindingMatchesAction } from '../../../../shared/keybindings' -import { ProjectGroupNameDialog } from './ProjectGroupNameDialog' -import { ProjectGroupDeleteDialog } from './ProjectGroupDeleteDialog' -import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets' -import { findRepoForHost } from '@/store/slices/repo-host-identity' -import { isGitRepoKind } from '../../../../shared/repo-kind' +import { getActiveSidebarWorkspaceId } from '../../../../shared/workspace-scope' +import { getPinnedWorktreeDisplayPolicy } from './worktree-list-groups' +import { selectWorktreeListReviewCacheInputs } from './worktree-list-review-cache-inputs' +import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' +import { SidebarWorktreeListDialogs } from './worktree-list/SidebarWorktreeListDialogs' +import { SidebarWorktreeListEmptyState } from './worktree-list/SidebarWorktreeListEmptyState' +import { VirtualizedWorktreeViewport } from './worktree-list/VirtualizedWorktreeViewport' +import { markSidebarWorktreeActiveImmediately } from './worktree-list/worktree-option-dom' import { - effectiveExternalWorktreeVisibility, - isLegacyRepoForExternalWorktreeVisibility -} from '../../../../shared/worktree/ownership' -import { RepoIconGlyph } from '@/components/repo/repo-icon' -import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' -import ImportedWorktreesVisibilityLine from './ImportedWorktreesVisibilityLine' -import NewExternalWorktreesInboxLine from './NewExternalWorktreesInboxLine' -import SuppressExternalWorktreeInboxDialog from './SuppressExternalWorktreeInboxDialog' -import { - keepImportedWorktreesHiddenCard, - IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR, - showImportedWorktreesCard, - type ImportedWorktreeCardActionState -} from './imported-worktrees-card-actions' -import { - suppressNewExternalWorktreeInbox, - type NewExternalWorktreesInboxActionState -} from './new-external-worktrees-inbox-actions' -import { isEligibleWorktreeParent } from './worktree-parent-candidates' -import { - buildImportedWorktreesCardCandidates, - getHiddenImportedWorktrees -} from './imported-worktrees-card-candidates' -import { buildNewExternalWorktreesInboxCandidates } from './new-external-worktrees-inbox-candidates' -import { - WORKTREE_SECTION_HEADER_PADDING_LEFT, - LINEAGE_CHILDREN_INLINE_OFFSET, - getFolderBackedRepoWorktreeCardContentIndent, - getFolderBackedRepoWorktreeCardSurfaceInset, - getFolderWorkspaceRowGeometry, - getLineageChildrenInlineStyle, - getLineageNestedRowGeometry, - getProjectGroupHeaderPaddingLeft, - getWorktreeCardContentIndent, - getWorktreeCardSurfaceInset -} from './worktree-list-indentation' -import { addHostSectionRows, type HostHeaderRow, type HostSectionRow } from './host-section-rows' -import { orderHostSectionOptions } from './host-section-order' -import { useHostHeaderDrag } from './host-header-drag' -import { buildSidebarHostOptions } from './sidebar-host-options' -import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' -import { ProjectHeaderActions } from './ProjectHeaderActions' -import { translate } from '@/i18n/i18n' -import { folderWorkspaceKey, getActiveSidebarWorkspaceId } from '../../../../shared/workspace-scope' -import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' -import { - isConfirmedStaleFolderPathStatus, - type FolderWorkspacePathStatus -} from '../../../../shared/folder-workspace-path-status' -import { - getFolderWorkspaceRevealGroupKeys, - getKnownSidebarWorktreeById, - sidebarWorkspaceStillExists -} from './worktree-list-folder-reveal' -import { - filterFolderWorkspacesForVisibleHosts, - filterProjectGroupsForVisibleHosts, - getFolderPathStatusRouteOptionsForRows, - getVisibleSidebarHostIdSet -} from './worktree-list-host-filtering' -import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' -import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference' -import { getCyclableWorktreeIds, resolveCycledWorktreeId } from './worktree-keyboard-cycle' - -export { - getScrollTopToRevealBounds, - WORKTREE_SIDEBAR_REVEAL_TOP_INSET -} from './worktree-sidebar-reveal' - -type ProjectGroupNameDialogState = - | { type: 'create-from-repo'; repo: Repo } - | { type: 'rename'; groupId: string; currentName: string } - -type ProjectGroupDeleteDialogState = { - groupId: string - groupName: string - removeContainedProjects: boolean -} - -// Why: epoch-driven recomputes often produce arrays whose contents and order are unchanged; reusing the previous identity when element-wise equal keeps downstream memos and React.memo'd cards bailing out. Safe only because elements (Worktree objects / id strings) are immutably REPLACED on change — never wrap arrays of mutated-in-place objects. -function useReusedArrayIdentity(next: T[]): T[] { - const previousRef = useRef(next) - const result = reuseArrayIfEqual(previousRef.current, next) - previousRef.current = result - return result -} - -// Debounce re-sort after a sortEpoch bump so background score changes don't jar row positions. -const SORT_SETTLE_MS = 3_000 -const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500 -const EMPTY_PROJECT_GROUPS: readonly ProjectGroup[] = [] -const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} -const EMPTY_WORKTREE_ID_SET: ReadonlySet = new Set() -const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} -const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} -const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} -const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} -const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300 -const NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK = (): void => {} -const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { - // Why: TanStack Virtual owns scroll correction; native overflow anchoring fights it and causes jumps. - overflowAnchor: 'none' -} - -const recordKeyCountCache = new WeakMap, number>() - -export function countRecordKeysByReference(record: Record): number { - const cached = recordKeyCountCache.get(record) - if (cached !== undefined) { - return cached - } - const count = Object.keys(record).length - recordKeyCountCache.set(record, count) - return count -} - -export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: { - isScrolling: boolean - now: number - suppressUntil: number -}): boolean { - return !args.isScrolling && args.now >= args.suppressUntil -} - -export function resolvePendingSidebarReveal(args: { - targetIndex: number - targetWorktreeStillExists: boolean -}): 'scroll-and-clear' | 'clear' | 'keep-pending' { - if (args.targetIndex !== -1) { - return 'scroll-and-clear' - } - return args.targetWorktreeStillExists ? 'keep-pending' : 'clear' -} - -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false - } - - // xterm's hidden input textarea isn't a real text field; treating it as one would block sidebar shortcuts. - if (target.classList.contains('xterm-helper-textarea')) { - return false - } - - if (target.isContentEditable) { - return true - } - - return ( - target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== - null - ) -} - -function stopRepoHeaderKeyboardToggle(event: React.KeyboardEvent): void { - if (event.key === 'Enter' || event.key === ' ') { - event.stopPropagation() - } -} - -function stopNestedWorktreeCardBubble(event: React.SyntheticEvent): void { - event.stopPropagation() -} - -function handleRepoHeaderActionPointerDown(event: React.PointerEvent): void { - event.stopPropagation() -} - -function handleRepoHeaderCollapseAffordancePointerDown( - event: React.PointerEvent -): void { - // Why: keep collapse-chevron clicks from arming the repo-header row drag. - event.stopPropagation() -} - -function stopRepoHeaderMenuEvent(event: React.SyntheticEvent): void { - event.stopPropagation() -} - -function shouldIgnoreRepoHeaderToggle(event: React.SyntheticEvent): boolean { - return isRepoHeaderActionTarget(event.target, event.currentTarget) -} - -function getWorktreeOptionId(rowKey: string): string { - return `worktree-list-option-${encodeURIComponent(rowKey)}` -} - -function getMountedWorktreeOptions(worktreeId: string, root?: ParentNode | null): HTMLElement[] { - const scope = root ?? document - const result: HTMLElement[] = [] - scope.querySelectorAll('[data-worktree-id]').forEach((element) => { - if (element.dataset.worktreeId === worktreeId) { - result.push(element) - } - }) - return result -} - -function markSidebarWorktreeActiveImmediately(worktreeId: string, primaryRowKey?: string): void { - const sidebar = document.querySelector('[data-worktree-sidebar]') - const nextOptions = getMountedWorktreeOptions(worktreeId, sidebar) - const nextOption = nextOptions[0] - if (!nextOption) { - return - } - - sidebar - ?.querySelectorAll('[role="option"][aria-current="page"]') - .forEach((option) => option.removeAttribute('aria-current')) - - for (const option of nextOptions) { - option.setAttribute('aria-current', 'page') - } - sidebar - ?.querySelectorAll('[data-worktree-card-surface][data-worktree-card-active]') - .forEach((surface) => { - if (!nextOptions.some((option) => option.contains(surface))) { - surface.removeAttribute('data-worktree-card-active') - } - }) - for (const option of nextOptions) { - const activeSurfaceVariant = - primaryRowKey !== undefined - ? option.dataset.worktreeRowKey === primaryRowKey - ? 'primary' - : 'secondary' - : option === nextOption - ? 'primary' - : 'secondary' - const surface = option.matches('[data-worktree-card-surface]') - ? option - : option.querySelector('[data-worktree-card-surface]') - surface?.setAttribute('data-worktree-card-active', activeSurfaceVariant) - } -} - -function revealMountedWorktreeElement( - container: HTMLElement, - worktreeId: string, - behavior: ScrollBehavior, - optionId?: string, - onScrollIssued?: (targetTop: number) => void -): HTMLElement | null { - const element = optionId - ? document.getElementById(optionId) - : getMountedWorktreeOptions(worktreeId, container)[0] - if (!element || !container.contains(element)) { - return null - } - return revealElementInScrollContainer(container, element, behavior, onScrollIssued) - ? element - : null -} - -function revealMountedSidebarRowElement( - container: HTMLElement, - rowKey: string, - behavior: ScrollBehavior, - onScrollIssued?: (targetTop: number) => void -): HTMLElement | null { - const element = document.getElementById(getWorktreeOptionId(rowKey)) - if (!element || !container.contains(element)) { - return null - } - return revealElementInScrollContainer(container, element, behavior, onScrollIssued) - ? element - : null -} - -function getRenderRowSidebarKey(row: RenderRow): string | null { - if (row.type === 'header') { - return row.key - } - if (row.type === 'item') { - return row.rowKey - } - if (row.type === 'folder-workspace') { - return folderWorkspaceKey(row.folderWorkspace.id) - } - if (row.type === 'pending-creation') { - return `pending:${row.creationId}` - } - if (row.type === 'imported-worktrees-card') { - return row.key - } - if (row.type === 'new-external-worktrees-inbox') { - return row.key - } - return null -} - -function rowKeyMatchesRenderRow(row: RenderRow, rowKey: string): boolean { - if (row.type === 'lineage-group') { - return row.rows.some((item) => item.rowKey === rowKey) - } - return getRenderRowSidebarKey(row) === rowKey -} - -function getProjectIdFromHeaderRowKey(rowKey: string): string | null { - if (!rowKey.startsWith('project:')) { - return null - } - const withoutPrefix = rowKey.slice('project:'.length) - const setupSeparator = withoutPrefix.indexOf('::setup:') - return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) -} - -function getRepoIdsFromHeaderRowKey( - rowKey: string, - repoMap: Map, - projectGrouping?: ProjectGroupingModel -): string[] { - if (rowKey.startsWith('repo:')) { - return [rowKey.slice('repo:'.length)] - } - const setupMarker = '::setup:' - const setupIndex = rowKey.indexOf(setupMarker) - if (rowKey.startsWith('project:') && setupIndex !== -1) { - return [rowKey.slice(setupIndex + setupMarker.length)] - } - const projectId = getProjectIdFromHeaderRowKey(rowKey) - if (!projectId) { - return [] - } - const repoIds = new Set() - for (const setup of projectGrouping?.projectHostSetups ?? []) { - if (setup.projectId === projectId && repoMap.has(setup.repoId)) { - repoIds.add(setup.repoId) - } - } - const project = projectGrouping?.projects.find((candidate) => candidate.id === projectId) - for (const repoId of project?.sourceRepoIds ?? []) { - if (repoMap.has(repoId)) { - repoIds.add(repoId) - } - } - return [...repoIds] -} - -function getProjectGroupAncestorKeys( - projectGroupId: string | null | undefined, - projectGroups: readonly ProjectGroup[] -): string[] { - const groupsById = new Map(projectGroups.map((group) => [group.id, group])) - const keys: string[] = [] - const seen = new Set() - let currentGroupId = projectGroupId ?? null - while (currentGroupId && !seen.has(currentGroupId)) { - const group = groupsById.get(currentGroupId) - if (!group) { - break - } - seen.add(currentGroupId) - keys.unshift(getProjectGroupHeaderKey(group.id)) - currentGroupId = group.parentGroupId - } - return keys -} - -function getSidebarRowRevealAncestorKeys(args: { - rowKey: string - repoMap: Map - projectGroups: readonly ProjectGroup[] - projectGrouping?: ProjectGroupingModel -}): string[] { - if (args.rowKey.startsWith('project-group:')) { - const groupId = args.rowKey.slice('project-group:'.length) - const group = args.projectGroups.find((candidate) => candidate.id === groupId) - return getProjectGroupAncestorKeys(group?.parentGroupId, args.projectGroups) - } - const keys = new Set() - for (const repoId of getRepoIdsFromHeaderRowKey( - args.rowKey, - args.repoMap, - args.projectGrouping - )) { - const repo = args.repoMap.get(repoId) - for (const key of getProjectGroupAncestorKeys(repo?.projectGroupId, args.projectGroups)) { - keys.add(key) - } - } - return [...keys] -} - -function getWorktreeVisibilityMenuLabel(repo: Repo): string { - const visibility = effectiveExternalWorktreeVisibility( - repo, - isLegacyRepoForExternalWorktreeVisibility(repo) - ) - return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees' -} - -const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4 -type VirtualizedWorktreeViewportProps = { - rows: HostSectionRow[] - activeWorktreeId: string | null - currentWorktreeId: string | null - groupBy: WorktreeGroupBy - pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy - projectOrderBy: ProjectOrderBy - toggleGroup: (key: string) => void - collapsedGroups: Set - handleCreateForRepo: (projectId: string) => void - handleOpenRepoSettings: (projectId: string, sectionId?: string) => void - handleOpenWorktreeVisibility: (repo: Repo) => void - handleShowImportedWorktrees: (projectId: string) => void - handleKeepImportedWorktreesHidden: (projectId: string) => void - importedWorktreeCardActionState: ReadonlyMap - handleOpenSuppressExternalWorktreeInbox: (projectId: string) => void - newExternalWorktreeInboxActionState: ReadonlyMap - handleRemoveProject: (repo: Repo) => void - handleCreateGroupFromRepo: (repo: Repo) => void - handleMoveProjectToGroup: (repo: Repo, groupId: string) => void - handleRemoveProjectFromGroup: (repo: Repo) => void - handleRenameProjectGroup: (groupId: string, currentName: string) => void - handleDeleteProjectGroup: (groupId: string, groupName: string) => void - handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void - activeModal: string - pendingRevealWorktree: PendingSidebarWorktreeReveal | null - pendingRevealSidebarRow: PendingSidebarRowReveal | null - clearPendingRevealWorktreeId: () => void - clearPendingRevealSidebarRow: () => void - agentSendTargetWorktreeId: string | null - worktrees: Worktree[] - folderWorkspaces: readonly FolderWorkspace[] - selectedWorktreeIds: ReadonlySet - selectedWorktrees: readonly Worktree[] - onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean - onImmediateWorktreeActivate: (worktreeId: string, rowKey: string | undefined) => void - onContextMenuSelect: ( - event: React.MouseEvent, - worktree: Worktree - ) => readonly Worktree[] - repoMap: Map - defaultHostId: ExecutionHostId - worktreeMap: Map - worktreeLineageById: Record - workspaceLineageByChildKey: Record - // Full canonical repo-id order; must include hidden repos or a reorder silently drops them. - allRepoIds: string[] - onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void - onHostDragActiveChange: (active: boolean) => void - prCache: AppState['prCache'] | null - hostedReviewCache: AppState['hostedReviewCache'] | null - workspaceStatuses: readonly WorkspaceStatusDefinition[] - projectGrouping?: ProjectGroupingModel - projectGroups?: readonly ProjectGroup[] - onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void - onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void - onMoveWorktreesToStatusAtIndex: (args: { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] - }) => void - onPinWorktree: (worktreeId: string) => void - onPinWorktrees: (worktreeIds: readonly string[]) => void - onDropWorktreesOnWorkspaceBoard: (args: { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] - }) => void - workspaceBoardOpen: boolean - onWorkspaceBoardDragPreviewStart: () => void - onWorkspaceBoardDragPreviewCommit: () => void - onWorkspaceBoardDragPreviewCancel: () => void - shouldShowWorkspaceBoardDropIndicator: ( - worktreeIds: readonly string[], - status: WorkspaceStatus - ) => boolean - onReorderWorktrees: (args: { - groups: readonly WorktreeDragGroup[] - sourceGroupKey: string - draggedIds: readonly string[] - dropIndex: number - }) => void - // Why: grouping remounts the viewport, add/delete stays mounted; bridge both so the virtualizer never resets to scrollTop 0. - scrollOffsetRef: React.MutableRefObject - scrollAnchorRef: React.MutableRefObject -} - -type WorktreeItemRow = Extract -type FolderWorkspaceItemRow = Extract - -function formatSectionActivityLabel(count: number, label: string): string { - return `${count} ${label}${count === 1 ? '' : 's'}` -} - -function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element { - const totalLabel = formatSectionActivityLabel(count, 'workspace') - - return ( - - - - - {count} - - - - {totalLabel} - - - - ) -} - -function HostHeaderHealthIcon({ - health -}: { - health: HostHeaderRow['health'] -}): React.JSX.Element | null { - // Why: only surface states needing attention; healthy is the silent default. - if (health === 'connecting') { - return - } - if (health === 'blocked' || health === 'error') { - return - } - return null -} - -function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } | null { - // Why: a blocked compatibility verdict earns a compact warning so the host stands out. - if (row.health === 'blocked') { - return { - text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'), - isWarning: true - } - } - // Why: auth-failed needs a worded status; the health icon alone doesn't tell the user to re-auth. - if (row.connectionStatus === 'auth-failed') { - return { - text: translate( - 'auto.components.sidebar.WorktreeList.hostAuthNeeded', - 'Authentication needed' - ), - isWarning: true - } - } - if (row.health === 'disconnected') { - return { - text: translate('auto.components.sidebar.WorktreeList.hostDisconnected', 'Disconnected'), - isWarning: false - } - } - // Why: show the transport detail only for remote hosts; it's noise under the local label. - if (row.kind !== 'local') { - return { text: row.detail, isWarning: false } - } - return null -} - -function HostSectionHeader({ - row, - onToggle, - onDragPointerDown, - dragging -}: { - row: HostHeaderRow - onToggle: () => void - onDragPointerDown?: (event: React.PointerEvent) => void - dragging?: boolean -}): React.JSX.Element { - const isBlocked = row.health === 'blocked' - const isDisconnected = row.health === 'disconnected' - const detail = getHostHeaderDetail(row) - return ( -
- {/* Why: outlined card + server glyph marks hosts as machines, not mere groups. */} -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onToggle() - } - }} - > - {isDisconnected ? ( - - ) : ( - - )} - - {/* Why: badge hugs the label (like repo headers) instead of floating by the hover controls. */} -
- - {row.label} - - {detail ? ( - - {detail.text} - - ) : null} - -
-
- -
- - - -
-
- ) -} - -function FolderPathStatusIndicator({ - status -}: { - status: FolderWorkspacePathStatus | null | undefined -}): React.JSX.Element | null { - const title = getFolderWorkspacePathStatusTitle(status) - if (!status || status.exists || !title) { - return null - } - const destructive = isConfirmedStaleFolderPathStatus(status) - return ( - - - - - - - -
-
{title}
-
- {getFolderWorkspacePathStatusDescription(status)} -
-
-
-
- ) -} - -type WorktreeRowDragState = { - draggingWorktreeId: string | null - sourceGroupKey: string | null - dropIndex: number | null - dropIndicatorY: number | null - previewOffsetsByWorktreeId: ReadonlyMap - pointerY: number | null -} - -const EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS: ReadonlyMap = new Map() - -const WORKTREE_ROW_DRAG_INITIAL_STATE: WorktreeRowDragState = { - draggingWorktreeId: null, - sourceGroupKey: null, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null -} - -type WorktreePointerDrag = { - pointerId: number - sourceRow: HTMLElement - startX: number - startY: number - currentX: number - currentY: number - worktreeId: string - draggedIds: readonly string[] - reorderDraggedIds: readonly string[] - reorderUnitDraggedIds: readonly string[] - sourceGroupKey: string - rects: readonly WorktreeSidebarDragRect[] - active: boolean - preview: HTMLElement | null - previewOffsetX: number - previewOffsetY: number - workspaceBoardDragPreviewRequested: boolean - frameId: number | null - latestBoardDropTarget: WorkspaceKanbanCardTrackedDropTarget | null - latestStatusDropTarget: WorktreeSidebarTrackedStatusDropTarget | null -} - -function areWorktreeDragPreviewOffsetsEqual( - a: ReadonlyMap, - b: ReadonlyMap -): boolean { - if (a === b) { - return true - } - if (a.size !== b.size) { - return false - } - for (const [key, value] of a) { - if (b.get(key) !== value) { - return false - } - } - return true -} - -function updateLatestWorktreeStatusDropTarget( - drag: WorktreePointerDrag, - target: WorktreeSidebarStatusDropTarget & { lineageParentId: string | null }, - preview: WorktreeSidebarDropPreview | null -): void { - drag.latestStatusDropTarget = - target.status || target.isPinDrop || target.lineageParentId - ? { - target, - preview, - x: drag.currentX, - y: drag.currentY - } - : null -} - -function getWorktreeVirtualRowTransform(start: number, previewOffset: number): string { - const base = getVirtualRowTransform(start) - return previewOffset === 0 ? base : `${base} translateY(${previewOffset}px)` -} - -function getPointerDropStatusTarget(args: { - container: HTMLElement - x: number - y: number -}): WorktreeSidebarStatusDropTarget & { lineageParentId: string | null } { - const target = document.elementFromPoint(args.x, args.y) - if (!(target instanceof Element) || !args.container.contains(target)) { - return { status: null, isPinDrop: false, lineageParentId: null } - } - const pinTarget = target.closest('[data-workspace-pin-drop-target]') - if (pinTarget && args.container.contains(pinTarget)) { - return { status: null, isPinDrop: true, lineageParentId: null } - } - const lineageParentId = getWorktreeLineageDropTargetId({ - container: args.container, - target, - pointerY: args.y - }) - const statusTarget = target.closest('[data-workspace-status-drop-target]') - return { - status: - statusTarget && args.container.contains(statusTarget) - ? ((statusTarget.dataset.workspaceStatus as WorkspaceStatus | undefined) ?? null) - : null, - isPinDrop: false, - lineageParentId - } -} - -function shouldPreferSidebarStatusDropTarget(args: { - sourceGroupKey: string - target: WorktreeSidebarStatusDropTarget - workspaceStatuses: readonly WorkspaceStatusDefinition[] -}): boolean { - if (args.target.isPinDrop) { - return true - } - if (!args.target.status) { - return false - } - const sourceStatus = getWorkspaceStatusFromGroupKey(args.sourceGroupKey, args.workspaceStatuses) - // Why: overlapping edge zones — the section under the pointer must win so guide and drop agree. - return sourceStatus !== null && args.target.status !== sourceStatus -} - -function isWorktreeItemRow(row: HostSectionRow): row is WorktreeItemRow { - return row.type === 'item' -} - -export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | null): boolean { - if (worktreeId === null) { - return false - } - if (row.type === 'folder-workspace') { - return folderWorkspaceKey(row.folderWorkspace.id) === worktreeId - } - if (row.type === 'lineage-group') { - return row.rows.some((item) => item.worktree.id === worktreeId) - } - return row.type === 'item' && row.worktree.id === worktreeId -} - -function isPinnedWorktreeRow(row: WorktreeItemRow): boolean { - return row.sectionKey === PINNED_GROUP_KEY -} - -function getRenderRowWorktreeItem(row: RenderRow, worktreeId: string): WorktreeItemRow | null { - if (row.type === 'lineage-group') { - return row.rows.find((item) => item.worktree.id === worktreeId) ?? null - } - return row.type === 'item' && row.worktree.id === worktreeId ? row : null -} - -function getRenderRowOptionId( - row: RenderRow | undefined, - worktreeId?: string | null -): string | undefined { - if (!row) { - return undefined - } - if (row.type === 'lineage-group') { - const targetRow = worktreeId ? row.rows.find((item) => item.worktree.id === worktreeId) : null - return getWorktreeOptionId((targetRow ?? row.rows[0])?.rowKey ?? row.key) - } - if (row.type === 'item') { - return getWorktreeOptionId(row.rowKey) - } - if (row.type === 'folder-workspace') { - return getWorktreeOptionId(folderWorkspaceKey(row.folderWorkspace.id)) - } - return undefined -} - -function getActiveDescendantOptionId(args: { - activeWorktreeId: string | null - primaryActiveRowKey?: string - pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy - renderRows: readonly RenderRow[] - virtualItems: readonly { index: number }[] -}): string | undefined { - if (args.activeWorktreeId === null) { - return undefined - } - if (args.primaryActiveRowKey) { - const primaryOptionId = getWorktreeOptionId(args.primaryActiveRowKey) - for (const item of args.virtualItems) { - const row = args.renderRows[item.index] - if (row && getRenderRowOptionId(row, args.activeWorktreeId) === primaryOptionId) { - return primaryOptionId - } - } - } - let fallbackOptionId: string | undefined - for (const item of args.virtualItems) { - const row = args.renderRows[item.index] - if (row && renderRowContainsWorktree(row, args.activeWorktreeId)) { - const optionId = getRenderRowOptionId(row, args.activeWorktreeId) - if (!optionId) { - continue - } - const itemRow = getRenderRowWorktreeItem(row, args.activeWorktreeId) - if ( - args.pinnedDisplayPolicy === 'duplicate-in-groups' && - itemRow && - !isPinnedWorktreeRow(itemRow) - ) { - return optionId - } - fallbackOptionId ??= optionId - } - } - return fallbackOptionId -} - -function findPreferredRenderRowIndexForWorktree( - renderRows: readonly RenderRow[], - worktreeId: string, - pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy -): number { - let fallbackIndex = -1 - for (let index = 0; index < renderRows.length; index++) { - const row = renderRows[index] - if (!renderRowContainsWorktree(row, worktreeId)) { - continue - } - if (fallbackIndex === -1) { - fallbackIndex = index - } - const itemRow = getRenderRowWorktreeItem(row, worktreeId) - if (pinnedDisplayPolicy === 'duplicate-in-groups' && itemRow && !isPinnedWorktreeRow(itemRow)) { - return index - } - } - return fallbackIndex -} - -export function getPinnedWorktreeRevealCollapsedGroupKeys({ - worktree, - collapsedGroups -}: { - worktree: Worktree - collapsedGroups: ReadonlySet -}): string[] { - if (!worktree.isPinned) { - return [] - } - const keys: string[] = [] - // Why: the reveal effect already opens this host; re-returning it would toggle it back closed. - if (collapsedGroups.has(PINNED_GROUP_KEY)) { - keys.push(PINNED_GROUP_KEY) - } - return keys -} - -function uniqueWorktreeIds(ids: readonly string[]): string[] { - return Array.from(new Set(ids)) -} - -function buildRenderableRows(rows: HostSectionRow[]): RenderRow[] { - const renderRows: RenderRow[] = [] - for (let index = 0; index < rows.length; index++) { - const row = rows[index] - if ( - !isWorktreeItemRow(row) || - row.lineageChildCount === 0 || - row.lineageCollapsed || - rows[index + 1]?.type !== 'item' || - (rows[index + 1] as WorktreeItemRow).depth <= row.depth - ) { - renderRows.push(row) - continue - } - - const groupRows: WorktreeItemRow[] = [row] - let cursor = index + 1 - while (cursor < rows.length) { - const child = rows[cursor] - if (!isWorktreeItemRow(child) || child.depth <= row.depth) { - break - } - groupRows.push(child) - cursor++ - } - renderRows.push({ - type: 'lineage-group', - key: `${row.sectionKey}:${getLineageGroupKey(row.worktree.id)}`, - rows: groupRows - }) - index = cursor - 1 - } - return renderRows -} - -// Why: getRenderRowKey lives with the other virtual-row helpers now; keep the -// long-standing import path working for callers that reach for it here. -export { getRenderRowKey } - -export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { - const groups: WorktreeDragGroup[] = [] - let current: { key: string; ids: string[] } | null = null - const naturalWorktreeIds = new Set( - rows.flatMap((row) => - row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] - ) - ) - - for (const row of rows) { - if (row.type === 'header') { - current = { key: row.key, ids: [] } - groups.push({ key: current.key, worktreeIds: current.ids }) - continue - } - if ( - row.type === 'host-header' || - row.type === 'imported-worktrees-card' || - row.type === 'new-external-worktrees-inbox' || - row.type === 'pending-creation' || - row.type === 'folder-workspace' - ) { - continue - } - if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { - continue - } - if (!current) { - current = { key: ALL_GROUP_KEY, ids: [] } - groups.push({ key: current.key, worktreeIds: current.ids }) - } - current.ids.push(row.worktree.id) - } - - return groups.filter((group) => group.worktreeIds.length > 0) -} - -export function canKeepImportedWorktreesHidden( - row: Extract, - actionState: ImportedWorktreeCardActionState | undefined -): boolean { - return row.placement === 'repo-group' && actionState?.forceVisible !== true -} - -export function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): { - groupKeyByRowKey: Map - groupIndexByRowKey: Map -} { - const groupKeyByRowKey = new Map() - const groupIndexByRowKey = new Map() - const groupIndexes = new Map() - const naturalWorktreeIds = new Set( - rows.flatMap((row) => - row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] - ) - ) - for (const row of rows) { - if (row.type === 'header') { - groupIndexes.set(row.key, 0) - continue - } - if (row.type !== 'item') { - continue - } - if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { - continue - } - const index = groupIndexes.get(row.sectionKey) ?? 0 - groupKeyByRowKey.set(row.rowKey, row.sectionKey) - groupIndexByRowKey.set(row.rowKey, index) - groupIndexes.set(row.sectionKey, index + 1) - } - return { groupKeyByRowKey, groupIndexByRowKey } -} - -function getVirtualRowIndex(element: Element): number | null { - const index = Number.parseInt(element.getAttribute('data-index') ?? '', 10) - return Number.isNaN(index) ? null : index -} - -function getVirtualRowKey(element: Element): string | null { - return element.getAttribute('data-worktree-virtual-row-key') -} - -const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport({ - rows, - activeWorktreeId, - currentWorktreeId, - groupBy, - pinnedDisplayPolicy, - projectOrderBy, - toggleGroup, - collapsedGroups, - handleCreateForRepo, - handleOpenRepoSettings, - handleOpenWorktreeVisibility, - handleShowImportedWorktrees, - handleKeepImportedWorktreesHidden, - importedWorktreeCardActionState, - handleOpenSuppressExternalWorktreeInbox, - newExternalWorktreeInboxActionState, - handleRemoveProject, - handleCreateGroupFromRepo, - handleMoveProjectToGroup, - handleRemoveProjectFromGroup, - handleRenameProjectGroup, - handleDeleteProjectGroup, - handleCreateFolderWorkspace, - activeModal, - pendingRevealWorktree, - pendingRevealSidebarRow, - clearPendingRevealWorktreeId, - clearPendingRevealSidebarRow, - agentSendTargetWorktreeId, - worktrees, - folderWorkspaces, - selectedWorktreeIds, - selectedWorktrees, - onSelectionGesture, - onImmediateWorktreeActivate, - onContextMenuSelect, - repoMap, - defaultHostId, - worktreeMap, - worktreeLineageById, - workspaceLineageByChildKey, - allRepoIds, - onReorderHostSections, - onHostDragActiveChange, - prCache, - hostedReviewCache, - workspaceStatuses, - projectGrouping, - projectGroups = EMPTY_PROJECT_GROUPS, - onMoveWorktreeToStatus, - onMoveWorktreesToStatus, - onMoveWorktreesToStatusAtIndex, - onPinWorktree, - onPinWorktrees, - onDropWorktreesOnWorkspaceBoard, - workspaceBoardOpen, - onWorkspaceBoardDragPreviewStart, - onWorkspaceBoardDragPreviewCommit, - onWorkspaceBoardDragPreviewCancel, - shouldShowWorkspaceBoardDropIndicator, - onReorderWorktrees, - scrollOffsetRef, - scrollAnchorRef -}: VirtualizedWorktreeViewportProps) { - const scrollRef = useRef(null) - // Why: callback-ref only mutates scrollRef; state re-runs the scroll-to-top listener attach. - const [scrollElement, setScrollElement] = useState(null) - const suppressMeasurementAdjustmentUntilRef = useRef(0) - const directScrollInputUntilRef = useRef(0) - const [dragOverStatus, setDragOverStatus] = useState(null) - const [pinDragOver, setPinDragOver] = useState(false) - const [nativeLineageDropTargetId, setNativeLineageDropTargetId] = useState(null) - const [worktreeDragState, setWorktreeDragState] = useState( - WORKTREE_ROW_DRAG_INITIAL_STATE - ) - const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) - const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) - const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) - const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) - const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) - const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) - const cyclicLineageIds = useMemo( - () => getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap), - [worktreeLineageById, worktreeMap] - ) - const worktreeDragSessionRef = useRef(null) - // Why: cross-group hovers hit-test a group the session never captured, so hold - // that group's drop decision separately or a card expanding in the target group - // moves the insertion line under a still pointer. - const statusDropAnchorsRef = useRef>(new Map()) - const worktreePointerDragRef = useRef(null) - const worktreePointerAutoscrollFrameIdRef = useRef(null) - const worktreePointerAutoscrollLastFrameTimeRef = useRef(null) - const worktreeNativeAutoscrollFrameIdRef = useRef(null) - const worktreeNativeAutoscrollLastFrameTimeRef = useRef(null) - const worktreeNativeLatestPointRef = useRef(null) - const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null) - const pendingRowRevealRetryRef = useRef<{ rowKey: string; count: number } | null>(null) - const pendingRevealFrameIdsRef = useRef>(new Set()) - const pendingRevealScrollRef = useRef(null) - const revealHighlightFrameIdRef = useRef(null) - const revealHighlightTimeoutRef = useRef(null) - const cancelPendingRevealFrames = useCallback(() => { - for (const frameId of pendingRevealFrameIdsRef.current) { - window.cancelAnimationFrame(frameId) - } - pendingRevealFrameIdsRef.current.clear() - }, []) - const schedulePendingRevealFrame = useCallback((callback: FrameRequestCallback) => { - const frameId = window.requestAnimationFrame((time) => { - pendingRevealFrameIdsRef.current.delete(frameId) - callback(time) - }) - pendingRevealFrameIdsRef.current.add(frameId) - }, []) - const clearRevealHighlightFrame = useCallback(() => { - if (revealHighlightFrameIdRef.current !== null) { - window.cancelAnimationFrame(revealHighlightFrameIdRef.current) - revealHighlightFrameIdRef.current = null - } - }, []) - const clearRevealHighlightTimeout = useCallback(() => { - if (revealHighlightTimeoutRef.current !== null) { - window.clearTimeout(revealHighlightTimeoutRef.current) - revealHighlightTimeoutRef.current = null - } - }, []) - const flashRevealedRow = useCallback( - (rowKey: string) => { - clearRevealHighlightTimeout() - clearRevealHighlightFrame() - // Why: clear before set restarts the CSS glow when revealing the same row repeatedly. - setHighlightedRevealRowKey(null) - revealHighlightFrameIdRef.current = window.requestAnimationFrame(() => { - revealHighlightFrameIdRef.current = null - setHighlightedRevealRowKey(rowKey) - revealHighlightTimeoutRef.current = window.setTimeout(() => { - revealHighlightTimeoutRef.current = null - setHighlightedRevealRowKey(null) - }, 1500) - }) - }, - [clearRevealHighlightFrame, clearRevealHighlightTimeout] - ) - const suppressWorktreeClickUntilRef = useRef(0) - const hasProjectGroups = projectGroups.length > 0 - const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual' - const canReorderProjectGroupHeaders = groupBy === 'repo' && hasProjectGroups - const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) - const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) - const lastVisibleRefreshKeyRef = useRef('') - const reportVisibleGitHubPRRefreshCandidates = useAppStore( - (s) => s.reportVisibleGitHubPRRefreshCandidates - ) - const cardProps = useAppStore((s) => s.worktreeCardProperties) - const rightSidebarShowsPR = useAppStore((s) => rightSidebarShowsPullRequestData(s)) - const keybindings = useAppStore((s) => s.keybindings) - const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) - const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration) - const settings = useAppStore((s) => s.settings) - const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true - const reorderRepos = useAppStore((s) => s.reorderRepos) - const folderBackedProjectGroupIds = useMemo( - () => - new Set( - projectGroups - .filter((group) => group.createdFrom === 'folder-scan') - .map((group) => group.id) - ), - [projectGroups] - ) - const projectGroupByIdForHeaderDrag = useMemo( - () => new Map(projectGroups.map((group) => [group.id, group])), - [projectGroups] - ) - - useEffect( - () => - installWorktreeVisibleRefreshVisibilityListener(() => { - if (document.visibilityState !== 'visible') { - // Why: row identity may be unchanged after a hidden window; reset the key so PR/CI rows refresh. - lastVisibleRefreshKeyRef.current = '__document_hidden__' - return - } - setDocumentVisibilityRevision((revision) => revision + 1) - }), - [] - ) - - // Why: reorder keeps scrollTop stable; flag direct scroll input so anchor-restore won't chase the moved row (jumpy drop). - const commitRepoReorder = useCallback( - (orderedIds: string[]) => { - const suppressUntil = - window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - suppressMeasurementAdjustmentUntilRef.current = suppressUntil - directScrollInputUntilRef.current = suppressUntil - reorderRepos(orderedIds) - }, - [reorderRepos] - ) - const orderedHostIds = useMemo( - () => - rows - .filter((row): row is HostHeaderRow => row.type === 'host-header') - .map((row) => row.hostId), - [rows] - ) - const hostDrag = useHostHeaderDrag({ - orderedHostIds, - onCommit: onReorderHostSections, - getScrollContainer: () => scrollRef.current - }) - useEffect(() => { - onHostDragActiveChange(hostDrag.state.draggingHostId !== null) - }, [hostDrag.state.draggingHostId, onHostDragActiveChange]) - useEffect(() => () => onHostDragActiveChange(false), [onHostDragActiveChange]) - const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows]) - const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows]) - const naturalDragWorktreeIds = useMemo( - () => - new Set( - rows.flatMap((row) => - row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] - ) - ), - [rows] - ) - const worktreeLineageDragRows = useMemo( - () => - rows - .filter((row): row is WorktreeItemRow => row.type === 'item') - .filter( - (row) => - row.sectionKey !== PINNED_GROUP_KEY || !naturalDragWorktreeIds.has(row.worktree.id) - ) - .map((row) => ({ worktreeId: row.worktree.id, depth: row.depth })), - [naturalDragWorktreeIds, rows] - ) - const getReorderDraggedIds = useCallback( - (draggedIds: readonly string[]) => - expandDraggedWorktreeIdsForVisibleLineage(worktreeLineageDragRows, draggedIds), - [worktreeLineageDragRows] - ) - const getReorderUnitDraggedIds = useCallback( - (sourceGroupKey: string, reorderDraggedIds: readonly string[]) => { - const group = worktreeDragUnitGroups.find((candidate) => candidate.key === sourceGroupKey) - if (!group) { - return reorderDraggedIds - } - const unitIds = new Set(group.worktreeIds) - const filtered = reorderDraggedIds.filter((worktreeId) => unitIds.has(worktreeId)) - return filtered.length > 0 ? filtered : reorderDraggedIds - }, - [worktreeDragUnitGroups] - ) - const { groupKeyByRowKey, groupIndexByRowKey } = useMemo( - () => getWorktreeDragIndexes(rows), - [rows] - ) - const refreshWorktreeDragSession = useCallback((): boolean => { - const session = worktreeDragSessionRef.current - const container = scrollRef.current - if (!session || !container) { - return false - } - - const refreshedSession = refreshWorktreeSidebarDragSession({ - session, - groups: worktreeDragGroups, - unitGroups: worktreeDragUnitGroups, - rects: getWorktreeSidebarDragRectsForGroup(container, session.sourceGroupKey) - }) - worktreeDragSessionRef.current = refreshedSession - return refreshedSession !== null - }, [worktreeDragGroups, worktreeDragUnitGroups]) - const computeWorktreeDropForGroup = useCallback( - (args: { - pointerY: number - groupKey: string - rects: readonly WorktreeSidebarDragRect[] - draggedIds: readonly string[] - draggingWorktreeId?: string | null - grab?: WorktreeSidebarDragGrab | null - anchor?: WorktreeSidebarDropAnchor | null - }): WorktreeSidebarDropPreview | null => { - const container = scrollRef.current - if (!container) { - return null - } - const group = worktreeDragUnitGroups.find((candidate) => candidate.key === args.groupKey) - if (!group) { - return null - } - const containerRect = container.getBoundingClientRect() - return computeWorktreeSidebarDropPreview({ - pointerY: args.pointerY, - containerTop: containerRect.top, - scrollTop: container.scrollTop, - rects: args.rects, - groupIds: group.worktreeIds, - draggedIds: args.draggedIds, - draggingWorktreeId: args.draggingWorktreeId, - fallbackGap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, - grab: args.grab, - anchor: args.anchor - }) - }, - [worktreeDragUnitGroups] - ) - const computeWorktreeDrop = useCallback( - (pointerY: number): WorktreeSidebarDropPreview | null => { - const session = worktreeDragSessionRef.current - const container = scrollRef.current - if (!session || !container) { - return null - } - const scrollTop = container.scrollTop - // Why: only real pointer or scroll movement should re-decide the slot; a - // card growing under a still pointer must not move it. - const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ - anchor: session.anchor, - pointerY, - scrollTop - }) - ? null - : session.anchor - const preview = computeWorktreeDropForGroup({ - pointerY, - groupKey: session.sourceGroupKey, - rects: session.rects, - draggedIds: session.reorderUnitDraggedIds, - draggingWorktreeId: session.draggingWorktreeId, - grab: session.grab, - anchor - }) - worktreeDragSessionRef.current = { - ...session, - anchor: preview ? { beforeWorktreeId: preview.dropAnchorId, pointerY, scrollTop } : null - } - return preview - }, - [computeWorktreeDropForGroup] - ) - const computeWorktreeStatusDrop = useCallback( - (args: { - pointerY: number - status: WorkspaceStatus - draggedIds: readonly string[] - }): WorktreeSidebarDropPreview | null => { - const container = scrollRef.current - if (!container) { - return null - } - const groupKey = getWorkspaceStatusGroupKey(args.status) - const session = worktreeDragSessionRef.current - const scrollTop = container.scrollTop - const heldAnchor = statusDropAnchorsRef.current.get(groupKey) ?? null - const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ - anchor: heldAnchor, - pointerY: args.pointerY, - scrollTop - }) - ? null - : heldAnchor - const preview = computeWorktreeDropForGroup({ - pointerY: args.pointerY, - groupKey, - rects: getWorktreeSidebarDragRectsForGroup(container, groupKey), - draggedIds: args.draggedIds, - draggingWorktreeId: session?.draggingWorktreeId ?? null, - grab: session?.grab ?? null, - anchor - }) - if (preview) { - statusDropAnchorsRef.current.set(groupKey, { - beforeWorktreeId: preview.dropAnchorId, - pointerY: args.pointerY, - scrollTop - }) - } else { - statusDropAnchorsRef.current.delete(groupKey) - } - return preview - }, - [computeWorktreeDropForGroup] - ) - const renderRows = useMemo(() => buildRenderableRows(rows), [rows]) - const sidebarRepoHeaderIdsByBucket = useMemo( - () => - getSidebarOrderedRepoHeaderIdsByBucket( - rows.filter((row): row is Row => row.type !== 'host-header') - ), - [rows] - ) - const sidebarProjectGroupHeaderIdsByBucket = useMemo( - () => - getSidebarOrderedProjectGroupHeaderIdsByBucket( - rows.filter((row): row is Row => row.type !== 'host-header'), - projectGroupByIdForHeaderDrag - ), - [projectGroupByIdForHeaderDrag, rows] - ) - const repoHeaderIndexByRepoId = useMemo(() => { - const map = new Map() - for (const repoIds of sidebarRepoHeaderIdsByBucket.values()) { - repoIds.forEach((repoId, index) => { - map.set(repoId, index) - }) - } - return map - }, [sidebarRepoHeaderIdsByBucket]) - const repoHeaderBucketByRepoId = useMemo(() => { - const map = new Map() - for (const [bucketKey, repoIds] of sidebarRepoHeaderIdsByBucket) { - for (const repoId of repoIds) { - map.set(repoId, bucketKey) - } - } - return map - }, [sidebarRepoHeaderIdsByBucket]) - const projectGroupHeaderIndexByGroupId = useMemo(() => { - const map = new Map() - for (const groupIds of sidebarProjectGroupHeaderIdsByBucket.values()) { - groupIds.forEach((groupId, index) => { - map.set(groupId, index) - }) - } - return map - }, [sidebarProjectGroupHeaderIdsByBucket]) - const projectGroupHeaderBucketByGroupId = useMemo(() => { - const map = new Map() - for (const [bucketKey, groupIds] of sidebarProjectGroupHeaderIdsByBucket) { - for (const groupId of groupIds) { - map.set(groupId, bucketKey) - } - } - return map - }, [sidebarProjectGroupHeaderIdsByBucket]) - const commitProjectGroupOrder = useCallback( - (repoId: string, projectGroupId: string | null, order: number) => { - void moveProjectToGroup(repoId, projectGroupId, order) - }, - [moveProjectToGroup] - ) - const commitProjectGroupHeaderOrder = useCallback( - (groupId: string, tabOrder: number) => { - if (!Number.isFinite(tabOrder)) { - return - } - const suppressUntil = - window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - suppressMeasurementAdjustmentUntilRef.current = suppressUntil - directScrollInputUntilRef.current = suppressUntil - void updateProjectGroup(groupId, { tabOrder }) - }, - [updateProjectGroup] - ) - // Drag applies only in manual order; still construct the controller inert for stable hook order. - const repoDrag = useRepoHeaderDrag({ - orderedRepoIds: allRepoIds, - sidebarRepoHeaderIdsByBucket, - repoById: repoMap, - usesProjectGroupOrdering: hasProjectGroups, - onCommitRepoOrder: commitRepoReorder, - onCommitProjectGroupOrder: commitProjectGroupOrder, - getScrollContainer: () => scrollRef.current - }) - const projectGroupDrag = useProjectGroupHeaderDrag({ - sidebarProjectGroupHeaderIdsByBucket, - projectGroupById: projectGroupByIdForHeaderDrag, - onCommitProjectGroupTabOrder: commitProjectGroupHeaderOrder, - getScrollContainer: () => scrollRef.current - }) - const [primaryActiveWorktreeRow, setPrimaryActiveWorktreeRow] = useState<{ - worktreeId: string - rowKey: string - } | null>(null) - useEffect(() => { - if (activeWorktreeId === null) { - setPrimaryActiveWorktreeRow(null) - return - } - setPrimaryActiveWorktreeRow((current) => { - if (current === null || current.worktreeId !== activeWorktreeId) { - return null - } - const rowStillVisible = rows.some( - (row) => - row.type === 'item' && - row.worktree.id === current.worktreeId && - row.rowKey === current.rowKey - ) - return rowStillVisible ? current : null - }) - }, [activeWorktreeId, rows]) - const getActiveSurfaceVariant = useCallback( - (row: WorktreeItemRow): ActiveSurfaceVariant => { - if (primaryActiveWorktreeRow?.worktreeId === row.worktree.id) { - return primaryActiveWorktreeRow.rowKey === row.rowKey ? 'primary' : 'secondary' - } - if ( - pinnedDisplayPolicy === 'duplicate-in-groups' && - activeWorktreeId === row.worktree.id && - isPinnedWorktreeRow(row) - ) { - return 'secondary' - } - return 'primary' - }, - [activeWorktreeId, pinnedDisplayPolicy, primaryActiveWorktreeRow] - ) - const handleImmediateWorktreeRowActivate = useCallback( - (worktreeId: string, rowKey: string | undefined): void => { - setPrimaryActiveWorktreeRow(rowKey ? { worktreeId, rowKey } : null) - onImmediateWorktreeActivate(worktreeId, rowKey) - }, - [onImmediateWorktreeActivate] - ) - const firstHeaderIndex = useMemo( - () => renderRows.findIndex((row) => row.type === 'header' || row.type === 'host-header'), - [renderRows] - ) - const repoHeaderSectionEndByRepoId = useMemo( - () => - getRepoHeaderSectionEndByRepoId({ - rows: renderRows, - firstHeaderIndex, - sidebarRepoHeaderIdsByBucket, - repoHeaderBucketByRepoId - }), - [firstHeaderIndex, renderRows, repoHeaderBucketByRepoId, sidebarRepoHeaderIdsByBucket] - ) - const projectGroupHeaderSectionEndByGroupId = useMemo( - () => - getProjectGroupHeaderSectionEndByGroupId({ - rows: renderRows, - firstHeaderIndex, - sidebarProjectGroupHeaderIdsByBucket, - projectGroupHeaderBucketByGroupId - }), - [ - firstHeaderIndex, - projectGroupHeaderBucketByGroupId, - renderRows, - sidebarProjectGroupHeaderIdsByBucket - ] - ) - const firstHeaderIndexRef = useRef(firstHeaderIndex) - firstHeaderIndexRef.current = firstHeaderIndex - const stickyHeaderIndexes = useMemo(() => getStickyHeaderIndexes(renderRows), [renderRows]) - const stickyHeaderIndexesRef = useRef(stickyHeaderIndexes) - stickyHeaderIndexesRef.current = stickyHeaderIndexes - const activeStickyHeaderIndexRef = useRef(null) - const activeStickyHostIndexRef = useRef(null) - const stickyRangeStartIndexRef = useRef(0) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const { - folderWorkspacePathStatuses, - fetchFolderWorkspacePathStatus, - getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus, - activeRuntimeEnvironmentId - } = useAppStore( - useShallow((s) => ({ - folderWorkspacePathStatuses: s.folderWorkspacePathStatuses, - fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus, - getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus, - activeRuntimeEnvironmentId: s.settings?.activeRuntimeEnvironmentId ?? null - })) - ) - const folderPathStatusRepoMembershipKey = useMemo( - () => - allRepoIds - .map((repoId) => { - const repo = repoMap.get(repoId) - return `${repoId}:${repo?.path ?? ''}:${repo?.projectGroupId ?? ''}:${repo?.connectionId ?? ''}` - }) - .join('\0'), - [allRepoIds, repoMap] - ) - const folderPathStatusSshConnectionKey = useMemo( - () => - [...sshConnectionStates.entries()] - .map(([connectionId, state]) => `${connectionId}:${state.status}`) - .sort() - .join('\0'), - [sshConnectionStates] - ) - const folderPathStatusCacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick( - folderWorkspacePathStatuses - ) - const projectGroupByIdForFolderPathStatus = useMemo( - () => new Map(projectGroups.map((group) => [group.id, group])), - [projectGroups] - ) - const folderWorkspaceByIdForFolderPathStatus = useMemo( - () => new Map(folderWorkspaces.map((workspace) => [workspace.id, workspace])), - [folderWorkspaces] - ) - const getFolderPathStatusRouteOptions = useCallback( - (request: Parameters[0]) => - getFolderPathStatusRouteOptionsForRows({ - request, - projectGroupsById: projectGroupByIdForFolderPathStatus, - folderWorkspacesById: folderWorkspaceByIdForFolderPathStatus - }), - [folderWorkspaceByIdForFolderPathStatus, projectGroupByIdForFolderPathStatus] - ) - useEffect(() => { - const requests = new Map< - string, - { - request: Parameters[0] - options?: { runtimeEnvironmentId: string | null } - } - >() - for (const group of projectGroups) { - if (group.parentPath) { - const request = { scope: 'project-group' as const, projectGroupId: group.id } - const options = getFolderPathStatusRouteOptions(request) - requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) - } - } - for (const workspace of folderWorkspaces) { - const request = { scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id } - const options = getFolderPathStatusRouteOptions(request) - requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) - } - for (const { request, options } of requests.values()) { - void fetchFolderWorkspacePathStatus(request, { force: true, ...options }) - } - }, [ - activeRuntimeEnvironmentId, - fetchFolderWorkspacePathStatus, - folderPathStatusRepoMembershipKey, - folderPathStatusSshConnectionKey, - folderWorkspaces, - getFolderPathStatusRouteOptions, - getFolderWorkspacePathStatusCacheKey, - projectGroups - ]) - const getCachedFolderWorkspacePathStatus = useCallback( - (request: Parameters[0]) => { - const options = getFolderPathStatusRouteOptions(request) - const cacheKey = getFolderWorkspacePathStatusCacheKey(request, options) - // Why: don't let an expired negative status keep folder workspaces disabled while a refresh is in flight. - void folderWorkspacePathStatuses[cacheKey] - void folderPathStatusCacheExpiryTick - return getFreshFolderWorkspacePathStatus(request, options) - }, - [ - folderWorkspacePathStatuses, - folderPathStatusCacheExpiryTick, - getFolderPathStatusRouteOptions, - getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus - ] - ) - const renderRowsRef = useRef(renderRows) - renderRowsRef.current = renderRows - const getVirtualItemKey = useCallback( - (index: number) => { - const row = renderRows[index] - if (!row) { - return `__stale_${index}` - } - return getRenderRowKey(row) - }, - [renderRows] - ) - const getExpectedVirtualRowKey = useCallback((element: Element) => { - const index = getVirtualRowIndex(element) - const row = index === null ? undefined : renderRowsRef.current[index] - return row ? getRenderRowKey(row) : null - }, []) - const isCurrentVirtualRowElement = useCallback( - (element: Element) => { - const expectedKey = getExpectedVirtualRowKey(element) - return ( - element.isConnected && - expectedKey !== null && - element.getAttribute('data-worktree-virtual-row-key') === expectedKey - ) - }, - [getExpectedVirtualRowKey] - ) - const measureCurrentVirtualRowElement = useCallback( - ( - element: HTMLDivElement, - entry: ResizeObserverEntry | undefined, - instance: Parameters>[2] - ) => { - if (!isCurrentVirtualRowElement(element)) { - const index = getVirtualRowIndex(element) - const measured = instance.getVirtualItems().find((item) => item.index === index) - // Why: a stale ResizeObserver row after remount would write a wrong height; return current size to no-op it. - return ( - measured?.size ?? - estimateRenderRowSize( - renderRowsRef.current, - index ?? -1, - firstHeaderIndexRef.current, - activeStickyHeaderIndexRef.current - ) - ) - } - const index = getVirtualRowIndex(element) - if ( - index !== null && - (renderRowsRef.current[index]?.type === 'header' || - renderRowsRef.current[index]?.type === 'host-header') - ) { - return estimateRenderRowSize( - renderRowsRef.current, - index, - firstHeaderIndexRef.current, - activeStickyHeaderIndexRef.current - ) - } - return measureVirtualElementSize(element, entry, instance) - }, - [isCurrentVirtualRowElement] - ) - const markScrollMovement = useCallback(() => { - suppressMeasurementAdjustmentUntilRef.current = - window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - }, []) - const markDirectScrollInput = useCallback(() => { - const suppressUntil = window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - suppressMeasurementAdjustmentUntilRef.current = suppressUntil - directScrollInputUntilRef.current = suppressUntil - }, []) - const { showScrollToTop, scrollToTop } = useWorktreeListScrollToTop({ - scrollElement, - onUserScrollIntent: markDirectScrollInput - }) - const hasDirectScrollInput = useCallback( - () => window.performance.now() < directScrollInputUntilRef.current, - [] - ) - const markRevealScroll = useCallback((targetTop: number) => { - pendingRevealScrollRef.current = createPendingRevealScroll(targetTop, window.performance.now()) - }, []) - const isRevealScrollSettlingNow = useCallback(() => { - const settling = isRevealScrollSettling({ - now: window.performance.now(), - pending: pendingRevealScrollRef.current, - scrollTop: scrollRef.current?.scrollTop ?? 0 - }) - if (!settling) { - pendingRevealScrollRef.current = null - } - return settling - }, []) - // Why: programmatic scrolls keep measurement correction quiet, but only direct input blocks anchor-restore retries. - // A reveal's smooth scroll is the exception: restoring the anchor mid-animation cancels it a few pixels in. - const shouldSkipScrollAnchorRestore = useCallback( - () => - window.performance.now() < directScrollInputUntilRef.current || isRevealScrollSettlingNow(), - [isRevealScrollSettlingNow] - ) - - const virtualizer = useVirtualizer({ - count: renderRows.length, - getScrollElement: () => scrollRef.current, - estimateSize: (index) => - estimateRenderRowSize( - renderRows, - index, - firstHeaderIndex, - activeStickyHeaderIndexRef.current - ), - measureElement: measureCurrentVirtualRowElement, - // Why: TanStack memoizes rangeExtractor by identity; header indexes must be deps or sticky slots go stale. - rangeExtractor: useCallback( - (range: Range) => { - stickyRangeStartIndexRef.current = range.startIndex - return extractWorktreeVirtualRowIndexes({ - range, - stickyHeaderIndexes, - rows: renderRowsRef.current - }) - }, - [stickyHeaderIndexes] - ), - overscan: 10, - gap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, - // Why: the sticky group header lives inside the virtual list, so scroll math needs the same top inset as the DOM reveal. - scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET, - isScrollingResetDelay: USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS, - // Why: sync-flushing rich card renders in the scroll listener stalls wheel input; async + overscan keeps rows filled. - useFlushSync: false, - // Why: seed scrollOffset from the ref (not 0) so the first getVirtualItems() after remount picks the right rows. - initialOffset: () => scrollOffsetRef.current, - getItemKey: getVirtualItemKey - }) - // Why: TanStack's default correction writes scrollTop while cards remeasure mid-wheel, which feels like rubber-banding. - // TODO(scroll-origin-migration): wall-clock suppression misclassifies under jank; migrate to programmaticScrollMarks + restoreSignal (see CombinedDiffViewer). - virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => - shouldAdjustWorktreeSidebarMeasuredRowScroll({ - isScrolling: instance.isScrolling, - now: window.performance.now(), - suppressUntil: suppressMeasurementAdjustmentUntilRef.current - }) - - useEffect(() => { - const handleSuppress = () => { - // Why: let an expanding agent row grow in place instead of TanStack compensating scrollTop. - suppressMeasurementAdjustmentUntilRef.current = - window.performance.now() + EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - } - window.addEventListener(SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT, handleSuppress) - return () => { - window.removeEventListener(SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT, handleSuppress) - } - }, []) - - React.useEffect(() => { - if (!pendingRevealWorktree) { - return - } - - if (agentSendTargetWorktreeId !== pendingRevealWorktree.worktreeId) { - const folderGroupKeys = getFolderWorkspaceRevealGroupKeys( - pendingRevealWorktree.worktreeId, - folderWorkspaces, - projectGroups - ) - if (folderGroupKeys.length > 0) { - for (const groupKey of folderGroupKeys) { - if (collapsedGroups.has(groupKey)) { - toggleGroup(groupKey) - } - } - } else { - const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId) - const targetRepo = targetWorktree ? repoMap.get(targetWorktree.repoId) : undefined - if (targetWorktree) { - const hostId = getWorktreeExecutionHostId(targetWorktree, targetRepo, defaultHostId) - const hostGroupKey = `host:${hostId}` - if (collapsedGroups.has(hostGroupKey)) { - toggleGroup(hostGroupKey) - } - - for (const parent of getWorktreeLineageAncestors( - targetWorktree, - worktreeLineageById, - worktreeMap - )) { - const lineageGroupKey = getLineageGroupKey(parent.id) - if (collapsedGroups.has(lineageGroupKey)) { - toggleGroup(lineageGroupKey) - } - } - - const groupKeys = - targetWorktree.isPinned && pinnedDisplayPolicy === 'single-location' - ? getPinnedWorktreeRevealCollapsedGroupKeys({ - worktree: targetWorktree, - collapsedGroups - }) - : getGroupKeysForWorktree( - groupBy, - targetWorktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups, - projectGrouping - ) - for (const groupKey of groupKeys) { - if (collapsedGroups.has(groupKey)) { - toggleGroup(groupKey) - } - } - } - } - } - - let cancelled = false - schedulePendingRevealFrame(() => { - if (cancelled) { - return - } - const targetWorktreeStillExists = sidebarWorkspaceStillExists( - pendingRevealWorktree.worktreeId, - worktrees, - folderWorkspaces - ) - const targetIndex = findPreferredRenderRowIndexForWorktree( - renderRows, - pendingRevealWorktree.worktreeId, - pinnedDisplayPolicy - ) - const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists }) - if (outcome === 'scroll-and-clear') { - const targetRow = renderRows[targetIndex] - const container = scrollRef.current - const retryExactRevealOnNextFrame = () => { - const previousRetry = pendingRevealRetryRef.current - const nextRetryCount = - previousRetry?.worktreeId === pendingRevealWorktree.worktreeId - ? previousRetry.count + 1 - : 1 - pendingRevealRetryRef.current = { - worktreeId: pendingRevealWorktree.worktreeId, - count: nextRetryCount - } - if (nextRetryCount <= 8) { - schedulePendingRevealFrame(() => { - if (!cancelled) { - setPendingRevealRetryTick((tick) => tick + 1) - } - }) - } else { - pendingRevealRetryRef.current = null - clearPendingRevealWorktreeId() - } - } - const revealedOption = container - ? revealMountedWorktreeElement( - container, - pendingRevealWorktree.worktreeId, - pendingRevealWorktree.behavior, - getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId), - markRevealScroll - ) - : null - if (revealedOption) { - if (pendingRevealWorktree.highlight) { - const revealedRowKey = - revealedOption.dataset.worktreeRowKey ?? getRenderRowSidebarKey(targetRow) - if (revealedRowKey) { - flashRevealedRow(revealedRowKey) - } - } - if (pendingRevealWorktree.beginRename) { - setRenamingWorktreeId({ - worktreeId: pendingRevealWorktree.worktreeId, - rowKey: revealedOption.dataset.worktreeRowKey - }) - } - pendingRevealRetryRef.current = null - clearPendingRevealWorktreeId() - return - } - - if (targetRow?.type !== 'lineage-group') { - // Why: virtual indexing can leave the card edge clipped; stage it into the window, then retry the exact DOM reveal. - virtualizer.scrollToIndex(targetIndex, { - align: 'auto', - behavior: 'auto' - }) - retryExactRevealOnNextFrame() - return - } - - // Why: for lineage groups the virtual row is only a staging target; jump into the window, then retry the exact reveal. - virtualizer.scrollToIndex(targetIndex, { - align: 'auto', - behavior: 'auto' - }) - retryExactRevealOnNextFrame() - return - } - if (outcome === 'clear') { - pendingRevealRetryRef.current = null - clearPendingRevealWorktreeId() - } - }) - return () => { - cancelled = true - cancelPendingRevealFrames() - } - }, [ - pendingRevealWorktree, - agentSendTargetWorktreeId, - groupBy, - worktrees, - folderWorkspaces, - repoMap, - prCache, - worktreeLineageById, - worktreeMap, - renderRows, - virtualizer, - clearPendingRevealWorktreeId, - toggleGroup, - collapsedGroups, - defaultHostId, - workspaceStatuses, - settings, - pinnedDisplayPolicy, - projectGrouping, - projectGroups, - pendingRevealRetryTick, - flashRevealedRow, - markRevealScroll, - setRenamingWorktreeId, - schedulePendingRevealFrame, - cancelPendingRevealFrames - ]) - - React.useEffect(() => { - if (!pendingRevealSidebarRow) { - return - } - - const isProjectHeaderTarget = - pendingRevealSidebarRow.rowKey.startsWith('project-group:') || - pendingRevealSidebarRow.rowKey.startsWith('project:') || - pendingRevealSidebarRow.rowKey.startsWith('repo:') - if (isProjectHeaderTarget && groupBy !== 'repo') { - return - } - - let toggledAncestor = false - for (const groupKey of getSidebarRowRevealAncestorKeys({ - rowKey: pendingRevealSidebarRow.rowKey, - repoMap, - projectGroups, - projectGrouping - })) { - if (collapsedGroups.has(groupKey)) { - toggleGroup(groupKey) - toggledAncestor = true - } - } - if (toggledAncestor) { - return - } - - let cancelled = false - const retryPendingReveal = () => { - const previousRetry = pendingRowRevealRetryRef.current - const nextRetryCount = - previousRetry?.rowKey === pendingRevealSidebarRow.rowKey ? previousRetry.count + 1 : 1 - pendingRowRevealRetryRef.current = { - rowKey: pendingRevealSidebarRow.rowKey, - count: nextRetryCount - } - if (nextRetryCount <= 8) { - schedulePendingRevealFrame(() => { - if (!cancelled) { - setPendingRevealRetryTick((tick) => tick + 1) - } - }) - return true - } - return false - } - schedulePendingRevealFrame(() => { - if (cancelled) { - return - } - const targetIndex = renderRows.findIndex((row) => - rowKeyMatchesRenderRow(row, pendingRevealSidebarRow.rowKey) - ) - if (targetIndex === -1) { - if (retryPendingReveal()) { - return - } - pendingRowRevealRetryRef.current = null - clearPendingRevealSidebarRow() - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.sidebarRowMissing', - 'Target no longer exists' - ) - ) - return - } - - const retryExactRevealOnNextFrame = () => { - if (retryPendingReveal()) { - return - } - pendingRowRevealRetryRef.current = null - clearPendingRevealSidebarRow() - } - - const container = scrollRef.current - const revealedElement = container - ? revealMountedSidebarRowElement( - container, - pendingRevealSidebarRow.rowKey, - pendingRevealSidebarRow.behavior, - markRevealScroll - ) - : null - if (revealedElement) { - if (pendingRevealSidebarRow.highlight) { - flashRevealedRow(pendingRevealSidebarRow.rowKey) - } - pendingRowRevealRetryRef.current = null - clearPendingRevealSidebarRow() - return - } - - virtualizer.scrollToIndex(targetIndex, { - align: 'auto', - behavior: 'auto' - }) - retryExactRevealOnNextFrame() - }) - - return () => { - cancelled = true - cancelPendingRevealFrames() - } - }, [ - pendingRevealSidebarRow, - repoMap, - projectGroups, - projectGrouping, - collapsedGroups, - groupBy, - toggleGroup, - renderRows, - virtualizer, - pendingRevealRetryTick, - flashRevealedRow, - markRevealScroll, - clearPendingRevealSidebarRow, - schedulePendingRevealFrame, - cancelPendingRevealFrames - ]) - - const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache)) - const issueCacheLen = useAppStore((s) => countRecordKeysByReference(s.issueCache)) - const renderRowKeySignature = useMemo( - () => renderRows.map(getRenderRowKey).join('\n'), - [renderRows] - ) - const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows]) - const lineageRowRekeys = useMemo(() => buildLineageRowRekeyMap(renderRows), [renderRows]) - const totalSize = virtualizer.getTotalSize() - const virtualItems = virtualizer.getVirtualItems() - const activeStickyIndexes = getActiveStickyIndexesForScroll({ - rows: renderRows, - rangeStartIndex: stickyRangeStartIndexRef.current, - scrollOffset: virtualizer.scrollOffset ?? scrollOffsetRef.current, - stickyHeaderIndexes, - virtualItems - }) - activeStickyHeaderIndexRef.current = activeStickyIndexes.groupIndex - activeStickyHostIndexRef.current = activeStickyIndexes.hostIndex - - const measureMountedRows = useCallback(() => { - virtualizer.elementsCache.forEach((element) => { - if (!isCurrentVirtualRowElement(element)) { - return - } - virtualizer.measureElement(element) - }) - }, [isCurrentVirtualRowElement, virtualizer]) - const measureVirtualRowElement = useCallback( - (element: HTMLDivElement | null) => { - if (!element) { - virtualizer.measureElement(null) - return - } - if (!isCurrentVirtualRowElement(element)) { - return - } - virtualizer.measureElement(element) - }, - [isCurrentVirtualRowElement, virtualizer] - ) - - useLayoutEffect(() => { - pruneStaleVirtualRowElementCache({ - activeRowKeys: activeRenderRowKeys, - virtualizer - }) - // Why: a stale retained element after delete/collapse measures 0px and corrupts the next slot; measure only key-matched rows. - measureMountedRows() - const frameId = window.requestAnimationFrame(measureMountedRows) - return () => window.cancelAnimationFrame(frameId) - }, [ - activeRenderRowKeys, - prCacheLen, - issueCacheLen, - measureMountedRows, - renderRowKeySignature, - virtualizer - ]) - - useVirtualizedScrollAnchor({ - anchorRef: scrollAnchorRef, - getItemElementKey: getVirtualRowKey, - getRowKey: getRenderRowKey, - itemElementSelector: '[data-worktree-virtual-row]', - rekeyedRowKeys: lineageRowRekeys, - rows: renderRows, - scrollElementRef: scrollRef, - scrollOffsetRef, - hasDirectScrollInput, - shouldSkipRestore: shouldSkipScrollAnchorRestore, - totalSize, - virtualizer - }) - - const recordCurrentScrollAnchor = useCallback(() => { - scrollRef.current?.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)) - }, []) - const toggleGroupWithScrollAnchor = useCallback( - (groupKey: string) => { - recordCurrentScrollAnchor() - toggleGroup(groupKey) - }, - [recordCurrentScrollAnchor, toggleGroup] - ) - // Why: memo'd WorktreeCard needs a per-group-key stable onLineageToggle - // identity to bail out of re-renders; see worktree-lineage-toggle-handler-cache. - const getLineageToggleHandler = useMemo( - () => createLineageToggleHandlerCache(toggleGroupWithScrollAnchor), - [toggleGroupWithScrollAnchor] - ) - - const navigateWorktree = useCallback( - (direction: 'up' | 'down') => { - // Why: cycle over the rows the sidebar actually rendered — collapsing a group - // means "not now", and a rebuilt near-copy would drift from what is on screen - // (host sections, pinned placement, folder workspaces). - const nextWorktreeId = resolveCycledWorktreeId({ - worktreeIds: getCyclableWorktreeIds(rows, pinnedDisplayPolicy), - activeWorktreeId, - direction - }) - if (nextWorktreeId === null) { - return - } - - // Why: keyboard cycling is real navigation; route through the activation helper that records history. - activateAndRevealWorktree(nextWorktreeId) - - const rowIndex = findPreferredRenderRowIndexForWorktree( - renderRows, - nextWorktreeId, - pinnedDisplayPolicy - ) - if (rowIndex !== -1) { - virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) - } - }, - [rows, renderRows, activeWorktreeId, virtualizer, pinnedDisplayPolicy] - ) - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (activeModal !== 'none' || isEditableTarget(e.target)) { - return - } - - const platform = getShortcutPlatform() - if (keybindingMatchesAction('sidebar.focusWorktreeList', e, platform, keybindings)) { - scrollRef.current?.focus() - e.preventDefault() - return - } - - const direction = keybindingMatchesAction('worktree.navigateUp', e, platform, keybindings) - ? 'up' - : keybindingMatchesAction('worktree.navigateDown', e, platform, keybindings) - ? 'down' - : null - if (direction) { - markDirectScrollInput() - navigateWorktree(direction) - e.preventDefault() - } - } - - window.addEventListener('keydown', handleKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) - }, [activeModal, keybindings, markDirectScrollInput, navigateWorktree]) - - const handleContainerKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - if (e.target !== e.currentTarget) { - return - } - markDirectScrollInput() - navigateWorktree(e.key === 'ArrowUp' ? 'up' : 'down') - e.preventDefault() - } else if (e.key === 'Enter') { - const helper = document.querySelector( - '.xterm-helper-textarea' - ) as HTMLTextAreaElement | null - if (helper) { - helper.focus() - } - e.preventDefault() - } else if (['PageUp', 'PageDown', 'Home', 'End', ' '].includes(e.key)) { - markDirectScrollInput() - } - }, - [markDirectScrollInput, navigateWorktree] - ) - - const handleScrollPointerDown = useCallback( - (event: React.PointerEvent) => { - const scrollbarWidth = event.currentTarget.offsetWidth - event.currentTarget.clientWidth - if (scrollbarWidth <= 0) { - return - } - const rect = event.currentTarget.getBoundingClientRect() - if (event.clientX >= rect.right - scrollbarWidth) { - markDirectScrollInput() - } - }, - [markDirectScrollInput] - ) - const handleScroll = useCallback(() => { - markScrollMovement() - }, [markScrollMovement]) - - const cancelWorktreePointerAutoscroll = useCallback(() => { - if (worktreePointerAutoscrollFrameIdRef.current !== null) { - window.cancelAnimationFrame(worktreePointerAutoscrollFrameIdRef.current) - worktreePointerAutoscrollFrameIdRef.current = null - } - worktreePointerAutoscrollLastFrameTimeRef.current = null - }, []) - - const cancelWorktreeNativeAutoscroll = useCallback(() => { - if (worktreeNativeAutoscrollFrameIdRef.current !== null) { - window.cancelAnimationFrame(worktreeNativeAutoscrollFrameIdRef.current) - worktreeNativeAutoscrollFrameIdRef.current = null - } - worktreeNativeAutoscrollLastFrameTimeRef.current = null - worktreeNativeLatestPointRef.current = null - }, []) - - const cleanupWorktreePointerDrag = useCallback(() => { - const drag = worktreePointerDragRef.current - cancelWorktreePointerAutoscroll() - setNativeLineageDropTargetId(null) - if (!drag) { - return - } - if (drag.frameId !== null) { - window.cancelAnimationFrame(drag.frameId) - } - drag.preview?.remove() - worktreePointerDragRef.current = null - setSidebarPointerDragDocumentStyles(false) - setDragOverStatus(null) - setPinDragOver(false) - clearWorkspaceKanbanSidebarDropTargetVisual() - onWorkspaceBoardDragPreviewCancel() - }, [cancelWorktreePointerAutoscroll, onWorkspaceBoardDragPreviewCancel]) - - const clearWorktreeDrag = useCallback(() => { - cleanupWorktreePointerDrag() - cancelWorktreeNativeAutoscroll() - worktreeDragSessionRef.current = null - statusDropAnchorsRef.current.clear() - setWorktreeDragState(WORKTREE_ROW_DRAG_INITIAL_STATE) - }, [cancelWorktreeNativeAutoscroll, cleanupWorktreePointerDrag]) - - const setScrollRootRef = useCallback( - (node: HTMLDivElement | null) => { - if (node === null && scrollRef.current !== null) { - // Why: drag previews, autoscroll frames, and reveal snapshots are tied to the scroll root; clear them before it unmounts. - cancelPendingRevealFrames() - clearRevealHighlightFrame() - clearRevealHighlightTimeout() - clearWorktreeDrag() - } - scrollRef.current = node - setScrollElement(node) - }, - [ - cancelPendingRevealFrames, - clearRevealHighlightFrame, - clearRevealHighlightTimeout, - clearWorktreeDrag - ] - ) - - const getEligibleLineageDropTarget = useCallback( - ( - target: WorktreeSidebarStatusDropTarget & { lineageParentId: string | null }, - draggedIds: readonly string[] - ): WorktreeSidebarStatusDropTarget & { lineageParentId: string | null } => { - const parentId = target.lineageParentId - if (!parentId) { - return target - } - const canAssignAll = draggedIds.every((draggedId) => { - const child = worktreeMap.get(draggedId) - if (!child) { - return false - } - const candidateParent = worktreeMap.get(parentId) - return Boolean( - candidateParent && - isEligibleWorktreeParent({ - child, - candidateParent, - lineageById: worktreeLineageById, - worktreeMap, - repoMap, - cyclicLineageIds - }) - ) - }) - return canAssignAll ? target : { ...target, lineageParentId: null } - }, - [cyclicLineageIds, repoMap, worktreeLineageById, worktreeMap] - ) - - const commitWorktreeLineageParentDrop = useCallback( - (draggedIds: readonly string[], parentId: string): boolean => { - const target = getEligibleLineageDropTarget( - { status: null, isPinDrop: false, lineageParentId: parentId }, - draggedIds - ) - if (!target.lineageParentId) { - return false - } - void Promise.all( - draggedIds.map((id) => assignWorktreeParent(id, { parentWorktreeId: parentId })) - ).catch((err) => { - console.error('Failed to nest workspace:', err) - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.failedNestWorkspace', - 'Failed to nest workspace' - ) - ) - }) - return true - }, - [assignWorktreeParent, getEligibleLineageDropTarget] - ) - - const clearReorderedWorktreeParents = useCallback( - (args: { draggedIds: readonly string[]; sourceGroupKey: string }) => { - const sourceGroup = worktreeDragGroups.find((group) => group.key === args.sourceGroupKey) - if (!sourceGroup) { - return - } - const ids = getReorderedWorktreeIdsToUnnest({ - draggedIds: args.draggedIds, - sourceGroupIds: sourceGroup.worktreeIds, - lineageById: worktreeLineageById, - worktreeMap, - cyclicLineageIds - }) - if (ids.length === 0) { - return - } - // Why: dropping a nested card on a reorder line is the un-nest escape hatch; clear only the dragged children. - void Promise.all(ids.map((id) => updateWorktreeLineage(id, { noParent: true }))).catch( - (err) => { - console.error('Failed to unnest workspace:', err) - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.failedUnnestWorkspace', - 'Failed to unnest workspace' - ) - ) - } - ) - }, - [cyclicLineageIds, updateWorktreeLineage, worktreeDragGroups, worktreeLineageById, worktreeMap] - ) - - const flushWorktreePointerDrag = useCallback(() => { - const drag = worktreePointerDragRef.current - if (!drag) { - return - } - drag.frameId = null - if (!drag.active || !drag.preview) { - return - } - updateSidebarDragPreviewPosition({ - preview: drag.preview, - pointerX: drag.currentX, - pointerY: drag.currentY, - offsetX: drag.previewOffsetX, - offsetY: drag.previewOffsetY - }) - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - // Why: show the board preview as soon as a card drag begins so the drop target is visible up front, not only at the sidebar edge. - if ( - !drag.workspaceBoardDragPreviewRequested && - !workspaceBoardOpen && - !hasWorkspaceKanbanSidebarDropBoard() - ) { - drag.workspaceBoardDragPreviewRequested = true - onWorkspaceBoardDragPreviewStart() - } - const boardTarget = updateWorkspaceKanbanSidebarDropTargetVisual({ - x: drag.currentX, - y: drag.currentY, - shouldShowDropIndicator: (target) => - Boolean( - target.status && - shouldShowWorkspaceBoardDropIndicator(drag.reorderDraggedIds, target.status) - ) - }) - drag.latestBoardDropTarget = { - target: boardTarget, - x: drag.currentX, - y: drag.currentY - } - if (isWorkspaceKanbanSidebarDropPointInBoard(drag.currentX, drag.currentY)) { - onWorkspaceBoardDragPreviewCommit() - } - if (boardTarget.status || boardTarget.isPinDrop) { - drag.latestStatusDropTarget = null - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.pointerY === drag.currentY && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - } - ) - return - } - - const sidebarContainer = scrollRef.current - const preferredStatusTarget = getEligibleLineageDropTarget( - sidebarContainer - ? getPointerDropStatusTarget({ - container: sidebarContainer, - x: drag.currentX, - y: drag.currentY - }) - : { status: null, isPinDrop: false, lineageParentId: null }, - drag.draggedIds - ) - if (preferredStatusTarget.lineageParentId) { - updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, null) - clearWorkspaceKanbanSidebarDropTargetVisual() - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.pointerY === drag.currentY && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - } - ) - return - } - if ( - shouldPreferSidebarStatusDropTarget({ - sourceGroupKey: drag.sourceGroupKey, - target: preferredStatusTarget, - workspaceStatuses - }) - ) { - const statusDrop = preferredStatusTarget.status - ? computeWorktreeStatusDrop({ - pointerY: drag.currentY, - status: preferredStatusTarget.status, - draggedIds: drag.reorderDraggedIds - }) - : null - if (statusDrop) { - updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, statusDrop) - clearWorkspaceKanbanSidebarDropTargetVisual() - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === statusDrop.dropIndex && - prev.dropIndicatorY === statusDrop.dropIndicatorY && - prev.pointerY === drag.currentY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - statusDrop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...statusDrop, pointerY: drag.currentY } - ) - return - } - updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, statusDrop) - setDragOverStatus(preferredStatusTarget.status) - setPinDragOver(preferredStatusTarget.isPinDrop) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.pointerY === drag.currentY && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - } - ) - return - } - - const drop = computeWorktreeDrop(drag.currentY) - if (!drop) { - const target = preferredStatusTarget - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: drag.currentY, - status: target.status, - draggedIds: drag.reorderDraggedIds - }) - : null - if (statusDrop) { - updateLatestWorktreeStatusDropTarget(drag, target, statusDrop) - clearWorkspaceKanbanSidebarDropTargetVisual() - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === statusDrop.dropIndex && - prev.dropIndicatorY === statusDrop.dropIndicatorY && - prev.pointerY === drag.currentY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - statusDrop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...statusDrop, pointerY: drag.currentY } - ) - return - } - updateLatestWorktreeStatusDropTarget(drag, target, statusDrop) - setDragOverStatus(target.status) - setPinDragOver(target.isPinDrop) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.pointerY === drag.currentY && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - } - ) - return - } - drag.latestStatusDropTarget = null - clearWorkspaceKanbanSidebarDropTargetVisual() - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === drop.dropIndex && - prev.dropIndicatorY === drop.dropIndicatorY && - prev.pointerY === drag.currentY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - drop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...drop, pointerY: drag.currentY } - ) - }, [ - clearWorktreeDrag, - computeWorktreeDrop, - computeWorktreeStatusDrop, - onWorkspaceBoardDragPreviewStart, - refreshWorktreeDragSession, - onWorkspaceBoardDragPreviewCommit, - shouldShowWorkspaceBoardDropIndicator, - getEligibleLineageDropTarget, - workspaceBoardOpen, - workspaceStatuses - ]) - - const scheduleWorktreePointerDragFrame = useCallback( - (drag: WorktreePointerDrag) => { - if (drag.frameId !== null) { - return - } - drag.frameId = window.requestAnimationFrame(flushWorktreePointerDrag) - }, - [flushWorktreePointerDrag] - ) - - const runWorktreePointerAutoscrollFrame = useCallback( - (frameTime: number) => { - worktreePointerAutoscrollFrameIdRef.current = null - const drag = worktreePointerDragRef.current - const container = scrollRef.current - const session = worktreeDragSessionRef.current - if (!drag?.active || !container || !session) { - cancelWorktreePointerAutoscroll() - return - } - - const previousFrameTime = worktreePointerAutoscrollLastFrameTimeRef.current ?? frameTime - worktreePointerAutoscrollLastFrameTimeRef.current = frameTime - const autoscroll = getWorktreeSidebarDragAutoscroll({ - point: { clientX: drag.currentX, clientY: drag.currentY }, - containerRect: container.getBoundingClientRect(), - scrollTop: container.scrollTop, - scrollHeight: container.scrollHeight, - clientHeight: container.clientHeight, - elapsedMs: frameTime - previousFrameTime - }) - if (autoscroll) { - markScrollMovement() - container.scrollTop = autoscroll.scrollTop - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - scheduleWorktreePointerDragFrame(drag) - } - - worktreePointerAutoscrollFrameIdRef.current = window.requestAnimationFrame( - runWorktreePointerAutoscrollFrame - ) - }, - [ - cancelWorktreePointerAutoscroll, - clearWorktreeDrag, - markScrollMovement, - refreshWorktreeDragSession, - scheduleWorktreePointerDragFrame - ] - ) - - const startWorktreePointerAutoscroll = useCallback(() => { - if (worktreePointerAutoscrollFrameIdRef.current !== null) { - return - } - worktreePointerAutoscrollLastFrameTimeRef.current = null - worktreePointerAutoscrollFrameIdRef.current = window.requestAnimationFrame( - runWorktreePointerAutoscrollFrame - ) - }, [runWorktreePointerAutoscrollFrame]) - - const beginWorktreePointerDrag = useCallback( - (drag: WorktreePointerDrag) => { - const { preview, offsetX, offsetY, height } = createSidebarDragPreview({ - sourceRow: drag.sourceRow, - pointerX: drag.currentX, - pointerY: drag.currentY, - draggedCount: drag.draggedIds.length - }) - drag.active = true - drag.preview = preview - drag.previewOffsetX = offsetX - drag.previewOffsetY = offsetY - suppressWorktreeClickUntilRef.current = window.performance.now() + 500 - setSidebarPointerDragDocumentStyles(true) - worktreeDragSessionRef.current = { - draggingWorktreeId: drag.worktreeId, - sourceGroupKey: drag.sourceGroupKey, - draggedIds: drag.draggedIds, - reorderDraggedIds: drag.reorderDraggedIds, - reorderUnitDraggedIds: drag.reorderUnitDraggedIds, - rects: drag.rects, - // Why: reuse the floating preview's own offset so the hit test tracks the - // card the user sees, not the raw pointer. - grab: getWorktreeSidebarDragGrab({ offsetY, height }), - anchor: null - } - setWorktreeDragState({ - draggingWorktreeId: drag.worktreeId, - sourceGroupKey: drag.sourceGroupKey, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - }) - startWorktreePointerAutoscroll() - scheduleWorktreePointerDragFrame(drag) - }, - [scheduleWorktreePointerDragFrame, startWorktreePointerAutoscroll] - ) - - const handleWorktreeRowPointerDown = useCallback( - (event: React.PointerEvent, worktreeId: string, rowKey: string) => { - if (event.button !== 0 || event.pointerType === 'touch') { - return - } - const sourceRow = event.currentTarget - if (isSidebarPointerDragBlocked(event.target, sourceRow)) { - return - } - const sourceGroupKey = groupKeyByRowKey.get(rowKey) - const container = scrollRef.current - if (!sourceGroupKey || !container) { - return - } - const rects = getWorktreeSidebarDragRectsForGroup(container, sourceGroupKey) - const canPreviewWorkspaceBoardOnDrag = - !workspaceBoardOpen && - onWorkspaceBoardDragPreviewStart !== NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK - if ( - rects.length <= 1 && - !hasWorkspaceKanbanSidebarDropBoard() && - !canPreviewWorkspaceBoardOnDrag - ) { - return - } - const draggedIds = - selectedWorktreeIds.has(worktreeId) && selectedWorktrees.length > 1 - ? selectedWorktrees.map((worktree) => worktree.id) - : [worktreeId] - const reorderDraggedIds = getReorderDraggedIds(draggedIds) - const reorderUnitDraggedIds = getReorderUnitDraggedIds(sourceGroupKey, reorderDraggedIds) - worktreePointerDragRef.current = { - pointerId: event.pointerId, - sourceRow, - startX: event.clientX, - startY: event.clientY, - currentX: event.clientX, - currentY: event.clientY, - worktreeId, - draggedIds, - reorderDraggedIds, - reorderUnitDraggedIds, - sourceGroupKey, - rects, - active: false, - preview: null, - previewOffsetX: 0, - previewOffsetY: 0, - workspaceBoardDragPreviewRequested: false, - frameId: null, - latestBoardDropTarget: null, - latestStatusDropTarget: null - } - }, - [ - getReorderDraggedIds, - getReorderUnitDraggedIds, - groupKeyByRowKey, - onWorkspaceBoardDragPreviewStart, - selectedWorktreeIds, - selectedWorktrees, - workspaceBoardOpen - ] - ) - - const handleWorktreeRowClickCapture = useCallback((event: React.MouseEvent) => { - if (window.performance.now() >= suppressWorktreeClickUntilRef.current) { - return - } - event.preventDefault() - event.stopPropagation() - }, []) - - useEffect(() => { - const handlePointerMove = (event: PointerEvent): void => { - const drag = worktreePointerDragRef.current - if (!drag || event.pointerId !== drag.pointerId) { - return - } - drag.currentX = event.clientX - drag.currentY = event.clientY - if (!drag.active) { - const distance = Math.hypot(drag.currentX - drag.startX, drag.currentY - drag.startY) - if (distance < SIDEBAR_POINTER_DRAG_THRESHOLD_PX) { - return - } - beginWorktreePointerDrag(drag) - } - event.preventDefault() - event.stopPropagation() - scheduleWorktreePointerDragFrame(drag) - } - - const handlePointerUp = (event: PointerEvent): void => { - const drag = worktreePointerDragRef.current - if (!drag || event.pointerId !== drag.pointerId) { - return - } - drag.currentX = event.clientX - drag.currentY = event.clientY - if (!drag.active) { - worktreePointerDragRef.current = null - return - } - event.preventDefault() - event.stopPropagation() - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const boardDropTarget = resolveWorkspaceKanbanCardDropCommitTarget({ - currentTarget: getWorkspaceKanbanSidebarDropTarget(event.clientX, event.clientY), - latestTrackedTarget: drag.latestBoardDropTarget, - x: event.clientX, - y: event.clientY - }) - if (isWorkspaceKanbanSidebarDropPointInBoard(event.clientX, event.clientY)) { - onWorkspaceBoardDragPreviewCommit() - } - if (boardDropTarget.isPinDrop) { - onPinWorktrees(drag.draggedIds) - } else if (boardDropTarget.status) { - onDropWorktreesOnWorkspaceBoard({ - worktreeIds: drag.reorderDraggedIds, - status: boardDropTarget.status, - // Why: the target counts rendered cards, but the groups are the full - // lane. Board search can make those two differ. - dropIndex: resolveWorkspaceKanbanSidebarFullLaneDropIndex( - boardDropTarget.status, - boardDropTarget.dropIndex - ), - groups: getWorkspaceKanbanSidebarDropGroups() - }) - } else { - const preferredStatusTarget = getEligibleLineageDropTarget( - scrollRef.current - ? getPointerDropStatusTarget({ - container: scrollRef.current, - x: event.clientX, - y: event.clientY - }) - : { status: null, isPinDrop: false, lineageParentId: null }, - drag.draggedIds - ) - if (preferredStatusTarget.lineageParentId) { - commitWorktreeLineageParentDrop(drag.draggedIds, preferredStatusTarget.lineageParentId) - clearWorktreeDrag() - return - } - if ( - shouldPreferSidebarStatusDropTarget({ - sourceGroupKey: drag.sourceGroupKey, - target: preferredStatusTarget, - workspaceStatuses - }) - ) { - const statusDrop = preferredStatusTarget.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: preferredStatusTarget.status, - draggedIds: drag.reorderDraggedIds - }) - : null - if (preferredStatusTarget.isPinDrop) { - onPinWorktrees(drag.draggedIds) - } else if (preferredStatusTarget.status) { - if (statusDrop) { - onMoveWorktreesToStatusAtIndex({ - worktreeIds: drag.reorderDraggedIds, - status: preferredStatusTarget.status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - } else { - onMoveWorktreesToStatus(drag.reorderDraggedIds, preferredStatusTarget.status) - } - } - clearWorktreeDrag() - return - } - const drop = computeWorktreeDrop(event.clientY) - if (drop) { - onReorderWorktrees({ - groups: worktreeDragGroups, - sourceGroupKey: drag.sourceGroupKey, - draggedIds: drag.reorderDraggedIds, - dropIndex: getFullDropIndexForWorktreeDragUnit({ - groups: worktreeDragUnitGroups, - sourceGroupKey: drag.sourceGroupKey, - dropIndex: drop.dropIndex - }) - }) - clearReorderedWorktreeParents({ - draggedIds: drag.draggedIds, - sourceGroupKey: drag.sourceGroupKey - }) - } else if (scrollRef.current) { - const currentTarget = preferredStatusTarget - const currentPreview = currentTarget.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: currentTarget.status, - draggedIds: drag.reorderDraggedIds - }) - : null - const { target, preview: statusDrop } = resolveWorktreeSidebarStatusDropCommitTarget({ - currentTarget, - currentPreview, - latestTrackedTarget: drag.latestStatusDropTarget, - x: event.clientX, - y: event.clientY - }) - if (target.lineageParentId) { - commitWorktreeLineageParentDrop(drag.draggedIds, target.lineageParentId) - } else if (target.isPinDrop) { - onPinWorktrees(drag.draggedIds) - } else if (target.status) { - if (statusDrop) { - onMoveWorktreesToStatusAtIndex({ - worktreeIds: drag.reorderDraggedIds, - status: target.status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - } else { - onMoveWorktreesToStatus(drag.reorderDraggedIds, target.status) - } - } - } - } - clearWorktreeDrag() - } - - const handlePointerCancel = (event: PointerEvent): void => { - const drag = worktreePointerDragRef.current - if (!drag || event.pointerId !== drag.pointerId) { - return - } - clearWorktreeDrag() - } - - window.addEventListener('pointermove', handlePointerMove, { capture: true }) - window.addEventListener('pointerup', handlePointerUp, { capture: true }) - window.addEventListener('pointercancel', handlePointerCancel, { capture: true }) - return () => { - window.removeEventListener('pointermove', handlePointerMove, { capture: true }) - window.removeEventListener('pointerup', handlePointerUp, { capture: true }) - window.removeEventListener('pointercancel', handlePointerCancel, { capture: true }) - } - }, [ - beginWorktreePointerDrag, - clearWorktreeDrag, - clearReorderedWorktreeParents, - commitWorktreeLineageParentDrop, - computeWorktreeDrop, - computeWorktreeStatusDrop, - getEligibleLineageDropTarget, - onMoveWorktreesToStatus, - onMoveWorktreesToStatusAtIndex, - onDropWorktreesOnWorkspaceBoard, - onPinWorktrees, - onReorderWorktrees, - onWorkspaceBoardDragPreviewCommit, - refreshWorktreeDragSession, - scheduleWorktreePointerDragFrame, - shouldShowWorkspaceBoardDropIndicator, - worktreeDragGroups, - worktreeDragUnitGroups, - workspaceStatuses - ]) - - useEffect(() => { - const handleClick = (event: MouseEvent): void => { - if (window.performance.now() >= suppressWorktreeClickUntilRef.current) { - return - } - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() - } - - document.addEventListener('click', handleClick, true) - return () => document.removeEventListener('click', handleClick, true) - }, []) - - const runWorktreeNativeAutoscrollFrame = useCallback( - (frameTime: number) => { - worktreeNativeAutoscrollFrameIdRef.current = null - const point = worktreeNativeLatestPointRef.current - const container = scrollRef.current - const session = worktreeDragSessionRef.current - if (!point || !container || !session) { - cancelWorktreeNativeAutoscroll() - return - } - - const previousFrameTime = worktreeNativeAutoscrollLastFrameTimeRef.current ?? frameTime - worktreeNativeAutoscrollLastFrameTimeRef.current = frameTime - const autoscroll = getWorktreeSidebarDragAutoscroll({ - point, - containerRect: container.getBoundingClientRect(), - scrollTop: container.scrollTop, - scrollHeight: container.scrollHeight, - clientHeight: container.clientHeight, - elapsedMs: frameTime - previousFrameTime - }) - if (autoscroll) { - markScrollMovement() - container.scrollTop = autoscroll.scrollTop - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const drop = computeWorktreeDrop(point.clientY) - if (!drop) { - const target = getPointerDropStatusTarget({ - container, - x: point.clientX, - y: point.clientY - }) - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: point.clientY, - status: target.status, - draggedIds: session.reorderDraggedIds - }) - : null - if (statusDrop) { - setWorktreeDragState((prev) => - prev.dropIndex === statusDrop.dropIndex && - prev.dropIndicatorY === statusDrop.dropIndicatorY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - statusDrop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...statusDrop, pointerY: point.clientY } - ) - return - } - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null - } - ) - } else { - setWorktreeDragState((prev) => - prev.dropIndex === drop.dropIndex && - prev.dropIndicatorY === drop.dropIndicatorY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - drop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...drop, pointerY: point.clientY } - ) - } - } - - worktreeNativeAutoscrollFrameIdRef.current = window.requestAnimationFrame( - runWorktreeNativeAutoscrollFrame - ) - }, - [ - cancelWorktreeNativeAutoscroll, - clearWorktreeDrag, - computeWorktreeDrop, - computeWorktreeStatusDrop, - markScrollMovement, - refreshWorktreeDragSession - ] - ) - - const startWorktreeNativeAutoscroll = useCallback(() => { - if (worktreeNativeAutoscrollFrameIdRef.current !== null) { - return - } - worktreeNativeAutoscrollLastFrameTimeRef.current = null - worktreeNativeAutoscrollFrameIdRef.current = window.requestAnimationFrame( - runWorktreeNativeAutoscrollFrame - ) - }, [runWorktreeNativeAutoscrollFrame]) - - const handleWorktreeCardDragStart = useCallback( - (event: React.DragEvent, worktreeId: string, draggedIds: readonly string[]) => { - const sourceGroupKey = - worktreeDragGroups.find((group) => group.worktreeIds.includes(worktreeId))?.key ?? null - if (!sourceGroupKey) { - return - } - const reorderDraggedIds = getReorderDraggedIds(draggedIds) - const reorderUnitDraggedIds = getReorderUnitDraggedIds(sourceGroupKey, reorderDraggedIds) - const rects = scrollRef.current - ? getWorktreeSidebarDragRectsForGroup(scrollRef.current, sourceGroupKey) - : [] - const sourceRect = event.currentTarget.getBoundingClientRect() - worktreeDragSessionRef.current = { - draggingWorktreeId: worktreeId, - sourceGroupKey, - draggedIds, - reorderDraggedIds, - reorderUnitDraggedIds, - rects, - grab: getWorktreeSidebarDragGrab({ - offsetY: event.clientY - sourceRect.top, - height: sourceRect.height - }), - anchor: null - } - setWorktreeDragState({ - draggingWorktreeId: worktreeId, - sourceGroupKey, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null - }) - }, - [getReorderDraggedIds, getReorderUnitDraggedIds, worktreeDragGroups] - ) - - const handleWorktreeDragOver = useCallback( - (event: React.DragEvent) => { - const session = worktreeDragSessionRef.current - if (!session) { - return - } - worktreeNativeLatestPointRef.current = { clientX: event.clientX, clientY: event.clientY } - startWorktreeNativeAutoscroll() - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const target = getEligibleLineageDropTarget( - getPointerDropStatusTarget({ - container: event.currentTarget, - x: event.clientX, - y: event.clientY - }), - session.draggedIds - ) - if (target.lineageParentId) { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setNativeLineageDropTargetId(target.lineageParentId) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: event.clientY - } - ) - return - } - setNativeLineageDropTargetId(null) - - const drop = computeWorktreeDrop(event.clientY) - if (!drop) { - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: target.status, - draggedIds: session.reorderDraggedIds - }) - : null - if (statusDrop) { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setWorktreeDragState((prev) => - prev.dropIndex === statusDrop.dropIndex && - prev.dropIndicatorY === statusDrop.dropIndicatorY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - statusDrop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...statusDrop, pointerY: event.clientY } - ) - return - } - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null - } - ) - return - } - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setWorktreeDragState((prev) => - prev.dropIndex === drop.dropIndex && - prev.dropIndicatorY === drop.dropIndicatorY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - drop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...drop, pointerY: event.clientY } - ) - }, - [ - clearWorktreeDrag, - computeWorktreeDrop, - computeWorktreeStatusDrop, - getEligibleLineageDropTarget, - refreshWorktreeDragSession, - startWorktreeNativeAutoscroll - ] - ) - - const handleWorktreeDrop = useCallback( - (event: React.DragEvent) => { - const session = worktreeDragSessionRef.current - if (!session) { - return - } - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const boardDropTarget = getWorkspaceKanbanSidebarDropTarget(event.clientX, event.clientY) - if (boardDropTarget.status || boardDropTarget.isPinDrop) { - clearWorktreeDrag() - return - } - - const container = scrollRef.current - const target = getEligibleLineageDropTarget( - container - ? getPointerDropStatusTarget({ - container, - x: event.clientX, - y: event.clientY - }) - : { status: null, isPinDrop: false, lineageParentId: null }, - session.draggedIds - ) - - if (target.lineageParentId) { - event.preventDefault() - event.stopPropagation() - commitWorktreeLineageParentDrop(session.draggedIds, target.lineageParentId) - clearWorktreeDrag() - return - } - - const drop = computeWorktreeDrop(event.clientY) - if (!drop) { - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: target.status, - draggedIds: session.reorderDraggedIds - }) - : null - if (target.status && statusDrop) { - event.preventDefault() - event.stopPropagation() - onMoveWorktreesToStatusAtIndex({ - worktreeIds: session.reorderDraggedIds, - status: target.status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - clearWorktreeDrag() - return - } - clearWorktreeDrag() - return - } - event.preventDefault() - onReorderWorktrees({ - groups: worktreeDragGroups, - sourceGroupKey: session.sourceGroupKey, - draggedIds: session.reorderDraggedIds, - dropIndex: getFullDropIndexForWorktreeDragUnit({ - groups: worktreeDragUnitGroups, - sourceGroupKey: session.sourceGroupKey, - dropIndex: drop.dropIndex - }) - }) - clearReorderedWorktreeParents({ - draggedIds: session.draggedIds, - sourceGroupKey: session.sourceGroupKey - }) - clearWorktreeDrag() - }, - [ - clearWorktreeDrag, - clearReorderedWorktreeParents, - commitWorktreeLineageParentDrop, - computeWorktreeDrop, - computeWorktreeStatusDrop, - getEligibleLineageDropTarget, - onMoveWorktreesToStatusAtIndex, - onReorderWorktrees, - refreshWorktreeDragSession, - worktreeDragGroups, - worktreeDragUnitGroups - ] - ) - - useEffect(() => { - if (document.visibilityState !== 'visible') { - lastVisibleRefreshKeyRef.current = '__document_hidden__' - return - } - const currentWorktree = currentWorktreeId ? (worktreeMap.get(currentWorktreeId) ?? null) : null - // Why: this reporter feeds the GitHub coordinator; GitLab-only MR panels refresh via hosted-review paths. - const sidebarWorktreeHasGitHubReview = - currentWorktree !== null && - ((currentWorktree.linkedGitLabMR ?? null) === null || - (currentWorktree.linkedPR ?? null) !== null) - const shouldTrackSidebarWorktree = rightSidebarShowsPR && sidebarWorktreeHasGitHubReview - const shouldTrackVisibleRows = - groupBy === 'pr-status' || - (newCardStyle - ? cardProps.includes('status') - : cardProps.includes('pr') || cardProps.includes('ci')) - if (!shouldTrackVisibleRows && !shouldTrackSidebarWorktree) { - if (lastVisibleRefreshKeyRef.current !== '__hidden__') { - lastVisibleRefreshKeyRef.current = '__hidden__' - reportVisibleGitHubPRRefreshCandidates([], Date.now()) - } - return - } - const scrollEl = scrollRef.current - if (!scrollEl) { - return - } - const viewportTop = scrollEl.scrollTop - const viewportBottom = viewportTop + scrollEl.clientHeight - const visibleRows = virtualItems - .filter((item) => item.start < viewportBottom && item.end > viewportTop) - .map((item) => renderRows[item.index]) - .filter((row): row is WorktreeItemRow => row?.type === 'item') - .filter((row) => row.repo?.kind === 'git' && !row.worktree.isBare && row.worktree.branch) - const visibleWorktreeIds = new Set(visibleRows.map((row) => row.worktree.id)) - if ( - shouldTrackSidebarWorktree && - currentWorktree && - !currentWorktree.isBare && - currentWorktree.branch - ) { - visibleWorktreeIds.add(currentWorktree.id) - } - const visibleIdentity = visibleRows - .map((row) => `${row.worktree.id}:${row.worktree.branch}:${row.worktree.linkedPR ?? ''}`) - .join('|') - const sidebarIdentity = - shouldTrackSidebarWorktree && currentWorktree - ? `${currentWorktree.id}:${currentWorktree.branch}:${currentWorktree.linkedPR ?? ''}` - : '' - const key = `${visibleIdentity}:${sidebarIdentity}:${sshConnectedGeneration}:${prVisibleRefreshGeneration}:${cardProps.join(',')}` - if (!key || key === lastVisibleRefreshKeyRef.current) { - return - } - lastVisibleRefreshKeyRef.current = key - reportVisibleGitHubPRRefreshCandidates(Array.from(visibleWorktreeIds), Date.now()) - }, [ - cardProps, - currentWorktreeId, - documentVisibilityRevision, - groupBy, - renderRows, - reportVisibleGitHubPRRefreshCandidates, - prVisibleRefreshGeneration, - rightSidebarShowsPR, - sshConnectedGeneration, - newCardStyle, - virtualItems, - worktreeMap - ]) - - const activeDescendantId = getActiveDescendantOptionId({ - activeWorktreeId, - primaryActiveRowKey: - primaryActiveWorktreeRow?.worktreeId === activeWorktreeId - ? primaryActiveWorktreeRow.rowKey - : undefined, - pinnedDisplayPolicy, - renderRows, - virtualItems - }) - - const hasWorkspaceDropTargets = useMemo( - () => - groupBy === 'workspace-status' || - rows.some((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY), - [groupBy, rows] - ) - - const handleWorkspaceStatusDragOver = useCallback( - (event: React.DragEvent, status: WorkspaceStatus) => { - if (!hasWorkspaceDragData(event.dataTransfer)) { - return - } - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setDragOverStatus(status) - }, - [] - ) - - const handleWorkspaceStatusDragLeave = useCallback((event: React.DragEvent) => { - const relatedTarget = event.relatedTarget - if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { - return - } - setDragOverStatus(null) - }, []) - - const handleWorkspacePinDragOver = useCallback((event: React.DragEvent) => { - if (!hasWorkspaceDragData(event.dataTransfer)) { - return - } - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setPinDragOver(true) - }, []) - - const handleWorkspacePinDragLeave = useCallback((event: React.DragEvent) => { - const relatedTarget = event.relatedTarget - if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { - return - } - setPinDragOver(false) - }, []) - - const handleWorkspaceStatusDragFinish = useCallback(() => { - setDragOverStatus(null) - setPinDragOver(false) - }, []) - - const handleWorkspaceStatusDrop = useCallback( - (event: React.DragEvent, status: WorkspaceStatus) => { - const worktreeIds = readWorkspaceDragDataIds(event.dataTransfer) - if (worktreeIds.length === 0) { - return - } - event.preventDefault() - const session = worktreeDragSessionRef.current - const statusDrop = session - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status, - draggedIds: session.reorderDraggedIds - }) - : null - setDragOverStatus(null) - if (session && statusDrop) { - event.stopPropagation() - onMoveWorktreesToStatusAtIndex({ - worktreeIds: session.reorderDraggedIds, - status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - clearWorktreeDrag() - return - } - // Match status-drop scope to drag-preview scope (#9083): session uses its expanded set, else expand dataTransfer ids live. - onMoveWorktreesToStatus( - session ? session.reorderDraggedIds : getReorderDraggedIds(worktreeIds), - status - ) - }, - [ - clearWorktreeDrag, - computeWorktreeStatusDrop, - getReorderDraggedIds, - onMoveWorktreesToStatus, - onMoveWorktreesToStatusAtIndex, - worktreeDragGroups - ] - ) - - useEffect(() => { - const handleDocumentDrop = (event: DragEvent): void => { - const session = worktreeDragSessionRef.current - if (!session) { - return - } - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const drop = computeWorktreeDrop(event.clientY) - if (!drop) { - const container = scrollRef.current - const target = getEligibleLineageDropTarget( - container - ? getPointerDropStatusTarget({ - container, - x: event.clientX, - y: event.clientY - }) - : { status: null, isPinDrop: false, lineageParentId: null }, - session.draggedIds - ) - if (target.lineageParentId) { - event.preventDefault() - event.stopPropagation() - commitWorktreeLineageParentDrop(session.draggedIds, target.lineageParentId) - clearWorktreeDrag() - return - } - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: target.status, - draggedIds: session.reorderDraggedIds - }) - : null - if (target.status && statusDrop) { - event.preventDefault() - event.stopPropagation() - onMoveWorktreesToStatusAtIndex({ - worktreeIds: session.reorderDraggedIds, - status: target.status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - clearWorktreeDrag() - return - } - clearWorktreeDrag() - return - } - // Why: pointer still inside the source group means reorder, not status move; commit here and stop the capture handler. - event.preventDefault() - event.stopPropagation() - onReorderWorktrees({ - groups: worktreeDragGroups, - sourceGroupKey: session.sourceGroupKey, - draggedIds: session.reorderDraggedIds, - dropIndex: getFullDropIndexForWorktreeDragUnit({ - groups: worktreeDragUnitGroups, - sourceGroupKey: session.sourceGroupKey, - dropIndex: drop.dropIndex - }) - }) - clearReorderedWorktreeParents({ - draggedIds: session.draggedIds, - sourceGroupKey: session.sourceGroupKey - }) - clearWorktreeDrag() - } - - document.addEventListener('drop', handleDocumentDrop, true) - return () => document.removeEventListener('drop', handleDocumentDrop, true) - }, [ - clearWorktreeDrag, - clearReorderedWorktreeParents, - commitWorktreeLineageParentDrop, - computeWorktreeDrop, - computeWorktreeStatusDrop, - getEligibleLineageDropTarget, - onMoveWorktreesToStatusAtIndex, - onReorderWorktrees, - refreshWorktreeDragSession, - worktreeDragGroups, - worktreeDragUnitGroups - ]) - - useEffect(() => { - const handleDocumentDragEnd = (): void => { - if (worktreeDragSessionRef.current) { - clearWorktreeDrag() - } - } - - document.addEventListener('dragend', handleDocumentDragEnd, true) - return () => document.removeEventListener('dragend', handleDocumentDragEnd, true) - }, [clearWorktreeDrag]) - - useEffect(() => { - const handleVisibilityChange = (): void => { - if (document.visibilityState !== 'visible' && worktreeDragSessionRef.current) { - clearWorktreeDrag() - } - } - - document.addEventListener('visibilitychange', handleVisibilityChange) - return () => document.removeEventListener('visibilitychange', handleVisibilityChange) - }, [clearWorktreeDrag]) - - // Why: expand here (not the shared hook, used by the flat board) so a dropped parent carries its lineage children (#9083). - const moveWorktreesToStatusForDocumentDrop = useCallback( - (ids: readonly string[], status: WorkspaceStatus) => - onMoveWorktreesToStatus(getReorderDraggedIds(ids), status), - [getReorderDraggedIds, onMoveWorktreesToStatus] - ) - - useWorkspaceStatusDocumentDrop( - scrollRef, - onMoveWorktreeToStatus, - onPinWorktree, - handleWorkspaceStatusDragFinish, - hasWorkspaceDropTargets, - { - onMoveWorktreesToStatus: moveWorktreesToStatusForDocumentDrop, - onPinWorktrees - } - ) - - return ( -
-
-
- {canReorderRepoHeaders && - repoDrag.state.draggingRepoId !== null && - repoDrag.state.dropIndicatorY !== null ? ( - - ) : null} - {canReorderProjectGroupHeaders && - projectGroupDrag.state.draggingGroupId !== null && - projectGroupDrag.state.dropIndicatorY !== null ? ( - - ) : null} - {hostDrag.state.draggingHostId !== null && hostDrag.state.dropIndicatorY !== null ? ( - - ) : null} - {worktreeDragState.draggingWorktreeId !== null && - worktreeDragState.dropIndicatorY !== null ? ( - - ) : null} - {virtualItems.map((vItem) => { - const row = renderRows[vItem.index] - if (!row) { - return null - } - - if (row.type === 'host-header') { - // Why: the host card is the outer tier; it pins above group headers (z-30 vs z-20) and stays put as they hand off. - const isActiveStickyHost = activeStickyHostIndexRef.current === vItem.index - const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ - rows: renderRows, - index: vItem.index, - firstHeaderIndex - }) - return ( -
- toggleGroupWithScrollAnchor(row.key)} - onDragPointerDown={ - orderedHostIds.length > 1 - ? (e) => hostDrag.onHandlePointerDown(e, row.hostId) - : undefined - } - dragging={hostDrag.state.draggingHostId === row.hostId} - /> -
- ) - } - - if (row.type === 'header') { - const isActiveStickyHeader = activeStickyHeaderIndexRef.current === vItem.index - // Why: when a host card is pinned, the group tier pins flush beneath it, not at the viewport top. - const stickyTopClass = - activeStickyHostIndexRef.current !== null ? 'top-[35px]' : '-top-px' - const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ - rows: renderRows, - index: vItem.index, - firstHeaderIndex - }) - const isRepoHeader = groupBy === 'repo' && row.repo !== undefined - const isProjectGroupHeader = groupBy === 'repo' && row.projectGroup !== undefined - const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined - const projectGroupIdForHeader = - isProjectGroupHeader && !row.repo && typeof row.projectGroup?.id === 'string' - ? row.projectGroup.id - : undefined - const repoHeaderIndex = - projectIdForHeader !== undefined - ? repoHeaderIndexByRepoId.get(projectIdForHeader) - : undefined - const repoHeaderBucketKey = - projectIdForHeader !== undefined - ? repoHeaderBucketByRepoId.get(projectIdForHeader) - : undefined - const projectGroupHeaderIndex = - projectGroupIdForHeader !== undefined - ? projectGroupHeaderIndexByGroupId.get(projectGroupIdForHeader) - : undefined - const projectGroupHeaderBucketKey = - projectGroupIdForHeader !== undefined - ? projectGroupHeaderBucketByGroupId.get(projectGroupIdForHeader) - : undefined - const isDraggableRepoHeader = Boolean( - canReorderRepoHeaders && - isRepoHeader && - projectIdForHeader && - repoHeaderBucketKey && - (sidebarRepoHeaderIdsByBucket.get(repoHeaderBucketKey)?.length ?? 0) > 1 - ) - const isDraggableProjectGroupHeader = Boolean( - canReorderProjectGroupHeaders && - projectGroupIdForHeader && - projectGroupHeaderBucketKey && - (sidebarProjectGroupHeaderIdsByBucket.get(projectGroupHeaderBucketKey)?.length ?? - 0) > 1 - ) - const isDraggingThis = - canReorderRepoHeaders && - repoDrag.state.draggingRepoId !== null && - repoDrag.state.draggingRepoId === projectIdForHeader - const isDraggingThisProjectGroup = - canReorderProjectGroupHeaders && - projectGroupDrag.state.draggingGroupId !== null && - projectGroupDrag.state.draggingGroupId === projectGroupIdForHeader - const headerWorkspaceStatus = - groupBy === 'workspace-status' - ? getWorkspaceStatusFromGroupKey(row.key, workspaceStatuses) - : null - const isPinnedHeader = row.key === PINNED_GROUP_KEY - const repoHeaderColor = resolveProjectGroupHeaderColor({ - groupBy, - headerKey: row.key, - badgeColor: row.repo?.badgeColor - }) - const createState = row.repo - ? getRepoHeaderCreateState({ - repo: row.repo, - label: row.label, - sshStatus: row.repo.connectionId - ? (sshConnectionStates.get(row.repo.connectionId)?.status ?? null) - : null - }) - : null - const projectGroupPathStatus = - isProjectGroupHeader && - row.projectGroup && - 'parentPath' in row.projectGroup && - row.projectGroup.parentPath - ? getCachedFolderWorkspacePathStatus({ - scope: 'project-group', - projectGroupId: row.projectGroup.id - }) - : null - const folderWorkspaceCreateDisabled = - projectGroupPathStatus?.exists === false && - (isConfirmedStaleFolderPathStatus(projectGroupPathStatus) || - projectGroupPathStatus.reason === 'ambiguous-connection') - const projectGroupDepth = row.projectGroupDepth ?? 0 - const isHeaderCollapsed = collapsedGroups.has(row.key) - // Why: repo/project/status/pinned share compact section chrome; flat "All" stays a simple label. - const showHeaderCollapseAffordance = - row.count > 0 && - (isRepoHeader || - isProjectGroupHeader || - headerWorkspaceStatus !== null || - isPinnedHeader) - // Why: non-project headers like "All" are flat-list labels; don't reserve project hierarchy indent. - const headerPaddingLeft = - isRepoHeader || isProjectGroupHeader - ? getProjectGroupHeaderPaddingLeft(projectGroupDepth) - : WORKTREE_SECTION_HEADER_PADDING_LEFT - return ( -
-
handleWorkspaceStatusDragOver(event, headerWorkspaceStatus) - : undefined - } - onDragLeave={ - isPinnedHeader - ? handleWorkspacePinDragLeave - : headerWorkspaceStatus - ? handleWorkspaceStatusDragLeave - : undefined - } - onDrop={ - headerWorkspaceStatus - ? (event) => handleWorkspaceStatusDrop(event, headerWorkspaceStatus) - : undefined - } - onPointerDown={ - isDraggableRepoHeader && projectIdForHeader - ? (event) => repoDrag.onHandlePointerDown(event, projectIdForHeader) - : isDraggableProjectGroupHeader && projectGroupIdForHeader - ? (event) => - projectGroupDrag.onHandlePointerDown(event, projectGroupIdForHeader) - : undefined - } - onClick={(event) => { - if (shouldIgnoreRepoHeaderToggle(event)) { - return - } - toggleGroupWithScrollAnchor(row.key) - }} - onKeyDown={(e) => { - if (shouldIgnoreRepoHeaderToggle(e)) { - return - } - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - toggleGroupWithScrollAnchor(row.key) - } - }} - > - {/* Why: grab cursor on icon+title only. Row still has handle attrs so - indent/padding can arm drag; actions are excluded via data-repo-header-actions. - self-stretch fills h-7 so grab matches the full title column height. */} -
- {row.icon ? ( -
- {row.repo ? ( - - ) : ( - - )} -
- ) : null} - -
-
-
- {row.label} -
- - -
-
-
- - - {showHeaderCollapseAffordance ? ( -
{ - event.preventDefault() - event.stopPropagation() - toggleGroupWithScrollAnchor(row.key) - }} - > - -
- ) : null} - - {isProjectGroupHeader && !row.repo && row.projectGroup?.id ? ( - - - - - - { - if (row.projectGroup?.id) { - handleRenameProjectGroup(row.projectGroup.id, row.label) - } - }} - > - {translate( - 'auto.components.sidebar.WorktreeList.4d7b73658c', - 'Rename group' - )} - - { - if (row.projectGroup?.id) { - handleDeleteProjectGroup(row.projectGroup.id, row.label) - } - }} - > - {translate( - 'auto.components.sidebar.WorktreeList.902115cdbe', - 'Delete group' - )} - - - - ) : null} - - {isProjectGroupHeader && - !row.repo && - row.projectGroup && - 'parentPath' in row.projectGroup && - row.projectGroup.parentPath ? ( - - - - - - {projectGroupPathStatus?.exists === false - ? getFolderWorkspacePathStatusDescription(projectGroupPathStatus) - : translate( - 'auto.components.sidebar.WorktreeList.bd37a57ac8', - 'Create workspace for {{value0}}', - { value0: row.label } - )} - - - ) : null} - - {row.repo && groupBy === 'repo' ? ( - - - - - - - - - {translate( - 'auto.components.sidebar.WorktreeList.2ef41bf9a7', - 'Project actions' - )} - - - - { - if (row.repo) { - handleOpenRepoSettings(row.repo.id) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.2cdffbc728', - 'Project Settings' - )} - - { - if (row.repo) { - handleOpenRepoSettings( - row.repo.id, - getRepositoryIconSectionId(row.repo.id) - ) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.e82d3589a1', - 'Change Project Icon' - )} - - {row.repo && isGitRepoKind(row.repo) ? ( - { - if (row.repo) { - handleOpenWorktreeVisibility(row.repo) - } - }} - > - - {getWorktreeVisibilityMenuLabel(row.repo)} - - ) : null} - { - if (row.repo) { - handleCreateGroupFromRepo(row.repo) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.cbfd565f83', - 'New group from project' - )} - - {projectGroups.length > 0 ? ( - - - - {translate( - 'auto.components.sidebar.WorktreeList.4a08fb55f2', - 'Move to group' - )} - - - {projectGroups.map((group) => ( - { - if (row.repo) { - handleMoveProjectToGroup(row.repo, group.id) - } - }} - > - {group.name} - - ))} - - - ) : null} - {row.repo.projectGroupId ? ( - { - if (row.repo) { - handleRemoveProjectFromGroup(row.repo) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.64e55f7f01', - 'Remove from group' - )} - - ) : null} - - { - if (row.repo) { - handleRemoveProject(row.repo) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.c83968f87f', - 'Remove Project' - )} - - - - ) : null} - - {row.repo && groupBy === 'repo' ? ( - - - {createState?.disabled ? ( - event.stopPropagation()} - onPointerDown={handleRepoHeaderActionPointerDown} - > - - - ) : ( - - )} - - - {createState?.tooltip ?? - translate( - 'auto.components.sidebar.WorktreeList.bb85cd86ba', - 'Create workspace for {{value0}}', - { value0: row.label } - )} - - - ) : null} -
-
-
- ) - } - - const renderWorktreeRow = ( - itemRow: WorktreeItemRow, - nested: boolean, - lineageChildren?: React.ReactNode, - forceActiveSurface = false - ) => { - const lineageToggleGroupKey = itemRow.lineageGroupKey - const experimentalNewWorktreeCardStyle = - settings?.experimentalNewWorktreeCardStyle === true - const projectGroupId = itemRow.repo?.projectGroupId - const isFolderBackedRepoChild = - groupBy === 'repo' && - Boolean(projectGroupId && folderBackedProjectGroupIds.has(projectGroupId)) - // Why: experimental in-card lineage inherits the parent surface; legacy cards keep depth-based nested geometry. - const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth - const getCardContentIndent = (lineageDepth: number): number => - isFolderBackedRepoChild - ? getFolderBackedRepoWorktreeCardContentIndent({ - groupDepth: itemRow.groupDepth, - lineageDepth - }) - : getWorktreeCardContentIndent({ - isGrouped: groupBy !== 'none', - groupDepth: itemRow.groupDepth, - lineageDepth - }) - const inheritedCardContentIndent = getCardContentIndent(0) - const nestedLineageGeometry = nested - ? getLineageNestedRowGeometry({ - experimentalNewWorktreeCardStyle, - inheritedCardContentIndent, - lineageDepth: itemRow.depth - }) - : null - // Why: grouped rows inherit their header depth, but the card surface still spans the full row. - const paddingLeft = - nested && groupBy !== 'none' - ? getWorktreeCardContentIndent({ - isGrouped: false, - groupDepth: itemRow.groupDepth, - lineageDepth: paddingDepth - }) - : getCardContentIndent(paddingDepth) - const surfaceInset = nested - ? nestedLineageGeometry!.surfaceInset - : isFolderBackedRepoChild - ? getFolderBackedRepoWorktreeCardSurfaceInset({ - groupDepth: itemRow.groupDepth, - lineageDepth: paddingDepth - }) - : getWorktreeCardSurfaceInset({ - isGrouped: groupBy !== 'none', - groupDepth: itemRow.groupDepth - }) - const cardContentIndent = nested - ? nestedLineageGeometry!.cardContentIndent - : Math.max(0, paddingLeft - surfaceInset) - const lineageChildrenStyle = lineageChildren - ? getLineageChildrenInlineStyle( - nestedLineageGeometry?.lineageChildrenInlineOffset ?? - LINEAGE_CHILDREN_INLINE_OFFSET - ) - : undefined - const worktreeDragGroupKey = groupKeyByRowKey.get(itemRow.rowKey) - const worktreeDragGroupIndex = groupIndexByRowKey.get(itemRow.rowKey) - const revealHighlightTone = - agentSendTargetWorktreeId === itemRow.worktree.id ? 'ai' : 'default' - const isLineageDropTarget = - worktreeDragState.draggingWorktreeId && - (worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId === - itemRow.worktree.id || - nativeLineageDropTargetId === itemRow.worktree.id) - const isPinnedOverlayRow = itemRow.sectionKey === PINNED_GROUP_KEY - const isActiveWorktree = activeWorktreeId === itemRow.worktree.id - const activeSurfaceVariant = getActiveSurfaceVariant(itemRow) - return ( -
{ - if (nested) { - event.stopPropagation() - } - handleWorktreeRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) - }} - style={{ - paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined - }} - > - -
- ) - } - - const renderLineageDescendants = ( - parent: WorktreeItemRow, - descendants: readonly WorktreeItemRow[] - ): React.ReactNode | undefined => { - const childNodes: React.ReactNode[] = [] - let cursor = 0 - while (cursor < descendants.length) { - const child = descendants[cursor] - if (!child || child.depth !== parent.depth + 1) { - cursor++ - continue - } - - let nextSiblingIndex = cursor + 1 - while ( - nextSiblingIndex < descendants.length && - descendants[nextSiblingIndex]!.depth > child.depth - ) { - nextSiblingIndex++ - } - - const childLineageChildren = renderLineageDescendants( - child, - descendants.slice(cursor + 1, nextSiblingIndex) - ) - childNodes.push(renderWorktreeRow(child, true, childLineageChildren)) - cursor = nextSiblingIndex - } - return childNodes.length > 0 ? childNodes : undefined - } - - if (row.type === 'lineage-group') { - const [parent, ...children] = row.rows - const childIsActive = children.some((child) => child.worktree.id === activeWorktreeId) - const parentPreviewOffset = parent - ? (worktreeDragState.previewOffsetsByWorktreeId.get(parent.worktree.id) ?? 0) - : 0 - return ( -
-
- {parent - ? renderWorktreeRow( - parent, - false, - renderLineageDescendants(parent, children), - childIsActive - ) - : null} -
-
- ) - } - - if (row.type === 'imported-worktrees-card') { - const actionState = importedWorktreeCardActionState.get(row.repo.id) - return ( -
- handleShowImportedWorktrees(row.repo.id)} - onKeepHidden={ - canKeepImportedWorktreesHidden(row, actionState) - ? () => handleKeepImportedWorktreesHidden(row.repo.id) - : undefined - } - /> -
- ) - } - - if (row.type === 'new-external-worktrees-inbox') { - const actionState = newExternalWorktreeInboxActionState.get(row.repo.id) - return ( -
- handleOpenWorktreeVisibility(row.repo)} - onSuppress={() => handleOpenSuppressExternalWorktreeInbox(row.repo.id)} - /> -
- ) - } - - if (row.type === 'pending-creation') { - return ( -
- -
- ) - } - - if (row.type === 'folder-workspace') { - const folderWorkspaceRow = row as FolderWorkspaceItemRow - const folderWorktree = folderWorkspaceToWorktree(folderWorkspaceRow.folderWorkspace) - const folderWorkspacePathStatus = getCachedFolderWorkspacePathStatus({ - scope: 'folder-workspace', - folderWorkspaceId: folderWorkspaceRow.folderWorkspace.id - }) - const folderWorkspaceActivationDisabled = - folderWorkspacePathStatus?.exists === false && - (isConfirmedStaleFolderPathStatus(folderWorkspacePathStatus) || - folderWorkspacePathStatus.reason === 'ambiguous-connection') - const folderPrDisplay = getFolderWorkspaceCardPrDisplay({ - folderWorkspaceId: folderWorkspaceRow.folderWorkspace.id, - workspaceLineageByChildKey, - worktreeLineageById, - worktreeMap, - repoMap, - hostedReviewCache, - prCache, - settings - }) - const isFolderBackedWorkspaceChild = - groupBy === 'repo' && folderWorkspaceRow.projectGroup.createdFrom === 'folder-scan' - const { surfaceInset, cardContentIndent } = getFolderWorkspaceRowGeometry({ - experimentalNewWorktreeCardStyle: newCardStyle, - isFolderBackedWorkspaceChild, - isGrouped: groupBy !== 'none', - groupDepth: folderWorkspaceRow.groupDepth, - lineageDepth: folderWorkspaceRow.depth - }) - return ( -
- handleWorktreeRowPointerDown(event, folderWorktree.id, folderWorktree.id) - } - > -
0 ? { paddingLeft: surfaceInset } : undefined} - > - -
- -
-
-
- ) - } - - const itemWorkspaceStatus = - groupBy === 'workspace-status' - ? getWorkspaceStatus(row.worktree, workspaceStatuses) - : null - const itemPreviewOffset = - worktreeDragState.previewOffsetsByWorktreeId.get(row.worktree.id) ?? 0 - - return ( -
handleWorkspaceStatusDragOver(event, itemWorkspaceStatus) - : undefined - } - onDragLeave={itemWorkspaceStatus ? handleWorkspaceStatusDragLeave : undefined} - onDrop={ - itemWorkspaceStatus - ? (event) => handleWorkspaceStatusDrop(event, itemWorkspaceStatus) - : undefined - } - > - {renderWorktreeRow(row, false)} -
- ) - })} -
-
- {showScrollToTop ? : null} -
- ) -}) + EMPTY_PROJECT_GROUPS, + NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK +} from './worktree-list/virtualized-worktree-viewport-props' +import { useAgentSendTargetWorktreeId } from './worktree-list/use-agent-send-target-worktree' +import { useEffectiveCollapsedGroups } from './worktree-list/use-effective-collapsed-groups' +import { useProjectGroupDialogs } from './worktree-list/use-project-group-dialogs' +import { useSidebarExternalWorktreeCards } from './worktree-list/use-sidebar-external-worktree-cards' +import { useSidebarHostVisibleScope } from './worktree-list/use-sidebar-host-visible-scope' +import { useSidebarRevealRequests } from './worktree-list/use-sidebar-reveal-requests' +import { useSidebarSectionRows } from './worktree-list/use-sidebar-section-rows' +import { useSidebarWorktreeFilters } from './worktree-list/use-sidebar-worktree-filters' +import { useSidebarWorktreeSelection } from './worktree-list/use-sidebar-worktree-selection' +import { useSidebarWorktreeSortOrder } from './worktree-list/use-sidebar-worktree-sort-order' +import { useVisibleSidebarWorktrees } from './worktree-list/use-visible-sidebar-worktrees' +import { useWorktreeStatusMutations } from './worktree-list/use-worktree-status-mutations' type WorktreeListProps = { scrollOffsetRef: React.MutableRefObject @@ -5213,11 +47,6 @@ type WorktreeListProps = { onWorkspaceBoardDragPreviewCancel?: () => void } -export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void { - document.addEventListener('visibilitychange', onChange) - return () => document.removeEventListener('visibilitychange', onChange) -} - const WorktreeList = React.memo(function WorktreeList({ scrollOffsetRef, scrollAnchorRef, @@ -5230,9 +59,9 @@ const WorktreeList = React.memo(function WorktreeList({ const allWorktrees = useAllWorktrees() const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() + const repos = useAppStore((s) => s.repos) const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) @@ -5241,365 +70,29 @@ const WorktreeList = React.memo(function WorktreeList({ [activeWorkspaceKey, activeWorktreeId] ) const groupBy = useAppStore((s) => s.groupBy) - const setGroupBy = useAppStore((s) => s.setGroupBy) - const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) - const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) - const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder) - const setWorkspaceHostOrder = useAppStore((s) => s.setWorkspaceHostOrder) const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const sortBy = useAppStore((s) => s.sortBy) - const setSortBy = useAppStore((s) => s.setSortBy) const projectOrderBy = useAppStore((s) => s.projectOrderBy) - const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) - const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0)) - const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) - const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) - const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) - const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) - const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) - const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) - const filterRepoIds = useAppStore((s) => s.filterRepoIds) const openModal = useAppStore((s) => s.openModal) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) - const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) - const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta) - const updateRepo = useAppStore((s) => s.updateRepo) - const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) const activeView = useAppStore((s) => s.activeView) const activeModal = useAppStore((s) => s.activeModal) const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree) const pendingRevealSidebarRow = useAppStore((s) => s.pendingRevealSidebarRow) - const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) - const revealSidebarRow = useAppStore((s) => s.revealSidebarRow) - const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal) const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId) const clearPendingRevealSidebarRow = useAppStore((s) => s.clearPendingRevealSidebarRow) - const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) - // Why: eligibility only matters while the picker is open; when closed, don't subscribe to wake-time layout churn. - const agentTargetStatusByPaneKey = useAppStore((s) => - agentSendPopoverTargetMode ? s.agentStatusByPaneKey : EMPTY_AGENT_STATUS_BY_PANE_KEY - ) - const agentTargetStatusEpoch = useAppStore((s) => - agentSendPopoverTargetMode ? s.agentStatusEpoch : 0 - ) - const agentTargetTabsByWorktree = useAppStore((s) => - agentSendPopoverTargetMode ? s.tabsByWorktree : EMPTY_TABS_BY_WORKTREE - ) - const agentTargetTerminalLayoutsByTabId = useAppStore((s) => - agentSendPopoverTargetMode ? s.terminalLayoutsByTabId : EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID - ) - const agentTargetPtyIdsByTabId = useAppStore((s) => - agentSendPopoverTargetMode ? s.ptyIdsByTabId : EMPTY_PTY_IDS_BY_TAB_ID - ) - const agentTargetRuntimePaneTitlesByTabId = useAppStore((s) => - agentSendPopoverTargetMode ? s.runtimePaneTitlesByTabId : EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID - ) - const agentSendTargetWorktreeId = useMemo(() => { - void agentTargetStatusEpoch - if (!agentSendPopoverTargetMode) { - return null - } - const targets = deriveRunningAgentSendTargets( - { - agentStatusByPaneKey: agentTargetStatusByPaneKey, - tabsByWorktree: agentTargetTabsByWorktree, - terminalLayoutsByTabId: agentTargetTerminalLayoutsByTabId, - ptyIdsByTabId: agentTargetPtyIdsByTabId, - runtimePaneTitlesByTabId: agentTargetRuntimePaneTitlesByTabId - }, - agentSendPopoverTargetMode.worktreeId - ) - return targets.some((target) => target.status === 'eligible') - ? agentSendPopoverTargetMode.worktreeId - : null - }, [ - // Why: eligibility can flip when the stale-boundary scheduler bumps this epoch without replacing the status map. - agentTargetStatusEpoch, - agentSendPopoverTargetMode, - agentTargetStatusByPaneKey, - agentTargetTabsByWorktree, - agentTargetTerminalLayoutsByTabId, - agentTargetPtyIdsByTabId, - agentTargetRuntimePaneTitlesByTabId - ]) - - // Read tabsByWorktree when needed for filtering or sorting - const needsActivityMaps = !showSleepingWorkspaces || sortBy === 'smart' - const tabsByWorktree = useAppStore((s) => - needsActivityMaps ? getVisibleWorktreeTerminalActivityTabs(s.tabsByWorktree) : null - ) - const ptyIdsByTabId = useAppStore((s) => (needsActivityMaps ? s.ptyIdsByTabId : null)) - const browserTabsByWorktree = useAppStore((s) => - !showSleepingWorkspaces ? getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree) : null - ) - + const collapsedGroups = useAppStore((s) => s.collapsedGroups) + const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup) + const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS) + const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const settings = useAppStore((s) => s.settings) const cardProps = useAppStore((s) => s.worktreeCardProperties) - const { prCache, hostedReviewCache } = useAppStore( useShallow((s) => selectWorktreeListReviewCacheInputs(s, groupBy, cardProps)) ) - const settings = useAppStore((s) => s.settings) const pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings) - const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) - const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) - const pairedDeviceIdsByEnvironment = useMemo( - () => - hideWorkspacesFromOtherDevices - ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) - : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, - [hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId] - ) - - const sortEpoch = useAppStore((s) => s.sortEpoch) - - // Non-archived count — detects structural changes (add/remove) so the debounce below can apply immediately. - const worktreeCount = useMemo(() => { - let count = 0 - for (const worktree of allWorktrees) { - if (!worktree.isArchived) { - count++ - } - } - return count - }, [allWorktrees]) - - // Why debounce: scores are time-decaying, so recomputing on every sortEpoch bump makes worktrees jump; settle to coalesce. - // Structural changes (add/remove) bypass the debounce so a new worktree appears at its sorted position immediately. - const [debouncedSortEpoch, setDebouncedSortEpoch] = useState(sortEpoch) - const prevWorktreeCountRef = useRef(worktreeCount) - useEffect(() => { - if (debouncedSortEpoch === sortEpoch) { - return - } - - const structuralChange = worktreeCount !== prevWorktreeCountRef.current - prevWorktreeCountRef.current = worktreeCount - - // Why: manual drag/drop is direct manipulation; the settle-window delay would make a successful drop look broken. - if (structuralChange || sortBy === 'manual') { - setDebouncedSortEpoch(sortEpoch) - return - } - - const timer = setTimeout(() => setDebouncedSortEpoch(sortEpoch), SORT_SETTLE_MS) - return () => clearTimeout(timer) - }, [sortEpoch, debouncedSortEpoch, worktreeCount, sortBy]) - - // Why a latching ref: a live signal makes Smart authoritative for the session, even after that activity ends. - const sessionHasHadLiveSmartSignal = useRef(false) - - // ── Stable sort order ────────────────────────────────────────── - // Why sortEpoch (not selection): selection side-effects (clearing isUnread, PR-cache refresh) must not reorder the sidebar under the user. - // Why useMemo not useEffect: order must be computed synchronously before the worktrees memo reads it. - // Why a ref alongside the memo: telemetry effects need the last attention map without re-reading store state. - const lastAttentionByWorktreeRef = useRef | null>(null) - - const recomputedSortedIds = useMemo(() => { - const state = useAppStore.getState() - const nonArchivedWorktrees = getAllWorktreesFromState(state).filter( - (worktree) => !worktree.isArchived - ) - const now = Date.now() - - // Why cold-start detection: agent-status hydrates async, so the warm comparator would collapse all to Class 4; keep the persisted order until a live signal appears. - if (sortBy === 'smart' && !sessionHasHadLiveSmartSignal.current) { - // Why tabHasLivePty over tab.ptyId: slept terminals keep tab.ptyId as a wake hint, so it'd falsely keep cold-start ordering off. - const hasAnyLivePty = Object.values(state.tabsByWorktree) - .flat() - .some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) - if ( - hasAnyLivePty || - hasFreshAttributedAgentStatus(state.agentStatusByPaneKey, now, state.tabsByWorktree) - ) { - sessionHasHadLiveSmartSignal.current = true - } else { - nonArchivedWorktrees.sort( - (a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b) - ) - lastAttentionByWorktreeRef.current = null - return nonArchivedWorktrees.map((w) => w.id) - } - } - - const currentTabs = state.tabsByWorktree - // Why precompute: hot sort — build the attention map once so the O(N log N) comparator does O(1) lookups. - const attentionByWorktree = - sortBy === 'smart' - ? buildAttentionByWorktree( - nonArchivedWorktrees, - currentTabs, - state.agentStatusByPaneKey, - state.runtimePaneTitlesByTabId, - state.ptyIdsByTabId, - now, - state.migrationUnsupportedByPtyId, - state.terminalLayoutsByTabId - ) - : new Map() - lastAttentionByWorktreeRef.current = sortBy === 'smart' ? attentionByWorktree : null - nonArchivedWorktrees.sort(buildWorktreeComparator(sortBy, repoMap, now, attentionByWorktree)) - return nonArchivedWorktrees.map((w) => w.id) - // debouncedSortEpoch is an intentional trigger not read in the memo; its change (debounced) signals a recompute. - // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSortEpoch, repoMap, sortBy]) - // Why: stable ID order prevents rank-only refreshes from echoing an unchanged snapshot. - const sortedIds = useReusedArrayIdentity(recomputedSortedIds) - - // Why a ref of prior class: fire class_1_promotion only on transitions into Class 1, not every recompute that stays there. - const prevClassByWorktreeIdRef = useRef>(new Map()) - // Why gate the first observation: an empty prev-class map makes every existing Class-1 worktree look freshly promoted; treat the first pass as a silent baseline. - const hasObservedSmartOnceRef = useRef(false) - - useEffect(() => { - const attention = lastAttentionByWorktreeRef.current - if (sortBy !== 'smart' || !attention) { - // Why reset: leaving Smart drops the prior-class map (and first-observation gate) so re-entry doesn't fire stale promotions. - prevClassByWorktreeIdRef.current = new Map() - hasObservedSmartOnceRef.current = false - return - } - const next = new Map() - const isFirstObservation = !hasObservedSmartOnceRef.current - for (const [worktreeId, info] of attention) { - const prev = prevClassByWorktreeIdRef.current.get(worktreeId) - if (!isFirstObservation && info.cls === 1 && prev !== 1 && info.cause) { - track('smart_sort_class_1_promotion', { cause: info.cause }) - } - next.set(worktreeId, info.cls) - } - prevClassByWorktreeIdRef.current = next - hasObservedSmartOnceRef.current = true - }, [sortBy, recomputedSortedIds]) - - // Why retry on recomputation: Smart may activate before attention hydrates; fire once, then stay quiet until the user leaves Smart. - const hasTrackedSmartDistributionRef = useRef(false) - useEffect(() => { - if (sortBy !== 'smart') { - hasTrackedSmartDistributionRef.current = false - return - } - if (hasTrackedSmartDistributionRef.current) { - return - } - const attention = lastAttentionByWorktreeRef.current - if (!attention || attention.size === 0) { - return - } - let class1 = 0 - let class2 = 0 - let class3 = 0 - let class4 = 0 - for (const info of attention.values()) { - if (info.cls === 1) { - class1++ - } else if (info.cls === 2) { - class2++ - } else if (info.cls === 3) { - class3++ - } else { - class4++ - } - } - track('smart_sort_class_distribution', { - class_1: class1, - class_2: class2, - class_3: class3, - class_4: class4, - total_worktrees: attention.size - }) - hasTrackedSmartDistributionRef.current = true - }, [sortBy, recomputedSortedIds]) - - // Why fire on the transition: switching away from Smart is the signal; compare via ref so a round-trip doesn't double-fire. - const prevSortByRef = useRef(sortBy) - useEffect(() => { - const prev = prevSortByRef.current - prevSortByRef.current = sortBy - if (prev === 'smart' && sortBy === 'recent') { - track('smart_to_recent_switch', {}) - } - }, [sortBy]) - - // Why: only persist during live sessions so cold start reads the persisted order instead of overwriting it. - useEffect(() => { - if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadLiveSmartSignal.current) { - return - } - // Why: sortOrder lives in each host's worktreeMeta, so persist each host's ids on that host. - const state = useAppStore.getState() - persistWorktreeSortOrderByHost(state, sortedIds) - }, [sortedIds, sortBy]) - - // Flatten/filter/sort via the shared utility so card order matches Cmd+1–9 numbering. - const recomputedVisibleWorktrees = useMemo(() => { - void agentStatusEpoch - const ids = computeVisibleWorktreeIds(worktreesByRepo, sortedIds, { - filterRepoIds, - showSleepingWorkspaces, - tabsByWorktree, - ptyIdsByTabId, - browserTabsByWorktree, - // Why snapshot on agentStatusEpoch: update membership immediately without repainting on every hook ping. - worktreeIdsWithLiveAgent: showSleepingWorkspaces - ? EMPTY_WORKTREE_ID_SET - : getWorktreeIdsWithLiveAgent( - useAppStore.getState().agentStatusByPaneKey, - tabsByWorktree, - Date.now() - ), - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - pairedDeviceIdsByEnvironment, - alwaysShowDefaultBranchWorkspace, - repoMap, - workspaceHostScope, - visibleWorkspaceHostIds, - defaultHostId: getSettingsFocusedExecutionHostId(settings), - worktreeLineageById, - forcedVisibleWorktreeIds: agentSendTargetWorktreeId ? [agentSendTargetWorktreeId] : undefined - }) - return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null) - }, [ - agentSendTargetWorktreeId, - agentStatusEpoch, - filterRepoIds, - showSleepingWorkspaces, - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - alwaysShowDefaultBranchWorkspace, - workspaceHostScope, - visibleWorkspaceHostIds, - settings, - repoMap, - tabsByWorktree, - ptyIdsByTabId, - browserTabsByWorktree, - sortedIds, - worktreeMap, - worktreeLineageById, - worktreesByRepo, - pairedDeviceIdsByEnvironment - ]) - // Why: agentStatusEpoch bumps recompute this memo even when membership and - // order are unchanged; keeping the previous identity stops the whole - // rows/sectionRows/renderedWorktrees chain from churning per epoch. - const visibleWorktrees = useReusedArrayIdentity(recomputedVisibleWorktrees) - - const worktrees = visibleWorktrees - const collapsedGroups = useAppStore((s) => s.collapsedGroups) - const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup) - - // Why: manual header order is bound to state.repos; Recent/Smart derive order from the sorted worktree stream. - const repos = useAppStore((s) => s.repos) + const defaultHostId = getSettingsFocusedExecutionHostId(settings) const projectHostSetupProjection = useProjectHostSetupProjection() const projectGrouping = useMemo( () => ({ @@ -5608,427 +101,89 @@ const WorktreeList = React.memo(function WorktreeList({ }), [projectHostSetupProjection] ) - const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS) - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) - const effectiveCollapsedGroups = useMemo(() => { - if (!agentSendTargetWorktreeId) { - return collapsedGroups - } - const targetWorktree = worktreeMap.get(agentSendTargetWorktreeId) - if (!targetWorktree) { - return collapsedGroups - } - const next = new Set(collapsedGroups) - if (targetWorktree.isPinned) { - next.delete(PINNED_GROUP_KEY) - } else { - for (const groupKey of getGroupKeysForWorktree( - groupBy, - targetWorktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups, - projectGrouping - )) { - next.delete(groupKey) - } - } - for (const parent of getWorktreeLineageAncestors( - targetWorktree, - worktreeLineageById, - worktreeMap - )) { - next.delete(getLineageGroupKey(parent.id)) - } - return next - }, [ - agentSendTargetWorktreeId, - collapsedGroups, - groupBy, - prCache, - projectGroups, - projectGrouping, + const agentSendTargetWorktreeId = useAgentSendTargetWorktreeId() + const { filterState, hasFilters, clearFilters } = useSidebarWorktreeFilters() + const sortedIds = useSidebarWorktreeSortOrder({ allWorktrees, repoMap, sortBy }) + const { visibleWorktrees, pairedDeviceIdsByEnvironment } = useVisibleSidebarWorktrees({ + filterState, + sortBy, + sortedIds, repoMap, + worktreeMap, + worktreeLineageById, + settings, + agentSendTargetWorktreeId + }) + const effectiveCollapsedGroups = useEffectiveCollapsedGroups({ + collapsedGroups, + agentSendTargetWorktreeId, + groupBy, + repoMap, + worktreeMap, + worktreeLineageById, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + }) + const visibleScope = useSidebarHostVisibleScope({ + filterState, + defaultHostId, + repos, + projectGroups, + folderWorkspaces, + pairedDeviceIdsByEnvironment + }) + const externalWorktreeCards = useSidebarExternalWorktreeCards({ + repos, + visibleReposForRows: visibleScope.visibleReposForRows, + detectedWorktreesByRepo, + filterRepoIds: filterState.filterRepoIds + }) + const rowModel = useSidebarSectionRows({ + groupBy, + projectOrderBy, + pinnedDisplayPolicy, + defaultHostId, + worktrees: visibleWorktrees, + repos, + repoMap, + worktreeMap, + worktreeLineageById, + prCache, settings, workspaceStatuses, - worktreeLineageById, - worktreeMap - ]) - const defaultHostId = getSettingsFocusedExecutionHostId(settings) - const visibleHostIdSet = useMemo( - () => getVisibleSidebarHostIdSet(visibleWorkspaceHostIds, workspaceHostScope), - [visibleWorkspaceHostIds, workspaceHostScope] - ) - const visibleReposForRows = useMemo(() => { - if (!visibleHostIdSet) { - return repos - } - return repos.filter((repo) => { - const hostId = - repo.connectionId || repo.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId - return visibleHostIdSet.has(hostId) - }) - }, [defaultHostId, repos, visibleHostIdSet]) - const visibleProjectGroupsForRows = useMemo( - () => filterProjectGroupsForVisibleHosts(projectGroups, visibleHostIdSet, defaultHostId), - [defaultHostId, projectGroups, visibleHostIdSet] - ) - const visibleFolderWorkspacesForRows = useMemo(() => { - const hostVisibleWorkspaces = filterFolderWorkspacesForVisibleHosts( - folderWorkspaces, - projectGroups, - visibleHostIdSet, - defaultHostId - ) - if (!hideWorkspacesFromOtherDevices) { - return hostVisibleWorkspaces - } - return filterFolderWorkspacesFromOtherDevices( - hostVisibleWorkspaces, - pairedDeviceIdsByEnvironment - ) - }, [ - defaultHostId, - folderWorkspaces, - hideWorkspacesFromOtherDevices, - pairedDeviceIdsByEnvironment, - projectGroups, - visibleHostIdSet - ]) - const repoOrder = useMemo(() => { - return getLogicalRepoOrderRankById(repos.map((repo) => repo.id)) - }, [repos]) - const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState< - Map - >(new Map()) - const [newExternalWorktreeInboxActionState, setNewExternalWorktreeInboxActionState] = useState< - Map - >(new Map()) - const [suppressExternalWorktreeInboxRepoId, setSuppressExternalWorktreeInboxRepoId] = useState< - string | null - >(null) - const importedWorktreesByRepo = useMemo(() => { - const forceVisibleRepoIds = new Set( - [...importedWorktreeCardActionState.entries()] - .filter(([, state]) => state.forceVisible) - .map(([repoId]) => repoId) - ) - return buildImportedWorktreesCardCandidates({ - repos: visibleReposForRows, - detectedWorktreesByRepo, - filterRepoIds, - forceVisibleRepoIds - }) - }, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, visibleReposForRows]) - const newExternalWorktreesInboxByRepo = useMemo( - () => - buildNewExternalWorktreesInboxCandidates({ - repos: visibleReposForRows, - detectedWorktreesByRepo, - filterRepoIds - }), - [detectedWorktreesByRepo, filterRepoIds, visibleReposForRows] - ) - const placeholderRepoIds = useMemo(() => { - return getEmptyProjectPlaceholderRepoIds({ - groupBy, - repos: visibleReposForRows, - worktreesByRepo, - visibleWorktrees, - filterRepoIds - }) - }, [filterRepoIds, groupBy, visibleReposForRows, visibleWorktrees, worktreesByRepo]) - const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) - - // Why: subscribe on a flat key array (useShallow) so progress ticks don't rebuild the whole row model. - // Split on first space — creationId is a UUID (no space) so a space-containing repoId stays intact. - const pendingCreationKeys = useAppStore( - useShallow((s) => - Object.values(s.pendingWorktreeCreations ?? {}).map( - (creation) => `${creation.creationId} ${creation.request.repoId}` - ) - ) - ) - const pendingCreations = useMemo( - () => - pendingCreationKeys.map((key) => { - const separator = key.indexOf(' ') - return { creationId: key.slice(0, separator), repoId: key.slice(separator + 1) } - }), - [pendingCreationKeys] - ) - const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) - const hostOptions = useMemo( - () => - buildSidebarHostOptions({ - repos, - sshTargetLabels, - sshConnectionStates, - settings, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - hostLabelOverrides - }), - [ - repos, - sshTargetLabels, - sshConnectionStates, - settings, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - hostLabelOverrides - ] - ) - const hostLabelById = useMemo( - () => new Map(hostOptions.map((host) => [host.id, host.label])), - [hostOptions] - ) - - const rows: Row[] = useMemo( - () => - buildRows( - groupBy, - worktrees, - repoMap, - prCache, - effectiveCollapsedGroups, - repoOrder, - workspaceStatuses, - projectOrderBy, - worktreeLineageById, - worktreeMap, - true, - settings, - visibleProjectGroupsForRows, - placeholderRepoIds, - importedWorktreesByRepo, - newExternalWorktreesInboxByRepo, - pendingCreations, - projectGrouping, - visibleFolderWorkspacesForRows, - hostLabelById, - defaultHostId, - pinnedDisplayPolicy - ), - [ - groupBy, - worktrees, - repoMap, - prCache, - effectiveCollapsedGroups, - defaultHostId, - repoOrder, - workspaceStatuses, - projectOrderBy, - worktreeLineageById, - worktreeMap, - settings, - projectGrouping, - visibleProjectGroupsForRows, - visibleFolderWorkspacesForRows, - placeholderRepoIds, - importedWorktreesByRepo, - newExternalWorktreesInboxByRepo, - pendingCreations, - hostLabelById, - pinnedDisplayPolicy - ] - ) - const orderedHostOptions = useMemo( - () => orderHostSectionOptions(hostOptions, workspaceHostOrder), - [hostOptions, workspaceHostOrder] - ) - const [hostDragActive, setHostDragActive] = useState(false) - const handleReorderHostSections = useCallback( - (orderedVisibleHostIds: ExecutionHostId[]) => { - const visibleHostIds = new Set(orderedVisibleHostIds) - const hostOptionIds = orderedHostOptions.map((host) => host.id) - const knownHostIds = new Set(hostOptionIds) - const nextOrder: ExecutionHostId[] = [...orderedVisibleHostIds] - const seen = new Set(nextOrder) - // Why: dragging only covers rendered hosts; keep non-rendered SSH/runtime hosts in the saved order so they return in place. - for (const hostId of [...workspaceHostOrder, ...hostOptionIds]) { - if (!knownHostIds.has(hostId) || visibleHostIds.has(hostId) || seen.has(hostId)) { - continue - } - nextOrder.push(hostId) - seen.add(hostId) - } - setWorkspaceHostOrder(nextOrder) - }, - [orderedHostOptions, setWorkspaceHostOrder, workspaceHostOrder] - ) - const sectionRows = useMemo( - () => - addHostSectionRows({ - rows, - hostOptions: orderedHostOptions, - workspaceHostScope, - visibleWorkspaceHostIds, - defaultHostId, - collapsedHostKeys: effectiveCollapsedGroups, - forceCollapseHosts: hostDragActive, - // Why: projects/workspaces are the primary sidebar object; host sections are only an explicit host-filter view. - preferProjectGrouping: true - }), - [ - defaultHostId, - effectiveCollapsedGroups, - hostDragActive, - orderedHostOptions, - rows, - visibleWorkspaceHostIds, - workspaceHostScope - ] - ) - const renderedSidebarRowKeys = useMemo(() => { - const keys = new Set() - for (const row of sectionRows) { - if (row.type === 'header') { - keys.add(row.key) - } else if (row.type === 'item') { - keys.add(row.rowKey) - } else if (row.type === 'folder-workspace') { - keys.add(folderWorkspaceKey(row.folderWorkspace.id)) - } else if (row.type === 'pending-creation') { - keys.add(`pending:${row.creationId}`) - } else if (row.type === 'imported-worktrees-card') { - keys.add(row.key) - } else if (row.type === 'new-external-worktrees-inbox') { - keys.add(row.key) - } - } - return keys - }, [sectionRows]) - // Why: status headers move during wake (inactive -> active); key only on grouping mode so row identity survives. - const visibleHostResetKey = visibleWorkspaceHostIds?.join(',') ?? 'all' - const viewportResetKey = `group:${groupBy}:host:${visibleHostResetKey}:lineage` - - // Why: derive order from the built rows, not the flat worktrees array, so Cmd+1–9 match visual positions when grouping reorders cards. - const renderedWorktrees = useMemo( - () => getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy), - [pinnedDisplayPolicy, sectionRows] - ) - // Why: order-preserving sectionRows rebuilds must not give this array a new - // identity — updateSelectionForGesture depends on it, and a fresh identity - // there defeats React.memo bail-out for every WorktreeCard on epoch bumps. - const renderedWorktreeIds = useReusedArrayIdentity( - useMemo( - () => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)), - [renderedWorktrees] - ) - ) - const [selectedWorktreeIds, setSelectedWorktreeIds] = useState>(new Set()) - const [selectionAnchorId, setSelectionAnchorId] = useState(null) - - const prunedSelection = pruneWorktreeSelection( - selectedWorktreeIds, - selectionAnchorId, - renderedWorktreeIds - ) - // Why: filters/grouping can hide selected cards; prune during render so nothing sees stale ids for unrendered worktrees. - if (!areWorktreeSelectionsEqual(selectedWorktreeIds, prunedSelection.selectedIds)) { - setSelectedWorktreeIds(prunedSelection.selectedIds) - } - if (selectionAnchorId !== prunedSelection.anchorId) { - setSelectionAnchorId(prunedSelection.anchorId) - } - - // Why identity reuse: the empty/unchanged-selection case must keep one array - // identity — selectForContextMenu and both drag-start handlers depend on - // this array, and card memo bail-out depends on those staying stable. - const selectedWorktrees = useReusedArrayIdentity( - useMemo(() => { - if (selectedWorktreeIds.size === 0) { - return [] - } - const selected = new Map() - for (const worktree of renderedWorktrees) { - if (selectedWorktreeIds.has(worktree.id) && !selected.has(worktree.id)) { - selected.set(worktree.id, worktree) - } - } - return Array.from(selected.values()) - }, [renderedWorktrees, selectedWorktreeIds]) - ) - - useEffect(() => { - if (selectedWorktreeIds.size === 0) { - return - } - - const clearSelectionOutsideSidebar = (event: PointerEvent): void => { - const target = event.target - const sidebarContainer = document.querySelector('[data-worktree-sidebar-container]') - if (target instanceof Node && sidebarContainer?.contains(target)) { - return - } - setSelectedWorktreeIds(new Set()) - setSelectionAnchorId(null) - } - - document.addEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) - return () => { - document.removeEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) - } - }, [selectedWorktreeIds.size]) - - const updateSelectionForGesture = useCallback( - (event: React.MouseEvent, worktreeId: string): boolean => { - const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac')) - const result = updateWorktreeSelection({ - visibleIds: renderedWorktreeIds, - previousSelectedIds: selectedWorktreeIds, - previousAnchorId: selectionAnchorId, - targetId: worktreeId, - intent - }) - setSelectedWorktreeIds(result.selectedIds) - setSelectionAnchorId(result.anchorId) - // Plain click navigates; modifier gestures are selection-only so a batch can build without switching away. - return intent !== 'replace' - }, - [renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId] - ) - - const selectForContextMenu = useCallback( - (_event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => { - if (selectedWorktreeIds.has(worktree.id) && selectedWorktreeIds.size > 1) { - return selectedWorktrees - } - setSelectedWorktreeIds(new Set([worktree.id])) - setSelectionAnchorId(worktree.id) - return [worktree] - }, - [selectedWorktreeIds, selectedWorktrees] - ) + effectiveCollapsedGroups, + projectGrouping, + visibleReposForRows: visibleScope.visibleReposForRows, + visibleProjectGroupsForRows: visibleScope.visibleProjectGroupsForRows, + visibleFolderWorkspacesForRows: visibleScope.visibleFolderWorkspacesForRows, + importedWorktreesByRepo: externalWorktreeCards.importedWorktreesByRepo, + newExternalWorktreesInboxByRepo: externalWorktreeCards.newExternalWorktreesInboxByRepo, + filterRepoIds: filterState.filterRepoIds, + visibleWorkspaceHostIds: filterState.visibleWorkspaceHostIds, + workspaceHostScope: filterState.workspaceHostScope + }) + const selection = useSidebarWorktreeSelection({ + sectionRows: rowModel.sectionRows, + pinnedDisplayPolicy + }) + const statusMutations = useWorktreeStatusMutations({ worktreeMap, workspaceStatuses, sortBy }) + const projectGroupDialogs = useProjectGroupDialogs({ repos, repoMap, projectGroups }) const handleImmediateWorktreeActivate = useCallback((worktreeId: string, rowKey?: string) => { // Why: re-rendering the virtualized sidebar on the pointer path adds visible latency; mutate the row directly and let store state reconcile after. markSidebarWorktreeActiveImmediately(worktreeId, rowKey) }, []) - // Why: full-page nav views aren't scoped to a worktree, so no sidebar card should look selected. - const selectedSidebarWorktreeId = - activeView === 'tasks' || activeView === 'activity' ? null : currentSidebarWorktreeId - - // Why layout effect: the Cmd/Ctrl+1–9 handler can fire right after commit; publishing after paint would leave the shortcut cache stale. - useLayoutEffect(() => { - setVisibleWorktreeIds(renderedWorktreeIds) - // Why null, not []: [] is a real rendered order (all collapsed/filtered); null tells shortcuts the list is unmounted. - return () => setVisibleWorktreeIds(null) - }, [renderedWorktreeIds]) - const handleCreateForRepo = useCallback( (projectId: string) => { openModal('new-workspace-composer', { initialRepoId: projectId, telemetrySource: 'sidebar' }) }, [openModal] ) - const handleOpenRepoSettings = useCallback( (projectId: string, sectionId?: string) => { openSettingsTarget({ pane: 'repo', repoId: projectId, ...(sectionId ? { sectionId } : {}) }) @@ -6036,141 +191,12 @@ const WorktreeList = React.memo(function WorktreeList({ }, [openSettingsPage, openSettingsTarget] ) - const handleOpenWorktreeVisibility = useCallback( (repo: Repo) => { - openModal('worktree-visibility', { - repoId: repo.id, - hostId: getRepoExecutionHostId(repo) - }) + openModal('worktree-visibility', { repoId: repo.id, hostId: getRepoExecutionHostId(repo) }) }, [openModal] ) - - const setImportedWorktreeCardState = useCallback( - (projectId: string, state: ImportedWorktreeCardActionState | null) => { - setImportedWorktreeCardActionState((previous) => { - const next = new Map(previous) - if (state) { - next.set(projectId, state) - } else { - next.delete(projectId) - } - return next - }) - }, - [] - ) - - const handleShowImportedWorktrees = useCallback( - async (projectId: string) => { - await showImportedWorktreesCard({ - projectId, - forceVisible: importedWorktreeCardActionState.get(projectId)?.forceVisible === true, - updateRepo, - fetchWorktrees, - setCardState: setImportedWorktreeCardState - }) - }, - [fetchWorktrees, importedWorktreeCardActionState, setImportedWorktreeCardState, updateRepo] - ) - - const handleKeepImportedWorktreesHidden = useCallback( - async (projectId: string) => { - const repo = repos.find((candidate) => candidate.id === projectId) - let detected = detectedWorktreesByRepo[projectId] - // Why: baseline seeding needs authoritative hidden paths, so don't dismiss on a stale snapshot. - if (detected?.authoritative !== true) { - const refreshed = await fetchWorktrees(projectId, { requireAuthoritative: true }) - if (!refreshed) { - setImportedWorktreeCardState(projectId, { - pending: false, - error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR - }) - return - } - detected = useAppStore.getState().detectedWorktreesByRepo[projectId] - } - if (detected?.authoritative !== true) { - setImportedWorktreeCardState(projectId, { - pending: false, - error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR - }) - return - } - const hiddenWorktrees = getHiddenImportedWorktrees(detected) - await keepImportedWorktreesHiddenCard({ - projectId, - updateRepo, - setCardState: setImportedWorktreeCardState, - hiddenWorktreePaths: hiddenWorktrees.map((worktree) => worktree.path), - existingBaselinePaths: repo?.externalWorktreeInboxBaselinePaths - }) - }, - [detectedWorktreesByRepo, fetchWorktrees, repos, setImportedWorktreeCardState, updateRepo] - ) - - const setNewExternalWorktreeInboxState = useCallback( - (projectId: string, state: NewExternalWorktreesInboxActionState | null) => { - setNewExternalWorktreeInboxActionState((previous) => { - const next = new Map(previous) - if (state) { - next.set(projectId, state) - } else { - next.delete(projectId) - } - return next - }) - }, - [] - ) - - const getNewExternalWorktreeInboxActionArgs = useCallback( - (projectId: string, worktreePaths: readonly string[]) => { - const repo = repos.find((candidate) => candidate.id === projectId) - if (!repo) { - return null - } - return { - projectId, - repo, - worktreePaths, - updateRepo, - fetchWorktrees, - setInboxState: setNewExternalWorktreeInboxState - } - }, - [fetchWorktrees, repos, setNewExternalWorktreeInboxState, updateRepo] - ) - - const handleOpenSuppressExternalWorktreeInbox = useCallback((projectId: string) => { - setSuppressExternalWorktreeInboxRepoId(projectId) - }, []) - - const handleConfirmSuppressExternalWorktreeInbox = useCallback(async () => { - if (!suppressExternalWorktreeInboxRepoId) { - return - } - const projectId = suppressExternalWorktreeInboxRepoId - const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? [] - const args = getNewExternalWorktreeInboxActionArgs( - projectId, - inboxWorktrees.map((worktree) => worktree.path) - ) - if (!args) { - setSuppressExternalWorktreeInboxRepoId(null) - return - } - const suppressed = await suppressNewExternalWorktreeInbox(args) - if (suppressed) { - setSuppressExternalWorktreeInboxRepoId(null) - } - }, [ - getNewExternalWorktreeInboxActionArgs, - newExternalWorktreesInboxByRepo, - suppressExternalWorktreeInboxRepoId - ]) - const handleRemoveProject = useCallback( (repo: Repo) => { openModal('confirm-remove-folder', { @@ -6181,139 +207,6 @@ const WorktreeList = React.memo(function WorktreeList({ }, [openModal] ) - - const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) - const createProjectGroup = useAppStore((s) => s.createProjectGroup) - const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) - const deleteProjectGroupWithContainedProjects = useAppStore( - (s) => s.deleteProjectGroupWithContainedProjects - ) - const [projectGroupNameDialog, setProjectGroupNameDialog] = - useState(null) - const [projectGroupDeleteDialog, setProjectGroupDeleteDialog] = - useState(null) - - const handleCreateGroupFromRepo = useCallback((repo: Repo) => { - setProjectGroupNameDialog({ type: 'create-from-repo', repo }) - }, []) - - const handleMoveProjectToGroup = useCallback( - (repo: Repo, groupId: string) => { - if (repo.projectGroupId === groupId) { - return - } - void moveProjectToGroup(repo.id, groupId) - }, - [moveProjectToGroup] - ) - - const handleRemoveProjectFromGroup = useCallback( - (repo: Repo) => { - void moveProjectToGroup(repo.id, null) - }, - [moveProjectToGroup] - ) - - const handleRenameProjectGroup = useCallback((groupId: string, currentName: string) => { - setProjectGroupNameDialog({ type: 'rename', groupId, currentName }) - }, []) - - const handleSubmitProjectGroupName = useCallback( - async (name: string) => { - if (!projectGroupNameDialog) { - return - } - if (projectGroupNameDialog.type === 'create-from-repo') { - const group = await createProjectGroup(name) - if (group) { - await moveProjectToGroup(projectGroupNameDialog.repo.id, group.id) - } - return - } - await updateProjectGroup(projectGroupNameDialog.groupId, { name }) - }, - [createProjectGroup, moveProjectToGroup, projectGroupNameDialog, updateProjectGroup] - ) - - const projectGroupDeleteTargets = useMemo(() => { - if (!projectGroupDeleteDialog) { - return null - } - return selectProjectGroupRemovalTargets(projectGroups, repos, projectGroupDeleteDialog.groupId) - }, [projectGroupDeleteDialog, projectGroups, repos]) - const projectGroupDeleteProjectCount = projectGroupDeleteTargets?.projectIds.length ?? 0 - const projectGroupDeleteProjectNames = useMemo( - () => - (projectGroupDeleteTargets?.projectIds ?? []).map( - (projectId) => repoMap.get(projectId)?.displayName ?? projectId - ), - [projectGroupDeleteTargets, repoMap] - ) - const projectGroupRemoveContainedProjects = - projectGroupDeleteProjectCount > 0 && projectGroupDeleteDialog?.removeContainedProjects === true - - const handleDeleteProjectGroup = useCallback((groupId: string, groupName: string) => { - setProjectGroupDeleteDialog({ groupId, groupName, removeContainedProjects: false }) - }, []) - - const handleConfirmDeleteProjectGroup = useCallback(async () => { - if (!projectGroupDeleteDialog) { - return - } - try { - const result = await deleteProjectGroupWithContainedProjects( - projectGroupDeleteDialog.groupId, - { - removeContainedProjects: projectGroupRemoveContainedProjects - } - ) - // Why: a missing group is already the desired end state, so only a real delete failure warrants a toast. - if (result.status === 'group-delete-failed') { - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.groupDeleteFailed', - 'Failed to delete group' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeList.groupDeleteFailedDesc', - 'Something went wrong while deleting the group. No projects were removed.' - ) - } - ) - return - } - if (result.status === 'deleted-group' && result.failedProjectRemovals.length > 0) { - const failedCount = result.failedProjectRemovals.length - const requestedCount = result.requestedProjectIds.length - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.b667b59632', - 'Some projects could not be removed from Orca' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeList.f94466bc39', - '{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.', - { - value0: failedCount, - value1: requestedCount, - value2: requestedCount === 1 ? '' : 's' - } - ) - } - ) - } - } finally { - // Why: deleting contained projects can unmount this dialog before its close handler runs, so the parent owns cleanup. - setProjectGroupDeleteDialog(null) - } - }, [ - deleteProjectGroupWithContainedProjects, - projectGroupRemoveContainedProjects, - projectGroupDeleteDialog - ]) - const handleCreateFolderWorkspace = useCallback( (projectGroup: ProjectGroup) => { if (!projectGroup.parentPath) { @@ -6327,472 +220,55 @@ const WorktreeList = React.memo(function WorktreeList({ [openModal] ) - const moveWorktreeToStatus = useCallback( - (worktreeId: string, status: WorkspaceStatus) => { - const current = worktreeMap.get(worktreeId) - if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { - return - } - void updateWorktreeMeta(worktreeId, { workspaceStatus: status }) - }, - [updateWorktreeMeta, worktreeMap, workspaceStatuses] - ) - - const moveWorktreesToStatus = useCallback( - (worktreeIds: readonly string[], status: WorkspaceStatus) => { - const updates = new Map() - for (const worktreeId of worktreeIds) { - const current = worktreeMap.get(worktreeId) - if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { - continue - } - updates.set(worktreeId, { workspaceStatus: status }) - } - if (updates.size > 0) { - void updateWorktreesMeta(updates) - } - }, - [updateWorktreesMeta, worktreeMap, workspaceStatuses] - ) - - const moveWorktreesToStatusAtIndex = useCallback( - (args: { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] - }) => { - const targetGroupKey = getWorkspaceStatusGroupKey(args.status) - const rankByWorktreeId = new Map() - for (const group of args.groups) { - for (const worktreeId of group.worktreeIds) { - const worktree = worktreeMap.get(worktreeId) - if (worktree) { - rankByWorktreeId.set(worktreeId, worktree.manualOrder ?? worktree.sortOrder) - } - } - } - const order = buildManualOrderUpdatesForGroupDrop({ - groups: args.groups, - targetGroupKey, - draggedIds: args.worktreeIds, - dropIndex: args.dropIndex, - now: Date.now(), - rankByWorktreeId - }) - const updates = new Map>() - for (const worktreeId of args.worktreeIds) { - const current = worktreeMap.get(worktreeId) - if (!current) { - continue - } - const next: Partial = {} - if (getWorkspaceStatus(current, workspaceStatuses) !== args.status) { - next.workspaceStatus = args.status - } - updates.set(worktreeId, next) - } - for (const [worktreeId, manualOrder] of order.updates) { - updates.set(worktreeId, { ...updates.get(worktreeId), ...manualOrder }) - } - for (const [worktreeId, update] of Array.from(updates)) { - if (Object.keys(update).length === 0) { - updates.delete(worktreeId) - } - } - if (updates.size === 0) { - return - } - // Why: the insertion line promises exact placement, so persist manual order on a cross-status drop. - if (order.changed) { - setSortBy('manual') - } - void updateWorktreesMeta(updates) - }, - [setSortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] - ) - - const pinWorktree = useCallback( - (worktreeId: string) => { - setWorktreesPinnedAndReveal([worktreeId], true) - }, - [setWorktreesPinnedAndReveal] - ) - - const pinWorktrees = useCallback( - (worktreeIds: readonly string[]) => { - setWorktreesPinnedAndReveal(worktreeIds, true) - }, - [setWorktreesPinnedAndReveal] - ) - - const reorderWorktrees = useCallback( - (args: { - groups: readonly WorktreeDragGroup[] - sourceGroupKey: string - draggedIds: readonly string[] - dropIndex: number - }) => { - const rankByWorktreeId = new Map() - for (const group of args.groups) { - for (const worktreeId of group.worktreeIds) { - const worktree = worktreeMap.get(worktreeId) - if (worktree) { - rankByWorktreeId.set(worktreeId, worktree.manualOrder ?? worktree.sortOrder) - } - } - } - const result = buildManualOrderUpdatesForVisibleGroups({ - ...args, - now: Date.now(), - rankByWorktreeId - }) - if (!result.changed) { - return - } - // Why: only switch to Manual after a real move so accidental click-drags don't change the sort. - setSortBy('manual') - void updateWorktreesMeta(result.updates) - }, - [setSortBy, updateWorktreesMeta, worktreeMap] - ) - - const shouldShowWorkspaceBoardDropIndicator = useCallback( - (worktreeIds: readonly string[], status: WorkspaceStatus) => { - const sourceGroupKeys = worktreeIds.flatMap((worktreeId) => { - const worktree = worktreeMap.get(worktreeId) - return worktree ? [getWorkspaceStatus(worktree, workspaceStatuses)] : [] - }) - return shouldWriteManualOrderForGroupDrop({ - sortBy, - sourceGroupKeys, - targetGroupKey: status - }) - }, - [sortBy, worktreeMap, workspaceStatuses] - ) - - const dropWorktreesOnWorkspaceBoard = useCallback( - (args: { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] - }) => { - const result = buildWorkspaceKanbanSidebarDropUpdates({ - ...args, - worktreeById: worktreeMap, - workspaceStatuses, - sortBy, - now: Date.now() - }) - if (result.updates.size === 0) { - return - } - // Why: switch to Manual when the drop changes order so the placement stays visible. - if (result.shouldSwitchToManual) { - setSortBy('manual') - } - useAppStore.getState().recordFeatureInteraction('workspace-board-actions') - void updateWorktreesMeta(result.updates) - }, - [setSortBy, sortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] - ) - - // Why: count hideDefaultBranchWorkspace as a filter so the Clear Filters escape hatch stays reachable when it alone empties the list. - const filterState = useMemo( - () => ({ - showSleepingWorkspaces, - filterRepoIds, - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - alwaysShowDefaultBranchWorkspace, - visibleWorkspaceHostIds, - workspaceHostScope - }), - [ - showSleepingWorkspaces, - filterRepoIds, - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - alwaysShowDefaultBranchWorkspace, - visibleWorkspaceHostIds, - workspaceHostScope - ] - ) - const hasFilters = sidebarHasActiveFilters(filterState) - const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) - const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) - const setHideAutomationGeneratedWorkspaces = useAppStore( - (s) => s.setHideAutomationGeneratedWorkspaces - ) - const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) - const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) - const setHideWorkspacesFromOtherDevices = useAppStore((s) => s.setHideWorkspacesFromOtherDevices) - const setAlwaysShowDefaultBranchWorkspace = useAppStore( - (s) => s.setAlwaysShowDefaultBranchWorkspace - ) - const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) - const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) - - const clearFilters = useCallback(() => { - const actions = computeClearFilterActions(filterState) - if (actions.resetShowSleepingWorkspaces) { - setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES) - } - if (actions.resetFilterRepoIds) { - setFilterRepoIds([]) - } - if (actions.resetHideDefaultBranchWorkspace) { - setHideDefaultBranchWorkspace(false) - } - if (actions.resetHideAutomationGeneratedWorkspaces) { - setHideAutomationGeneratedWorkspaces(false) - } - if (actions.resetHideCliCreatedWorkspaces) { - setHideCliCreatedWorkspaces(false) - } - if (actions.resetHideDetachedHeadWorkspaces) { - setHideDetachedHeadWorkspaces(false) - } - if (actions.resetHideWorkspacesFromOtherDevices) { - setHideWorkspacesFromOtherDevices(false) - } - if (actions.resetAlwaysShowDefaultBranchWorkspace) { - setAlwaysShowDefaultBranchWorkspace(true) - } - if (actions.resetVisibleWorkspaceHostIds) { - setVisibleWorkspaceHostIds(null) - } - }, [ - setShowSleepingWorkspaces, - setFilterRepoIds, - setHideDefaultBranchWorkspace, - setHideAutomationGeneratedWorkspaces, - setHideCliCreatedWorkspaces, - setHideDetachedHeadWorkspaces, - setHideWorkspacesFromOtherDevices, - setAlwaysShowDefaultBranchWorkspace, - setVisibleWorkspaceHostIds, - filterState - ]) - - useEffect(() => { - if (!pendingRevealSidebarRow) { - return - } - const rowKey = pendingRevealSidebarRow.rowKey - const isProjectHeaderTarget = - rowKey.startsWith('project-group:') || - rowKey.startsWith('project:') || - rowKey.startsWith('repo:') - if (isProjectHeaderTarget && groupBy !== 'repo') { - setGroupBy('repo') - return - } - if (!renderedSidebarRowKeys.has(rowKey) && hasFilters) { - clearFilters() - } - }, [ - clearFilters, + useSidebarRevealRequests({ groupBy, + renderedSidebarRowKeys: rowModel.renderedSidebarRowKeys, + renderedWorktreeIds: selection.renderedWorktreeIds, + currentSidebarWorktreeId, + worktreeMap, + folderWorkspaces, hasFilters, - pendingRevealSidebarRow, - renderedSidebarRowKeys, - setGroupBy - ]) - - const handleRevealCurrentWorkspaceRequest = useCallback( - (event: Event) => { - const detail = - event instanceof CustomEvent - ? (event.detail as ScrollToCurrentWorkspaceRevealRequestDetail | undefined) - : undefined - if (detail?.target?.type === 'sidebar-row') { - const sidebarDetail = detail as Extract< - ScrollToCurrentWorkspaceRevealRequestDetail, - { target: { type: 'sidebar-row' } } - > - revealSidebarRow(detail.target.rowKey, { - behavior: 'smooth', - highlight: sidebarDetail.highlight !== false - }) - return - } - if (!currentSidebarWorktreeId) { - return - } - const activeWorktree = getKnownSidebarWorktreeById( - currentSidebarWorktreeId, - worktreeMap, - folderWorkspaces - ) - if (!activeWorktree || activeWorktree.isArchived) { - return - } - if (!renderedWorktreeIds.includes(currentSidebarWorktreeId)) { - // Why: the reveal action must show the current workspace, so relax filters that hide it first. - clearFilters() - } - revealWorktreeInSidebar(currentSidebarWorktreeId, { - behavior: 'smooth', - highlight: true, - beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true - }) - }, - [ - clearFilters, - currentSidebarWorktreeId, - folderWorkspaces, - revealSidebarRow, - renderedWorktreeIds, - revealWorktreeInSidebar, - worktreeMap - ] - ) - - useEffect(() => { - window.addEventListener( - SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, - handleRevealCurrentWorkspaceRequest - ) - return () => { - window.removeEventListener( - SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, - handleRevealCurrentWorkspaceRequest - ) - } - }, [handleRevealCurrentWorkspaceRequest]) + clearFilters + }) const filtersHideAllRows = hasFilters && - worktrees.length === 0 && - placeholderRepoIds.size === 0 && - importedWorktreesByRepo.size === 0 + visibleWorktrees.length === 0 && + rowModel.placeholderRepoIds.size === 0 && + externalWorktreeCards.importedWorktreesByRepo.size === 0 // Why: when active filters hide every row, the Clear Filters empty state must win over Project Group headers. - if (rows.length === 0 || filtersHideAllRows) { - return ( -
-
-
- - {translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')} - - {hasFilters && ( - - )} -
-
-
- ) + if (rowModel.rows.length === 0 || filtersHideAllRows) { + return } return ( <> - { - if (!open) { - setProjectGroupNameDialog(null) - } + onConfirmSuppressExternalWorktreeInbox={() => { + void externalWorktreeCards.handleConfirmSuppressExternalWorktreeInbox() }} - onSubmit={handleSubmitProjectGroupName} - /> - repo.id === suppressExternalWorktreeInboxRepoId)?.displayName ?? - '') - : '' - } - pending={ - suppressExternalWorktreeInboxRepoId - ? (newExternalWorktreeInboxActionState.get(suppressExternalWorktreeInboxRepoId) - ?.pending ?? false) - : false - } - onOpenChange={(open) => { - if (!open) { - setSuppressExternalWorktreeInboxRepoId(null) - } - }} - onConfirm={() => { - void handleConfirmSuppressExternalWorktreeInbox() - }} - onOpenRecovery={() => { - if (!suppressExternalWorktreeInboxRepoId) { - return - } - const repo = findRepoForHost(repos, suppressExternalWorktreeInboxRepoId, { settings }) - setSuppressExternalWorktreeInboxRepoId(null) - if (repo) { - handleOpenWorktreeVisibility(repo) - } - }} - /> - { - setProjectGroupDeleteDialog((current) => - current ? { ...current, removeContainedProjects } : current - ) - }} - onOpenChange={(open) => { - if (!open) { - setProjectGroupDeleteDialog(null) - } - }} - onConfirm={handleConfirmDeleteProjectGroup} + onOpenWorktreeVisibility={handleOpenWorktreeVisibility} /> active); key only on grouping mode so row identity survives. + key={`group:${groupBy}:host:${filterState.visibleWorkspaceHostIds?.join(',') ?? 'all'}:lineage`} + rows={rowModel.sectionRows} + // Why: full-page nav views aren't scoped to a worktree, so no sidebar card should look selected. + activeWorktreeId={ + activeView === 'tasks' || activeView === 'activity' ? null : currentSidebarWorktreeId + } currentWorktreeId={currentSidebarWorktreeId} groupBy={groupBy} pinnedDisplayPolicy={pinnedDisplayPolicy} @@ -6802,17 +278,21 @@ const WorktreeList = React.memo(function WorktreeList({ handleCreateForRepo={handleCreateForRepo} handleOpenRepoSettings={handleOpenRepoSettings} handleOpenWorktreeVisibility={handleOpenWorktreeVisibility} - handleShowImportedWorktrees={handleShowImportedWorktrees} - handleKeepImportedWorktreesHidden={handleKeepImportedWorktreesHidden} - importedWorktreeCardActionState={importedWorktreeCardActionState} - handleOpenSuppressExternalWorktreeInbox={handleOpenSuppressExternalWorktreeInbox} - newExternalWorktreeInboxActionState={newExternalWorktreeInboxActionState} + handleShowImportedWorktrees={externalWorktreeCards.handleShowImportedWorktrees} + handleKeepImportedWorktreesHidden={externalWorktreeCards.handleKeepImportedWorktreesHidden} + importedWorktreeCardActionState={externalWorktreeCards.importedWorktreeCardActionState} + handleOpenSuppressExternalWorktreeInbox={ + externalWorktreeCards.handleOpenSuppressExternalWorktreeInbox + } + newExternalWorktreeInboxActionState={ + externalWorktreeCards.newExternalWorktreeInboxActionState + } handleRemoveProject={handleRemoveProject} - handleCreateGroupFromRepo={handleCreateGroupFromRepo} - handleMoveProjectToGroup={handleMoveProjectToGroup} - handleRemoveProjectFromGroup={handleRemoveProjectFromGroup} - handleRenameProjectGroup={handleRenameProjectGroup} - handleDeleteProjectGroup={handleDeleteProjectGroup} + handleCreateGroupFromRepo={projectGroupDialogs.handleCreateGroupFromRepo} + handleMoveProjectToGroup={projectGroupDialogs.handleMoveProjectToGroup} + handleRemoveProjectFromGroup={projectGroupDialogs.handleRemoveProjectFromGroup} + handleRenameProjectGroup={projectGroupDialogs.handleRenameProjectGroup} + handleDeleteProjectGroup={projectGroupDialogs.handleDeleteProjectGroup} handleCreateFolderWorkspace={handleCreateFolderWorkspace} activeModal={activeModal} pendingRevealWorktree={pendingRevealWorktree} @@ -6820,38 +300,40 @@ const WorktreeList = React.memo(function WorktreeList({ clearPendingRevealWorktreeId={clearPendingRevealWorktreeId} clearPendingRevealSidebarRow={clearPendingRevealSidebarRow} agentSendTargetWorktreeId={agentSendTargetWorktreeId} - worktrees={worktrees} + worktrees={visibleWorktrees} folderWorkspaces={folderWorkspaces} - selectedWorktreeIds={selectedWorktreeIds} - selectedWorktrees={selectedWorktrees} - onSelectionGesture={updateSelectionForGesture} + selectedWorktreeIds={selection.selectedWorktreeIds} + selectedWorktrees={selection.selectedWorktrees} + onSelectionGesture={selection.updateSelectionForGesture} onImmediateWorktreeActivate={handleImmediateWorktreeActivate} - onContextMenuSelect={selectForContextMenu} + onContextMenuSelect={selection.selectForContextMenu} repoMap={repoMap} defaultHostId={defaultHostId} worktreeMap={worktreeMap} worktreeLineageById={worktreeLineageById} workspaceLineageByChildKey={workspaceLineageByChildKey} - allRepoIds={allRepoIds} - onReorderHostSections={handleReorderHostSections} - onHostDragActiveChange={setHostDragActive} + allRepoIds={rowModel.allRepoIds} + onReorderHostSections={rowModel.handleReorderHostSections} + onHostDragActiveChange={rowModel.setHostDragActive} prCache={prCache} hostedReviewCache={hostedReviewCache} workspaceStatuses={workspaceStatuses} projectGrouping={projectGrouping} projectGroups={projectGroups} - onMoveWorktreeToStatus={moveWorktreeToStatus} - onMoveWorktreesToStatus={moveWorktreesToStatus} - onMoveWorktreesToStatusAtIndex={moveWorktreesToStatusAtIndex} - onPinWorktree={pinWorktree} - onPinWorktrees={pinWorktrees} - onDropWorktreesOnWorkspaceBoard={dropWorktreesOnWorkspaceBoard} + onMoveWorktreeToStatus={statusMutations.moveWorktreeToStatus} + onMoveWorktreesToStatus={statusMutations.moveWorktreesToStatus} + onMoveWorktreesToStatusAtIndex={statusMutations.moveWorktreesToStatusAtIndex} + onPinWorktree={statusMutations.pinWorktree} + onPinWorktrees={statusMutations.pinWorktrees} + onDropWorktreesOnWorkspaceBoard={statusMutations.dropWorktreesOnWorkspaceBoard} workspaceBoardOpen={workspaceBoardOpen} onWorkspaceBoardDragPreviewStart={onWorkspaceBoardDragPreviewStart} onWorkspaceBoardDragPreviewCommit={onWorkspaceBoardDragPreviewCommit} onWorkspaceBoardDragPreviewCancel={onWorkspaceBoardDragPreviewCancel} - shouldShowWorkspaceBoardDropIndicator={shouldShowWorkspaceBoardDropIndicator} - onReorderWorktrees={reorderWorktrees} + shouldShowWorkspaceBoardDropIndicator={ + statusMutations.shouldShowWorkspaceBoardDropIndicator + } + onReorderWorktrees={statusMutations.reorderWorktrees} scrollOffsetRef={scrollOffsetRef} scrollAnchorRef={scrollAnchorRef} /> diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts index 5f7d294ec61..58da2de4511 100644 --- a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -3,7 +3,17 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' function readWorktreeListSource(): string { - return readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8') + return readFileSync( + fileURLToPath(new URL('./worktree-list/worktree-section-header-row.tsx', import.meta.url)), + 'utf8' + ) +} + +function readHeaderDragSource(): string { + return readFileSync( + fileURLToPath(new URL('./worktree-list/use-worktree-sidebar-header-drag.ts', import.meta.url)), + 'utf8' + ) } describe('Project Group header drag DOM source', () => { @@ -17,7 +27,7 @@ describe('Project Group header drag DOM source', () => { }) it('commits Project Group manual sorting through updateProjectGroup tabOrder', () => { - const source = readWorktreeListSource() + const source = readHeaderDragSource() expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)') expect(source).toContain('void updateProjectGroup(groupId, { tabOrder })') diff --git a/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts b/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts index 2b8bc7d68cd..edc4f41f47e 100644 --- a/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts +++ b/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts @@ -132,7 +132,9 @@ describe('getCyclableWorktreeIds', () => { describe('WorktreeList keyboard cycling', () => { it('cycles over the rendered rows instead of rebuilding a parallel layout', () => { const source = readFileSync( - fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), + fileURLToPath( + new URL('./worktree-list/use-worktree-list-keyboard-navigation.ts', import.meta.url) + ), 'utf8' ) const navigateWorktree = source.slice( diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index dacfcb3a139..7e6fe4b3b58 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -63,6 +63,13 @@ function readWorktreeListSource(): string { return readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8') } +function readSectionHeaderRowSource(): string { + return readFileSync( + fileURLToPath(new URL('./worktree-list/worktree-section-header-row.tsx', import.meta.url)), + 'utf8' + ) +} + const remoteRepo: Repo = { id: 'repo-remote', path: '/home/alice/orca', @@ -3985,7 +3992,7 @@ describe('buildRows workspace lineage nesting', () => { describe('WorktreeList header styles', () => { it('does not title-case workspace group labels', () => { - const source = readWorktreeListSource() + const source = readSectionHeaderRowSource() expect(source).not.toContain('leading-none capitalize') }) @@ -4005,7 +4012,7 @@ describe('WorktreeList header styles', () => { }) it('resolves repo header color from project group headers only', () => { - const source = readWorktreeListSource() + const source = readSectionHeaderRowSource() expect(source).toContain('resolveProjectGroupHeaderColor({') expect(source).toContain('headerKey: row.key') diff --git a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts index 9cda62a93b4..552515ff936 100644 --- a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' -import { - canKeepImportedWorktreesHidden, - getRenderRowKey, - getWorktreeDragGroups, - getWorktreeDragIndexes, - renderRowContainsWorktree -} from './WorktreeList' +import { canKeepImportedWorktreesHidden } from './worktree-list/imported-worktrees-card-visibility' +import { getRenderRowKey } from './worktree-list-virtual-rows' +import { getWorktreeDragGroups, getWorktreeDragIndexes } from './worktree-list/worktree-drag-groups' +import { renderRowContainsWorktree } from './worktree-list/render-row-worktree-lookup' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { Row } from './worktree-list-groups' diff --git a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts index f5b9d586f63..dd6f7c0689a 100644 --- a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { countRecordKeysByReference, - getScrollTopToRevealBounds, resolvePendingSidebarReveal, - WORKTREE_SIDEBAR_REVEAL_TOP_INSET, shouldAdjustWorktreeSidebarMeasuredRowScroll -} from './WorktreeList' +} from './worktree-list/worktree-sidebar-scroll-tuning' +import { + getScrollTopToRevealBounds, + WORKTREE_SIDEBAR_REVEAL_TOP_INSET +} from './worktree-sidebar-reveal' import { extractWorktreeVirtualRowIndexes, estimateRenderRowSize, diff --git a/src/renderer/src/components/sidebar/worktree-list-visible-refresh.test.ts b/src/renderer/src/components/sidebar/worktree-list-visible-refresh.test.ts index 1e519ac6d36..b99c0957d52 100644 --- a/src/renderer/src/components/sidebar/worktree-list-visible-refresh.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-visible-refresh.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { installWorktreeVisibleRefreshVisibilityListener } from './WorktreeList' +import { installWorktreeVisibleRefreshVisibilityListener } from './worktree-list/worktree-visible-refresh-listener' describe('installWorktreeVisibleRefreshVisibilityListener', () => { beforeEach(() => { diff --git a/src/renderer/src/components/sidebar/worktree-list/FolderPathStatusIndicator.tsx b/src/renderer/src/components/sidebar/worktree-list/FolderPathStatusIndicator.tsx new file mode 100644 index 00000000000..9b699c1086c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/FolderPathStatusIndicator.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { FolderX } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { + getFolderWorkspacePathStatusDescription, + getFolderWorkspacePathStatusTitle +} from '@/lib/folder-workspace-path-status' +import { + isConfirmedStaleFolderPathStatus, + type FolderWorkspacePathStatus +} from '../../../../../shared/folder-workspace-path-status' + +export function FolderPathStatusIndicator({ + status +}: { + status: FolderWorkspacePathStatus | null | undefined +}): React.JSX.Element | null { + const title = getFolderWorkspacePathStatusTitle(status) + if (!status || status.exists || !title) { + return null + } + const destructive = isConfirmedStaleFolderPathStatus(status) + return ( + + + + + + + +
+
{title}
+
+ {getFolderWorkspacePathStatusDescription(status)} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/HostSectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/HostSectionHeader.tsx new file mode 100644 index 00000000000..898719956f2 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/HostSectionHeader.tsx @@ -0,0 +1,161 @@ +import React from 'react' +import { AlertTriangle, ChevronDown, Loader2, Server, ServerOff } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { HostHeaderRow } from '../host-section-rows' +import { HostSectionHeaderMenu } from '../HostSectionHeaderMenu' + +function formatSectionActivityLabel(count: number, label: string): string { + return `${count} ${label}${count === 1 ? '' : 's'}` +} + +function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element { + const totalLabel = formatSectionActivityLabel(count, 'workspace') + + return ( + + + + + {count} + + + + {totalLabel} + + + + ) +} + +function HostHeaderHealthIcon({ + health +}: { + health: HostHeaderRow['health'] +}): React.JSX.Element | null { + // Why: only surface states needing attention; healthy is the silent default. + if (health === 'connecting') { + return + } + if (health === 'blocked' || health === 'error') { + return + } + return null +} + +function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } | null { + // Why: a blocked compatibility verdict earns a compact warning so the host stands out. + if (row.health === 'blocked') { + return { + text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'), + isWarning: true + } + } + // Why: auth-failed needs a worded status; the health icon alone doesn't tell the user to re-auth. + if (row.connectionStatus === 'auth-failed') { + return { + text: translate( + 'auto.components.sidebar.WorktreeList.hostAuthNeeded', + 'Authentication needed' + ), + isWarning: true + } + } + if (row.health === 'disconnected') { + return { + text: translate('auto.components.sidebar.WorktreeList.hostDisconnected', 'Disconnected'), + isWarning: false + } + } + // Why: show the transport detail only for remote hosts; it's noise under the local label. + if (row.kind !== 'local') { + return { text: row.detail, isWarning: false } + } + return null +} + +export function HostSectionHeader({ + row, + onToggle, + onDragPointerDown, + dragging +}: { + row: HostHeaderRow + onToggle: () => void + onDragPointerDown?: (event: React.PointerEvent) => void + dragging?: boolean +}): React.JSX.Element { + const isBlocked = row.health === 'blocked' + const isDisconnected = row.health === 'disconnected' + const detail = getHostHeaderDetail(row) + return ( +
+ {/* Why: outlined card + server glyph marks hosts as machines, not mere groups. */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + }} + > + {isDisconnected ? ( + + ) : ( + + )} + + {/* Why: badge hugs the label (like repo headers) instead of floating by the hover controls. */} +
+ + {row.label} + + {detail ? ( + + {detail.text} + + ) : null} + +
+
+ +
+ + + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx b/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx new file mode 100644 index 00000000000..cd46a80c3c7 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import { findRepoForHost } from '@/store/slices/repo-host-identity' +import type { AppState } from '@/store/types' +import type { Repo } from '../../../../../shared/repo-types' +import { ProjectGroupNameDialog } from '../ProjectGroupNameDialog' +import { ProjectGroupDeleteDialog } from '../ProjectGroupDeleteDialog' +import SuppressExternalWorktreeInboxDialog from '../SuppressExternalWorktreeInboxDialog' +import type { NewExternalWorktreesInboxActionState } from '../new-external-worktrees-inbox-actions' +import type { ProjectGroupDialogs } from './use-project-group-dialogs' + +export function SidebarWorktreeListDialogs({ + dialogs, + repos, + settings, + suppressExternalWorktreeInboxRepoId, + setSuppressExternalWorktreeInboxRepoId, + newExternalWorktreeInboxActionState, + onConfirmSuppressExternalWorktreeInbox, + onOpenWorktreeVisibility +}: { + dialogs: ProjectGroupDialogs + repos: readonly Repo[] + settings: AppState['settings'] + suppressExternalWorktreeInboxRepoId: string | null + setSuppressExternalWorktreeInboxRepoId: (repoId: string | null) => void + newExternalWorktreeInboxActionState: ReadonlyMap + onConfirmSuppressExternalWorktreeInbox: () => void + onOpenWorktreeVisibility: (repo: Repo) => void +}): React.JSX.Element { + const { nameDialog, setNameDialog, deleteDialog, setDeleteDialog } = dialogs + return ( + <> + { + if (!open) { + setNameDialog(null) + } + }} + onSubmit={dialogs.handleSubmitProjectGroupName} + /> + repo.id === suppressExternalWorktreeInboxRepoId)?.displayName ?? + '') + : '' + } + pending={ + suppressExternalWorktreeInboxRepoId + ? (newExternalWorktreeInboxActionState.get(suppressExternalWorktreeInboxRepoId) + ?.pending ?? false) + : false + } + onOpenChange={(open) => { + if (!open) { + setSuppressExternalWorktreeInboxRepoId(null) + } + }} + onConfirm={onConfirmSuppressExternalWorktreeInbox} + onOpenRecovery={() => { + if (!suppressExternalWorktreeInboxRepoId) { + return + } + const repo = findRepoForHost(repos, suppressExternalWorktreeInboxRepoId, { settings }) + setSuppressExternalWorktreeInboxRepoId(null) + if (repo) { + onOpenWorktreeVisibility(repo) + } + }} + /> + { + setDeleteDialog((current) => + current ? { ...current, removeContainedProjects } : current + ) + }} + onOpenChange={(open) => { + if (!open) { + setDeleteDialog(null) + } + }} + onConfirm={dialogs.handleConfirmDeleteProjectGroup} + /> + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx b/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx new file mode 100644 index 00000000000..05f98037c60 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { CircleX } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +export function SidebarWorktreeListEmptyState({ + hasFilters, + onClearFilters +}: { + hasFilters: boolean + onClearFilters: () => void +}): React.JSX.Element { + return ( +
+
+
+ + {translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')} + + {hasFilters && ( + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/VirtualizedWorktreeViewport.tsx b/src/renderer/src/components/sidebar/worktree-list/VirtualizedWorktreeViewport.tsx new file mode 100644 index 00000000000..94455e16037 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/VirtualizedWorktreeViewport.tsx @@ -0,0 +1,411 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { WorktreeSidebarDropIndicator } from '../WorktreeSidebarDropIndicator' +import { WorktreeListScrollToTopButton } from '../WorktreeListScrollToTopButton' +import { useWorktreeListScrollToTop } from '../use-worktree-list-scroll-to-top' +import { getActiveDescendantOptionId } from './active-descendant-option' +import { buildRenderableRows } from './renderable-rows' +import { useFolderWorkspacePathStatusRows } from './use-folder-workspace-path-status-rows' +import { useGroupToggleWithScrollAnchor } from './use-group-toggle-with-scroll-anchor' +import { usePendingSidebarReveal } from './use-pending-sidebar-reveal' +import { usePrimaryActiveWorktreeRow } from './use-primary-active-worktree-row' +import { useSidebarRevealHighlight } from './use-sidebar-reveal-highlight' +import { useVirtualRowMeasurementSync } from './use-virtual-row-measurement-sync' +import { useVisiblePrRefreshReporting } from './use-visible-pr-refresh-reporting' +import { useWorkspaceStatusRowDrag } from './use-workspace-status-row-drag' +import { useWorktreeDragRuntime } from './use-worktree-drag-runtime' +import { useWorktreeDragSession } from './use-worktree-drag-session' +import { useWorktreeDocumentDrop } from './use-worktree-document-drop' +import { useWorktreeLineageDropCommit } from './use-worktree-lineage-drop-commit' +import { useWorktreeListKeyboardNavigation } from './use-worktree-list-keyboard-navigation' +import { useWorktreeListVirtualizer } from './use-worktree-list-virtualizer' +import { useWorktreeNativeDrag } from './use-worktree-native-drag' +import { useWorktreePointerDrag } from './use-worktree-pointer-drag' +import { useWorktreeSidebarHeaderDrag } from './use-worktree-sidebar-header-drag' +import { useWorktreeSidebarScrollSuppression } from './use-worktree-sidebar-scroll-suppression' +import { + EMPTY_PROJECT_GROUPS, + type VirtualizedWorktreeViewportProps +} from './virtualized-worktree-viewport-props' +import { WORKTREE_SIDEBAR_SCROLL_STYLE } from './worktree-sidebar-scroll-tuning' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import { buildWorktreeVirtualRowContext } from './worktree-virtual-row-context' +import { renderWorktreeVirtualRow } from './worktree-virtual-row-dispatch' + +export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport( + props: VirtualizedWorktreeViewportProps +) { + const { + rows, + groupBy, + pinnedDisplayPolicy, + activeWorktreeId, + collapsedGroups, + toggleGroup, + repoMap, + worktreeMap, + worktreeLineageById, + workspaceStatuses, + projectGroups = EMPTY_PROJECT_GROUPS, + scrollOffsetRef, + scrollAnchorRef + } = props + const scrollRef = useRef(null) + // Why: callback-ref only mutates scrollRef; state re-runs the scroll-to-top listener attach. + const [scrollElement, setScrollElement] = useState(null) + const settings = useAppStore((s) => s.settings) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true + + const reveal = useSidebarRevealHighlight() + const scrollSuppression = useWorktreeSidebarScrollSuppression(scrollRef) + const { markDirectScrollInput, markScrollMovement } = scrollSuppression + + const renderRows = useMemo(() => buildRenderableRows(rows), [rows]) + const firstHeaderIndex = useMemo( + () => renderRows.findIndex((row) => row.type === 'header' || row.type === 'host-header'), + [renderRows] + ) + const folderBackedProjectGroupIds = useMemo( + () => + new Set( + projectGroups + .filter((group) => group.createdFrom === 'folder-scan') + .map((group) => group.id) + ), + [projectGroups] + ) + + const headerDrag = useWorktreeSidebarHeaderDrag({ + rows, + renderRows, + firstHeaderIndex, + allRepoIds: props.allRepoIds, + repoMap, + projectGroups, + groupBy, + projectOrderBy: props.projectOrderBy, + scrollRef, + onReorderHostSections: props.onReorderHostSections, + onHostDragActiveChange: props.onHostDragActiveChange, + suppressMeasurementAdjustmentUntilRef: scrollSuppression.suppressMeasurementAdjustmentUntilRef, + directScrollInputUntilRef: scrollSuppression.directScrollInputUntilRef + }) + + const session = useWorktreeDragSession({ rows, scrollRef }) + const lineageDrop = useWorktreeLineageDropCommit({ + repoMap, + worktreeMap, + worktreeLineageById, + worktreeDragGroups: session.worktreeDragGroups + }) + const runtime = useWorktreeDragRuntime({ + worktreeDragSessionRef: session.worktreeDragSessionRef, + statusDropAnchorsRef: session.statusDropAnchorsRef, + onWorkspaceBoardDragPreviewCancel: props.onWorkspaceBoardDragPreviewCancel + }) + + const primaryActive = usePrimaryActiveWorktreeRow({ + rows, + activeWorktreeId, + pinnedDisplayPolicy, + onImmediateWorktreeActivate: props.onImmediateWorktreeActivate + }) + + const getCachedFolderWorkspacePathStatus = useFolderWorkspacePathStatusRows({ + allRepoIds: props.allRepoIds, + repoMap, + projectGroups, + folderWorkspaces: props.folderWorkspaces, + sshConnectionStates + }) + + const { showScrollToTop, scrollToTop } = useWorktreeListScrollToTop({ + scrollElement, + onUserScrollIntent: markDirectScrollInput + }) + + const virtualization = useWorktreeListVirtualizer({ + renderRows, + firstHeaderIndex, + scrollRef, + scrollOffsetRef, + suppressMeasurementAdjustmentUntilRef: scrollSuppression.suppressMeasurementAdjustmentUntilRef + }) + + usePendingSidebarReveal({ + pendingRevealWorktree: props.pendingRevealWorktree, + pendingRevealSidebarRow: props.pendingRevealSidebarRow, + clearPendingRevealWorktreeId: props.clearPendingRevealWorktreeId, + clearPendingRevealSidebarRow: props.clearPendingRevealSidebarRow, + agentSendTargetWorktreeId: props.agentSendTargetWorktreeId, + renderRows, + virtualizer: virtualization.virtualizer, + scrollRef, + worktrees: props.worktrees, + folderWorkspaces: props.folderWorkspaces, + repoMap, + worktreeMap, + worktreeLineageById, + collapsedGroups, + toggleGroup, + groupBy, + pinnedDisplayPolicy, + defaultHostId: props.defaultHostId, + prCache: props.prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping: props.projectGrouping, + flashRevealedRow: reveal.flashRevealedRow, + markRevealScroll: scrollSuppression.markRevealScroll, + schedulePendingRevealFrame: reveal.schedulePendingRevealFrame, + cancelPendingRevealFrames: reveal.cancelPendingRevealFrames + }) + + const { virtualItems, measureVirtualRowElement } = useVirtualRowMeasurementSync({ + renderRows, + virtualization, + scrollRef, + scrollOffsetRef, + scrollAnchorRef, + hasDirectScrollInput: scrollSuppression.hasDirectScrollInput, + shouldSkipScrollAnchorRestore: scrollSuppression.shouldSkipScrollAnchorRestore + }) + + const { toggleGroupWithScrollAnchor, getLineageToggleHandler } = useGroupToggleWithScrollAnchor({ + scrollRef, + toggleGroup + }) + + const { handleContainerKeyDown } = useWorktreeListKeyboardNavigation({ + rows, + renderRows, + activeWorktreeId, + pinnedDisplayPolicy, + virtualizer: virtualization.virtualizer, + scrollRef, + activeModal: props.activeModal, + markDirectScrollInput + }) + + const dropCtx = useMemo( + () => ({ + scrollRef, + workspaceStatuses, + worktreeDragGroups: session.worktreeDragGroups, + worktreeDragUnitGroups: session.worktreeDragUnitGroups, + computeWorktreeDrop: session.computeWorktreeDrop, + computeWorktreeStatusDrop: session.computeWorktreeStatusDrop, + refreshWorktreeDragSession: session.refreshWorktreeDragSession, + getEligibleLineageDropTarget: lineageDrop.getEligibleLineageDropTarget, + commitWorktreeLineageParentDrop: lineageDrop.commitWorktreeLineageParentDrop, + clearReorderedWorktreeParents: lineageDrop.clearReorderedWorktreeParents, + clearWorktreeDrag: runtime.clearWorktreeDrag, + onMoveWorktreesToStatus: props.onMoveWorktreesToStatus, + onMoveWorktreesToStatusAtIndex: props.onMoveWorktreesToStatusAtIndex, + onReorderWorktrees: props.onReorderWorktrees, + onPinWorktrees: props.onPinWorktrees + }), + [ + lineageDrop.clearReorderedWorktreeParents, + lineageDrop.commitWorktreeLineageParentDrop, + lineageDrop.getEligibleLineageDropTarget, + props.onMoveWorktreesToStatus, + props.onMoveWorktreesToStatusAtIndex, + props.onPinWorktrees, + props.onReorderWorktrees, + runtime.clearWorktreeDrag, + session.computeWorktreeDrop, + session.computeWorktreeStatusDrop, + session.refreshWorktreeDragSession, + session.worktreeDragGroups, + session.worktreeDragUnitGroups, + workspaceStatuses + ] + ) + + const { handleWorktreeRowPointerDown, handleWorktreeRowClickCapture } = useWorktreePointerDrag({ + ctx: dropCtx, + session, + runtime, + scrollRef, + markScrollMovement, + selectedWorktreeIds: props.selectedWorktreeIds, + selectedWorktrees: props.selectedWorktrees, + workspaceBoardOpen: props.workspaceBoardOpen, + onWorkspaceBoardDragPreviewStart: props.onWorkspaceBoardDragPreviewStart, + onWorkspaceBoardDragPreviewCommit: props.onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard: props.onDropWorktreesOnWorkspaceBoard, + shouldShowWorkspaceBoardDropIndicator: props.shouldShowWorkspaceBoardDropIndicator + }) + const nativeDrag = useWorktreeNativeDrag({ + ctx: dropCtx, + session, + runtime, + scrollRef, + markScrollMovement + }) + useWorktreeDocumentDrop({ + ctx: dropCtx, + worktreeDragSessionRef: session.worktreeDragSessionRef + }) + const statusDrag = useWorkspaceStatusRowDrag({ + ctx: dropCtx, + session, + runtime, + scrollRef, + rows, + groupBy, + onMoveWorktreeToStatus: props.onMoveWorktreeToStatus, + onPinWorktree: props.onPinWorktree + }) + + useVisiblePrRefreshReporting({ + currentWorktreeId: props.currentWorktreeId, + worktreeMap, + groupBy, + newCardStyle, + renderRows, + virtualItems, + scrollRef + }) + + // Why: a callback ref that changes identity is re-invoked with null on every render, which + // would run the teardown below mid-drag; depend only on the stable teardown callbacks. + const { cancelPendingRevealFrames, clearRevealHighlightFrame, clearRevealHighlightTimeout } = + reveal + const { clearWorktreeDrag } = runtime + const setScrollRootRef = useCallback( + (node: HTMLDivElement | null) => { + if (node === null && scrollRef.current !== null) { + // Why: drag previews, autoscroll frames, and reveal snapshots are tied to the scroll root; clear them before it unmounts. + cancelPendingRevealFrames() + clearRevealHighlightFrame() + clearRevealHighlightTimeout() + clearWorktreeDrag() + } + scrollRef.current = node + setScrollElement(node) + }, + [ + cancelPendingRevealFrames, + clearRevealHighlightFrame, + clearRevealHighlightTimeout, + clearWorktreeDrag + ] + ) + const handleScrollPointerDown = useCallback( + (event: React.PointerEvent) => { + const scrollbarWidth = event.currentTarget.offsetWidth - event.currentTarget.clientWidth + if (scrollbarWidth <= 0) { + return + } + const rect = event.currentTarget.getBoundingClientRect() + if (event.clientX >= rect.right - scrollbarWidth) { + markDirectScrollInput() + } + }, + [markDirectScrollInput] + ) + const handleScroll = useCallback(() => { + markScrollMovement() + }, [markScrollMovement]) + + const rowContext = buildWorktreeVirtualRowContext({ + props, + renderRows, + firstHeaderIndex, + virtualization, + measureVirtualRowElement, + settings, + sshConnectionStates, + newCardStyle, + folderBackedProjectGroupIds, + projectGroups, + session, + runtime, + primaryActive, + reveal, + statusDrag, + nativeDrag, + headerDrag, + getCachedFolderWorkspacePathStatus, + getLineageToggleHandler, + toggleGroupWithScrollAnchor, + onRowClickCapture: handleWorktreeRowClickCapture, + onRowPointerDown: handleWorktreeRowPointerDown + }) + + return ( +
+
+
+ {headerDrag.canReorderRepoHeaders && + headerDrag.repoDrag.state.draggingRepoId !== null && + headerDrag.repoDrag.state.dropIndicatorY !== null ? ( + + ) : null} + {headerDrag.canReorderProjectGroupHeaders && + headerDrag.projectGroupDrag.state.draggingGroupId !== null && + headerDrag.projectGroupDrag.state.dropIndicatorY !== null ? ( + + ) : null} + {headerDrag.hostDrag.state.draggingHostId !== null && + headerDrag.hostDrag.state.dropIndicatorY !== null ? ( + + ) : null} + {runtime.worktreeDragState.draggingWorktreeId !== null && + runtime.worktreeDragState.dropIndicatorY !== null ? ( + + ) : null} + {virtualItems.map((vItem) => { + const row = renderRows[vItem.index] + return row ? renderWorktreeVirtualRow(rowContext, row, vItem) : null + })} +
+
+ {showScrollToTop ? : null} +
+ ) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/active-descendant-option.ts b/src/renderer/src/components/sidebar/worktree-list/active-descendant-option.ts new file mode 100644 index 00000000000..b52614cfe4c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/active-descendant-option.ts @@ -0,0 +1,67 @@ +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import type { RenderRow } from '../worktree-list-virtual-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import { isPinnedWorktreeRow } from './render-row-item-rows' +import { getRenderRowWorktreeItem, renderRowContainsWorktree } from './render-row-worktree-lookup' +import { getWorktreeOptionId } from './worktree-option-dom' + +export function getRenderRowOptionId( + row: RenderRow | undefined, + worktreeId?: string | null +): string | undefined { + if (!row) { + return undefined + } + if (row.type === 'lineage-group') { + const targetRow = worktreeId ? row.rows.find((item) => item.worktree.id === worktreeId) : null + return getWorktreeOptionId((targetRow ?? row.rows[0])?.rowKey ?? row.key) + } + if (row.type === 'item') { + return getWorktreeOptionId(row.rowKey) + } + if (row.type === 'folder-workspace') { + return getWorktreeOptionId(folderWorkspaceKey(row.folderWorkspace.id)) + } + return undefined +} + +export function getActiveDescendantOptionId(args: { + activeWorktreeId: string | null + primaryActiveRowKey?: string + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + renderRows: readonly RenderRow[] + virtualItems: readonly { index: number }[] +}): string | undefined { + if (args.activeWorktreeId === null) { + return undefined + } + if (args.primaryActiveRowKey) { + const primaryOptionId = getWorktreeOptionId(args.primaryActiveRowKey) + for (const item of args.virtualItems) { + const row = args.renderRows[item.index] + if (row && getRenderRowOptionId(row, args.activeWorktreeId) === primaryOptionId) { + return primaryOptionId + } + } + } + let fallbackOptionId: string | undefined + for (const item of args.virtualItems) { + const row = args.renderRows[item.index] + if (row && renderRowContainsWorktree(row, args.activeWorktreeId)) { + const optionId = getRenderRowOptionId(row, args.activeWorktreeId) + if (!optionId) { + continue + } + const itemRow = getRenderRowWorktreeItem(row, args.activeWorktreeId) + if ( + args.pinnedDisplayPolicy === 'duplicate-in-groups' && + itemRow && + !isPinnedWorktreeRow(itemRow) + ) { + return optionId + } + fallbackOptionId ??= optionId + } + } + return fallbackOptionId +} diff --git a/src/renderer/src/components/sidebar/worktree-list/folder-workspace-virtual-row.tsx b/src/renderer/src/components/sidebar/worktree-list/folder-workspace-virtual-row.tsx new file mode 100644 index 00000000000..eb3dc2f3556 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/folder-workspace-virtual-row.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import type { AppState } from '@/store/types' +import type { Repo } from '../../../../../shared/repo-types' +import type { + WorkspaceLineage, + WorktreeLineage +} from '../../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { FolderWorkspacePathStatus } from '../../../../../shared/folder-workspace-path-status' +import { isConfirmedStaleFolderPathStatus } from '../../../../../shared/folder-workspace-path-status' +import { folderWorkspaceToWorktree } from '../../../../../shared/folder-workspace-worktree' +import WorktreeCard from '../WorktreeCard' +import type { WorktreeGroupBy } from '../worktree-list-groups' +import { getVirtualRowTransform } from '../worktree-list-virtual-rows' +import { getFolderWorkspaceRowGeometry } from '../worktree-list-indentation' +import { getFolderWorkspaceCardPrDisplay } from '../folder-workspace-card-pr-display' +import { FolderPathStatusIndicator } from './FolderPathStatusIndicator' +import type { FolderWorkspaceItemRow } from './render-row-item-rows' +import { getWorktreeOptionId } from './worktree-option-dom' + +export type FolderWorkspaceRowContext = { + groupBy: WorktreeGroupBy + newCardStyle: boolean + settings: AppState['settings'] + activeWorktreeId: string | null + currentWorktreeId: string | null + selectedWorktreeIds: ReadonlySet + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + workspaceLineageByChildKey: Record + prCache: AppState['prCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null + getCachedFolderWorkspacePathStatus: (request: { + scope: 'folder-workspace' + folderWorkspaceId: string + }) => FolderWorkspacePathStatus | null + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + onImmediateActivate: (worktreeId: string, rowKey: string | undefined) => void + onRowClickCapture: (event: React.MouseEvent) => void + onRowPointerDown: ( + event: React.PointerEvent, + worktreeId: string, + rowKey: string + ) => void +} + +export function renderFolderWorkspaceVirtualRow(args: { + ctx: FolderWorkspaceRowContext + row: FolderWorkspaceItemRow + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void +}): React.JSX.Element { + const { ctx, row, vItem } = args + const folderWorktree = folderWorkspaceToWorktree(row.folderWorkspace) + const pathStatus = ctx.getCachedFolderWorkspacePathStatus({ + scope: 'folder-workspace', + folderWorkspaceId: row.folderWorkspace.id + }) + const activationDisabled = + pathStatus?.exists === false && + (isConfirmedStaleFolderPathStatus(pathStatus) || pathStatus.reason === 'ambiguous-connection') + const folderPrDisplay = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: row.folderWorkspace.id, + workspaceLineageByChildKey: ctx.workspaceLineageByChildKey, + worktreeLineageById: ctx.worktreeLineageById, + worktreeMap: ctx.worktreeMap, + repoMap: ctx.repoMap, + hostedReviewCache: ctx.hostedReviewCache, + prCache: ctx.prCache, + settings: ctx.settings + }) + const { surfaceInset, cardContentIndent } = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: ctx.newCardStyle, + isFolderBackedWorkspaceChild: + ctx.groupBy === 'repo' && row.projectGroup.createdFrom === 'folder-scan', + isGrouped: ctx.groupBy !== 'none', + groupDepth: row.groupDepth, + lineageDepth: row.depth + }) + return ( +
ctx.onRowPointerDown(event, folderWorktree.id, folderWorktree.id)} + > +
0 ? { paddingLeft: surfaceInset } : undefined} + > + +
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/imported-worktrees-card-visibility.ts b/src/renderer/src/components/sidebar/worktree-list/imported-worktrees-card-visibility.ts new file mode 100644 index 00000000000..bfb7b38b039 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/imported-worktrees-card-visibility.ts @@ -0,0 +1,9 @@ +import type { Row } from '../worktree-list-groups' +import type { ImportedWorktreeCardActionState } from '../imported-worktrees-card-actions' + +export function canKeepImportedWorktreesHidden( + row: Extract, + actionState: ImportedWorktreeCardActionState | undefined +): boolean { + return row.placement === 'repo-group' && actionState?.forceVisible !== true +} diff --git a/src/renderer/src/components/sidebar/worktree-list/mounted-row-reveal.ts b/src/renderer/src/components/sidebar/worktree-list/mounted-row-reveal.ts new file mode 100644 index 00000000000..b3b1436211d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/mounted-row-reveal.ts @@ -0,0 +1,35 @@ +import { revealElementInScrollContainer } from '../worktree-sidebar-reveal' +import { getMountedWorktreeOptions, getWorktreeOptionId } from './worktree-option-dom' + +export function revealMountedWorktreeElement( + container: HTMLElement, + worktreeId: string, + behavior: ScrollBehavior, + optionId?: string, + onScrollIssued?: (targetTop: number) => void +): HTMLElement | null { + const element = optionId + ? document.getElementById(optionId) + : getMountedWorktreeOptions(worktreeId, container)[0] + if (!element || !container.contains(element)) { + return null + } + return revealElementInScrollContainer(container, element, behavior, onScrollIssued) + ? element + : null +} + +export function revealMountedSidebarRowElement( + container: HTMLElement, + rowKey: string, + behavior: ScrollBehavior, + onScrollIssued?: (targetTop: number) => void +): HTMLElement | null { + const element = document.getElementById(getWorktreeOptionId(rowKey)) + if (!element || !container.contains(element)) { + return null + } + return revealElementInScrollContainer(container, element, behavior, onScrollIssued) + ? element + : null +} diff --git a/src/renderer/src/components/sidebar/worktree-list/pending-sidebar-reveal-inputs.ts b/src/renderer/src/components/sidebar/worktree-list/pending-sidebar-reveal-inputs.ts new file mode 100644 index 00000000000..c29b08bd17f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/pending-sidebar-reveal-inputs.ts @@ -0,0 +1,116 @@ +import type React from 'react' +import type { Virtualizer } from '@tanstack/react-virtual' +import type { AppState } from '@/store/types' +import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' +import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import { getWorktreeExecutionHostId } from '../../../../../shared/execution-host' +import type { RenderRow } from '../worktree-list-virtual-rows' +import { + getGroupKeysForWorktree, + getLineageGroupKey, + type PinnedWorktreeDisplayPolicy, + type ProjectGroupingModel, + type WorktreeGroupBy +} from '../worktree-list-groups' +import { getWorktreeLineageAncestors } from '../worktree-lineage-projection' +import { getFolderWorkspaceRevealGroupKeys } from '../worktree-list-folder-reveal' +import { getPinnedWorktreeRevealCollapsedGroupKeys } from './sidebar-row-reveal-ancestors' + +export const MAX_REVEAL_RETRIES = 8 + +export type PendingSidebarRevealArgs = { + pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null + clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void + agentSendTargetWorktreeId: string | null + renderRows: RenderRow[] + virtualizer: Virtualizer + scrollRef: React.RefObject + worktrees: Worktree[] + folderWorkspaces: readonly FolderWorkspace[] + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + collapsedGroups: Set + toggleGroup: (key: string) => void + groupBy: WorktreeGroupBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + defaultHostId: ExecutionHostId + prCache: AppState['prCache'] | null + workspaceStatuses: readonly WorkspaceStatusDefinition[] + settings: AppState['settings'] + projectGroups: readonly ProjectGroup[] + projectGrouping?: ProjectGroupingModel + flashRevealedRow: (rowKey: string) => void + markRevealScroll: (targetTop: number) => void + schedulePendingRevealFrame: (callback: FrameRequestCallback) => void + cancelPendingRevealFrames: () => void +} + +// Expand whatever collapsed section hides the reveal target, then scroll to it. +export function expandGroupsForWorktreeReveal( + args: PendingSidebarRevealArgs, + worktreeId: string +): void { + const folderGroupKeys = getFolderWorkspaceRevealGroupKeys( + worktreeId, + args.folderWorkspaces, + args.projectGroups + ) + if (folderGroupKeys.length > 0) { + for (const groupKey of folderGroupKeys) { + if (args.collapsedGroups.has(groupKey)) { + args.toggleGroup(groupKey) + } + } + return + } + const targetWorktree = args.worktrees.find((w) => w.id === worktreeId) + if (!targetWorktree) { + return + } + const targetRepo = args.repoMap.get(targetWorktree.repoId) + const hostGroupKey = `host:${getWorktreeExecutionHostId(targetWorktree, targetRepo, args.defaultHostId)}` + if (args.collapsedGroups.has(hostGroupKey)) { + args.toggleGroup(hostGroupKey) + } + + for (const parent of getWorktreeLineageAncestors( + targetWorktree, + args.worktreeLineageById, + args.worktreeMap + )) { + const lineageGroupKey = getLineageGroupKey(parent.id) + if (args.collapsedGroups.has(lineageGroupKey)) { + args.toggleGroup(lineageGroupKey) + } + } + + const groupKeys = + targetWorktree.isPinned && args.pinnedDisplayPolicy === 'single-location' + ? getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree: targetWorktree, + collapsedGroups: args.collapsedGroups + }) + : getGroupKeysForWorktree( + args.groupBy, + targetWorktree, + args.repoMap, + args.prCache, + args.workspaceStatuses, + args.settings, + args.projectGroups, + args.projectGrouping + ) + for (const groupKey of groupKeys) { + if (args.collapsedGroups.has(groupKey)) { + args.toggleGroup(groupKey) + } + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/pointer-drop-status-target.ts b/src/renderer/src/components/sidebar/worktree-list/pointer-drop-status-target.ts new file mode 100644 index 00000000000..46e20c56234 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/pointer-drop-status-target.ts @@ -0,0 +1,56 @@ +import type { + WorkspaceStatus, + WorkspaceStatusDefinition +} from '../../../../../shared/worktree/types' +import { getWorkspaceStatusFromGroupKey } from '../workspace-status' +import { getWorktreeLineageDropTargetId } from '../worktree-lineage-drag-drop' +import type { WorktreeSidebarStatusDropTarget } from '../worktree-sidebar-drop-preview' +import { + NO_WORKTREE_SIDEBAR_DROP_TARGET, + type WorktreeSidebarLineageDropTarget +} from './worktree-row-drag-state' + +export function getPointerDropStatusTarget(args: { + container: HTMLElement + x: number + y: number +}): WorktreeSidebarLineageDropTarget { + const target = document.elementFromPoint(args.x, args.y) + if (!(target instanceof Element) || !args.container.contains(target)) { + return NO_WORKTREE_SIDEBAR_DROP_TARGET + } + const pinTarget = target.closest('[data-workspace-pin-drop-target]') + if (pinTarget && args.container.contains(pinTarget)) { + return { status: null, isPinDrop: true, lineageParentId: null } + } + const lineageParentId = getWorktreeLineageDropTargetId({ + container: args.container, + target, + pointerY: args.y + }) + const statusTarget = target.closest('[data-workspace-status-drop-target]') + return { + status: + statusTarget && args.container.contains(statusTarget) + ? ((statusTarget.dataset.workspaceStatus as WorkspaceStatus | undefined) ?? null) + : null, + isPinDrop: false, + lineageParentId + } +} + +export function shouldPreferSidebarStatusDropTarget(args: { + sourceGroupKey: string + target: WorktreeSidebarStatusDropTarget + workspaceStatuses: readonly WorkspaceStatusDefinition[] +}): boolean { + if (args.target.isPinDrop) { + return true + } + if (!args.target.status) { + return false + } + const sourceStatus = getWorkspaceStatusFromGroupKey(args.sourceGroupKey, args.workspaceStatuses) + // Why: overlapping edge zones — the section under the pointer must win so guide and drop agree. + return sourceStatus !== null && args.target.status !== sourceStatus +} diff --git a/src/renderer/src/components/sidebar/worktree-list/project-group-header-actions.tsx b/src/renderer/src/components/sidebar/worktree-list/project-group-header-actions.tsx new file mode 100644 index 00000000000..fc9edf19114 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/project-group-header-actions.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import { Ellipsis, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { getFolderWorkspacePathStatusDescription } from '@/lib/folder-workspace-path-status' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { FolderWorkspacePathStatus } from '../../../../../shared/folder-workspace-path-status' +import { REPO_HEADER_ACTION_BUTTON_CLASS } from '../repo-header-action-button-class' +import { + handleRepoHeaderActionPointerDown, + stopRepoHeaderKeyboardToggle, + stopRepoHeaderMenuEvent +} from './repo-header-event-guards' + +export function ProjectGroupHeaderMenu({ + groupId, + label, + onRename, + onDelete +}: { + groupId: string + label: string + onRename: (groupId: string, currentName: string) => void + onDelete: (groupId: string, groupName: string) => void +}): React.JSX.Element { + return ( + + + + + + onRename(groupId, label)}> + {translate('auto.components.sidebar.WorktreeList.4d7b73658c', 'Rename group')} + + onDelete(groupId, label)}> + {translate('auto.components.sidebar.WorktreeList.902115cdbe', 'Delete group')} + + + + ) +} + +export function ProjectGroupCreateWorkspaceButton({ + projectGroup, + label, + pathStatus, + disabled, + onCreate +}: { + projectGroup: ProjectGroup + label: string + pathStatus: FolderWorkspacePathStatus | null + disabled: boolean + onCreate: (projectGroup: ProjectGroup) => void +}): React.JSX.Element { + const createLabel = translate( + 'auto.components.sidebar.WorktreeList.bd37a57ac8', + 'Create workspace for {{value0}}', + { value0: label } + ) + return ( + + + + + + {pathStatus?.exists === false + ? getFolderWorkspacePathStatusDescription(pathStatus) + : createLabel} + + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/render-row-item-rows.ts b/src/renderer/src/components/sidebar/worktree-list/render-row-item-rows.ts new file mode 100644 index 00000000000..b0895ad6cd4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/render-row-item-rows.ts @@ -0,0 +1,13 @@ +import { PINNED_GROUP_KEY } from '../worktree-list-groups' +import type { HostSectionRow } from '../host-section-rows' + +export type WorktreeItemRow = Extract +export type FolderWorkspaceItemRow = Extract + +export function isWorktreeItemRow(row: HostSectionRow): row is WorktreeItemRow { + return row.type === 'item' +} + +export function isPinnedWorktreeRow(row: WorktreeItemRow): boolean { + return row.sectionKey === PINNED_GROUP_KEY +} diff --git a/src/renderer/src/components/sidebar/worktree-list/render-row-worktree-lookup.ts b/src/renderer/src/components/sidebar/worktree-list/render-row-worktree-lookup.ts new file mode 100644 index 00000000000..4605058d231 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/render-row-worktree-lookup.ts @@ -0,0 +1,79 @@ +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import type { RenderRow } from '../worktree-list-virtual-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import { isPinnedWorktreeRow, type WorktreeItemRow } from './render-row-item-rows' + +export function getRenderRowSidebarKey(row: RenderRow): string | null { + if (row.type === 'header') { + return row.key + } + if (row.type === 'item') { + return row.rowKey + } + if (row.type === 'folder-workspace') { + return folderWorkspaceKey(row.folderWorkspace.id) + } + if (row.type === 'pending-creation') { + return `pending:${row.creationId}` + } + if (row.type === 'imported-worktrees-card') { + return row.key + } + if (row.type === 'new-external-worktrees-inbox') { + return row.key + } + return null +} + +export function rowKeyMatchesRenderRow(row: RenderRow, rowKey: string): boolean { + if (row.type === 'lineage-group') { + return row.rows.some((item) => item.rowKey === rowKey) + } + return getRenderRowSidebarKey(row) === rowKey +} + +export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | null): boolean { + if (worktreeId === null) { + return false + } + if (row.type === 'folder-workspace') { + return folderWorkspaceKey(row.folderWorkspace.id) === worktreeId + } + if (row.type === 'lineage-group') { + return row.rows.some((item) => item.worktree.id === worktreeId) + } + return row.type === 'item' && row.worktree.id === worktreeId +} + +export function getRenderRowWorktreeItem( + row: RenderRow, + worktreeId: string +): WorktreeItemRow | null { + if (row.type === 'lineage-group') { + return row.rows.find((item) => item.worktree.id === worktreeId) ?? null + } + return row.type === 'item' && row.worktree.id === worktreeId ? row : null +} + +// Prefer the worktree's natural group row over its pinned duplicate when both are rendered. +export function findPreferredRenderRowIndexForWorktree( + renderRows: readonly RenderRow[], + worktreeId: string, + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +): number { + let fallbackIndex = -1 + for (let index = 0; index < renderRows.length; index++) { + const row = renderRows[index] + if (!renderRowContainsWorktree(row, worktreeId)) { + continue + } + if (fallbackIndex === -1) { + fallbackIndex = index + } + const itemRow = getRenderRowWorktreeItem(row, worktreeId) + if (pinnedDisplayPolicy === 'duplicate-in-groups' && itemRow && !isPinnedWorktreeRow(itemRow)) { + return index + } + } + return fallbackIndex +} diff --git a/src/renderer/src/components/sidebar/worktree-list/renderable-rows.ts b/src/renderer/src/components/sidebar/worktree-list/renderable-rows.ts new file mode 100644 index 00000000000..cc7c5e7870b --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/renderable-rows.ts @@ -0,0 +1,40 @@ +import { getLineageGroupKey } from '../worktree-list-groups' +import type { HostSectionRow } from '../host-section-rows' +import type { RenderRow } from '../worktree-list-virtual-rows' +import { isWorktreeItemRow, type WorktreeItemRow } from './render-row-item-rows' + +// Collapse a parent and its visible lineage descendants into one virtual row so the card renders them inline. +export function buildRenderableRows(rows: HostSectionRow[]): RenderRow[] { + const renderRows: RenderRow[] = [] + for (let index = 0; index < rows.length; index++) { + const row = rows[index] + if ( + !isWorktreeItemRow(row) || + row.lineageChildCount === 0 || + row.lineageCollapsed || + rows[index + 1]?.type !== 'item' || + (rows[index + 1] as WorktreeItemRow).depth <= row.depth + ) { + renderRows.push(row) + continue + } + + const groupRows: WorktreeItemRow[] = [row] + let cursor = index + 1 + while (cursor < rows.length) { + const child = rows[cursor] + if (!isWorktreeItemRow(child) || child.depth <= row.depth) { + break + } + groupRows.push(child) + cursor++ + } + renderRows.push({ + type: 'lineage-group', + key: `${row.sectionKey}:${getLineageGroupKey(row.worktree.id)}`, + rows: groupRows + }) + index = cursor - 1 + } + return renderRows +} diff --git a/src/renderer/src/components/sidebar/worktree-list/repo-header-event-guards.ts b/src/renderer/src/components/sidebar/worktree-list/repo-header-event-guards.ts new file mode 100644 index 00000000000..c4ba87c3f50 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/repo-header-event-guards.ts @@ -0,0 +1,31 @@ +import type React from 'react' +import { isRepoHeaderActionTarget } from '../project-header-drag' + +export function stopRepoHeaderKeyboardToggle(event: React.KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.stopPropagation() + } +} + +export function stopNestedWorktreeCardBubble(event: React.SyntheticEvent): void { + event.stopPropagation() +} + +export function handleRepoHeaderActionPointerDown(event: React.PointerEvent): void { + event.stopPropagation() +} + +export function handleRepoHeaderCollapseAffordancePointerDown( + event: React.PointerEvent +): void { + // Why: keep collapse-chevron clicks from arming the repo-header row drag. + event.stopPropagation() +} + +export function stopRepoHeaderMenuEvent(event: React.SyntheticEvent): void { + event.stopPropagation() +} + +export function shouldIgnoreRepoHeaderToggle(event: React.SyntheticEvent): boolean { + return isRepoHeaderActionTarget(event.target, event.currentTarget) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/repo-header-project-actions.tsx b/src/renderer/src/components/sidebar/worktree-list/repo-header-project-actions.tsx new file mode 100644 index 00000000000..cd0d09c0733 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/repo-header-project-actions.tsx @@ -0,0 +1,227 @@ +import React from 'react' +import { + CircleX, + Ellipsis, + Eye, + FolderInput, + FolderPlus, + Plus, + Shapes, + SlidersHorizontal, + Trash2 +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import { isGitRepoKind } from '../../../../../shared/repo-kind' +import { + REPO_HEADER_ACTION_BUTTON_CLASS, + REPO_HEADER_ACTION_REVEAL_CLASS +} from '../repo-header-action-button-class' +import type { getRepoHeaderCreateState } from '../repo-header-create-state' +import { + handleRepoHeaderActionPointerDown, + stopRepoHeaderKeyboardToggle, + stopRepoHeaderMenuEvent +} from './repo-header-event-guards' +import { getWorktreeVisibilityMenuLabel } from './worktree-visibility-menu-label' + +export type RepoHeaderProjectActions = { + onOpenRepoSettings: (projectId: string, sectionId?: string) => void + onOpenWorktreeVisibility: (repo: Repo) => void + onCreateGroupFromRepo: (repo: Repo) => void + onMoveProjectToGroup: (repo: Repo, groupId: string) => void + onRemoveProjectFromGroup: (repo: Repo) => void + onRemoveProject: (repo: Repo) => void + onCreateForRepo: (projectId: string) => void +} + +export function RepoHeaderProjectActionsMenu({ + repo, + label, + projectGroups, + actions +}: { + repo: Repo + label: string + projectGroups: readonly ProjectGroup[] + actions: RepoHeaderProjectActions +}): React.JSX.Element { + return ( + + + + + + + + + {translate('auto.components.sidebar.WorktreeList.2ef41bf9a7', 'Project actions')} + + + + actions.onOpenRepoSettings(repo.id)}> + + {translate('auto.components.sidebar.WorktreeList.2cdffbc728', 'Project Settings')} + + actions.onOpenRepoSettings(repo.id, getRepositoryIconSectionId(repo.id))} + > + + {translate('auto.components.sidebar.WorktreeList.e82d3589a1', 'Change Project Icon')} + + {isGitRepoKind(repo) ? ( + actions.onOpenWorktreeVisibility(repo)}> + + {getWorktreeVisibilityMenuLabel(repo)} + + ) : null} + actions.onCreateGroupFromRepo(repo)}> + + {translate('auto.components.sidebar.WorktreeList.cbfd565f83', 'New group from project')} + + {projectGroups.length > 0 ? ( + + + + {translate('auto.components.sidebar.WorktreeList.4a08fb55f2', 'Move to group')} + + + {projectGroups.map((group) => ( + actions.onMoveProjectToGroup(repo, group.id)} + > + {group.name} + + ))} + + + ) : null} + {repo.projectGroupId ? ( + actions.onRemoveProjectFromGroup(repo)}> + + {translate('auto.components.sidebar.WorktreeList.64e55f7f01', 'Remove from group')} + + ) : null} + + actions.onRemoveProject(repo)}> + + {translate('auto.components.sidebar.WorktreeList.c83968f87f', 'Remove Project')} + + + + ) +} + +export function RepoHeaderCreateWorkspaceButton({ + repo, + label, + createState, + onCreateForRepo +}: { + repo: Repo + label: string + createState: ReturnType | null + onCreateForRepo: (projectId: string) => void +}): React.JSX.Element { + const fallbackLabel = translate( + 'auto.components.sidebar.WorktreeList.bb85cd86ba', + 'Create workspace for {{value0}}', + { value0: label } + ) + return ( + + + {createState?.disabled ? ( + event.stopPropagation()} + onPointerDown={handleRepoHeaderActionPointerDown} + > + + + ) : ( + + )} + + + {createState?.tooltip ?? fallbackLabel} + + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/sidebar-editable-target.ts b/src/renderer/src/components/sidebar/worktree-list/sidebar-editable-target.ts new file mode 100644 index 00000000000..390df9dae2f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/sidebar-editable-target.ts @@ -0,0 +1,19 @@ +export function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + + // xterm's hidden input textarea isn't a real text field; treating it as one would block sidebar shortcuts. + if (target.classList.contains('xterm-helper-textarea')) { + return false + } + + if (target.isContentEditable) { + return true + } + + return ( + target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== + null + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/sidebar-row-reveal-ancestors.ts b/src/renderer/src/components/sidebar/worktree-list/sidebar-row-reveal-ancestors.ts new file mode 100644 index 00000000000..cc387daba32 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/sidebar-row-reveal-ancestors.ts @@ -0,0 +1,112 @@ +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import { + getProjectGroupHeaderKey, + PINNED_GROUP_KEY, + type ProjectGroupingModel +} from '../worktree-list-groups' + +function getProjectIdFromHeaderRowKey(rowKey: string): string | null { + if (!rowKey.startsWith('project:')) { + return null + } + const withoutPrefix = rowKey.slice('project:'.length) + const setupSeparator = withoutPrefix.indexOf('::setup:') + return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) +} + +function getRepoIdsFromHeaderRowKey( + rowKey: string, + repoMap: Map, + projectGrouping?: ProjectGroupingModel +): string[] { + if (rowKey.startsWith('repo:')) { + return [rowKey.slice('repo:'.length)] + } + const setupMarker = '::setup:' + const setupIndex = rowKey.indexOf(setupMarker) + if (rowKey.startsWith('project:') && setupIndex !== -1) { + return [rowKey.slice(setupIndex + setupMarker.length)] + } + const projectId = getProjectIdFromHeaderRowKey(rowKey) + if (!projectId) { + return [] + } + const repoIds = new Set() + for (const setup of projectGrouping?.projectHostSetups ?? []) { + if (setup.projectId === projectId && repoMap.has(setup.repoId)) { + repoIds.add(setup.repoId) + } + } + const project = projectGrouping?.projects.find((candidate) => candidate.id === projectId) + for (const repoId of project?.sourceRepoIds ?? []) { + if (repoMap.has(repoId)) { + repoIds.add(repoId) + } + } + return [...repoIds] +} + +function getProjectGroupAncestorKeys( + projectGroupId: string | null | undefined, + projectGroups: readonly ProjectGroup[] +): string[] { + const groupsById = new Map(projectGroups.map((group) => [group.id, group])) + const keys: string[] = [] + const seen = new Set() + let currentGroupId = projectGroupId ?? null + while (currentGroupId && !seen.has(currentGroupId)) { + const group = groupsById.get(currentGroupId) + if (!group) { + break + } + seen.add(currentGroupId) + keys.unshift(getProjectGroupHeaderKey(group.id)) + currentGroupId = group.parentGroupId + } + return keys +} + +export function getSidebarRowRevealAncestorKeys(args: { + rowKey: string + repoMap: Map + projectGroups: readonly ProjectGroup[] + projectGrouping?: ProjectGroupingModel +}): string[] { + if (args.rowKey.startsWith('project-group:')) { + const groupId = args.rowKey.slice('project-group:'.length) + const group = args.projectGroups.find((candidate) => candidate.id === groupId) + return getProjectGroupAncestorKeys(group?.parentGroupId, args.projectGroups) + } + const keys = new Set() + for (const repoId of getRepoIdsFromHeaderRowKey( + args.rowKey, + args.repoMap, + args.projectGrouping + )) { + const repo = args.repoMap.get(repoId) + for (const key of getProjectGroupAncestorKeys(repo?.projectGroupId, args.projectGroups)) { + keys.add(key) + } + } + return [...keys] +} + +export function getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree, + collapsedGroups +}: { + worktree: Worktree + collapsedGroups: ReadonlySet +}): string[] { + if (!worktree.isPinned) { + return [] + } + const keys: string[] = [] + // Why: the reveal effect already opens this host; re-returning it would toggle it back closed. + if (collapsedGroups.has(PINNED_GROUP_KEY)) { + keys.push(PINNED_GROUP_KEY) + } + return keys +} diff --git a/src/renderer/src/components/sidebar/worktree-list/stable-empty-store-slices.ts b/src/renderer/src/components/sidebar/worktree-list/stable-empty-store-slices.ts new file mode 100644 index 00000000000..49cae96a88b --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/stable-empty-store-slices.ts @@ -0,0 +1,9 @@ +import type { AppState } from '@/store/types' + +// Why: selectors that opt out of a slice must return one stable reference, or every store tick looks like a change. +export const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} +export const EMPTY_WORKTREE_ID_SET: ReadonlySet = new Set() +export const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} +export const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} +export const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} +export const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts b/src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts new file mode 100644 index 00000000000..61269d78225 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts @@ -0,0 +1,63 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' +import { + EMPTY_AGENT_STATUS_BY_PANE_KEY, + EMPTY_PTY_IDS_BY_TAB_ID, + EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID, + EMPTY_TABS_BY_WORKTREE, + EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID +} from './stable-empty-store-slices' + +// The agent send picker forces its target workspace visible, but only while a running +// agent there can actually receive the message. +export function useAgentSendTargetWorktreeId(): string | null { + const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) + // Why: eligibility only matters while the picker is open; when closed, don't subscribe to wake-time layout churn. + const agentTargetStatusByPaneKey = useAppStore((s) => + agentSendPopoverTargetMode ? s.agentStatusByPaneKey : EMPTY_AGENT_STATUS_BY_PANE_KEY + ) + const agentTargetStatusEpoch = useAppStore((s) => + agentSendPopoverTargetMode ? s.agentStatusEpoch : 0 + ) + const agentTargetTabsByWorktree = useAppStore((s) => + agentSendPopoverTargetMode ? s.tabsByWorktree : EMPTY_TABS_BY_WORKTREE + ) + const agentTargetTerminalLayoutsByTabId = useAppStore((s) => + agentSendPopoverTargetMode ? s.terminalLayoutsByTabId : EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID + ) + const agentTargetPtyIdsByTabId = useAppStore((s) => + agentSendPopoverTargetMode ? s.ptyIdsByTabId : EMPTY_PTY_IDS_BY_TAB_ID + ) + const agentTargetRuntimePaneTitlesByTabId = useAppStore((s) => + agentSendPopoverTargetMode ? s.runtimePaneTitlesByTabId : EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID + ) + return useMemo(() => { + void agentTargetStatusEpoch + if (!agentSendPopoverTargetMode) { + return null + } + const targets = deriveRunningAgentSendTargets( + { + agentStatusByPaneKey: agentTargetStatusByPaneKey, + tabsByWorktree: agentTargetTabsByWorktree, + terminalLayoutsByTabId: agentTargetTerminalLayoutsByTabId, + ptyIdsByTabId: agentTargetPtyIdsByTabId, + runtimePaneTitlesByTabId: agentTargetRuntimePaneTitlesByTabId + }, + agentSendPopoverTargetMode.worktreeId + ) + return targets.some((target) => target.status === 'eligible') + ? agentSendPopoverTargetMode.worktreeId + : null + }, [ + // Why: eligibility can flip when the stale-boundary scheduler bumps this epoch without replacing the status map. + agentTargetStatusEpoch, + agentSendPopoverTargetMode, + agentTargetStatusByPaneKey, + agentTargetTabsByWorktree, + agentTargetTerminalLayoutsByTabId, + agentTargetPtyIdsByTabId, + agentTargetRuntimePaneTitlesByTabId + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts b/src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts new file mode 100644 index 00000000000..681056b5fe6 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts @@ -0,0 +1,90 @@ +import { useMemo } from 'react' +import type { AppState } from '@/store/types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types' +import { + getGroupKeysForWorktree, + getLineageGroupKey, + PINNED_GROUP_KEY, + type ProjectGroupingModel, + type WorktreeGroupBy +} from '../worktree-list-groups' +import { getWorktreeLineageAncestors } from '../worktree-lineage-projection' + +// While the agent send picker targets a workspace, force open every section that hides it. +export function useEffectiveCollapsedGroups(args: { + collapsedGroups: Set + agentSendTargetWorktreeId: string | null + groupBy: WorktreeGroupBy + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + prCache: AppState['prCache'] | null + workspaceStatuses: readonly WorkspaceStatusDefinition[] + settings: AppState['settings'] + projectGroups: readonly ProjectGroup[] + projectGrouping: ProjectGroupingModel +}): Set { + const { + collapsedGroups, + agentSendTargetWorktreeId, + groupBy, + repoMap, + worktreeMap, + worktreeLineageById, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + } = args + return useMemo(() => { + if (!agentSendTargetWorktreeId) { + return collapsedGroups + } + const targetWorktree = worktreeMap.get(agentSendTargetWorktreeId) + if (!targetWorktree) { + return collapsedGroups + } + const next = new Set(collapsedGroups) + if (targetWorktree.isPinned) { + next.delete(PINNED_GROUP_KEY) + } else { + for (const groupKey of getGroupKeysForWorktree( + groupBy, + targetWorktree, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + )) { + next.delete(groupKey) + } + } + + for (const parent of getWorktreeLineageAncestors( + targetWorktree, + worktreeLineageById, + worktreeMap + )) { + next.delete(getLineageGroupKey(parent.id)) + } + return next + }, [ + agentSendTargetWorktreeId, + collapsedGroups, + groupBy, + prCache, + projectGroups, + projectGrouping, + repoMap, + settings, + workspaceStatuses, + worktreeLineageById, + worktreeMap + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-folder-workspace-path-status-rows.ts b/src/renderer/src/components/sidebar/worktree-list/use-folder-workspace-path-status-rows.ts new file mode 100644 index 00000000000..8e5bd17b81d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-folder-workspace-path-status-rows.ts @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry' +import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { AppState } from '@/store/types' +import { getFolderPathStatusRouteOptionsForRows } from '../worktree-list-host-filtering' + +type FolderPathStatusRequest = Parameters[0] + +// Keeps the sidebar's folder-path probes fresh for every project group and folder workspace +// it renders, and hands rows a cache reader that ignores expired negative results. +export function useFolderWorkspacePathStatusRows(args: { + allRepoIds: string[] + repoMap: Map + projectGroups: readonly ProjectGroup[] + folderWorkspaces: readonly FolderWorkspace[] + sshConnectionStates: AppState['sshConnectionStates'] +}) { + const { allRepoIds, repoMap, projectGroups, folderWorkspaces, sshConnectionStates } = args + const { + folderWorkspacePathStatuses, + fetchFolderWorkspacePathStatus, + getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus, + activeRuntimeEnvironmentId + } = useAppStore( + useShallow((s) => ({ + folderWorkspacePathStatuses: s.folderWorkspacePathStatuses, + fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus, + getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus, + activeRuntimeEnvironmentId: s.settings?.activeRuntimeEnvironmentId ?? null + })) + ) + const folderPathStatusRepoMembershipKey = useMemo( + () => + allRepoIds + .map((repoId) => { + const repo = repoMap.get(repoId) + return `${repoId}:${repo?.path ?? ''}:${repo?.projectGroupId ?? ''}:${repo?.connectionId ?? ''}` + }) + .join('\0'), + [allRepoIds, repoMap] + ) + const folderPathStatusSshConnectionKey = useMemo( + () => + [...sshConnectionStates.entries()] + .map(([connectionId, state]) => `${connectionId}:${state.status}`) + .sort() + .join('\0'), + [sshConnectionStates] + ) + const folderPathStatusCacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick( + folderWorkspacePathStatuses + ) + const projectGroupByIdForFolderPathStatus = useMemo( + () => new Map(projectGroups.map((group) => [group.id, group])), + [projectGroups] + ) + const folderWorkspaceByIdForFolderPathStatus = useMemo( + () => new Map(folderWorkspaces.map((workspace) => [workspace.id, workspace])), + [folderWorkspaces] + ) + const getFolderPathStatusRouteOptions = useCallback( + (request: FolderPathStatusRequest) => + getFolderPathStatusRouteOptionsForRows({ + request, + projectGroupsById: projectGroupByIdForFolderPathStatus, + folderWorkspacesById: folderWorkspaceByIdForFolderPathStatus + }), + [folderWorkspaceByIdForFolderPathStatus, projectGroupByIdForFolderPathStatus] + ) + useEffect(() => { + const requests = new Map< + string, + { + request: FolderPathStatusRequest + options?: { runtimeEnvironmentId: string | null } + } + >() + for (const group of projectGroups) { + if (group.parentPath) { + const request = { scope: 'project-group' as const, projectGroupId: group.id } + const options = getFolderPathStatusRouteOptions(request) + requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) + } + } + for (const workspace of folderWorkspaces) { + const request = { scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id } + const options = getFolderPathStatusRouteOptions(request) + requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) + } + for (const { request, options } of requests.values()) { + void fetchFolderWorkspacePathStatus(request, { force: true, ...options }) + } + }, [ + activeRuntimeEnvironmentId, + fetchFolderWorkspacePathStatus, + folderPathStatusRepoMembershipKey, + folderPathStatusSshConnectionKey, + folderWorkspaces, + getFolderPathStatusRouteOptions, + getFolderWorkspacePathStatusCacheKey, + projectGroups + ]) + const getCachedFolderWorkspacePathStatus = useCallback( + (request: FolderPathStatusRequest) => { + const options = getFolderPathStatusRouteOptions(request) + const cacheKey = getFolderWorkspacePathStatusCacheKey(request, options) + // Why: don't let an expired negative status keep folder workspaces disabled while a refresh is in flight. + void folderWorkspacePathStatuses[cacheKey] + void folderPathStatusCacheExpiryTick + return getFreshFolderWorkspacePathStatus(request, options) + }, + [ + folderWorkspacePathStatuses, + folderPathStatusCacheExpiryTick, + getFolderPathStatusRouteOptions, + getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus + ] + ) + + return getCachedFolderWorkspacePathStatus +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-group-toggle-with-scroll-anchor.ts b/src/renderer/src/components/sidebar/worktree-list/use-group-toggle-with-scroll-anchor.ts new file mode 100644 index 00000000000..044c2a2651f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-group-toggle-with-scroll-anchor.ts @@ -0,0 +1,30 @@ +import { useCallback, useMemo } from 'react' +import type React from 'react' +import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' +import { createLineageToggleHandlerCache } from '../worktree-lineage-toggle-handler-cache' + +// Collapsing a section changes total height, so snapshot the anchor first or the viewport jumps. +export function useGroupToggleWithScrollAnchor(args: { + scrollRef: React.RefObject + toggleGroup: (key: string) => void +}) { + const { scrollRef, toggleGroup } = args + const recordCurrentScrollAnchor = useCallback(() => { + scrollRef.current?.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)) + }, [scrollRef]) + const toggleGroupWithScrollAnchor = useCallback( + (groupKey: string) => { + recordCurrentScrollAnchor() + toggleGroup(groupKey) + }, + [recordCurrentScrollAnchor, toggleGroup] + ) + // Why: memo'd WorktreeCard needs a per-group-key stable onLineageToggle + // identity to bail out of re-renders; see worktree-lineage-toggle-handler-cache. + const getLineageToggleHandler = useMemo( + () => createLineageToggleHandlerCache(toggleGroupWithScrollAnchor), + [toggleGroupWithScrollAnchor] + ) + + return { toggleGroupWithScrollAnchor, getLineageToggleHandler } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts b/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts new file mode 100644 index 00000000000..7ba04fb5149 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts @@ -0,0 +1,288 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { getRenderRowOptionId } from './active-descendant-option' +import { + findPreferredRenderRowIndexForWorktree, + getRenderRowSidebarKey, + rowKeyMatchesRenderRow +} from './render-row-worktree-lookup' +import { revealMountedSidebarRowElement, revealMountedWorktreeElement } from './mounted-row-reveal' +import { getSidebarRowRevealAncestorKeys } from './sidebar-row-reveal-ancestors' +import { sidebarWorkspaceStillExists } from '../worktree-list-folder-reveal' +import { resolvePendingSidebarReveal } from './worktree-sidebar-scroll-tuning' +import { + expandGroupsForWorktreeReveal, + MAX_REVEAL_RETRIES, + type PendingSidebarRevealArgs +} from './pending-sidebar-reveal-inputs' + +// Drives the store's two pending reveal requests (a worktree card, or any sidebar row) +// from "expand the ancestors" through "scroll the mounted element into view". +export function usePendingSidebarReveal(args: PendingSidebarRevealArgs): void { + const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) + const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) + const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null) + const pendingRowRevealRetryRef = useRef<{ rowKey: string; count: number } | null>(null) + const argsRef = useRef(args) + argsRef.current = args + + const { + pendingRevealWorktree, + pendingRevealSidebarRow, + clearPendingRevealWorktreeId, + clearPendingRevealSidebarRow, + renderRows, + virtualizer, + schedulePendingRevealFrame, + cancelPendingRevealFrames, + flashRevealedRow, + markRevealScroll, + pinnedDisplayPolicy + } = args + + const scheduleRetryTick = useCallback( + (cancelled: () => boolean) => { + schedulePendingRevealFrame(() => { + if (!cancelled()) { + setPendingRevealRetryTick((tick) => tick + 1) + } + }) + }, + [schedulePendingRevealFrame] + ) + + useEffect(() => { + if (!pendingRevealWorktree) { + return + } + const current = argsRef.current + if (current.agentSendTargetWorktreeId !== pendingRevealWorktree.worktreeId) { + expandGroupsForWorktreeReveal(current, pendingRevealWorktree.worktreeId) + } + + let cancelled = false + schedulePendingRevealFrame(() => { + if (cancelled) { + return + } + const targetWorktreeStillExists = sidebarWorkspaceStillExists( + pendingRevealWorktree.worktreeId, + argsRef.current.worktrees, + argsRef.current.folderWorkspaces + ) + const targetIndex = findPreferredRenderRowIndexForWorktree( + renderRows, + pendingRevealWorktree.worktreeId, + pinnedDisplayPolicy + ) + const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists }) + if (outcome === 'clear') { + pendingRevealRetryRef.current = null + clearPendingRevealWorktreeId() + return + } + if (outcome !== 'scroll-and-clear') { + return + } + const targetRow = renderRows[targetIndex] + const container = argsRef.current.scrollRef.current + const revealedOption = container + ? revealMountedWorktreeElement( + container, + pendingRevealWorktree.worktreeId, + pendingRevealWorktree.behavior, + getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId), + markRevealScroll + ) + : null + if (revealedOption) { + if (pendingRevealWorktree.highlight) { + const revealedRowKey = + revealedOption.dataset.worktreeRowKey ?? getRenderRowSidebarKey(targetRow) + if (revealedRowKey) { + flashRevealedRow(revealedRowKey) + } + } + if (pendingRevealWorktree.beginRename) { + setRenamingWorktreeId({ + worktreeId: pendingRevealWorktree.worktreeId, + rowKey: revealedOption.dataset.worktreeRowKey + }) + } + pendingRevealRetryRef.current = null + clearPendingRevealWorktreeId() + return + } + + // Why: virtual indexing can leave the card edge clipped; stage it into the window, then retry the exact DOM reveal. + virtualizer.scrollToIndex(targetIndex, { align: 'auto', behavior: 'auto' }) + const previousRetry = pendingRevealRetryRef.current + const nextRetryCount = + previousRetry?.worktreeId === pendingRevealWorktree.worktreeId ? previousRetry.count + 1 : 1 + pendingRevealRetryRef.current = { + worktreeId: pendingRevealWorktree.worktreeId, + count: nextRetryCount + } + if (nextRetryCount <= MAX_REVEAL_RETRIES) { + scheduleRetryTick(() => cancelled) + return + } + pendingRevealRetryRef.current = null + clearPendingRevealWorktreeId() + }) + return () => { + cancelled = true + cancelPendingRevealFrames() + } + // Why: the effect re-reads live inputs through argsRef; only reveal-relevant identities should retrigger it. + // oxlint-disable-next-line react-hooks/exhaustive-deps + }, [ + pendingRevealWorktree, + args.agentSendTargetWorktreeId, + args.groupBy, + args.worktrees, + args.folderWorkspaces, + args.repoMap, + args.prCache, + args.worktreeLineageById, + args.worktreeMap, + renderRows, + virtualizer, + clearPendingRevealWorktreeId, + args.toggleGroup, + args.collapsedGroups, + args.defaultHostId, + args.workspaceStatuses, + args.settings, + pinnedDisplayPolicy, + args.projectGrouping, + args.projectGroups, + pendingRevealRetryTick, + flashRevealedRow, + markRevealScroll, + setRenamingWorktreeId, + schedulePendingRevealFrame, + cancelPendingRevealFrames, + scheduleRetryTick + ]) + + useEffect(() => { + if (!pendingRevealSidebarRow) { + return + } + const current = argsRef.current + + const isProjectHeaderTarget = + pendingRevealSidebarRow.rowKey.startsWith('project-group:') || + pendingRevealSidebarRow.rowKey.startsWith('project:') || + pendingRevealSidebarRow.rowKey.startsWith('repo:') + if (isProjectHeaderTarget && current.groupBy !== 'repo') { + return + } + + let toggledAncestor = false + for (const groupKey of getSidebarRowRevealAncestorKeys({ + rowKey: pendingRevealSidebarRow.rowKey, + repoMap: current.repoMap, + projectGroups: current.projectGroups, + projectGrouping: current.projectGrouping + })) { + if (current.collapsedGroups.has(groupKey)) { + current.toggleGroup(groupKey) + toggledAncestor = true + } + } + if (toggledAncestor) { + return + } + + let cancelled = false + const retryPendingReveal = (): boolean => { + const previousRetry = pendingRowRevealRetryRef.current + const nextRetryCount = + previousRetry?.rowKey === pendingRevealSidebarRow.rowKey ? previousRetry.count + 1 : 1 + pendingRowRevealRetryRef.current = { + rowKey: pendingRevealSidebarRow.rowKey, + count: nextRetryCount + } + if (nextRetryCount <= MAX_REVEAL_RETRIES) { + scheduleRetryTick(() => cancelled) + return true + } + return false + } + schedulePendingRevealFrame(() => { + if (cancelled) { + return + } + const targetIndex = renderRows.findIndex((row) => + rowKeyMatchesRenderRow(row, pendingRevealSidebarRow.rowKey) + ) + if (targetIndex === -1) { + if (retryPendingReveal()) { + return + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.sidebarRowMissing', + 'Target no longer exists' + ) + ) + return + } + + const container = argsRef.current.scrollRef.current + const revealedElement = container + ? revealMountedSidebarRowElement( + container, + pendingRevealSidebarRow.rowKey, + pendingRevealSidebarRow.behavior, + markRevealScroll + ) + : null + if (revealedElement) { + if (pendingRevealSidebarRow.highlight) { + flashRevealedRow(pendingRevealSidebarRow.rowKey) + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + return + } + + virtualizer.scrollToIndex(targetIndex, { align: 'auto', behavior: 'auto' }) + if (retryPendingReveal()) { + return + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + }) + + return () => { + cancelled = true + cancelPendingRevealFrames() + } + // Why: the effect re-reads live inputs through argsRef; only reveal-relevant identities should retrigger it. + // oxlint-disable-next-line react-hooks/exhaustive-deps + }, [ + pendingRevealSidebarRow, + args.repoMap, + args.projectGroups, + args.projectGrouping, + args.collapsedGroups, + args.groupBy, + args.toggleGroup, + renderRows, + virtualizer, + pendingRevealRetryTick, + flashRevealedRow, + markRevealScroll, + clearPendingRevealSidebarRow, + schedulePendingRevealFrame, + cancelPendingRevealFrames, + scheduleRetryTick + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts b/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts new file mode 100644 index 00000000000..698d0b6d7c8 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useState } from 'react' +import type { ActiveSurfaceVariant } from '../WorktreeCard' +import type { HostSectionRow } from '../host-section-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import { isPinnedWorktreeRow, type WorktreeItemRow } from './render-row-item-rows' + +// A worktree can render in more than one section; the row the user actually clicked owns +// the primary active surface so its duplicates stay visually secondary. +export function usePrimaryActiveWorktreeRow(args: { + rows: HostSectionRow[] + activeWorktreeId: string | null + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + onImmediateWorktreeActivate: (worktreeId: string, rowKey: string | undefined) => void +}) { + const { rows, activeWorktreeId, pinnedDisplayPolicy, onImmediateWorktreeActivate } = args + const [primaryActiveWorktreeRow, setPrimaryActiveWorktreeRow] = useState<{ + worktreeId: string + rowKey: string + } | null>(null) + + useEffect(() => { + if (activeWorktreeId === null) { + setPrimaryActiveWorktreeRow(null) + return + } + setPrimaryActiveWorktreeRow((current) => { + if (current === null || current.worktreeId !== activeWorktreeId) { + return null + } + const rowStillVisible = rows.some( + (row) => + row.type === 'item' && + row.worktree.id === current.worktreeId && + row.rowKey === current.rowKey + ) + return rowStillVisible ? current : null + }) + }, [activeWorktreeId, rows]) + + const getActiveSurfaceVariant = useCallback( + (row: WorktreeItemRow): ActiveSurfaceVariant => { + if (primaryActiveWorktreeRow?.worktreeId === row.worktree.id) { + return primaryActiveWorktreeRow.rowKey === row.rowKey ? 'primary' : 'secondary' + } + if ( + pinnedDisplayPolicy === 'duplicate-in-groups' && + activeWorktreeId === row.worktree.id && + isPinnedWorktreeRow(row) + ) { + return 'secondary' + } + return 'primary' + }, + [activeWorktreeId, pinnedDisplayPolicy, primaryActiveWorktreeRow] + ) + + const handleImmediateWorktreeRowActivate = useCallback( + (worktreeId: string, rowKey: string | undefined): void => { + setPrimaryActiveWorktreeRow(rowKey ? { worktreeId, rowKey } : null) + onImmediateWorktreeActivate(worktreeId, rowKey) + }, + [onImmediateWorktreeActivate] + ) + + return { + primaryActiveWorktreeRow, + getActiveSurfaceVariant, + handleImmediateWorktreeRowActivate + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts b/src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts new file mode 100644 index 00000000000..800852f9570 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts @@ -0,0 +1,172 @@ +import { useCallback, useMemo, useState } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' + +export type ProjectGroupNameDialogState = + | { type: 'create-from-repo'; repo: Repo } + | { type: 'rename'; groupId: string; currentName: string } + +export type ProjectGroupDeleteDialogState = { + groupId: string + groupName: string + removeContainedProjects: boolean +} + +export type ProjectGroupDialogs = ReturnType + +function reportProjectGroupDeleteFailures(result: { + status: string + failedProjectRemovals: readonly unknown[] + requestedProjectIds: readonly unknown[] +}): void { + // Why: a missing group is already the desired end state, so only a real delete failure warrants a toast. + if (result.status === 'group-delete-failed') { + toast.error( + translate('auto.components.sidebar.WorktreeList.groupDeleteFailed', 'Failed to delete group'), + { + description: translate( + 'auto.components.sidebar.WorktreeList.groupDeleteFailedDesc', + 'Something went wrong while deleting the group. No projects were removed.' + ) + } + ) + return + } + if (result.status === 'deleted-group' && result.failedProjectRemovals.length > 0) { + const requestedCount = result.requestedProjectIds.length + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.b667b59632', + 'Some projects could not be removed from Orca' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeList.f94466bc39', + '{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.', + { + value0: result.failedProjectRemovals.length, + value1: requestedCount, + value2: requestedCount === 1 ? '' : 's' + } + ) + } + ) + } +} + +// Create/rename/delete flows for project groups, including the contained-project fan-out. +export function useProjectGroupDialogs(args: { + repos: readonly Repo[] + repoMap: Map + projectGroups: readonly ProjectGroup[] +}) { + const { repos, repoMap, projectGroups } = args + const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) + const createProjectGroup = useAppStore((s) => s.createProjectGroup) + const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) + const deleteProjectGroupWithContainedProjects = useAppStore( + (s) => s.deleteProjectGroupWithContainedProjects + ) + const [nameDialog, setNameDialog] = useState(null) + const [deleteDialog, setDeleteDialog] = useState(null) + + const handleCreateGroupFromRepo = useCallback((repo: Repo) => { + setNameDialog({ type: 'create-from-repo', repo }) + }, []) + + const handleMoveProjectToGroup = useCallback( + (repo: Repo, groupId: string) => { + if (repo.projectGroupId === groupId) { + return + } + void moveProjectToGroup(repo.id, groupId) + }, + [moveProjectToGroup] + ) + + const handleRemoveProjectFromGroup = useCallback( + (repo: Repo) => { + void moveProjectToGroup(repo.id, null) + }, + [moveProjectToGroup] + ) + + const handleRenameProjectGroup = useCallback((groupId: string, currentName: string) => { + setNameDialog({ type: 'rename', groupId, currentName }) + }, []) + + const handleSubmitProjectGroupName = useCallback( + async (name: string) => { + if (!nameDialog) { + return + } + if (nameDialog.type === 'create-from-repo') { + const group = await createProjectGroup(name) + if (group) { + await moveProjectToGroup(nameDialog.repo.id, group.id) + } + return + } + await updateProjectGroup(nameDialog.groupId, { name }) + }, + [createProjectGroup, moveProjectToGroup, nameDialog, updateProjectGroup] + ) + + const deleteTargets = useMemo(() => { + if (!deleteDialog) { + return null + } + return selectProjectGroupRemovalTargets(projectGroups, repos, deleteDialog.groupId) + }, [deleteDialog, projectGroups, repos]) + const deleteProjectCount = deleteTargets?.projectIds.length ?? 0 + const deleteProjectNames = useMemo( + () => + (deleteTargets?.projectIds ?? []).map( + (projectId) => repoMap.get(projectId)?.displayName ?? projectId + ), + [deleteTargets, repoMap] + ) + const removeContainedProjects = + deleteProjectCount > 0 && deleteDialog?.removeContainedProjects === true + + const handleDeleteProjectGroup = useCallback((groupId: string, groupName: string) => { + setDeleteDialog({ groupId, groupName, removeContainedProjects: false }) + }, []) + + const handleConfirmDeleteProjectGroup = useCallback(async () => { + if (!deleteDialog) { + return + } + try { + reportProjectGroupDeleteFailures( + await deleteProjectGroupWithContainedProjects(deleteDialog.groupId, { + removeContainedProjects + }) + ) + } finally { + // Why: deleting contained projects can unmount this dialog before its close handler runs, so the parent owns cleanup. + setDeleteDialog(null) + } + }, [deleteProjectGroupWithContainedProjects, removeContainedProjects, deleteDialog]) + + return { + nameDialog, + setNameDialog, + deleteDialog, + setDeleteDialog, + deleteProjectCount, + deleteProjectNames, + removeContainedProjects, + handleCreateGroupFromRepo, + handleMoveProjectToGroup, + handleRemoveProjectFromGroup, + handleRenameProjectGroup, + handleSubmitProjectGroupName, + handleDeleteProjectGroup, + handleConfirmDeleteProjectGroup + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts b/src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts new file mode 100644 index 00000000000..892f2d7694a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts @@ -0,0 +1,10 @@ +import { useRef } from 'react' +import { reuseArrayIfEqual } from '../worktree-agent-row-selectors' + +// Why: epoch-driven recomputes often produce arrays whose contents and order are unchanged; reusing the previous identity when element-wise equal keeps downstream memos and React.memo'd cards bailing out. Safe only because elements (Worktree objects / id strings) are immutably REPLACED on change — never wrap arrays of mutated-in-place objects. +export function useReusedArrayIdentity(next: T[]): T[] { + const previousRef = useRef(next) + const result = reuseArrayIfEqual(previousRef.current, next) + previousRef.current = result + return result +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts new file mode 100644 index 00000000000..a4ab21143da --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts @@ -0,0 +1,187 @@ +import { useCallback, useMemo, useState } from 'react' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { Repo } from '../../../../../shared/repo-types' +import { + IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR, + keepImportedWorktreesHiddenCard, + showImportedWorktreesCard, + type ImportedWorktreeCardActionState +} from '../imported-worktrees-card-actions' +import { + buildImportedWorktreesCardCandidates, + getHiddenImportedWorktrees +} from '../imported-worktrees-card-candidates' +import { + suppressNewExternalWorktreeInbox, + type NewExternalWorktreesInboxActionState +} from '../new-external-worktrees-inbox-actions' +import { buildNewExternalWorktreesInboxCandidates } from '../new-external-worktrees-inbox-candidates' + +// The two sidebar notice rows for worktrees Orca detected but does not yet show, plus the +// pending/error state their inline actions surface. +export function useSidebarExternalWorktreeCards(args: { + repos: readonly Repo[] + visibleReposForRows: readonly Repo[] + detectedWorktreesByRepo: AppState['detectedWorktreesByRepo'] + filterRepoIds: readonly string[] +}) { + const { repos, visibleReposForRows, detectedWorktreesByRepo, filterRepoIds } = args + const updateRepo = useAppStore((s) => s.updateRepo) + const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState< + Map + >(new Map()) + const [newExternalWorktreeInboxActionState, setNewExternalWorktreeInboxActionState] = useState< + Map + >(new Map()) + const [suppressExternalWorktreeInboxRepoId, setSuppressExternalWorktreeInboxRepoId] = useState< + string | null + >(null) + + const importedWorktreesByRepo = useMemo(() => { + const forceVisibleRepoIds = new Set( + [...importedWorktreeCardActionState.entries()] + .filter(([, state]) => state.forceVisible) + .map(([repoId]) => repoId) + ) + return buildImportedWorktreesCardCandidates({ + repos: visibleReposForRows, + detectedWorktreesByRepo, + filterRepoIds, + forceVisibleRepoIds + }) + }, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, visibleReposForRows]) + const newExternalWorktreesInboxByRepo = useMemo( + () => + buildNewExternalWorktreesInboxCandidates({ + repos: visibleReposForRows, + detectedWorktreesByRepo, + filterRepoIds + }), + [detectedWorktreesByRepo, filterRepoIds, visibleReposForRows] + ) + + const setImportedWorktreeCardState = useCallback( + (projectId: string, state: ImportedWorktreeCardActionState | null) => { + setImportedWorktreeCardActionState((previous) => { + const next = new Map(previous) + if (state) { + next.set(projectId, state) + } else { + next.delete(projectId) + } + return next + }) + }, + [] + ) + + const handleShowImportedWorktrees = useCallback( + async (projectId: string) => { + await showImportedWorktreesCard({ + projectId, + forceVisible: importedWorktreeCardActionState.get(projectId)?.forceVisible === true, + updateRepo, + fetchWorktrees, + setCardState: setImportedWorktreeCardState + }) + }, + [fetchWorktrees, importedWorktreeCardActionState, setImportedWorktreeCardState, updateRepo] + ) + + const handleKeepImportedWorktreesHidden = useCallback( + async (projectId: string) => { + const repo = repos.find((candidate) => candidate.id === projectId) + let detected = detectedWorktreesByRepo[projectId] + // Why: baseline seeding needs authoritative hidden paths, so don't dismiss on a stale snapshot. + if (detected?.authoritative !== true) { + const refreshed = await fetchWorktrees(projectId, { requireAuthoritative: true }) + if (!refreshed) { + setImportedWorktreeCardState(projectId, { + pending: false, + error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR + }) + return + } + detected = useAppStore.getState().detectedWorktreesByRepo[projectId] + } + if (detected?.authoritative !== true) { + setImportedWorktreeCardState(projectId, { + pending: false, + error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR + }) + return + } + await keepImportedWorktreesHiddenCard({ + projectId, + updateRepo, + setCardState: setImportedWorktreeCardState, + hiddenWorktreePaths: getHiddenImportedWorktrees(detected).map((worktree) => worktree.path), + existingBaselinePaths: repo?.externalWorktreeInboxBaselinePaths + }) + }, + [detectedWorktreesByRepo, fetchWorktrees, repos, setImportedWorktreeCardState, updateRepo] + ) + + const setNewExternalWorktreeInboxState = useCallback( + (projectId: string, state: NewExternalWorktreesInboxActionState | null) => { + setNewExternalWorktreeInboxActionState((previous) => { + const next = new Map(previous) + if (state) { + next.set(projectId, state) + } else { + next.delete(projectId) + } + return next + }) + }, + [] + ) + + const handleOpenSuppressExternalWorktreeInbox = useCallback((projectId: string) => { + setSuppressExternalWorktreeInboxRepoId(projectId) + }, []) + + const handleConfirmSuppressExternalWorktreeInbox = useCallback(async () => { + if (!suppressExternalWorktreeInboxRepoId) { + return + } + const projectId = suppressExternalWorktreeInboxRepoId + const repo = repos.find((candidate) => candidate.id === projectId) + if (!repo) { + setSuppressExternalWorktreeInboxRepoId(null) + return + } + const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? [] + const suppressed = await suppressNewExternalWorktreeInbox({ + projectId, + repo, + worktreePaths: inboxWorktrees.map((worktree) => worktree.path), + updateRepo, + setInboxState: setNewExternalWorktreeInboxState + }) + if (suppressed) { + setSuppressExternalWorktreeInboxRepoId(null) + } + }, [ + newExternalWorktreesInboxByRepo, + repos, + setNewExternalWorktreeInboxState, + suppressExternalWorktreeInboxRepoId, + updateRepo + ]) + + return { + importedWorktreeCardActionState, + newExternalWorktreeInboxActionState, + importedWorktreesByRepo, + newExternalWorktreesInboxByRepo, + suppressExternalWorktreeInboxRepoId, + setSuppressExternalWorktreeInboxRepoId, + handleShowImportedWorktrees, + handleKeepImportedWorktreesHidden, + handleOpenSuppressExternalWorktreeInbox, + handleConfirmSuppressExternalWorktreeInbox + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts new file mode 100644 index 00000000000..0a0b4cea44a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts @@ -0,0 +1,69 @@ +import { useMemo } from 'react' +import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../../shared/execution-host' +import type { SidebarWorktreeFilters } from './use-sidebar-worktree-filters' +import { filterFolderWorkspacesFromOtherDevices } from '../workspace-creator-visibility' +import { + filterFolderWorkspacesForVisibleHosts, + filterProjectGroupsForVisibleHosts, + getVisibleSidebarHostIdSet +} from '../worktree-list-host-filtering' + +// Narrows repos, project groups, and folder workspaces to the hosts (and devices) the +// current host filter admits. +export function useSidebarHostVisibleScope(args: { + filterState: SidebarWorktreeFilters['filterState'] + defaultHostId: ExecutionHostId + repos: readonly Repo[] + projectGroups: readonly ProjectGroup[] + folderWorkspaces: readonly FolderWorkspace[] + pairedDeviceIdsByEnvironment: Parameters[1] +}) { + const { filterState, defaultHostId, repos, projectGroups, folderWorkspaces } = args + const { visibleWorkspaceHostIds, workspaceHostScope, hideWorkspacesFromOtherDevices } = + filterState + const visibleHostIdSet = useMemo( + () => getVisibleSidebarHostIdSet(visibleWorkspaceHostIds, workspaceHostScope), + [visibleWorkspaceHostIds, workspaceHostScope] + ) + const visibleReposForRows = useMemo(() => { + if (!visibleHostIdSet) { + return repos + } + return repos.filter((repo) => { + const hostId = + repo.connectionId || repo.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId + return visibleHostIdSet.has(hostId) + }) + }, [defaultHostId, repos, visibleHostIdSet]) + const visibleProjectGroupsForRows = useMemo( + () => filterProjectGroupsForVisibleHosts(projectGroups, visibleHostIdSet, defaultHostId), + [defaultHostId, projectGroups, visibleHostIdSet] + ) + const visibleFolderWorkspacesForRows = useMemo(() => { + const hostVisibleWorkspaces = filterFolderWorkspacesForVisibleHosts( + folderWorkspaces, + projectGroups, + visibleHostIdSet, + defaultHostId + ) + if (!hideWorkspacesFromOtherDevices) { + return hostVisibleWorkspaces + } + return filterFolderWorkspacesFromOtherDevices( + hostVisibleWorkspaces, + args.pairedDeviceIdsByEnvironment + ) + }, [ + args.pairedDeviceIdsByEnvironment, + defaultHostId, + folderWorkspaces, + hideWorkspacesFromOtherDevices, + projectGroups, + visibleHostIdSet + ]) + + return { visibleReposForRows, visibleProjectGroupsForRows, visibleFolderWorkspacesForRows } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-highlight.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-highlight.ts new file mode 100644 index 00000000000..82811c7cd4a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-highlight.ts @@ -0,0 +1,63 @@ +import { useCallback, useRef, useState } from 'react' + +export type SidebarRevealHighlight = ReturnType + +// Owns the frames a reveal schedules and the short glow it leaves on the revealed row. +export function useSidebarRevealHighlight() { + const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) + const pendingRevealFrameIdsRef = useRef>(new Set()) + const revealHighlightFrameIdRef = useRef(null) + const revealHighlightTimeoutRef = useRef(null) + + const cancelPendingRevealFrames = useCallback(() => { + for (const frameId of pendingRevealFrameIdsRef.current) { + window.cancelAnimationFrame(frameId) + } + pendingRevealFrameIdsRef.current.clear() + }, []) + const schedulePendingRevealFrame = useCallback((callback: FrameRequestCallback) => { + const frameId = window.requestAnimationFrame((time) => { + pendingRevealFrameIdsRef.current.delete(frameId) + callback(time) + }) + pendingRevealFrameIdsRef.current.add(frameId) + }, []) + const clearRevealHighlightFrame = useCallback(() => { + if (revealHighlightFrameIdRef.current !== null) { + window.cancelAnimationFrame(revealHighlightFrameIdRef.current) + revealHighlightFrameIdRef.current = null + } + }, []) + const clearRevealHighlightTimeout = useCallback(() => { + if (revealHighlightTimeoutRef.current !== null) { + window.clearTimeout(revealHighlightTimeoutRef.current) + revealHighlightTimeoutRef.current = null + } + }, []) + const flashRevealedRow = useCallback( + (rowKey: string) => { + clearRevealHighlightTimeout() + clearRevealHighlightFrame() + // Why: clear before set restarts the CSS glow when revealing the same row repeatedly. + setHighlightedRevealRowKey(null) + revealHighlightFrameIdRef.current = window.requestAnimationFrame(() => { + revealHighlightFrameIdRef.current = null + setHighlightedRevealRowKey(rowKey) + revealHighlightTimeoutRef.current = window.setTimeout(() => { + revealHighlightTimeoutRef.current = null + setHighlightedRevealRowKey(null) + }, 1500) + }) + }, + [clearRevealHighlightFrame, clearRevealHighlightTimeout] + ) + + return { + highlightedRevealRowKey, + cancelPendingRevealFrames, + schedulePendingRevealFrame, + clearRevealHighlightFrame, + clearRevealHighlightTimeout, + flashRevealedRow + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-requests.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-requests.ts new file mode 100644 index 00000000000..4737a7fc60a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-requests.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect } from 'react' +import { useAppStore } from '@/store' +import { + SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, + type ScrollToCurrentWorkspaceRevealRequestDetail +} from '@/lib/scroll-to-current-workspace-status' +import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeGroupBy } from '../worktree-list-groups' +import { getKnownSidebarWorktreeById } from '../worktree-list-folder-reveal' + +// Turns a "show me the current workspace" request into whatever the sidebar must change +// first — grouping mode, active filters — before the viewport can scroll to it. +export function useSidebarRevealRequests(args: { + groupBy: WorktreeGroupBy + renderedSidebarRowKeys: ReadonlySet + renderedWorktreeIds: readonly string[] + currentSidebarWorktreeId: string | null + worktreeMap: Map + folderWorkspaces: readonly FolderWorkspace[] + hasFilters: boolean + clearFilters: () => void +}): void { + const { + groupBy, + renderedSidebarRowKeys, + renderedWorktreeIds, + currentSidebarWorktreeId, + worktreeMap, + folderWorkspaces, + hasFilters, + clearFilters + } = args + const setGroupBy = useAppStore((s) => s.setGroupBy) + const pendingRevealSidebarRow = useAppStore((s) => s.pendingRevealSidebarRow) + const revealSidebarRow = useAppStore((s) => s.revealSidebarRow) + const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) + + useEffect(() => { + if (!pendingRevealSidebarRow) { + return + } + const rowKey = pendingRevealSidebarRow.rowKey + const isProjectHeaderTarget = + rowKey.startsWith('project-group:') || + rowKey.startsWith('project:') || + rowKey.startsWith('repo:') + if (isProjectHeaderTarget && groupBy !== 'repo') { + setGroupBy('repo') + return + } + if (!renderedSidebarRowKeys.has(rowKey) && hasFilters) { + clearFilters() + } + }, [ + clearFilters, + groupBy, + hasFilters, + pendingRevealSidebarRow, + renderedSidebarRowKeys, + setGroupBy + ]) + + const handleRevealCurrentWorkspaceRequest = useCallback( + (event: Event) => { + const detail = + event instanceof CustomEvent + ? (event.detail as ScrollToCurrentWorkspaceRevealRequestDetail | undefined) + : undefined + if (detail?.target?.type === 'sidebar-row') { + const sidebarDetail = detail as Extract< + ScrollToCurrentWorkspaceRevealRequestDetail, + { target: { type: 'sidebar-row' } } + > + revealSidebarRow(detail.target.rowKey, { + behavior: 'smooth', + highlight: sidebarDetail.highlight !== false + }) + return + } + if (!currentSidebarWorktreeId) { + return + } + const activeWorktree = getKnownSidebarWorktreeById( + currentSidebarWorktreeId, + worktreeMap, + folderWorkspaces + ) + if (!activeWorktree || activeWorktree.isArchived) { + return + } + if (!renderedWorktreeIds.includes(currentSidebarWorktreeId)) { + // Why: the reveal action must show the current workspace, so relax filters that hide it first. + clearFilters() + } + revealWorktreeInSidebar(currentSidebarWorktreeId, { + behavior: 'smooth', + highlight: true, + beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true + }) + }, + [ + clearFilters, + currentSidebarWorktreeId, + folderWorkspaces, + revealSidebarRow, + renderedWorktreeIds, + revealWorktreeInSidebar, + worktreeMap + ] + ) + + useEffect(() => { + window.addEventListener( + SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, + handleRevealCurrentWorkspaceRequest + ) + return () => { + window.removeEventListener( + SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, + handleRevealCurrentWorkspaceRequest + ) + } + }, [handleRevealCurrentWorkspaceRequest]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts new file mode 100644 index 00000000000..9b139e88719 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts @@ -0,0 +1,260 @@ +import { useCallback, useMemo, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { ProjectOrderBy } from '../../../../../shared/ui-chrome-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import { getHostDisplayLabelOverrides } from '../../../../../shared/host-setting-overrides' +import { + buildRows, + type PinnedWorktreeDisplayPolicy, + type ProjectGroupingModel, + type Row, + type WorktreeGroupBy +} from '../worktree-list-groups' +import { getLogicalRepoOrderRankById } from '../project-header-drop' +import { getEmptyProjectPlaceholderRepoIds } from '../empty-project-placeholder-repos' +import { addHostSectionRows } from '../host-section-rows' +import { orderHostSectionOptions } from '../host-section-order' +import { buildSidebarHostOptions } from '../sidebar-host-options' + +type SectionRowsArgs = { + groupBy: WorktreeGroupBy + projectOrderBy: ProjectOrderBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + defaultHostId: ExecutionHostId + worktrees: Worktree[] + repos: readonly Repo[] + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + prCache: AppState['prCache'] | null + settings: AppState['settings'] + workspaceStatuses: readonly WorkspaceStatusDefinition[] + effectiveCollapsedGroups: Set + projectGrouping: ProjectGroupingModel + visibleReposForRows: readonly Repo[] + visibleProjectGroupsForRows: readonly ProjectGroup[] + visibleFolderWorkspacesForRows: readonly FolderWorkspace[] + importedWorktreesByRepo: Parameters[14] + newExternalWorktreesInboxByRepo: Parameters[15] + filterRepoIds: readonly string[] + visibleWorkspaceHostIds: readonly ExecutionHostId[] | null + workspaceHostScope: AppState['workspaceHostScope'] +} + +function collectRenderedSidebarRowKeys(sectionRows: ReturnType) { + const keys = new Set() + for (const row of sectionRows) { + if (row.type === 'header') { + keys.add(row.key) + } else if (row.type === 'item') { + keys.add(row.rowKey) + } else if (row.type === 'folder-workspace') { + keys.add(folderWorkspaceKey(row.folderWorkspace.id)) + } else if (row.type === 'pending-creation') { + keys.add(`pending:${row.creationId}`) + } else if (row.type === 'imported-worktrees-card') { + keys.add(row.key) + } else if (row.type === 'new-external-worktrees-inbox') { + keys.add(row.key) + } + } + return keys +} + +// Builds the full sidebar row model: grouped worktree rows first, then the host-section +// tier wrapped around them. +export function useSidebarSectionRows(args: SectionRowsArgs) { + const { repos, worktrees, repoMap, effectiveCollapsedGroups, defaultHostId } = args + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder) + const setWorkspaceHostOrder = useAppStore((s) => s.setWorkspaceHostOrder) + + // Why: manual header order is bound to state.repos; Recent/Smart derive order from the sorted worktree stream. + const repoOrder = useMemo( + () => getLogicalRepoOrderRankById(repos.map((repo) => repo.id)), + [repos] + ) + const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) + const placeholderRepoIds = useMemo( + () => + getEmptyProjectPlaceholderRepoIds({ + groupBy: args.groupBy, + repos: args.visibleReposForRows, + worktreesByRepo, + visibleWorktrees: worktrees, + filterRepoIds: args.filterRepoIds + }), + [args.filterRepoIds, args.groupBy, args.visibleReposForRows, worktrees, worktreesByRepo] + ) + + // Why: subscribe on a flat key array (useShallow) so progress ticks don't rebuild the whole row model. + // Split on first space — creationId is a UUID (no space) so a space-containing repoId stays intact. + const pendingCreationKeys = useAppStore( + useShallow((s) => + Object.values(s.pendingWorktreeCreations ?? {}).map( + (creation) => `${creation.creationId} ${creation.request.repoId}` + ) + ) + ) + const pendingCreations = useMemo( + () => + pendingCreationKeys.map((key) => { + const separator = key.indexOf(' ') + return { creationId: key.slice(0, separator), repoId: key.slice(separator + 1) } + }), + [pendingCreationKeys] + ) + const hostLabelOverrides = useMemo( + () => getHostDisplayLabelOverrides(args.settings), + [args.settings] + ) + const hostOptions = useMemo( + () => + buildSidebarHostOptions({ + repos, + sshTargetLabels, + sshConnectionStates, + settings: args.settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }), + [ + repos, + sshTargetLabels, + sshConnectionStates, + args.settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) + const hostLabelById = useMemo( + () => new Map(hostOptions.map((host) => [host.id, host.label])), + [hostOptions] + ) + + const rows: Row[] = useMemo( + () => + buildRows( + args.groupBy, + worktrees, + repoMap, + args.prCache, + effectiveCollapsedGroups, + repoOrder, + args.workspaceStatuses, + args.projectOrderBy, + args.worktreeLineageById, + args.worktreeMap, + true, + args.settings, + args.visibleProjectGroupsForRows, + placeholderRepoIds, + args.importedWorktreesByRepo, + args.newExternalWorktreesInboxByRepo, + pendingCreations, + args.projectGrouping, + args.visibleFolderWorkspacesForRows, + hostLabelById, + defaultHostId, + args.pinnedDisplayPolicy + ), + [ + args.groupBy, + worktrees, + repoMap, + args.prCache, + effectiveCollapsedGroups, + defaultHostId, + repoOrder, + args.workspaceStatuses, + args.projectOrderBy, + args.worktreeLineageById, + args.worktreeMap, + args.settings, + args.projectGrouping, + args.visibleProjectGroupsForRows, + args.visibleFolderWorkspacesForRows, + placeholderRepoIds, + args.importedWorktreesByRepo, + args.newExternalWorktreesInboxByRepo, + pendingCreations, + hostLabelById, + args.pinnedDisplayPolicy + ] + ) + const orderedHostOptions = useMemo( + () => orderHostSectionOptions(hostOptions, workspaceHostOrder), + [hostOptions, workspaceHostOrder] + ) + const [hostDragActive, setHostDragActive] = useState(false) + const handleReorderHostSections = useCallback( + (orderedVisibleHostIds: ExecutionHostId[]) => { + const visibleHostIds = new Set(orderedVisibleHostIds) + const hostOptionIds = orderedHostOptions.map((host) => host.id) + const knownHostIds = new Set(hostOptionIds) + const nextOrder: ExecutionHostId[] = [...orderedVisibleHostIds] + const seen = new Set(nextOrder) + // Why: dragging only covers rendered hosts; keep non-rendered SSH/runtime hosts in the saved order so they return in place. + for (const hostId of [...workspaceHostOrder, ...hostOptionIds]) { + if (!knownHostIds.has(hostId) || visibleHostIds.has(hostId) || seen.has(hostId)) { + continue + } + nextOrder.push(hostId) + seen.add(hostId) + } + setWorkspaceHostOrder(nextOrder) + }, + [orderedHostOptions, setWorkspaceHostOrder, workspaceHostOrder] + ) + const sectionRows = useMemo( + () => + addHostSectionRows({ + rows, + hostOptions: orderedHostOptions, + workspaceHostScope: args.workspaceHostScope, + visibleWorkspaceHostIds: args.visibleWorkspaceHostIds, + defaultHostId, + collapsedHostKeys: effectiveCollapsedGroups, + forceCollapseHosts: hostDragActive, + // Why: projects/workspaces are the primary sidebar object; host sections are only an explicit host-filter view. + preferProjectGrouping: true + }), + [ + args.visibleWorkspaceHostIds, + args.workspaceHostScope, + defaultHostId, + effectiveCollapsedGroups, + hostDragActive, + orderedHostOptions, + rows + ] + ) + const renderedSidebarRowKeys = useMemo( + () => collectRenderedSidebarRowKeys(sectionRows), + [sectionRows] + ) + + return { + rows, + sectionRows, + renderedSidebarRowKeys, + allRepoIds, + placeholderRepoIds, + handleReorderHostSections, + setHostDragActive + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts new file mode 100644 index 00000000000..7f59d344e6d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts @@ -0,0 +1,106 @@ +import { useCallback, useMemo } from 'react' +import { useAppStore } from '@/store' +import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../../shared/constants' +import { computeClearFilterActions, sidebarHasActiveFilters } from '../visible-worktrees' + +export type SidebarWorktreeFilters = ReturnType + +// Every sidebar filter, plus the single escape hatch that resets all of them. +export function useSidebarWorktreeFilters() { + const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) + const filterRepoIds = useAppStore((s) => s.filterRepoIds) + const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) + const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) + const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) + const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) + + const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) + const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) + const setHideAutomationGeneratedWorkspaces = useAppStore( + (s) => s.setHideAutomationGeneratedWorkspaces + ) + const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) + const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const setHideWorkspacesFromOtherDevices = useAppStore((s) => s.setHideWorkspacesFromOtherDevices) + const setAlwaysShowDefaultBranchWorkspace = useAppStore( + (s) => s.setAlwaysShowDefaultBranchWorkspace + ) + const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) + const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) + + // Why: count hideDefaultBranchWorkspace as a filter so the Clear Filters escape hatch stays reachable when it alone empties the list. + const filterState = useMemo( + () => ({ + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace, + visibleWorkspaceHostIds, + workspaceHostScope + }), + [ + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace, + visibleWorkspaceHostIds, + workspaceHostScope + ] + ) + + const clearFilters = useCallback(() => { + const actions = computeClearFilterActions(filterState) + if (actions.resetShowSleepingWorkspaces) { + setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES) + } + if (actions.resetFilterRepoIds) { + setFilterRepoIds([]) + } + if (actions.resetHideDefaultBranchWorkspace) { + setHideDefaultBranchWorkspace(false) + } + if (actions.resetHideAutomationGeneratedWorkspaces) { + setHideAutomationGeneratedWorkspaces(false) + } + if (actions.resetHideCliCreatedWorkspaces) { + setHideCliCreatedWorkspaces(false) + } + if (actions.resetHideDetachedHeadWorkspaces) { + setHideDetachedHeadWorkspaces(false) + } + if (actions.resetHideWorkspacesFromOtherDevices) { + setHideWorkspacesFromOtherDevices(false) + } + if (actions.resetAlwaysShowDefaultBranchWorkspace) { + setAlwaysShowDefaultBranchWorkspace(true) + } + if (actions.resetVisibleWorkspaceHostIds) { + setVisibleWorkspaceHostIds(null) + } + }, [ + setShowSleepingWorkspaces, + setFilterRepoIds, + setHideDefaultBranchWorkspace, + setHideAutomationGeneratedWorkspaces, + setHideCliCreatedWorkspaces, + setHideDetachedHeadWorkspaces, + setHideWorkspacesFromOtherDevices, + setAlwaysShowDefaultBranchWorkspace, + setVisibleWorkspaceHostIds, + filterState + ]) + + return { filterState, hasFilters: sidebarHasActiveFilters(filterState), clearFilters } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts new file mode 100644 index 00000000000..426385c6063 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' +import type React from 'react' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { HostSectionRow } from '../host-section-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import { getRenderedWorktreesInSidebarOrder } from '../worktree-sidebar-row-preference' +import { setVisibleWorktreeIds } from '../visible-worktrees' +import { + areWorktreeSelectionsEqual, + getWorktreeSelectionIntent, + pruneWorktreeSelection, + updateWorktreeSelection +} from '../worktree-multi-selection' +import { useReusedArrayIdentity } from './use-reused-array-identity' + +function uniqueWorktreeIds(ids: readonly string[]): string[] { + return Array.from(new Set(ids)) +} + +// Multi-select over the rows the sidebar actually rendered, so gestures, context menus, and +// the Cmd+1–9 shortcut cache all agree on one order. +export function useSidebarWorktreeSelection(args: { + sectionRows: HostSectionRow[] + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +}) { + const { sectionRows, pinnedDisplayPolicy } = args + // Why: derive order from the built rows, not the flat worktrees array, so Cmd+1–9 match visual positions when grouping reorders cards. + const renderedWorktrees = useMemo( + () => getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy), + [pinnedDisplayPolicy, sectionRows] + ) + // Why: order-preserving sectionRows rebuilds must not give this array a new + // identity — updateSelectionForGesture depends on it, and a fresh identity + // there defeats React.memo bail-out for every WorktreeCard on epoch bumps. + const renderedWorktreeIds = useReusedArrayIdentity( + useMemo( + () => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)), + [renderedWorktrees] + ) + ) + const [selectedWorktreeIds, setSelectedWorktreeIds] = useState>(new Set()) + const [selectionAnchorId, setSelectionAnchorId] = useState(null) + + const prunedSelection = pruneWorktreeSelection( + selectedWorktreeIds, + selectionAnchorId, + renderedWorktreeIds + ) + // Why: filters/grouping can hide selected cards; prune during render so nothing sees stale ids for unrendered worktrees. + if (!areWorktreeSelectionsEqual(selectedWorktreeIds, prunedSelection.selectedIds)) { + setSelectedWorktreeIds(prunedSelection.selectedIds) + } + if (selectionAnchorId !== prunedSelection.anchorId) { + setSelectionAnchorId(prunedSelection.anchorId) + } + + // Why identity reuse: the empty/unchanged-selection case must keep one array + // identity — selectForContextMenu and both drag-start handlers depend on + // this array, and card memo bail-out depends on those staying stable. + const selectedWorktrees = useReusedArrayIdentity( + useMemo(() => { + if (selectedWorktreeIds.size === 0) { + return [] + } + const selected = new Map() + for (const worktree of renderedWorktrees) { + if (selectedWorktreeIds.has(worktree.id) && !selected.has(worktree.id)) { + selected.set(worktree.id, worktree) + } + } + return Array.from(selected.values()) + }, [renderedWorktrees, selectedWorktreeIds]) + ) + + useEffect(() => { + if (selectedWorktreeIds.size === 0) { + return + } + + const clearSelectionOutsideSidebar = (event: PointerEvent): void => { + const target = event.target + const sidebarContainer = document.querySelector('[data-worktree-sidebar-container]') + if (target instanceof Node && sidebarContainer?.contains(target)) { + return + } + setSelectedWorktreeIds(new Set()) + setSelectionAnchorId(null) + } + + document.addEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) + return () => { + document.removeEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) + } + }, [selectedWorktreeIds.size]) + + const updateSelectionForGesture = useCallback( + (event: React.MouseEvent, worktreeId: string): boolean => { + const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac')) + const result = updateWorktreeSelection({ + visibleIds: renderedWorktreeIds, + previousSelectedIds: selectedWorktreeIds, + previousAnchorId: selectionAnchorId, + targetId: worktreeId, + intent + }) + setSelectedWorktreeIds(result.selectedIds) + setSelectionAnchorId(result.anchorId) + // Plain click navigates; modifier gestures are selection-only so a batch can build without switching away. + return intent !== 'replace' + }, + [renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId] + ) + + const selectForContextMenu = useCallback( + (_event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => { + if (selectedWorktreeIds.has(worktree.id) && selectedWorktreeIds.size > 1) { + return selectedWorktrees + } + setSelectedWorktreeIds(new Set([worktree.id])) + setSelectionAnchorId(worktree.id) + return [worktree] + }, + [selectedWorktreeIds, selectedWorktrees] + ) + + // Why layout effect: the Cmd/Ctrl+1–9 handler can fire right after commit; publishing after paint would leave the shortcut cache stale. + useLayoutEffect(() => { + setVisibleWorktreeIds(renderedWorktreeIds) + // Why null, not []: [] is a real rendered order (all collapsed/filtered); null tells shortcuts the list is unmounted. + return () => setVisibleWorktreeIds(null) + }, [renderedWorktreeIds]) + + return { + renderedWorktreeIds, + selectedWorktreeIds, + selectedWorktrees, + updateSelectionForGesture, + selectForContextMenu + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts new file mode 100644 index 00000000000..64b2e426abf --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts @@ -0,0 +1,207 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { getAllWorktreesFromState } from '@/store/selectors' +import { track } from '@/lib/telemetry' +import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import { persistWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-persistence' +import type { Repo } from '../../../../../shared/repo-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import { buildWorktreeComparator, compareWorktreeSortLabel, type SortBy } from '../smart-sort' +import { + buildAttentionByWorktree, + hasFreshAttributedAgentStatus, + type SmartClass, + type WorktreeAttention +} from '../smart-attention' +import { useReusedArrayIdentity } from './use-reused-array-identity' +import { SORT_SETTLE_MS } from './worktree-sidebar-scroll-tuning' + +function trackSmartClassDistribution(attention: ReadonlyMap): void { + let class1 = 0 + let class2 = 0 + let class3 = 0 + let class4 = 0 + for (const info of attention.values()) { + if (info.cls === 1) { + class1++ + } else if (info.cls === 2) { + class2++ + } else if (info.cls === 3) { + class3++ + } else { + class4++ + } + } + track('smart_sort_class_distribution', { + class_1: class1, + class_2: class2, + class_3: class3, + class_4: class4, + total_worktrees: attention.size + }) +} + +// Why debounce: scores are time-decaying, so recomputing on every sortEpoch bump makes worktrees jump; settle to coalesce. +// Structural changes (add/remove) bypass the debounce so a new worktree appears at its sorted position immediately. +function useDebouncedSortEpoch(worktreeCount: number, sortBy: SortBy): number { + const sortEpoch = useAppStore((s) => s.sortEpoch) + const [debouncedSortEpoch, setDebouncedSortEpoch] = useState(sortEpoch) + const prevWorktreeCountRef = useRef(worktreeCount) + useEffect(() => { + if (debouncedSortEpoch === sortEpoch) { + return + } + + const structuralChange = worktreeCount !== prevWorktreeCountRef.current + prevWorktreeCountRef.current = worktreeCount + + // Why: manual drag/drop is direct manipulation; the settle-window delay would make a successful drop look broken. + if (structuralChange || sortBy === 'manual') { + setDebouncedSortEpoch(sortEpoch) + return + } + + const timer = setTimeout(() => setDebouncedSortEpoch(sortEpoch), SORT_SETTLE_MS) + return () => clearTimeout(timer) + }, [sortEpoch, debouncedSortEpoch, worktreeCount, sortBy]) + return debouncedSortEpoch +} + +// ── Stable sort order ────────────────────────────────────────── +// Why sortEpoch (not selection): selection side-effects (clearing isUnread, PR-cache refresh) must not reorder the sidebar under the user. +// Why useMemo not useEffect: order must be computed synchronously before the worktrees memo reads it. +export function useSidebarWorktreeSortOrder(args: { + allWorktrees: readonly Worktree[] + repoMap: Map + sortBy: SortBy +}): string[] { + const { allWorktrees, repoMap, sortBy } = args + // Non-archived count — detects structural changes (add/remove) so the debounce below can apply immediately. + const worktreeCount = useMemo(() => { + let count = 0 + for (const worktree of allWorktrees) { + if (!worktree.isArchived) { + count++ + } + } + return count + }, [allWorktrees]) + const debouncedSortEpoch = useDebouncedSortEpoch(worktreeCount, sortBy) + + // Why a latching ref: a live signal makes Smart authoritative for the session, even after that activity ends. + const sessionHasHadLiveSmartSignal = useRef(false) + // Why a ref alongside the memo: telemetry effects need the last attention map without re-reading store state. + const lastAttentionByWorktreeRef = useRef | null>(null) + + const recomputedSortedIds = useMemo(() => { + const state = useAppStore.getState() + const nonArchivedWorktrees = getAllWorktreesFromState(state).filter( + (worktree) => !worktree.isArchived + ) + const now = Date.now() + + // Why cold-start detection: agent-status hydrates async, so the warm comparator would collapse all to Class 4; keep the persisted order until a live signal appears. + if (sortBy === 'smart' && !sessionHasHadLiveSmartSignal.current) { + // Why tabHasLivePty over tab.ptyId: slept terminals keep tab.ptyId as a wake hint, so it'd falsely keep cold-start ordering off. + const hasAnyLivePty = Object.values(state.tabsByWorktree) + .flat() + .some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) + if ( + hasAnyLivePty || + hasFreshAttributedAgentStatus(state.agentStatusByPaneKey, now, state.tabsByWorktree) + ) { + sessionHasHadLiveSmartSignal.current = true + } else { + nonArchivedWorktrees.sort( + (a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b) + ) + lastAttentionByWorktreeRef.current = null + return nonArchivedWorktrees.map((w) => w.id) + } + } + + // Why precompute: hot sort — build the attention map once so the O(N log N) comparator does O(1) lookups. + const attentionByWorktree = + sortBy === 'smart' + ? buildAttentionByWorktree( + nonArchivedWorktrees, + state.tabsByWorktree, + state.agentStatusByPaneKey, + state.runtimePaneTitlesByTabId, + state.ptyIdsByTabId, + now, + state.migrationUnsupportedByPtyId, + state.terminalLayoutsByTabId + ) + : new Map() + lastAttentionByWorktreeRef.current = sortBy === 'smart' ? attentionByWorktree : null + nonArchivedWorktrees.sort(buildWorktreeComparator(sortBy, repoMap, now, attentionByWorktree)) + return nonArchivedWorktrees.map((w) => w.id) + // debouncedSortEpoch is an intentional trigger not read in the memo; its change (debounced) signals a recompute. + // oxlint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSortEpoch, repoMap, sortBy]) + // Why: stable ID order prevents rank-only refreshes from echoing an unchanged snapshot. + const sortedIds = useReusedArrayIdentity(recomputedSortedIds) + + // Why a ref of prior class: fire class_1_promotion only on transitions into Class 1, not every recompute that stays there. + const prevClassByWorktreeIdRef = useRef>(new Map()) + // Why gate the first observation: an empty prev-class map makes every existing Class-1 worktree look freshly promoted; treat the first pass as a silent baseline. + const hasObservedSmartOnceRef = useRef(false) + + useEffect(() => { + const attention = lastAttentionByWorktreeRef.current + if (sortBy !== 'smart' || !attention) { + // Why reset: leaving Smart drops the prior-class map (and first-observation gate) so re-entry doesn't fire stale promotions. + prevClassByWorktreeIdRef.current = new Map() + hasObservedSmartOnceRef.current = false + return + } + const next = new Map() + const isFirstObservation = !hasObservedSmartOnceRef.current + for (const [worktreeId, info] of attention) { + const prev = prevClassByWorktreeIdRef.current.get(worktreeId) + if (!isFirstObservation && info.cls === 1 && prev !== 1 && info.cause) { + track('smart_sort_class_1_promotion', { cause: info.cause }) + } + next.set(worktreeId, info.cls) + } + prevClassByWorktreeIdRef.current = next + hasObservedSmartOnceRef.current = true + }, [sortBy, recomputedSortedIds]) + + // Why retry on recomputation: Smart may activate before attention hydrates; fire once, then stay quiet until the user leaves Smart. + const hasTrackedSmartDistributionRef = useRef(false) + useEffect(() => { + if (sortBy !== 'smart') { + hasTrackedSmartDistributionRef.current = false + return + } + const attention = lastAttentionByWorktreeRef.current + if (hasTrackedSmartDistributionRef.current || !attention || attention.size === 0) { + return + } + trackSmartClassDistribution(attention) + hasTrackedSmartDistributionRef.current = true + }, [sortBy, recomputedSortedIds]) + + // Why fire on the transition: switching away from Smart is the signal; compare via ref so a round-trip doesn't double-fire. + const prevSortByRef = useRef(sortBy) + useEffect(() => { + const prev = prevSortByRef.current + prevSortByRef.current = sortBy + if (prev === 'smart' && sortBy === 'recent') { + track('smart_to_recent_switch', {}) + } + }, [sortBy]) + + // Why: only persist during live sessions so cold start reads the persisted order instead of overwriting it. + useEffect(() => { + if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadLiveSmartSignal.current) { + return + } + // Why: sortOrder lives in each host's worktreeMeta, so persist each host's ids on that host. + persistWorktreeSortOrderByHost(useAppStore.getState(), sortedIds) + }, [sortedIds, sortBy]) + + return sortedIds +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-virtual-row-measurement-sync.ts b/src/renderer/src/components/sidebar/worktree-list/use-virtual-row-measurement-sync.ts new file mode 100644 index 00000000000..79f0d53b429 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-virtual-row-measurement-sync.ts @@ -0,0 +1,116 @@ +import { useCallback, useLayoutEffect, useMemo } from 'react' +import type React from 'react' +import { useAppStore } from '@/store' +import { + useVirtualizedScrollAnchor, + type VirtualizedScrollAnchor +} from '@/hooks/useVirtualizedScrollAnchor' +import { + buildLineageRowRekeyMap, + getActiveStickyIndexesForScroll, + getRenderRowKey, + pruneStaleVirtualRowElementCache, + type RenderRow +} from '../worktree-list-virtual-rows' +import { getVirtualRowKey } from './virtual-row-dom-attributes' +import { countRecordKeysByReference } from './worktree-sidebar-scroll-tuning' +import type { WorktreeListVirtualizer } from './use-worktree-list-virtualizer' + +// Re-measures only rows whose DOM node still matches its virtual key, and publishes the +// sticky-header slots the render pass reads out of refs. +export function useVirtualRowMeasurementSync(args: { + renderRows: RenderRow[] + virtualization: WorktreeListVirtualizer + scrollRef: React.RefObject + scrollOffsetRef: React.MutableRefObject + scrollAnchorRef: React.MutableRefObject + hasDirectScrollInput: () => boolean + shouldSkipScrollAnchorRestore: () => boolean +}) { + const { + renderRows, + virtualization, + scrollRef, + scrollOffsetRef, + scrollAnchorRef, + hasDirectScrollInput, + shouldSkipScrollAnchorRestore + } = args + const { virtualizer, isCurrentVirtualRowElement } = virtualization + const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache)) + const issueCacheLen = useAppStore((s) => countRecordKeysByReference(s.issueCache)) + const renderRowKeySignature = useMemo( + () => renderRows.map(getRenderRowKey).join('\n'), + [renderRows] + ) + const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows]) + const lineageRowRekeys = useMemo(() => buildLineageRowRekeyMap(renderRows), [renderRows]) + const totalSize = virtualizer.getTotalSize() + const virtualItems = virtualizer.getVirtualItems() + const activeStickyIndexes = getActiveStickyIndexesForScroll({ + rows: renderRows, + rangeStartIndex: virtualization.stickyRangeStartIndexRef.current, + scrollOffset: virtualizer.scrollOffset ?? scrollOffsetRef.current, + stickyHeaderIndexes: virtualization.stickyHeaderIndexes, + virtualItems + }) + virtualization.activeStickyHeaderIndexRef.current = activeStickyIndexes.groupIndex + virtualization.activeStickyHostIndexRef.current = activeStickyIndexes.hostIndex + + const measureMountedRows = useCallback(() => { + virtualizer.elementsCache.forEach((element) => { + if (!isCurrentVirtualRowElement(element)) { + return + } + virtualizer.measureElement(element) + }) + }, [isCurrentVirtualRowElement, virtualizer]) + const measureVirtualRowElement = useCallback( + (element: HTMLDivElement | null) => { + if (!element) { + virtualizer.measureElement(null) + return + } + if (!isCurrentVirtualRowElement(element)) { + return + } + virtualizer.measureElement(element) + }, + [isCurrentVirtualRowElement, virtualizer] + ) + + useLayoutEffect(() => { + pruneStaleVirtualRowElementCache({ + activeRowKeys: activeRenderRowKeys, + virtualizer + }) + // Why: a stale retained element after delete/collapse measures 0px and corrupts the next slot; measure only key-matched rows. + measureMountedRows() + const frameId = window.requestAnimationFrame(measureMountedRows) + return () => window.cancelAnimationFrame(frameId) + }, [ + activeRenderRowKeys, + prCacheLen, + issueCacheLen, + measureMountedRows, + renderRowKeySignature, + virtualizer + ]) + + useVirtualizedScrollAnchor({ + anchorRef: scrollAnchorRef, + getItemElementKey: getVirtualRowKey, + getRowKey: getRenderRowKey, + itemElementSelector: '[data-worktree-virtual-row]', + rekeyedRowKeys: lineageRowRekeys, + rows: renderRows, + scrollElementRef: scrollRef, + scrollOffsetRef, + hasDirectScrollInput, + shouldSkipRestore: shouldSkipScrollAnchorRestore, + totalSize, + virtualizer + }) + + return { virtualItems, measureVirtualRowElement } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-visible-pr-refresh-reporting.ts b/src/renderer/src/components/sidebar/worktree-list/use-visible-pr-refresh-reporting.ts new file mode 100644 index 00000000000..ba6bafc0b5a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-visible-pr-refresh-reporting.ts @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from 'react' +import type React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import { useAppStore } from '@/store' +import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeGroupBy } from '../worktree-list-groups' +import type { RenderRow } from '../worktree-list-virtual-rows' +import type { WorktreeItemRow } from './render-row-item-rows' +import { installWorktreeVisibleRefreshVisibilityListener } from './worktree-visible-refresh-listener' + +const DOCUMENT_HIDDEN_KEY = '__document_hidden__' +const NOTHING_TO_TRACK_KEY = '__hidden__' + +// Reports which sidebar rows are on screen so the GitHub PR/CI coordinator can refresh +// exactly those, and no more. +export function useVisiblePrRefreshReporting(args: { + currentWorktreeId: string | null + worktreeMap: Map + groupBy: WorktreeGroupBy + newCardStyle: boolean + renderRows: RenderRow[] + virtualItems: readonly VirtualItem[] + scrollRef: React.RefObject +}): void { + const { + currentWorktreeId, + worktreeMap, + groupBy, + newCardStyle, + renderRows, + virtualItems, + scrollRef + } = args + const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) + const lastVisibleRefreshKeyRef = useRef('') + const reportVisibleGitHubPRRefreshCandidates = useAppStore( + (s) => s.reportVisibleGitHubPRRefreshCandidates + ) + const cardProps = useAppStore((s) => s.worktreeCardProperties) + const rightSidebarShowsPR = useAppStore((s) => rightSidebarShowsPullRequestData(s)) + const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) + const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration) + + useEffect( + () => + installWorktreeVisibleRefreshVisibilityListener(() => { + if (document.visibilityState !== 'visible') { + // Why: row identity may be unchanged after a hidden window; reset the key so PR/CI rows refresh. + lastVisibleRefreshKeyRef.current = DOCUMENT_HIDDEN_KEY + return + } + setDocumentVisibilityRevision((revision) => revision + 1) + }), + [] + ) + + useEffect(() => { + if (document.visibilityState !== 'visible') { + lastVisibleRefreshKeyRef.current = DOCUMENT_HIDDEN_KEY + return + } + const currentWorktree = currentWorktreeId ? (worktreeMap.get(currentWorktreeId) ?? null) : null + // Why: this reporter feeds the GitHub coordinator; GitLab-only MR panels refresh via hosted-review paths. + const sidebarWorktreeHasGitHubReview = + currentWorktree !== null && + ((currentWorktree.linkedGitLabMR ?? null) === null || + (currentWorktree.linkedPR ?? null) !== null) + const shouldTrackSidebarWorktree = rightSidebarShowsPR && sidebarWorktreeHasGitHubReview + const shouldTrackVisibleRows = + groupBy === 'pr-status' || + (newCardStyle + ? cardProps.includes('status') + : cardProps.includes('pr') || cardProps.includes('ci')) + if (!shouldTrackVisibleRows && !shouldTrackSidebarWorktree) { + if (lastVisibleRefreshKeyRef.current !== NOTHING_TO_TRACK_KEY) { + lastVisibleRefreshKeyRef.current = NOTHING_TO_TRACK_KEY + reportVisibleGitHubPRRefreshCandidates([], Date.now()) + } + return + } + const scrollEl = scrollRef.current + if (!scrollEl) { + return + } + const viewportTop = scrollEl.scrollTop + const viewportBottom = viewportTop + scrollEl.clientHeight + const visibleRows = virtualItems + .filter((item) => item.start < viewportBottom && item.end > viewportTop) + .map((item) => renderRows[item.index]) + .filter((row): row is WorktreeItemRow => row?.type === 'item') + .filter((row) => row.repo?.kind === 'git' && !row.worktree.isBare && row.worktree.branch) + const visibleWorktreeIds = new Set(visibleRows.map((row) => row.worktree.id)) + if ( + shouldTrackSidebarWorktree && + currentWorktree && + !currentWorktree.isBare && + currentWorktree.branch + ) { + visibleWorktreeIds.add(currentWorktree.id) + } + const visibleIdentity = visibleRows + .map((row) => `${row.worktree.id}:${row.worktree.branch}:${row.worktree.linkedPR ?? ''}`) + .join('|') + const sidebarIdentity = + shouldTrackSidebarWorktree && currentWorktree + ? `${currentWorktree.id}:${currentWorktree.branch}:${currentWorktree.linkedPR ?? ''}` + : '' + const key = `${visibleIdentity}:${sidebarIdentity}:${sshConnectedGeneration}:${prVisibleRefreshGeneration}:${cardProps.join(',')}` + if (!key || key === lastVisibleRefreshKeyRef.current) { + return + } + lastVisibleRefreshKeyRef.current = key + reportVisibleGitHubPRRefreshCandidates(Array.from(visibleWorktreeIds), Date.now()) + }, [ + cardProps, + currentWorktreeId, + documentVisibilityRevision, + groupBy, + renderRows, + reportVisibleGitHubPRRefreshCandidates, + prVisibleRefreshGeneration, + rightSidebarShowsPR, + scrollRef, + sshConnectedGeneration, + newCardStyle, + virtualItems, + worktreeMap + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts b/src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts new file mode 100644 index 00000000000..bd1f37fc708 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts @@ -0,0 +1,134 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' +import type { AppState } from '@/store/types' +import type { Repo } from '../../../../../shared/repo-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types' +import { getSettingsFocusedExecutionHostId } from '../../../../../shared/execution-host' +import { computeVisibleWorktreeIds } from '../visible-worktrees' +import { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + getPairedDeviceIdsByEnvironment +} from '../workspace-creator-visibility' +import { + getVisibleWorktreeBrowserActivityTabs, + getVisibleWorktreeTerminalActivityTabs +} from '../visible-worktree-activity-inputs' +import type { SortBy } from '../smart-sort' +import type { SidebarWorktreeFilters } from './use-sidebar-worktree-filters' +import { EMPTY_WORKTREE_ID_SET } from './stable-empty-store-slices' +import { useReusedArrayIdentity } from './use-reused-array-identity' + +// Applies every sidebar filter to the sorted id stream. Flatten/filter/sort goes through the +// shared utility so card order matches Cmd+1–9 numbering. +export function useVisibleSidebarWorktrees(args: { + filterState: SidebarWorktreeFilters['filterState'] + sortBy: SortBy + sortedIds: string[] + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + settings: AppState['settings'] + agentSendTargetWorktreeId: string | null +}) { + const { filterState, sortBy, sortedIds, repoMap, worktreeMap, worktreeLineageById, settings } = + args + const { + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace, + visibleWorkspaceHostIds, + workspaceHostScope + } = filterState + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0)) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const pairedDeviceIdsByEnvironment = useMemo( + () => + hideWorkspacesFromOtherDevices + ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + [hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId] + ) + + // Read tabsByWorktree when needed for filtering or sorting + const needsActivityMaps = !showSleepingWorkspaces || sortBy === 'smart' + const tabsByWorktree = useAppStore((s) => + needsActivityMaps ? getVisibleWorktreeTerminalActivityTabs(s.tabsByWorktree) : null + ) + const ptyIdsByTabId = useAppStore((s) => (needsActivityMaps ? s.ptyIdsByTabId : null)) + const browserTabsByWorktree = useAppStore((s) => + !showSleepingWorkspaces ? getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree) : null + ) + + const recomputedVisibleWorktrees = useMemo(() => { + void agentStatusEpoch + const ids = computeVisibleWorktreeIds(worktreesByRepo, sortedIds, { + filterRepoIds, + showSleepingWorkspaces, + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + // Why snapshot on agentStatusEpoch: update membership immediately without repainting on every hook ping. + worktreeIdsWithLiveAgent: showSleepingWorkspaces + ? EMPTY_WORKTREE_ID_SET + : getWorktreeIdsWithLiveAgent( + useAppStore.getState().agentStatusByPaneKey, + tabsByWorktree, + Date.now() + ), + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, + alwaysShowDefaultBranchWorkspace, + repoMap, + workspaceHostScope, + visibleWorkspaceHostIds, + defaultHostId: getSettingsFocusedExecutionHostId(settings), + worktreeLineageById, + forcedVisibleWorktreeIds: args.agentSendTargetWorktreeId + ? [args.agentSendTargetWorktreeId] + : undefined + }) + return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null) + }, [ + args.agentSendTargetWorktreeId, + agentStatusEpoch, + filterRepoIds, + showSleepingWorkspaces, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace, + workspaceHostScope, + visibleWorkspaceHostIds, + settings, + repoMap, + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + sortedIds, + worktreeMap, + worktreeLineageById, + worktreesByRepo, + pairedDeviceIdsByEnvironment + ]) + // Why: agentStatusEpoch bumps recompute this memo even when membership and + // order are unchanged; keeping the previous identity stops the whole + // rows/sectionRows/renderedWorktrees chain from churning per epoch. + const visibleWorktrees = useReusedArrayIdentity(recomputedVisibleWorktrees) + + return { visibleWorktrees, pairedDeviceIdsByEnvironment } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-workspace-status-row-drag.ts b/src/renderer/src/components/sidebar/worktree-list/use-workspace-status-row-drag.ts new file mode 100644 index 00000000000..cb45d99a93f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-workspace-status-row-drag.ts @@ -0,0 +1,147 @@ +import { useCallback, useMemo } from 'react' +import type React from 'react' +import type { WorkspaceStatus } from '../../../../../shared/worktree/types' +import type { HostSectionRow } from '../host-section-rows' +import { PINNED_GROUP_KEY, type WorktreeGroupBy } from '../worktree-list-groups' +import { hasWorkspaceDragData, readWorkspaceDragDataIds } from '../workspace-status' +import { useWorkspaceStatusDocumentDrop } from '../use-workspace-status-drop' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' + +// Drag-and-drop onto a status section header or a status-grouped row, including the +// document-level fallback used when the pointer leaves the sidebar mid-drag. +export function useWorkspaceStatusRowDrag(args: { + ctx: WorktreeDropCommitContext + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + rows: HostSectionRow[] + groupBy: WorktreeGroupBy + onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void + onPinWorktree: (worktreeId: string) => void +}) { + const { ctx, session, runtime, scrollRef, rows, groupBy } = args + const { setDragOverStatus, setPinDragOver, clearWorktreeDrag } = runtime + + const hasWorkspaceDropTargets = useMemo( + () => + groupBy === 'workspace-status' || + rows.some((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY), + [groupBy, rows] + ) + + const handleWorkspaceStatusDragOver = useCallback( + (event: React.DragEvent, status: WorkspaceStatus) => { + if (!hasWorkspaceDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setDragOverStatus(status) + }, + [setDragOverStatus] + ) + + const handleWorkspaceStatusDragLeave = useCallback( + (event: React.DragEvent) => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return + } + setDragOverStatus(null) + }, + [setDragOverStatus] + ) + + const handleWorkspacePinDragOver = useCallback( + (event: React.DragEvent) => { + if (!hasWorkspaceDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setPinDragOver(true) + }, + [setPinDragOver] + ) + + const handleWorkspacePinDragLeave = useCallback( + (event: React.DragEvent) => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return + } + setPinDragOver(false) + }, + [setPinDragOver] + ) + + const handleWorkspaceStatusDragFinish = useCallback(() => { + setDragOverStatus(null) + setPinDragOver(false) + }, [setDragOverStatus, setPinDragOver]) + + const handleWorkspaceStatusDrop = useCallback( + (event: React.DragEvent, status: WorkspaceStatus) => { + const worktreeIds = readWorkspaceDragDataIds(event.dataTransfer) + if (worktreeIds.length === 0) { + return + } + event.preventDefault() + const dragSession = session.worktreeDragSessionRef.current + const statusDrop = dragSession + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status, + draggedIds: dragSession.reorderDraggedIds + }) + : null + setDragOverStatus(null) + if (dragSession && statusDrop) { + event.stopPropagation() + ctx.onMoveWorktreesToStatusAtIndex({ + worktreeIds: dragSession.reorderDraggedIds, + status, + dropIndex: statusDrop.dropIndex, + groups: ctx.worktreeDragGroups + }) + clearWorktreeDrag() + return + } + // Match status-drop scope to drag-preview scope (#9083): session uses its expanded set, else expand dataTransfer ids live. + ctx.onMoveWorktreesToStatus( + dragSession ? dragSession.reorderDraggedIds : session.getReorderDraggedIds(worktreeIds), + status + ) + }, + [clearWorktreeDrag, ctx, session, setDragOverStatus] + ) + + // Why: expand here (not the shared hook, used by the flat board) so a dropped parent carries its lineage children (#9083). + const moveWorktreesToStatusForDocumentDrop = useCallback( + (ids: readonly string[], status: WorkspaceStatus) => + ctx.onMoveWorktreesToStatus(session.getReorderDraggedIds(ids), status), + [ctx, session] + ) + + useWorkspaceStatusDocumentDrop( + scrollRef, + args.onMoveWorktreeToStatus, + args.onPinWorktree, + handleWorkspaceStatusDragFinish, + hasWorkspaceDropTargets, + { + onMoveWorktreesToStatus: moveWorktreesToStatusForDocumentDrop, + onPinWorktrees: ctx.onPinWorktrees + } + ) + + return { + handleWorkspaceStatusDragOver, + handleWorkspaceStatusDragLeave, + handleWorkspacePinDragOver, + handleWorkspacePinDragLeave, + handleWorkspaceStatusDrop + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-document-drop.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-document-drop.ts new file mode 100644 index 00000000000..7902f0647cb --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-document-drop.ts @@ -0,0 +1,115 @@ +import { useEffect } from 'react' +import type React from 'react' +import { getFullDropIndexForWorktreeDragUnit } from '../worktree-drag-units' +import type { WorktreeSidebarDragSession } from '../worktree-sidebar-drag-autoscroll' +import { getPointerDropStatusTarget } from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import { NO_WORKTREE_SIDEBAR_DROP_TARGET } from './worktree-row-drag-state' + +// Why: a drop that lands outside the sidebar still belongs to the in-flight session, so the +// capture-phase document listeners commit or abandon it before anything else sees the event. +export function useWorktreeDocumentDrop(args: { + ctx: WorktreeDropCommitContext + worktreeDragSessionRef: React.MutableRefObject +}): void { + const { ctx, worktreeDragSessionRef } = args + const { clearWorktreeDrag } = ctx + + useEffect(() => { + const handleDocumentDrop = (event: DragEvent): void => { + const session = worktreeDragSessionRef.current + if (!session) { + return + } + if (!ctx.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + const drop = ctx.computeWorktreeDrop(event.clientY) + if (!drop) { + const container = ctx.scrollRef.current + const target = ctx.getEligibleLineageDropTarget( + container + ? getPointerDropStatusTarget({ + container, + x: event.clientX, + y: event.clientY + }) + : NO_WORKTREE_SIDEBAR_DROP_TARGET, + session.draggedIds + ) + if (target.lineageParentId) { + event.preventDefault() + event.stopPropagation() + ctx.commitWorktreeLineageParentDrop(session.draggedIds, target.lineageParentId) + clearWorktreeDrag() + return + } + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: target.status, + draggedIds: session.reorderDraggedIds + }) + : null + if (target.status && statusDrop) { + event.preventDefault() + event.stopPropagation() + ctx.onMoveWorktreesToStatusAtIndex({ + worktreeIds: session.reorderDraggedIds, + status: target.status, + dropIndex: statusDrop.dropIndex, + groups: ctx.worktreeDragGroups + }) + clearWorktreeDrag() + return + } + clearWorktreeDrag() + return + } + // Why: pointer still inside the source group means reorder, not status move; commit here and stop the capture handler. + event.preventDefault() + event.stopPropagation() + ctx.onReorderWorktrees({ + groups: ctx.worktreeDragGroups, + sourceGroupKey: session.sourceGroupKey, + draggedIds: session.reorderDraggedIds, + dropIndex: getFullDropIndexForWorktreeDragUnit({ + groups: ctx.worktreeDragUnitGroups, + sourceGroupKey: session.sourceGroupKey, + dropIndex: drop.dropIndex + }) + }) + ctx.clearReorderedWorktreeParents({ + draggedIds: session.draggedIds, + sourceGroupKey: session.sourceGroupKey + }) + clearWorktreeDrag() + } + + document.addEventListener('drop', handleDocumentDrop, true) + return () => document.removeEventListener('drop', handleDocumentDrop, true) + }, [clearWorktreeDrag, ctx, worktreeDragSessionRef]) + + useEffect(() => { + const handleDocumentDragEnd = (): void => { + if (worktreeDragSessionRef.current) { + clearWorktreeDrag() + } + } + + document.addEventListener('dragend', handleDocumentDragEnd, true) + return () => document.removeEventListener('dragend', handleDocumentDragEnd, true) + }, [clearWorktreeDrag, worktreeDragSessionRef]) + + useEffect(() => { + const handleVisibilityChange = (): void => { + if (document.visibilityState !== 'visible' && worktreeDragSessionRef.current) { + clearWorktreeDrag() + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => document.removeEventListener('visibilitychange', handleVisibilityChange) + }, [clearWorktreeDrag, worktreeDragSessionRef]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-runtime.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-runtime.ts new file mode 100644 index 00000000000..978c31c5cc3 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-runtime.ts @@ -0,0 +1,123 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import type React from 'react' +import type { WorkspaceStatus } from '../../../../../shared/worktree/types' +import { clearWorkspaceKanbanSidebarDropTargetVisual } from '../workspace-kanban-sidebar-drop' +import { setSidebarPointerDragDocumentStyles } from '../worktree-sidebar-pointer-drag-dom' +import type { + WorktreeSidebarDragPoint, + WorktreeSidebarDragSession +} from '../worktree-sidebar-drag-autoscroll' +import type { WorktreeSidebarDropAnchor } from '../worktree-sidebar-drag-geometry' +import { + WORKTREE_ROW_DRAG_INITIAL_STATE, + type WorktreePointerDrag, + type WorktreeRowDragState +} from './worktree-row-drag-state' + +export type WorktreeDragRuntime = ReturnType + +// Owns everything a drag leaves behind: the visual state the rows read, the RAF handles, +// and the single teardown both the pointer and native drag paths call. +export function useWorktreeDragRuntime(args: { + worktreeDragSessionRef: React.MutableRefObject + statusDropAnchorsRef: React.MutableRefObject> + onWorkspaceBoardDragPreviewCancel: () => void +}) { + const { worktreeDragSessionRef, statusDropAnchorsRef, onWorkspaceBoardDragPreviewCancel } = args + const [dragOverStatus, setDragOverStatus] = useState(null) + const [pinDragOver, setPinDragOver] = useState(false) + const [nativeLineageDropTargetId, setNativeLineageDropTargetId] = useState(null) + const [worktreeDragState, setWorktreeDragState] = useState( + WORKTREE_ROW_DRAG_INITIAL_STATE + ) + const worktreePointerDragRef = useRef(null) + const pointerAutoscrollFrameIdRef = useRef(null) + const pointerAutoscrollLastFrameTimeRef = useRef(null) + const nativeAutoscrollFrameIdRef = useRef(null) + const nativeAutoscrollLastFrameTimeRef = useRef(null) + const nativeLatestPointRef = useRef(null) + const suppressWorktreeClickUntilRef = useRef(0) + + const cancelWorktreePointerAutoscroll = useCallback(() => { + if (pointerAutoscrollFrameIdRef.current !== null) { + window.cancelAnimationFrame(pointerAutoscrollFrameIdRef.current) + pointerAutoscrollFrameIdRef.current = null + } + pointerAutoscrollLastFrameTimeRef.current = null + }, []) + + const cancelWorktreeNativeAutoscroll = useCallback(() => { + if (nativeAutoscrollFrameIdRef.current !== null) { + window.cancelAnimationFrame(nativeAutoscrollFrameIdRef.current) + nativeAutoscrollFrameIdRef.current = null + } + nativeAutoscrollLastFrameTimeRef.current = null + nativeLatestPointRef.current = null + }, []) + + const cleanupWorktreePointerDrag = useCallback(() => { + const drag = worktreePointerDragRef.current + cancelWorktreePointerAutoscroll() + setNativeLineageDropTargetId(null) + if (!drag) { + return + } + if (drag.frameId !== null) { + window.cancelAnimationFrame(drag.frameId) + } + drag.preview?.remove() + worktreePointerDragRef.current = null + setSidebarPointerDragDocumentStyles(false) + setDragOverStatus(null) + setPinDragOver(false) + clearWorkspaceKanbanSidebarDropTargetVisual() + onWorkspaceBoardDragPreviewCancel() + }, [cancelWorktreePointerAutoscroll, onWorkspaceBoardDragPreviewCancel]) + + const clearWorktreeDrag = useCallback(() => { + cleanupWorktreePointerDrag() + cancelWorktreeNativeAutoscroll() + worktreeDragSessionRef.current = null + statusDropAnchorsRef.current.clear() + setWorktreeDragState(WORKTREE_ROW_DRAG_INITIAL_STATE) + }, [ + cancelWorktreeNativeAutoscroll, + cleanupWorktreePointerDrag, + statusDropAnchorsRef, + worktreeDragSessionRef + ]) + + // Why: drag handlers built from this object end up on memoised cards; keep one identity + // per meaningful change instead of one per render. + return useMemo( + () => ({ + dragOverStatus, + setDragOverStatus, + pinDragOver, + setPinDragOver, + nativeLineageDropTargetId, + setNativeLineageDropTargetId, + worktreeDragState, + setWorktreeDragState, + worktreePointerDragRef, + pointerAutoscrollFrameIdRef, + pointerAutoscrollLastFrameTimeRef, + nativeAutoscrollFrameIdRef, + nativeAutoscrollLastFrameTimeRef, + nativeLatestPointRef, + suppressWorktreeClickUntilRef, + cancelWorktreePointerAutoscroll, + cancelWorktreeNativeAutoscroll, + clearWorktreeDrag + }), + [ + cancelWorktreeNativeAutoscroll, + cancelWorktreePointerAutoscroll, + clearWorktreeDrag, + dragOverStatus, + nativeLineageDropTargetId, + pinDragOver, + worktreeDragState + ] + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-session.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-session.ts new file mode 100644 index 00000000000..f1077c71ff0 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-session.ts @@ -0,0 +1,244 @@ +import { useCallback, useMemo, useRef } from 'react' +import type React from 'react' +import type { WorkspaceStatus } from '../../../../../shared/worktree/types' +import type { HostSectionRow } from '../host-section-rows' +import { PINNED_GROUP_KEY } from '../worktree-list-groups' +import { WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP } from '../worktree-list-virtual-rows' +import { getWorkspaceStatusGroupKey } from '../workspace-status' +import { expandDraggedWorktreeIdsForVisibleLineage } from '../worktree-manual-order' +import { getWorktreeDragUnitGroups } from '../worktree-drag-units' +import { + getWorktreeSidebarDragRectsForGroup, + refreshWorktreeSidebarDragSession, + type WorktreeSidebarDragRect, + type WorktreeSidebarDragSession +} from '../worktree-sidebar-drag-autoscroll' +import { + shouldReevaluateWorktreeSidebarDropAnchor, + type WorktreeSidebarDragGrab, + type WorktreeSidebarDropAnchor +} from '../worktree-sidebar-drag-geometry' +import { + computeWorktreeSidebarDropPreview, + type WorktreeSidebarDropPreview +} from '../worktree-sidebar-drop-preview' +import { getWorktreeDragGroups, getWorktreeDragIndexes } from './worktree-drag-groups' +import type { WorktreeItemRow } from './render-row-item-rows' + +export type WorktreeStatusDropRequest = { + pointerY: number + status: WorkspaceStatus + draggedIds: readonly string[] +} + +export type WorktreeDragSession = ReturnType + +// Owns the geometry side of a sidebar row drag: which groups exist, which ids travel +// together, and where the insertion line lands for a given pointer position. +export function useWorktreeDragSession(args: { + rows: HostSectionRow[] + scrollRef: React.RefObject +}) { + const { rows, scrollRef } = args + const worktreeDragSessionRef = useRef(null) + // Why: cross-group hovers hit-test a group the session never captured, so hold + // that group's drop decision separately or a card expanding in the target group + // moves the insertion line under a still pointer. + const statusDropAnchorsRef = useRef>(new Map()) + + const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows]) + const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows]) + const naturalDragWorktreeIds = useMemo( + () => + new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ), + [rows] + ) + const worktreeLineageDragRows = useMemo( + () => + rows + .filter((row): row is WorktreeItemRow => row.type === 'item') + .filter( + (row) => + row.sectionKey !== PINNED_GROUP_KEY || !naturalDragWorktreeIds.has(row.worktree.id) + ) + .map((row) => ({ worktreeId: row.worktree.id, depth: row.depth })), + [naturalDragWorktreeIds, rows] + ) + const getReorderDraggedIds = useCallback( + (draggedIds: readonly string[]) => + expandDraggedWorktreeIdsForVisibleLineage(worktreeLineageDragRows, draggedIds), + [worktreeLineageDragRows] + ) + const getReorderUnitDraggedIds = useCallback( + (sourceGroupKey: string, reorderDraggedIds: readonly string[]) => { + const group = worktreeDragUnitGroups.find((candidate) => candidate.key === sourceGroupKey) + if (!group) { + return reorderDraggedIds + } + const unitIds = new Set(group.worktreeIds) + const filtered = reorderDraggedIds.filter((worktreeId) => unitIds.has(worktreeId)) + return filtered.length > 0 ? filtered : reorderDraggedIds + }, + [worktreeDragUnitGroups] + ) + const { groupKeyByRowKey, groupIndexByRowKey } = useMemo( + () => getWorktreeDragIndexes(rows), + [rows] + ) + const refreshWorktreeDragSession = useCallback((): boolean => { + const session = worktreeDragSessionRef.current + const container = scrollRef.current + if (!session || !container) { + return false + } + + const refreshedSession = refreshWorktreeSidebarDragSession({ + session, + groups: worktreeDragGroups, + unitGroups: worktreeDragUnitGroups, + rects: getWorktreeSidebarDragRectsForGroup(container, session.sourceGroupKey) + }) + worktreeDragSessionRef.current = refreshedSession + return refreshedSession !== null + }, [scrollRef, worktreeDragGroups, worktreeDragUnitGroups]) + const computeWorktreeDropForGroup = useCallback( + (dropArgs: { + pointerY: number + groupKey: string + rects: readonly WorktreeSidebarDragRect[] + draggedIds: readonly string[] + draggingWorktreeId?: string | null + grab?: WorktreeSidebarDragGrab | null + anchor?: WorktreeSidebarDropAnchor | null + }): WorktreeSidebarDropPreview | null => { + const container = scrollRef.current + if (!container) { + return null + } + const group = worktreeDragUnitGroups.find((candidate) => candidate.key === dropArgs.groupKey) + if (!group) { + return null + } + const containerRect = container.getBoundingClientRect() + return computeWorktreeSidebarDropPreview({ + pointerY: dropArgs.pointerY, + containerTop: containerRect.top, + scrollTop: container.scrollTop, + rects: dropArgs.rects, + groupIds: group.worktreeIds, + draggedIds: dropArgs.draggedIds, + draggingWorktreeId: dropArgs.draggingWorktreeId, + fallbackGap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, + grab: dropArgs.grab, + anchor: dropArgs.anchor + }) + }, + [scrollRef, worktreeDragUnitGroups] + ) + const computeWorktreeDrop = useCallback( + (pointerY: number): WorktreeSidebarDropPreview | null => { + const session = worktreeDragSessionRef.current + const container = scrollRef.current + if (!session || !container) { + return null + } + const scrollTop = container.scrollTop + // Why: only real pointer or scroll movement should re-decide the slot; a + // card growing under a still pointer must not move it. + const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ + anchor: session.anchor, + pointerY, + scrollTop + }) + ? null + : session.anchor + const preview = computeWorktreeDropForGroup({ + pointerY, + groupKey: session.sourceGroupKey, + rects: session.rects, + draggedIds: session.reorderUnitDraggedIds, + draggingWorktreeId: session.draggingWorktreeId, + grab: session.grab, + anchor + }) + worktreeDragSessionRef.current = { + ...session, + anchor: preview ? { beforeWorktreeId: preview.dropAnchorId, pointerY, scrollTop } : null + } + return preview + }, + [computeWorktreeDropForGroup, scrollRef] + ) + const computeWorktreeStatusDrop = useCallback( + (request: WorktreeStatusDropRequest): WorktreeSidebarDropPreview | null => { + const container = scrollRef.current + if (!container) { + return null + } + const groupKey = getWorkspaceStatusGroupKey(request.status) + const session = worktreeDragSessionRef.current + const scrollTop = container.scrollTop + const heldAnchor = statusDropAnchorsRef.current.get(groupKey) ?? null + const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ + anchor: heldAnchor, + pointerY: request.pointerY, + scrollTop + }) + ? null + : heldAnchor + const preview = computeWorktreeDropForGroup({ + pointerY: request.pointerY, + groupKey, + rects: getWorktreeSidebarDragRectsForGroup(container, groupKey), + draggedIds: request.draggedIds, + draggingWorktreeId: session?.draggingWorktreeId ?? null, + grab: session?.grab ?? null, + anchor + }) + if (preview) { + statusDropAnchorsRef.current.set(groupKey, { + beforeWorktreeId: preview.dropAnchorId, + pointerY: request.pointerY, + scrollTop + }) + } else { + statusDropAnchorsRef.current.delete(groupKey) + } + return preview + }, + [computeWorktreeDropForGroup, scrollRef] + ) + + // Why: consumers pass session-derived callbacks into memoised cards; a fresh object every + // render would defeat those bail-outs. + return useMemo( + () => ({ + worktreeDragSessionRef, + statusDropAnchorsRef, + worktreeDragGroups, + worktreeDragUnitGroups, + groupKeyByRowKey, + groupIndexByRowKey, + getReorderDraggedIds, + getReorderUnitDraggedIds, + refreshWorktreeDragSession, + computeWorktreeDrop, + computeWorktreeStatusDrop + }), + [ + computeWorktreeDrop, + computeWorktreeStatusDrop, + getReorderDraggedIds, + getReorderUnitDraggedIds, + groupIndexByRowKey, + groupKeyByRowKey, + refreshWorktreeDragSession, + worktreeDragGroups, + worktreeDragUnitGroups + ] + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-lineage-drop-commit.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-lineage-drop-commit.ts new file mode 100644 index 00000000000..6629fe526e1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-lineage-drop-commit.ts @@ -0,0 +1,127 @@ +import { useCallback, useMemo } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import type { Repo } from '../../../../../shared/repo-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types' +import { isEligibleWorktreeParent } from '../worktree-parent-candidates' +import { getCyclicProjectedWorktreeLineageIds } from '../worktree-lineage-projection' +import { getReorderedWorktreeIdsToUnnest } from '../worktree-lineage-drag-drop' +import type { WorktreeDragGroup } from '../worktree-manual-order' +import type { WorktreeSidebarLineageDropTarget } from './worktree-row-drag-state' + +export type WorktreeLineageDropCommit = ReturnType + +// Nesting and un-nesting are the two lineage mutations a sidebar drag can commit. +export function useWorktreeLineageDropCommit(args: { + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + worktreeDragGroups: readonly WorktreeDragGroup[] +}) { + const { repoMap, worktreeMap, worktreeLineageById, worktreeDragGroups } = args + const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) + const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) + const cyclicLineageIds = useMemo( + () => getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap), + [worktreeLineageById, worktreeMap] + ) + + const getEligibleLineageDropTarget = useCallback( + ( + target: WorktreeSidebarLineageDropTarget, + draggedIds: readonly string[] + ): WorktreeSidebarLineageDropTarget => { + const parentId = target.lineageParentId + if (!parentId) { + return target + } + const canAssignAll = draggedIds.every((draggedId) => { + const child = worktreeMap.get(draggedId) + if (!child) { + return false + } + const candidateParent = worktreeMap.get(parentId) + return Boolean( + candidateParent && + isEligibleWorktreeParent({ + child, + candidateParent, + lineageById: worktreeLineageById, + worktreeMap, + repoMap, + cyclicLineageIds + }) + ) + }) + return canAssignAll ? target : { ...target, lineageParentId: null } + }, + [cyclicLineageIds, repoMap, worktreeLineageById, worktreeMap] + ) + + const commitWorktreeLineageParentDrop = useCallback( + (draggedIds: readonly string[], parentId: string): boolean => { + const target = getEligibleLineageDropTarget( + { status: null, isPinDrop: false, lineageParentId: parentId }, + draggedIds + ) + if (!target.lineageParentId) { + return false + } + void Promise.all( + draggedIds.map((id) => assignWorktreeParent(id, { parentWorktreeId: parentId })) + ).catch((err) => { + console.error('Failed to nest workspace:', err) + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.failedNestWorkspace', + 'Failed to nest workspace' + ) + ) + }) + return true + }, + [assignWorktreeParent, getEligibleLineageDropTarget] + ) + + const clearReorderedWorktreeParents = useCallback( + (unnestArgs: { draggedIds: readonly string[]; sourceGroupKey: string }) => { + const sourceGroup = worktreeDragGroups.find( + (group) => group.key === unnestArgs.sourceGroupKey + ) + if (!sourceGroup) { + return + } + const ids = getReorderedWorktreeIdsToUnnest({ + draggedIds: unnestArgs.draggedIds, + sourceGroupIds: sourceGroup.worktreeIds, + lineageById: worktreeLineageById, + worktreeMap, + cyclicLineageIds + }) + if (ids.length === 0) { + return + } + // Why: dropping a nested card on a reorder line is the un-nest escape hatch; clear only the dragged children. + void Promise.all(ids.map((id) => updateWorktreeLineage(id, { noParent: true }))).catch( + (err) => { + console.error('Failed to unnest workspace:', err) + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.failedUnnestWorkspace', + 'Failed to unnest workspace' + ) + ) + } + ) + }, + [cyclicLineageIds, updateWorktreeLineage, worktreeDragGroups, worktreeLineageById, worktreeMap] + ) + + return { + getEligibleLineageDropTarget, + commitWorktreeLineageParentDrop, + clearReorderedWorktreeParents + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-keyboard-navigation.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-keyboard-navigation.ts new file mode 100644 index 00000000000..4a9fc419703 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-keyboard-navigation.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect } from 'react' +import type React from 'react' +import type { Virtualizer } from '@tanstack/react-virtual' +import { useAppStore } from '@/store' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { getShortcutPlatform } from '@/lib/shortcut-platform' +import { keybindingMatchesAction } from '../../../../../shared/keybindings' +import type { HostSectionRow } from '../host-section-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import type { RenderRow } from '../worktree-list-virtual-rows' +import { getCyclableWorktreeIds, resolveCycledWorktreeId } from '../worktree-keyboard-cycle' +import { findPreferredRenderRowIndexForWorktree } from './render-row-worktree-lookup' +import { isEditableTarget } from './sidebar-editable-target' + +export function useWorktreeListKeyboardNavigation(args: { + rows: HostSectionRow[] + renderRows: RenderRow[] + activeWorktreeId: string | null + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + virtualizer: Virtualizer + scrollRef: React.RefObject + activeModal: string + markDirectScrollInput: () => void +}) { + const { + rows, + renderRows, + activeWorktreeId, + pinnedDisplayPolicy, + virtualizer, + scrollRef, + activeModal, + markDirectScrollInput + } = args + const keybindings = useAppStore((s) => s.keybindings) + + const navigateWorktree = useCallback( + (direction: 'up' | 'down') => { + // Why: cycle over the rows the sidebar actually rendered — collapsing a group + // means "not now", and a rebuilt near-copy would drift from what is on screen + // (host sections, pinned placement, folder workspaces). + const nextWorktreeId = resolveCycledWorktreeId({ + worktreeIds: getCyclableWorktreeIds(rows, pinnedDisplayPolicy), + activeWorktreeId, + direction + }) + if (nextWorktreeId === null) { + return + } + + // Why: keyboard cycling is real navigation; route through the activation helper that records history. + activateAndRevealWorktree(nextWorktreeId) + + const rowIndex = findPreferredRenderRowIndexForWorktree( + renderRows, + nextWorktreeId, + pinnedDisplayPolicy + ) + if (rowIndex !== -1) { + virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) + } + }, + [rows, renderRows, activeWorktreeId, virtualizer, pinnedDisplayPolicy] + ) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (activeModal !== 'none' || isEditableTarget(e.target)) { + return + } + + const platform = getShortcutPlatform() + if (keybindingMatchesAction('sidebar.focusWorktreeList', e, platform, keybindings)) { + scrollRef.current?.focus() + e.preventDefault() + return + } + + const direction = keybindingMatchesAction('worktree.navigateUp', e, platform, keybindings) + ? 'up' + : keybindingMatchesAction('worktree.navigateDown', e, platform, keybindings) + ? 'down' + : null + if (direction) { + markDirectScrollInput() + navigateWorktree(direction) + e.preventDefault() + } + } + + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, [activeModal, keybindings, markDirectScrollInput, navigateWorktree, scrollRef]) + + const handleContainerKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + if (e.target !== e.currentTarget) { + return + } + markDirectScrollInput() + navigateWorktree(e.key === 'ArrowUp' ? 'up' : 'down') + e.preventDefault() + } else if (e.key === 'Enter') { + const helper = document.querySelector( + '.xterm-helper-textarea' + ) as HTMLTextAreaElement | null + if (helper) { + helper.focus() + } + e.preventDefault() + } else if (['PageUp', 'PageDown', 'Home', 'End', ' '].includes(e.key)) { + markDirectScrollInput() + } + }, + [markDirectScrollInput, navigateWorktree] + ) + + return { handleContainerKeyDown } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-virtualizer.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-virtualizer.ts new file mode 100644 index 00000000000..52bd92358ce --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-virtualizer.ts @@ -0,0 +1,159 @@ +import { useCallback, useMemo, useRef } from 'react' +import type React from 'react' +import { + measureElement as measureVirtualElementSize, + useVirtualizer +} from '@tanstack/react-virtual' +import type { Range } from '@tanstack/react-virtual' +import { + estimateRenderRowSize, + extractWorktreeVirtualRowIndexes, + getRenderRowKey, + getStickyHeaderIndexes, + WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, + type RenderRow +} from '../worktree-list-virtual-rows' +import { WORKTREE_SIDEBAR_REVEAL_TOP_INSET } from '../worktree-sidebar-reveal' +import { getVirtualRowIndex } from './virtual-row-dom-attributes' +import { + shouldAdjustWorktreeSidebarMeasuredRowScroll, + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS +} from './worktree-sidebar-scroll-tuning' + +export type WorktreeListVirtualizer = ReturnType + +// The TanStack virtualizer plus the row-identity guards that stop a recycled DOM node +// from writing a stale height into the wrong slot. +export function useWorktreeListVirtualizer(args: { + renderRows: RenderRow[] + firstHeaderIndex: number + scrollRef: React.RefObject + scrollOffsetRef: React.MutableRefObject + suppressMeasurementAdjustmentUntilRef: React.MutableRefObject +}) { + const { renderRows, firstHeaderIndex, scrollRef, scrollOffsetRef } = args + const renderRowsRef = useRef(renderRows) + renderRowsRef.current = renderRows + const firstHeaderIndexRef = useRef(firstHeaderIndex) + firstHeaderIndexRef.current = firstHeaderIndex + const stickyHeaderIndexes = useMemo(() => getStickyHeaderIndexes(renderRows), [renderRows]) + const activeStickyHeaderIndexRef = useRef(null) + const activeStickyHostIndexRef = useRef(null) + const stickyRangeStartIndexRef = useRef(0) + + const getVirtualItemKey = useCallback( + (index: number) => { + const row = renderRows[index] + if (!row) { + return `__stale_${index}` + } + return getRenderRowKey(row) + }, + [renderRows] + ) + const getExpectedVirtualRowKey = useCallback((element: Element) => { + const index = getVirtualRowIndex(element) + const row = index === null ? undefined : renderRowsRef.current[index] + return row ? getRenderRowKey(row) : null + }, []) + const isCurrentVirtualRowElement = useCallback( + (element: Element) => { + const expectedKey = getExpectedVirtualRowKey(element) + return ( + element.isConnected && + expectedKey !== null && + element.getAttribute('data-worktree-virtual-row-key') === expectedKey + ) + }, + [getExpectedVirtualRowKey] + ) + const measureCurrentVirtualRowElement = useCallback( + ( + element: HTMLDivElement, + entry: ResizeObserverEntry | undefined, + instance: Parameters>[2] + ) => { + if (!isCurrentVirtualRowElement(element)) { + const index = getVirtualRowIndex(element) + const measured = instance.getVirtualItems().find((item) => item.index === index) + // Why: a stale ResizeObserver row after remount would write a wrong height; return current size to no-op it. + return ( + measured?.size ?? + estimateRenderRowSize( + renderRowsRef.current, + index ?? -1, + firstHeaderIndexRef.current, + activeStickyHeaderIndexRef.current + ) + ) + } + const index = getVirtualRowIndex(element) + if ( + index !== null && + (renderRowsRef.current[index]?.type === 'header' || + renderRowsRef.current[index]?.type === 'host-header') + ) { + return estimateRenderRowSize( + renderRowsRef.current, + index, + firstHeaderIndexRef.current, + activeStickyHeaderIndexRef.current + ) + } + return measureVirtualElementSize(element, entry, instance) + }, + [isCurrentVirtualRowElement] + ) + + const virtualizer = useVirtualizer({ + count: renderRows.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => + estimateRenderRowSize( + renderRows, + index, + firstHeaderIndex, + activeStickyHeaderIndexRef.current + ), + measureElement: measureCurrentVirtualRowElement, + // Why: TanStack memoizes rangeExtractor by identity; header indexes must be deps or sticky slots go stale. + rangeExtractor: useCallback( + (range: Range) => { + stickyRangeStartIndexRef.current = range.startIndex + return extractWorktreeVirtualRowIndexes({ + range, + stickyHeaderIndexes, + rows: renderRowsRef.current + }) + }, + [stickyHeaderIndexes] + ), + overscan: 10, + gap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, + // Why: the sticky group header lives inside the virtual list, so scroll math needs the same top inset as the DOM reveal. + scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET, + isScrollingResetDelay: USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS, + // Why: sync-flushing rich card renders in the scroll listener stalls wheel input; async + overscan keeps rows filled. + useFlushSync: false, + // Why: seed scrollOffset from the ref (not 0) so the first getVirtualItems() after remount picks the right rows. + initialOffset: () => scrollOffsetRef.current, + getItemKey: getVirtualItemKey + }) + // Why: TanStack's default correction writes scrollTop while cards remeasure mid-wheel, which feels like rubber-banding. + // TODO(scroll-origin-migration): wall-clock suppression misclassifies under jank; migrate to programmaticScrollMarks + restoreSignal (see CombinedDiffViewer). + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => + shouldAdjustWorktreeSidebarMeasuredRowScroll({ + isScrolling: instance.isScrolling, + now: window.performance.now(), + suppressUntil: args.suppressMeasurementAdjustmentUntilRef.current + }) + + return { + virtualizer, + isCurrentVirtualRowElement, + stickyHeaderIndexes, + activeStickyHeaderIndexRef, + activeStickyHostIndexRef, + stickyRangeStartIndexRef + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag-autoscroll.ts new file mode 100644 index 00000000000..339f1f8b768 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag-autoscroll.ts @@ -0,0 +1,108 @@ +import { useCallback } from 'react' +import type React from 'react' +import { getWorktreeSidebarDragAutoscroll } from '../worktree-sidebar-drag-autoscroll' +import { getPointerDropStatusTarget } from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import { applyWorktreeDropPreview, clearWorktreeDropPreview } from './worktree-row-drag-state' + +// The HTML5 drag equivalent of pointer autoscroll: the browser gives no move events while +// hovering the edge, so re-derive the drop preview from the last known point each frame. +export function useWorktreeNativeDragAutoscroll(args: { + ctx: WorktreeDropCommitContext + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + markScrollMovement: () => void +}): () => void { + const { ctx, session, runtime, scrollRef, markScrollMovement } = args + const { + nativeAutoscrollFrameIdRef, + nativeAutoscrollLastFrameTimeRef, + nativeLatestPointRef, + cancelWorktreeNativeAutoscroll, + clearWorktreeDrag, + setWorktreeDragState + } = runtime + + const runFrame = useCallback( + (frameTime: number) => { + nativeAutoscrollFrameIdRef.current = null + const point = nativeLatestPointRef.current + const container = scrollRef.current + const dragSession = session.worktreeDragSessionRef.current + if (!point || !container || !dragSession) { + cancelWorktreeNativeAutoscroll() + return + } + + const previousFrameTime = nativeAutoscrollLastFrameTimeRef.current ?? frameTime + nativeAutoscrollLastFrameTimeRef.current = frameTime + const autoscroll = getWorktreeSidebarDragAutoscroll({ + point, + containerRect: container.getBoundingClientRect(), + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + elapsedMs: frameTime - previousFrameTime + }) + if (autoscroll) { + markScrollMovement() + container.scrollTop = autoscroll.scrollTop + if (!session.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + const drop = ctx.computeWorktreeDrop(point.clientY) + if (!drop) { + const target = getPointerDropStatusTarget({ + container, + x: point.clientX, + y: point.clientY + }) + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: point.clientY, + status: target.status, + draggedIds: dragSession.reorderDraggedIds + }) + : null + if (statusDrop) { + setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, statusDrop, { pointerY: point.clientY }) + ) + return + } + setWorktreeDragState((prev) => clearWorktreeDropPreview(prev, { pointerY: null })) + } else { + setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, drop, { pointerY: point.clientY }) + ) + } + } + + nativeAutoscrollFrameIdRef.current = window.requestAnimationFrame(runFrame) + }, + [ + cancelWorktreeNativeAutoscroll, + clearWorktreeDrag, + ctx, + markScrollMovement, + nativeAutoscrollFrameIdRef, + nativeAutoscrollLastFrameTimeRef, + nativeLatestPointRef, + scrollRef, + session, + setWorktreeDragState + ] + ) + + return useCallback(() => { + if (nativeAutoscrollFrameIdRef.current !== null) { + return + } + nativeAutoscrollLastFrameTimeRef.current = null + nativeAutoscrollFrameIdRef.current = window.requestAnimationFrame(runFrame) + }, [nativeAutoscrollFrameIdRef, nativeAutoscrollLastFrameTimeRef, runFrame]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag.ts new file mode 100644 index 00000000000..b7fc693326e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag.ts @@ -0,0 +1,231 @@ +import { useCallback } from 'react' +import type React from 'react' +import { getWorkspaceKanbanSidebarDropTarget } from '../workspace-kanban-sidebar-drop' +import { getFullDropIndexForWorktreeDragUnit } from '../worktree-drag-units' +import { getWorktreeSidebarDragRectsForGroup } from '../worktree-sidebar-drag-autoscroll' +import { getWorktreeSidebarDragGrab } from '../worktree-sidebar-drag-geometry' +import { getPointerDropStatusTarget } from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import { useWorktreeNativeDragAutoscroll } from './use-worktree-native-drag-autoscroll' +import { + applyWorktreeDropPreview, + clearWorktreeDropPreview, + EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + NO_WORKTREE_SIDEBAR_DROP_TARGET +} from './worktree-row-drag-state' + +// The HTML5 drag path used by cards that start a native dragstart (board <-> sidebar transfers). +export function useWorktreeNativeDrag(args: { + ctx: WorktreeDropCommitContext + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + markScrollMovement: () => void +}) { + const { ctx, session, runtime, scrollRef, markScrollMovement } = args + const { + nativeLatestPointRef, + clearWorktreeDrag, + setWorktreeDragState, + setNativeLineageDropTargetId + } = runtime + + const startWorktreeNativeAutoscroll = useWorktreeNativeDragAutoscroll({ + ctx, + session, + runtime, + scrollRef, + markScrollMovement + }) + + const handleWorktreeCardDragStart = useCallback( + (event: React.DragEvent, worktreeId: string, draggedIds: readonly string[]) => { + const sourceGroupKey = + ctx.worktreeDragGroups.find((group) => group.worktreeIds.includes(worktreeId))?.key ?? null + if (!sourceGroupKey) { + return + } + const reorderDraggedIds = session.getReorderDraggedIds(draggedIds) + const reorderUnitDraggedIds = session.getReorderUnitDraggedIds( + sourceGroupKey, + reorderDraggedIds + ) + const rects = scrollRef.current + ? getWorktreeSidebarDragRectsForGroup(scrollRef.current, sourceGroupKey) + : [] + const sourceRect = event.currentTarget.getBoundingClientRect() + session.worktreeDragSessionRef.current = { + draggingWorktreeId: worktreeId, + sourceGroupKey, + draggedIds, + reorderDraggedIds, + reorderUnitDraggedIds, + rects, + grab: getWorktreeSidebarDragGrab({ + offsetY: event.clientY - sourceRect.top, + height: sourceRect.height + }), + anchor: null + } + setWorktreeDragState({ + draggingWorktreeId: worktreeId, + sourceGroupKey, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: null + }) + }, + [ctx.worktreeDragGroups, scrollRef, session, setWorktreeDragState] + ) + + const handleWorktreeDragOver = useCallback( + (event: React.DragEvent) => { + const dragSession = session.worktreeDragSessionRef.current + if (!dragSession) { + return + } + nativeLatestPointRef.current = { clientX: event.clientX, clientY: event.clientY } + startWorktreeNativeAutoscroll() + if (!session.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + const target = ctx.getEligibleLineageDropTarget( + getPointerDropStatusTarget({ + container: event.currentTarget, + x: event.clientX, + y: event.clientY + }), + dragSession.draggedIds + ) + if (target.lineageParentId) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setNativeLineageDropTargetId(target.lineageParentId) + setWorktreeDragState((prev) => clearWorktreeDropPreview(prev, { pointerY: event.clientY })) + return + } + setNativeLineageDropTargetId(null) + + const drop = ctx.computeWorktreeDrop(event.clientY) + if (!drop) { + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: target.status, + draggedIds: dragSession.reorderDraggedIds + }) + : null + if (statusDrop) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, statusDrop, { pointerY: event.clientY }) + ) + return + } + setWorktreeDragState((prev) => clearWorktreeDropPreview(prev, { pointerY: null })) + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, drop, { pointerY: event.clientY }) + ) + }, + [ + clearWorktreeDrag, + ctx, + nativeLatestPointRef, + session, + setNativeLineageDropTargetId, + setWorktreeDragState, + startWorktreeNativeAutoscroll + ] + ) + + const handleWorktreeDrop = useCallback( + (event: React.DragEvent) => { + const dragSession = session.worktreeDragSessionRef.current + if (!dragSession) { + return + } + if (!session.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + const boardDropTarget = getWorkspaceKanbanSidebarDropTarget(event.clientX, event.clientY) + if (boardDropTarget.status || boardDropTarget.isPinDrop) { + clearWorktreeDrag() + return + } + + const container = scrollRef.current + const target = ctx.getEligibleLineageDropTarget( + container + ? getPointerDropStatusTarget({ + container, + x: event.clientX, + y: event.clientY + }) + : NO_WORKTREE_SIDEBAR_DROP_TARGET, + dragSession.draggedIds + ) + + if (target.lineageParentId) { + event.preventDefault() + event.stopPropagation() + ctx.commitWorktreeLineageParentDrop(dragSession.draggedIds, target.lineageParentId) + clearWorktreeDrag() + return + } + + const drop = ctx.computeWorktreeDrop(event.clientY) + if (!drop) { + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: target.status, + draggedIds: dragSession.reorderDraggedIds + }) + : null + if (target.status && statusDrop) { + event.preventDefault() + event.stopPropagation() + ctx.onMoveWorktreesToStatusAtIndex({ + worktreeIds: dragSession.reorderDraggedIds, + status: target.status, + dropIndex: statusDrop.dropIndex, + groups: ctx.worktreeDragGroups + }) + clearWorktreeDrag() + return + } + clearWorktreeDrag() + return + } + event.preventDefault() + ctx.onReorderWorktrees({ + groups: ctx.worktreeDragGroups, + sourceGroupKey: dragSession.sourceGroupKey, + draggedIds: dragSession.reorderDraggedIds, + dropIndex: getFullDropIndexForWorktreeDragUnit({ + groups: ctx.worktreeDragUnitGroups, + sourceGroupKey: dragSession.sourceGroupKey, + dropIndex: drop.dropIndex + }) + }) + ctx.clearReorderedWorktreeParents({ + draggedIds: dragSession.draggedIds, + sourceGroupKey: dragSession.sourceGroupKey + }) + clearWorktreeDrag() + }, + [clearWorktreeDrag, ctx, scrollRef, session] + ) + + return { handleWorktreeCardDragStart, handleWorktreeDragOver, handleWorktreeDrop } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-autoscroll.ts new file mode 100644 index 00000000000..a2af2bd422d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-autoscroll.ts @@ -0,0 +1,79 @@ +import { useCallback } from 'react' +import type React from 'react' +import { getWorktreeSidebarDragAutoscroll } from '../worktree-sidebar-drag-autoscroll' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import type { WorktreePointerDrag } from './worktree-row-drag-state' + +// Scrolls the sidebar while a pointer drag hovers near its top or bottom edge, refreshing +// the drag session on every scrolled frame so the insertion line stays on the right row. +export function useWorktreePointerDragAutoscroll(args: { + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + markScrollMovement: () => void + scheduleWorktreePointerDragFrame: (drag: WorktreePointerDrag) => void +}): () => void { + const { session, runtime, scrollRef, markScrollMovement, scheduleWorktreePointerDragFrame } = args + const { + worktreePointerDragRef, + pointerAutoscrollFrameIdRef, + pointerAutoscrollLastFrameTimeRef, + cancelWorktreePointerAutoscroll, + clearWorktreeDrag + } = runtime + + const runFrame = useCallback( + (frameTime: number) => { + pointerAutoscrollFrameIdRef.current = null + const drag = worktreePointerDragRef.current + const container = scrollRef.current + const dragSession = session.worktreeDragSessionRef.current + if (!drag?.active || !container || !dragSession) { + cancelWorktreePointerAutoscroll() + return + } + + const previousFrameTime = pointerAutoscrollLastFrameTimeRef.current ?? frameTime + pointerAutoscrollLastFrameTimeRef.current = frameTime + const autoscroll = getWorktreeSidebarDragAutoscroll({ + point: { clientX: drag.currentX, clientY: drag.currentY }, + containerRect: container.getBoundingClientRect(), + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + elapsedMs: frameTime - previousFrameTime + }) + if (autoscroll) { + markScrollMovement() + container.scrollTop = autoscroll.scrollTop + if (!session.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + scheduleWorktreePointerDragFrame(drag) + } + + pointerAutoscrollFrameIdRef.current = window.requestAnimationFrame(runFrame) + }, + [ + cancelWorktreePointerAutoscroll, + clearWorktreeDrag, + markScrollMovement, + pointerAutoscrollFrameIdRef, + pointerAutoscrollLastFrameTimeRef, + scheduleWorktreePointerDragFrame, + scrollRef, + session, + worktreePointerDragRef + ] + ) + + return useCallback(() => { + if (pointerAutoscrollFrameIdRef.current !== null) { + return + } + pointerAutoscrollLastFrameTimeRef.current = null + pointerAutoscrollFrameIdRef.current = window.requestAnimationFrame(runFrame) + }, [pointerAutoscrollFrameIdRef, pointerAutoscrollLastFrameTimeRef, runFrame]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-window-events.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-window-events.ts new file mode 100644 index 00000000000..745670dde4d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-window-events.ts @@ -0,0 +1,95 @@ +import { useEffect } from 'react' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' +import { commitWorktreePointerDrop } from './worktree-pointer-drag-commit' +import { SIDEBAR_POINTER_DRAG_THRESHOLD_PX } from './worktree-sidebar-scroll-tuning' +import type { WorktreePointerDrag } from './worktree-row-drag-state' + +// Pointer drags escape the row that started them, so move/up/cancel are tracked on the +// window in capture phase. +export function useWorktreePointerDragWindowEvents(args: { + ctx: WorktreeDropCommitContext + runtime: WorktreeDragRuntime + beginWorktreePointerDrag: (drag: WorktreePointerDrag) => void + scheduleWorktreePointerDragFrame: (drag: WorktreePointerDrag) => void + onWorkspaceBoardDragPreviewCommit: () => void + onDropWorktreesOnWorkspaceBoard: (dropArgs: WorktreeStatusDropAtIndexArgs) => void +}): void { + const { + ctx, + beginWorktreePointerDrag, + scheduleWorktreePointerDragFrame, + onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard + } = args + const { worktreePointerDragRef, clearWorktreeDrag } = args.runtime + + useEffect(() => { + const handlePointerMove = (event: PointerEvent): void => { + const drag = worktreePointerDragRef.current + if (!drag || event.pointerId !== drag.pointerId) { + return + } + drag.currentX = event.clientX + drag.currentY = event.clientY + if (!drag.active) { + const distance = Math.hypot(drag.currentX - drag.startX, drag.currentY - drag.startY) + if (distance < SIDEBAR_POINTER_DRAG_THRESHOLD_PX) { + return + } + beginWorktreePointerDrag(drag) + } + event.preventDefault() + event.stopPropagation() + scheduleWorktreePointerDragFrame(drag) + } + + const handlePointerUp = (event: PointerEvent): void => { + const drag = worktreePointerDragRef.current + if (!drag || event.pointerId !== drag.pointerId) { + return + } + drag.currentX = event.clientX + drag.currentY = event.clientY + if (!drag.active) { + worktreePointerDragRef.current = null + return + } + event.preventDefault() + event.stopPropagation() + commitWorktreePointerDrop({ + event, + drag, + ctx, + onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard + }) + } + + const handlePointerCancel = (event: PointerEvent): void => { + const drag = worktreePointerDragRef.current + if (!drag || event.pointerId !== drag.pointerId) { + return + } + clearWorktreeDrag() + } + + window.addEventListener('pointermove', handlePointerMove, { capture: true }) + window.addEventListener('pointerup', handlePointerUp, { capture: true }) + window.addEventListener('pointercancel', handlePointerCancel, { capture: true }) + return () => { + window.removeEventListener('pointermove', handlePointerMove, { capture: true }) + window.removeEventListener('pointerup', handlePointerUp, { capture: true }) + window.removeEventListener('pointercancel', handlePointerCancel, { capture: true }) + } + }, [ + beginWorktreePointerDrag, + clearWorktreeDrag, + ctx, + onDropWorktreesOnWorkspaceBoard, + onWorkspaceBoardDragPreviewCommit, + scheduleWorktreePointerDragFrame, + worktreePointerDragRef + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag.ts new file mode 100644 index 00000000000..b61694d9bd4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag.ts @@ -0,0 +1,259 @@ +import { useCallback, useEffect } from 'react' +import type React from 'react' +import type { WorkspaceStatus, Worktree } from '../../../../../shared/worktree/types' +import { hasWorkspaceKanbanSidebarDropBoard } from '../workspace-kanban-sidebar-drop' +import { + createSidebarDragPreview, + isSidebarPointerDragBlocked, + setSidebarPointerDragDocumentStyles +} from '../worktree-sidebar-pointer-drag-dom' +import { getWorktreeSidebarDragRectsForGroup } from '../worktree-sidebar-drag-autoscroll' +import { getWorktreeSidebarDragGrab } from '../worktree-sidebar-drag-geometry' +import { NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK } from './virtualized-worktree-viewport-props' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import { useWorktreePointerDragAutoscroll } from './use-worktree-pointer-drag-autoscroll' +import { useWorktreePointerDragWindowEvents } from './use-worktree-pointer-drag-window-events' +import { flushWorktreePointerDragFrame } from './worktree-pointer-drag-flush' +import { + EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + type WorktreePointerDrag +} from './worktree-row-drag-state' + +export function useWorktreePointerDrag(args: { + ctx: WorktreeDropCommitContext + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + markScrollMovement: () => void + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + workspaceBoardOpen: boolean + onWorkspaceBoardDragPreviewStart: () => void + onWorkspaceBoardDragPreviewCommit: () => void + onDropWorktreesOnWorkspaceBoard: (dropArgs: WorktreeStatusDropAtIndexArgs) => void + shouldShowWorkspaceBoardDropIndicator: ( + worktreeIds: readonly string[], + status: WorkspaceStatus + ) => boolean +}) { + const { + ctx, + session, + runtime, + scrollRef, + markScrollMovement, + selectedWorktreeIds, + selectedWorktrees, + workspaceBoardOpen, + onWorkspaceBoardDragPreviewStart, + onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard, + shouldShowWorkspaceBoardDropIndicator + } = args + const { + worktreePointerDragRef, + suppressWorktreeClickUntilRef, + setWorktreeDragState, + setDragOverStatus, + setPinDragOver + } = runtime + + const flushWorktreePointerDrag = useCallback(() => { + const drag = worktreePointerDragRef.current + if (!drag) { + return + } + flushWorktreePointerDragFrame({ + drag, + ctx, + workspaceBoardOpen, + onWorkspaceBoardDragPreviewStart, + onWorkspaceBoardDragPreviewCommit, + shouldShowWorkspaceBoardDropIndicator, + setWorktreeDragState, + setDragOverStatus, + setPinDragOver + }) + }, [ + ctx, + onWorkspaceBoardDragPreviewCommit, + onWorkspaceBoardDragPreviewStart, + setDragOverStatus, + setPinDragOver, + setWorktreeDragState, + shouldShowWorkspaceBoardDropIndicator, + workspaceBoardOpen, + worktreePointerDragRef + ]) + + const scheduleWorktreePointerDragFrame = useCallback( + (drag: WorktreePointerDrag) => { + if (drag.frameId !== null) { + return + } + drag.frameId = window.requestAnimationFrame(flushWorktreePointerDrag) + }, + [flushWorktreePointerDrag] + ) + + const startWorktreePointerAutoscroll = useWorktreePointerDragAutoscroll({ + session, + runtime, + scrollRef, + markScrollMovement, + scheduleWorktreePointerDragFrame + }) + + const beginWorktreePointerDrag = useCallback( + (drag: WorktreePointerDrag) => { + const { preview, offsetX, offsetY, height } = createSidebarDragPreview({ + sourceRow: drag.sourceRow, + pointerX: drag.currentX, + pointerY: drag.currentY, + draggedCount: drag.draggedIds.length + }) + drag.active = true + drag.preview = preview + drag.previewOffsetX = offsetX + drag.previewOffsetY = offsetY + suppressWorktreeClickUntilRef.current = window.performance.now() + 500 + setSidebarPointerDragDocumentStyles(true) + session.worktreeDragSessionRef.current = { + draggingWorktreeId: drag.worktreeId, + sourceGroupKey: drag.sourceGroupKey, + draggedIds: drag.draggedIds, + reorderDraggedIds: drag.reorderDraggedIds, + reorderUnitDraggedIds: drag.reorderUnitDraggedIds, + rects: drag.rects, + // Why: reuse the floating preview's own offset so the hit test tracks the + // card the user sees, not the raw pointer. + grab: getWorktreeSidebarDragGrab({ offsetY, height }), + anchor: null + } + setWorktreeDragState({ + draggingWorktreeId: drag.worktreeId, + sourceGroupKey: drag.sourceGroupKey, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: drag.currentY + }) + startWorktreePointerAutoscroll() + scheduleWorktreePointerDragFrame(drag) + }, + [ + scheduleWorktreePointerDragFrame, + session, + setWorktreeDragState, + startWorktreePointerAutoscroll, + suppressWorktreeClickUntilRef + ] + ) + + const handleWorktreeRowPointerDown = useCallback( + (event: React.PointerEvent, worktreeId: string, rowKey: string) => { + if (event.button !== 0 || event.pointerType === 'touch') { + return + } + const sourceRow = event.currentTarget + if (isSidebarPointerDragBlocked(event.target, sourceRow)) { + return + } + const sourceGroupKey = session.groupKeyByRowKey.get(rowKey) + const container = scrollRef.current + if (!sourceGroupKey || !container) { + return + } + const rects = getWorktreeSidebarDragRectsForGroup(container, sourceGroupKey) + const canPreviewWorkspaceBoardOnDrag = + !workspaceBoardOpen && + onWorkspaceBoardDragPreviewStart !== NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK + if ( + rects.length <= 1 && + !hasWorkspaceKanbanSidebarDropBoard() && + !canPreviewWorkspaceBoardOnDrag + ) { + return + } + const draggedIds = + selectedWorktreeIds.has(worktreeId) && selectedWorktrees.length > 1 + ? selectedWorktrees.map((worktree) => worktree.id) + : [worktreeId] + const reorderDraggedIds = session.getReorderDraggedIds(draggedIds) + const reorderUnitDraggedIds = session.getReorderUnitDraggedIds( + sourceGroupKey, + reorderDraggedIds + ) + worktreePointerDragRef.current = { + pointerId: event.pointerId, + sourceRow, + startX: event.clientX, + startY: event.clientY, + currentX: event.clientX, + currentY: event.clientY, + worktreeId, + draggedIds, + reorderDraggedIds, + reorderUnitDraggedIds, + sourceGroupKey, + rects, + active: false, + preview: null, + previewOffsetX: 0, + previewOffsetY: 0, + workspaceBoardDragPreviewRequested: false, + frameId: null, + latestBoardDropTarget: null, + latestStatusDropTarget: null + } + }, + [ + onWorkspaceBoardDragPreviewStart, + scrollRef, + selectedWorktreeIds, + selectedWorktrees, + session, + workspaceBoardOpen, + worktreePointerDragRef + ] + ) + + const handleWorktreeRowClickCapture = useCallback( + (event: React.MouseEvent) => { + if (window.performance.now() >= suppressWorktreeClickUntilRef.current) { + return + } + event.preventDefault() + event.stopPropagation() + }, + [suppressWorktreeClickUntilRef] + ) + + useWorktreePointerDragWindowEvents({ + ctx, + runtime, + beginWorktreePointerDrag, + scheduleWorktreePointerDragFrame, + onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard + }) + + useEffect(() => { + const handleClick = (event: MouseEvent): void => { + if (window.performance.now() >= suppressWorktreeClickUntilRef.current) { + return + } + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + } + + document.addEventListener('click', handleClick, true) + return () => document.removeEventListener('click', handleClick, true) + }, [suppressWorktreeClickUntilRef]) + + return { handleWorktreeRowPointerDown, handleWorktreeRowClickCapture } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-header-drag.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-header-drag.ts new file mode 100644 index 00000000000..93be311bc8e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-header-drag.ts @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useMemo } from 'react' +import type React from 'react' +import { useAppStore } from '@/store' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { ProjectOrderBy } from '../../../../../shared/ui-chrome-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import type { HostHeaderRow, HostSectionRow } from '../host-section-rows' +import type { Row, WorktreeGroupBy } from '../worktree-list-groups' +import type { RenderRow } from '../worktree-list-virtual-rows' +import { + getProjectGroupHeaderSectionEndByGroupId, + getRepoHeaderSectionEndByRepoId +} from '../worktree-header-section-boundaries' +import { useHostHeaderDrag } from '../host-header-drag' +import { useRepoHeaderDrag } from '../project-header-drag' +import { getSidebarOrderedRepoHeaderIdsByBucket } from '../project-header-drop' +import { useProjectGroupHeaderDrag } from '../project-group-header-drag' +import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from '../project-group-header-drop' +import { USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS } from './worktree-sidebar-scroll-tuning' + +export type WorktreeSidebarHeaderDrag = ReturnType + +function indexById(byBucket: ReadonlyMap): Map { + const map = new Map() + for (const ids of byBucket.values()) { + ids.forEach((id, index) => { + map.set(id, index) + }) + } + return map +} + +function bucketById(byBucket: ReadonlyMap): Map { + const map = new Map() + for (const [bucketKey, ids] of byBucket) { + for (const id of ids) { + map.set(id, bucketKey) + } + } + return map +} + +// Reordering the three header tiers (host card, project group, project) plus the bucket +// bookkeeping their drag controllers and DOM attributes need. +export function useWorktreeSidebarHeaderDrag(args: { + rows: HostSectionRow[] + renderRows: readonly RenderRow[] + firstHeaderIndex: number + allRepoIds: string[] + repoMap: Map + projectGroups: readonly ProjectGroup[] + groupBy: WorktreeGroupBy + projectOrderBy: ProjectOrderBy + scrollRef: React.RefObject + onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void + onHostDragActiveChange: (active: boolean) => void + suppressMeasurementAdjustmentUntilRef: React.MutableRefObject + directScrollInputUntilRef: React.MutableRefObject +}) { + const { + rows, + renderRows, + firstHeaderIndex, + allRepoIds, + repoMap, + projectGroups, + groupBy, + projectOrderBy, + scrollRef, + onReorderHostSections, + onHostDragActiveChange, + suppressMeasurementAdjustmentUntilRef, + directScrollInputUntilRef + } = args + const reorderRepos = useAppStore((s) => s.reorderRepos) + const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) + const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) + const hasProjectGroups = projectGroups.length > 0 + const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual' + const canReorderProjectGroupHeaders = groupBy === 'repo' && hasProjectGroups + const projectGroupByIdForHeaderDrag = useMemo( + () => new Map(projectGroups.map((group) => [group.id, group])), + [projectGroups] + ) + + // Why: reorder keeps scrollTop stable; flag direct scroll input so anchor-restore won't chase the moved row (jumpy drop). + const suppressScrollCorrectionForHeaderCommit = useCallback(() => { + const suppressUntil = window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + suppressMeasurementAdjustmentUntilRef.current = suppressUntil + directScrollInputUntilRef.current = suppressUntil + }, [directScrollInputUntilRef, suppressMeasurementAdjustmentUntilRef]) + const commitRepoReorder = useCallback( + (orderedIds: string[]) => { + suppressScrollCorrectionForHeaderCommit() + reorderRepos(orderedIds) + }, + [reorderRepos, suppressScrollCorrectionForHeaderCommit] + ) + const orderedHostIds = useMemo( + () => + rows + .filter((row): row is HostHeaderRow => row.type === 'host-header') + .map((row) => row.hostId), + [rows] + ) + const hostDrag = useHostHeaderDrag({ + orderedHostIds, + onCommit: onReorderHostSections, + getScrollContainer: () => scrollRef.current + }) + useEffect(() => { + onHostDragActiveChange(hostDrag.state.draggingHostId !== null) + }, [hostDrag.state.draggingHostId, onHostDragActiveChange]) + useEffect(() => () => onHostDragActiveChange(false), [onHostDragActiveChange]) + + const sidebarRepoHeaderIdsByBucket = useMemo( + () => + getSidebarOrderedRepoHeaderIdsByBucket( + rows.filter((row): row is Row => row.type !== 'host-header') + ), + [rows] + ) + const sidebarProjectGroupHeaderIdsByBucket = useMemo( + () => + getSidebarOrderedProjectGroupHeaderIdsByBucket( + rows.filter((row): row is Row => row.type !== 'host-header'), + projectGroupByIdForHeaderDrag + ), + [projectGroupByIdForHeaderDrag, rows] + ) + const repoHeaderIndexByRepoId = useMemo( + () => indexById(sidebarRepoHeaderIdsByBucket), + [sidebarRepoHeaderIdsByBucket] + ) + const repoHeaderBucketByRepoId = useMemo( + () => bucketById(sidebarRepoHeaderIdsByBucket), + [sidebarRepoHeaderIdsByBucket] + ) + const projectGroupHeaderIndexByGroupId = useMemo( + () => indexById(sidebarProjectGroupHeaderIdsByBucket), + [sidebarProjectGroupHeaderIdsByBucket] + ) + const projectGroupHeaderBucketByGroupId = useMemo( + () => bucketById(sidebarProjectGroupHeaderIdsByBucket), + [sidebarProjectGroupHeaderIdsByBucket] + ) + const commitProjectGroupOrder = useCallback( + (repoId: string, projectGroupId: string | null, order: number) => { + void moveProjectToGroup(repoId, projectGroupId, order) + }, + [moveProjectToGroup] + ) + const commitProjectGroupHeaderOrder = useCallback( + (groupId: string, tabOrder: number) => { + if (!Number.isFinite(tabOrder)) { + return + } + suppressScrollCorrectionForHeaderCommit() + void updateProjectGroup(groupId, { tabOrder }) + }, + [suppressScrollCorrectionForHeaderCommit, updateProjectGroup] + ) + // Drag applies only in manual order; still construct the controller inert for stable hook order. + const repoDrag = useRepoHeaderDrag({ + orderedRepoIds: allRepoIds, + sidebarRepoHeaderIdsByBucket, + repoById: repoMap, + usesProjectGroupOrdering: hasProjectGroups, + onCommitRepoOrder: commitRepoReorder, + onCommitProjectGroupOrder: commitProjectGroupOrder, + getScrollContainer: () => scrollRef.current + }) + const projectGroupDrag = useProjectGroupHeaderDrag({ + sidebarProjectGroupHeaderIdsByBucket, + projectGroupById: projectGroupByIdForHeaderDrag, + onCommitProjectGroupTabOrder: commitProjectGroupHeaderOrder, + getScrollContainer: () => scrollRef.current + }) + const repoHeaderSectionEndByRepoId = useMemo( + () => + getRepoHeaderSectionEndByRepoId({ + rows: renderRows, + firstHeaderIndex, + sidebarRepoHeaderIdsByBucket, + repoHeaderBucketByRepoId + }), + [firstHeaderIndex, renderRows, repoHeaderBucketByRepoId, sidebarRepoHeaderIdsByBucket] + ) + const projectGroupHeaderSectionEndByGroupId = useMemo( + () => + getProjectGroupHeaderSectionEndByGroupId({ + rows: renderRows, + firstHeaderIndex, + sidebarProjectGroupHeaderIdsByBucket, + projectGroupHeaderBucketByGroupId + }), + [ + firstHeaderIndex, + projectGroupHeaderBucketByGroupId, + renderRows, + sidebarProjectGroupHeaderIdsByBucket + ] + ) + + return { + canReorderRepoHeaders, + canReorderProjectGroupHeaders, + orderedHostIds, + hostDrag, + repoDrag, + projectGroupDrag, + sidebarRepoHeaderIdsByBucket, + sidebarProjectGroupHeaderIdsByBucket, + repoHeaderIndexByRepoId, + repoHeaderBucketByRepoId, + projectGroupHeaderIndexByGroupId, + projectGroupHeaderBucketByGroupId, + repoHeaderSectionEndByRepoId, + projectGroupHeaderSectionEndByGroupId + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-scroll-suppression.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-scroll-suppression.ts new file mode 100644 index 00000000000..e606c270a70 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-scroll-suppression.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef } from 'react' +import type React from 'react' +import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from '../WorktreeCardAgents' +import { + createPendingRevealScroll, + isRevealScrollSettling, + type PendingRevealScroll +} from '../worktree-sidebar-reveal-scroll-settle' +import { + EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS, + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS +} from './worktree-sidebar-scroll-tuning' + +export type WorktreeSidebarScrollSuppression = ReturnType< + typeof useWorktreeSidebarScrollSuppression +> + +// Tracks the two suppression windows the virtualizer honours: measurement correction +// (any scroll movement) and anchor restore (direct user input or a settling reveal). +export function useWorktreeSidebarScrollSuppression( + scrollRef: React.RefObject +) { + const suppressMeasurementAdjustmentUntilRef = useRef(0) + const directScrollInputUntilRef = useRef(0) + const pendingRevealScrollRef = useRef(null) + + const markScrollMovement = useCallback(() => { + suppressMeasurementAdjustmentUntilRef.current = + window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + }, []) + const markDirectScrollInput = useCallback(() => { + const suppressUntil = window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + suppressMeasurementAdjustmentUntilRef.current = suppressUntil + directScrollInputUntilRef.current = suppressUntil + }, []) + const hasDirectScrollInput = useCallback( + () => window.performance.now() < directScrollInputUntilRef.current, + [] + ) + const markRevealScroll = useCallback((targetTop: number) => { + pendingRevealScrollRef.current = createPendingRevealScroll(targetTop, window.performance.now()) + }, []) + const isRevealScrollSettlingNow = useCallback(() => { + const settling = isRevealScrollSettling({ + now: window.performance.now(), + pending: pendingRevealScrollRef.current, + scrollTop: scrollRef.current?.scrollTop ?? 0 + }) + if (!settling) { + pendingRevealScrollRef.current = null + } + return settling + }, [scrollRef]) + // Why: programmatic scrolls keep measurement correction quiet, but only direct input blocks anchor-restore retries. + // A reveal's smooth scroll is the exception: restoring the anchor mid-animation cancels it a few pixels in. + const shouldSkipScrollAnchorRestore = useCallback( + () => + window.performance.now() < directScrollInputUntilRef.current || isRevealScrollSettlingNow(), + [isRevealScrollSettlingNow] + ) + + useEffect(() => { + const handleSuppress = () => { + // Why: let an expanding agent row grow in place instead of TanStack compensating scrollTop. + suppressMeasurementAdjustmentUntilRef.current = + window.performance.now() + EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + } + window.addEventListener(SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT, handleSuppress) + return () => { + window.removeEventListener(SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT, handleSuppress) + } + }, []) + + return { + suppressMeasurementAdjustmentUntilRef, + directScrollInputUntilRef, + markScrollMovement, + markDirectScrollInput, + hasDirectScrollInput, + markRevealScroll, + shouldSkipScrollAnchorRestore + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-status-mutations.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-status-mutations.ts new file mode 100644 index 00000000000..77f9cc38b3f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-status-mutations.ts @@ -0,0 +1,201 @@ +import { useCallback } from 'react' +import { useAppStore } from '@/store' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition, + Worktree +} from '../../../../../shared/worktree/types' +import type { WorktreeMeta } from '../../../../../shared/worktree/meta-types' +import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../workspace-status' +import { + buildManualOrderUpdatesForGroupDrop, + buildManualOrderUpdatesForVisibleGroups, + shouldWriteManualOrderForGroupDrop, + type WorktreeDragGroup +} from '../worktree-manual-order' +import { buildWorkspaceKanbanSidebarDropUpdates } from '../workspace-kanban-sidebar-drop' +import type { SortBy } from '../smart-sort' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' + +function buildRankByWorktreeId( + groups: readonly WorktreeDragGroup[], + worktreeMap: Map +): Map { + const rankByWorktreeId = new Map() + for (const group of groups) { + for (const worktreeId of group.worktreeIds) { + const worktree = worktreeMap.get(worktreeId) + if (worktree) { + rankByWorktreeId.set(worktreeId, worktree.manualOrder ?? worktree.sortOrder) + } + } + } + return rankByWorktreeId +} + +// Every write a sidebar drop can make: status changes, pin, manual order, and the board lane drop. +export function useWorktreeStatusMutations(args: { + worktreeMap: Map + workspaceStatuses: readonly WorkspaceStatusDefinition[] + sortBy: SortBy +}) { + const { worktreeMap, workspaceStatuses, sortBy } = args + const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) + const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta) + const setSortBy = useAppStore((s) => s.setSortBy) + const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal) + + const moveWorktreeToStatus = useCallback( + (worktreeId: string, status: WorkspaceStatus) => { + const current = worktreeMap.get(worktreeId) + if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { + return + } + void updateWorktreeMeta(worktreeId, { workspaceStatus: status }) + }, + [updateWorktreeMeta, worktreeMap, workspaceStatuses] + ) + + const moveWorktreesToStatus = useCallback( + (worktreeIds: readonly string[], status: WorkspaceStatus) => { + const updates = new Map() + for (const worktreeId of worktreeIds) { + const current = worktreeMap.get(worktreeId) + if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { + continue + } + updates.set(worktreeId, { workspaceStatus: status }) + } + if (updates.size > 0) { + void updateWorktreesMeta(updates) + } + }, + [updateWorktreesMeta, worktreeMap, workspaceStatuses] + ) + + const moveWorktreesToStatusAtIndex = useCallback( + (dropArgs: WorktreeStatusDropAtIndexArgs) => { + const order = buildManualOrderUpdatesForGroupDrop({ + groups: dropArgs.groups, + targetGroupKey: getWorkspaceStatusGroupKey(dropArgs.status), + draggedIds: dropArgs.worktreeIds, + dropIndex: dropArgs.dropIndex, + now: Date.now(), + rankByWorktreeId: buildRankByWorktreeId(dropArgs.groups, worktreeMap) + }) + const updates = new Map>() + for (const worktreeId of dropArgs.worktreeIds) { + const current = worktreeMap.get(worktreeId) + if (!current) { + continue + } + const next: Partial = {} + if (getWorkspaceStatus(current, workspaceStatuses) !== dropArgs.status) { + next.workspaceStatus = dropArgs.status + } + updates.set(worktreeId, next) + } + for (const [worktreeId, manualOrder] of order.updates) { + updates.set(worktreeId, { ...updates.get(worktreeId), ...manualOrder }) + } + for (const [worktreeId, update] of Array.from(updates)) { + if (Object.keys(update).length === 0) { + updates.delete(worktreeId) + } + } + if (updates.size === 0) { + return + } + // Why: the insertion line promises exact placement, so persist manual order on a cross-status drop. + if (order.changed) { + setSortBy('manual') + } + void updateWorktreesMeta(updates) + }, + [setSortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] + ) + + const pinWorktree = useCallback( + (worktreeId: string) => { + setWorktreesPinnedAndReveal([worktreeId], true) + }, + [setWorktreesPinnedAndReveal] + ) + + const pinWorktrees = useCallback( + (worktreeIds: readonly string[]) => { + setWorktreesPinnedAndReveal(worktreeIds, true) + }, + [setWorktreesPinnedAndReveal] + ) + + const reorderWorktrees = useCallback( + (reorderArgs: { + groups: readonly WorktreeDragGroup[] + sourceGroupKey: string + draggedIds: readonly string[] + dropIndex: number + }) => { + const result = buildManualOrderUpdatesForVisibleGroups({ + ...reorderArgs, + now: Date.now(), + rankByWorktreeId: buildRankByWorktreeId(reorderArgs.groups, worktreeMap) + }) + if (!result.changed) { + return + } + // Why: only switch to Manual after a real move so accidental click-drags don't change the sort. + setSortBy('manual') + void updateWorktreesMeta(result.updates) + }, + [setSortBy, updateWorktreesMeta, worktreeMap] + ) + + const shouldShowWorkspaceBoardDropIndicator = useCallback( + (worktreeIds: readonly string[], status: WorkspaceStatus) => { + const sourceGroupKeys = worktreeIds.flatMap((worktreeId) => { + const worktree = worktreeMap.get(worktreeId) + return worktree ? [getWorkspaceStatus(worktree, workspaceStatuses)] : [] + }) + return shouldWriteManualOrderForGroupDrop({ + sortBy, + sourceGroupKeys, + targetGroupKey: status + }) + }, + [sortBy, worktreeMap, workspaceStatuses] + ) + + const dropWorktreesOnWorkspaceBoard = useCallback( + (dropArgs: WorktreeStatusDropAtIndexArgs) => { + const result = buildWorkspaceKanbanSidebarDropUpdates({ + ...dropArgs, + worktreeById: worktreeMap, + workspaceStatuses, + sortBy, + now: Date.now() + }) + if (result.updates.size === 0) { + return + } + // Why: switch to Manual when the drop changes order so the placement stays visible. + if (result.shouldSwitchToManual) { + setSortBy('manual') + } + useAppStore.getState().recordFeatureInteraction('workspace-board-actions') + void updateWorktreesMeta(result.updates) + }, + [setSortBy, sortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] + ) + + return { + moveWorktreeToStatus, + moveWorktreesToStatus, + moveWorktreesToStatusAtIndex, + pinWorktree, + pinWorktrees, + reorderWorktrees, + shouldShowWorkspaceBoardDropIndicator, + dropWorktreesOnWorkspaceBoard + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/virtual-row-dom-attributes.ts b/src/renderer/src/components/sidebar/worktree-list/virtual-row-dom-attributes.ts new file mode 100644 index 00000000000..7523418e7c1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/virtual-row-dom-attributes.ts @@ -0,0 +1,15 @@ +import { getVirtualRowTransform } from '../worktree-list-virtual-rows' + +export function getVirtualRowIndex(element: Element): number | null { + const index = Number.parseInt(element.getAttribute('data-index') ?? '', 10) + return Number.isNaN(index) ? null : index +} + +export function getVirtualRowKey(element: Element): string | null { + return element.getAttribute('data-worktree-virtual-row-key') +} + +export function getWorktreeVirtualRowTransform(start: number, previewOffset: number): string { + const base = getVirtualRowTransform(start) + return previewOffset === 0 ? base : `${base} translateY(${previewOffset}px)` +} diff --git a/src/renderer/src/components/sidebar/worktree-list/virtualized-worktree-viewport-props.ts b/src/renderer/src/components/sidebar/worktree-list/virtualized-worktree-viewport-props.ts new file mode 100644 index 00000000000..2aaacf85795 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/virtualized-worktree-viewport-props.ts @@ -0,0 +1,116 @@ +import type React from 'react' +import type { AppState } from '@/store/types' +import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' +import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' +import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { ProjectOrderBy } from '../../../../../shared/ui-chrome-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { + WorkspaceLineage, + WorktreeLineage +} from '../../../../../shared/worktree/lineage-types' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition, + Worktree +} from '../../../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import type { HostSectionRow } from '../host-section-rows' +import type { ImportedWorktreeCardActionState } from '../imported-worktrees-card-actions' +import type { NewExternalWorktreesInboxActionState } from '../new-external-worktrees-inbox-actions' +import type { WorktreeDragGroup } from '../worktree-manual-order' +import type { + PinnedWorktreeDisplayPolicy, + ProjectGroupingModel, + WorktreeGroupBy +} from '../worktree-list-groups' + +export const EMPTY_PROJECT_GROUPS: readonly ProjectGroup[] = [] +export const NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK = (): void => {} + +export type WorktreeStatusDropAtIndexArgs = { + worktreeIds: readonly string[] + status: WorkspaceStatus + dropIndex: number + groups: readonly WorktreeDragGroup[] +} + +export type VirtualizedWorktreeViewportProps = { + rows: HostSectionRow[] + activeWorktreeId: string | null + currentWorktreeId: string | null + groupBy: WorktreeGroupBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + projectOrderBy: ProjectOrderBy + toggleGroup: (key: string) => void + collapsedGroups: Set + handleCreateForRepo: (projectId: string) => void + handleOpenRepoSettings: (projectId: string, sectionId?: string) => void + handleOpenWorktreeVisibility: (repo: Repo) => void + handleShowImportedWorktrees: (projectId: string) => void + handleKeepImportedWorktreesHidden: (projectId: string) => void + importedWorktreeCardActionState: ReadonlyMap + handleOpenSuppressExternalWorktreeInbox: (projectId: string) => void + newExternalWorktreeInboxActionState: ReadonlyMap + handleRemoveProject: (repo: Repo) => void + handleCreateGroupFromRepo: (repo: Repo) => void + handleMoveProjectToGroup: (repo: Repo, groupId: string) => void + handleRemoveProjectFromGroup: (repo: Repo) => void + handleRenameProjectGroup: (groupId: string, currentName: string) => void + handleDeleteProjectGroup: (groupId: string, groupName: string) => void + handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void + activeModal: string + pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null + clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void + agentSendTargetWorktreeId: string | null + worktrees: Worktree[] + folderWorkspaces: readonly FolderWorkspace[] + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onImmediateWorktreeActivate: (worktreeId: string, rowKey: string | undefined) => void + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + repoMap: Map + defaultHostId: ExecutionHostId + worktreeMap: Map + worktreeLineageById: Record + workspaceLineageByChildKey: Record + // Full canonical repo-id order; must include hidden repos or a reorder silently drops them. + allRepoIds: string[] + onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void + onHostDragActiveChange: (active: boolean) => void + prCache: AppState['prCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null + workspaceStatuses: readonly WorkspaceStatusDefinition[] + projectGrouping?: ProjectGroupingModel + projectGroups?: readonly ProjectGroup[] + onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void + onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void + onMoveWorktreesToStatusAtIndex: (args: WorktreeStatusDropAtIndexArgs) => void + onPinWorktree: (worktreeId: string) => void + onPinWorktrees: (worktreeIds: readonly string[]) => void + onDropWorktreesOnWorkspaceBoard: (args: WorktreeStatusDropAtIndexArgs) => void + workspaceBoardOpen: boolean + onWorkspaceBoardDragPreviewStart: () => void + onWorkspaceBoardDragPreviewCommit: () => void + onWorkspaceBoardDragPreviewCancel: () => void + shouldShowWorkspaceBoardDropIndicator: ( + worktreeIds: readonly string[], + status: WorkspaceStatus + ) => boolean + onReorderWorktrees: (args: { + groups: readonly WorktreeDragGroup[] + sourceGroupKey: string + draggedIds: readonly string[] + dropIndex: number + }) => void + // Why: grouping remounts the viewport, add/delete stays mounted; bridge both so the virtualizer never resets to scrollTop 0. + scrollOffsetRef: React.MutableRefObject + scrollAnchorRef: React.MutableRefObject +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-drag-groups.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-drag-groups.ts new file mode 100644 index 00000000000..eb93ecf617a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-drag-groups.ts @@ -0,0 +1,72 @@ +import { ALL_GROUP_KEY, PINNED_GROUP_KEY } from '../worktree-list-groups' +import type { HostSectionRow } from '../host-section-rows' +import type { WorktreeDragGroup } from '../worktree-manual-order' + +// A pinned duplicate of a worktree that also renders in its natural group is not its own drag slot. +function getNaturalWorktreeIds(rows: readonly HostSectionRow[]): Set { + return new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ) +} + +export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { + const groups: WorktreeDragGroup[] = [] + let current: { key: string; ids: string[] } | null = null + const naturalWorktreeIds = getNaturalWorktreeIds(rows) + + for (const row of rows) { + if (row.type === 'header') { + current = { key: row.key, ids: [] } + groups.push({ key: current.key, worktreeIds: current.ids }) + continue + } + if ( + row.type === 'host-header' || + row.type === 'imported-worktrees-card' || + row.type === 'new-external-worktrees-inbox' || + row.type === 'pending-creation' || + row.type === 'folder-workspace' + ) { + continue + } + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { + continue + } + if (!current) { + current = { key: ALL_GROUP_KEY, ids: [] } + groups.push({ key: current.key, worktreeIds: current.ids }) + } + current.ids.push(row.worktree.id) + } + + return groups.filter((group) => group.worktreeIds.length > 0) +} + +export function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): { + groupKeyByRowKey: Map + groupIndexByRowKey: Map +} { + const groupKeyByRowKey = new Map() + const groupIndexByRowKey = new Map() + const groupIndexes = new Map() + const naturalWorktreeIds = getNaturalWorktreeIds(rows) + for (const row of rows) { + if (row.type === 'header') { + groupIndexes.set(row.key, 0) + continue + } + if (row.type !== 'item') { + continue + } + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { + continue + } + const index = groupIndexes.get(row.sectionKey) ?? 0 + groupKeyByRowKey.set(row.rowKey, row.sectionKey) + groupIndexByRowKey.set(row.rowKey, index) + groupIndexes.set(row.sectionKey, index + 1) + } + return { groupKeyByRowKey, groupIndexByRowKey } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-drop-commit-context.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-drop-commit-context.ts new file mode 100644 index 00000000000..2fee58ba4a4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-drop-commit-context.ts @@ -0,0 +1,43 @@ +import type React from 'react' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition +} from '../../../../../shared/worktree/types' +import type { WorktreeDragGroup } from '../worktree-manual-order' +import type { WorktreeDragUnitGroup } from '../worktree-drag-units' +import type { WorktreeSidebarDropPreview } from '../worktree-sidebar-drop-preview' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' +import type { WorktreeStatusDropRequest } from './use-worktree-drag-session' +import type { WorktreeSidebarLineageDropTarget } from './worktree-row-drag-state' + +// The shared surface every drop path (pointer, native drag, document capture) commits through. +export type WorktreeDropCommitContext = { + scrollRef: React.RefObject + workspaceStatuses: readonly WorkspaceStatusDefinition[] + worktreeDragGroups: readonly WorktreeDragGroup[] + worktreeDragUnitGroups: readonly WorktreeDragUnitGroup[] + computeWorktreeDrop: (pointerY: number) => WorktreeSidebarDropPreview | null + computeWorktreeStatusDrop: ( + request: WorktreeStatusDropRequest + ) => WorktreeSidebarDropPreview | null + refreshWorktreeDragSession: () => boolean + getEligibleLineageDropTarget: ( + target: WorktreeSidebarLineageDropTarget, + draggedIds: readonly string[] + ) => WorktreeSidebarLineageDropTarget + commitWorktreeLineageParentDrop: (draggedIds: readonly string[], parentId: string) => boolean + clearReorderedWorktreeParents: (args: { + draggedIds: readonly string[] + sourceGroupKey: string + }) => void + clearWorktreeDrag: () => void + onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void + onMoveWorktreesToStatusAtIndex: (args: WorktreeStatusDropAtIndexArgs) => void + onReorderWorktrees: (args: { + groups: readonly WorktreeDragGroup[] + sourceGroupKey: string + draggedIds: readonly string[] + dropIndex: number + }) => void + onPinWorktrees: (worktreeIds: readonly string[]) => void +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-list-item-row.tsx b/src/renderer/src/components/sidebar/worktree-list/worktree-list-item-row.tsx new file mode 100644 index 00000000000..8e0a5c070c5 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-list-item-row.tsx @@ -0,0 +1,251 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import type { AppState } from '@/store/types' +import type { Worktree } from '../../../../../shared/worktree/types' +import WorktreeCard, { type ActiveSurfaceVariant } from '../WorktreeCard' +import { PINNED_GROUP_KEY, type WorktreeGroupBy } from '../worktree-list-groups' +import { + getFolderBackedRepoWorktreeCardContentIndent, + getFolderBackedRepoWorktreeCardSurfaceInset, + getLineageChildrenInlineStyle, + getLineageNestedRowGeometry, + getWorktreeCardContentIndent, + getWorktreeCardSurfaceInset, + LINEAGE_CHILDREN_INLINE_OFFSET +} from '../worktree-list-indentation' +import type { LineageToggleHandler } from '../worktree-lineage-toggle-handler-cache' +import { stopNestedWorktreeCardBubble } from './repo-header-event-guards' +import type { WorktreeItemRow } from './render-row-item-rows' +import { getWorktreeOptionId } from './worktree-option-dom' +import type { WorktreePointerDrag, WorktreeRowDragState } from './worktree-row-drag-state' + +export type WorktreeItemRowContext = { + settings: AppState['settings'] + groupBy: WorktreeGroupBy + folderBackedProjectGroupIds: ReadonlySet + groupKeyByRowKey: ReadonlyMap + groupIndexByRowKey: ReadonlyMap + agentSendTargetWorktreeId: string | null + worktreeDragState: WorktreeRowDragState + worktreePointerDragRef: React.MutableRefObject + nativeLineageDropTargetId: string | null + activeWorktreeId: string | null + currentWorktreeId: string | null + highlightedRevealRowKey: string | null + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + getActiveSurfaceVariant: (row: WorktreeItemRow) => ActiveSurfaceVariant + getLineageToggleHandler: (groupKey: string) => LineageToggleHandler + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + onImmediateActivate: (worktreeId: string, rowKey: string | undefined) => void + onRowClickCapture: (event: React.MouseEvent) => void + onRowPointerDown: ( + event: React.PointerEvent, + worktreeId: string, + rowKey: string + ) => void + onCardDragStart: ( + event: React.DragEvent, + worktreeId: string, + draggedIds: readonly string[] + ) => void + onCardDragEnd: () => void +} + +// Geometry differs three ways: a plain grouped row, a lineage child inheriting its parent's +// surface, and a row inside a folder-backed project group. +function getWorktreeItemRowGeometry( + ctx: WorktreeItemRowContext, + itemRow: WorktreeItemRow, + nested: boolean +): { surfaceInset: number; cardContentIndent: number; lineageChildrenInlineOffset?: number } { + const projectGroupId = itemRow.repo?.projectGroupId + const isFolderBackedRepoChild = + ctx.groupBy === 'repo' && + Boolean(projectGroupId && ctx.folderBackedProjectGroupIds.has(projectGroupId)) + // Why: experimental in-card lineage inherits the parent surface; legacy cards keep depth-based nested geometry. + const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth + const getCardContentIndent = (lineageDepth: number): number => + isFolderBackedRepoChild + ? getFolderBackedRepoWorktreeCardContentIndent({ + groupDepth: itemRow.groupDepth, + lineageDepth + }) + : getWorktreeCardContentIndent({ + isGrouped: ctx.groupBy !== 'none', + groupDepth: itemRow.groupDepth, + lineageDepth + }) + const nestedLineageGeometry = nested + ? getLineageNestedRowGeometry({ + experimentalNewWorktreeCardStyle: ctx.settings?.experimentalNewWorktreeCardStyle === true, + inheritedCardContentIndent: getCardContentIndent(0), + lineageDepth: itemRow.depth + }) + : null + // Why: grouped rows inherit their header depth, but the card surface still spans the full row. + const paddingLeft = + nested && ctx.groupBy !== 'none' + ? getWorktreeCardContentIndent({ + isGrouped: false, + groupDepth: itemRow.groupDepth, + lineageDepth: paddingDepth + }) + : getCardContentIndent(paddingDepth) + const surfaceInset = nestedLineageGeometry + ? nestedLineageGeometry.surfaceInset + : isFolderBackedRepoChild + ? getFolderBackedRepoWorktreeCardSurfaceInset({ + groupDepth: itemRow.groupDepth, + lineageDepth: paddingDepth + }) + : getWorktreeCardSurfaceInset({ + isGrouped: ctx.groupBy !== 'none', + groupDepth: itemRow.groupDepth + }) + return { + surfaceInset, + cardContentIndent: nestedLineageGeometry + ? nestedLineageGeometry.cardContentIndent + : Math.max(0, paddingLeft - surfaceInset), + lineageChildrenInlineOffset: nestedLineageGeometry?.lineageChildrenInlineOffset + } +} + +export function renderWorktreeItemRow( + ctx: WorktreeItemRowContext, + itemRow: WorktreeItemRow, + nested: boolean, + lineageChildren?: React.ReactNode, + forceActiveSurface = false +): React.JSX.Element { + const { surfaceInset, cardContentIndent, lineageChildrenInlineOffset } = + getWorktreeItemRowGeometry(ctx, itemRow, nested) + const lineageChildrenStyle = lineageChildren + ? getLineageChildrenInlineStyle(lineageChildrenInlineOffset ?? LINEAGE_CHILDREN_INLINE_OFFSET) + : undefined + const worktreeDragGroupKey = ctx.groupKeyByRowKey.get(itemRow.rowKey) + const isLineageDropTarget = + ctx.worktreeDragState.draggingWorktreeId && + (ctx.worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId === + itemRow.worktree.id || + ctx.nativeLineageDropTargetId === itemRow.worktree.id) + const isActiveWorktree = ctx.activeWorktreeId === itemRow.worktree.id + return ( +
{ + if (nested) { + event.stopPropagation() + } + ctx.onRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) + }} + style={{ + paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined + }} + > + +
+ ) +} + +// Rebuild the visible lineage subtree so each card renders its own children inline. +export function renderWorktreeLineageDescendants( + ctx: WorktreeItemRowContext, + parent: WorktreeItemRow, + descendants: readonly WorktreeItemRow[] +): React.ReactNode | undefined { + const childNodes: React.ReactNode[] = [] + let cursor = 0 + while (cursor < descendants.length) { + const child = descendants[cursor] + if (!child || child.depth !== parent.depth + 1) { + cursor++ + continue + } + + let nextSiblingIndex = cursor + 1 + while ( + nextSiblingIndex < descendants.length && + descendants[nextSiblingIndex]!.depth > child.depth + ) { + nextSiblingIndex++ + } + + const childLineageChildren = renderWorktreeLineageDescendants( + ctx, + child, + descendants.slice(cursor + 1, nextSiblingIndex) + ) + childNodes.push(renderWorktreeItemRow(ctx, child, true, childLineageChildren)) + cursor = nextSiblingIndex + } + return childNodes.length > 0 ? childNodes : undefined +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-notice-virtual-rows.tsx b/src/renderer/src/components/sidebar/worktree-list/worktree-notice-virtual-rows.tsx new file mode 100644 index 00000000000..d997548f4bd --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-notice-virtual-rows.tsx @@ -0,0 +1,116 @@ +import React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import type { Row } from '../worktree-list-groups' +import { getVirtualRowTransform } from '../worktree-list-virtual-rows' +import { PendingWorktreeRow } from '../PendingWorktreeRow' +import ImportedWorktreesVisibilityLine from '../ImportedWorktreesVisibilityLine' +import NewExternalWorktreesInboxLine from '../NewExternalWorktreesInboxLine' +import type { ImportedWorktreeCardActionState } from '../imported-worktrees-card-actions' +import type { NewExternalWorktreesInboxActionState } from '../new-external-worktrees-inbox-actions' +import { canKeepImportedWorktreesHidden } from './imported-worktrees-card-visibility' + +type NoticeRowFrameProps = { + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void + className?: string + children: React.ReactNode +} + +// Every non-card sidebar row shares the same absolutely-positioned virtual frame. +function NoticeRowFrame({ + vItem, + measureVirtualRowElement, + className = 'absolute left-0 right-0 top-0', + children +}: NoticeRowFrameProps): React.JSX.Element { + return ( +
+ {children} +
+ ) +} + +export function renderImportedWorktreesVirtualRow(args: { + row: Extract + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void + actionState: ImportedWorktreeCardActionState | undefined + onShow: (projectId: string) => void + onKeepHidden: (projectId: string) => void +}): React.JSX.Element { + const { row, actionState } = args + return ( + + args.onShow(row.repo.id)} + onKeepHidden={ + canKeepImportedWorktreesHidden(row, actionState) + ? () => args.onKeepHidden(row.repo.id) + : undefined + } + /> + + ) +} + +export function renderNewExternalWorktreesInboxVirtualRow(args: { + row: Extract + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void + actionState: NewExternalWorktreesInboxActionState | undefined + onReview: (repo: Extract['repo']) => void + onSuppress: (projectId: string) => void +}): React.JSX.Element { + const { row, actionState } = args + return ( + + args.onReview(row.repo)} + onSuppress={() => args.onSuppress(row.repo.id)} + /> + + ) +} + +export function renderPendingCreationVirtualRow(args: { + row: Extract + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void +}): React.JSX.Element { + return ( + + + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-option-dom.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-option-dom.ts new file mode 100644 index 00000000000..ec52da40a12 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-option-dom.ts @@ -0,0 +1,58 @@ +export function getWorktreeOptionId(rowKey: string): string { + return `worktree-list-option-${encodeURIComponent(rowKey)}` +} + +export function getMountedWorktreeOptions( + worktreeId: string, + root?: ParentNode | null +): HTMLElement[] { + const scope = root ?? document + const result: HTMLElement[] = [] + scope.querySelectorAll('[data-worktree-id]').forEach((element) => { + if (element.dataset.worktreeId === worktreeId) { + result.push(element) + } + }) + return result +} + +export function markSidebarWorktreeActiveImmediately( + worktreeId: string, + primaryRowKey?: string +): void { + const sidebar = document.querySelector('[data-worktree-sidebar]') + const nextOptions = getMountedWorktreeOptions(worktreeId, sidebar) + const nextOption = nextOptions[0] + if (!nextOption) { + return + } + + sidebar + ?.querySelectorAll('[role="option"][aria-current="page"]') + .forEach((option) => option.removeAttribute('aria-current')) + + for (const option of nextOptions) { + option.setAttribute('aria-current', 'page') + } + sidebar + ?.querySelectorAll('[data-worktree-card-surface][data-worktree-card-active]') + .forEach((surface) => { + if (!nextOptions.some((option) => option.contains(surface))) { + surface.removeAttribute('data-worktree-card-active') + } + }) + for (const option of nextOptions) { + const activeSurfaceVariant = + primaryRowKey !== undefined + ? option.dataset.worktreeRowKey === primaryRowKey + ? 'primary' + : 'secondary' + : option === nextOption + ? 'primary' + : 'secondary' + const surface = option.matches('[data-worktree-card-surface]') + ? option + : option.querySelector('[data-worktree-card-surface]') + surface?.setAttribute('data-worktree-card-active', activeSurfaceVariant) + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-commit.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-commit.ts new file mode 100644 index 00000000000..1e09a403885 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-commit.ts @@ -0,0 +1,159 @@ +import { + getWorkspaceKanbanSidebarDropGroups, + getWorkspaceKanbanSidebarDropTarget, + isWorkspaceKanbanSidebarDropPointInBoard, + resolveWorkspaceKanbanSidebarFullLaneDropIndex +} from '../workspace-kanban-sidebar-drop' +import { resolveWorkspaceKanbanCardDropCommitTarget } from '../workspace-kanban-card-pointer-drag-dom' +import { getFullDropIndexForWorktreeDragUnit } from '../worktree-drag-units' +import { resolveWorktreeSidebarStatusDropCommitTarget } from '../worktree-sidebar-drop-preview' +import { + getPointerDropStatusTarget, + shouldPreferSidebarStatusDropTarget +} from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' +import type { WorktreeSidebarStatusDropTarget } from '../worktree-sidebar-drop-preview' +import { + NO_WORKTREE_SIDEBAR_DROP_TARGET, + type WorktreePointerDrag +} from './worktree-row-drag-state' + +type PointerDropCommitArgs = { + event: PointerEvent + drag: WorktreePointerDrag + ctx: WorktreeDropCommitContext + onWorkspaceBoardDragPreviewCommit: () => void + onDropWorktreesOnWorkspaceBoard: (args: WorktreeStatusDropAtIndexArgs) => void +} + +function commitStatusOrPinDrop( + args: PointerDropCommitArgs, + target: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null }, + dropIndex: number | null +): void { + const { drag, ctx } = args + if (target.isPinDrop) { + ctx.onPinWorktrees(drag.draggedIds) + return + } + if (!target.status) { + return + } + if (dropIndex === null) { + ctx.onMoveWorktreesToStatus(drag.reorderDraggedIds, target.status) + return + } + ctx.onMoveWorktreesToStatusAtIndex({ + worktreeIds: drag.reorderDraggedIds, + status: target.status, + dropIndex, + groups: ctx.worktreeDragGroups + }) +} + +// Resolve where a released pointer drag lands: workspace board lane, lineage parent, +// status/pin section, or a reorder slot inside the source group. +export function commitWorktreePointerDrop(args: PointerDropCommitArgs): void { + const { event, drag, ctx } = args + if (!ctx.refreshWorktreeDragSession()) { + ctx.clearWorktreeDrag() + return + } + const boardDropTarget = resolveWorkspaceKanbanCardDropCommitTarget({ + currentTarget: getWorkspaceKanbanSidebarDropTarget(event.clientX, event.clientY), + latestTrackedTarget: drag.latestBoardDropTarget, + x: event.clientX, + y: event.clientY + }) + if (isWorkspaceKanbanSidebarDropPointInBoard(event.clientX, event.clientY)) { + args.onWorkspaceBoardDragPreviewCommit() + } + if (boardDropTarget.isPinDrop) { + ctx.onPinWorktrees(drag.draggedIds) + } else if (boardDropTarget.status) { + args.onDropWorktreesOnWorkspaceBoard({ + worktreeIds: drag.reorderDraggedIds, + status: boardDropTarget.status, + // Why: the target counts rendered cards, but the groups are the full + // lane. Board search can make those two differ. + dropIndex: resolveWorkspaceKanbanSidebarFullLaneDropIndex( + boardDropTarget.status, + boardDropTarget.dropIndex + ), + groups: getWorkspaceKanbanSidebarDropGroups() + }) + } else { + const preferredStatusTarget = ctx.getEligibleLineageDropTarget( + ctx.scrollRef.current + ? getPointerDropStatusTarget({ + container: ctx.scrollRef.current, + x: event.clientX, + y: event.clientY + }) + : NO_WORKTREE_SIDEBAR_DROP_TARGET, + drag.draggedIds + ) + if (preferredStatusTarget.lineageParentId) { + ctx.commitWorktreeLineageParentDrop(drag.draggedIds, preferredStatusTarget.lineageParentId) + ctx.clearWorktreeDrag() + return + } + if ( + shouldPreferSidebarStatusDropTarget({ + sourceGroupKey: drag.sourceGroupKey, + target: preferredStatusTarget, + workspaceStatuses: ctx.workspaceStatuses + }) + ) { + const statusDrop = preferredStatusTarget.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: preferredStatusTarget.status, + draggedIds: drag.reorderDraggedIds + }) + : null + commitStatusOrPinDrop(args, preferredStatusTarget, statusDrop?.dropIndex ?? null) + ctx.clearWorktreeDrag() + return + } + const drop = ctx.computeWorktreeDrop(event.clientY) + if (drop) { + ctx.onReorderWorktrees({ + groups: ctx.worktreeDragGroups, + sourceGroupKey: drag.sourceGroupKey, + draggedIds: drag.reorderDraggedIds, + dropIndex: getFullDropIndexForWorktreeDragUnit({ + groups: ctx.worktreeDragUnitGroups, + sourceGroupKey: drag.sourceGroupKey, + dropIndex: drop.dropIndex + }) + }) + ctx.clearReorderedWorktreeParents({ + draggedIds: drag.draggedIds, + sourceGroupKey: drag.sourceGroupKey + }) + } else if (ctx.scrollRef.current) { + const currentPreview = preferredStatusTarget.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: preferredStatusTarget.status, + draggedIds: drag.reorderDraggedIds + }) + : null + const { target, preview: statusDrop } = resolveWorktreeSidebarStatusDropCommitTarget({ + currentTarget: preferredStatusTarget, + currentPreview, + latestTrackedTarget: drag.latestStatusDropTarget, + x: event.clientX, + y: event.clientY + }) + if (target.lineageParentId) { + ctx.commitWorktreeLineageParentDrop(drag.draggedIds, target.lineageParentId) + } else { + commitStatusOrPinDrop(args, target, statusDrop?.dropIndex ?? null) + } + } + } + ctx.clearWorktreeDrag() +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-flush.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-flush.ts new file mode 100644 index 00000000000..90d70d4534b --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-flush.ts @@ -0,0 +1,172 @@ +import type React from 'react' +import type { WorkspaceStatus } from '../../../../../shared/worktree/types' +import { + clearWorkspaceKanbanSidebarDropTargetVisual, + hasWorkspaceKanbanSidebarDropBoard, + isWorkspaceKanbanSidebarDropPointInBoard, + updateWorkspaceKanbanSidebarDropTargetVisual +} from '../workspace-kanban-sidebar-drop' +import { updateSidebarDragPreviewPosition } from '../worktree-sidebar-pointer-drag-dom' +import { + getPointerDropStatusTarget, + shouldPreferSidebarStatusDropTarget +} from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import { + applyWorktreeDropPreview, + clearWorktreeDropPreview, + NO_WORKTREE_SIDEBAR_DROP_TARGET, + updateLatestWorktreeStatusDropTarget, + type WorktreePointerDrag, + type WorktreeRowDragState, + type WorktreeSidebarLineageDropTarget +} from './worktree-row-drag-state' + +export type WorktreePointerDragFrameArgs = { + drag: WorktreePointerDrag + ctx: WorktreeDropCommitContext + workspaceBoardOpen: boolean + onWorkspaceBoardDragPreviewStart: () => void + onWorkspaceBoardDragPreviewCommit: () => void + shouldShowWorkspaceBoardDropIndicator: ( + worktreeIds: readonly string[], + status: WorkspaceStatus + ) => boolean + setWorktreeDragState: React.Dispatch> + setDragOverStatus: (status: WorkspaceStatus | null) => void + setPinDragOver: (pinDragOver: boolean) => void +} + +// Reflect a status/pin hover that has no insertion line of its own. +function showStatusHoverWithoutInsertionLine( + args: WorktreePointerDragFrameArgs, + target: WorktreeSidebarLineageDropTarget +): void { + const { drag, ctx } = args + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: drag.currentY, + status: target.status, + draggedIds: drag.reorderDraggedIds + }) + : null + updateLatestWorktreeStatusDropTarget(drag, target, statusDrop) + if (statusDrop) { + clearWorkspaceKanbanSidebarDropTargetVisual() + args.setDragOverStatus(null) + args.setPinDragOver(false) + args.setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, statusDrop, { + pointerY: drag.currentY, + matchPointerY: true + }) + ) + return + } + args.setDragOverStatus(target.status) + args.setPinDragOver(target.isPinDrop) + args.setWorktreeDragState((prev) => + clearWorktreeDropPreview(prev, { pointerY: drag.currentY, matchPointerY: true }) + ) +} + +function clearInsertionLine(args: WorktreePointerDragFrameArgs): void { + args.setDragOverStatus(null) + args.setPinDragOver(false) + args.setWorktreeDragState((prev) => + clearWorktreeDropPreview(prev, { pointerY: args.drag.currentY, matchPointerY: true }) + ) +} + +// One animation frame of an in-flight pointer drag: move the floating preview, then decide +// whether the pointer is over the workspace board, a status/pin section, or a reorder slot. +export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs): void { + const { drag, ctx } = args + drag.frameId = null + if (!drag.active || !drag.preview) { + return + } + updateSidebarDragPreviewPosition({ + preview: drag.preview, + pointerX: drag.currentX, + pointerY: drag.currentY, + offsetX: drag.previewOffsetX, + offsetY: drag.previewOffsetY + }) + if (!ctx.refreshWorktreeDragSession()) { + ctx.clearWorktreeDrag() + return + } + // Why: show the board preview as soon as a card drag begins so the drop target is visible up front, not only at the sidebar edge. + if ( + !drag.workspaceBoardDragPreviewRequested && + !args.workspaceBoardOpen && + !hasWorkspaceKanbanSidebarDropBoard() + ) { + drag.workspaceBoardDragPreviewRequested = true + args.onWorkspaceBoardDragPreviewStart() + } + const boardTarget = updateWorkspaceKanbanSidebarDropTargetVisual({ + x: drag.currentX, + y: drag.currentY, + shouldShowDropIndicator: (target) => + Boolean( + target.status && + args.shouldShowWorkspaceBoardDropIndicator(drag.reorderDraggedIds, target.status) + ) + }) + drag.latestBoardDropTarget = { + target: boardTarget, + x: drag.currentX, + y: drag.currentY + } + if (isWorkspaceKanbanSidebarDropPointInBoard(drag.currentX, drag.currentY)) { + args.onWorkspaceBoardDragPreviewCommit() + } + if (boardTarget.status || boardTarget.isPinDrop) { + drag.latestStatusDropTarget = null + clearInsertionLine(args) + return + } + + const sidebarContainer = ctx.scrollRef.current + const preferredStatusTarget = ctx.getEligibleLineageDropTarget( + sidebarContainer + ? getPointerDropStatusTarget({ + container: sidebarContainer, + x: drag.currentX, + y: drag.currentY + }) + : NO_WORKTREE_SIDEBAR_DROP_TARGET, + drag.draggedIds + ) + if (preferredStatusTarget.lineageParentId) { + updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, null) + clearWorkspaceKanbanSidebarDropTargetVisual() + clearInsertionLine(args) + return + } + if ( + shouldPreferSidebarStatusDropTarget({ + sourceGroupKey: drag.sourceGroupKey, + target: preferredStatusTarget, + workspaceStatuses: ctx.workspaceStatuses + }) + ) { + showStatusHoverWithoutInsertionLine(args, preferredStatusTarget) + return + } + + const drop = ctx.computeWorktreeDrop(drag.currentY) + if (!drop) { + showStatusHoverWithoutInsertionLine(args, preferredStatusTarget) + return + } + drag.latestStatusDropTarget = null + clearWorkspaceKanbanSidebarDropTargetVisual() + args.setDragOverStatus(null) + args.setPinDragOver(false) + args.setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, drop, { pointerY: drag.currentY, matchPointerY: true }) + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-row-drag-state.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-row-drag-state.ts new file mode 100644 index 00000000000..2bb47089e22 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-row-drag-state.ts @@ -0,0 +1,132 @@ +import type { WorkspaceKanbanCardTrackedDropTarget } from '../workspace-kanban-card-pointer-drag-dom' +import type { WorktreeSidebarDragRect } from '../worktree-sidebar-drag-autoscroll' +import type { + WorktreeSidebarDropPreview, + WorktreeSidebarStatusDropTarget, + WorktreeSidebarTrackedStatusDropTarget +} from '../worktree-sidebar-drop-preview' + +export type WorktreeRowDragState = { + draggingWorktreeId: string | null + sourceGroupKey: string | null + dropIndex: number | null + dropIndicatorY: number | null + previewOffsetsByWorktreeId: ReadonlyMap + pointerY: number | null +} + +export const EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS: ReadonlyMap = new Map() + +export const WORKTREE_ROW_DRAG_INITIAL_STATE: WorktreeRowDragState = { + draggingWorktreeId: null, + sourceGroupKey: null, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: null +} + +export type WorktreePointerDrag = { + pointerId: number + sourceRow: HTMLElement + startX: number + startY: number + currentX: number + currentY: number + worktreeId: string + draggedIds: readonly string[] + reorderDraggedIds: readonly string[] + reorderUnitDraggedIds: readonly string[] + sourceGroupKey: string + rects: readonly WorktreeSidebarDragRect[] + active: boolean + preview: HTMLElement | null + previewOffsetX: number + previewOffsetY: number + workspaceBoardDragPreviewRequested: boolean + frameId: number | null + latestBoardDropTarget: WorkspaceKanbanCardTrackedDropTarget | null + latestStatusDropTarget: WorktreeSidebarTrackedStatusDropTarget | null +} + +export type WorktreeSidebarLineageDropTarget = WorktreeSidebarStatusDropTarget & { + lineageParentId: string | null +} + +export const NO_WORKTREE_SIDEBAR_DROP_TARGET: WorktreeSidebarLineageDropTarget = { + status: null, + isPinDrop: false, + lineageParentId: null +} + +export function areWorktreeDragPreviewOffsetsEqual( + a: ReadonlyMap, + b: ReadonlyMap +): boolean { + if (a === b) { + return true + } + if (a.size !== b.size) { + return false + } + for (const [key, value] of a) { + if (b.get(key) !== value) { + return false + } + } + return true +} + +export function updateLatestWorktreeStatusDropTarget( + drag: WorktreePointerDrag, + target: WorktreeSidebarLineageDropTarget, + preview: WorktreeSidebarDropPreview | null +): void { + drag.latestStatusDropTarget = + target.status || target.isPinDrop || target.lineageParentId + ? { + target, + preview, + x: drag.currentX, + y: drag.currentY + } + : null +} + +// Why matchPointerY: pointer-driven updates must repaint when only the pointer moved; native-drag +// updates deliberately keep the previous state identity in that case. +export function clearWorktreeDropPreview( + previous: WorktreeRowDragState, + args: { pointerY: number | null; matchPointerY?: boolean } +): WorktreeRowDragState { + const unchanged = + previous.dropIndex === null && + previous.dropIndicatorY === null && + previous.previewOffsetsByWorktreeId.size === 0 && + (args.matchPointerY !== true || previous.pointerY === args.pointerY) + return unchanged + ? previous + : { + ...previous, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: args.pointerY + } +} + +export function applyWorktreeDropPreview( + previous: WorktreeRowDragState, + drop: WorktreeSidebarDropPreview, + args: { pointerY: number; matchPointerY?: boolean } +): WorktreeRowDragState { + const unchanged = + previous.dropIndex === drop.dropIndex && + previous.dropIndicatorY === drop.dropIndicatorY && + (args.matchPointerY !== true || previous.pointerY === args.pointerY) && + areWorktreeDragPreviewOffsetsEqual( + previous.previewOffsetsByWorktreeId, + drop.previewOffsetsByWorktreeId + ) + return unchanged ? previous : { ...previous, ...drop, pointerY: args.pointerY } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-section-header-row.tsx b/src/renderer/src/components/sidebar/worktree-list/worktree-section-header-row.tsx new file mode 100644 index 00000000000..dd77e3dd0da --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-section-header-row.tsx @@ -0,0 +1,393 @@ +import React from 'react' +import { ChevronDown } from 'lucide-react' +import type { VirtualItem } from '@tanstack/react-virtual' +import { cn } from '@/lib/utils' +import type { AppState } from '@/store/types' +import { RepoIconGlyph } from '@/components/repo/repo-icon' +import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' +import type { FolderWorkspacePathStatus } from '../../../../../shared/folder-workspace-path-status' +import { isConfirmedStaleFolderPathStatus } from '../../../../../shared/folder-workspace-path-status' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition +} from '../../../../../shared/worktree/types' +import type { GroupHeaderRow, WorktreeGroupBy } from '../worktree-list-groups' +import { PINNED_GROUP_KEY } from '../worktree-list-groups' +import { getWorkspaceStatusFromGroupKey } from '../workspace-status' +import { getVirtualRowTransform } from '../worktree-list-virtual-rows' +import { resolveProjectGroupHeaderColor } from '../project-header-color' +import { getRepoHeaderCreateState } from '../repo-header-create-state' +import { ProjectHeaderActions } from '../ProjectHeaderActions' +import { + getProjectGroupHeaderPaddingLeft, + WORKTREE_SECTION_HEADER_PADDING_LEFT +} from '../worktree-list-indentation' +import { FolderPathStatusIndicator } from './FolderPathStatusIndicator' +import { + ProjectGroupCreateWorkspaceButton, + ProjectGroupHeaderMenu +} from './project-group-header-actions' +import { + RepoHeaderCreateWorkspaceButton, + RepoHeaderProjectActionsMenu, + type RepoHeaderProjectActions +} from './repo-header-project-actions' +import { + handleRepoHeaderCollapseAffordancePointerDown, + shouldIgnoreRepoHeaderToggle +} from './repo-header-event-guards' +import type { WorktreeSidebarHeaderDrag } from './use-worktree-sidebar-header-drag' +import { getWorktreeOptionId } from './worktree-option-dom' + +export type SectionHeaderRowContext = { + groupBy: WorktreeGroupBy + collapsedGroups: Set + workspaceStatuses: readonly WorkspaceStatusDefinition[] + projectGroups: readonly ProjectGroup[] + sshConnectionStates: AppState['sshConnectionStates'] + highlightedRevealRowKey: string | null + dragOverStatus: WorkspaceStatus | null + pinDragOver: boolean + headerDrag: WorktreeSidebarHeaderDrag + getCachedFolderWorkspacePathStatus: (request: { + scope: 'project-group' + projectGroupId: string + }) => FolderWorkspacePathStatus | null + toggleGroupWithScrollAnchor: (groupKey: string) => void + projectActions: RepoHeaderProjectActions + onRenameProjectGroup: (groupId: string, currentName: string) => void + onDeleteProjectGroup: (groupId: string, groupName: string) => void + onCreateFolderWorkspace: (projectGroup: ProjectGroup) => void + onWorkspaceStatusDragOver: (event: React.DragEvent, status: WorkspaceStatus) => void + onWorkspaceStatusDragLeave: (event: React.DragEvent) => void + onWorkspacePinDragOver: (event: React.DragEvent) => void + onWorkspacePinDragLeave: (event: React.DragEvent) => void + onWorkspaceStatusDrop: (event: React.DragEvent, status: WorkspaceStatus) => void +} + +// The folder-scan project group whose parent path is gone can't create new workspaces. +function isFolderWorkspaceCreateDisabled(status: FolderWorkspacePathStatus | null): boolean { + return ( + status?.exists === false && + (isConfirmedStaleFolderPathStatus(status) || status.reason === 'ambiguous-connection') + ) +} + +export function renderWorktreeSectionHeaderRow(args: { + ctx: SectionHeaderRowContext + row: GroupHeaderRow + vItem: VirtualItem + isActiveStickyHeader: boolean + hasStickyHost: boolean + hasHeaderTopSpacing: boolean + measureVirtualRowElement: (element: HTMLDivElement | null) => void +}): React.JSX.Element { + const { ctx, row, vItem, isActiveStickyHeader } = args + const { headerDrag } = ctx + const isRepoHeader = ctx.groupBy === 'repo' && row.repo !== undefined + const isProjectGroupHeader = ctx.groupBy === 'repo' && row.projectGroup !== undefined + const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined + const projectGroupIdForHeader = + isProjectGroupHeader && !row.repo && typeof row.projectGroup?.id === 'string' + ? row.projectGroup.id + : undefined + const repoHeaderIndex = + projectIdForHeader !== undefined + ? headerDrag.repoHeaderIndexByRepoId.get(projectIdForHeader) + : undefined + const repoHeaderBucketKey = + projectIdForHeader !== undefined + ? headerDrag.repoHeaderBucketByRepoId.get(projectIdForHeader) + : undefined + const projectGroupHeaderIndex = + projectGroupIdForHeader !== undefined + ? headerDrag.projectGroupHeaderIndexByGroupId.get(projectGroupIdForHeader) + : undefined + const projectGroupHeaderBucketKey = + projectGroupIdForHeader !== undefined + ? headerDrag.projectGroupHeaderBucketByGroupId.get(projectGroupIdForHeader) + : undefined + const isDraggableRepoHeader = Boolean( + headerDrag.canReorderRepoHeaders && + isRepoHeader && + projectIdForHeader && + repoHeaderBucketKey && + (headerDrag.sidebarRepoHeaderIdsByBucket.get(repoHeaderBucketKey)?.length ?? 0) > 1 + ) + const isDraggableProjectGroupHeader = Boolean( + headerDrag.canReorderProjectGroupHeaders && + projectGroupIdForHeader && + projectGroupHeaderBucketKey && + (headerDrag.sidebarProjectGroupHeaderIdsByBucket.get(projectGroupHeaderBucketKey)?.length ?? + 0) > 1 + ) + const isDraggingThis = + headerDrag.canReorderRepoHeaders && + headerDrag.repoDrag.state.draggingRepoId !== null && + headerDrag.repoDrag.state.draggingRepoId === projectIdForHeader + const isDraggingThisProjectGroup = + headerDrag.canReorderProjectGroupHeaders && + headerDrag.projectGroupDrag.state.draggingGroupId !== null && + headerDrag.projectGroupDrag.state.draggingGroupId === projectGroupIdForHeader + const headerWorkspaceStatus = + ctx.groupBy === 'workspace-status' + ? getWorkspaceStatusFromGroupKey(row.key, ctx.workspaceStatuses) + : null + const isPinnedHeader = row.key === PINNED_GROUP_KEY + const repoHeaderColor = resolveProjectGroupHeaderColor({ + groupBy: ctx.groupBy, + headerKey: row.key, + badgeColor: row.repo?.badgeColor + }) + const createState = row.repo + ? getRepoHeaderCreateState({ + repo: row.repo, + label: row.label, + sshStatus: row.repo.connectionId + ? (ctx.sshConnectionStates.get(row.repo.connectionId)?.status ?? null) + : null + }) + : null + const folderBackedProjectGroup = + isProjectGroupHeader && + !row.repo && + row.projectGroup && + 'parentPath' in row.projectGroup && + row.projectGroup.parentPath + ? row.projectGroup + : null + const projectGroupPathStatus = folderBackedProjectGroup + ? ctx.getCachedFolderWorkspacePathStatus({ + scope: 'project-group', + projectGroupId: folderBackedProjectGroup.id + }) + : null + const isHeaderCollapsed = ctx.collapsedGroups.has(row.key) + // Why: repo/project/status/pinned share compact section chrome; flat "All" stays a simple label. + const showHeaderCollapseAffordance = + row.count > 0 && + (isRepoHeader || isProjectGroupHeader || headerWorkspaceStatus !== null || isPinnedHeader) + return ( +
+
ctx.onWorkspaceStatusDragOver(event, headerWorkspaceStatus) + : undefined + } + onDragLeave={ + isPinnedHeader + ? ctx.onWorkspacePinDragLeave + : headerWorkspaceStatus + ? ctx.onWorkspaceStatusDragLeave + : undefined + } + onDrop={ + headerWorkspaceStatus + ? (event) => ctx.onWorkspaceStatusDrop(event, headerWorkspaceStatus) + : undefined + } + onPointerDown={ + isDraggableRepoHeader && projectIdForHeader + ? (event) => headerDrag.repoDrag.onHandlePointerDown(event, projectIdForHeader) + : isDraggableProjectGroupHeader && projectGroupIdForHeader + ? (event) => + headerDrag.projectGroupDrag.onHandlePointerDown(event, projectGroupIdForHeader) + : undefined + } + onClick={(event) => { + if (shouldIgnoreRepoHeaderToggle(event)) { + return + } + ctx.toggleGroupWithScrollAnchor(row.key) + }} + onKeyDown={(e) => { + if (shouldIgnoreRepoHeaderToggle(e)) { + return + } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + ctx.toggleGroupWithScrollAnchor(row.key) + } + }} + > + {/* Why: grab cursor on icon+title only. Row still has handle attrs so + indent/padding can arm drag; actions are excluded via data-repo-header-actions. + self-stretch fills h-7 so grab matches the full title column height. */} +
+ {row.icon ? ( +
+ {row.repo ? ( + + ) : ( + + )} +
+ ) : null} + +
+
+
+ {row.label} +
+ + +
+
+
+ + + {showHeaderCollapseAffordance ? ( +
{ + event.preventDefault() + event.stopPropagation() + ctx.toggleGroupWithScrollAnchor(row.key) + }} + > + +
+ ) : null} + + {isProjectGroupHeader && !row.repo && projectGroupIdForHeader ? ( + + ) : null} + + {folderBackedProjectGroup ? ( + + ) : null} + + {row.repo && ctx.groupBy === 'repo' ? ( + + ) : null} + + {row.repo && ctx.groupBy === 'repo' ? ( + + ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-sidebar-scroll-tuning.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-sidebar-scroll-tuning.ts new file mode 100644 index 00000000000..bb404507e26 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-sidebar-scroll-tuning.ts @@ -0,0 +1,42 @@ +import type React from 'react' + +// Debounce re-sort after a sortEpoch bump so background score changes don't jar row positions. +export const SORT_SETTLE_MS = 3_000 +export const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500 +export const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300 +export const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4 + +export const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { + // Why: TanStack Virtual owns scroll correction; native overflow anchoring fights it and causes jumps. + overflowAnchor: 'none' +} + +const recordKeyCountCache = new WeakMap, number>() + +export function countRecordKeysByReference(record: Record): number { + const cached = recordKeyCountCache.get(record) + if (cached !== undefined) { + return cached + } + const count = Object.keys(record).length + recordKeyCountCache.set(record, count) + return count +} + +export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: { + isScrolling: boolean + now: number + suppressUntil: number +}): boolean { + return !args.isScrolling && args.now >= args.suppressUntil +} + +export function resolvePendingSidebarReveal(args: { + targetIndex: number + targetWorktreeStillExists: boolean +}): 'scroll-and-clear' | 'clear' | 'keep-pending' { + if (args.targetIndex !== -1) { + return 'scroll-and-clear' + } + return args.targetWorktreeStillExists ? 'keep-pending' : 'clear' +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-context.ts new file mode 100644 index 00000000000..122bf81fc35 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-context.ts @@ -0,0 +1,149 @@ +import type React from 'react' +import type { AppState } from '@/store/types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { RenderRow } from '../worktree-list-virtual-rows' +import type { LineageToggleHandler } from '../worktree-lineage-toggle-handler-cache' +import type { SidebarRevealHighlight } from './use-sidebar-reveal-highlight' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import type { useFolderWorkspacePathStatusRows } from './use-folder-workspace-path-status-rows' +import type { usePrimaryActiveWorktreeRow } from './use-primary-active-worktree-row' +import type { useWorkspaceStatusRowDrag } from './use-workspace-status-row-drag' +import type { useWorktreeNativeDrag } from './use-worktree-native-drag' +import type { WorktreeSidebarHeaderDrag } from './use-worktree-sidebar-header-drag' +import type { WorktreeListVirtualizer } from './use-worktree-list-virtualizer' +import type { VirtualizedWorktreeViewportProps } from './virtualized-worktree-viewport-props' +import type { WorktreeVirtualRowContext } from './worktree-virtual-row-dispatch' + +type BuildArgs = { + props: VirtualizedWorktreeViewportProps + projectGroups: readonly ProjectGroup[] + renderRows: RenderRow[] + firstHeaderIndex: number + virtualization: WorktreeListVirtualizer + measureVirtualRowElement: (element: HTMLDivElement | null) => void + settings: AppState['settings'] + sshConnectionStates: AppState['sshConnectionStates'] + newCardStyle: boolean + folderBackedProjectGroupIds: ReadonlySet + session: WorktreeDragSession + runtime: WorktreeDragRuntime + primaryActive: ReturnType + reveal: SidebarRevealHighlight + statusDrag: ReturnType + nativeDrag: ReturnType + headerDrag: WorktreeSidebarHeaderDrag + getCachedFolderWorkspacePathStatus: ReturnType + getLineageToggleHandler: (groupKey: string) => LineageToggleHandler + toggleGroupWithScrollAnchor: (groupKey: string) => void + onRowClickCapture: (event: React.MouseEvent) => void + onRowPointerDown: ( + event: React.PointerEvent, + worktreeId: string, + rowKey: string + ) => void +} + +// Assembles the per-render context the row dispatcher reads. Plain data — everything in it +// is either a prop, a memoised hook result, or a stable callback. +export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtualRowContext { + const { props, runtime, session, statusDrag, headerDrag, primaryActive, reveal } = args + return { + renderRows: args.renderRows, + firstHeaderIndex: args.firstHeaderIndex, + activeStickyHeaderIndexRef: args.virtualization.activeStickyHeaderIndexRef, + activeStickyHostIndexRef: args.virtualization.activeStickyHostIndexRef, + measureVirtualRowElement: args.measureVirtualRowElement, + groupBy: props.groupBy, + workspaceStatuses: props.workspaceStatuses, + activeWorktreeId: props.activeWorktreeId, + worktreeDragState: runtime.worktreeDragState, + orderedHostIds: headerDrag.orderedHostIds, + hostDrag: headerDrag.hostDrag, + toggleGroupWithScrollAnchor: args.toggleGroupWithScrollAnchor, + importedWorktreeCardActionState: props.importedWorktreeCardActionState, + newExternalWorktreeInboxActionState: props.newExternalWorktreeInboxActionState, + onShowImportedWorktrees: props.handleShowImportedWorktrees, + onKeepImportedWorktreesHidden: props.handleKeepImportedWorktreesHidden, + onOpenWorktreeVisibility: props.handleOpenWorktreeVisibility, + onOpenSuppressExternalWorktreeInbox: props.handleOpenSuppressExternalWorktreeInbox, + onWorkspaceStatusDragOver: statusDrag.handleWorkspaceStatusDragOver, + onWorkspaceStatusDragLeave: statusDrag.handleWorkspaceStatusDragLeave, + onWorkspaceStatusDrop: statusDrag.handleWorkspaceStatusDrop, + header: { + groupBy: props.groupBy, + collapsedGroups: props.collapsedGroups, + workspaceStatuses: props.workspaceStatuses, + projectGroups: args.projectGroups, + sshConnectionStates: args.sshConnectionStates, + highlightedRevealRowKey: reveal.highlightedRevealRowKey, + dragOverStatus: runtime.dragOverStatus, + pinDragOver: runtime.pinDragOver, + headerDrag, + getCachedFolderWorkspacePathStatus: args.getCachedFolderWorkspacePathStatus, + toggleGroupWithScrollAnchor: args.toggleGroupWithScrollAnchor, + projectActions: { + onOpenRepoSettings: props.handleOpenRepoSettings, + onOpenWorktreeVisibility: props.handleOpenWorktreeVisibility, + onCreateGroupFromRepo: props.handleCreateGroupFromRepo, + onMoveProjectToGroup: props.handleMoveProjectToGroup, + onRemoveProjectFromGroup: props.handleRemoveProjectFromGroup, + onRemoveProject: props.handleRemoveProject, + onCreateForRepo: props.handleCreateForRepo + }, + onRenameProjectGroup: props.handleRenameProjectGroup, + onDeleteProjectGroup: props.handleDeleteProjectGroup, + onCreateFolderWorkspace: props.handleCreateFolderWorkspace, + onWorkspaceStatusDragOver: statusDrag.handleWorkspaceStatusDragOver, + onWorkspaceStatusDragLeave: statusDrag.handleWorkspaceStatusDragLeave, + onWorkspacePinDragOver: statusDrag.handleWorkspacePinDragOver, + onWorkspacePinDragLeave: statusDrag.handleWorkspacePinDragLeave, + onWorkspaceStatusDrop: statusDrag.handleWorkspaceStatusDrop + }, + item: { + settings: args.settings, + groupBy: props.groupBy, + folderBackedProjectGroupIds: args.folderBackedProjectGroupIds, + groupKeyByRowKey: session.groupKeyByRowKey, + groupIndexByRowKey: session.groupIndexByRowKey, + agentSendTargetWorktreeId: props.agentSendTargetWorktreeId, + worktreeDragState: runtime.worktreeDragState, + worktreePointerDragRef: runtime.worktreePointerDragRef, + nativeLineageDropTargetId: runtime.nativeLineageDropTargetId, + activeWorktreeId: props.activeWorktreeId, + currentWorktreeId: props.currentWorktreeId, + highlightedRevealRowKey: reveal.highlightedRevealRowKey, + selectedWorktreeIds: props.selectedWorktreeIds, + selectedWorktrees: props.selectedWorktrees, + getActiveSurfaceVariant: primaryActive.getActiveSurfaceVariant, + getLineageToggleHandler: args.getLineageToggleHandler, + onSelectionGesture: props.onSelectionGesture, + onContextMenuSelect: props.onContextMenuSelect, + onImmediateActivate: primaryActive.handleImmediateWorktreeRowActivate, + onRowClickCapture: args.onRowClickCapture, + onRowPointerDown: args.onRowPointerDown, + onCardDragStart: args.nativeDrag.handleWorktreeCardDragStart, + onCardDragEnd: runtime.clearWorktreeDrag + }, + folderWorkspace: { + groupBy: props.groupBy, + newCardStyle: args.newCardStyle, + settings: args.settings, + activeWorktreeId: props.activeWorktreeId, + currentWorktreeId: props.currentWorktreeId, + selectedWorktreeIds: props.selectedWorktreeIds, + repoMap: props.repoMap, + worktreeMap: props.worktreeMap, + worktreeLineageById: props.worktreeLineageById, + workspaceLineageByChildKey: props.workspaceLineageByChildKey, + prCache: props.prCache, + hostedReviewCache: props.hostedReviewCache, + getCachedFolderWorkspacePathStatus: args.getCachedFolderWorkspacePathStatus, + onSelectionGesture: props.onSelectionGesture, + onContextMenuSelect: props.onContextMenuSelect, + onImmediateActivate: primaryActive.handleImmediateWorktreeRowActivate, + onRowClickCapture: args.onRowClickCapture, + onRowPointerDown: args.onRowPointerDown + } + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-dispatch.tsx b/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-dispatch.tsx new file mode 100644 index 00000000000..b17c81439d9 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-dispatch.tsx @@ -0,0 +1,262 @@ +import React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import { cn } from '@/lib/utils' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition +} from '../../../../../shared/worktree/types' +import { getWorkspaceStatus } from '../workspace-status' +import type { WorktreeGroupBy } from '../worktree-list-groups' +import { + getVirtualRowTransform, + shouldUseHeaderTopSpacing, + type RenderRow +} from '../worktree-list-virtual-rows' +import type { ImportedWorktreeCardActionState } from '../imported-worktrees-card-actions' +import type { NewExternalWorktreesInboxActionState } from '../new-external-worktrees-inbox-actions' +import type { useHostHeaderDrag } from '../host-header-drag' +import { HostSectionHeader } from './HostSectionHeader' +import { + renderFolderWorkspaceVirtualRow, + type FolderWorkspaceRowContext +} from './folder-workspace-virtual-row' +import { + renderImportedWorktreesVirtualRow, + renderNewExternalWorktreesInboxVirtualRow, + renderPendingCreationVirtualRow +} from './worktree-notice-virtual-rows' +import { + renderWorktreeItemRow, + renderWorktreeLineageDescendants, + type WorktreeItemRowContext +} from './worktree-list-item-row' +import { + renderWorktreeSectionHeaderRow, + type SectionHeaderRowContext +} from './worktree-section-header-row' +import { getWorktreeVirtualRowTransform } from './virtual-row-dom-attributes' +import type { WorktreeRowDragState } from './worktree-row-drag-state' + +export type WorktreeVirtualRowContext = { + renderRows: RenderRow[] + firstHeaderIndex: number + activeStickyHeaderIndexRef: React.MutableRefObject + activeStickyHostIndexRef: React.MutableRefObject + measureVirtualRowElement: (element: HTMLDivElement | null) => void + groupBy: WorktreeGroupBy + workspaceStatuses: readonly WorkspaceStatusDefinition[] + activeWorktreeId: string | null + worktreeDragState: WorktreeRowDragState + orderedHostIds: readonly string[] + hostDrag: ReturnType + toggleGroupWithScrollAnchor: (groupKey: string) => void + header: SectionHeaderRowContext + item: WorktreeItemRowContext + folderWorkspace: FolderWorkspaceRowContext + importedWorktreeCardActionState: ReadonlyMap + newExternalWorktreeInboxActionState: ReadonlyMap + onShowImportedWorktrees: (projectId: string) => void + onKeepImportedWorktreesHidden: (projectId: string) => void + onOpenWorktreeVisibility: ( + repo: Parameters[0] + ) => void + onOpenSuppressExternalWorktreeInbox: (projectId: string) => void + onWorkspaceStatusDragOver: (event: React.DragEvent, status: WorkspaceStatus) => void + onWorkspaceStatusDragLeave: (event: React.DragEvent) => void + onWorkspaceStatusDrop: (event: React.DragEvent, status: WorkspaceStatus) => void +} + +function renderHostHeaderVirtualRow( + ctx: WorktreeVirtualRowContext, + row: Extract, + vItem: VirtualItem +): React.JSX.Element { + // Why: the host card is the outer tier; it pins above group headers (z-30 vs z-20) and stays put as they hand off. + const isActiveStickyHost = ctx.activeStickyHostIndexRef.current === vItem.index + const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ + rows: ctx.renderRows, + index: vItem.index, + firstHeaderIndex: ctx.firstHeaderIndex + }) + return ( +
+ ctx.toggleGroupWithScrollAnchor(row.key)} + onDragPointerDown={ + ctx.orderedHostIds.length > 1 + ? (e) => ctx.hostDrag.onHandlePointerDown(e, row.hostId) + : undefined + } + dragging={ctx.hostDrag.state.draggingHostId === row.hostId} + /> +
+ ) +} + +function renderLineageGroupVirtualRow( + ctx: WorktreeVirtualRowContext, + row: Extract, + vItem: VirtualItem +): React.JSX.Element { + const [parent, ...children] = row.rows + const childIsActive = children.some((child) => child.worktree.id === ctx.activeWorktreeId) + const parentPreviewOffset = parent + ? (ctx.worktreeDragState.previewOffsetsByWorktreeId.get(parent.worktree.id) ?? 0) + : 0 + return ( +
+
+ {parent + ? renderWorktreeItemRow( + ctx.item, + parent, + false, + renderWorktreeLineageDescendants(ctx.item, parent, children), + childIsActive + ) + : null} +
+
+ ) +} + +// One virtual slot -> one row renderer. Every branch owns its own absolute/sticky frame +// because sticky headers and lineage groups position differently from plain cards. +export function renderWorktreeVirtualRow( + ctx: WorktreeVirtualRowContext, + row: RenderRow, + vItem: VirtualItem +): React.JSX.Element | null { + if (row.type === 'host-header') { + return renderHostHeaderVirtualRow(ctx, row, vItem) + } + + if (row.type === 'header') { + return renderWorktreeSectionHeaderRow({ + ctx: ctx.header, + row, + vItem, + isActiveStickyHeader: ctx.activeStickyHeaderIndexRef.current === vItem.index, + hasStickyHost: ctx.activeStickyHostIndexRef.current !== null, + hasHeaderTopSpacing: shouldUseHeaderTopSpacing({ + rows: ctx.renderRows, + index: vItem.index, + firstHeaderIndex: ctx.firstHeaderIndex + }), + measureVirtualRowElement: ctx.measureVirtualRowElement + }) + } + + if (row.type === 'lineage-group') { + return renderLineageGroupVirtualRow(ctx, row, vItem) + } + + if (row.type === 'imported-worktrees-card') { + return renderImportedWorktreesVirtualRow({ + row, + vItem, + measureVirtualRowElement: ctx.measureVirtualRowElement, + actionState: ctx.importedWorktreeCardActionState.get(row.repo.id), + onShow: ctx.onShowImportedWorktrees, + onKeepHidden: ctx.onKeepImportedWorktreesHidden + }) + } + + if (row.type === 'new-external-worktrees-inbox') { + return renderNewExternalWorktreesInboxVirtualRow({ + row, + vItem, + measureVirtualRowElement: ctx.measureVirtualRowElement, + actionState: ctx.newExternalWorktreeInboxActionState.get(row.repo.id), + onReview: ctx.onOpenWorktreeVisibility, + onSuppress: ctx.onOpenSuppressExternalWorktreeInbox + }) + } + + if (row.type === 'pending-creation') { + return renderPendingCreationVirtualRow({ + row, + vItem, + measureVirtualRowElement: ctx.measureVirtualRowElement + }) + } + + if (row.type === 'folder-workspace') { + return renderFolderWorkspaceVirtualRow({ + ctx: ctx.folderWorkspace, + row, + vItem, + measureVirtualRowElement: ctx.measureVirtualRowElement + }) + } + + const itemWorkspaceStatus = + ctx.groupBy === 'workspace-status' + ? getWorkspaceStatus(row.worktree, ctx.workspaceStatuses) + : null + const itemPreviewOffset = + ctx.worktreeDragState.previewOffsetsByWorktreeId.get(row.worktree.id) ?? 0 + + return ( +
ctx.onWorkspaceStatusDragOver(event, itemWorkspaceStatus) + : undefined + } + onDragLeave={itemWorkspaceStatus ? ctx.onWorkspaceStatusDragLeave : undefined} + onDrop={ + itemWorkspaceStatus + ? (event) => ctx.onWorkspaceStatusDrop(event, itemWorkspaceStatus) + : undefined + } + > + {renderWorktreeItemRow(ctx.item, row, false)} +
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-visibility-menu-label.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-visibility-menu-label.ts new file mode 100644 index 00000000000..666cd5121d8 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-visibility-menu-label.ts @@ -0,0 +1,13 @@ +import type { Repo } from '../../../../../shared/repo-types' +import { + effectiveExternalWorktreeVisibility, + isLegacyRepoForExternalWorktreeVisibility +} from '../../../../../shared/worktree/ownership' + +export function getWorktreeVisibilityMenuLabel(repo: Repo): string { + const visibility = effectiveExternalWorktreeVisibility( + repo, + isLegacyRepoForExternalWorktreeVisibility(repo) + ) + return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees' +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-visible-refresh-listener.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-visible-refresh-listener.ts new file mode 100644 index 00000000000..b9e77fefb83 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-visible-refresh-listener.ts @@ -0,0 +1,4 @@ +export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void { + document.addEventListener('visibilitychange', onChange) + return () => document.removeEventListener('visibilitychange', onChange) +} diff --git a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts index 933261f20a4..fb984cb2d54 100644 --- a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts +++ b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getScrollTopToRevealBounds } from './WorktreeList' +import { getScrollTopToRevealBounds } from './worktree-sidebar-reveal' describe('getScrollTopToRevealBounds', () => { const makeContainer = (scrollTop: number, clientHeight: number) => diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts index 328ffc95739..bf7e6c75bee 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts @@ -148,10 +148,13 @@ describe('shouldHandleTerminalInterruptKeyboardEvent', () => { // And a non-Latin key on some other physical key must not become an interrupt. it('does not handle a non-Latin logical key on a physical key other than KeyC', () => { expect( - shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'ㅁ', code: 'KeyA', ctrlKey: true }), { - isMac: true, - hasSelection: false - }) + shouldHandleTerminalInterruptKeyboardEvent( + event({ key: 'ㅁ', code: 'KeyA', ctrlKey: true }), + { + isMac: true, + hasSelection: false + } + ) ).toBe(false) }) @@ -160,10 +163,13 @@ describe('shouldHandleTerminalInterruptKeyboardEvent', () => { it('uses the layout map when an IME is layered over a Latin layout', () => { _setLayoutMapForTests(new Map([['KeyC', 'c']])) expect( - shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }), { - isMac: true, - hasSelection: false - }) + shouldHandleTerminalInterruptKeyboardEvent( + event({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }), + { + isMac: true, + hasSelection: false + } + ) ).toBe(true) }) @@ -172,10 +178,13 @@ describe('shouldHandleTerminalInterruptKeyboardEvent', () => { it('declines when the layout map shows the physical key is not C', () => { _setLayoutMapForTests(new Map([['KeyC', 'j']])) expect( - shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }), { - isMac: true, - hasSelection: false - }) + shouldHandleTerminalInterruptKeyboardEvent( + event({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }), + { + isMac: true, + hasSelection: false + } + ) ).toBe(false) }) From fcd99fc032187b7627c656ee587dbab15d00fa5c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:40:04 -0700 Subject: [PATCH 05/34] fix(sidebar): satisfy extracted hook quality gates (#14467) --- .../sidebar/worktree-list/use-pending-sidebar-reveal.ts | 4 ---- .../sidebar/worktree-list/use-primary-active-worktree-row.ts | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts b/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts index 7ba04fb5149..26cd2b352db 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts +++ b/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts @@ -136,8 +136,6 @@ export function usePendingSidebarReveal(args: PendingSidebarRevealArgs): void { cancelled = true cancelPendingRevealFrames() } - // Why: the effect re-reads live inputs through argsRef; only reveal-relevant identities should retrigger it. - // oxlint-disable-next-line react-hooks/exhaustive-deps }, [ pendingRevealWorktree, args.agentSendTargetWorktreeId, @@ -265,8 +263,6 @@ export function usePendingSidebarReveal(args: PendingSidebarRevealArgs): void { cancelled = true cancelPendingRevealFrames() } - // Why: the effect re-reads live inputs through argsRef; only reveal-relevant identities should retrigger it. - // oxlint-disable-next-line react-hooks/exhaustive-deps }, [ pendingRevealSidebarRow, args.repoMap, diff --git a/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts b/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts index 698d0b6d7c8..e3e30642c8e 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts +++ b/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useLayoutEffect, useState } from 'react' import type { ActiveSurfaceVariant } from '../WorktreeCard' import type { HostSectionRow } from '../host-section-rows' import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' @@ -18,7 +18,7 @@ export function usePrimaryActiveWorktreeRow(args: { rowKey: string } | null>(null) - useEffect(() => { + useLayoutEffect(() => { if (activeWorktreeId === null) { setPrimaryActiveWorktreeRow(null) return From 2e91a74debba4ffbb942b9d8a4d728f9aa001234 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:44:43 -0700 Subject: [PATCH 06/34] fix(renderer): stop git history hover from showing two tooltips (#14468) Remove native title attributes from commit rows, subjects, and ref badges so only the managed tooltip appears. --- .../right-sidebar/GitHistoryPanel.test.tsx | 29 +++++++++++++++++++ .../right-sidebar/GitHistoryRow.tsx | 12 ++------ tests/e2e/git-history-tooltip-wrap.spec.ts | 2 ++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx b/src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx index 07129f53077..33fc4565527 100644 --- a/src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx @@ -76,4 +76,33 @@ describe('GitHistoryPanel', () => { expect(markup).toContain('Fix tab overflow') expect(markup).toContain('52ad492') }) + + it('does not add native title tooltips alongside managed history tooltips', () => { + const result = makeHistoryResult() + result.items[0].references = [ + { id: 'refs/heads/main', name: 'main', category: 'branches' }, + { id: 'refs/heads/feature', name: 'feature', category: 'branches' }, + { id: 'refs/tags/v1.0.0', name: 'v1.0.0', category: 'tags' } + ] + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Fix tab overflow') + expect(markup).toContain('main') + expect(markup).toContain('feature') + expect(markup).toContain('+1') + expect(markup).not.toContain('title="Fix tab overflow"') + expect(markup).not.toContain('title="main"') + expect(markup).not.toContain('title="feature"') + expect(markup).not.toContain('title="v1.0.0"') + expect(markup).not.toMatch(/\stitle=/) + }) }) diff --git a/src/renderer/src/components/right-sidebar/GitHistoryRow.tsx b/src/renderer/src/components/right-sidebar/GitHistoryRow.tsx index f41ea2f92b4..f2c3f020178 100644 --- a/src/renderer/src/components/right-sidebar/GitHistoryRow.tsx +++ b/src/renderer/src/components/right-sidebar/GitHistoryRow.tsx @@ -20,7 +20,6 @@ function GitHistoryRefBadge({ itemRef }: { itemRef: GitHistoryItemRef }): React. borderColor: itemRef.color ? graphColor(itemRef.color) : 'var(--border)', color: itemRef.color ? graphColor(itemRef.color) : 'var(--muted-foreground)' }} - title={itemRef.name} > {itemRef.name} @@ -90,9 +89,7 @@ export const GitHistoryRow = React.forwardRef( )} - - {item.subject} - + {item.subject} ( {hiddenRefs.length > 0 && ( - ref.name).join(', ')} - > + +{hiddenRefs.length} @@ -135,7 +129,6 @@ export const GitHistoryRow = React.forwardRef( {...rootProps} ref={ref as React.Ref} className={rowClassName} - title={rowTooltip} data-testid="git-history-row" > {rowContent} @@ -157,7 +150,6 @@ export const GitHistoryRow = React.forwardRef( ref={ref as React.Ref} type="button" className={rowClassName} - title={rowTooltip} aria-expanded={canExpand ? expanded : undefined} aria-label={ canExpand diff --git a/tests/e2e/git-history-tooltip-wrap.spec.ts b/tests/e2e/git-history-tooltip-wrap.spec.ts index 6a8c4f5895b..f2f0fa50b36 100644 --- a/tests/e2e/git-history-tooltip-wrap.spec.ts +++ b/tests/e2e/git-history-tooltip-wrap.spec.ts @@ -73,7 +73,9 @@ test('keeps conventional commit-message lines intact in the history tooltip', as const row = orcaPage.getByTestId('git-history-row').filter({ hasText: subject }) await expect(row).toBeVisible({ timeout: 10_000 }) + await expect(row).not.toHaveAttribute('title') const trigger = row.locator('[data-slot="tooltip-trigger"]').filter({ hasText: subject }) + await expect(trigger).not.toHaveAttribute('title') await trigger.hover({ position: { x: 20, y: 10 } }) await trigger.hover({ position: { x: 40, y: 10 } }) From f4e4b5aded982031aaf4d64b7e4fa0e153315073 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:46:22 +0000 Subject: [PATCH 07/34] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 610bdb8b92e..7a9c2cb494e 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 19m + + downloads: 20m @@ -15,7 +15,7 @@ downloads downloads - 19m - 19m + 20m + 20m From 9e5ee5ef8eb2e6a70d8c9a2b13d11d05bea399ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:47:20 -0700 Subject: [PATCH 08/34] feat(workspaces): rework cleanup discovery and dialog (#13413) * fix(workspaces): support full cleanup scans * feat(workspaces): persist cleanup snapshots * feat(workspaces): add cleanup filter model * refactor(workspaces): remove cleanup presets * feat(workspaces): rework cleanup dialog * fix(workspaces): keep cleanup row ordering render-pure * refactor(workspaces): simplify cleanup browsing * refactor(workspaces): show cleanup facts * refactor(workspaces): surface cleanup row facts * fix(workspaces): remove misleading cleanup count * fix(workspaces): preserve full scan semantics * fix(workspaces): scope snapshot persistence * fix(workspaces): preserve cleanup browse compatibility * fix(workspaces): reconcile cleanup dialog state * test(workspaces): update snapshot store fixtures * test(workspaces): preserve cleanup scan modes * perf(workspace-cleanup): stream scan progress and size results * fix(workspace-cleanup): address review feedback * fix(workspace-cleanup): preserve host-scoped cleanup metadata * fix(workspace-cleanup): declare review source dependencies * fix(workspace-cleanup): align size scan banner * fix(workspace-cleanup): shorten scan action * perf(workspace-cleanup): avoid redundant scan IO * perf(workspace-cleanup): bound restarted evidence scans * fix(workspace-cleanup): satisfy scan queue lint * perf(workspace-cleanup): bound scan and snapshot work * perf(workspace-cleanup): serialize final enrichment * test(workspace-cleanup): assert final enrichment drain * fix(workspace-cleanup): stop progress after renderer teardown * perf: batch workspace cleanup git evidence scans * perf(workspace-cleanup): stop redundant snapshot and scan work * fix(workspace-cleanup): resolve review findings across scan, store, and dialog Correctness: - Chunk git-evidence dispatches at the shared 500-target limit and exclude queued/in-flight ids from target selection, so fleets past the limit can no longer strand rows permanently mislabeled as checked-but-unknown. - Key destructive selection pruning on the user's filter state instead of the per-tick matched-set identity; streaming reclassification no longer silently deselects rows. - Clamp the facet clock to max(scannedAt, open time): a stale hydrated snapshot no longer misbuckets idle thresholds or keeps dead agents fresh; row labels use the same clock. - Supersede and cancel the previous broad scan when a new one starts (renderer registry and same-sender guard in main) instead of racing two fleet scans. - Gate snapshot persistence on hasTargetedWorkspaceCleanupScan so worktreeIds: [] can never persist an empty fleet snapshot. - Re-apply dismissals at set-time in progress application so a dismissal landing mid-enrichment is not clobbered. - Record a one-off local snapshot prune for single (unbatched) remote deletes so removed workspaces cannot resurrect from cache. - Strip .exe when normalizing foreground process names so Windows agent processes match. Performance: - Cache per-candidate facet and review-info objects on candidate identity; no-op streaming ticks reuse the previous rows array and skip every downstream pass; matched-set identity is stable under equal membership. - Compute facet counts/options only while the filter popover is open. - Equality-bail git-evidence publishes; structural (non-stringify) facet-group comparison memoized in the toolbar. - Identity-token fast path for the enrichment cache (cache hits skip both JSON.stringify signatures); prune viewed/dismissal records on removal and expiry; bound the superseded-scan-id set. - Restore the no-op bail in removeWorkspaceSpaceWorktrees (regression). - Abort main-side scans when the renderer is destroyed; module-scope controller maps survive handler re-registration. - Batch removal preflight into one targeted scan (with refreshActivity) per 500 ids instead of one scan per row. - Scan repos at concurrency 2, report discovered counts upfront for honest progress, share fs-activity probes per path (folder workspaces), read only the reflog tail, and skip the snapshot read-before-write via a remembered scannedAt. Split workspace-cleanup-worktree-listing, workspace-cleanup-facet-row-caches, and workspace-cleanup-selection-model out of files that crossed max-lines. * fix(workspace-cleanup): address verifier findings - Fall back to a full reflog read when the newest record exceeds the 8KB tail window, so an oversized subject cannot hide recent ref activity. - Bound the single-removal snapshot prune batch id with a UUID; embedding the unbounded worktreeId silently failed main's 128-char validation and skipped the prune for long remote ids. - Key the main-side broad-scan supersession by sender AND scan mode so legacy suggestion-only and full-workspace scans stay isolated, matching the renderer registry. * fix(workspace-cleanup): own facet caches with useMemo instead of render-time ref writes React Doctor (CI changed-lines gate) correctly flagged the three cache refs written during render. Each per-candidate cache now lives in one memo with the derived context it is keyed on, so the memo deps are the invalidation and interior fills stay content-addressed; the matched-set identity stabilization is dropped since its only consumer reads through a useEffectEvent and never keys on identity. --- config/max-lines-baseline.txt | 1 - config/scripts/locale-ko-value-overrides.mjs | 2 +- .../ipc/workspace-cleanup-activity.test.ts | 34 +- src/main/ipc/workspace-cleanup-activity.ts | 91 +- .../ipc/workspace-cleanup-broad-scan.test.ts | 453 +++++ src/main/ipc/workspace-cleanup-candidate.ts | 8 +- .../ipc/workspace-cleanup-disconnected-ssh.ts | 31 +- .../workspace-cleanup-folder-workspaces.ts | 23 + .../ipc/workspace-cleanup-git-evidence.ts | 25 +- ...orkspace-cleanup-process-preflight.test.ts | 112 ++ .../ipc/workspace-cleanup-progress-emitter.ts | 83 + .../ipc/workspace-cleanup-scan-eligibility.ts | 35 + .../ipc/workspace-cleanup-scan-primitives.ts | 28 +- .../ipc/workspace-cleanup-scan-targets.ts | 31 + src/main/ipc/workspace-cleanup-scan.ts | 342 ++-- .../workspace-cleanup-snapshot-ipc.test.ts | 281 +++ .../ipc/workspace-cleanup-worktree-listing.ts | 76 + src/main/ipc/workspace-cleanup.test.ts | 74 +- src/main/ipc/workspace-cleanup.ts | 155 +- src/main/ipc/workspace-space.test.ts | 104 +- src/main/ipc/workspace-space.ts | 34 +- src/main/ipc/worktrees-windows.test.ts | 1 + src/main/ipc/worktrees.test.ts | 100 +- src/main/ipc/worktrees.ts | 99 +- src/main/persistence.test.ts | 24 + src/main/persistence.ts | 9 + .../runtime/rpc/methods/client-ui-schemas.ts | 14 +- .../methods/ui-state-schema-parity-checks.ts | 17 + .../workspace-cleanup-ui-schema.test.ts | 66 + .../methods/workspace-cleanup-ui-schema.ts | 29 + src/main/sidecar-snapshot-file.ts | 51 + .../attach-main-window-services.test.ts | 3 +- ...ace-cleanup-removal-snapshot-prune.test.ts | 126 ++ ...orkspace-cleanup-removal-snapshot-prune.ts | 138 ++ .../workspace-cleanup-scan-snapshot.test.ts | 450 +++++ src/main/workspace-cleanup-scan-snapshot.ts | 313 ++++ src/main/workspace-snapshot-prune-index.ts | 57 + .../workspace-snapshot-pruning.bench.test.ts | 153 ++ .../workspace-space-analysis-snapshot.test.ts | 438 +++++ src/main/workspace-space-analysis-snapshot.ts | 328 ++++ src/main/workspace-space-analysis.test.ts | 20 +- src/main/workspace-space-analysis.ts | 23 +- src/preload/api/workspace-cleanup-api.ts | 13 +- src/preload/api/worktree-api.ts | 2 + src/preload/index.ts | 11 +- .../delete-worktree-parallel-flow.test.ts | 40 +- .../sidebar/worktree-delete-execution.ts | 91 +- .../sidebar/worktree-delete-request.ts | 1 + .../sidebar/worktree-snapshot-prune-batch.ts | 24 + .../status-bar/ResourceUsageStatusSegment.tsx | 10 +- .../status-bar/WorkspaceSpaceManagerPanel.tsx | 21 +- ...rkspaceCleanupDialog.mount-gating.test.tsx | 376 ++-- ...anupDialog.stale-while-revalidate.test.tsx | 399 +++++ .../WorkspaceCleanupDialog.tsx | 1574 ++++------------- .../inactive-workspace-estimate.test.ts | 70 - .../inactive-workspace-estimate.ts | 38 - .../use-workspace-cleanup-browse-state.ts | 68 + .../use-workspace-cleanup-dialog-lifecycle.ts | 83 + .../use-workspace-cleanup-facet-rows.test.tsx | 298 ++++ .../use-workspace-cleanup-facet-rows.ts | 289 +++ ...se-workspace-cleanup-git-evidence.test.tsx | 192 ++ .../use-workspace-cleanup-git-evidence.ts | 183 ++ .../use-workspace-cleanup-removal.ts | 242 +++ .../use-workspace-cleanup-row-order.test.ts | 89 + .../use-workspace-cleanup-row-order.ts | 123 ++ .../use-workspace-cleanup-scan-lifecycle.ts | 135 ++ .../workspace-cleanup-active-facets.test.ts | 38 + .../workspace-cleanup-active-facets.ts | 70 + ...-background-removal-snapshot-batch.test.ts | 59 + .../workspace-cleanup-background-removal.ts | 152 +- .../workspace-cleanup-browse-toolbar.tsx | 96 + .../workspace-cleanup-candidate-list.tsx | 14 +- ...rkspace-cleanup-candidate-row-data.test.ts | 26 +- .../workspace-cleanup-candidate-row-data.ts | 34 +- .../workspace-cleanup-candidate-row.test.tsx | 97 + .../workspace-cleanup-candidate-row.tsx | 135 +- .../workspace-cleanup-confirm-remove.test.tsx | 76 + .../workspace-cleanup-confirm-remove.tsx | 253 +++ .../workspace-cleanup-dialog-header.tsx | 97 + .../workspace-cleanup-dialog-notices.test.tsx | 58 + .../workspace-cleanup-dialog-notices.tsx | 148 ++ .../workspace-cleanup-dialog-session.test.tsx | 175 -- .../workspace-cleanup-dialog-session.ts | 205 --- .../workspace-cleanup-facet-controls.test.tsx | 161 ++ .../workspace-cleanup-facet-controls.tsx | 225 +++ .../workspace-cleanup-facet-filter.test.ts | 518 ++++++ .../workspace-cleanup-facet-labels.ts | 175 ++ .../workspace-cleanup-facet-matchers.ts | 214 +++ .../workspace-cleanup-facet-panel-model.ts | 46 + .../workspace-cleanup-facet-row-caches.ts | 143 ++ .../workspace-cleanup-facet-sort.test.ts | 140 ++ .../workspace-cleanup-facet-sort.ts | 190 ++ .../workspace-cleanup-facet.test.fixture.ts | 126 ++ .../workspace-cleanup-facets.ts | 239 +++ .../workspace-cleanup-filter-bar.test.tsx | 44 + .../workspace-cleanup-filter-bar.tsx | 111 ++ .../workspace-cleanup-filter-sort.ts | 30 +- .../workspace-cleanup-flat-list.test.tsx | 139 ++ .../workspace-cleanup-freshness.tsx | 55 + .../workspace-cleanup-git-evidence.test.ts | 148 ++ .../workspace-cleanup-git-evidence.ts | 101 ++ .../workspace-cleanup-git-review-facets.tsx | 209 +++ .../workspace-cleanup-host-identity.ts | 137 ++ .../workspace-cleanup-lifecycle-facets.tsx | 198 +++ .../workspace-cleanup-metadata-chip.tsx | 41 + .../workspace-cleanup-presentation.test.ts | 71 + .../workspace-cleanup-presentation.ts | 70 +- .../workspace-cleanup-query.ts | 142 ++ .../workspace-cleanup-relative-time.ts | 27 + .../workspace-cleanup-removal-session.ts | 265 --- .../workspace-cleanup-row-list.tsx | 98 + .../workspace-cleanup-scan-notice.ts | 121 ++ .../workspace-cleanup-scan-session.ts | 88 - .../workspace-cleanup-selection-model.ts | 38 + .../workspace-cleanup-sort-header.test.tsx | 95 + .../workspace-cleanup-sort-header.tsx | 101 ++ .../workspace-cleanup-view-selection.test.ts | 94 - .../workspace-cleanup-view-selection.ts | 34 - src/renderer/src/i18n/locales/en.json | 183 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- src/renderer/src/store/index.ts | 2 + .../src/store/slices/diffComments.test.ts | 94 +- .../src/store/slices/store-test-helpers.ts | 2 + src/renderer/src/store/slices/ui.ts | 5 + .../workspace-cleanup-broad-scan-registry.ts | 87 + .../slices/workspace-cleanup-browse.test.ts | 97 + .../store/slices/workspace-cleanup-browse.ts | 54 + .../workspace-cleanup-cache-hydration.test.ts | 196 ++ .../workspace-cleanup-cache-hydration.ts | 41 + ...ace-cleanup-enrichment-performance.test.ts | 485 +++++ ...orkspace-cleanup-removal-preflight.test.ts | 40 +- .../workspace-cleanup-scan-progress.test.ts | 79 +- .../workspace-cleanup-slice-test-harness.ts | 6 +- .../src/store/slices/workspace-cleanup.ts | 480 +++-- .../src/store/slices/workspace-space.test.ts | 43 + .../src/store/slices/workspace-space.ts | 79 +- .../src/store/slices/worktree-helpers.ts | 1 + .../src/store/slices/worktrees.test.ts | 17 +- src/renderer/src/store/slices/worktrees.ts | 40 +- src/renderer/src/store/types.ts | 2 + src/renderer/src/web/web-preload-api.ts | 5 + src/shared/structural-value-equality.ts | 4 +- ...e-cleanup-browse-state-persistence.test.ts | 82 + src/shared/workspace-cleanup-browse-state.ts | 49 + .../workspace-cleanup-facet-rankings.ts | 135 ++ src/shared/workspace-cleanup-filter-model.ts | 180 ++ .../workspace-cleanup-filter-state-codec.ts | 224 +++ src/shared/workspace-cleanup-ui-state.test.ts | 50 + src/shared/workspace-cleanup-ui-state.ts | 15 + src/shared/workspace-cleanup.ts | 27 + src/shared/workspace-space-types.ts | 11 + 154 files changed, 14719 insertions(+), 3325 deletions(-) create mode 100644 src/main/ipc/workspace-cleanup-broad-scan.test.ts create mode 100644 src/main/ipc/workspace-cleanup-folder-workspaces.ts create mode 100644 src/main/ipc/workspace-cleanup-process-preflight.test.ts create mode 100644 src/main/ipc/workspace-cleanup-progress-emitter.ts create mode 100644 src/main/ipc/workspace-cleanup-scan-eligibility.ts create mode 100644 src/main/ipc/workspace-cleanup-scan-targets.ts create mode 100644 src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts create mode 100644 src/main/ipc/workspace-cleanup-worktree-listing.ts create mode 100644 src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts create mode 100644 src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts create mode 100644 src/main/sidecar-snapshot-file.ts create mode 100644 src/main/workspace-cleanup-removal-snapshot-prune.test.ts create mode 100644 src/main/workspace-cleanup-removal-snapshot-prune.ts create mode 100644 src/main/workspace-cleanup-scan-snapshot.test.ts create mode 100644 src/main/workspace-cleanup-scan-snapshot.ts create mode 100644 src/main/workspace-snapshot-prune-index.ts create mode 100644 src/main/workspace-snapshot-pruning.bench.test.ts create mode 100644 src/main/workspace-space-analysis-snapshot.test.ts create mode 100644 src/main/workspace-space-analysis-snapshot.ts create mode 100644 src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts create mode 100644 src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx delete mode 100644 src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.test.ts delete mode 100644 src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.ts create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-header.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx delete mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.test.tsx delete mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-panel-model.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet.test.fixture.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-freshness.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-lifecycle-facets.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-relative-time.ts delete mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-session.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts delete mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-session.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx delete mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-view-selection.test.ts delete mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-view-selection.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup-broad-scan-registry.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup-browse.test.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup-browse.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup-cache-hydration.test.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup-cache-hydration.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts create mode 100644 src/shared/workspace-cleanup-browse-state-persistence.test.ts create mode 100644 src/shared/workspace-cleanup-browse-state.ts create mode 100644 src/shared/workspace-cleanup-facet-rankings.ts create mode 100644 src/shared/workspace-cleanup-filter-model.ts create mode 100644 src/shared/workspace-cleanup-filter-state-codec.ts create mode 100644 src/shared/workspace-cleanup-ui-state.test.ts create mode 100644 src/shared/workspace-cleanup-ui-state.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 839595375eb..c1421ba2499 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -258,7 +258,6 @@ inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts -inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx inline src/renderer/src/hooks/useAutomationDispatchEvents.ts inline src/renderer/src/hooks/useComposerState.ts inline src/renderer/src/hooks/useEditorExternalWatch.ts diff --git a/config/scripts/locale-ko-value-overrides.mjs b/config/scripts/locale-ko-value-overrides.mjs index 5dac8b29a0d..bcc5b6e9e8e 100644 --- a/config/scripts/locale-ko-value-overrides.mjs +++ b/config/scripts/locale-ko-value-overrides.mjs @@ -173,7 +173,7 @@ export const KO_VALUE_OVERRIDES = { '아래 워크스페이스 오류를 확인한 후 새로고침하세요.', 'Review inactive workspaces before deleting their local files and Orca state.': '로컬 파일과 Orca 상태를 삭제하기 전에 비활성 워크스페이스를 확인하세요.', - 'Review inactive workspaces (': '비활성 워크스페이스 확인(', + 'Clean up workspaces': '워크스페이스 정리', 'Review the settings that will be imported from your Ghostty config.': 'Ghostty 구성에서 가져올 설정을 확인하세요.', 'GitHub requires review approval before this pull request can merge': diff --git a/src/main/ipc/workspace-cleanup-activity.test.ts b/src/main/ipc/workspace-cleanup-activity.test.ts index a7d1e980266..a0b68ccb683 100644 --- a/src/main/ipc/workspace-cleanup-activity.test.ts +++ b/src/main/ipc/workspace-cleanup-activity.test.ts @@ -1,3 +1,5 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' @@ -135,10 +137,40 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { readTextFile ) - expect(readTextFile).toHaveBeenCalledWith(reflogPath) + // Why: only the tail of the reflog is read — the newest entry is enough. + expect(readTextFile).toHaveBeenCalledWith(reflogPath, { tailBytes: 8192 }) expect(worktree.lastActivityAt).toBe(1_700_000_900_000) }) + it('falls back to a full reflog read when the newest entry exceeds the tail window', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'cleanup-activity-')) + try { + const gitDir = path.join(dir, 'gitdir') + await mkdir(path.join(gitDir, 'logs'), { recursive: true }) + const worktreePath = path.join(dir, 'wt') + await mkdir(worktreePath, { recursive: true }) + await writeFile(path.join(worktreePath, '.git'), `gitdir: ${gitDir}\n`) + // Why: a single record longer than the 8192-byte tail window keeps its + // timestamp before the window; the reader must fall back to a full read. + await writeFile( + path.join(gitDir, 'logs', 'HEAD'), + '0000 1111 Dev 1700000000 -0700\tbranch: Created from HEAD\n' + + `1111 2222 Dev 1700000900 -0700\tcommit: ${'x'.repeat(9000)}\n` + ) + const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree({ path: worktreePath }), + statPath + ) + + expect(worktree.lastActivityAt).toBe(1_700_000_900_000) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + it('degrades to other probes when the reflog was expired to an empty file', async () => { const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) diff --git a/src/main/ipc/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup-activity.ts index 93a6b5673cd..0d7c44b602c 100644 --- a/src/main/ipc/workspace-cleanup-activity.ts +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -1,4 +1,4 @@ -import { lstat, readFile } from 'node:fs/promises' +import { lstat, open, readFile } from 'node:fs/promises' import path from 'node:path' import type { Repo } from '../../shared/repo-types' import type { Worktree } from '../../shared/worktree/types' @@ -7,7 +7,11 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }> -type ReadTextFile = (targetPath: string) => Promise +type ReadTextFile = (targetPath: string, options?: { tailBytes?: number }) => Promise + +// Why: only the newest reflog entry matters; a long-lived main worktree's full +// reflog can be megabytes, and it was read and split whole per broad scan. +const REFLOG_TAIL_BYTES = 8192 export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Worktree): Worktree { const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) @@ -17,13 +21,24 @@ export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Workt return { ...worktree, lastActivityAt: persistedActivityAt } } +export type WorkspaceCleanupFsActivityCache = Map> + export async function resolveWorkspaceCleanupActivityWorktree( repo: Repo, worktree: Worktree, statPath: StatPath = statLocalPath, - readTextFile: ReadTextFile = readLocalTextFile + readTextFile: ReadTextFile = readLocalTextFile, + // Why: folder workspaces surface one row per instance of the same directory; + // the filesystem probes are identical per path and must run once per scan. + fsActivityCache?: WorkspaceCleanupFsActivityCache ): Promise { - const activityAt = await resolveWorkspaceCleanupActivityAt(repo, worktree, statPath, readTextFile) + const activityAt = await resolveWorkspaceCleanupActivityAt( + repo, + worktree, + statPath, + readTextFile, + fsActivityCache + ) if (activityAt <= worktree.lastActivityAt) { return worktree } @@ -35,27 +50,48 @@ async function statLocalPath(targetPath: string): Promise<{ mtimeMs: number }> { return { mtimeMs: Number(stats.mtimeMs) } } -async function readLocalTextFile(targetPath: string): Promise { - return readFile(targetPath, 'utf8') +async function readLocalTextFile( + targetPath: string, + options?: { tailBytes?: number } +): Promise { + if (options?.tailBytes === undefined) { + return readFile(targetPath, 'utf8') + } + const handle = await open(targetPath, 'r') + try { + const { size } = await handle.stat() + const length = Math.min(options.tailBytes, size) + if (length === 0) { + return '' + } + const { buffer, bytesRead } = await handle.read({ + buffer: Buffer.alloc(length), + position: size - length + }) + return buffer.toString('utf8', 0, bytesRead) + } finally { + await handle.close() + } } async function resolveWorkspaceCleanupActivityAt( repo: Repo, worktree: Worktree, statPath: StatPath, - readTextFile: ReadTextFile + readTextFile: ReadTextFile, + fsActivityCache?: WorkspaceCleanupFsActivityCache ): Promise { const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) if (repo.connectionId) { return persistedActivityAt } - const filesystemActivityAt = await getNewestLocalWorktreeActivityAt( - worktree.path, - statPath, - readTextFile - ) - return Math.max(persistedActivityAt, filesystemActivityAt) + let filesystemActivity = fsActivityCache?.get(worktree.path) + if (!filesystemActivity) { + filesystemActivity = getNewestLocalWorktreeActivityAt(worktree.path, statPath, readTextFile) + fsActivityCache?.set(worktree.path, filesystemActivity) + } + return Math.max(persistedActivityAt, await filesystemActivity) } // Why: best-effort only. Win32 stat over \\wsl.localhost (9P) can falsely @@ -97,20 +133,33 @@ async function readNewestReflogEntryAt( readTextFile: ReadTextFile ): Promise { try { - const lines = (await readTextFile(reflogPath)).split('\n') - for (let index = lines.length - 1; index >= 0; index -= 1) { - // The trailing timezone + tab anchors the capture, so no digit-count floor is needed. - const seconds = /\s(\d{1,11})\s[-+]\d{4}\t/.exec(lines[index] ?? '')?.[1] - if (seconds) { - return Number(seconds) * 1000 - } + // A tail cut can start mid-line; scanning backward for the last parseable + // entry tolerates that. + const tail = await readTextFile(reflogPath, { tailBytes: REFLOG_TAIL_BYTES }) + const newestFromTail = parseNewestReflogEntryAt(tail) + if (newestFromTail !== 0 || tail.length === 0) { + return newestFromTail } - return 0 + // Why: a single record longer than the tail window keeps its timestamp + // before the window starts; fall back to the full read in that rare case. + return parseNewestReflogEntryAt(await readTextFile(reflogPath)) } catch { return 0 } } +function parseNewestReflogEntryAt(reflog: string): number { + const lines = reflog.split('\n') + for (let index = lines.length - 1; index >= 0; index -= 1) { + // The trailing timezone + tab anchors the capture, so no digit-count floor is needed. + const seconds = /\s(\d{1,11})\s[-+]\d{4}\t/.exec(lines[index] ?? '')?.[1] + if (seconds) { + return Number(seconds) * 1000 + } + } + return 0 +} + async function readLocalWorktreeGitDir( worktreePath: string, gitPath: string, diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts new file mode 100644 index 00000000000..122f4bdd014 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -0,0 +1,453 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import path from 'node:path' +import type { Store } from '../persistence' +import type * as RepoWorktreesModule from '../repo-worktrees' +import type { GitStatusResult } from '../../shared/git-status-types' +import type { Repo } from '../../shared/repo-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import { WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT } from '../../shared/workspace-cleanup' + +const { + lstatMock, + readFileMock, + listRepoWorktreesMock, + getStatusMock, + gitExecFileAsyncMock, + getLocalProjectWorktreeGitOptionsMock, + getSshGitProviderMock +} = vi.hoisted(() => ({ + lstatMock: vi.fn(), + readFileMock: vi.fn(), + listRepoWorktreesMock: vi.fn(), + getStatusMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + getLocalProjectWorktreeGitOptionsMock: vi.fn(), + getSshGitProviderMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + readFile: readFileMock +})) + +vi.mock('../repo-worktrees', async () => { + const actual = await vi.importActual('../repo-worktrees') + return { + listRepoWorktrees: listRepoWorktreesMock, + createFolderWorktree: actual.createFolderWorktree + } +}) + +vi.mock('../git/status', () => ({ + getStatus: getStatusMock +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock +})) + +import { scanWorkspaceCleanup } from './workspace-cleanup-scan' + +const NOW = 1_700_000_000_000 +const DAY_MS = 24 * 60 * 60 * 1000 +const REPO: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: NOW, + symlinkPaths: ['node_modules'] +} +const FOLDER_REPO: Repo = { + ...REPO, + id: 'repo-folder', + path: '/folder-workspace', + displayName: 'Folder', + kind: 'folder' +} + +const GIT_WORKTREES: GitWorktreeInfo[] = [ + { + path: '/repo', + head: 'main123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo-old', + head: 'old123', + branch: 'refs/heads/old', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-recent', + head: 'recent123', + branch: 'refs/heads/recent', + isBare: false, + isMainWorktree: false + } +] + +function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + baseRef: 'origin/main', + ...overrides + } as WorktreeMeta +} + +const META_BY_WORKTREE_ID: Record = { + 'repo-1::/repo': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }), + 'repo-1::/repo-old': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }), + 'repo-1::/repo-recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }), + 'repo-folder::/folder-workspace': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }) +} + +function makeStore(repos: Repo[] = [REPO], allMeta: Record = {}): Store { + return { + getRepos: () => repos, + getWorktreeMeta: (worktreeId: string) => META_BY_WORKTREE_ID[worktreeId] ?? allMeta[worktreeId], + getAllWorktreeMeta: () => allMeta, + getGitHubCache: () => ({ pr: {}, issue: {} }) + } as unknown as Store +} + +describe('workspace cleanup broad scan opt-in', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + lstatMock.mockReset().mockResolvedValue({ mtimeMs: 0 }) + readFileMock.mockReset().mockRejectedValue(new Error('not a gitdir pointer')) + listRepoWorktreesMock.mockReset().mockResolvedValue(GIT_WORKTREES) + getStatusMock.mockReset().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + } satisfies GitStatusResult) + gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '0\n', stderr: '' }) + getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) + getSshGitProviderMock.mockReset().mockReturnValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('emits every worktree when the client opts into the full workspace list', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + + expect(result.errors).toEqual([]) + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo-1::/repo', + 'repo-1::/repo-old', + 'repo-1::/repo-recent' + ]) + }) + + it('preserves a worktree runtime host on cleanup candidates', async () => { + const runtimeMeta = makeWorktreeMeta({ + lastActivityAt: NOW - 40 * DAY_MS, + hostId: 'runtime:env-1' + }) + const store = { + ...makeStore(), + getWorktreeMeta: (worktreeId: string) => + worktreeId === 'repo-1::/repo-old' ? runtimeMeta : META_BY_WORKTREE_ID[worktreeId] + } as Store + + const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + + expect( + result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old') + ?.executionHostId + ).toBe('runtime:env-1') + }) + + it('keeps the legacy suggestion-only projection when the flag is absent', async () => { + const result = await scanWorkspaceCleanup(makeStore()) + + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo-1::/repo-old' + ]) + }) + + it('reports recent workspaces with no cleanup reasons', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + + const recent = result.candidates.find( + (candidate) => candidate.worktreeId === 'repo-1::/repo-recent' + ) + expect(recent).toMatchObject({ + reasons: [], + tier: 'review', + selectedByDefault: false, + lastActivityAt: NOW - 2 * DAY_MS + }) + }) + + it('reports the main worktree with a main-worktree blocker', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + + const main = result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo') + expect(main).toMatchObject({ + tier: 'protected', + selectedByDefault: false, + blockers: ['main-worktree'] + }) + }) + + it('reports folder workspaces with a folder-repo blocker', async () => { + const instanceId = `${FOLDER_REPO.id}::${FOLDER_REPO.path}::workspace:11111111-2222-4333-8444-555555555555` + const instanceMeta = makeWorktreeMeta({ + displayName: 'Folder session', + lastActivityAt: NOW - 2 * DAY_MS + }) + const result = await scanWorkspaceCleanup( + makeStore([FOLDER_REPO], { [instanceId]: instanceMeta }), + { includeAllWorkspaces: true } + ) + + expect(result.candidates).toEqual([ + expect.objectContaining({ + worktreeId: 'repo-folder::/folder-workspace', + tier: 'protected', + selectedByDefault: false, + blockers: expect.arrayContaining(['folder-repo', 'main-worktree']) + }), + expect.objectContaining({ + worktreeId: instanceId, + displayName: 'Folder session', + path: FOLDER_REPO.path, + tier: 'protected', + selectedByDefault: false, + blockers: ['folder-repo'] + }) + ]) + }) + + it('does not mark connected SSH folder workspaces as disconnected', async () => { + const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' } + getSshGitProviderMock.mockReturnValue({}) + + const result = await scanWorkspaceCleanup(makeStore([sshFolderRepo]), { + includeAllWorkspaces: true + }) + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]).toMatchObject({ + worktreeId: 'repo-folder::/folder-workspace', + connectionId: 'ssh-1', + blockers: ['main-worktree', 'folder-repo'] + }) + }) + + it('defers git evidence for rows that no cleanup decision can use', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + + // Why: only the inactive row can ever be queued, so it is the only row worth + // a git read; the rest stream immediately with empty evidence. + expect(getStatusMock).toHaveBeenCalledTimes(1) + expect(getStatusMock).toHaveBeenCalledWith('/repo-old', expect.anything()) + expect( + result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-recent')?.git + ).toEqual({ clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }) + expect( + result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old')?.git + ).toMatchObject({ clean: true, checkedAt: expect.any(Number) }) + }) + + it('does not read activity files for rows already known to be recent', async () => { + const recentWorktrees = ['/repo-recent-a', '/repo-recent-b'].map( + (worktreePath, index): GitWorktreeInfo => ({ + path: worktreePath, + head: `recent-${index}`, + branch: `refs/heads/recent-${index}`, + isBare: false, + isMainWorktree: false + }) + ) + listRepoWorktreesMock.mockResolvedValue(recentWorktrees) + const recentMeta = makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }) + const store = { + ...makeStore(), + getWorktreeMeta: () => recentMeta + } as unknown as Store + + const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + + expect(result.candidates).toHaveLength(recentWorktrees.length) + expect(readFileMock).not.toHaveBeenCalled() + expect(lstatMock).not.toHaveBeenCalled() + expect(getStatusMock).not.toHaveBeenCalled() + }) + + it('batches fleet progress instead of emitting one IPC payload per row', async () => { + const worktrees = [ + GIT_WORKTREES[0], + ...Array.from( + { length: 200 }, + (_, index): GitWorktreeInfo => ({ + path: `/repo-batch-${index}`, + head: `head-${index}`, + branch: `refs/heads/batch-${index}`, + isBare: false, + isMainWorktree: false + }) + ) + ] + listRepoWorktreesMock.mockResolvedValue(worktrees) + const onProgress = vi.fn() + + const result = await scanWorkspaceCleanup( + makeStore(), + { + scanId: 'fleet-scan', + includeAllWorkspaces: true, + skipGitWorktreeIds: worktrees.map((worktree) => `${REPO.id}::${worktree.path}`) + }, + { onProgress } + ) + + expect(onProgress.mock.calls.length).toBeLessThanOrEqual(2) + const progress = onProgress.mock.lastCall?.[0] + expect(progress).toMatchObject({ + scannedWorktreeCount: result.candidates.length, + totalWorktreeCount: result.candidates.length, + candidateMode: 'append' + }) + const streamedCandidates = onProgress.mock.calls.flatMap(([update]) => update.candidates) + expect(streamedCandidates.map((candidate) => candidate.worktreeId)).toEqual( + result.candidates.map((candidate) => candidate.worktreeId) + ) + }) + + it('still forces a git read for a focused scan of a recent workspace', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeId: 'repo-1::/repo-recent', + includeAllWorkspaces: true + }) + + expect(getStatusMock).toHaveBeenCalledTimes(1) + expect(lstatMock).toHaveBeenCalledWith('/repo-recent') + expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-recent', '.git')) + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]).toMatchObject({ + worktreeId: 'repo-1::/repo-recent', + reasons: [], + git: { clean: true, checkedAt: expect.any(Number) } + }) + }) + + it('lists a repo once for a targeted Git-evidence batch', async () => { + const targets = ['repo-1::/repo-recent', 'repo-1::/repo-old'] + + const result = await scanWorkspaceCleanup(makeStore(), { worktreeIds: targets }) + + expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1) + expect(getStatusMock).toHaveBeenCalledTimes(2) + expect(lstatMock).not.toHaveBeenCalled() + expect(new Set(result.candidates.map((candidate) => candidate.worktreeId))).toEqual( + new Set(targets) + ) + }) + + it('keeps the maximum targeted batch to one repo listing', async () => { + const worktrees = Array.from( + { length: WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT + 1 }, + (_, index): GitWorktreeInfo => ({ + path: `/repo-evidence-${index}`, + head: `head-${index}`, + branch: `refs/heads/evidence-${index}`, + isBare: false, + isMainWorktree: false + }) + ) + listRepoWorktreesMock.mockResolvedValue(worktrees) + + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeIds: worktrees.map((worktree) => `${REPO.id}::${worktree.path}`) + }) + + expect(result.candidates).toHaveLength(WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) + expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1) + expect(getStatusMock).toHaveBeenCalledTimes(WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) + expect(lstatMock).not.toHaveBeenCalled() + }) + + it('lists disconnected SSH workspaces only on an opt-in scan', async () => { + const sshRepo: Repo = { ...REPO, id: 'repo-ssh', connectionId: 'ssh-1' } + const allMeta: Record = { + 'repo-ssh::/remote/recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }) + } + + const legacy = await scanWorkspaceCleanup(makeStore([sshRepo], allMeta)) + const optIn = await scanWorkspaceCleanup(makeStore([sshRepo], allMeta), { + includeAllWorkspaces: true + }) + + expect(legacy.candidates).toEqual([]) + expect(optIn.candidates).toHaveLength(1) + expect(optIn.candidates[0]).toMatchObject({ + worktreeId: 'repo-ssh::/remote/recent', + blockers: ['ssh-disconnected'], + reasons: [] + }) + }) + + it('uses the backing path for disconnected SSH folder instances', async () => { + const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' } + const instanceId = `${sshFolderRepo.id}::${sshFolderRepo.path}::workspace:11111111-2222-4333-8444-555555555555` + const allMeta = { + [instanceId]: makeWorktreeMeta({ displayName: 'Remote folder session' }) + } + + const result = await scanWorkspaceCleanup(makeStore([sshFolderRepo], allMeta), { + includeAllWorkspaces: true + }) + + expect(result.candidates).toEqual([ + expect.objectContaining({ + worktreeId: instanceId, + path: sshFolderRepo.path, + branch: 'folder-workspace', + blockers: ['ssh-disconnected'] + }) + ]) + }) + + it('streams every row through scan progress on an opt-in scan', async () => { + const progress: { scannedWorktreeCount: number; totalWorktreeCount: number }[] = [] + + await scanWorkspaceCleanup( + makeStore(), + { includeAllWorkspaces: true, scanId: 'scan-all' }, + { onProgress: (event) => progress.push(event) } + ) + + expect(progress.at(-1)).toMatchObject({ + scanId: 'scan-all', + scannedWorktreeCount: 3, + totalWorktreeCount: 3 + }) + }) +}) diff --git a/src/main/ipc/workspace-cleanup-candidate.ts b/src/main/ipc/workspace-cleanup-candidate.ts index d6e93429b8a..58c56316acc 100644 --- a/src/main/ipc/workspace-cleanup-candidate.ts +++ b/src/main/ipc/workspace-cleanup-candidate.ts @@ -2,6 +2,7 @@ import type { IGitProvider } from '../providers/types' import { isFolderRepo } from '../../shared/repo-kind' import type { Repo } from '../../shared/repo-types' import type { Worktree } from '../../shared/worktree/types' +import { getWorktreeExecutionHostId } from '../../shared/execution-host' import { applyWorkspaceCleanupPolicy, createWorkspaceCleanupFingerprint, @@ -24,8 +25,9 @@ export async function buildWorkspaceCleanupCandidate(args: { provider: IGitProvider | null skipGit: boolean forceGitCheck: boolean + signal?: AbortSignal }): Promise { - const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck } = args + const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck, signal } = args const blockers: WorkspaceCleanupBlocker[] = [] const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(worktree, scannedAt) const repoIsFolder = isFolderRepo(repo) @@ -51,7 +53,7 @@ export async function buildWorkspaceCleanupCandidate(args: { const gitEvidence = !shouldReadGit ? createEmptyWorkspaceCleanupGitEvidence() - : await readWorkspaceCleanupGitEvidence(worktree, repo, provider) + : await readWorkspaceCleanupGitEvidence(worktree, repo, provider, signal) appendWorkspaceCleanupItems(blockers, gitEvidence.blockers) const candidateWithoutFingerprint: WorkspaceCleanupCandidate = { @@ -59,6 +61,7 @@ export async function buildWorkspaceCleanupCandidate(args: { repoId: repo.id, repoName: repo.displayName, connectionId: repo.connectionId ?? null, + executionHostId: getWorktreeExecutionHostId(worktree, repo), displayName: worktree.displayName, branch: shortWorkspaceCleanupBranchName(worktree.branch), path: worktree.path, @@ -103,6 +106,7 @@ export function buildWorkspaceCleanupCandidateFromError( repoId: repo.id, repoName: repo.displayName, connectionId: repo.connectionId ?? null, + executionHostId: getWorktreeExecutionHostId(worktree, repo), displayName: worktree.displayName, branch: shortWorkspaceCleanupBranchName(worktree.branch), path: worktree.path, diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts index 264e1eac9e2..e01484cb24a 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -1,4 +1,5 @@ import { basename } from 'node:path' +import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../shared/execution-host' import type { Store } from '../persistence' import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' @@ -7,7 +8,7 @@ import { createWorkspaceCleanupFingerprint, type WorkspaceCleanupCandidate } from '../../shared/workspace-cleanup' -import { splitWorktreeId } from '../../shared/worktree/id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { getNewestWorkspaceCleanupDiffCommentAt, getWorkspaceCleanupInactivityReasonsForWorkspace, @@ -18,17 +19,24 @@ export function synthesizeDisconnectedSshCleanupCandidates( store: Store, repo: Repo, scannedAt: number, - targetWorktreeId?: string + targetWorktreeIds?: ReadonlySet, + includeAllWorkspaces = false ): WorkspaceCleanupCandidate[] { const repoWorktreePrefix = `${repo.id}::` - if (targetWorktreeId) { - if (!targetWorktreeId.startsWith(repoWorktreePrefix)) { - return [] - } - // Why: focused delete preflight names one workspace already; walking all + if (targetWorktreeIds) { + const candidates: WorkspaceCleanupCandidate[] = [] + // Why: targeted refreshes name their workspaces already; walking all // persisted metadata is unnecessary for disconnected SSH repos. - const meta = store.getWorktreeMeta(targetWorktreeId) - return meta ? [createDisconnectedSshCandidate(repo, scannedAt, targetWorktreeId, meta)] : [] + for (const worktreeId of targetWorktreeIds) { + if (!worktreeId.startsWith(repoWorktreePrefix)) { + continue + } + const meta = store.getWorktreeMeta(worktreeId) + if (meta) { + candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta)) + } + } + return candidates } const candidates: WorkspaceCleanupCandidate[] = [] @@ -38,7 +46,7 @@ export function synthesizeDisconnectedSshCleanupCandidates( continue } const meta = allMeta[worktreeId] - if (!meta || !isWorkspaceInactiveForCleanup(meta, scannedAt)) { + if (!meta || (!includeAllWorkspaces && !isWorkspaceInactiveForCleanup(meta, scannedAt))) { continue } candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta)) @@ -52,7 +60,7 @@ function createDisconnectedSshCandidate( worktreeId: string, meta: WorktreeMeta ): WorkspaceCleanupCandidate { - const parsed = splitWorktreeId(worktreeId) + const parsed = splitWorktreeIdForFilesystem(worktreeId) const path = parsed?.worktreePath ?? worktreeId const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(meta, scannedAt) return applyWorkspaceCleanupPolicy({ @@ -60,6 +68,7 @@ function createDisconnectedSshCandidate( repoId: repo.id, repoName: repo.displayName, connectionId: repo.connectionId ?? null, + executionHostId: normalizeExecutionHostId(meta.hostId) ?? getRepoExecutionHostId(repo), displayName: meta.displayName || basename(path), branch: basename(path), path, diff --git a/src/main/ipc/workspace-cleanup-folder-workspaces.ts b/src/main/ipc/workspace-cleanup-folder-workspaces.ts new file mode 100644 index 00000000000..fc6112460cc --- /dev/null +++ b/src/main/ipc/workspace-cleanup-folder-workspaces.ts @@ -0,0 +1,23 @@ +import type { Store } from '../persistence' +import { createFolderWorktree } from '../repo-worktrees' +import type { Repo } from '../../shared/repo-types' +import type { Worktree } from '../../shared/worktree/types' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' +import { mergeWorktree } from './worktree-logic' + +export function listWorkspaceCleanupFolderWorkspaces(store: Store, repo: Repo): Worktree[] { + const rootId = `${repo.id}::${repo.path}` + const instancePrefix = `${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + const allMeta = store.getAllWorktreeMeta() + const worktreeIds = [ + rootId, + ...Object.keys(allMeta).filter((worktreeId) => worktreeId.startsWith(instancePrefix)) + ] + const folderWorktree = createFolderWorktree(repo) + + return worktreeIds.map((worktreeId) => ({ + ...mergeWorktree(repo.id, folderWorktree, store.getWorktreeMeta(worktreeId), repo.displayName), + id: worktreeId, + isMainWorktree: worktreeId === rootId + })) +} diff --git a/src/main/ipc/workspace-cleanup-git-evidence.ts b/src/main/ipc/workspace-cleanup-git-evidence.ts index 91aca462c7a..a54829c7c25 100644 --- a/src/main/ipc/workspace-cleanup-git-evidence.ts +++ b/src/main/ipc/workspace-cleanup-git-evidence.ts @@ -7,6 +7,7 @@ import type { Worktree } from '../../shared/worktree/types' import type { WorkspaceCleanupBlocker } from '../../shared/workspace-cleanup' import { WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + WorkspaceCleanupScanCancelledError, withWorkspaceCleanupTimeout } from './workspace-cleanup-scan-primitives' import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' @@ -32,7 +33,8 @@ export function createEmptyWorkspaceCleanupGitEvidence(): WorkspaceCleanupGitEvi export async function readWorkspaceCleanupGitEvidence( worktree: Worktree, repo: Repo, - provider: IGitProvider | null + provider: IGitProvider | null, + signal?: AbortSignal ): Promise { const blockers: WorkspaceCleanupBlocker[] = [] let status: GitStatusResult @@ -49,9 +51,13 @@ export async function readWorkspaceCleanupGitEvidence( ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out reading git status.' + 'Timed out reading git status.', + signal ) - } catch { + } catch (error) { + if (error instanceof WorkspaceCleanupScanCancelledError) { + throw error + } return { ...createEmptyWorkspaceCleanupGitEvidence(), blockers: ['git-status-error'] @@ -76,7 +82,7 @@ export async function readWorkspaceCleanupGitEvidence( blockers.push('unpushed-commits') } if (clean && upstreamAhead === null) { - const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider) + const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider, signal) if (unpushedCommitCount === null) { blockers.push('unknown-base') } else if (unpushedCommitCount > 0) { @@ -96,7 +102,8 @@ export async function readWorkspaceCleanupGitEvidence( async function readUnpushedCommitCount( worktree: Worktree, repo: Repo, - provider: IGitProvider | null + provider: IGitProvider | null, + signal?: AbortSignal ): Promise { try { const result = await withWorkspaceCleanupTimeout( @@ -110,11 +117,15 @@ async function readUnpushedCommitCount( signal }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out checking unpushed commits.' + 'Timed out checking unpushed commits.', + signal ) const count = Number.parseInt(result.stdout.trim(), 10) return Number.isFinite(count) ? count : null - } catch { + } catch (error) { + if (error instanceof WorkspaceCleanupScanCancelledError) { + throw error + } return null } } diff --git a/src/main/ipc/workspace-cleanup-process-preflight.test.ts b/src/main/ipc/workspace-cleanup-process-preflight.test.ts new file mode 100644 index 00000000000..1c6b8e78c30 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-process-preflight.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ipcMain } from 'electron' +import type { Store } from '../persistence' + +const { getSshPtyProviderMock } = vi.hoisted(() => ({ + getSshPtyProviderMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn() + } +})) + +vi.mock('./pty', () => ({ + getSshPtyProvider: getSshPtyProviderMock +})) + +vi.mock('../memory/pty-registry', () => ({ + listRegisteredPtys: vi.fn(() => []) +})) + +vi.mock('../workspace-cleanup-scan-snapshot', () => ({ + persistWorkspaceCleanupScanResult: vi.fn(async () => undefined), + readWorkspaceCleanupScanSnapshot: vi.fn(async () => null) +})) + +vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({ + beginWorkspaceCleanupRemovalSnapshotPruneBatch: vi.fn(), + finishWorkspaceCleanupRemovalSnapshotPruneBatch: vi.fn(async () => undefined), + recordWorkspaceCleanupRemovalSnapshotPrune: vi.fn() +})) + +import { registerWorkspaceCleanupHandlers } from './workspace-cleanup' + +function makeEmptyStore(): Store { + return { + getProfileStorageDirectory: () => '/profile-a', + getRepos: () => [], + getWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => ({}), + getGitHubCache: () => ({ pr: {}, issue: {} }) + } as unknown as Store +} + +function getPreflightHandler(): ((...args: never[]) => unknown) | undefined { + return vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1] +} + +describe('workspace cleanup process preflight', () => { + beforeEach(() => { + vi.mocked(ipcMain.handle).mockReset() + getSshPtyProviderMock.mockReset() + }) + + it('reports local processes that workspace deletion would kill', async () => { + const localProvider = { + listProcesses: vi.fn().mockResolvedValue([ + { + id: 'repo-1::/repo-feature@@session-1', + cwd: '/repo-feature', + title: 'zsh' + } + ]) + } + registerWorkspaceCleanupHandlers(makeEmptyStore(), { + runtime: { + hasTerminalsForWorktree: vi.fn().mockResolvedValue(false) + } as never, + getLocalPtyProvider: () => localProvider as never + }) + + await expect( + getPreflightHandler()?.({} as never, { worktreeId: 'repo-1::/repo-feature' } as never) + ).resolves.toEqual({ + hasKillableProcesses: true + }) + }) + + it('reports SSH processes inside the remote workspace path', async () => { + getSshPtyProviderMock.mockReturnValue({ + listProcesses: vi.fn().mockResolvedValue([ + { + id: 'remote-session-1', + cwd: '/remote/repo-feature/subdir', + title: 'codex' + } + ]) + }) + registerWorkspaceCleanupHandlers(makeEmptyStore(), { + runtime: { + hasTerminalsForWorktree: vi.fn().mockResolvedValue(false) + } as never + }) + + await expect( + getPreflightHandler()?.( + {} as never, + { + worktreeId: 'repo-ssh::/remote/repo-feature', + connectionId: 'ssh-1', + worktreePath: '/remote/repo-feature' + } as never + ) + ).resolves.toEqual({ + hasKillableProcesses: true + }) + }) +}) diff --git a/src/main/ipc/workspace-cleanup-progress-emitter.ts b/src/main/ipc/workspace-cleanup-progress-emitter.ts new file mode 100644 index 00000000000..7a2a5394963 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-progress-emitter.ts @@ -0,0 +1,83 @@ +import type { + WorkspaceCleanupCandidate, + WorkspaceCleanupScanError, + WorkspaceCleanupScanProgress +} from '../../shared/workspace-cleanup' +import { appendWorkspaceCleanupItems } from './workspace-cleanup-scan-primitives' + +const WORKSPACE_CLEANUP_PROGRESS_EMIT_INTERVAL_MS = 100 + +export type WorkspaceCleanupScanOptions = { + onProgress?: (progress: WorkspaceCleanupScanProgress) => void + signal?: AbortSignal +} + +export type WorkspaceCleanupProgressEmitter = { + addDiscovered: (count: number) => void + addCandidate: (candidate: WorkspaceCleanupCandidate) => void + addErrors: (errors: WorkspaceCleanupScanError[]) => void + flush: () => void +} + +/** Caps renderer projection churn while preserving an immediate final progress state. */ +export function createWorkspaceCleanupProgressEmitter( + scanId: string | undefined, + scannedAt: number, + options: WorkspaceCleanupScanOptions +): WorkspaceCleanupProgressEmitter { + const errors: WorkspaceCleanupScanError[] = [] + let pendingCandidates: WorkspaceCleanupCandidate[] = [] + let totalWorktreeCount = 0 + let scannedWorktreeCount = 0 + let dirty = false + let emittedInitialDiscovery = false + let timer: NodeJS.Timeout | null = null + const flush = (): void => { + if (!scanId || !dirty) { + return + } + if (timer) { + clearTimeout(timer) + timer = null + } + dirty = false + const candidates = pendingCandidates + pendingCandidates = [] + options.onProgress?.({ + scanId, + scannedAt, + totalWorktreeCount, + scannedWorktreeCount, + candidates, + errors: [...errors], + candidateMode: 'append' + }) + } + const schedule = (): void => { + if (!scanId) { + return + } + dirty = true + timer ??= setTimeout(flush, WORKSPACE_CLEANUP_PROGRESS_EMIT_INTERVAL_MS) + } + return { + addDiscovered: (count) => { + totalWorktreeCount += count + schedule() + if (!emittedInitialDiscovery) { + emittedInitialDiscovery = true + flush() + } + }, + addCandidate: (candidate) => { + pendingCandidates.push(candidate) + scannedWorktreeCount += 1 + schedule() + }, + addErrors: (newErrors) => { + appendWorkspaceCleanupItems(errors, newErrors) + schedule() + }, + flush + } +} diff --git a/src/main/ipc/workspace-cleanup-scan-eligibility.ts b/src/main/ipc/workspace-cleanup-scan-eligibility.ts new file mode 100644 index 00000000000..d6b2c94b218 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-scan-eligibility.ts @@ -0,0 +1,35 @@ +import type { Worktree } from '../../shared/worktree/types' +import { getPersistedWorkspaceCleanupActivityAt } from '../../shared/workspace-cleanup' +import { isWorkspaceInactiveForCleanup } from './workspace-cleanup-candidate' + +/** + * Which worktrees a broad (non-targeted) cleanup scan is allowed to emit. + * + * Why: a client that has not opted into the full workspace list still renders + * the suggestion-only views, so the host must keep publishing exactly the rows + * it always did — inactive, non-main, non-folder worktrees — or an older client + * is flooded with rows it has no view for. + */ +export function shouldScanBroadWorkspaceCleanupWorktree(args: { + includeAllWorkspaces: boolean + repoIsFolder: boolean + worktree: Worktree + scannedAt: number +}): boolean { + const { includeAllWorkspaces, repoIsFolder, worktree, scannedAt } = args + if (includeAllWorkspaces) { + return true + } + if (repoIsFolder || worktree.isMainWorktree) { + return false + } + // Why: persisted stamps only; the filesystem read that refines them is the + // expensive part this pre-filter exists to avoid. + return isWorkspaceInactiveForCleanup( + { + isArchived: worktree.isArchived, + lastActivityAt: getPersistedWorkspaceCleanupActivityAt(worktree) + }, + scannedAt + ) +} diff --git a/src/main/ipc/workspace-cleanup-scan-primitives.ts b/src/main/ipc/workspace-cleanup-scan-primitives.ts index 8b4c5c839b4..93af63562c2 100644 --- a/src/main/ipc/workspace-cleanup-scan-primitives.ts +++ b/src/main/ipc/workspace-cleanup-scan-primitives.ts @@ -4,6 +4,19 @@ import type { WorkspaceCleanupScanError } from '../../shared/workspace-cleanup' export const WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS = 8_000 +export class WorkspaceCleanupScanCancelledError extends Error { + constructor() { + super('Workspace cleanup scan cancelled') + this.name = 'WorkspaceCleanupScanCancelledError' + } +} + +export function throwIfWorkspaceCleanupScanAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new WorkspaceCleanupScanCancelledError() + } +} + export function appendWorkspaceCleanupItems(target: T[], entries: readonly T[]): void { // Why: cleanup can aggregate generated-size worktree batches; spreading // those batches into push can exceed JavaScript's argument limit. @@ -35,15 +48,25 @@ export async function mapWorkspaceCleanupWithConcurrency( export async function withWorkspaceCleanupTimeout( run: (signal: AbortSignal) => Promise, timeoutMs: number, - message: string + message: string, + parentSignal?: AbortSignal ): Promise { const controller = new AbortController() let timeoutId: NodeJS.Timeout | undefined + let onParentAbort: (() => void) | undefined const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { controller.abort() reject(new Error(message)) }, timeoutMs) + onParentAbort = () => { + controller.abort() + reject(new WorkspaceCleanupScanCancelledError()) + } + parentSignal?.addEventListener('abort', onParentAbort, { once: true }) + if (parentSignal?.aborted) { + onParentAbort() + } }) try { return await Promise.race([run(controller.signal), timeoutPromise]) @@ -51,6 +74,9 @@ export async function withWorkspaceCleanupTimeout( if (timeoutId) { clearTimeout(timeoutId) } + if (onParentAbort) { + parentSignal?.removeEventListener('abort', onParentAbort) + } } } diff --git a/src/main/ipc/workspace-cleanup-scan-targets.ts b/src/main/ipc/workspace-cleanup-scan-targets.ts new file mode 100644 index 00000000000..b437c13736e --- /dev/null +++ b/src/main/ipc/workspace-cleanup-scan-targets.ts @@ -0,0 +1,31 @@ +import { splitWorktreeId } from '../../shared/worktree/id' +import { + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT, + type WorkspaceCleanupScanArgs +} from '../../shared/workspace-cleanup' + +export function hasTargetedWorkspaceCleanupScan(args: WorkspaceCleanupScanArgs): boolean { + return typeof args.worktreeId === 'string' || Array.isArray(args.worktreeIds) +} + +export function getTargetWorktreeIdsByRepo( + args: WorkspaceCleanupScanArgs +): Map> { + const requestedIds = args.worktreeId + ? [args.worktreeId] + : (args.worktreeIds ?? []).slice(0, WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) + const idsByRepo = new Map>() + for (const worktreeId of requestedIds) { + if (typeof worktreeId !== 'string') { + continue + } + const parsed = splitWorktreeId(worktreeId) + if (!parsed) { + continue + } + const repoIds = idsByRepo.get(parsed.repoId) ?? new Set() + repoIds.add(worktreeId) + idsByRepo.set(parsed.repoId, repoIds) + } + return idsByRepo +} diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 7072199ffcd..da6f9821356 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -1,23 +1,24 @@ import type { Store } from '../persistence' -import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { IGitProvider } from '../providers/types' import { isFolderRepo } from '../../shared/repo-kind' import type { Repo } from '../../shared/repo-types' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import { mergeWorktree } from './worktree-logic' -import { splitWorktreeId } from '../../shared/worktree/id' import type { WorkspaceCleanupCandidate, - WorkspaceCleanupScanError, WorkspaceCleanupScanArgs, - WorkspaceCleanupScanProgress, + WorkspaceCleanupScanError, WorkspaceCleanupScanResult } from '../../shared/workspace-cleanup' -import { getPersistedWorkspaceCleanupActivityAt } from '../../shared/workspace-cleanup' +import { + handleRepoWorktreeListError, + listCleanupGitWorktrees +} from './workspace-cleanup-worktree-listing' +import { shouldScanBroadWorkspaceCleanupWorktree } from './workspace-cleanup-scan-eligibility' import { resolvePersistedWorkspaceCleanupActivityWorktree, - resolveWorkspaceCleanupActivityWorktree + resolveWorkspaceCleanupActivityWorktree, + type WorkspaceCleanupFsActivityCache } from './workspace-cleanup-activity' import { buildWorkspaceCleanupCandidate, @@ -27,19 +28,26 @@ import { import { synthesizeDisconnectedSshCleanupCandidates } from './workspace-cleanup-disconnected-ssh' import { WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + WorkspaceCleanupScanCancelledError, appendWorkspaceCleanupItems, - createWorkspaceCleanupScanError, mapWorkspaceCleanupWithConcurrency, - toSafeWorkspaceCleanupRepoScanError, + throwIfWorkspaceCleanupScanAborted, withWorkspaceCleanupTimeout } from './workspace-cleanup-scan-primitives' -import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import { listWorkspaceCleanupFolderWorkspaces } from './workspace-cleanup-folder-workspaces' +import { + createWorkspaceCleanupProgressEmitter, + type WorkspaceCleanupScanOptions +} from './workspace-cleanup-progress-emitter' +import { + getTargetWorktreeIdsByRepo, + hasTargetedWorkspaceCleanupScan +} from './workspace-cleanup-scan-targets' const WORKTREE_SCAN_CONCURRENCY = 3 - -type WorkspaceCleanupScanOptions = { - onProgress?: (progress: WorkspaceCleanupScanProgress) => void -} +// Why: SSH repos pay a worktree-list round trip each; strictly serial repos +// made scan wall-clock the sum of per-repo network latencies. +const REPO_SCAN_CONCURRENCY = 2 type WorkspaceCleanupScanRepoProgress = { onWorktreesDiscovered?: (count: number) => void @@ -47,45 +55,54 @@ type WorkspaceCleanupScanRepoProgress = { onErrors?: (errors: WorkspaceCleanupScanError[]) => void } -type WorkspaceCleanupProgressEmitter = { - addDiscovered: (count: number) => void - addCandidate: (candidate: WorkspaceCleanupCandidate) => void - addErrors: (errors: WorkspaceCleanupScanError[]) => void -} - export async function scanWorkspaceCleanup( store: Store, args: WorkspaceCleanupScanArgs = {}, options: WorkspaceCleanupScanOptions = {} ): Promise { + throwIfWorkspaceCleanupScanAborted(options.signal) const scannedAt = Date.now() - const parsedTarget = args.worktreeId ? splitWorktreeId(args.worktreeId) : null - if (args.worktreeId && !parsedTarget) { + const targetWorktreeIdsByRepo = getTargetWorktreeIdsByRepo(args) + if (hasTargetedWorkspaceCleanupScan(args) && targetWorktreeIdsByRepo.size === 0) { return { scannedAt, candidates: [], errors: [] } } - const repos = parsedTarget - ? store.getRepos().filter((repo) => repo.id === parsedTarget.repoId) - : store.getRepos() - const progress = createProgressEmitter(args.scanId, scannedAt, options) + const repos = + targetWorktreeIdsByRepo.size > 0 + ? store.getRepos().filter((repo) => targetWorktreeIdsByRepo.has(repo.id)) + : store.getRepos() + const progress = createWorkspaceCleanupProgressEmitter(args.scanId, scannedAt, options) const errors: WorkspaceCleanupScanResult['errors'] = [] const candidates: WorkspaceCleanupCandidate[] = [] - for (const repo of repos) { - const result = await scanRepoWorkspaces({ - store, - repo, - scannedAt, - targetWorktreeId: args.worktreeId, - skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []), - onWorktreesDiscovered: progress.addDiscovered, - onCandidateScanned: progress.addCandidate, - onErrors: progress.addErrors - }) - appendWorkspaceCleanupItems(candidates, result.candidates) - appendWorkspaceCleanupItems(errors, result.errors) + try { + const repoResults = await mapWorkspaceCleanupWithConcurrency( + repos, + REPO_SCAN_CONCURRENCY, + async (repo) => { + throwIfWorkspaceCleanupScanAborted(options.signal) + return scanRepoWorkspaces({ + store, + repo, + scannedAt, + targetWorktreeIds: targetWorktreeIdsByRepo.get(repo.id), + refreshTargetActivity: args.worktreeId !== undefined || args.refreshActivity === true, + includeAllWorkspaces: args.includeAllWorkspaces === true, + skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []), + signal: options.signal, + onWorktreesDiscovered: progress.addDiscovered, + onCandidateScanned: progress.addCandidate, + onErrors: progress.addErrors + }) + } + ) + for (const result of repoResults) { + appendWorkspaceCleanupItems(candidates, result.candidates) + appendWorkspaceCleanupItems(errors, result.errors) + } + return { scannedAt, candidates, errors } + } finally { + progress.flush() } - - return { scannedAt, candidates, errors } } async function scanRepoWorkspaces( @@ -93,16 +110,22 @@ async function scanRepoWorkspaces( store: Store repo: Repo scannedAt: number - targetWorktreeId?: string + targetWorktreeIds?: ReadonlySet + refreshTargetActivity: boolean + includeAllWorkspaces: boolean skipGitWorktreeIds: Set + signal?: AbortSignal } & WorkspaceCleanupScanRepoProgress ): Promise { const { store, repo, scannedAt, - targetWorktreeId, + targetWorktreeIds, + refreshTargetActivity, + includeAllWorkspaces, skipGitWorktreeIds, + signal, onWorktreesDiscovered, onCandidateScanned, onErrors @@ -113,17 +136,35 @@ async function scanRepoWorkspaces( let gitWorktrees: GitWorktreeInfo[] = [] try { - const discovered = await listCleanupGitWorktrees(store, repo, repoIsFolder) + const discovered = await listCleanupGitWorktrees(store, repo, repoIsFolder, signal) provider = discovered.provider gitWorktrees = discovered.gitWorktrees } catch (error) { - return handleRepoWorktreeListError({ repo, targetWorktreeId, scannedAt, error, onErrors }) + if (error instanceof WorkspaceCleanupScanCancelledError) { + throw error + } + return handleRepoWorktreeListError({ + repo, + targeted: targetWorktreeIds !== undefined, + scannedAt, + error, + onErrors + }) } if (repo.connectionId && !provider) { - const candidates = targetWorktreeId - ? synthesizeDisconnectedSshCleanupCandidates(store, repo, scannedAt, targetWorktreeId) - : [] + // Why: a disconnected host still owns real workspaces; the full list shows + // them (blocked), while legacy scans keep omitting what they cannot inspect. + const candidates = + targetWorktreeIds || includeAllWorkspaces + ? synthesizeDisconnectedSshCleanupCandidates( + store, + repo, + scannedAt, + targetWorktreeIds, + includeAllWorkspaces + ) + : [] onWorktreesDiscovered?.(candidates.length) for (const candidate of candidates) { onCandidateScanned?.(candidate) @@ -131,43 +172,84 @@ async function scanRepoWorkspaces( return { scannedAt, candidates, errors: [] } } - const mergedWorktrees = gitWorktrees.map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = store.getWorktreeMeta(worktreeId) - return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) - }) - const candidateWorktrees = targetWorktreeId - ? mergedWorktrees.filter((worktree) => worktree.id === targetWorktreeId) + const mergedWorktrees = + repoIsFolder && includeAllWorkspaces + ? listWorkspaceCleanupFolderWorkspaces(store, repo) + : gitWorktrees.map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + const meta = store.getWorktreeMeta(worktreeId) + return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + }) + // Why: with includeAllWorkspaces the browser shows every workspace and lets + // filters narrow it; an age threshold here would hide rows from all views. + const candidateWorktrees = targetWorktreeIds + ? mergedWorktrees.filter((worktree) => targetWorktreeIds.has(worktree.id)) : mergedWorktrees.filter((worktree) => - shouldResolveBroadWorkspaceCleanupActivity(repoIsFolder, worktree, scannedAt) + shouldScanBroadWorkspaceCleanupWorktree({ + includeAllWorkspaces, + repoIsFolder, + worktree, + scannedAt + }) ) + // Why: with a target list or the full-list browser, every filtered row will + // be reported; counting them upfront keeps the progress bar honest instead + // of advancing discovered/scanned in lockstep at ~100%. + const reportDiscoveredUpfront = targetWorktreeIds !== undefined || includeAllWorkspaces + if (reportDiscoveredUpfront && candidateWorktrees.length > 0) { + onWorktreesDiscovered?.(candidateWorktrees.length) + } // Why: fs stat has no cancellation, so on a hung network/WSL mount every // timed-out row would abandon more threadpool work. After the first timeout, // stop statting this repo and use persisted activity only. let activityStatsUnavailable = false + const fsActivityCache: WorkspaceCleanupFsActivityCache = new Map() const candidatesWithSkipped = await mapWorkspaceCleanupWithConcurrency( candidateWorktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { + throwIfWorkspaceCleanupScanAborted(signal) // Why: externally-created worktrees can miss Orca activity stamps; local // filesystem metadata is a conservative guard before suggesting deletion. - const worktreeWithActivity = activityStatsUnavailable - ? resolvePersistedWorkspaceCleanupActivityWorktree(worktree) - : await resolveCleanupActivityWithTimeout(repo, worktree, () => { - activityStatsUnavailable = true - }) - if (!targetWorktreeId && !isWorkspaceInactiveForCleanup(worktreeWithActivity, scannedAt)) { + const persistedActivityWorktree = resolvePersistedWorkspaceCleanupActivityWorktree(worktree) + const persistedActivityIsRecent = !isWorkspaceInactiveForCleanup( + persistedActivityWorktree, + scannedAt + ) + const worktreeWithActivity = + activityStatsUnavailable || + (targetWorktreeIds ? !refreshTargetActivity : persistedActivityIsRecent) + ? persistedActivityWorktree + : await resolveCleanupActivityWithTimeout( + repo, + worktree, + () => { + activityStatsUnavailable = true + }, + signal, + fsActivityCache + ) + const isInactive = isWorkspaceInactiveForCleanup(worktreeWithActivity, scannedAt) + if (!targetWorktreeIds && !includeAllWorkspaces && !isInactive) { return null } - onWorktreesDiscovered?.(1) + if (!reportDiscoveredUpfront) { + onWorktreesDiscovered?.(1) + } const candidate = await buildWorkspaceCleanupCandidate({ repo, worktree: worktreeWithActivity, scannedAt, provider, - skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id), - forceGitCheck: Boolean(targetWorktreeId) + // Why: a row with no inactivity reason can never be queued or selected, + // so full-fleet scans stream it now and let a focused scan read git later. + skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id) || !isInactive, + forceGitCheck: Boolean(targetWorktreeIds), + signal }).catch((error) => { + if (error instanceof WorkspaceCleanupScanCancelledError) { + throw error + } console.error('Workspace cleanup candidate scan failed', error) return buildWorkspaceCleanupCandidateFromError(repo, worktreeWithActivity, scannedAt) }) @@ -185,128 +267,30 @@ async function scanRepoWorkspaces( async function resolveCleanupActivityWithTimeout( repo: Repo, worktree: Worktree, - onActivityStatsUnavailable: () => void + onActivityStatsUnavailable: () => void, + signal?: AbortSignal, + fsActivityCache?: WorkspaceCleanupFsActivityCache ): Promise { try { return await withWorkspaceCleanupTimeout( - () => resolveWorkspaceCleanupActivityWorktree(repo, worktree), + () => + resolveWorkspaceCleanupActivityWorktree( + repo, + worktree, + undefined, + undefined, + fsActivityCache + ), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out reading worktree activity.' + 'Timed out reading worktree activity.', + signal ) } catch (error) { + if (error instanceof WorkspaceCleanupScanCancelledError) { + throw error + } onActivityStatsUnavailable() console.warn('Workspace cleanup activity scan failed', error) return resolvePersistedWorkspaceCleanupActivityWorktree(worktree) } } - -function shouldResolveBroadWorkspaceCleanupActivity( - repoIsFolder: boolean, - worktree: Worktree, - scannedAt: number -): boolean { - if (repoIsFolder || worktree.isMainWorktree) { - return false - } - return isWorkspaceInactiveForCleanup( - { - isArchived: worktree.isArchived, - lastActivityAt: getPersistedWorkspaceCleanupActivityAt(worktree) - }, - scannedAt - ) -} - -async function listCleanupGitWorktrees( - store: Store, - repo: Repo, - repoIsFolder: boolean -): Promise<{ provider: IGitProvider | null; gitWorktrees: GitWorktreeInfo[] }> { - if (repoIsFolder) { - return { provider: null, gitWorktrees: [createFolderWorktree(repo)] } - } - if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) ?? null - if (!provider) { - // Why: cleanup should reflect only workspaces Orca can currently inspect. - return { provider: null, gitWorktrees: [] } - } - return { - provider, - gitWorktrees: await withWorkspaceCleanupTimeout( - (signal) => provider.listWorktrees(repo.path, { signal }), - WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out listing SSH worktrees.' - ) - } - } - const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - return { - provider: null, - gitWorktrees: await withWorkspaceCleanupTimeout( - (signal) => listRepoWorktrees(repo, { ...localGitOptions, signal }), - WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out listing worktrees.' - ) - } -} - -function handleRepoWorktreeListError(args: { - repo: Repo - targetWorktreeId?: string - scannedAt: number - error: unknown - onErrors?: (errors: WorkspaceCleanupScanError[]) => void -}): WorkspaceCleanupScanResult { - const { repo, targetWorktreeId, scannedAt, error, onErrors } = args - console.error('Workspace cleanup repo scan failed', error) - if (repo.connectionId && !targetWorktreeId) { - // Why: broad cleanup only shows remote workspaces Orca can inspect now. - // A connected SSH repo that fails mid-scan is omitted, not bannered. - return { scannedAt, candidates: [], errors: [] } - } - const errors = [createWorkspaceCleanupScanError(repo, toSafeWorkspaceCleanupRepoScanError(error))] - onErrors?.(errors) - return { scannedAt, candidates: [], errors } -} - -function createProgressEmitter( - scanId: string | undefined, - scannedAt: number, - options: WorkspaceCleanupScanOptions -): WorkspaceCleanupProgressEmitter { - const errors: WorkspaceCleanupScanError[] = [] - let totalWorktreeCount = 0 - let scannedWorktreeCount = 0 - const emit = ( - candidates: WorkspaceCleanupCandidate[], - candidateMode: WorkspaceCleanupScanProgress['candidateMode'] = 'snapshot' - ): void => { - if (!scanId) { - return - } - options.onProgress?.({ - scanId, - scannedAt, - totalWorktreeCount, - scannedWorktreeCount, - candidates, - errors: [...errors], - candidateMode - }) - } - return { - addDiscovered: (count) => { - totalWorktreeCount += count - emit([], 'append') - }, - addCandidate: (candidate) => { - scannedWorktreeCount += 1 - emit([candidate], 'append') - }, - addErrors: (newErrors) => { - appendWorkspaceCleanupItems(errors, newErrors) - emit([], 'append') - } - } -} diff --git a/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts b/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts new file mode 100644 index 00000000000..8bfbc586959 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts @@ -0,0 +1,281 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ipcMain } from 'electron' +import type { Store } from '../persistence' + +const { persistScanResultMock, readScanSnapshotMock, scanWorkspaceCleanupMock } = vi.hoisted( + () => ({ + persistScanResultMock: vi.fn(), + readScanSnapshotMock: vi.fn(), + scanWorkspaceCleanupMock: vi.fn() + }) +) + +const { beginPruneBatchMock, finishPruneBatchMock, recordPruneMock } = vi.hoisted(() => ({ + beginPruneBatchMock: vi.fn(), + finishPruneBatchMock: vi.fn().mockResolvedValue(undefined), + recordPruneMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn() + } +})) + +vi.mock('../workspace-cleanup-scan-snapshot', () => ({ + persistWorkspaceCleanupScanResult: persistScanResultMock, + readWorkspaceCleanupScanSnapshot: readScanSnapshotMock +})) + +vi.mock('./workspace-cleanup-scan', () => ({ + scanWorkspaceCleanup: scanWorkspaceCleanupMock +})) + +vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({ + beginWorkspaceCleanupRemovalSnapshotPruneBatch: beginPruneBatchMock, + finishWorkspaceCleanupRemovalSnapshotPruneBatch: finishPruneBatchMock, + recordWorkspaceCleanupRemovalSnapshotPrune: recordPruneMock +})) + +import { registerWorkspaceCleanupHandlers } from './workspace-cleanup' + +const NOW = 1_700_000_000_000 + +function makeScanEvent(senderOverrides: Record = {}): never { + return { + sender: { + id: 1, + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + ...senderOverrides + } + } as never +} + +function makeEmptyStore(): Store { + return { + getProfileStorageDirectory: () => '/profile-a', + getRepos: () => [], + getWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => ({}), + getGitHubCache: () => ({ pr: {}, issue: {} }) + } as unknown as Store +} + +describe('workspace cleanup snapshot IPC', () => { + beforeEach(() => { + vi.mocked(ipcMain.handle).mockClear() + persistScanResultMock.mockReset().mockResolvedValue(undefined) + readScanSnapshotMock.mockReset() + scanWorkspaceCleanupMock.mockReset().mockImplementation(async (_store, args, options) => { + const result = { scannedAt: NOW, candidates: [], errors: [] } + options.onProgress?.({ + scanId: args.scanId, + scannedAt: NOW, + scannedWorktreeCount: 0, + totalWorktreeCount: 0, + candidates: [], + errors: [] + }) + return result + }) + beginPruneBatchMock.mockReset() + finishPruneBatchMock.mockReset().mockResolvedValue(undefined) + recordPruneMock.mockReset() + }) + + it('persists the completed scan result after replying', async () => { + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + + const args = { includeAllWorkspaces: true } + const result = await handler?.(makeScanEvent(), args) + + expect(persistScanResultMock).toHaveBeenCalledWith('/profile-a', args, result) + }) + + it('does not rewrite the fleet snapshot for a focused scan', async () => { + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + + await handler?.(makeScanEvent(), { worktreeId: 'repo-1::/repo-feature' }) + + expect(persistScanResultMock).not.toHaveBeenCalled() + }) + + it('does not rewrite the fleet snapshot for a targeted evidence batch', async () => { + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + + await handler?.(makeScanEvent(), { + worktreeIds: ['repo-1::/repo-a', 'repo-1::/repo-b'] + }) + + expect(persistScanResultMock).not.toHaveBeenCalled() + }) + + it('does not persist an empty-target scan over the fleet snapshot', async () => { + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + + // Why: worktreeIds: [] is a targeted scan that returns nothing; persisting + // it as broad would wipe the fleet cache. + await handler?.(makeScanEvent(), { worktreeIds: [], includeAllWorkspaces: true }) + + expect(persistScanResultMock).not.toHaveBeenCalled() + }) + + it('stops streaming progress after the invoking renderer is destroyed', async () => { + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + const send = vi.fn(() => { + throw new Error('Object has been destroyed') + }) + + await expect( + handler?.(makeScanEvent({ isDestroyed: () => true, send }), { scanId: 'scan-1' }) + ).resolves.toEqual({ scannedAt: NOW, candidates: [], errors: [] }) + expect(send).not.toHaveBeenCalled() + }) + + it('aborts the scan when the invoking renderer is destroyed mid-flight', async () => { + let abortListener: (() => void) | undefined + scanWorkspaceCleanupMock.mockImplementation((_store, _args, options) => { + return new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(new Error('cancelled')), { + once: true + }) + }) + }) + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + const event = makeScanEvent({ + once: vi.fn((eventName: string, listener: () => void) => { + if (eventName === 'destroyed') { + abortListener = listener + } + }) + }) + + const scan = handler?.(event, { includeAllWorkspaces: true }) + abortListener?.() + await expect(scan).rejects.toThrow('cancelled') + }) + + it('keeps legacy suggestion-only and full broad scans isolated across modes', async () => { + scanWorkspaceCleanupMock.mockImplementation((_store, _args, options) => { + return new Promise((resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(new Error('cancelled')), { + once: true + }) + setTimeout(() => resolve({ scannedAt: NOW, candidates: [], errors: [] }), 5) + }) + }) + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + + // Why: different includeAllWorkspaces modes are separate lanes; one must + // not abort the other even from the same renderer. + const [legacy, full] = await Promise.allSettled([ + handler?.(makeScanEvent({ id: 7 }), { includeAllWorkspaces: false }) as Promise, + handler?.(makeScanEvent({ id: 7 }), { includeAllWorkspaces: true }) as Promise + ]) + + expect(legacy?.status).toBe('fulfilled') + expect(full?.status).toBe('fulfilled') + }) + + it('supersedes a concurrent broad scan from the same renderer', async () => { + const settlements: Promise[] = [] + scanWorkspaceCleanupMock.mockImplementation((_store, _args, options) => { + return new Promise((resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(new Error('cancelled')), { + once: true + }) + setTimeout(() => resolve({ scannedAt: NOW, candidates: [], errors: [] }), 5) + }) + }) + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + + settlements.push( + handler?.(makeScanEvent({ id: 7 }), { includeAllWorkspaces: true }) as Promise, + handler?.(makeScanEvent({ id: 7 }), { includeAllWorkspaces: true }) as Promise + ) + const [first, second] = await Promise.allSettled(settlements) + + expect(first?.status).toBe('rejected') + expect(second?.status).toBe('fulfilled') + }) + + it('cancels only the invoking renderer scan id', async () => { + scanWorkspaceCleanupMock.mockImplementation((_store, _args, options) => { + return new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(new Error('cancelled')), { + once: true + }) + }) + }) + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handlers = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls) + const event = makeScanEvent({ id: 7 }) + + const scan = handlers['workspaceCleanup:scan']?.(event, { scanId: 'scan-1' }) + expect(handlers['workspaceCleanup:cancelScan']?.(event, 'scan-1')).toBe(true) + await expect(scan).rejects.toThrow('cancelled') + expect( + handlers['workspaceCleanup:cancelScan']?.({ sender: { id: 8 } } as never, 'scan-1') + ).toBe(false) + }) + + it('serves the cached scan snapshot through getCachedScan', async () => { + const snapshot = { scannedAt: NOW, candidates: [], errors: [] } + readScanSnapshotMock.mockResolvedValue(snapshot) + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:getCachedScan')?.[1] + + await expect(handler?.({} as never)).resolves.toBe(snapshot) + expect(readScanSnapshotMock).toHaveBeenCalledWith('/profile-a') + expect(vi.mocked(ipcMain.removeHandler)).toHaveBeenCalledWith('workspaceCleanup:getCachedScan') + }) + + it('routes a validated removal snapshot prune batch through its explicit boundary', async () => { + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handlers = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls) + const batch = { batchId: 'batch-1' } + const target = { + ...batch, + worktreeId: 'repo-ssh::/remote/feature', + executionHostId: 'ssh:ssh-1' + } + + await handlers['workspaceCleanup:beginRemovalSnapshotPruneBatch']?.({} as never, batch) + await handlers['workspaceCleanup:recordRemovalSnapshotPrune']?.({} as never, target) + await handlers['workspaceCleanup:finishRemovalSnapshotPruneBatch']?.({} as never, batch) + + expect(beginPruneBatchMock).toHaveBeenCalledExactlyOnceWith('/profile-a', batch) + expect(recordPruneMock).toHaveBeenCalledExactlyOnceWith('/profile-a', target) + expect(finishPruneBatchMock).toHaveBeenCalledExactlyOnceWith('/profile-a', batch) + }) +}) diff --git a/src/main/ipc/workspace-cleanup-worktree-listing.ts b/src/main/ipc/workspace-cleanup-worktree-listing.ts new file mode 100644 index 00000000000..dab37caf3f9 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-worktree-listing.ts @@ -0,0 +1,76 @@ +import type { Store } from '../persistence' +import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import type { IGitProvider } from '../providers/types' +import type { Repo } from '../../shared/repo-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import type { + WorkspaceCleanupScanError, + WorkspaceCleanupScanResult +} from '../../shared/workspace-cleanup' +import { + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + createWorkspaceCleanupScanError, + toSafeWorkspaceCleanupRepoScanError, + withWorkspaceCleanupTimeout +} from './workspace-cleanup-scan-primitives' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' + +export async function listCleanupGitWorktrees( + store: Store, + repo: Repo, + repoIsFolder: boolean, + signal?: AbortSignal +): Promise<{ provider: IGitProvider | null; gitWorktrees: GitWorktreeInfo[] }> { + if (repoIsFolder) { + return { + provider: repo.connectionId ? (getSshGitProvider(repo.connectionId) ?? null) : null, + gitWorktrees: [createFolderWorktree(repo)] + } + } + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) ?? null + if (!provider) { + // Why: cleanup should reflect only workspaces Orca can currently inspect. + return { provider: null, gitWorktrees: [] } + } + return { + provider, + gitWorktrees: await withWorkspaceCleanupTimeout( + (signal) => provider.listWorktrees(repo.path, { signal }), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out listing SSH worktrees.', + signal + ) + } + } + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + return { + provider: null, + gitWorktrees: await withWorkspaceCleanupTimeout( + (signal) => listRepoWorktrees(repo, { ...localGitOptions, signal }), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out listing worktrees.', + signal + ) + } +} + +export function handleRepoWorktreeListError(args: { + repo: Repo + targeted: boolean + scannedAt: number + error: unknown + onErrors?: (errors: WorkspaceCleanupScanError[]) => void +}): WorkspaceCleanupScanResult { + const { repo, targeted, scannedAt, error, onErrors } = args + console.error('Workspace cleanup repo scan failed', error) + if (repo.connectionId && !targeted) { + // Why: broad cleanup only shows remote workspaces Orca can inspect now. + // A connected SSH repo that fails mid-scan is omitted, not bannered. + return { scannedAt, candidates: [], errors: [] } + } + const errors = [createWorkspaceCleanupScanError(repo, toSafeWorkspaceCleanupRepoScanError(error))] + onErrors?.(errors) + return { scannedAt, candidates: [], errors } +} diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 96f0ce821f8..b35167906c8 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -17,7 +17,6 @@ const { gitExecFileAsyncMock, getLocalProjectWorktreeGitOptionsMock, getSshGitProviderMock, - getSshPtyProviderMock, listRegisteredPtysMock } = vi.hoisted(() => ({ lstatMock: vi.fn(), @@ -27,7 +26,6 @@ const { gitExecFileAsyncMock: vi.fn(), getLocalProjectWorktreeGitOptionsMock: vi.fn(), getSshGitProviderMock: vi.fn(), - getSshPtyProviderMock: vi.fn(), listRegisteredPtysMock: vi.fn() })) @@ -69,10 +67,16 @@ vi.mock('../memory/pty-registry', () => ({ })) vi.mock('./pty', () => ({ - getSshPtyProvider: getSshPtyProviderMock + getSshPtyProvider: vi.fn() })) -import { registerWorkspaceCleanupHandlers, scanWorkspaceCleanup } from './workspace-cleanup' +// Why: snapshot persistence does real file I/O; covered in workspace-cleanup-snapshot-ipc.test.ts. +vi.mock('../workspace-cleanup-scan-snapshot', () => ({ + persistWorkspaceCleanupScanResult: vi.fn(async () => undefined), + readWorkspaceCleanupScanSnapshot: vi.fn(async () => null) +})) + +import { scanWorkspaceCleanup } from './workspace-cleanup' const NOW = 1_700_000_000_000 const REPO: Repo = { @@ -161,7 +165,6 @@ describe('workspace cleanup scan', () => { gitExecFileAsyncMock.mockReset() getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) getSshGitProviderMock.mockReset() - getSshPtyProviderMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) lstatMock.mockResolvedValue({ mtimeMs: 0 }) @@ -246,11 +249,11 @@ describe('workspace cleanup scan', () => { (event as WorkspaceCleanupScanProgress).candidateMode === 'append' && (event as WorkspaceCleanupScanProgress).candidates.length > 0 ) - expect(candidateProgress).toHaveLength(2) - expect(candidateProgress.every((event) => event.candidates.length === 1)).toBe(true) + expect(candidateProgress.length).toBeLessThanOrEqual(2) const progressWorktreeIds = candidateProgress.flatMap((event) => event.candidates.map((candidate) => candidate.worktreeId) ) + expect(progressWorktreeIds).toHaveLength(2) expect(progressWorktreeIds).toEqual( expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b']) ) @@ -829,61 +832,4 @@ describe('workspace cleanup scan', () => { }) expect(result.candidates[0]).not.toHaveProperty('linkedPR') }) - - it('reports local processes that workspace deletion would kill', async () => { - const localProvider = { - listProcesses: vi.fn().mockResolvedValue([ - { - id: 'repo-1::/repo-feature@@session-1', - cwd: '/repo-feature', - title: 'zsh' - } - ]) - } - registerWorkspaceCleanupHandlers(makeStore(), { - runtime: { - hasTerminalsForWorktree: vi.fn().mockResolvedValue(false) - } as never, - getLocalPtyProvider: () => localProvider as never - }) - - const handler = vi - .mocked(ipcMain.handle) - .mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1] - - await expect(handler?.({} as never, { worktreeId: 'repo-1::/repo-feature' })).resolves.toEqual({ - hasKillableProcesses: true - }) - }) - - it('reports SSH processes inside the remote workspace path', async () => { - getSshPtyProviderMock.mockReturnValue({ - listProcesses: vi.fn().mockResolvedValue([ - { - id: 'remote-session-1', - cwd: '/remote/repo-feature/subdir', - title: 'codex' - } - ]) - }) - registerWorkspaceCleanupHandlers(makeStore(), { - runtime: { - hasTerminalsForWorktree: vi.fn().mockResolvedValue(false) - } as never - }) - - const handler = vi - .mocked(ipcMain.handle) - .mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1] - - await expect( - handler?.({} as never, { - worktreeId: 'repo-ssh::/remote/repo-feature', - connectionId: 'ssh-1', - worktreePath: '/remote/repo-feature' - }) - ).resolves.toEqual({ - hasKillableProcesses: true - }) - }) }) diff --git a/src/main/ipc/workspace-cleanup.ts b/src/main/ipc/workspace-cleanup.ts index 229f4c4177f..e73dd27cc5e 100644 --- a/src/main/ipc/workspace-cleanup.ts +++ b/src/main/ipc/workspace-cleanup.ts @@ -10,9 +10,22 @@ import { type WorkspaceCleanupLocalProcessArgs, type WorkspaceCleanupLocalProcessResult, type WorkspaceCleanupScanArgs, - type WorkspaceCleanupScanResult + type WorkspaceCleanupScanResult, + type WorkspaceCleanupSnapshotPruneBatchArgs, + type WorkspaceCleanupSnapshotPruneRecordArgs } from '../../shared/workspace-cleanup' +import { parseExecutionHostId } from '../../shared/execution-host' import { scanWorkspaceCleanup } from './workspace-cleanup-scan' +import { hasTargetedWorkspaceCleanupScan } from './workspace-cleanup-scan-targets' +import { + persistWorkspaceCleanupScanResult, + readWorkspaceCleanupScanSnapshot +} from '../workspace-cleanup-scan-snapshot' +import { + beginWorkspaceCleanupRemovalSnapshotPruneBatch, + finishWorkspaceCleanupRemovalSnapshotPruneBatch, + recordWorkspaceCleanupRemovalSnapshotPrune +} from '../workspace-cleanup-removal-snapshot-prune' export { scanWorkspaceCleanup } @@ -21,28 +34,109 @@ type WorkspaceCleanupHandlerDeps = { getLocalPtyProvider?: () => IPtyProvider } +// Why: module scope — handler re-registration on a new main window must not +// orphan the previous window's controllers in a discarded map. +const activeScans = new Map() +// Keyed by sender AND scan mode: legacy suggestion-only and full-workspace +// broad scans are separate lanes and must not supersede each other, matching +// the renderer's broad-scan registry. +const broadScanControllersBySenderMode = new Map() + +function getBroadScanModeKey(senderId: number, args: WorkspaceCleanupScanArgs): string { + return `${senderId}\0${args.includeAllWorkspaces === true}` +} + export function registerWorkspaceCleanupHandlers( store: Store, deps: WorkspaceCleanupHandlerDeps = {} ): void { + const snapshotDirectory = store.getProfileStorageDirectory() ipcMain.removeHandler('workspaceCleanup:scan') + ipcMain.removeHandler('workspaceCleanup:cancelScan') + ipcMain.removeHandler('workspaceCleanup:getCachedScan') ipcMain.removeHandler('workspaceCleanup:dismiss') ipcMain.removeHandler('workspaceCleanup:clearDismissals') ipcMain.removeHandler('workspaceCleanup:hasKillableLocalProcesses') + ipcMain.removeHandler('workspaceCleanup:beginRemovalSnapshotPruneBatch') + ipcMain.removeHandler('workspaceCleanup:recordRemovalSnapshotPrune') + ipcMain.removeHandler('workspaceCleanup:finishRemovalSnapshotPruneBatch') ipcMain.handle( 'workspaceCleanup:scan', - (event, args?: WorkspaceCleanupScanArgs): Promise => - scanWorkspaceCleanup(store, args ?? {}, { - onProgress: args?.scanId - ? (progress) => event.sender.send('workspaceCleanup:scanProgress', progress) - : undefined - }) + async (event, args?: WorkspaceCleanupScanArgs): Promise => { + const scanArgs = args ?? {} + const sender = event.sender + const scanKey = getWorkspaceCleanupScanKey(sender.id, scanArgs.scanId) + const controller = new AbortController() + if (scanKey) { + activeScans.set(scanKey, controller) + } + const targeted = hasTargetedWorkspaceCleanupScan(scanArgs) + const broadScanKey = getBroadScanModeKey(sender.id, scanArgs) + if (!targeted) { + // Why: two same-mode broad fleet scans from one renderer can only be a + // refresh race; running both doubles git subprocess and fs load for a + // result the renderer will discard. + broadScanControllersBySenderMode.get(broadScanKey)?.abort() + broadScanControllersBySenderMode.set(broadScanKey, controller) + } + // Why: a window close or reload must stop the fleet scan's git and fs + // work, not merely mute its progress events. + const onSenderDestroyed = (): void => controller.abort() + sender.once('destroyed', onSenderDestroyed) + try { + const result = await scanWorkspaceCleanup(store, scanArgs, { + signal: controller.signal, + onProgress: scanArgs.scanId + ? (progress) => { + if (!sender.isDestroyed()) { + sender.send('workspaceCleanup:scanProgress', progress) + } + } + : undefined + }) + // Focused scans are live-only; persisting each rewrites and fsyncs the + // fleet snapshot. worktreeIds: [] is still targeted — persisting its + // empty result would wipe the fleet cache. + if (!targeted) { + void persistWorkspaceCleanupScanResult(snapshotDirectory, scanArgs, result) + } + return result + } finally { + if (!sender.isDestroyed()) { + sender.removeListener('destroyed', onSenderDestroyed) + } + if (scanKey && activeScans.get(scanKey) === controller) { + activeScans.delete(scanKey) + } + if (!targeted && broadScanControllersBySenderMode.get(broadScanKey) === controller) { + broadScanControllersBySenderMode.delete(broadScanKey) + } + } + } + ) + + ipcMain.handle('workspaceCleanup:cancelScan', (event, scanId: string): boolean => { + const scanKey = getWorkspaceCleanupScanKey(event.sender.id, scanId) + const controller = scanKey ? activeScans.get(scanKey) : undefined + if (!controller || controller.signal.aborted) { + return false + } + controller.abort() + return true + }) + + ipcMain.handle( + 'workspaceCleanup:getCachedScan', + (): Promise => + readWorkspaceCleanupScanSnapshot(snapshotDirectory) ) ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => { - const current = store.getUI().workspaceCleanup?.dismissals ?? {} - const next = { ...current } + const next = { ...store.getUI().workspaceCleanup?.dismissals } + for (const worktreeId of args.removedWorktreeIds ?? []) { + delete next[worktreeId] + } for (const dismissal of args.dismissals ?? []) { if ( dismissal && @@ -69,6 +163,49 @@ export function registerWorkspaceCleanupHandlers( hasKillableProcesses: await hasKillableProcesses(args, deps) }) ) + + ipcMain.handle( + 'workspaceCleanup:beginRemovalSnapshotPruneBatch', + (_event, args: WorkspaceCleanupSnapshotPruneBatchArgs) => { + if (isSnapshotPruneBatchId(args?.batchId)) { + beginWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, args) + } + } + ) + + ipcMain.handle( + 'workspaceCleanup:recordRemovalSnapshotPrune', + (_event, args: WorkspaceCleanupSnapshotPruneRecordArgs) => { + if ( + isSnapshotPruneBatchId(args?.batchId) && + typeof args?.worktreeId === 'string' && + args.worktreeId.length > 0 && + (args.executionHostId === undefined || parseExecutionHostId(args.executionHostId)) + ) { + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, args) + } + } + ) + + ipcMain.handle( + 'workspaceCleanup:finishRemovalSnapshotPruneBatch', + (_event, args: WorkspaceCleanupSnapshotPruneBatchArgs) => { + if (isSnapshotPruneBatchId(args?.batchId)) { + return finishWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, args) + } + return undefined + } + ) +} + +function isSnapshotPruneBatchId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 128 +} + +function getWorkspaceCleanupScanKey(senderId: number, scanId: unknown): string | null { + return typeof scanId === 'string' && scanId.length > 0 && scanId.length <= 128 + ? `${senderId}\0${scanId}` + : null } async function hasKillableProcesses( diff --git a/src/main/ipc/workspace-space.test.ts b/src/main/ipc/workspace-space.test.ts index c6605c73cce..63b118eff19 100644 --- a/src/main/ipc/workspace-space.test.ts +++ b/src/main/ipc/workspace-space.test.ts @@ -11,6 +11,8 @@ const { analyzeWorkspaceSpaceMock, removeHandlerMock, handleMock, + persistAnalysisSnapshotMock, + readAnalysisSnapshotMock, WorkspaceSpaceScanCancelledErrorMock } = vi.hoisted(() => { const handlers = new Map Promise>() @@ -21,6 +23,8 @@ const { handleMock: vi.fn((channel: string, handler: (...args: unknown[]) => Promise) => { handlers.set(channel, handler) }), + persistAnalysisSnapshotMock: vi.fn(), + readAnalysisSnapshotMock: vi.fn(), WorkspaceSpaceScanCancelledErrorMock: class WorkspaceSpaceScanCancelledError extends Error {} } }) @@ -37,6 +41,11 @@ vi.mock('../workspace-space-analysis', () => ({ analyzeWorkspaceSpace: analyzeWorkspaceSpaceMock })) +vi.mock('../workspace-space-analysis-snapshot', () => ({ + persistWorkspaceSpaceAnalysisSnapshot: persistAnalysisSnapshotMock, + readWorkspaceSpaceAnalysisSnapshot: readAnalysisSnapshotMock +})) + import { registerWorkspaceSpaceHandlers } from './workspace-space' function createAnalysis(scannedAt: number): WorkspaceSpaceAnalysis { @@ -65,13 +74,19 @@ function createEvent() { } } +function createStore(): Store { + return { getProfileStorageDirectory: () => '/profile-a' } as Store +} + describe('registerWorkspaceSpaceHandlers', () => { beforeEach(() => { analyzeWorkspaceSpaceMock.mockReset() + persistAnalysisSnapshotMock.mockReset().mockResolvedValue(undefined) + readAnalysisSnapshotMock.mockReset().mockResolvedValue(null) }) it('shares an in-flight analysis request', async () => { - const store = {} as Store + const store = createStore() let resolveFirstScan: (analysis: WorkspaceSpaceAnalysis) => void = () => {} const firstScan = new Promise((resolve) => { resolveFirstScan = resolve @@ -109,7 +124,7 @@ describe('registerWorkspaceSpaceHandlers', () => { }) it('forwards scan progress to the requesting renderer', async () => { - const store = {} as Store + const store = createStore() let onProgress: ((progress: WorkspaceSpaceScanProgress) => void) | undefined analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => { onProgress = options.onProgress @@ -138,8 +153,65 @@ describe('registerWorkspaceSpaceHandlers', () => { expect(event.sender.send).toHaveBeenCalledWith('workspaceSpace:progress', progress) }) + it('batches completed measurements without dropping throttled rows', async () => { + const store = createStore() + let onProgress: ((progress: WorkspaceSpaceScanProgress) => void) | undefined + let resolveScan!: (analysis: WorkspaceSpaceAnalysis) => void + analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => { + onProgress = options.onProgress + return new Promise((resolve) => { + resolveScan = resolve + }) + }) + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + + try { + registerWorkspaceSpaceHandlers(store) + const event = createEvent() + const promise = handlers.get('workspaceSpace:analyze')!(event) + const base = { + scanId: 'scan-1', + state: 'running' as const, + startedAt: 1, + updatedAt: 1, + totalRepoCount: 1, + scannedRepoCount: 0, + totalWorktreeCount: 2, + currentRepoDisplayName: 'orca', + currentWorktreeDisplayName: 'feature' + } + onProgress?.({ ...base, scannedWorktreeCount: 0 }) + onProgress?.({ + ...base, + scannedWorktreeCount: 1, + completedMeasurements: [{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }] + }) + onProgress?.({ + ...base, + scannedWorktreeCount: 2, + completedMeasurements: [{ worktreeId: 'b', status: 'ok', sizeBytes: 20 }] + }) + resolveScan(createAnalysis(1)) + await promise + + expect(event.sender.send).toHaveBeenCalledTimes(2) + expect(event.sender.send).toHaveBeenLastCalledWith( + 'workspaceSpace:progress', + expect.objectContaining({ + scannedWorktreeCount: 2, + completedMeasurements: [ + { worktreeId: 'a', status: 'ok', sizeBytes: 10 }, + { worktreeId: 'b', status: 'ok', sizeBytes: 20 } + ] + }) + ) + } finally { + now.mockRestore() + } + }) + it('cancels the in-flight scan', async () => { - const store = {} as Store + const store = createStore() let signal: AbortSignal | undefined analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => { signal = options.signal @@ -157,12 +229,36 @@ describe('registerWorkspaceSpaceHandlers', () => { }) it('returns a normal cancelled result instead of rejecting expected cancellation', async () => { - const store = {} as Store + const store = createStore() analyzeWorkspaceSpaceMock.mockRejectedValueOnce(new WorkspaceSpaceScanCancelledErrorMock()) registerWorkspaceSpaceHandlers(store) const analyzeHandler = handlers.get('workspaceSpace:analyze') await expect(analyzeHandler!(createEvent())).resolves.toEqual({ ok: false, cancelled: true }) + expect(persistAnalysisSnapshotMock).not.toHaveBeenCalled() + }) + + it('persists the completed analysis snapshot', async () => { + const analysis = createAnalysis(7) + analyzeWorkspaceSpaceMock.mockResolvedValueOnce(analysis) + + registerWorkspaceSpaceHandlers(createStore()) + const analyzeHandler = handlers.get('workspaceSpace:analyze') + + await expect(analyzeHandler!(createEvent())).resolves.toEqual({ ok: true, analysis }) + expect(persistAnalysisSnapshotMock).toHaveBeenCalledWith('/profile-a', analysis) + }) + + it('serves the cached analysis through getCachedAnalysis', async () => { + const cached = createAnalysis(3) + readAnalysisSnapshotMock.mockResolvedValue(cached) + + registerWorkspaceSpaceHandlers(createStore()) + expect(removeHandlerMock).toHaveBeenCalledWith('workspaceSpace:getCachedAnalysis') + const cachedHandler = handlers.get('workspaceSpace:getCachedAnalysis') + + await expect(cachedHandler!()).resolves.toBe(cached) + expect(readAnalysisSnapshotMock).toHaveBeenCalledWith('/profile-a') }) }) diff --git a/src/main/ipc/workspace-space.ts b/src/main/ipc/workspace-space.ts index d4449f3db52..5b10b039b31 100644 --- a/src/main/ipc/workspace-space.ts +++ b/src/main/ipc/workspace-space.ts @@ -1,13 +1,19 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import type { + WorkspaceSpaceAnalysis, WorkspaceSpaceAnalyzeResult, - WorkspaceSpaceScanProgress + WorkspaceSpaceScanProgress, + WorkspaceSpaceWorktreeMeasurement } from '../../shared/workspace-space-types' import { analyzeWorkspaceSpace, WorkspaceSpaceScanCancelledError } from '../workspace-space-analysis' +import { + persistWorkspaceSpaceAnalysisSnapshot, + readWorkspaceSpaceAnalysisSnapshot +} from '../workspace-space-analysis-snapshot' const PROGRESS_EMIT_INTERVAL_MS = 100 @@ -19,9 +25,11 @@ type InFlightWorkspaceSpaceScan = { } export function registerWorkspaceSpaceHandlers(store: Store): void { + const snapshotDirectory = store.getProfileStorageDirectory() let inFlightScan: InFlightWorkspaceSpaceScan | null = null ipcMain.removeHandler('workspaceSpace:cancel') ipcMain.removeHandler('workspaceSpace:analyze') + ipcMain.removeHandler('workspaceSpace:getCachedAnalysis') ipcMain.handle('workspaceSpace:analyze', async (event): Promise => { if (!inFlightScan) { const controller = new AbortController() @@ -39,7 +47,11 @@ export function registerWorkspaceSpaceHandlers(store: Store): void { currentWorktreeDisplayName: null } let lastProgressSentAt = 0 + let pendingMeasurements: WorkspaceSpaceWorktreeMeasurement[] = [] const sendProgress = (progress: WorkspaceSpaceScanProgress): void => { + if (progress.completedMeasurements?.length) { + pendingMeasurements.push(...progress.completedMeasurements) + } // Why: large fleets can report one progress event per worktree; keep // the UI responsive without repainting the full Space page for each row. const now = Date.now() @@ -57,7 +69,12 @@ export function registerWorkspaceSpaceHandlers(store: Store): void { } lastProgressSentAt = now if (!event.sender.isDestroyed()) { - event.sender.send('workspaceSpace:progress', progress) + const completedMeasurements = pendingMeasurements + pendingMeasurements = [] + event.sender.send('workspaceSpace:progress', { + ...progress, + ...(completedMeasurements.length > 0 ? { completedMeasurements } : {}) + }) } } // Why: large worktree fleets require real disk traversal; duplicate @@ -78,7 +95,12 @@ export function registerWorkspaceSpaceHandlers(store: Store): void { sendProgress(progress) } }) - .then((analysis): WorkspaceSpaceAnalyzeResult => ({ ok: true, analysis })) + .then((analysis): WorkspaceSpaceAnalyzeResult => { + // Fire-and-forget: a ~6-minute cold analysis must survive reload/restart, but + // persistence must never delay or fail the reply. + void persistWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, analysis) + return { ok: true, analysis } + }) .catch((error: unknown): WorkspaceSpaceAnalyzeResult => { if (error instanceof WorkspaceSpaceScanCancelledError) { return { ok: false, cancelled: true } @@ -92,6 +114,12 @@ export function registerWorkspaceSpaceHandlers(store: Store): void { return inFlightScan.promise }) + ipcMain.handle( + 'workspaceSpace:getCachedAnalysis', + (): Promise => + readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory) + ) + ipcMain.handle('workspaceSpace:cancel', async (): Promise => { if (!inFlightScan || inFlightScan.controller.signal.aborted) { return false diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index ee9540048cd..692c2ea6d51 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -151,6 +151,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { } } const store = { + getProfileStorageDirectory: vi.fn(() => '/profile-a'), getRepos: vi.fn(), getRepo: vi.fn(), getProjects: vi.fn(), diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 9e2ae8ea8aa..b9ea11b85c8 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -236,6 +236,36 @@ vi.mock('../ports/advertised-url-watcher', () => ({ } })) +const { + pruneCleanupScanSnapshotMock, + pruneCleanupScanSnapshotsMock, + pruneSpaceAnalysisSnapshotMock, + pruneSpaceAnalysisSnapshotsMock +} = vi.hoisted(() => ({ + pruneCleanupScanSnapshotMock: vi.fn().mockResolvedValue(undefined), + pruneCleanupScanSnapshotsMock: vi.fn().mockResolvedValue(undefined), + pruneSpaceAnalysisSnapshotMock: vi.fn().mockResolvedValue(undefined), + pruneSpaceAnalysisSnapshotsMock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('../workspace-cleanup-scan-snapshot', () => ({ + pruneWorkspaceCleanupScanSnapshot: pruneCleanupScanSnapshotMock, + pruneWorkspaceCleanupScanSnapshots: pruneCleanupScanSnapshotsMock +})) + +vi.mock('../workspace-space-analysis-snapshot', () => ({ + pruneWorkspaceSpaceAnalysisSnapshot: pruneSpaceAnalysisSnapshotMock, + pruneWorkspaceSpaceAnalysisSnapshots: pruneSpaceAnalysisSnapshotsMock +})) + +const { recordRemovalSnapshotPruneMock } = vi.hoisted(() => ({ + recordRemovalSnapshotPruneMock: vi.fn() +})) + +vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({ + recordWorkspaceCleanupRemovalSnapshotPrune: recordRemovalSnapshotPruneMock +})) + const { killAllProcessesForWorktreeMock, clearProviderPtyStateMock, @@ -303,6 +333,7 @@ describe('registerWorktreeHandlers', () => { } const ipcEvent = { sender: { id: 1 } } const store = { + getProfileStorageDirectory: vi.fn(() => '/profile-a'), getRepos: vi.fn(), getRepo: vi.fn(), getProjects: vi.fn(), @@ -408,6 +439,11 @@ describe('registerWorktreeHandlers', () => { getSshPtyProviderMock, deleteWorktreeHistoryDirMock, advertisedUrlWatcherForgetWorktreeMock, + pruneCleanupScanSnapshotMock, + pruneCleanupScanSnapshotsMock, + pruneSpaceAnalysisSnapshotMock, + pruneSpaceAnalysisSnapshotsMock, + recordRemovalSnapshotPruneMock, findExistingWorktreeSymlinkPathsMock, removeWorktreeLinkedPathsMock ]) { @@ -2709,6 +2745,10 @@ describe('registerWorktreeHandlers', () => { const metaById: Record> = { 'repo-1::/remote/repo': makeWorktreeMeta({ displayName: 'main', hostId: sshHostId }), 'repo-1::/remote/deleted': makeWorktreeMeta({ displayName: 'deleted', hostId: sshHostId }), + 'repo-1::/remote/deleted-too': makeWorktreeMeta({ + displayName: 'deleted too', + hostId: sshHostId + }), 'repo-1::/remote/other-host': makeWorktreeMeta({ displayName: 'other host', hostId: toSshExecutionHostId('target-b') @@ -2726,18 +2766,33 @@ describe('registerWorktreeHandlers', () => { const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, { repoId: sshRepo.id, executionHostId: sshHostId, - // Only the first is this host's row; the rest must survive an over-broad request. + // Only the first two are this host's rows; the rest must survive an over-broad request. worktreeIds: [ 'repo-1::/remote/deleted', + 'repo-1::/remote/deleted-too', 'repo-1::/remote/other-host', 'repo-2::/remote/other-repo', 'repo-1::/remote/never-persisted' ] }) - expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/remote/deleted'] }) - expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(1) + expect(forgotten).toEqual({ + forgottenWorktreeIds: ['repo-1::/remote/deleted', 'repo-1::/remote/deleted-too'] + }) + expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(2) expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/remote/deleted', sshHostId) + const pruneTargets = [ + { worktreeId: 'repo-1::/remote/deleted', executionHostId: sshHostId }, + { worktreeId: 'repo-1::/remote/deleted-too', executionHostId: sshHostId } + ] + expect(pruneCleanupScanSnapshotsMock).toHaveBeenCalledExactlyOnceWith( + '/profile-a', + pruneTargets + ) + expect(pruneSpaceAnalysisSnapshotsMock).toHaveBeenCalledExactlyOnceWith( + '/profile-a', + pruneTargets + ) // Why: the renderer's suppression memory is session-scoped, so the next launch must not re-list the row. const relisted = (await handlers['worktrees:listKnownForExecutionHost'](null, { @@ -8295,6 +8350,45 @@ describe('registerWorktreeHandlers', () => { }) }) + it('prunes the persisted cleanup and space snapshots on removal', async () => { + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' }) + + // A removed workspace must never resurrect from the cached scan snapshots. + expect(pruneCleanupScanSnapshotMock).toHaveBeenCalledWith( + '/profile-a', + 'repo-1::/workspace/feature-wt', + 'local' + ) + expect(pruneSpaceAnalysisSnapshotMock).toHaveBeenCalledWith( + '/profile-a', + 'repo-1::/workspace/feature-wt', + 'local' + ) + }) + + it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => { + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + snapshotPruneBatchId: 'cleanup-batch-1' + }) + + expect(recordRemovalSnapshotPruneMock).toHaveBeenCalledExactlyOnceWith('/profile-a', { + batchId: 'cleanup-batch-1', + worktreeId: 'repo-1::/workspace/feature-wt', + executionHostId: 'local' + }) + expect(pruneCleanupScanSnapshotMock).not.toHaveBeenCalled() + expect(pruneSpaceAnalysisSnapshotMock).not.toHaveBeenCalled() + }) + it('traces the removal as worktree.remove with a stage sub-span tree', async () => { const records: RedactableSpan[] = [] setActiveSink({ diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 32c85af85a0..0ece74f7bb6 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -17,6 +17,15 @@ import { getProjectGroupSubtreeIds } from '../../shared/project-groups' import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' import { isPathInsideOrEqual, isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' +import { + pruneWorkspaceCleanupScanSnapshot, + pruneWorkspaceCleanupScanSnapshots +} from '../workspace-cleanup-scan-snapshot' +import { + pruneWorkspaceSpaceAnalysisSnapshot, + pruneWorkspaceSpaceAnalysisSnapshots +} from '../workspace-space-analysis-snapshot' +import { recordWorkspaceCleanupRemovalSnapshotPrune } from '../workspace-cleanup-removal-snapshot-prune' import type { OrcaHooks } from '../../shared/orca-yaml-hook-types' import type { Repo } from '../../shared/repo-types' import type { @@ -162,6 +171,7 @@ type RemoveWorktreeArgs = { /** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */ allowUnverifiedPtyStop?: boolean skipArchive?: boolean + snapshotPruneBatchId?: string } type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs @@ -326,7 +336,8 @@ function resolveWorktreeRemovalOwnerHostId( function removeWorktreeMetadataAndTransientState( store: Store, worktreeId: string, - hostId?: ExecutionHostId + hostId?: ExecutionHostId, + snapshotPruneBatchId?: string ): void { // Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace. if (hostId) { @@ -341,6 +352,18 @@ function removeWorktreeMetadataAndTransientState( deleteWorktreeHistoryDir(worktreeId) // Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep). pruneWorktreePRRefreshAliases(worktreeId) + // Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. + const snapshotDirectory = store.getProfileStorageDirectory() + if (snapshotPruneBatchId) { + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { + batchId: snapshotPruneBatchId, + worktreeId, + executionHostId: hostId + }) + return + } + void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId) + void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId) } function getProjectHostSetupMetaUpdates( @@ -2072,6 +2095,15 @@ export function registerWorktreeHandlers( store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) forgottenWorktreeIds.push(worktreeId) } + if (forgottenWorktreeIds.length > 0) { + const snapshotDirectory = store.getProfileStorageDirectory() + const targets = forgottenWorktreeIds.map((worktreeId) => ({ + worktreeId, + executionHostId: requestedExecutionHostId + })) + void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets) + void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) + } return { forgottenWorktreeIds } } ) @@ -2483,7 +2515,12 @@ export function registerWorktreeHandlers( }) }) await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) }) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) @@ -2595,7 +2632,12 @@ export function registerWorktreeHandlers( invalidateAuthorizedRootsCache() } runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, @@ -2645,7 +2687,12 @@ export function registerWorktreeHandlers( localWorktreeGitOptions ) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, @@ -2682,7 +2729,12 @@ export function registerWorktreeHandlers( invalidateAuthorizedRootsCache() } runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, @@ -2742,7 +2794,12 @@ export function registerWorktreeHandlers( removedPushTarget ) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) return removalResult ?? {} @@ -2841,7 +2898,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) await withWorktreeRemoveStageSpan('metadata_purge', 'remote', async () => { - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) }) notifyWorktreesChanged(mainWindow, repoId) return removalResult ?? {} @@ -2987,7 +3049,12 @@ export function registerWorktreeHandlers( localWorktreeGitOptions ) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, @@ -3024,7 +3091,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => { - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) }) await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { invalidateAuthorizedRootsCache() @@ -3056,7 +3128,7 @@ export function registerWorktreeHandlers( 'worktrees:forgetLocal', async ( _event, - args: Pick + args: Pick ): Promise => { const { repoId } = parseWorktreeId(args.worktreeId) const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId) @@ -3118,7 +3190,12 @@ export function registerWorktreeHandlers( runtime.clearOptimisticReconcileToken(args.worktreeId) // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. - removeWorktreeMetadataAndTransientState(store, args.worktreeId, ownerHost?.id) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + ownerHost?.id, + args.snapshotPruneBatchId + ) // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. invalidateAuthorizedRootsCache() if (ownerHost?.id) { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b92ed0c86c0..4bdcbce262c 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -37,6 +37,7 @@ import { SshConnectionStore } from './ssh/ssh-connection-store' import { setSourceControlActionDefault } from '../shared/source-control-ai-actions' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { closeTerminalTabInWorkspaceSession } from '../shared/workspace-session-terminal-tab-close' +import { createDefaultWorkspaceCleanupBrowseState } from '../shared/workspace-cleanup-browse-state' // Shared mutable state so the electron mock can reference a per-test directory const testState = { dir: '' } @@ -7129,6 +7130,29 @@ describe('Store', () => { }) }) + it('updateUI preserves browse state when a legacy peer publishes dismissals only', async () => { + const store = await createStore() + const browse = createDefaultWorkspaceCleanupBrowseState() + browse.filters.query = 'stale' + + store.updateUI({ workspaceCleanup: { dismissals: {}, browse } }) + store.updateUI({ + workspaceCleanup: { + dismissals: { + 'wt-1': { + worktreeId: 'wt-1', + dismissedAt: 1700000000000, + fingerprint: 'fp-1', + classifierVersion: 2 + } + } + } + }) + + expect(store.getUI().workspaceCleanup?.browse).toEqual(browse) + expect(store.getUI().workspaceCleanup?.dismissals).toHaveProperty('wt-1') + }) + it('updateUI merges contextual tour seen ids instead of replacing stale snapshots', async () => { const store = await createStore() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index b01a298d62f..c113c0f8d1c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -186,6 +186,7 @@ import { } from '../shared/cross-platform-path' import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' import { normalizeTaskProviderSettings } from '../shared/task-providers' +import { mergeWorkspaceCleanupUIState } from '../shared/workspace-cleanup-ui-state' import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings' import { addMobilePairingCustomAddress, @@ -4313,6 +4314,10 @@ export class Store { // ── Repos ────────────────────────────────────────────────────────── + getProfileStorageDirectory(): string { + return dirname(this.dataFile) + } + getRepos(): Repo[] { return this.state.repos.map((repo) => this.hydrateRepo(repo)) } @@ -6219,6 +6224,10 @@ export class Store { const nextUI = { ...currentUI, ...durableUpdates, + workspaceCleanup: mergeWorkspaceCleanupUIState( + currentUI.workspaceCleanup, + durableUpdates.workspaceCleanup + ), groupBy: durableUpdates.groupBy ? normalizeGroupBy(durableUpdates.groupBy) : normalizeGroupBy(this.state.ui?.groupBy), diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 2e2555038ed..8a333894520 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -21,6 +21,7 @@ import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest' import type { TaskProvider } from '../../../../shared/task-providers' import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields' import { TaskResumeState } from './task-resume-state-schema' +import { WorkspaceCleanup } from './workspace-cleanup-ui-schema' import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance' const NullableString = z.string().nullable() @@ -77,19 +78,6 @@ const WorkspaceStatusDefinition = z.object({ color: z.string().optional(), icon: z.string().optional() }) -const WorkspaceCleanupDismissal = z - .object({ - worktreeId: z.string(), - dismissedAt: z.number().finite(), - fingerprint: z.string(), - classifierVersion: z.number().finite() - }) - .strict() -const WorkspaceCleanup = z - .object({ - dismissals: z.record(z.string(), WorkspaceCleanupDismissal) - }) - .strict() const FeatureInteractionRecord = z .object({ firstInteractedAt: z.number().finite().nonnegative(), diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts index 2af2ffe0ed8..a8fe8ca00c7 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts @@ -1,5 +1,6 @@ import type { z } from 'zod' import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' +import type { WorkspaceCleanupUIState } from '../../../../shared/workspace-cleanup' import type { UiUpdateFieldsSchema } from './client-ui-schemas' import type { AssertNoMissingKeys, AssertNoMissingValues } from './ui-state-schema-parity' @@ -34,3 +35,19 @@ const _uiUpdateValueParity: AssertNoMissingValues< z.input > = true void _uiUpdateValueParity + +// Why a nested pair: the guards above only walk PersistedUIState's OWN keys, and +// a structural `extends` is satisfied by extra properties — so a sub-key added +// to workspaceCleanup (e.g. `browse`) and never added to its `.strict()` schema +// would keep both green while every paired client's ui.set payload is rejected. +type WorkspaceCleanupSchema = NonNullable['workspaceCleanup']> +const _workspaceCleanupParity: AssertNoMissingKeys< + WorkspaceCleanupUIState, + WorkspaceCleanupSchema +> = true +void _workspaceCleanupParity +const _workspaceCleanupValueParity: AssertNoMissingValues< + WorkspaceCleanupUIState, + WorkspaceCleanupSchema +> = true +void _workspaceCleanupValueParity diff --git a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts new file mode 100644 index 00000000000..090a7981147 --- /dev/null +++ b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { UiUpdate } from './client-ui-schemas' +import { WorkspaceCleanup } from './workspace-cleanup-ui-schema' +import { createDefaultWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' + +const DISMISSAL = { + worktreeId: 'wt-1', + dismissedAt: 1700000000000, + fingerprint: '2|main|abc|clean|19675', + classifierVersion: 2 +} + +describe('workspaceCleanup client-ui schema', () => { + it('accepts a default browse state', () => { + const browse = createDefaultWorkspaceCleanupBrowseState() + const parsed = WorkspaceCleanup.parse({ dismissals: { 'wt-1': DISMISSAL }, browse }) + + expect(parsed.browse).toEqual(browse) + expect(parsed.dismissals['wt-1']).toEqual(DISMISSAL) + }) + + it('still accepts a dismissals-only payload from a client that predates browse', () => { + expect(WorkspaceCleanup.parse({ dismissals: {} })).toEqual({ dismissals: {} }) + }) + + // The wire contract: a NEWER client's filter groups must never fail an older + // host's schema, or the whole ui.set payload is refused (dismissals included). + it('accepts a browse state carrying unknown future fields and narrows it', () => { + const parsed = WorkspaceCleanup.parse({ + dismissals: {}, + browse: { + version: 7, + activePresetId: 'suggested', + futureTopLevelField: ['whatever'], + filters: { + query: 'kept', + futureGroup: { enabled: true }, + activity: { idleSignal: 'invented-signal', idleMinDays: 14 } + }, + sort: { field: 'invented-column', direction: 'desc' }, + customPresets: [] + } + }) + + expect(parsed.browse?.filters.query).toBe('kept') + expect(parsed.browse?.filters.activity.idleMinDays).toBe(14) + expect(parsed.browse?.filters.activity.idleSignal).toBe('last-visited') + expect(parsed.browse?.sort).toEqual({ field: 'last-activity', direction: 'desc' }) + expect(parsed.browse).not.toHaveProperty('activePresetId') + expect(parsed.browse).not.toHaveProperty('customPresets') + expect(parsed.browse).not.toHaveProperty('futureTopLevelField') + }) + + it('carries browse through the full UiUpdate payload without dropping siblings', () => { + const browse = createDefaultWorkspaceCleanupBrowseState() + browse.filters.query = 'stale' + + const parsed = UiUpdate.parse({ + sidebarWidth: 320, + workspaceCleanup: { dismissals: { 'wt-1': DISMISSAL }, browse } + }) + + expect(parsed.sidebarWidth).toBe(320) + expect(parsed.workspaceCleanup?.browse?.filters.query).toBe('stale') + }) +}) diff --git a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts new file mode 100644 index 00000000000..79984b55721 --- /dev/null +++ b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' +import { + normalizeWorkspaceCleanupBrowseState, + type WorkspaceCleanupBrowseState +} from '../../../../shared/workspace-cleanup-browse-state' + +const WorkspaceCleanupDismissal = z.object({ + worktreeId: z.string(), + dismissedAt: z.number().finite(), + fingerprint: z.string(), + classifierVersion: z.number().finite() +}) + +/** + * Deliberately unvalidated shape, then normalized: the filter groups must NOT be + * strict or enumerated here. A newer client sends filters this build has never + * heard of, and a per-field zod shape would reject the whole `ui.set` payload + * instead of persisting the parts the host does understand. The shared + * normalizer never throws and degrades field by field, so an older host narrows + * the state rather than refusing it. + */ +const WorkspaceCleanupBrowse = z + .custom() + .transform((value) => normalizeWorkspaceCleanupBrowseState(value)) + +export const WorkspaceCleanup = z.object({ + dismissals: z.record(z.string(), WorkspaceCleanupDismissal), + browse: WorkspaceCleanupBrowse.optional() +}) diff --git a/src/main/sidecar-snapshot-file.ts b/src/main/sidecar-snapshot-file.ts new file mode 100644 index 00000000000..f7c0f23c0e4 --- /dev/null +++ b/src/main/sidecar-snapshot-file.ts @@ -0,0 +1,51 @@ +// Why a sidecar next to orca-data.json: scan snapshots rewrite wholesale and can reach hundreds +// of KB; folding them into orca-data.json would rewrite the whole state file per scan (the +// githubCache sidecar precedent). Best-effort by design: a lost snapshot only costs a rescan. +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + durableWriteTempPath, + removeStaleDurableWriteTempFiles, + writeFileDurable +} from './durable-file-write' + +const queues = new Map>() +const staleTempCleanups = new Map>() +const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 + +export function sidecarSnapshotFile(snapshotDirectory: string, fileName: string): string { + return join(snapshotDirectory, fileName) +} + +/** Serialize tasks per sidecar so read-modify-write patch/prune cycles cannot interleave. */ +export function withSidecarSnapshotQueue(file: string, task: () => Promise): Promise { + const previous = queues.get(file) ?? Promise.resolve() + const run = previous.then(task, task) + queues.set( + file, + run.then( + () => undefined, + () => undefined + ) + ) + return run +} + +/** Parsed sidecar contents, or null when the file is missing or not valid JSON. */ +export async function readSidecarSnapshot(file: string): Promise { + try { + return JSON.parse(await readFile(file, 'utf-8')) as unknown + } catch { + return null + } +} + +export async function writeSidecarSnapshot(file: string, payload: unknown): Promise { + let cleanup = staleTempCleanups.get(file) + if (!cleanup) { + cleanup = removeStaleDurableWriteTempFiles(file, { minimumAgeMs: STALE_TEMP_AGE_MS }) + staleTempCleanups.set(file, cleanup) + } + await cleanup + await writeFileDurable(durableWriteTempPath(file), file, JSON.stringify(payload)) +} diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 28fbeb4cd3d..ac473733753 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -165,8 +165,9 @@ function createMainWindow( function createStore(): Store & { flushPendingAsync: MockFn } { return { + getProfileStorageDirectory: vi.fn(() => '/profile-a'), flushPendingAsync: vi.fn(() => Promise.resolve()) - } as Store & { flushPendingAsync: MockFn } + } as unknown as Store & { flushPendingAsync: MockFn } } function createRuntime(): RuntimeStub { diff --git a/src/main/workspace-cleanup-removal-snapshot-prune.test.ts b/src/main/workspace-cleanup-removal-snapshot-prune.test.ts new file mode 100644 index 00000000000..466476b10c3 --- /dev/null +++ b/src/main/workspace-cleanup-removal-snapshot-prune.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + finalizeCleanupSnapshotsMock, + finalizeSpaceSnapshotsMock, + pruneCleanupSnapshotsMock, + pruneSpaceSnapshotsMock, + registerCleanupTombstonesMock, + registerSpaceTombstonesMock +} = vi.hoisted(() => ({ + finalizeCleanupSnapshotsMock: vi.fn().mockResolvedValue(undefined), + finalizeSpaceSnapshotsMock: vi.fn().mockResolvedValue(undefined), + pruneCleanupSnapshotsMock: vi.fn().mockResolvedValue(undefined), + pruneSpaceSnapshotsMock: vi.fn().mockResolvedValue(undefined), + registerCleanupTombstonesMock: vi.fn(), + registerSpaceTombstonesMock: vi.fn() +})) + +vi.mock('./workspace-cleanup-scan-snapshot', () => ({ + finalizeWorkspaceCleanupScanSnapshotPrunes: finalizeCleanupSnapshotsMock, + pruneWorkspaceCleanupScanSnapshots: pruneCleanupSnapshotsMock, + registerWorkspaceCleanupScanSnapshotPruneTombstones: registerCleanupTombstonesMock +})) + +vi.mock('./workspace-space-analysis-snapshot', () => ({ + finalizeWorkspaceSpaceAnalysisSnapshotPrunes: finalizeSpaceSnapshotsMock, + pruneWorkspaceSpaceAnalysisSnapshots: pruneSpaceSnapshotsMock, + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones: registerSpaceTombstonesMock +})) + +import { + beginWorkspaceCleanupRemovalSnapshotPruneBatch, + finishWorkspaceCleanupRemovalSnapshotPruneBatch, + recordWorkspaceCleanupRemovalSnapshotPrune, + resetWorkspaceCleanupRemovalSnapshotPruneBatchesForTests +} from './workspace-cleanup-removal-snapshot-prune' + +describe('workspace cleanup removal snapshot prune batch', () => { + beforeEach(() => { + vi.clearAllMocks() + resetWorkspaceCleanupRemovalSnapshotPruneBatchesForTests() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('registers every sequential tombstone immediately and flushes once per sidecar', async () => { + const snapshotDirectory = '/profile-a' + const batch = { batchId: 'batch-1' } + const targets = Array.from({ length: 100 }, (_, index) => ({ + worktreeId: `repo-${index % 10}::/workspace-${index}`, + executionHostId: index % 2 === 0 ? ('local' as const) : (`ssh:ssh-${index % 3}` as const) + })) + beginWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, batch) + + for (const target of targets) { + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { ...batch, ...target }) + } + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { ...batch, ...targets[0] }) + + expect(registerCleanupTombstonesMock).toHaveBeenCalledTimes(100) + expect(registerSpaceTombstonesMock).toHaveBeenCalledTimes(100) + expect(finalizeCleanupSnapshotsMock).not.toHaveBeenCalled() + expect(finalizeSpaceSnapshotsMock).not.toHaveBeenCalled() + + await finishWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, batch) + + expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith(snapshotDirectory, targets) + expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith(snapshotDirectory, targets) + }) + + it('falls back to immediate best-effort pruning when no batch is active', () => { + const target = { worktreeId: 'repo-1::/workspace', executionHostId: 'runtime:vm-1' as const } + + recordWorkspaceCleanupRemovalSnapshotPrune('/profile-a', { + batchId: 'missing-batch', + ...target + }) + + expect(pruneCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target]) + expect(pruneSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target]) + }) + + it('finalizes only the matching profile and batch scope', async () => { + const batch = { batchId: 'shared-id' } + const profileATarget = { worktreeId: 'repo-a::/workspace', executionHostId: 'local' as const } + const profileBTarget = { + worktreeId: 'repo-b::/workspace', + executionHostId: 'ssh:ssh-1' as const + } + beginWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch) + beginWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-b', batch) + recordWorkspaceCleanupRemovalSnapshotPrune('/profile-a', { ...batch, ...profileATarget }) + recordWorkspaceCleanupRemovalSnapshotPrune('/profile-b', { ...batch, ...profileBTarget }) + + await finishWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch) + + expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [ + profileATarget + ]) + expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [ + profileATarget + ]) + + await finishWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-b', batch) + expect(finalizeCleanupSnapshotsMock).toHaveBeenNthCalledWith(2, '/profile-b', [profileBTarget]) + expect(finalizeSpaceSnapshotsMock).toHaveBeenNthCalledWith(2, '/profile-b', [profileBTarget]) + }) + + it('expires and finalizes an unfinished idle batch', async () => { + vi.useFakeTimers() + const batch = { batchId: 'abandoned-batch' } + const target = { worktreeId: 'repo-1::/workspace', executionHostId: 'local' as const } + beginWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch) + recordWorkspaceCleanupRemovalSnapshotPrune('/profile-a', { ...batch, ...target }) + + await vi.advanceTimersByTimeAsync(5 * 60_000) + + expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target]) + expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target]) + await finishWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch) + expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledTimes(1) + expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/workspace-cleanup-removal-snapshot-prune.ts b/src/main/workspace-cleanup-removal-snapshot-prune.ts new file mode 100644 index 00000000000..9b4244400c6 --- /dev/null +++ b/src/main/workspace-cleanup-removal-snapshot-prune.ts @@ -0,0 +1,138 @@ +import type { + WorkspaceCleanupSnapshotPruneBatchArgs, + WorkspaceCleanupSnapshotPruneRecordArgs +} from '../shared/workspace-cleanup' +import { + finalizeWorkspaceCleanupScanSnapshotPrunes, + pruneWorkspaceCleanupScanSnapshots, + registerWorkspaceCleanupScanSnapshotPruneTombstones +} from './workspace-cleanup-scan-snapshot' +import { + finalizeWorkspaceSpaceAnalysisSnapshotPrunes, + pruneWorkspaceSpaceAnalysisSnapshots, + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones +} from './workspace-space-analysis-snapshot' + +type SnapshotPruneTarget = Omit + +type SnapshotPruneBatch = { + targets: Map + expirationTimer?: ReturnType +} + +const batches = new Map() +const SNAPSHOT_PRUNE_BATCH_IDLE_TIMEOUT_MS = 5 * 60_000 + +function batchKey(snapshotDirectory: string, batchId: string): string { + return `${snapshotDirectory}\0${batchId}` +} + +function targetKey(target: SnapshotPruneTarget): string { + return `${target.executionHostId ?? '*'}\0${target.worktreeId}` +} + +function scheduleBatchExpiration( + snapshotDirectory: string, + batchId: string, + batch: SnapshotPruneBatch +): ReturnType { + const timer = setTimeout(() => { + const expired = takeBatch(snapshotDirectory, batchId, batch) + if (expired) { + void finalizeBatch(snapshotDirectory, expired) + } + }, SNAPSHOT_PRUNE_BATCH_IDLE_TIMEOUT_MS) + timer.unref() + return timer +} + +function takeBatch( + snapshotDirectory: string, + batchId: string, + expected?: SnapshotPruneBatch +): SnapshotPruneBatch | undefined { + const key = batchKey(snapshotDirectory, batchId) + const batch = batches.get(key) + if (!batch || (expected && batch !== expected)) { + return undefined + } + batches.delete(key) + if (batch.expirationTimer) { + clearTimeout(batch.expirationTimer) + } + return batch +} + +async function finalizeBatch(snapshotDirectory: string, batch: SnapshotPruneBatch): Promise { + const targets = [...batch.targets.values()] + await Promise.all([ + finalizeWorkspaceCleanupScanSnapshotPrunes(snapshotDirectory, targets), + finalizeWorkspaceSpaceAnalysisSnapshotPrunes(snapshotDirectory, targets) + ]) +} + +export function beginWorkspaceCleanupRemovalSnapshotPruneBatch( + snapshotDirectory: string, + args: WorkspaceCleanupSnapshotPruneBatchArgs +): void { + const key = batchKey(snapshotDirectory, args.batchId) + const existing = batches.get(key) + if (existing) { + if (existing.expirationTimer) { + clearTimeout(existing.expirationTimer) + } + existing.expirationTimer = scheduleBatchExpiration(snapshotDirectory, args.batchId, existing) + return + } + const batch: SnapshotPruneBatch = { targets: new Map() } + batch.expirationTimer = scheduleBatchExpiration(snapshotDirectory, args.batchId, batch) + batches.set(key, batch) +} + +/** Tombstone now; defer the two durable sidecar rewrites until the batch closes. */ +export function recordWorkspaceCleanupRemovalSnapshotPrune( + snapshotDirectory: string, + args: WorkspaceCleanupSnapshotPruneRecordArgs +): void { + const target = { + worktreeId: args.worktreeId, + ...(args.executionHostId ? { executionHostId: args.executionHostId } : {}) + } + const batch = batches.get(batchKey(snapshotDirectory, args.batchId)) + if (!batch) { + void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, [target]) + void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, [target]) + return + } + + const key = targetKey(target) + if (!batch.targets.has(key)) { + registerWorkspaceCleanupScanSnapshotPruneTombstones(snapshotDirectory, [target]) + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(snapshotDirectory, [target]) + batch.targets.set(key, target) + } + if (batch.expirationTimer) { + clearTimeout(batch.expirationTimer) + } + batch.expirationTimer = scheduleBatchExpiration(snapshotDirectory, args.batchId, batch) +} + +export async function finishWorkspaceCleanupRemovalSnapshotPruneBatch( + snapshotDirectory: string, + args: WorkspaceCleanupSnapshotPruneBatchArgs +): Promise { + const batch = takeBatch(snapshotDirectory, args.batchId) + if (!batch) { + return + } + await finalizeBatch(snapshotDirectory, batch) +} + +export function resetWorkspaceCleanupRemovalSnapshotPruneBatchesForTests(): void { + for (const batch of batches.values()) { + if (batch.expirationTimer) { + clearTimeout(batch.expirationTimer) + } + } + batches.clear() +} diff --git a/src/main/workspace-cleanup-scan-snapshot.test.ts b/src/main/workspace-cleanup-scan-snapshot.test.ts new file mode 100644 index 00000000000..ca5fa7edffe --- /dev/null +++ b/src/main/workspace-cleanup-scan-snapshot.test.ts @@ -0,0 +1,450 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WorkspaceCleanupCandidate, + WorkspaceCleanupScanResult +} from '../shared/workspace-cleanup' + +const { snapshotWriteSpy, userDataDirHolder } = vi.hoisted(() => ({ + snapshotWriteSpy: vi.fn(), + userDataDirHolder: { dir: '' } +})) + +vi.mock('./sidecar-snapshot-file', async (importOriginal) => { + const actual = await importOriginal>() + const writeSidecarSnapshot = actual.writeSidecarSnapshot as ( + file: string, + payload: unknown + ) => Promise + return { + ...actual, + writeSidecarSnapshot: async (file: string, payload: unknown) => { + snapshotWriteSpy(file, payload) + await writeSidecarSnapshot(file, payload) + } + } +}) + +import { + finalizeWorkspaceCleanupScanSnapshotPrunes, + persistWorkspaceCleanupScanResult, + pruneWorkspaceCleanupScanSnapshot, + pruneWorkspaceCleanupScanSnapshots, + readWorkspaceCleanupScanSnapshot, + registerWorkspaceCleanupScanSnapshotPruneTombstones, + workspaceCleanupScanSnapshotFingerprint +} from './workspace-cleanup-scan-snapshot' + +const SNAPSHOT_FILE = 'orca-workspace-cleanup-scan.json' +const NOW = 1_700_000_000_000 + +function makeCandidate( + overrides: Partial = {} +): WorkspaceCleanupCandidate { + return { + worktreeId: 'repo-1::/repo-feature', + repoId: 'repo-1', + repoName: 'Repo', + connectionId: null, + executionHostId: 'local', + displayName: 'Feature', + branch: 'feature', + path: '/repo-feature', + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW }, + fingerprint: '2|feature|abc123|clean|19675', + ...overrides + } +} + +function makeBroadResult(candidates: WorkspaceCleanupCandidate[]): WorkspaceCleanupScanResult { + return { scannedAt: NOW, candidates, errors: [] } +} + +describe('workspace cleanup scan snapshot', () => { + beforeEach(async () => { + snapshotWriteSpy.mockClear() + userDataDirHolder.dir = await mkdtemp(join(tmpdir(), 'orca-cleanup-snapshot-')) + }) + + afterEach(async () => { + await rm(userDataDirHolder.dir, { recursive: true, force: true }) + }) + + it('round-trips a broad scan snapshot, including SSH connectionId rows', async () => { + const sshCandidate = makeCandidate({ + worktreeId: 'repo-ssh::/remote/repo-feature', + repoId: 'repo-ssh', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1', + path: '/remote/repo-feature', + blockers: ['ssh-disconnected'], + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) + const result = makeBroadResult([makeCandidate(), sshCandidate]) + + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + result + ) + + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(result) + }) + + it('does not let an older broad scan overwrite a newer snapshot', async () => { + const newer = { + scannedAt: NOW + 1, + candidates: [makeCandidate({ displayName: 'Newer' })], + errors: [] + } + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + newer + ) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([makeCandidate({ displayName: 'Older' })]) + ) + + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(newer) + }) + + it('returns null when no snapshot has been persisted', async () => { + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + }) + + it('degrades corrupt persisted blobs to null instead of throwing', async () => { + const file = join(userDataDirHolder.dir, SNAPSHOT_FILE) + + await writeFile(file, 'not json{', 'utf-8') + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + + await writeFile( + file, + JSON.stringify({ + version: 2, + argsFingerprint: workspaceCleanupScanSnapshotFingerprint(), + result: { scannedAt: NOW, candidates: 'nope', errors: [] } + }), + 'utf-8' + ) + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + + await writeFile( + file, + JSON.stringify({ + version: 2, + argsFingerprint: workspaceCleanupScanSnapshotFingerprint(), + result: { scannedAt: NOW, candidates: [{ worktreeId: 42 }], errors: [] } + }), + 'utf-8' + ) + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + }) + + it('treats a snapshot from another classifier version as absent', async () => { + await writeFile( + join(userDataDirHolder.dir, SNAPSHOT_FILE), + JSON.stringify({ + version: 2, + argsFingerprint: 'classifier:1|includeAllWorkspaces', + result: makeBroadResult([makeCandidate()]) + }), + 'utf-8' + ) + + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + }) + + it('patches targeted rescans into the snapshot without touching other rows', async () => { + const stale = makeCandidate({ git: { ...makeCandidate().git, clean: null, checkedAt: null } }) + const other = makeCandidate({ + worktreeId: 'repo-1::/repo-other', + path: '/repo-other', + branch: 'other' + }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([stale, other]) + ) + + const rescanned = makeCandidate({ tier: 'protected', blockers: ['dirty-files'] }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { worktreeId: stale.worktreeId }, + { scannedAt: NOW + 60_000, candidates: [rescanned], errors: [] } + ) + + const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir) + expect(snapshot?.candidates).toEqual([rescanned, other]) + // Snapshot freshness stays anchored to the last FULL scan. + expect(snapshot?.scannedAt).toBe(NOW) + }) + + it('appends targeted rows the snapshot has not seen yet', async () => { + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([makeCandidate()]) + ) + + const created = makeCandidate({ worktreeId: 'repo-1::/repo-new', path: '/repo-new' }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { worktreeId: created.worktreeId }, + { scannedAt: NOW + 1, candidates: [created], errors: [] } + ) + + const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir) + expect(snapshot?.candidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo-1::/repo-feature', + 'repo-1::/repo-new' + ]) + }) + + it('does not create a snapshot from a targeted scan alone', async () => { + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { worktreeId: 'repo-1::/repo-feature' }, + makeBroadResult([makeCandidate()]) + ) + + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + await expect(readFile(join(userDataDirHolder.dir, SNAPSHOT_FILE))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('keeps the broad snapshot when a legacy suggestion-only scan completes', async () => { + const broad = makeBroadResult([ + makeCandidate(), + makeCandidate({ worktreeId: 'repo-1::/b', path: '/b' }) + ]) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + broad + ) + + const legacyRow = makeCandidate({ tier: 'review', selectedByDefault: false }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + {}, + { scannedAt: NOW + 1, candidates: [legacyRow], errors: [] } + ) + + const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir) + expect(snapshot?.candidates).toHaveLength(2) + expect(snapshot?.candidates[0]).toEqual(legacyRow) + }) + + it('prunes a removed worktree so it cannot resurrect from cache', async () => { + const kept = makeCandidate({ worktreeId: 'repo-1::/repo-kept', path: '/repo-kept' }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([makeCandidate(), kept]) + ) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/repo-feature', 'local') + + const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir) + expect(snapshot?.candidates).toEqual([kept]) + expect(snapshotWriteSpy).toHaveBeenCalledTimes(1) + + // Unknown ids are a no-op. + snapshotWriteSpy.mockClear() + await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/never-existed') + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(snapshot) + expect(snapshotWriteSpy).not.toHaveBeenCalled() + }) + + it('coalesces host-scoped local and remote prunes into one write', async () => { + const localCollision = makeCandidate() + const remoteCollision = makeCandidate({ + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + }) + const localRemoved = makeCandidate({ + worktreeId: 'repo-1::/local-removed', + path: '/local-removed' + }) + const remoteRemoved = makeCandidate({ + worktreeId: 'repo-1::/remote-removed', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1', + path: '/remote-removed' + }) + const kept = makeCandidate({ worktreeId: 'repo-1::/kept', path: '/kept' }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([localCollision, remoteCollision, localRemoved, remoteRemoved, kept]) + ) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceCleanupScanSnapshots(userDataDirHolder.dir, [ + { worktreeId: localCollision.worktreeId, executionHostId: 'local' }, + { worktreeId: localRemoved.worktreeId, executionHostId: 'local' }, + { worktreeId: remoteRemoved.worktreeId, executionHostId: 'ssh:ssh-1' } + ]) + + expect(snapshotWriteSpy).toHaveBeenCalledTimes(1) + expect(snapshotWriteSpy).toHaveBeenCalledWith( + join(userDataDirHolder.dir, SNAPSHOT_FILE), + expect.objectContaining({ + result: expect.objectContaining({ candidates: [remoteCollision, kept] }) + }) + ) + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toMatchObject({ + candidates: [remoteCollision, kept] + }) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceCleanupScanSnapshots(userDataDirHolder.dir, [ + { worktreeId: 'repo-1::/missing-local', executionHostId: 'local' }, + { worktreeId: 'repo-1::/missing-remote', executionHostId: 'ssh:ssh-1' } + ]) + expect(snapshotWriteSpy).not.toHaveBeenCalled() + }) + + it('keeps profile snapshots isolated', async () => { + const otherProfile = await mkdtemp(join(tmpdir(), 'orca-cleanup-snapshot-other-')) + try { + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([makeCandidate()]) + ) + + await expect(readWorkspaceCleanupScanSnapshot(otherProfile)).resolves.toBeNull() + } finally { + await rm(otherProfile, { recursive: true, force: true }) + } + }) + + it('does not let a scan started before a bulk removal restore pruned rows', async () => { + const local = makeCandidate() + const remote = makeCandidate({ + worktreeId: 'repo-1::/remote-feature', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1', + path: '/remote-feature' + }) + const staleResult = { + ...makeBroadResult([local, remote]), + scannedAt: Date.now() - 1 + } + await pruneWorkspaceCleanupScanSnapshots(userDataDirHolder.dir, [ + { worktreeId: local.worktreeId, executionHostId: 'local' }, + { worktreeId: remote.worktreeId, executionHostId: 'ssh:ssh-1' } + ]) + + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + staleResult + ) + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([]) + + const recreated = { ...staleResult, scannedAt: Date.now() + 1 } + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + recreated + ) + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([ + local, + remote + ]) + }) + + it('registers a tombstone immediately without rewriting the sidecar', async () => { + const staleResult = { ...makeBroadResult([makeCandidate()]), scannedAt: Date.now() - 1 } + + registerWorkspaceCleanupScanSnapshotPruneTombstones(userDataDirHolder.dir, [ + { worktreeId: 'repo-1::/repo-feature', executionHostId: 'local' } + ]) + + expect(snapshotWriteSpy).not.toHaveBeenCalled() + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + staleResult + ) + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([]) + }) + + it('keeps the original tombstone time when a removal batch flushes', async () => { + const candidate = makeCandidate() + const target = { worktreeId: candidate.worktreeId, executionHostId: 'local' as const } + const now = vi.spyOn(Date, 'now').mockReturnValue(100) + registerWorkspaceCleanupScanSnapshotPruneTombstones(userDataDirHolder.dir, [target]) + now.mockReturnValue(200) + + await finalizeWorkspaceCleanupScanSnapshotPrunes(userDataDirHolder.dir, [target]) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + { ...makeBroadResult([candidate]), scannedAt: 150 } + ) + + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([ + candidate + ]) + now.mockRestore() + }) + + it('patches and prunes host-colliding workspace ids independently', async () => { + const local = makeCandidate() + const remote = makeCandidate({ + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1', + repoName: 'Remote repo' + }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([local, remote]) + ) + + const rescannedRemote = makeCandidate({ + ...remote, + tier: 'protected', + blockers: ['dirty-files'] + }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { worktreeId: remote.worktreeId }, + { scannedAt: NOW + 1, candidates: [rescannedRemote], errors: [] } + ) + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([ + local, + rescannedRemote + ]) + + await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, remote.worktreeId, 'ssh:ssh-1') + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([ + local + ]) + }) +}) diff --git a/src/main/workspace-cleanup-scan-snapshot.ts b/src/main/workspace-cleanup-scan-snapshot.ts new file mode 100644 index 00000000000..b87020e1ffe --- /dev/null +++ b/src/main/workspace-cleanup-scan-snapshot.ts @@ -0,0 +1,313 @@ +import { + WORKSPACE_CLEANUP_CLASSIFIER_VERSION, + type WorkspaceCleanupCandidate, + type WorkspaceCleanupScanArgs, + type WorkspaceCleanupScanResult +} from '../shared/workspace-cleanup' +import { + readSidecarSnapshot, + sidecarSnapshotFile, + withSidecarSnapshotQueue, + writeSidecarSnapshot +} from './sidecar-snapshot-file' +import type { ExecutionHostId } from '../shared/execution-host' +import { + activeWorkspaceSnapshotPruneKeys, + registerWorkspaceSnapshotPrunesForFile, + workspaceSnapshotPruneKey, + workspaceSnapshotPruneTargetKeys, + type WorkspaceSnapshotPruneTarget, + type WorkspaceSnapshotPruneTombstone +} from './workspace-snapshot-prune-index' + +const SNAPSHOT_FILE_NAME = 'orca-workspace-cleanup-scan.json' +const SNAPSHOT_VERSION = 2 + +export type WorkspaceCleanupScanSnapshotPruneTarget = WorkspaceSnapshotPruneTarget + +const prunedWorkspacesByFile = new Map>() + +type PersistedWorkspaceCleanupScanSnapshot = { + version: number + argsFingerprint: string + result: WorkspaceCleanupScanResult +} + +/** Why a fingerprint: a classifier bump reshuffles tiers/blockers wholesale, so an older snapshot must read as absent, not stale-but-plausible. */ +export function workspaceCleanupScanSnapshotFingerprint(): string { + return `classifier:${WORKSPACE_CLEANUP_CLASSIFIER_VERSION}|includeAllWorkspaces` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isPersistableCandidate(value: unknown): value is WorkspaceCleanupCandidate { + if (!isRecord(value)) { + return false + } + return ( + typeof value.worktreeId === 'string' && + typeof value.repoId === 'string' && + typeof value.fingerprint === 'string' && + (value.connectionId === null || typeof value.connectionId === 'string') && + typeof value.executionHostId === 'string' && + Array.isArray(value.reasons) && + Array.isArray(value.blockers) && + isRecord(value.git) && + isRecord(value.localContext) + ) +} + +/** Shape guard so a corrupt persisted blob degrades to null instead of throwing at startup. */ +function parseSnapshot(parsed: unknown): WorkspaceCleanupScanResult | null { + if (!isRecord(parsed)) { + return null + } + if (parsed.version !== SNAPSHOT_VERSION) { + return null + } + if (parsed.argsFingerprint !== workspaceCleanupScanSnapshotFingerprint()) { + return null + } + const result = parsed.result + if (!isRecord(result)) { + return null + } + if ( + typeof result.scannedAt !== 'number' || + !Array.isArray(result.candidates) || + !Array.isArray(result.errors) || + !result.candidates.every(isPersistableCandidate) + ) { + return null + } + return result as unknown as WorkspaceCleanupScanResult +} + +export async function readWorkspaceCleanupScanSnapshot( + snapshotDirectory: string +): Promise { + try { + return parseSnapshot( + await readSidecarSnapshot(sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)) + ) + } catch { + return null + } +} + +async function writeSnapshot(file: string, result: WorkspaceCleanupScanResult): Promise { + await writeSidecarSnapshot(file, { + version: SNAPSHOT_VERSION, + argsFingerprint: workspaceCleanupScanSnapshotFingerprint(), + result + } satisfies PersistedWorkspaceCleanupScanSnapshot) +} + +function patchCandidates( + existing: WorkspaceCleanupScanResult, + fresh: WorkspaceCleanupCandidate[] +): WorkspaceCleanupScanResult { + const freshById = new Map(fresh.map((candidate) => [candidateSnapshotKey(candidate), candidate])) + const candidates = existing.candidates.map((candidate) => { + const key = candidateSnapshotKey(candidate) + const replacement = freshById.get(key) + freshById.delete(key) + return replacement ?? candidate + }) + candidates.push(...freshById.values()) + // Why keep scannedAt: it marks the last FULL scan; a focused rescan must not advertise fleet-wide freshness. + return { ...existing, candidates } +} + +function candidateSnapshotKey( + candidate: Pick +): string { + return `${candidate.executionHostId ?? 'local'}\0${candidate.worktreeId}` +} + +/** Register anti-resurrection tombstones without scheduling a sidecar rewrite. */ +export function registerWorkspaceCleanupScanSnapshotPruneTombstones( + snapshotDirectory: string, + targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[] +): void { + if (targets.length === 0) { + return + } + registerWorkspaceSnapshotPrunesForFile( + prunedWorkspacesByFile, + sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME), + targets + ) +} + +function excludeRowsPrunedDuringScan( + file: string, + result: WorkspaceCleanupScanResult +): WorkspaceCleanupScanResult { + const prunedKeys = activeWorkspaceSnapshotPruneKeys( + prunedWorkspacesByFile.get(file), + result.scannedAt + ) + if (prunedKeys.size === 0) { + return result + } + const candidates = result.candidates.filter( + (candidate) => + !prunedKeys.has(workspaceSnapshotPruneKey(candidate.worktreeId, candidate.executionHostId)) && + !prunedKeys.has(workspaceSnapshotPruneKey(candidate.worktreeId)) + ) + return candidates.length === result.candidates.length ? result : { ...result, candidates } +} + +function clearSupersededPrunes( + file: string, + result: WorkspaceCleanupScanResult, + broad: boolean +): void { + const pruned = prunedWorkspacesByFile.get(file) + if (!pruned) { + return + } + const candidateKeys = broad + ? undefined + : new Set( + result.candidates.flatMap((candidate) => [ + workspaceSnapshotPruneKey(candidate.worktreeId, candidate.executionHostId), + workspaceSnapshotPruneKey(candidate.worktreeId) + ]) + ) + for (const [key, entry] of pruned) { + if (entry.prunedAt < result.scannedAt && (broad || candidateKeys?.has(key))) { + pruned.delete(key) + } + } + if (pruned.size === 0) { + prunedWorkspacesByFile.delete(file) + } +} + +/** + * Persist a completed scan: a broad (includeAllWorkspaces) scan replaces the snapshot, anything + * narrower patches matching rows into it. Never throws — the snapshot is a refetchable cache. + */ +// Why: skip the full snapshot read whose only purpose is the scannedAt +// comparison — on a large fleet that read is a multi-hundred-KB synchronous +// JSON.parse per scan. +const lastPersistedScannedAtByFile = new Map() + +export async function persistWorkspaceCleanupScanResult( + snapshotDirectory: string, + args: WorkspaceCleanupScanArgs, + result: WorkspaceCleanupScanResult +): Promise { + const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME) + try { + await withSidecarSnapshotQueue(file, async () => { + const filteredResult = excludeRowsPrunedDuringScan(file, result) + // worktreeIds (even empty) is a targeted scan; persisting it as broad + // would replace the fleet snapshot with a subset. + const broad = + !args.worktreeId && !Array.isArray(args.worktreeIds) && args.includeAllWorkspaces === true + if (broad) { + let knownScannedAt = lastPersistedScannedAtByFile.get(file) + if (knownScannedAt === undefined) { + const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory) + knownScannedAt = existing?.scannedAt + } + if (knownScannedAt !== undefined && knownScannedAt > filteredResult.scannedAt) { + lastPersistedScannedAtByFile.set(file, knownScannedAt) + return + } + await writeSnapshot(file, filteredResult) + lastPersistedScannedAtByFile.set(file, filteredResult.scannedAt) + clearSupersededPrunes(file, result, true) + return + } + if (filteredResult.candidates.length === 0) { + return + } + const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory) + // Why: a focused/legacy scan is a subset; without a broad baseline it is not a fleet snapshot. + if (!existing) { + return + } + await writeSnapshot(file, patchCandidates(existing, filteredResult.candidates)) + clearSupersededPrunes(file, result, false) + }) + } catch (error) { + console.warn('[workspace-cleanup] failed to persist scan snapshot:', error) + } +} + +async function pruneWorkspaceCleanupScanSnapshotsWithRegisteredTombstones( + snapshotDirectory: string, + targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[], + registerTombstones: boolean +): Promise { + if (targets.length === 0) { + return + } + const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME) + const targetKeys = workspaceSnapshotPruneTargetKeys(targets) + if (registerTombstones) { + registerWorkspaceSnapshotPrunesForFile(prunedWorkspacesByFile, file, targets) + } + try { + await withSidecarSnapshotQueue(file, async () => { + const registered = prunedWorkspacesByFile.get(file) + const coalescedTargetKeys = registerTombstones + ? targetKeys + : new Set([...targetKeys].filter((key) => registered?.has(key))) + if (coalescedTargetKeys.size === 0) { + return + } + const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory) + if (!existing) { + return + } + const candidates = existing.candidates.filter( + (candidate) => + !coalescedTargetKeys.has( + workspaceSnapshotPruneKey(candidate.worktreeId, candidate.executionHostId) + ) && !coalescedTargetKeys.has(workspaceSnapshotPruneKey(candidate.worktreeId)) + ) + if (candidates.length === existing.candidates.length) { + return + } + await writeSnapshot(file, { ...existing, candidates }) + }) + } catch (error) { + console.warn('[workspace-cleanup] failed to prune scan snapshot:', error) + } +} + +/** Drop removed workspaces in one sidecar transaction. Never throws. */ +export async function pruneWorkspaceCleanupScanSnapshots( + snapshotDirectory: string, + targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[] +): Promise { + await pruneWorkspaceCleanupScanSnapshotsWithRegisteredTombstones(snapshotDirectory, targets, true) +} + +/** Flush only tombstones still active for this batch, preserving their original prune time. */ +export async function finalizeWorkspaceCleanupScanSnapshotPrunes( + snapshotDirectory: string, + targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[] +): Promise { + await pruneWorkspaceCleanupScanSnapshotsWithRegisteredTombstones( + snapshotDirectory, + targets, + false + ) +} + +/** Drop a removed workspace so it never resurrects from cache. Never throws. */ +export async function pruneWorkspaceCleanupScanSnapshot( + snapshotDirectory: string, + worktreeId: string, + executionHostId?: ExecutionHostId +): Promise { + await pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, [{ worktreeId, executionHostId }]) +} diff --git a/src/main/workspace-snapshot-prune-index.ts b/src/main/workspace-snapshot-prune-index.ts new file mode 100644 index 00000000000..56b4289335b --- /dev/null +++ b/src/main/workspace-snapshot-prune-index.ts @@ -0,0 +1,57 @@ +import type { ExecutionHostId } from '../shared/execution-host' + +export type WorkspaceSnapshotPruneTarget = { + worktreeId: string + executionHostId?: ExecutionHostId +} + +export type WorkspaceSnapshotPruneTombstone = WorkspaceSnapshotPruneTarget & { + prunedAt: number +} + +export function workspaceSnapshotPruneKey( + worktreeId: string, + executionHostId?: ExecutionHostId +): string { + return `${executionHostId ?? '*'}\0${worktreeId}` +} + +export function workspaceSnapshotPruneTargetKeys( + targets: readonly WorkspaceSnapshotPruneTarget[] +): Set { + return new Set( + targets.map(({ worktreeId, executionHostId }) => + workspaceSnapshotPruneKey(worktreeId, executionHostId) + ) + ) +} + +export function activeWorkspaceSnapshotPruneKeys( + tombstones: ReadonlyMap | undefined, + scannedAt: number +): Set { + const keys = new Set() + for (const [key, entry] of tombstones ?? []) { + if (entry.prunedAt >= scannedAt) { + keys.add(key) + } + } + return keys +} + +export function registerWorkspaceSnapshotPrunesForFile( + tombstonesByFile: Map>, + file: string, + targets: readonly WorkspaceSnapshotPruneTarget[] +): void { + const tombstones = tombstonesByFile.get(file) ?? new Map() + const prunedAt = Date.now() + for (const { worktreeId, executionHostId } of targets) { + tombstones.set(workspaceSnapshotPruneKey(worktreeId, executionHostId), { + worktreeId, + ...(executionHostId ? { executionHostId } : {}), + prunedAt + }) + } + tombstonesByFile.set(file, tombstones) +} diff --git a/src/main/workspace-snapshot-pruning.bench.test.ts b/src/main/workspace-snapshot-pruning.bench.test.ts new file mode 100644 index 00000000000..0523894265b --- /dev/null +++ b/src/main/workspace-snapshot-pruning.bench.test.ts @@ -0,0 +1,153 @@ +// Opt in: ORCA_WORKSPACE_SNAPSHOT_PRUNE_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/workspace-snapshot-pruning.bench.test.ts +import { performance } from 'node:perf_hooks' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { WorkspaceCleanupCandidate } from '../shared/workspace-cleanup' +import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types' +import { + persistWorkspaceCleanupScanResult, + pruneWorkspaceCleanupScanSnapshots +} from './workspace-cleanup-scan-snapshot' +import { + persistWorkspaceSpaceAnalysisSnapshot, + pruneWorkspaceSpaceAnalysisSnapshots +} from './workspace-space-analysis-snapshot' + +const describeBench = process.env.ORCA_WORKSPACE_SNAPSHOT_PRUNE_BENCH ? describe : describe.skip +const ROW_COUNT = 610 +const DELETE_COUNT = 100 + +describeBench('workspace snapshot bulk pruning', () => { + let snapshotDirectory = '' + + beforeAll(async () => { + snapshotDirectory = await mkdtemp(join(tmpdir(), 'orca-snapshot-prune-bench-')) + }) + + afterAll(async () => { + await rm(snapshotDirectory, { recursive: true, force: true }) + }) + + it('prunes the measured 610-row/100-delete shape with one write per sidecar', async () => { + const candidates = Array.from({ length: ROW_COUNT }, (_, index) => makeCandidate(index)) + const worktrees = Array.from({ length: ROW_COUNT }, (_, index) => makeWorktree(index)) + const targets = candidates.slice(0, DELETE_COUNT).map((candidate) => ({ + worktreeId: candidate.worktreeId, + executionHostId: candidate.executionHostId + })) + await persistWorkspaceCleanupScanResult( + snapshotDirectory, + { includeAllWorkspaces: true }, + { scannedAt: 1, candidates, errors: [] } + ) + await persistWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, { + scannedAt: 1, + totalSizeBytes: ROW_COUNT * 1_000, + reclaimableBytes: ROW_COUNT * 1_000, + worktreeCount: ROW_COUNT, + scannedWorktreeCount: ROW_COUNT, + unavailableWorktreeCount: 0, + repos: makeRepoSummaries(worktrees), + worktrees + }) + + const startedAt = performance.now() + await Promise.all([ + pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets), + pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) + ]) + const pruneMs = performance.now() - startedAt + const cleanupPayload = JSON.parse( + await readFile(join(snapshotDirectory, 'orca-workspace-cleanup-scan.json'), 'utf-8') + ) as { result: { candidates: unknown[] } } + const spacePayload = JSON.parse( + await readFile(join(snapshotDirectory, 'orca-workspace-space-analysis.json'), 'utf-8') + ) as { analysis: { worktrees: unknown[] } } + + console.log( + `[bench] rows=${ROW_COUNT} deleted=${DELETE_COUNT} bulkPrune=${pruneMs.toFixed(2)}ms` + ) + expect(cleanupPayload.result.candidates).toHaveLength(ROW_COUNT - DELETE_COUNT) + expect(spacePayload.analysis.worktrees).toHaveLength(ROW_COUNT - DELETE_COUNT) + }) +}) + +function executionHostId(index: number): 'local' | `ssh:${string}` { + return index % 3 === 0 ? `ssh:ssh-${index % 7}` : 'local' +} + +function makeCandidate(index: number): WorkspaceCleanupCandidate { + const hostId = executionHostId(index) + return { + worktreeId: `repo-${index % 20}::/workspace-${index}`, + repoId: `repo-${index % 20}`, + repoName: `Repo ${index % 20}`, + connectionId: hostId === 'local' ? null : hostId.slice('ssh:'.length), + executionHostId: hostId, + displayName: `Workspace ${index}`, + branch: `branch-${index}`, + path: `/workspace-${index}`, + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: 0, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 0 }, + fingerprint: `fingerprint-${index}` + } +} + +function makeWorktree(index: number): WorkspaceSpaceWorktree { + const candidate = makeCandidate(index) + return { + worktreeId: candidate.worktreeId, + repoId: candidate.repoId, + executionHostId: candidate.executionHostId, + repoDisplayName: candidate.repoName, + repoPath: `/repo-${index % 20}`, + displayName: candidate.displayName, + path: candidate.path, + branch: candidate.branch, + isMainWorktree: false, + isRemote: candidate.executionHostId !== 'local', + isSparse: false, + canDelete: true, + lastActivityAt: 0, + status: 'ok', + error: null, + scannedAt: 1, + sizeBytes: 1_000, + reclaimableBytes: 1_000, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } +} + +function makeRepoSummaries(worktrees: WorkspaceSpaceWorktree[]) { + const groups = Map.groupBy(worktrees, (row) => `${row.executionHostId ?? 'local'}\0${row.repoId}`) + return [...groups.values()].map((rows) => ({ + repoId: rows[0].repoId, + executionHostId: rows[0].executionHostId, + displayName: rows[0].repoDisplayName, + path: rows[0].repoPath, + isRemote: rows[0].isRemote, + worktreeCount: rows.length, + scannedWorktreeCount: rows.length, + unavailableWorktreeCount: 0, + totalSizeBytes: rows.length * 1_000, + reclaimableBytes: rows.length * 1_000, + error: null + })) +} diff --git a/src/main/workspace-space-analysis-snapshot.test.ts b/src/main/workspace-space-analysis-snapshot.test.ts new file mode 100644 index 00000000000..a6d870674e3 --- /dev/null +++ b/src/main/workspace-space-analysis-snapshot.test.ts @@ -0,0 +1,438 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WorkspaceSpaceAnalysis, + WorkspaceSpaceWorktree +} from '../shared/workspace-space-types' + +const { snapshotWriteSpy, userDataDirHolder } = vi.hoisted(() => ({ + snapshotWriteSpy: vi.fn(), + userDataDirHolder: { dir: '' } +})) + +vi.mock('./sidecar-snapshot-file', async (importOriginal) => { + const actual = await importOriginal>() + const writeSidecarSnapshot = actual.writeSidecarSnapshot as ( + file: string, + payload: unknown + ) => Promise + return { + ...actual, + writeSidecarSnapshot: async (file: string, payload: unknown) => { + snapshotWriteSpy(file, payload) + await writeSidecarSnapshot(file, payload) + } + } +}) + +import { + finalizeWorkspaceSpaceAnalysisSnapshotPrunes, + persistWorkspaceSpaceAnalysisSnapshot, + pruneWorkspaceSpaceAnalysisSnapshot, + pruneWorkspaceSpaceAnalysisSnapshots, + readWorkspaceSpaceAnalysisSnapshot, + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones +} from './workspace-space-analysis-snapshot' + +const SNAPSHOT_FILE = 'orca-workspace-space-analysis.json' +const NOW = 1_700_000_000_000 + +function makeWorktreeRow(overrides: Partial = {}): WorkspaceSpaceWorktree { + return { + worktreeId: 'repo-1::/repo-feature', + repoId: 'repo-1', + executionHostId: 'local', + repoDisplayName: 'Repo', + repoPath: '/repo', + displayName: 'Feature', + path: '/repo-feature', + branch: 'feature', + isMainWorktree: false, + isRemote: false, + isSparse: false, + canDelete: true, + lastActivityAt: NOW - 1000, + status: 'ok', + error: null, + scannedAt: NOW, + sizeBytes: 1000, + reclaimableBytes: 1000, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0, + ...overrides + } +} + +function makeAnalysis(worktrees: WorkspaceSpaceWorktree[]): WorkspaceSpaceAnalysis { + const okRows = worktrees.filter((row) => row.status === 'ok') + const rowsByHost = Map.groupBy(worktrees, (row) => row.executionHostId ?? 'local') + return { + scannedAt: NOW, + totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0), + reclaimableBytes: worktrees.reduce((sum, row) => sum + row.reclaimableBytes, 0), + worktreeCount: worktrees.length, + scannedWorktreeCount: okRows.length, + unavailableWorktreeCount: worktrees.length - okRows.length, + repos: [...rowsByHost.entries()].map(([executionHostId, rows]) => { + const scanned = rows.filter((row) => row.status === 'ok') + return { + repoId: 'repo-1', + executionHostId, + displayName: 'Repo', + path: '/repo', + isRemote: executionHostId !== 'local', + worktreeCount: rows.length, + scannedWorktreeCount: scanned.length, + unavailableWorktreeCount: rows.length - scanned.length, + totalSizeBytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0), + reclaimableBytes: rows.reduce((sum, row) => sum + row.reclaimableBytes, 0), + error: null + } + }), + worktrees + } +} + +describe('workspace space analysis snapshot', () => { + beforeEach(async () => { + snapshotWriteSpy.mockClear() + userDataDirHolder.dir = await mkdtemp(join(tmpdir(), 'orca-space-snapshot-')) + }) + + afterEach(async () => { + await rm(userDataDirHolder.dir, { recursive: true, force: true }) + }) + + it('round-trips a completed analysis', async () => { + const analysis = makeAnalysis([makeWorktreeRow()]) + + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, analysis) + + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toEqual( + analysis + ) + }) + + it('prunes topLevelItems into the omitted counters to bound the payload', async () => { + const row = makeWorktreeRow({ + sizeBytes: 5000, + topLevelItems: [ + { + name: 'node_modules', + path: '/repo-feature/node_modules', + kind: 'directory', + sizeBytes: 3000 + }, + { name: 'src', path: '/repo-feature/src', kind: 'directory', sizeBytes: 1000 } + ], + omittedTopLevelItemCount: 2, + omittedTopLevelSizeBytes: 500 + }) + + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, makeAnalysis([row])) + + const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) + expect(cached?.worktrees[0]).toMatchObject({ + sizeBytes: 5000, + topLevelItems: [], + omittedTopLevelItemCount: 4, + omittedTopLevelSizeBytes: 4500 + }) + }) + + it('returns null when missing or corrupt instead of throwing', async () => { + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + + const file = join(userDataDirHolder.dir, SNAPSHOT_FILE) + await writeFile(file, '{"version":1,"analysis":', 'utf-8') + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + + await writeFile( + file, + JSON.stringify({ + version: 2, + analysis: { scannedAt: 'yesterday', repos: [], worktrees: [] } + }), + 'utf-8' + ) + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + + await writeFile( + file, + JSON.stringify({ version: 99, analysis: makeAnalysis([makeWorktreeRow()]) }), + 'utf-8' + ) + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + }) + + it('loads legacy rows without an execution host id', async () => { + const { executionHostId: _executionHostId, ...legacyRow } = makeWorktreeRow() + const analysis = makeAnalysis([makeWorktreeRow()]) + await writeFile( + join(userDataDirHolder.dir, SNAPSHOT_FILE), + JSON.stringify({ + version: 2, + analysis: { ...analysis, worktrees: [legacyRow] } + }), + 'utf-8' + ) + + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toMatchObject({ + worktrees: [expect.objectContaining({ worktreeId: legacyRow.worktreeId })] + }) + }) + + it('prunes a removed worktree row and rebalances totals', async () => { + const removed = makeWorktreeRow({ sizeBytes: 3000, reclaimableBytes: 3000 }) + const kept = makeWorktreeRow({ + worktreeId: 'repo-1::/repo-kept', + path: '/repo-kept', + status: 'missing', + sizeBytes: 1000, + reclaimableBytes: 0 + }) + await persistWorkspaceSpaceAnalysisSnapshot( + userDataDirHolder.dir, + makeAnalysis([removed, kept]) + ) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, removed.worktreeId, 'local') + + const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) + expect(cached?.worktrees.map((row) => row.worktreeId)).toEqual(['repo-1::/repo-kept']) + expect(cached).toMatchObject({ + worktreeCount: 1, + scannedWorktreeCount: 0, + unavailableWorktreeCount: 1, + totalSizeBytes: 1000, + reclaimableBytes: 0 + }) + expect(cached?.repos[0]).toMatchObject({ + worktreeCount: 1, + scannedWorktreeCount: 0, + unavailableWorktreeCount: 1, + totalSizeBytes: 1000, + reclaimableBytes: 0 + }) + expect(snapshotWriteSpy).toHaveBeenCalledTimes(1) + + // Unknown ids are a no-op. + snapshotWriteSpy.mockClear() + await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, 'repo-1::/never-existed') + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toEqual(cached) + expect(snapshotWriteSpy).not.toHaveBeenCalled() + }) + + it('coalesces local and remote row rebalancing into one write', async () => { + const localCollision = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 }) + const remoteCollision = makeWorktreeRow({ + executionHostId: 'ssh:ssh-1', + isRemote: true, + sizeBytes: 2000, + reclaimableBytes: 2000 + }) + const localRemoved = makeWorktreeRow({ + worktreeId: 'repo-1::/local-removed', + path: '/local-removed', + status: 'missing', + sizeBytes: 3000, + reclaimableBytes: 0 + }) + const remoteRemoved = makeWorktreeRow({ + worktreeId: 'repo-1::/remote-removed', + executionHostId: 'ssh:ssh-1', + isRemote: true, + path: '/remote-removed', + sizeBytes: 4000, + reclaimableBytes: 4000 + }) + const kept = makeWorktreeRow({ + worktreeId: 'repo-1::/kept', + path: '/kept', + sizeBytes: 500, + reclaimableBytes: 500 + }) + await persistWorkspaceSpaceAnalysisSnapshot( + userDataDirHolder.dir, + makeAnalysis([localCollision, remoteCollision, localRemoved, remoteRemoved, kept]) + ) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceSpaceAnalysisSnapshots(userDataDirHolder.dir, [ + { worktreeId: localCollision.worktreeId, executionHostId: 'local' }, + { worktreeId: localRemoved.worktreeId, executionHostId: 'local' }, + { worktreeId: remoteRemoved.worktreeId, executionHostId: 'ssh:ssh-1' } + ]) + + expect(snapshotWriteSpy).toHaveBeenCalledTimes(1) + expect(snapshotWriteSpy).toHaveBeenCalledWith( + join(userDataDirHolder.dir, SNAPSHOT_FILE), + expect.objectContaining({ + analysis: expect.objectContaining({ + worktrees: [remoteCollision, kept], + worktreeCount: 2, + scannedWorktreeCount: 2, + unavailableWorktreeCount: 0, + totalSizeBytes: 2500, + reclaimableBytes: 2500 + }) + }) + ) + const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) + expect(cached?.repos).toEqual([ + expect.objectContaining({ executionHostId: 'local', worktreeCount: 1, totalSizeBytes: 500 }), + expect.objectContaining({ + executionHostId: 'ssh:ssh-1', + worktreeCount: 1, + totalSizeBytes: 2000 + }) + ]) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceSpaceAnalysisSnapshots(userDataDirHolder.dir, [ + { worktreeId: 'repo-1::/missing-local', executionHostId: 'local' }, + { worktreeId: 'repo-1::/missing-remote', executionHostId: 'ssh:ssh-1' } + ]) + expect(snapshotWriteSpy).not.toHaveBeenCalled() + }) + + it('keeps profile snapshots isolated', async () => { + const otherProfile = await mkdtemp(join(tmpdir(), 'orca-space-snapshot-other-')) + try { + await persistWorkspaceSpaceAnalysisSnapshot( + userDataDirHolder.dir, + makeAnalysis([makeWorktreeRow()]) + ) + + await expect(readWorkspaceSpaceAnalysisSnapshot(otherProfile)).resolves.toBeNull() + } finally { + await rm(otherProfile, { recursive: true, force: true }) + } + }) + + it('does not let an analysis started before bulk removal restore pruned rows', async () => { + const local = makeWorktreeRow() + const remote = makeWorktreeRow({ + worktreeId: 'repo-1::/remote-feature', + executionHostId: 'ssh:ssh-1', + isRemote: true, + path: '/remote-feature' + }) + const staleAnalysis = { + ...makeAnalysis([local, remote]), + scannedAt: Date.now() - 1 + } + await pruneWorkspaceSpaceAnalysisSnapshots(userDataDirHolder.dir, [ + { worktreeId: local.worktreeId, executionHostId: 'local' }, + { worktreeId: remote.worktreeId, executionHostId: 'ssh:ssh-1' } + ]) + + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, staleAnalysis) + expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([]) + + const recreated = { ...staleAnalysis, scannedAt: Date.now() + 1 } + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, recreated) + expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([ + local, + remote + ]) + }) + + it('registers a tombstone immediately without rewriting the sidecar', async () => { + const staleAnalysis = { ...makeAnalysis([makeWorktreeRow()]), scannedAt: Date.now() - 1 } + + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(userDataDirHolder.dir, [ + { worktreeId: 'repo-1::/repo-feature', executionHostId: 'local' } + ]) + + expect(snapshotWriteSpy).not.toHaveBeenCalled() + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, staleAnalysis) + expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([]) + }) + + it('keeps the original tombstone time when a removal batch flushes', async () => { + const row = makeWorktreeRow() + const target = { worktreeId: row.worktreeId, executionHostId: 'local' as const } + const now = vi.spyOn(Date, 'now').mockReturnValue(100) + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(userDataDirHolder.dir, [target]) + now.mockReturnValue(200) + + await finalizeWorkspaceSpaceAnalysisSnapshotPrunes(userDataDirHolder.dir, [target]) + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, { + ...makeAnalysis([row]), + scannedAt: 150 + }) + + expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([ + { ...row, topLevelItems: [] } + ]) + now.mockRestore() + }) + + it('prunes host-colliding workspace ids without corrupting surviving totals', async () => { + const local = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 }) + const remote = makeWorktreeRow({ + executionHostId: 'ssh:ssh-1', + isRemote: true, + sizeBytes: 3000, + reclaimableBytes: 3000 + }) + await persistWorkspaceSpaceAnalysisSnapshot( + userDataDirHolder.dir, + makeAnalysis([local, remote]) + ) + + await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, remote.worktreeId, 'ssh:ssh-1') + + const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) + expect(cached?.worktrees).toEqual([local]) + expect(cached).toMatchObject({ + worktreeCount: 1, + scannedWorktreeCount: 1, + totalSizeBytes: 1000, + reclaimableBytes: 1000 + }) + expect(cached?.repos).toEqual([ + expect.objectContaining({ + executionHostId: 'local', + worktreeCount: 1, + totalSizeBytes: 1000 + }), + expect.objectContaining({ + executionHostId: 'ssh:ssh-1', + worktreeCount: 0, + totalSizeBytes: 0 + }) + ]) + }) + + it('prunes every host-colliding row when the host is unknown', async () => { + const local = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 }) + const remote = makeWorktreeRow({ + executionHostId: 'ssh:ssh-1', + isRemote: true, + sizeBytes: 3000, + reclaimableBytes: 3000 + }) + await persistWorkspaceSpaceAnalysisSnapshot( + userDataDirHolder.dir, + makeAnalysis([local, remote]) + ) + + await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, local.worktreeId) + + const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) + expect(cached?.worktrees).toEqual([]) + expect(cached).toMatchObject({ + worktreeCount: 0, + scannedWorktreeCount: 0, + totalSizeBytes: 0, + reclaimableBytes: 0 + }) + }) +}) diff --git a/src/main/workspace-space-analysis-snapshot.ts b/src/main/workspace-space-analysis-snapshot.ts new file mode 100644 index 00000000000..8118095d396 --- /dev/null +++ b/src/main/workspace-space-analysis-snapshot.ts @@ -0,0 +1,328 @@ +import type { + WorkspaceSpaceAnalysis, + WorkspaceSpaceWorktree +} from '../shared/workspace-space-types' +import { + readSidecarSnapshot, + sidecarSnapshotFile, + withSidecarSnapshotQueue, + writeSidecarSnapshot +} from './sidecar-snapshot-file' +import type { ExecutionHostId } from '../shared/execution-host' +import { + activeWorkspaceSnapshotPruneKeys, + registerWorkspaceSnapshotPrunesForFile, + workspaceSnapshotPruneKey, + workspaceSnapshotPruneTargetKeys, + type WorkspaceSnapshotPruneTarget, + type WorkspaceSnapshotPruneTombstone +} from './workspace-snapshot-prune-index' + +const SNAPSHOT_FILE_NAME = 'orca-workspace-space-analysis.json' +const SNAPSHOT_VERSION = 2 + +export type WorkspaceSpaceAnalysisSnapshotPruneTarget = WorkspaceSnapshotPruneTarget + +const prunedWorkspacesByFile = new Map>() + +type PersistedWorkspaceSpaceAnalysisSnapshot = { + version: number + analysis: WorkspaceSpaceAnalysis +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isPersistableWorktreeRow(value: unknown): value is WorkspaceSpaceWorktree { + if (!isRecord(value)) { + return false + } + return ( + typeof value.worktreeId === 'string' && + typeof value.repoId === 'string' && + (value.executionHostId === undefined || typeof value.executionHostId === 'string') && + typeof value.status === 'string' && + typeof value.sizeBytes === 'number' && + typeof value.reclaimableBytes === 'number' && + Array.isArray(value.topLevelItems) + ) +} + +/** Shape guard so a corrupt persisted blob degrades to null instead of throwing at startup. */ +function parseSnapshot(parsed: unknown): WorkspaceSpaceAnalysis | null { + if (!isRecord(parsed) || parsed.version !== SNAPSHOT_VERSION) { + return null + } + const analysis = parsed.analysis + if (!isRecord(analysis)) { + return null + } + if ( + typeof analysis.scannedAt !== 'number' || + typeof analysis.totalSizeBytes !== 'number' || + !Array.isArray(analysis.repos) || + !Array.isArray(analysis.worktrees) || + !analysis.worktrees.every(isPersistableWorktreeRow) + ) { + return null + } + return analysis as unknown as WorkspaceSpaceAnalysis +} + +// Why strip topLevelItems: 500+ worktrees x 48 items is a multi-MB blob, and the cached view only +// needs per-worktree totals. Fold the items into the omitted counters so each row stays consistent. +function stripTopLevelItems(analysis: WorkspaceSpaceAnalysis): WorkspaceSpaceAnalysis { + return { + ...analysis, + worktrees: analysis.worktrees.map((row) => ({ + ...row, + topLevelItems: [], + omittedTopLevelItemCount: row.omittedTopLevelItemCount + row.topLevelItems.length, + omittedTopLevelSizeBytes: + row.omittedTopLevelSizeBytes + + row.topLevelItems.reduce((sum, item) => sum + item.sizeBytes, 0) + })) + } +} + +export async function readWorkspaceSpaceAnalysisSnapshot( + snapshotDirectory: string +): Promise { + try { + return parseSnapshot( + await readSidecarSnapshot(sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)) + ) + } catch { + return null + } +} + +async function writeSnapshot(file: string, analysis: WorkspaceSpaceAnalysis): Promise { + await writeSidecarSnapshot(file, { + version: SNAPSHOT_VERSION, + analysis + } satisfies PersistedWorkspaceSpaceAnalysisSnapshot) +} + +/** Persist a completed analysis. Never throws — the snapshot is a refetchable cache. */ +export async function persistWorkspaceSpaceAnalysisSnapshot( + snapshotDirectory: string, + analysis: WorkspaceSpaceAnalysis +): Promise { + const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME) + try { + await withSidecarSnapshotQueue(file, async () => { + await writeSnapshot(file, stripTopLevelItems(excludeRowsPrunedDuringScan(file, analysis))) + clearSupersededPrunes(file, analysis) + }) + } catch (error) { + console.warn('[workspace-space] failed to persist analysis snapshot:', error) + } +} + +function withoutWorktreeRows( + analysis: WorkspaceSpaceAnalysis, + shouldRemove: (row: WorkspaceSpaceWorktree) => boolean +): WorkspaceSpaceAnalysis { + const worktrees: WorkspaceSpaceWorktree[] = [] + const removedByRepo = new Map< + string, + { + worktreeCount: number + scannedWorktreeCount: number + unavailableWorktreeCount: number + totalSizeBytes: number + reclaimableBytes: number + } + >() + let removedCount = 0 + let scannedDelta = 0 + let unavailableDelta = 0 + let totalSizeDelta = 0 + let reclaimableDelta = 0 + + for (const row of analysis.worktrees) { + if (!shouldRemove(row)) { + worktrees.push(row) + continue + } + const scanned = row.status === 'ok' ? 1 : 0 + const unavailable = row.status === 'ok' ? 0 : 1 + removedCount += 1 + scannedDelta += scanned + unavailableDelta += unavailable + totalSizeDelta += row.sizeBytes + reclaimableDelta += row.reclaimableBytes + const key = analysisRepoKey(row) + const delta = removedByRepo.get(key) ?? { + worktreeCount: 0, + scannedWorktreeCount: 0, + unavailableWorktreeCount: 0, + totalSizeBytes: 0, + reclaimableBytes: 0 + } + delta.worktreeCount += 1 + delta.scannedWorktreeCount += scanned + delta.unavailableWorktreeCount += unavailable + delta.totalSizeBytes += row.sizeBytes + delta.reclaimableBytes += row.reclaimableBytes + removedByRepo.set(key, delta) + } + if (removedCount === 0) { + return analysis + } + return { + ...analysis, + worktrees, + worktreeCount: Math.max(0, analysis.worktreeCount - removedCount), + scannedWorktreeCount: Math.max(0, analysis.scannedWorktreeCount - scannedDelta), + unavailableWorktreeCount: Math.max(0, analysis.unavailableWorktreeCount - unavailableDelta), + totalSizeBytes: Math.max(0, analysis.totalSizeBytes - totalSizeDelta), + reclaimableBytes: Math.max(0, analysis.reclaimableBytes - reclaimableDelta), + repos: analysis.repos.map((repo) => { + const delta = removedByRepo.get(analysisRepoKey(repo)) + return delta + ? { + ...repo, + worktreeCount: Math.max(0, repo.worktreeCount - delta.worktreeCount), + scannedWorktreeCount: Math.max( + 0, + repo.scannedWorktreeCount - delta.scannedWorktreeCount + ), + unavailableWorktreeCount: Math.max( + 0, + repo.unavailableWorktreeCount - delta.unavailableWorktreeCount + ), + totalSizeBytes: Math.max(0, repo.totalSizeBytes - delta.totalSizeBytes), + reclaimableBytes: Math.max(0, repo.reclaimableBytes - delta.reclaimableBytes) + } + : repo + }) + } +} + +function analysisRepoKey(entry: { repoId: string; executionHostId?: ExecutionHostId }): string { + return JSON.stringify([entry.executionHostId, entry.repoId]) +} + +/** Register anti-resurrection tombstones without scheduling a sidecar rewrite. */ +export function registerWorkspaceSpaceAnalysisSnapshotPruneTombstones( + snapshotDirectory: string, + targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[] +): void { + if (targets.length === 0) { + return + } + registerWorkspaceSnapshotPrunesForFile( + prunedWorkspacesByFile, + sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME), + targets + ) +} + +function excludeRowsPrunedDuringScan( + file: string, + analysis: WorkspaceSpaceAnalysis +): WorkspaceSpaceAnalysis { + const prunedKeys = activeWorkspaceSnapshotPruneKeys( + prunedWorkspacesByFile.get(file), + analysis.scannedAt + ) + return withoutWorktreeRows( + analysis, + (row) => + prunedKeys.has(workspaceSnapshotPruneKey(row.worktreeId, row.executionHostId)) || + prunedKeys.has(workspaceSnapshotPruneKey(row.worktreeId)) + ) +} + +function clearSupersededPrunes(file: string, analysis: WorkspaceSpaceAnalysis): void { + const pruned = prunedWorkspacesByFile.get(file) + if (!pruned) { + return + } + for (const [key, entry] of pruned) { + if (entry.prunedAt < analysis.scannedAt) { + pruned.delete(key) + } + } + if (pruned.size === 0) { + prunedWorkspacesByFile.delete(file) + } +} + +async function pruneWorkspaceSpaceAnalysisSnapshotsWithRegisteredTombstones( + snapshotDirectory: string, + targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[], + registerTombstones: boolean +): Promise { + if (targets.length === 0) { + return + } + const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME) + const targetKeys = workspaceSnapshotPruneTargetKeys(targets) + if (registerTombstones) { + registerWorkspaceSnapshotPrunesForFile(prunedWorkspacesByFile, file, targets) + } + try { + await withSidecarSnapshotQueue(file, async () => { + const registered = prunedWorkspacesByFile.get(file) + const coalescedTargetKeys = registerTombstones + ? targetKeys + : new Set([...targetKeys].filter((key) => registered?.has(key))) + if (coalescedTargetKeys.size === 0) { + return + } + const existing = await readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory) + if (!existing) { + return + } + const next = withoutWorktreeRows( + existing, + (row) => + coalescedTargetKeys.has(workspaceSnapshotPruneKey(row.worktreeId, row.executionHostId)) || + coalescedTargetKeys.has(workspaceSnapshotPruneKey(row.worktreeId)) + ) + if (next === existing) { + return + } + await writeSnapshot(file, next) + }) + } catch (error) { + console.warn('[workspace-space] failed to prune analysis snapshot:', error) + } +} + +/** Drop removed workspace rows and rebalance their totals in one sidecar transaction. Never throws. */ +export async function pruneWorkspaceSpaceAnalysisSnapshots( + snapshotDirectory: string, + targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[] +): Promise { + await pruneWorkspaceSpaceAnalysisSnapshotsWithRegisteredTombstones( + snapshotDirectory, + targets, + true + ) +} + +/** Flush only tombstones still active for this batch, preserving their original prune time. */ +export async function finalizeWorkspaceSpaceAnalysisSnapshotPrunes( + snapshotDirectory: string, + targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[] +): Promise { + await pruneWorkspaceSpaceAnalysisSnapshotsWithRegisteredTombstones( + snapshotDirectory, + targets, + false + ) +} + +/** Drop one removed workspace row and rebalance the totals it contributed. Never throws. */ +export async function pruneWorkspaceSpaceAnalysisSnapshot( + snapshotDirectory: string, + worktreeId: string, + executionHostId?: ExecutionHostId +): Promise { + await pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, [{ worktreeId, executionHostId }]) +} diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index 53082b73c7f..d6fbc4bb2d7 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -42,14 +42,14 @@ vi.mock('./project-runtime-git-options', () => ({ import { analyzeWorkspaceSpace, WorkspaceSpaceScanCancelledError } from './workspace-space-analysis' -function createStore(repos: Repo[]): Store { +function createStore(repos: Repo[], worktreeHostId?: `runtime:${string}`): Store { return { getRepos: () => repos, getWorktreeMeta: (worktreeId: string) => { if (worktreeId.endsWith('feature')) { - return { displayName: 'Feature Workspace', lastActivityAt: 200 } + return { displayName: 'Feature Workspace', lastActivityAt: 200, hostId: worktreeHostId } } - return undefined + return worktreeHostId ? { hostId: worktreeHostId } : undefined } } as Store } @@ -199,7 +199,7 @@ describe('analyzeWorkspaceSpace', () => { ]) const progress: unknown[] = [] - await analyzeWorkspaceSpace(createStore([repo]), { + await analyzeWorkspaceSpace(createStore([repo], 'runtime:env-1'), { scanId: 'scan-1', onProgress: (event) => progress.push(event) }) @@ -221,6 +221,18 @@ describe('analyzeWorkspaceSpace', () => { scannedRepoCount: 1, scannedWorktreeCount: 1 }) + expect(progress).toContainEqual( + expect.objectContaining({ + completedMeasurements: [ + expect.objectContaining({ + worktreeId: `repo-1::${repoPath}`, + executionHostId: 'runtime:env-1', + status: 'ok', + sizeBytes: expect.any(Number) + }) + ] + }) + ) }) it('rejects when a scan is cancelled before it starts', async () => { diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index 56c9a7525cd..9b36c2d3afa 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -36,6 +36,7 @@ import { getSshGitProvider } from './providers/ssh-git-dispatch' import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' import { mergeWorktree } from './ipc/worktree-logic' import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options' +import { getRepoExecutionHostId, getWorktreeExecutionHostId } from '../shared/execution-host' const REPO_SCAN_CONCURRENCY = 2 const WORKTREE_SCAN_CONCURRENCY = 3 @@ -311,6 +312,7 @@ function createBaseWorktreeRow( return { worktreeId: worktree.id, repoId: repo.id, + executionHostId: getWorktreeExecutionHostId(worktree, repo), repoDisplayName: repo.displayName, repoPath: repo.path, displayName: worktree.displayName, @@ -703,8 +705,13 @@ function reportProgress( updates: Partial, onProgress: WorkspaceSpaceAnalyzeOptions['onProgress'] ): void { + const completedMeasurements = updates.completedMeasurements Object.assign(progress, updates, { updatedAt: Date.now() }) - onProgress?.({ ...progress }) + delete progress.completedMeasurements + onProgress?.({ + ...progress, + ...(completedMeasurements?.length ? { completedMeasurements } : {}) + }) } async function scanRepo( @@ -735,6 +742,7 @@ async function scanRepo( worktrees: [], summary: { repoId: repo.id, + executionHostId: getRepoExecutionHostId(repo), displayName: repo.displayName, path: repo.path, isRemote: Boolean(repo.connectionId), @@ -792,7 +800,17 @@ async function scanRepo( ) reportProgress( progress, - { scannedWorktreeCount: progress.scannedWorktreeCount + 1 }, + { + scannedWorktreeCount: progress.scannedWorktreeCount + 1, + completedMeasurements: [ + { + worktreeId: row.worktreeId, + executionHostId: row.executionHostId, + status: row.status, + sizeBytes: row.sizeBytes + } + ] + }, options.onProgress ) return row @@ -811,6 +829,7 @@ async function scanRepo( worktrees: rows, summary: { repoId: repo.id, + executionHostId: getRepoExecutionHostId(repo), displayName: repo.displayName, path: repo.path, isRemote: Boolean(repo.connectionId), diff --git a/src/preload/api/workspace-cleanup-api.ts b/src/preload/api/workspace-cleanup-api.ts index 748d8216540..134dc2ee519 100644 --- a/src/preload/api/workspace-cleanup-api.ts +++ b/src/preload/api/workspace-cleanup-api.ts @@ -4,9 +4,12 @@ import type { WorkspaceCleanupLocalProcessResult, WorkspaceCleanupScanArgs, WorkspaceCleanupScanProgress, - WorkspaceCleanupScanResult + WorkspaceCleanupScanResult, + WorkspaceCleanupSnapshotPruneBatchArgs, + WorkspaceCleanupSnapshotPruneRecordArgs } from '../../shared/workspace-cleanup' import type { + WorkspaceSpaceAnalysis, WorkspaceSpaceAnalyzeResult, WorkspaceSpaceScanProgress } from '../../shared/workspace-space-types' @@ -16,15 +19,23 @@ export type WorkspaceCleanupApi = { args?: WorkspaceCleanupScanArgs, onProgress?: (progress: WorkspaceCleanupScanProgress) => void ) => Promise + cancelScan?: (scanId: string) => Promise + /** Last persisted broad-scan snapshot; null until one completes or when the cache is stale/corrupt. */ + getCachedScan: () => Promise dismiss: (args: WorkspaceCleanupDismissArgs) => Promise clearDismissals: () => Promise hasKillableLocalProcesses: ( args: WorkspaceCleanupLocalProcessArgs ) => Promise + beginRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise + recordRemovalSnapshotPrune?: (args: WorkspaceCleanupSnapshotPruneRecordArgs) => Promise + finishRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise } export type WorkspaceSpaceApi = { analyze: () => Promise + /** Last persisted analysis (topLevelItems pruned to bound the payload); null until one completes. */ + getCachedAnalysis: () => Promise cancel: () => Promise onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void } diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index 20c352af9d3..b6564d3f5db 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -93,11 +93,13 @@ export type WorktreeApi = { // may waive the proof that every PTY stopped. allowUnverifiedPtyStop?: boolean skipArchive?: boolean + snapshotPruneBatchId?: string }) => Promise // Forget a workspace from Orca only (no remote Git/FS work) — for workspaces pinned to a removed/disconnected SSH host. forgetLocal: (args: { worktreeId: string hostId?: ExecutionHostId + snapshotPruneBatchId?: string }) => Promise forceDeletePreservedBranch: (args: { worktreeId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 17db7429632..46c5d5362d8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -908,14 +908,23 @@ const api = { .invoke('workspaceCleanup:scan', { ...args, scanId }) .finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener)) }, + cancelScan: (scanId) => ipcRenderer.invoke('workspaceCleanup:cancelScan', scanId), + getCachedScan: () => ipcRenderer.invoke('workspaceCleanup:getCachedScan'), dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), hasKillableLocalProcesses: (args) => - ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args) + ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args), + beginRemovalSnapshotPruneBatch: (args) => + ipcRenderer.invoke('workspaceCleanup:beginRemovalSnapshotPruneBatch', args), + recordRemovalSnapshotPrune: (args) => + ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args), + finishRemovalSnapshotPruneBatch: (args) => + ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args) } satisfies PreloadApi['workspaceCleanup'], workspaceSpace: { analyze: () => ipcRenderer.invoke('workspaceSpace:analyze'), + getCachedAnalysis: () => ipcRenderer.invoke('workspaceSpace:getCachedAnalysis'), cancel: () => ipcRenderer.invoke('workspaceSpace:cancel'), onProgress: (callback) => { const listener = ( diff --git a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts index 42411ff6fce..b4930d74742 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { const state = { @@ -85,6 +85,44 @@ describe('runWorktreeDeletesInParallel', () => { vi.mocked(showPreservedBranchBatchToast).mockClear() }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses one snapshot prune batch for a 100-workspace delete', async () => { + const begin = vi.fn(async (_args: { batchId: string }) => undefined) + const record = vi.fn(async (_args: { batchId: string; worktreeId: string }) => undefined) + const finish = vi.fn(async (_args: { batchId: string }) => undefined) + vi.stubGlobal('window', { + api: { + workspaceCleanup: { + beginRemovalSnapshotPruneBatch: begin, + recordRemovalSnapshotPrune: record, + finishRemovalSnapshotPruneBatch: finish + } + } + }) + const targets = Array.from({ length: 100 }, (_, index) => ({ + id: `wt-${index}`, + displayName: `workspace ${index}`, + repoId: `repo-${index % 10}`, + path: `/workspaces/${index}` + })) + + await expect(runDeletesForCurrentWorktrees(targets)).resolves.toHaveLength(100) + + expect(begin).toHaveBeenCalledOnce() + const batchId = begin.mock.calls[0]?.[0].batchId + expect(batchId).toEqual(expect.any(String)) + expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(100) + for (const call of mocks.state.removeWorktree.mock.calls) { + expect(call[2]).toMatchObject({ snapshotPruneBatchId: batchId }) + } + expect(finish).toHaveBeenCalledOnce() + expect(finish).toHaveBeenCalledWith({ batchId }) + expect(record).not.toHaveBeenCalled() + }) + it('starts every selected delete before waiting for earlier deletes to finish', async () => { const first = deferredDeleteResult() const second = deferredDeleteResult() diff --git a/src/renderer/src/components/sidebar/worktree-delete-execution.ts b/src/renderer/src/components/sidebar/worktree-delete-execution.ts index f105acedcdf..7d0ca496a1a 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-execution.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-execution.ts @@ -14,6 +14,7 @@ import { showWorkspaceListChangedToast } from './stale-workspace-list-toast' import { showPreservedBranchBatchToast } from './preserved-branch-batch-toast' import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' import type { WorktreeDeleteWithToastOptions } from './worktree-delete-request' +import { beginWorktreeSnapshotPruneBatch } from './worktree-snapshot-prune-batch' // A failed delete usually means unresolved changes, so land on the diff panel. function viewWorktreeDiff(worktreeId: string): void { @@ -60,41 +61,56 @@ export async function runWorktreeDeletesInParallel( const preservedBranches: PreservedBranchCleanup[] = [] const aggregatePreservedBranches = uniqueTargets.length > 1 let listChanged = false - const groupResults = await Promise.all( - Array.from(groups.values()).map(async (group) => { - const deletedInGroup: string[] = [] - const failedInGroup: (typeof group)[number][] = [] - for (const target of group) { - // A queued target may be recreated while an earlier repo sibling is deleting. - const currentTarget = getWorktreeMapFromState(useAppStore.getState()).get(target.id) - if (!currentTarget || currentTarget.instanceId !== target.instanceId) { - useAppStore.getState().clearWorktreeDeleteState(target.id) - listChanged = true - continue - } - if (failedInGroup.some((failed) => isStrictDescendantPath(target.path, failed.path))) { - useAppStore.getState().clearWorktreeDeleteState(target.id) - continue - } - const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName, { - ...options, - focusSuccessorOnDelete: false, - suppressPreservedBranchToast: aggregatePreservedBranches, - onPreservedBranch: (branch) => { - preservedBranches.push(branch) - options.onPreservedBranch?.(branch) + const pendingSnapshotPruneBatch = + uniqueTargets.length > 1 ? beginWorktreeSnapshotPruneBatch() : null + const snapshotPruneBatch = pendingSnapshotPruneBatch ? await pendingSnapshotPruneBatch : null + let groupResults: string[][] + try { + groupResults = await Promise.all( + Array.from(groups.values()).map(async (group) => { + const deletedInGroup: string[] = [] + const failedInGroup: (typeof group)[number][] = [] + for (const target of group) { + // A queued target may be recreated while an earlier repo sibling is deleting. + const currentTarget = getWorktreeMapFromState(useAppStore.getState()).get(target.id) + if (!currentTarget || currentTarget.instanceId !== target.instanceId) { + useAppStore.getState().clearWorktreeDeleteState(target.id) + listChanged = true + continue + } + if (failedInGroup.some((failed) => isStrictDescendantPath(target.path, failed.path))) { + useAppStore.getState().clearWorktreeDeleteState(target.id) + continue + } + const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName, { + ...options, + focusSuccessorOnDelete: false, + suppressPreservedBranchToast: aggregatePreservedBranches, + ...(snapshotPruneBatch ? { snapshotPruneBatchId: snapshotPruneBatch.batchId } : {}), + onPreservedBranch: (branch) => { + preservedBranches.push(branch) + options.onPreservedBranch?.(branch) + } + }) + if (deleted) { + deletedInGroup.push(target.id) + } else { + // A failed child makes deleting its ancestor unsafe because the child lives below it. + failedInGroup.push(target) } - }) - if (deleted) { - deletedInGroup.push(target.id) - } else { - // A failed child makes deleting its ancestor unsafe because the child lives below it. - failedInGroup.push(target) } + return deletedInGroup + }) + ) + } finally { + if (snapshotPruneBatch) { + try { + await snapshotPruneBatch.finish() + } catch (error) { + console.warn('Failed to finish workspace snapshot prune batch:', error) } - return deletedInGroup - }) - ) + } + } if (listChanged) { showWorkspaceListChangedToast() } @@ -125,9 +141,14 @@ export function runWorktreeDeleteWithToast( const commitFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId) const focusSuccessor = options.focusSuccessorOnDelete !== false - const removal = options.suppressPreservedBranchToast - ? removeWorktree(worktreeId, options.force === true, { suppressPreservedBranchToast: true }) - : removeWorktree(worktreeId, options.force === true) + const removeOptions = { + ...(options.suppressPreservedBranchToast ? { suppressPreservedBranchToast: true } : {}), + ...(options.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } : {}) + } + const removal = + Object.keys(removeOptions).length > 0 + ? removeWorktree(worktreeId, options.force === true, removeOptions) + : removeWorktree(worktreeId, options.force === true) return removal .then((result) => { if (result.ok) { diff --git a/src/renderer/src/components/sidebar/worktree-delete-request.ts b/src/renderer/src/components/sidebar/worktree-delete-request.ts index df357d8c55e..f57a83a6b65 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-request.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-request.ts @@ -17,6 +17,7 @@ export type WorktreeDeleteWithToastOptions = { onForceDeleted?: (worktreeId: string) => void onPreservedBranch?: (branch: PreservedBranchCleanup) => void suppressPreservedBranchToast?: boolean + snapshotPruneBatchId?: string // Batch deletion commits one focus handoff after all targets settle. focusSuccessorOnDelete?: boolean } diff --git a/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts b/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts new file mode 100644 index 00000000000..91e970291fd --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts @@ -0,0 +1,24 @@ +export type WorktreeSnapshotPruneBatch = { + batchId: string + finish: () => Promise +} + +export function beginWorktreeSnapshotPruneBatch(): Promise | null { + if (typeof window === 'undefined') { + return null + } + const api = window.api.workspaceCleanup + const begin = api.beginRemovalSnapshotPruneBatch + const record = api.recordRemovalSnapshotPrune + const finish = api.finishRemovalSnapshotPruneBatch + if (typeof begin !== 'function' || typeof record !== 'function' || typeof finish !== 'function') { + return null + } + const batchId = crypto.randomUUID() + return begin({ batchId }) + .then(() => ({ batchId, finish: () => finish({ batchId }) })) + .catch((error: unknown) => { + console.warn('Failed to begin workspace snapshot prune batch:', error) + return null + }) +} diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 688d59c9a14..6ddfef2251c 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -36,7 +36,6 @@ import type { AppMemory, UsageValues } from '../../../../shared/process-stats-ty import type { Worktree } from '../../../../shared/worktree/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import { countEstimatedInactiveWorkspaces } from '../workspace-cleanup/inactive-workspace-estimate' import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' import type { Metric, @@ -909,17 +908,11 @@ export function ResourceUsageStatusSegment({ return map }, [repos]) - const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) const worktreeById = useMemo( () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])), [allWorktrees] ) - const oldWorkspaceCount = useMemo( - () => countEstimatedInactiveWorkspaces(allWorktrees, repoById, Date.now()), - [allWorktrees, repoById] - ) - // Why: skip the merge when closed; the always-mounted segment recomputing on every keystroke-driven store mutation made the app laggy. const unifiedRepos = useMemo( () => @@ -1482,8 +1475,7 @@ export function ResourceUsageStatusSegment({ {translate( 'auto.components.status.bar.ResourceUsageStatusSegment.92924a14e3', - 'Review inactive workspaces ({{value0}})', - { value0: oldWorkspaceCount } + 'Clean up workspaces' )} 0 + ? { + name: translate( + 'components.status.bar.workspaceSpace.otherTopLevelItems', + 'Other top-level items ({{value0}})', + { value0: worktree.omittedTopLevelItemCount } + ), + path: '', + kind: 'other', + sizeBytes: worktree.omittedTopLevelSizeBytes + } + : null return (
@@ -1010,7 +1026,7 @@ function BreakdownList({ )}
- ) : worktree.topLevelItems.length === 0 ? ( + ) : topLevelItemCount === 0 ? (
{translate( 'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079', @@ -1023,6 +1039,7 @@ function BreakdownList({ {worktree.topLevelItems.slice(0, 12).map((item) => ( ))} + {omittedItem ? : null}
)} diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx index 7887b7978fc..49656d86e12 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx @@ -1,210 +1,84 @@ // @vitest-environment happy-dom - -import { act } from 'react' +import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import { readStoreListenerCount } from '@/store/store-listener-census' -import type { - WorkspaceCleanupCandidate, - WorkspaceCleanupScanResult -} from '../../../../shared/workspace-cleanup' -import type * as WorkspaceCleanupPresentation from './workspace-cleanup-presentation' +import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' import WorkspaceCleanupDialog from './WorkspaceCleanupDialog' -import { NOW, makeCandidate, makeState } from './workspace-cleanup-presentation-fixtures' +import { WorkspaceCleanupScanSupersededError } from '@/store/slices/workspace-cleanup-broad-scan-registry' -const contentProbe = vi.hoisted(() => ({ - mounts: vi.fn(), - projections: vi.fn(), - renders: vi.fn(), - storeNotifications: vi.fn(), - unmounts: vi.fn() -})) +const probes = vi.hoisted(() => ({ facets: vi.fn() })) +const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })) -const toastProbe = vi.hoisted(() => ({ - error: vi.fn(), - info: vi.fn(), - message: vi.fn(), - success: vi.fn(), - warning: vi.fn() -})) - -vi.mock('sonner', () => ({ toast: toastProbe })) - -vi.mock('./workspace-cleanup-presentation', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - filterWorkspaceCleanupCandidates: ( - ...args: Parameters - ) => { - contentProbe.projections() - return actual.filterWorkspaceCleanupCandidates(...args) - }, - getWorkspaceCleanupReviewInfo: ( - ...args: Parameters - ) => { - contentProbe.projections() - return actual.getWorkspaceCleanupReviewInfo(...args) - }, - sortWorkspaceCleanupCandidates: ( - ...args: Parameters - ) => { - contentProbe.projections() - return actual.sortWorkspaceCleanupCandidates(...args) +vi.mock('sonner', () => ({ toast })) +vi.mock('./use-workspace-cleanup-facet-rows', () => ({ + useWorkspaceCleanupFacetRows: () => { + probes.facets() + return { + rows: [], + selectableWorktreeIds: [], + facetMatchedWorktreeIds: new Set(), + matchedCount: 0, + totalCount: 0, + facetCounts: { + activity: 0, + size: 0, + status: 0, + agent: 0, + git: 0, + review: 0, + ticket: 0, + context: 0, + location: 0, + safety: 0 + }, + options: { workspaceStatuses: [], hostIds: [], repos: [], reviewProviders: [] }, + reviewInfoByWorktreeId: new Map(), + sizeByWorktreeId: new Map(), + measuredSizeCount: 0, + unmeasuredSizeCount: 0 } } -}) - +})) vi.mock('@/components/ui/dialog', async () => { - const React = await import('react') - const Passthrough = ({ children }: { children: React.ReactNode }) => <>{children} - const DialogContent = () => { - contentProbe.renders() - React.useEffect(() => { - contentProbe.mounts() - const unsubscribe = useAppStore.subscribe(() => contentProbe.storeNotifications()) - return () => { - contentProbe.unmounts() - unsubscribe() - } - }, []) - // Why: render while closed so the marker measures Orca's content gate, not Radix's portal gate. - return
- } + const Passthrough = ({ children }: { children: ReactNode }) => <>{children} return { Dialog: Passthrough, - DialogContent, - DialogDescription: Passthrough, - DialogFooter: Passthrough, - DialogHeader: Passthrough, - DialogTitle: Passthrough + DialogContent: () =>
} }) -type Deferred = { - promise: Promise - resolve: (value: T) => void +const initialState = useAppStore.getInitialState() +let container: HTMLDivElement +let root: Root + +function emptyScan(scannedAt: number): WorkspaceCleanupScanResult { + return { scannedAt, candidates: [], errors: [] } } -const initialAppState = useAppStore.getInitialState() -const fixtureState = makeState() -const initialCandidate = makeCandidate() -const initialScan = makeScan(1, initialCandidate) -let testContainer: HTMLDivElement -let testRoot: Root - -function deferred(): Deferred { - let resolve!: (value: T) => void - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise - }) - return { promise, resolve } -} - -function makeScan(index: number, candidate: WorkspaceCleanupCandidate): WorkspaceCleanupScanResult { - return { - scannedAt: NOW + index, - candidates: [candidate], - errors: [] - } -} - -function makeChurnCandidate(index: number): WorkspaceCleanupCandidate { - return makeCandidate({ - worktreeId: `repo-1::/repo/background-${index}`, - displayName: `background-${index}`, - path: `/repo/background-${index}`, - fingerprint: `background-${index}` - }) -} - -function activeContentSubscriptions(): number { - return contentProbe.mounts.mock.calls.length - contentProbe.unmounts.mock.calls.length -} - -function requireStoreListenerCount(): number { - const count = readStoreListenerCount() - expect(count).not.toBeNull() - return count ?? 0 -} - -async function flushEffects(): Promise { +async function flush(): Promise { await act(async () => { await Promise.resolve() await Promise.resolve() }) } -async function renderDialog(): Promise { - await act(async () => testRoot.render()) - await flushEffects() -} - async function openDialog(): Promise { await act(async () => useAppStore.getState().openModal('workspace-cleanup')) - await flushEffects() + await flush() } async function closeDialog(): Promise { await act(async () => useAppStore.getState().closeModal()) - await flushEffects() + await flush() } -async function publishScanProgress(index: number): Promise { - const scan = makeScan(index, makeChurnCandidate(index)) - await act(async () => { - useAppStore.setState({ - workspaceCleanupScan: scan, - workspaceCleanupProgress: { - ...scan, - scanId: `scan-${index}`, - scannedWorktreeCount: 1, - totalWorktreeCount: 1 - } - }) - }) -} - -async function publishReviewChurn(index: number): Promise { - await act(async () => { - useAppStore.setState({ - hostedReviewCache: { - [`background-${index}`]: { data: null, fetchedAt: NOW + index } - } - }) - }) -} - -async function publishDeleteChurn(index: number): Promise { - const candidate = makeChurnCandidate(index) - await act(async () => { - useAppStore.setState({ - deleteStateByWorktreeId: { - [candidate.worktreeId]: { - isDeleting: true, - phase: 'queued', - error: null, - canForceDelete: false, - forceDeleteReason: null - } - } - }) - }) -} - -function seedStore( - scanWorkspaceCleanup: AppState['scanWorkspaceCleanup'] = vi.fn(async () => initialScan) -): void { - useAppStore.setState(initialAppState, true) +function seedStore(scanWorkspaceCleanup: AppState['scanWorkspaceCleanup']): void { + useAppStore.setState(initialState, true) useAppStore.setState({ activeModal: 'none', - repos: fixtureState.repos, - worktreesByRepo: fixtureState.worktreesByRepo, - hostedReviewCache: {}, - deleteStateByWorktreeId: {}, - workspaceCleanupScan: initialScan, + workspaceCleanupScan: emptyScan(1), workspaceCleanupProgress: null, workspaceCleanupLoading: false, workspaceCleanupError: null, @@ -216,117 +90,81 @@ describe('WorkspaceCleanupDialog mount gating', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - Object.values(contentProbe).forEach((probe) => probe.mockClear()) - Object.values(toastProbe).forEach((probe) => probe.mockClear()) - seedStore() - testContainer = document.createElement('div') - document.body.appendChild(testContainer) - testRoot = createRoot(testContainer) + probes.facets.mockClear() + toast.error.mockClear() + toast.success.mockClear() + seedStore(vi.fn(async () => emptyScan(2))) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) }) afterEach(async () => { - await act(async () => testRoot.unmount()) + await act(async () => root.unmount()) document.body.replaceChildren() - useAppStore.setState(initialAppState, true) + useAppStore.setState(initialState, true) vi.clearAllTimers() vi.useRealTimers() }) - it('keeps heavy content through 299 ms, then drops its projections and subscriptions', async () => { - const listenerBaseline = requireStoreListenerCount() - await renderDialog() - const shellListenerCount = requireStoreListenerCount() - - expect(shellListenerCount).toBe(listenerBaseline + 1) - expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).toBeNull() - expect(activeContentSubscriptions()).toBe(0) + it('drops heavy projections after the close animation', async () => { + await act(async () => root.render()) + await flush() + expect(probes.facets).not.toHaveBeenCalled() await openDialog() - const openListenerCount = requireStoreListenerCount() - - expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).not.toBeNull() - expect(activeContentSubscriptions()).toBe(1) - expect(openListenerCount).toBeGreaterThan(shellListenerCount) - - await closeDialog() - await act(async () => vi.advanceTimersByTimeAsync(299)) - - expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).not.toBeNull() - expect(activeContentSubscriptions()).toBe(1) - - const lingeringProjectionCount = contentProbe.projections.mock.calls.length - const lingeringNotificationCount = contentProbe.storeNotifications.mock.calls.length - await publishScanProgress(2) - await publishReviewChurn(2) - await publishDeleteChurn(2) - - expect(contentProbe.projections.mock.calls.length).toBeGreaterThan(lingeringProjectionCount) - expect(contentProbe.storeNotifications.mock.calls.length).toBeGreaterThan( - lingeringNotificationCount - ) - - await act(async () => vi.advanceTimersByTimeAsync(1)) - - expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).toBeNull() - expect(activeContentSubscriptions()).toBe(0) - expect(requireStoreListenerCount()).toBe(shellListenerCount) - - const hiddenProjectionCount = contentProbe.projections.mock.calls.length - const hiddenRenderCount = contentProbe.renders.mock.calls.length - const hiddenNotificationCount = contentProbe.storeNotifications.mock.calls.length - for (let index = 3; index < 103; index += 1) { - await publishScanProgress(index) - await publishReviewChurn(index) - await publishDeleteChurn(index) - } - - expect(contentProbe.projections).toHaveBeenCalledTimes(hiddenProjectionCount) - expect(contentProbe.renders).toHaveBeenCalledTimes(hiddenRenderCount) - expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(hiddenNotificationCount) - }) - - it('cancels the pending content unmount when reopened during the linger', async () => { - await renderDialog() - await openDialog() - await closeDialog() - await act(async () => vi.advanceTimersByTimeAsync(299)) - await openDialog() - await act(async () => vi.advanceTimersByTimeAsync(1_000)) - - expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).not.toBeNull() - expect(activeContentSubscriptions()).toBe(1) - - const projectionCount = contentProbe.projections.mock.calls.length - await publishScanProgress(4) - expect(contentProbe.projections.mock.calls.length).toBeGreaterThan(projectionCount) - }) - - it('toasts when an open-time scan settles after the closed content unmounts', async () => { - const pendingScan = deferred() - const scanWorkspaceCleanup = vi.fn(() => pendingScan.promise) - seedStore(scanWorkspaceCleanup) - await renderDialog() - await openDialog() - - expect(scanWorkspaceCleanup).toHaveBeenCalledTimes(1) + expect(probes.facets).toHaveBeenCalled() await closeDialog() await act(async () => vi.advanceTimersByTimeAsync(300)) - expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).toBeNull() + const hiddenCalls = probes.facets.mock.calls.length - const completedScan = makeScan(5, makeChurnCandidate(5)) - await act(async () => { - pendingScan.resolve(completedScan) - await pendingScan.promise - await Promise.resolve() - }) - await flushEffects() - - expect(toastProbe.success).toHaveBeenCalledWith( - 'Inactive workspace scan ready', - expect.objectContaining({ - action: expect.objectContaining({ label: 'Review' }) + for (let index = 0; index < 100; index += 1) { + await act(async () => { + useAppStore.setState({ + workspaceCleanupScan: emptyScan(index + 10), + hostedReviewCache: { [`review-${index}`]: { data: null, fetchedAt: index } }, + deleteStateByWorktreeId: {} + }) }) + } + + expect(probes.facets).toHaveBeenCalledTimes(hiddenCalls) + }) + + it('keeps scan completion ownership after heavy content unmounts', async () => { + let resolveScan!: (result: WorkspaceCleanupScanResult) => void + const scanPromise = new Promise((resolve) => { + resolveScan = resolve + }) + const scanWorkspaceCleanup = vi.fn(() => scanPromise) + seedStore(scanWorkspaceCleanup) + await act(async () => root.render()) + await openDialog() + + await closeDialog() + await act(async () => vi.advanceTimersByTimeAsync(300)) + expect(container.querySelector('[data-workspace-cleanup-content]')).toBeNull() + + await act(async () => resolveScan(emptyScan(20))) + await flush() + + expect(toast.success).toHaveBeenCalledWith( + 'Workspace scan ready', + expect.objectContaining({ action: expect.objectContaining({ label: 'Review' }) }) ) }) + + it('does not report a superseded scan as a failure', async () => { + seedStore( + vi.fn(async () => { + throw new WorkspaceCleanupScanSupersededError() + }) + ) + await act(async () => root.render()) + + await openDialog() + + expect(toast.error).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx new file mode 100644 index 00000000000..204b28317f1 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx @@ -0,0 +1,399 @@ +// @vitest-environment happy-dom +import type { ReactNode, Ref } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WorkspaceCleanupScanProgress, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' +import { createTestStore, seedStore } from '../../store/slices/store-test-helpers' +import { resetWorkspaceCleanupBrowsePersistTimer } from '../../store/slices/workspace-cleanup-browse' +import { NOW, makeCandidate } from '../../store/slices/workspace-cleanup-slice-test-harness' +import WorkspaceCleanupDialog from './WorkspaceCleanupDialog' + +const holders = vi.hoisted(() => ({ + store: null as unknown as { (selector: (s: unknown) => unknown): unknown } & { + getState: () => Record + setState: (partial: unknown) => void + subscribe: (listener: (state: unknown, previous: unknown) => void) => () => void + }, + infoToasts: [] as string[], + rig: null as null | { + resolvers: ((result: WorkspaceCleanupScanResult) => void)[] + } +})) + +vi.mock('@/store', () => { + const useAppStore = (selector: (s: unknown) => unknown): unknown => holders.store(selector) + useAppStore.getState = () => holders.store.getState() + useAppStore.setState = (partial: unknown) => holders.store.setState(partial) + useAppStore.subscribe = (listener: (state: unknown, previous: unknown) => void) => + holders.store.subscribe(listener) + return { useAppStore } +}) + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: (message: unknown) => holders.infoToasts.push(String(message)) + }) +})) + +vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children, viewportRef }: { children: ReactNode; viewportRef?: Ref }) => ( +
}>{children}
+ ), + ScrollBar: () => null +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null +})) + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: () => null +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: () => null, + DropdownMenuItem: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
+})) + +const CACHED_AT = NOW - 2 * 60 * 60 * 1000 + +type ScanRig = { + scan: ReturnType + progressCallbacks: ((progress: WorkspaceCleanupScanProgress) => void)[] + resolvers: ((result: WorkspaceCleanupScanResult) => void)[] +} + +function installApi(cachedScan: WorkspaceCleanupScanResult | null): ScanRig { + const rig: ScanRig = { scan: vi.fn(), progressCallbacks: [], resolvers: [] } + rig.scan.mockImplementation( + ( + args?: { worktreeId?: string }, + onProgress?: (progress: WorkspaceCleanupScanProgress) => void + ) => { + if (args?.worktreeId) { + return Promise.resolve({ + scannedAt: NOW, + candidates: [makeCandidate({ worktreeId: args.worktreeId })], + errors: [] + }) + } + if (onProgress) { + rig.progressCallbacks.push(onProgress) + } + return new Promise((resolve) => { + rig.resolvers.push(resolve) + }) + } + ) + ;(window as unknown as { api: unknown }).api = { + workspaceCleanup: { + scan: rig.scan, + getCachedScan: vi.fn().mockResolvedValue(cachedScan), + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ hasKillableProcesses: false }) + }, + workspaceSpace: { + getCachedAnalysis: vi.fn().mockResolvedValue(null), + analyze: vi.fn(), + cancel: vi.fn().mockResolvedValue(false), + onProgress: vi.fn().mockReturnValue(() => undefined) + }, + ui: { set: vi.fn().mockResolvedValue(undefined) } + } + holders.rig = rig + return rig +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function flush(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +async function renderDialog(): Promise { + await act(async () => { + root?.render() + }) +} + +async function openDialog(): Promise { + await act(async () => { + holders.store.setState({ activeModal: 'workspace-cleanup' }) + }) + await flush() +} + +function rowNames(): string[] { + return [...(container?.querySelectorAll('[data-workspace-cleanup-row-name]') ?? [])].map( + (node) => node.textContent ?? '' + ) +} + +function rowCheckbox(name: string): HTMLElement | null { + return container?.querySelector(`[aria-label="Select ${name}"]`) ?? null +} + +function cachedFleet(): WorkspaceCleanupScanResult { + return { + scannedAt: CACHED_AT, + candidates: [ + makeCandidate({ worktreeId: 'repo1::/tmp/alpha', displayName: 'alpha' }), + makeCandidate({ worktreeId: 'repo1::/tmp/beta', displayName: 'beta' }) + ], + errors: [] + } +} + +function progressTick( + rig: ScanRig, + progress: Partial & Pick +): void { + rig.progressCallbacks.at(-1)?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: progress.candidates.length, + totalWorktreeCount: 3, + errors: [], + candidateMode: 'append', + ...progress + }) +} + +describe('WorkspaceCleanupDialog stale-while-revalidate', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + // Why Date-only fake: relative "Updated Xh ago" labels need a pinned clock, + // but flush() depends on real setTimeout. + vi.useFakeTimers({ toFake: ['Date'], now: NOW }) + const store = createTestStore() + seedStore(store, {}) + holders.store = store as unknown as typeof holders.store + holders.infoToasts = [] + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + if (root) { + act(() => root?.unmount()) + } + // Why: broad-scan dedupe lives in slice module state; an unresolved scan + // left by one case would silently join into the next case's scan call. + for (const resolve of holders.rig?.resolvers ?? []) { + resolve({ scannedAt: NOW, candidates: [], errors: [] }) + } + holders.rig = null + await new Promise((resolve) => setTimeout(resolve, 0)) + container?.remove() + root = null + container = null + resetWorkspaceCleanupBrowsePersistTimer() + vi.useRealTimers() + }) + + it('renders the cached snapshot immediately and kicks one background rescan', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + + // Full list from cache while the rescan is still streaming (unresolved). + expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta'])) + expect(rig.scan).toHaveBeenCalledTimes(1) + expect(container?.textContent).toContain('Updated 2h ago') + expect(container?.textContent).toContain('Refreshing') + }) + + it('reconciles streamed rows into the cached list without clearing it', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + + progressTick(rig, { + candidates: [ + makeCandidate({ + worktreeId: 'repo1::/tmp/alpha', + displayName: 'alpha', + fingerprint: 'fingerprint-alpha-2' + }), + makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' }) + ], + scannedWorktreeCount: 2 + }) + await flush() + + // beta was not re-reported yet; it must still be on screen mid-refresh. + expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma'])) + }) + + it('keeps search and selection interactive while rows stream in', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + + const search = container?.querySelector('[aria-label="Search workspaces"]') + expect(search?.disabled).toBe(false) + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + await act(async () => { + setter?.call(search, 'beta') + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + progressTick(rig, { + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })], + scannedWorktreeCount: 1 + }) + await flush() + + // Filter applied mid-stream: only beta visible even as new rows arrive. + expect(rowNames()).toEqual(['beta']) + + await act(async () => { + setter?.call(search, '') + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + await flush() + expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma'])) + + // Rows stay selectable during the stream. + const checkbox = rowCheckbox('alpha') + expect(checkbox).not.toBeNull() + const before = checkbox?.getAttribute('aria-checked') + await act(async () => { + checkbox?.click() + }) + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).not.toBe(before) + }) + + it('does not clear selection while free-text search hides the row', async () => { + installApi(cachedFleet()) + await renderDialog() + await openDialog() + + if (rowCheckbox('alpha')?.getAttribute('aria-checked') !== 'true') { + await act(async () => rowCheckbox('alpha')?.click()) + } + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + const search = container?.querySelector('[aria-label="Search workspaces"]') + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + await act(async () => { + setter?.call(search, 'beta') + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + await flush() + expect(rowNames()).toEqual(['beta']) + + await act(async () => { + setter?.call(search, '') + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + await flush() + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + }) + + it('adopts the in-flight scan on reopen instead of starting a duplicate', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + expect(rig.scan).toHaveBeenCalledTimes(1) + + progressTick(rig, { + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })], + scannedWorktreeCount: 1 + }) + await flush() + + // Close at 40% and reopen: the same scan keeps streaming into the store. + await act(async () => { + holders.store.setState({ activeModal: null }) + }) + await openDialog() + expect(rig.scan).toHaveBeenCalledTimes(1) + + progressTick(rig, { + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/delta', displayName: 'delta' })], + scannedWorktreeCount: 2 + }) + await flush() + await act(async () => { + rig.resolvers.at(-1)?.({ + scannedAt: NOW, + candidates: [ + ...cachedFleet().candidates, + makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' }), + makeCandidate({ worktreeId: 'repo1::/tmp/delta', displayName: 'delta' }) + ], + errors: [] + }) + }) + await flush() + + expect(rig.scan).toHaveBeenCalledTimes(1) + expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma', 'delta'])) + }) + + it('keeps selections across a refresh and surfaces vanished selections', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + await act(async () => { + rig.resolvers.at(-1)?.(cachedFleet()) + }) + await flush() + + // Both rows are default-selected suggestions after the first settle. + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + expect(rowCheckbox('beta')?.getAttribute('aria-checked')).toBe('true') + + await act(async () => { + container?.querySelector('[aria-label="Refresh"]')?.click() + }) + await flush() + expect(rig.scan).toHaveBeenCalledTimes(2) + // Selection survives while the refresh streams. + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + + await act(async () => { + rig.resolvers.at(-1)?.({ + scannedAt: NOW + 1, + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/alpha', displayName: 'alpha' })], + errors: [] + }) + }) + await flush() + + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + expect(rowNames()).not.toContain('beta') + expect(holders.infoToasts).toContain('1 selected workspace no longer exists.') + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 586618d521b..e732584f266 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -1,245 +1,73 @@ -/* eslint-disable max-lines -- Why: the cleanup dialog keeps scan status, - filters, row actions, localized review copy, and force-aware confirmation - in one modal flow. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { - AlertTriangle, - Info, - Loader2, - RefreshCcw, - Search, - SlidersHorizontal, - Trash2, - X -} from 'lucide-react' +import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Input } from '@/components/ui/input' -import { Progress } from '@/components/ui/progress' -import RepoMultiCombobox from '@/components/ui/repo-multi-combobox' +import { Dialog, DialogContent } from '@/components/ui/dialog' import { ScrollArea } from '@/components/ui/scroll-area' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' -import { cn } from '@/lib/utils' import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { isGitRepoKind } from '../../../../shared/repo-kind' +import { translate } from '@/i18n/i18n' import { canQueueWorkspaceCleanupCandidate, - type WorkspaceCleanupCandidate, - type WorkspaceCleanupScanError, - type WorkspaceCleanupScanProgress + type WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' +import { WorkspaceCleanupBrowseToolbar } from './workspace-cleanup-browse-toolbar' +import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove' +import { WorkspaceCleanupDialogHeader } from './workspace-cleanup-dialog-header' import { - filterWorkspaceCleanupCandidates, - getWorkspaceCleanupReviewInfo, - sortWorkspaceCleanupCandidates, - type WorkspaceCleanupContextFilter, - type WorkspaceCleanupFilters, - type WorkspaceCleanupGitFilter, - type WorkspaceCleanupReviewFilter, - type WorkspaceCleanupReviewInfo, - type WorkspaceCleanupSortDirection, - type WorkspaceCleanupSortKey, - type WorkspaceCleanupTimeFilter -} from './workspace-cleanup-presentation' -import type { WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' -import { countEstimatedInactiveWorkspaces } from './inactive-workspace-estimate' -import { CandidateRow, type WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' -import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' + WorkspaceCleanupInitialScanBanner, + WorkspaceCleanupNotice, + WorkspaceCleanupSkeletonRows +} from './workspace-cleanup-dialog-notices' +import { WorkspaceCleanupRowList } from './workspace-cleanup-row-list' +import { formatWorkspaceCleanupScanNotice } from './workspace-cleanup-scan-notice' import { - getCandidateStatus, - getContextPillLabel, - getDirtyGitLabel, - getReviewPillTone, - shouldShowGitMetadataChip -} from './workspace-cleanup-candidate-row-data' -import { StatusPill } from './workspace-cleanup-status-pill' + applyWorkspaceCleanupGitEvidence, + needsWorkspaceCleanupGitEvidence +} from './workspace-cleanup-git-evidence' +import { useWorkspaceCleanupBrowseState } from './use-workspace-cleanup-browse-state' import { - resolveWorkspaceCleanupActiveView, - type WorkspaceCleanupView, - type WorkspaceCleanupViewCounts -} from './workspace-cleanup-view-selection' + useWorkspaceCleanupDialogLifecycle, + type WorkspaceCleanupDialogLifecycle +} from './use-workspace-cleanup-dialog-lifecycle' +import { useWorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows' +import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evidence' +import { useWorkspaceCleanupRowOrder } from './use-workspace-cleanup-row-order' import { - DEFAULT_WORKSPACE_CLEANUP_FILTERS, - useWorkspaceCleanupDialogSession, - type WorkspaceCleanupDialogSession -} from './workspace-cleanup-dialog-session' -import { translate } from '@/i18n/i18n' - -const WORKSPACE_CLEANUP_CLOSE_LINGER_MS = 300 - -const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = { - hasReview: false, - label: null, - state: null, - provider: null, - title: null -} - -function formatRelativeTime(timestamp: number): string { - if (!timestamp) { - return 'Never' - } - const deltaMs = Date.now() - timestamp - if (deltaMs < 60_000) { - return 'Just now' - } - const minutes = Math.floor(deltaMs / 60_000) - if (minutes < 60) { - return `${minutes}m ago` - } - const hours = Math.floor(minutes / 60) - if (hours < 48) { - return `${hours}h ago` - } - return `${Math.floor(hours / 24)}d ago` -} - -function isDisconnectedRemoteScanError(message: string): boolean { - return ( - message === 'SSH provider is unavailable.' || - message === 'Remote workspaces are not connected. Reconnect and refresh to check them.' - ) -} - -function formatScanNoticeMessage( - errors: WorkspaceCleanupScanError[], - repoNameById: Map -): string | null { - const visibleErrors = errors.filter( - (error) => !isDisconnectedRemoteScanError(error.message ?? '') - ) - if (visibleErrors.length === 0) { - return null - } - if (visibleErrors.length === 1) { - const error = visibleErrors[0] - const repoName = formatScanErrorRepoName(error, repoNameById) - return `Could not check ${repoName}: ${formatScanErrorReason(error.message)}. Some inactive workspaces may be missing. Refresh to try again.` - } - const repoNames = visibleErrors - .slice(0, 3) - .map((error) => formatScanErrorRepoName(error, repoNameById)) - .join(', ') - const moreCount = visibleErrors.length - 3 - const suffix = moreCount > 0 ? `, +${moreCount} more` : '' - return `Could not check ${visibleErrors.length} repositories (${repoNames}${suffix}). Some inactive workspaces may be missing. Refresh to try again.` -} - -function formatScanErrorRepoName( - error: Partial, - repoNameById: Map -): string { - const repoName = error.repoName?.trim() - if (repoName) { - return repoName - } - const fallback = error.repoId ? repoNameById.get(error.repoId)?.trim() : '' - return fallback || 'a repository' -} - -function formatScanErrorReason(message: string | undefined): string { - if (!message) { - return 'Git could not list worktrees' - } - if (message === 'Could not scan workspace cleanup for this repository.') { - return 'Git could not list worktrees' - } - return message.replace(/\.$/, '') -} + formatVanishedSelectionNotice, + getDefaultSelectedWorkspaceCleanupIds, + toggleSetMember +} from './workspace-cleanup-selection-model' +/** One filterable list of every workspace Orca knows about. */ export default function WorkspaceCleanupDialog(): React.JSX.Element | null { - // Why: scans and removals outlive the modal; only the subscribed projection tree may unmount. - const session = useWorkspaceCleanupDialogSession() - const [lingering, setLingering] = useState(session.open) - useEffect(() => { - if (session.open) { - setLingering(true) - return - } - const timer = window.setTimeout(() => setLingering(false), WORKSPACE_CLEANUP_CLOSE_LINGER_MS) - return () => window.clearTimeout(timer) - }, [session.open]) - - if (!session.open && !lingering) { + const lifecycle = useWorkspaceCleanupDialogLifecycle() + if (!lifecycle.mountedContent) { return null } - return + return } function WorkspaceCleanupDialogContent({ - session -}: { - session: WorkspaceCleanupDialogSession -}): React.JSX.Element { - const { - open, - loading, - selectedIds, - setSelectedIds, - expandedRowIds, - setExpandedRowIds, - activeView, - setActiveView, - confirming, - confirmCandidates, - removalProgress, - removalInFlight, - rowFailures, - repoSelection, - setRepoSelection, - filters, - setFilters, - sortKey, - setSortKey, - sortDirection, - setSortDirection, - selectedDefaultsScanAtRef, - removalInFlightRef, - close: closeModal, - markCandidateViewed, - restoreDismissals: resetDismissals, - startScan: startWorkspaceCleanupScan, - ignoreCandidate, - applyScanDefaults, - openConfirmRemove, - cancelConfirmRemove, - backToWorkspaceCleanupList, - confirmRemove - } = session + open, + loading, + closeModal, + selectedIds, + setSelectedIds, + removal, + startWorkspaceCleanupScan +}: WorkspaceCleanupDialogLifecycle): React.JSX.Element { const scan = useAppStore((s) => s.workspaceCleanupScan) const scanProgress = useAppStore((s) => s.workspaceCleanupProgress) const error = useAppStore((s) => s.workspaceCleanupError) + const spaceError = useAppStore((s) => s.workspaceSpaceScanError) const repos = useAppStore((s) => s.repos) - const reviewStateInputs = useAppStore( - useShallow((s) => ({ - worktreesByRepo: s.worktreesByRepo, - hostedReviewCache: s.hostedReviewCache, - repos: s.repos, - settings: s.settings - })) - ) + const markCandidateViewed = useAppStore((s) => s.markWorkspaceCleanupCandidateViewed) + const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates) + const refreshWorkspaceSpace = useAppStore((s) => s.refreshWorkspaceSpace) + const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning) + const workspaceSpaceProgress = useAppStore((s) => s.workspaceSpaceScanProgress) const deletionPhaseByWorktreeId = useAppStore( useShallow((s) => { const phases: Record = {} @@ -256,154 +84,94 @@ function WorkspaceCleanupDialogContent({ [deletionPhaseByWorktreeId] ) + const browse = useWorkspaceCleanupBrowseState() + // Why: facet counts/options are only rendered inside the filter popover; + // tracking its open state lets their O(N) passes skip while it is closed. + const [facetPanelOpen, setFacetPanelOpen] = useState(false) + const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) const [rowsScrollElement, setRowsScrollElement] = useState(null) - const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) - const eligibleRepoIds = useMemo(() => eligibleRepos.map((repo) => repo.id), [eligibleRepos]) + const selectedDefaultsScanAtRef = useRef(null) + const wasOpenRef = useRef(open) + // Why: a refresh completing mid-open must not clobber a selection the user + // already made (or was given) for this open — defaults apply at most once. + const defaultsAppliedForOpenRef = useRef(false) + const selectionTouchedRef = useRef(false) + const mountedRef = useMountedRef() + + // Why: the facet clock must be stable across renders but never older than + // this open — a hydrated snapshot's scannedAt can be days stale, which would + // misbucket idle thresholds and keep dead agent statuses "fresh". + const [openedAt, setOpenedAt] = useState(() => Date.now()) useEffect(() => { - if (!open) { - return + if (open && !wasOpenRef.current) { + defaultsAppliedForOpenRef.current = false + selectionTouchedRef.current = false + setOpenedAt(Date.now()) } - setRepoSelection(new Set(eligibleRepoIds)) - }, [eligibleRepoIds, open, setRepoSelection]) + wasOpenRef.current = open + }, [open]) + + const { removalInFlightRef } = removal const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates]) - const reviewInfoByWorktreeId = useMemo(() => { - const infos = new Map() - for (const candidate of candidates) { - infos.set(candidate.worktreeId, getWorkspaceCleanupReviewInfo(candidate, reviewStateInputs)) - } - return infos - }, [candidates, reviewStateInputs]) - const effectiveRepoSelection = useMemo>(() => { - if (repoSelection.size > 0 || eligibleRepoIds.length === 0) { - return repoSelection - } - return new Set(eligibleRepoIds) - }, [eligibleRepoIds, repoSelection]) - const selectedScanErrors = useMemo( - () => (scan?.errors ?? []).filter((error) => effectiveRepoSelection.has(error.repoId)), - [effectiveRepoSelection, scan?.errors] - ) - const filteredCandidates = useMemo(() => { - if ( - effectiveRepoSelection.size === 0 || - effectiveRepoSelection.size === eligibleRepoIds.length - ) { - return candidates - } - return candidates.filter((candidate) => effectiveRepoSelection.has(candidate.repoId)) - }, [candidates, effectiveRepoSelection, eligibleRepoIds.length]) - // Why: the Resource Manager button counts from the renderer's activity record only, - // so it routinely disagrees with the scanned list. Reconcile it rather than leaving - // the user to wonder which number is real. - const estimatedInactiveCount = useMemo(() => { - if (!open) { - return null - } - return countEstimatedInactiveWorkspaces( - Object.values(reviewStateInputs.worktreesByRepo).flat(), - new Map(reviewStateInputs.repos.map((repo) => [repo.id, repo])), - Date.now() - ) - }, [open, reviewStateInputs]) - // Why: the reconciliation only holds for a complete scan; a failed or partial - // one would blame the difference on git-history checks instead of the error. - const estimateMismatchNotice = - !loading && - !error && - selectedScanErrors.length === 0 && - scan && - estimatedInactiveCount !== null && - estimatedInactiveCount !== candidates.length && - filteredCandidates.length === candidates.length - ? translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.f637f63882', - "Resource Manager counts {{value0}}; this list found {{value1}}. That counter reads Orca's activity record alone, while this scan also checks each workspace's git history and skips disconnected remotes.", - { value0: estimatedInactiveCount, value1: candidates.length } - ) - : null - - useEffect(() => { - if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { - return - } - selectedDefaultsScanAtRef.current = scan.scannedAt - applyScanDefaults(scan.candidates, deletingWorktreeIds) - }, [applyScanDefaults, deletingWorktreeIds, loading, scan, selectedDefaultsScanAtRef]) - - const visibleCandidates = useMemo(() => { - const rows = filteredCandidates.filter((candidate) => !candidate.blockers.includes('dismissed')) - return sortWorkspaceCleanupCandidates(rows, 'activity', 'asc', reviewInfoByWorktreeId) - }, [filteredCandidates, reviewInfoByWorktreeId]) - const hiddenCandidates = useMemo( - () => - sortWorkspaceCleanupCandidates( - filteredCandidates.filter((candidate) => candidate.blockers.includes('dismissed')), - 'activity', - 'asc', - reviewInfoByWorktreeId - ), - [filteredCandidates, reviewInfoByWorktreeId] - ) - const groups = useMemo( - () => ({ - ready: visibleCandidates.filter((candidate) => candidate.tier === 'ready'), - review: visibleCandidates.filter((candidate) => candidate.tier === 'review'), - protected: visibleCandidates.filter((candidate) => candidate.tier === 'protected') - }), - [visibleCandidates] - ) - const cleanupViewCounts = useMemo( - () => ({ - ready: groups.ready.length, - review: groups.review.length, - protected: groups.protected.length, - hidden: hiddenCandidates.length - }), - [groups.protected.length, groups.ready.length, groups.review.length, hiddenCandidates.length] - ) - const resolvedActiveView = resolveWorkspaceCleanupActiveView({ - requestedView: activeView, - counts: cleanupViewCounts, - open, - loading, - hasScan: scan != null + const gitEvidenceNeeded = open && needsWorkspaceCleanupGitEvidence(browse.filters, browse.sort) + const gitEvidence = useWorkspaceCleanupGitEvidence({ + enabled: gitEvidenceNeeded, + candidates, + scannedAt: scan?.scannedAt ?? null }) - const repoNameById = useMemo( - () => new Map(repos.map((repo) => [repo.id, repo.displayName || repo.path])), - [repos] + const evidencedCandidates = useMemo( + () => applyWorkspaceCleanupGitEvidence(candidates, gitEvidence.evidenceByWorktreeId), + [candidates, gitEvidence.evidenceByWorktreeId] ) + // Why: a live clock would re-run every facet on each render; the newer of + // scan time and open time is the honest stable "as of" moment — a fresh scan + // keeps its timestamp, a stale hydrated snapshot is judged from this open. + const facetNow = Math.max(scan?.scannedAt ?? 0, openedAt) + const facetRows = useWorkspaceCleanupFacetRows({ + candidates: evidencedCandidates, + filters: browse.filters, + sort: browse.sort, + now: facetNow, + facetPanelOpen + }) + const rows = useWorkspaceCleanupRowOrder({ + rows: facetRows.rows, + streaming: loading, + sort: browse.sort + }) + const candidateIds = useMemo( + () => new Set(candidates.map((candidate) => candidate.worktreeId)), + [candidates] + ) + const scanNoticeMessage = useMemo( - () => formatScanNoticeMessage(selectedScanErrors, repoNameById), - [repoNameById, selectedScanErrors] - ) - const hasAnyCandidates = candidates.length > 0 - const initialLoading = loading && !hasAnyCandidates - const activeBaseRows = - resolvedActiveView === 'hidden' ? hiddenCandidates : groups[resolvedActiveView] - const activeRows = useMemo( () => - sortWorkspaceCleanupCandidates( - filterWorkspaceCleanupCandidates( - activeBaseRows, - filters, - reviewInfoByWorktreeId, - scan?.scannedAt ?? Date.now() - ), - sortKey, - sortDirection, - reviewInfoByWorktreeId + formatWorkspaceCleanupScanNotice( + scan?.errors ?? [], + new Map(repos.map((repo) => [repo.id, repo.displayName || repo.path])) ), - [activeBaseRows, filters, reviewInfoByWorktreeId, scan?.scannedAt, sortDirection, sortKey] + [repos, scan?.errors] ) - const activeRowIds = useMemo( - () => new Set(activeRows.map((candidate) => candidate.worktreeId)), - [activeRows] - ) - const activeFilters = hasActiveWorkspaceCleanupFilters(filters) + const initialLoading = loading && candidates.length === 0 + const runSpaceScan = useCallback(() => { + void refreshWorkspaceSpace().catch((scanError: unknown) => { + if (!mountedRef.current) { + return + } + toast.error( + translate( + 'components.workspace.cleanup.browse.measureSizesFailed', + 'Could not scan workspace sizes' + ), + { description: scanError instanceof Error ? scanError.message : String(scanError) } + ) + }) + }, [mountedRef, refreshWorkspaceSpace]) + const selectedCandidates = useMemo(() => { - const byId = new Map(activeRows.map((candidate) => [candidate.worktreeId, candidate])) + const byId = new Map(rows.map((row) => [row.worktreeId, row.candidate])) return [...selectedIds] .map((id) => byId.get(id)) .filter( @@ -412,59 +180,140 @@ function WorkspaceCleanupDialogContent({ canQueueWorkspaceCleanupCandidate(candidate) && !deletingWorktreeIds.has(candidate.worktreeId) ) - }, [activeRows, deletingWorktreeIds, selectedIds]) + }, [deletingWorktreeIds, rows, selectedIds]) + const selectedCount = selectedCandidates.length + useEffect(() => { - if (!open || confirming) { + if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { return } - // Why: destructive selection must stay scoped to the rows the user can - // currently review after tier/filter changes. + selectedDefaultsScanAtRef.current = scan.scannedAt + if (removalInFlightRef.current) { + return + } + if (defaultsAppliedForOpenRef.current || selectionTouchedRef.current) { + return + } + defaultsAppliedForOpenRef.current = true + setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds)) + }, [deletingWorktreeIds, loading, removalInFlightRef, scan, setSelectedIds]) + + const pruneSelectionToVisibleRows = useEffectEvent(() => { setSelectedIds((current) => { const next = new Set( - [...current].filter((id) => activeRowIds.has(id) && !deletingWorktreeIds.has(id)) + [...current].filter( + (id) => facetRows.facetMatchedWorktreeIds.has(id) && !deletingWorktreeIds.has(id) + ) ) return next.size === current.size ? current : next }) - }, [activeRowIds, confirming, deletingWorktreeIds, open, setSelectedIds]) + }) - const handleOpenChange = useCallback( - (nextOpen: boolean) => { - if (!nextOpen) { - closeModal() - } + useEffect(() => { + if (!open || removal.confirming) { + return + } + // Why: destructive selection must stay scoped to the rows the user can + // currently review after a filter change — and only then; keying on the + // user's filter state (not the matched set, whose identity changes every + // streaming tick) keeps a re-classified row from being silently deselected. + pruneSelectionToVisibleRows() + }, [browse.filters, open, removal.confirming]) + + const pruneVanishedSelections = useEffectEvent(() => { + const kept = [...selectedIds].filter( + (id) => candidateIds.has(id) && !deletingWorktreeIds.has(id) + ) + if (kept.length === selectedIds.size) { + return + } + const vanishedCount = [...selectedIds].filter( + (id) => !candidateIds.has(id) && !deletingWorktreeIds.has(id) + ).length + setSelectedIds(new Set(kept)) + if (vanishedCount > 0) { + toast.info(formatVanishedSelectionNotice(vanishedCount)) + } + }) + + useEffect(() => { + if (loading) { + return + } + // Why: a settled refresh reconciles by worktreeId — selections survive it, + // and only rows that truly no longer exist drop (with a visible reason). + pruneVanishedSelections() + }, [candidateIds, deletingWorktreeIds, loading]) + + const ignoreCandidate = useCallback( + (candidate: WorkspaceCleanupCandidate) => { + void dismissCandidates([candidate]) + .then(() => { + if (mountedRef.current) { + setSelectedIds((current) => { + const next = new Set(current) + next.delete(candidate.worktreeId) + return next + }) + } + }) + .catch((err: unknown) => { + if (mountedRef.current) { + toast.error( + translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7f451a3e2c', + 'Could not ignore cleanup suggestion' + ), + { description: err instanceof Error ? err.message : String(err) } + ) + } + }) }, - [closeModal] + [dismissCandidates, mountedRef, setSelectedIds] ) - const refresh = useCallback(() => { - startWorkspaceCleanupScan({ notifyWhenReady: true }) - }, [startWorkspaceCleanupScan]) - - const toggleExpandedRow = useCallback( - (worktreeId: string) => { - setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) - }, - [setExpandedRowIds] - ) + const toggleExpandedRow = useCallback((worktreeId: string) => { + setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) + }, []) const toggleSelectedRow = useCallback( (worktreeId: string) => { + selectionTouchedRef.current = true setSelectedIds((current) => toggleSetMember(current, worktreeId)) }, [setSelectedIds] ) + const selectableWorktreeIds = useMemo( + () => + removal.removalInFlight + ? [] + : facetRows.selectableWorktreeIds.filter((id) => !deletingWorktreeIds.has(id)), + [deletingWorktreeIds, facetRows.selectableWorktreeIds, removal.removalInFlight] + ) + const toggleSelectAll = useCallback( + (selectAll: boolean) => { + selectionTouchedRef.current = true + setSelectedIds(selectAll ? new Set(selectableWorktreeIds) : new Set()) + }, + [selectableWorktreeIds, setSelectedIds] + ) + // Why: stable per-row handlers so React.memo keeps unchanged CandidateRow // instances from re-rendering on scan stream-in and selection changes. + const openConfirmRemove = removal.openConfirmRemove const handleRemoveRow = useCallback( (candidate: WorkspaceCleanupCandidate) => { - if (loading || removalInFlightRef.current) { + // Why: rows stay actionable while a rescan streams; the confirm-time + // preflight is the safety gate, not the scan state. + if (removalInFlightRef.current) { return } + selectionTouchedRef.current = true setSelectedIds(new Set([candidate.worktreeId])) openConfirmRemove([candidate]) }, - [loading, openConfirmRemove, removalInFlightRef, setSelectedIds] + [openConfirmRemove, removalInFlightRef, setSelectedIds] ) const handleViewCandidate = useCallback( @@ -476,911 +325,102 @@ function WorkspaceCleanupDialogContent({ [closeModal, markCandidateViewed] ) - const selectedCount = selectedCandidates.length + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + closeModal() + } + }, + [closeModal] + ) return ( - {!confirming ? ( + {!removal.confirming ? ( <> - -
-
- - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.b2c1331844', - 'Delete Inactive Workspaces' - )} - -
-
- - - - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7ae2ad30f4', - 'Refresh' - )} - - - -
-
-
+ openConfirmRemove(selectedCandidates)} + onRefresh={() => startWorkspaceCleanupScan({ notifyWhenReady: true })} + onClose={closeModal} + /> - {initialLoading ? ( -
- -
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7eee951968', - 'Checking inactive workspaces' - )} -
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.47123d0108', - 'Scanning inactive workspaces. You can close this and come back.' - )} -
-
- {formatWorkspaceCleanupProgress(scanProgress)} -
-
-
- ) : hasAnyCandidates ? ( -
-
-
- {selectedCount}{' '} - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ac5ba84cc1', - 'selected' - )} -
-
-
- {eligibleRepos.length > 1 ? ( -
- setRepoSelection(new Set(next))} - onSelectAll={() => setRepoSelection(new Set(eligibleRepoIds))} - triggerClassName="h-8 w-full rounded-md border border-border/60 bg-background px-2 text-xs font-medium shadow-xs hover:bg-accent/60" - /> -
- ) : null} - -
-
+ {initialLoading ? : null} + {error ? : null} + {!error && spaceError ? ( + + ) : null} + {!error && scanNoticeMessage ? ( + ) : null} - {loading && scan && hasAnyCandidates ? ( -
-
- - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9a3be9f2df', - 'Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.' - )} - - - {formatWorkspaceCleanupProgress(scanProgress)} - -
-
- ) : null} + - {error ? ( -
- {error} + +
+ {initialLoading ? : null} +
- ) : scanNoticeMessage ? ( -
- - {scanNoticeMessage} -
- ) : null} - - {estimateMismatchNotice ? ( -
- - {estimateMismatchNotice} -
- ) : null} - -
- -
- {filteredCandidates.length > 0 ? ( - 0 - } - sortKey={sortKey} - sortDirection={sortDirection} - onFiltersChange={setFilters} - onSortKeyChange={setSortKey} - onSortDirectionChange={setSortDirection} - onRestoreIgnored={() => void resetDismissals()} - /> - ) : null} - -
- {initialLoading ? : null} - {!loading && scan && candidates.length === 0 && !scanNoticeMessage ? ( - - ) : null} - {!loading && scan && candidates.length === 0 && scanNoticeMessage ? ( - - ) : null} - {!loading && - scan && - candidates.length > 0 && - filteredCandidates.length === 0 ? ( - setRepoSelection(new Set(eligibleRepoIds))} - /> - ) : null} - {!loading && - scan && - filteredCandidates.length > 0 && - visibleCandidates.length === 0 ? ( - setActiveView('hidden')} - /> - ) : null} - {!loading && - scan && - activeRows.length === 0 && - activeBaseRows.length > 0 && - activeFilters ? ( - setFilters(DEFAULT_WORKSPACE_CLEANUP_FILTERS)} - /> - ) : null} - {!loading && - scan && - activeRows.length === 0 && - visibleCandidates.length > 0 && - !activeFilters ? ( - - ) : null} - ( - 1 && index === activeRows.length - 1} - expanded={expandedRowIds.has(candidate.worktreeId)} - lastActivityLabel={formatRelativeTime(candidate.lastActivityAt)} - deletionPhase={deletionPhaseByWorktreeId[candidate.worktreeId]} - removing={ - loading || - removalInFlight || - deletingWorktreeIds.has(candidate.worktreeId) - } - selected={ - selectedIds.has(candidate.worktreeId) && - !loading && - !deletingWorktreeIds.has(candidate.worktreeId) - } - failure={rowFailures[candidate.worktreeId]} - onToggleExpanded={toggleExpandedRow} - onToggleSelected={toggleSelectedRow} - onView={handleViewCandidate} - onIgnore={ignoreCandidate} - onRemove={handleRemoveRow} - /> - )} - /> -
-
-
-
+
) : ( - )}
) } - -function WorkspaceCleanupFilterToolbar({ - filters, - showRestoreIgnored, - sortKey, - sortDirection, - onFiltersChange, - onSortKeyChange, - onSortDirectionChange, - onRestoreIgnored -}: { - filters: WorkspaceCleanupFilters - showRestoreIgnored: boolean - sortKey: WorkspaceCleanupSortKey - sortDirection: WorkspaceCleanupSortDirection - onFiltersChange: (filters: WorkspaceCleanupFilters) => void - onSortKeyChange: (sortKey: WorkspaceCleanupSortKey) => void - onSortDirectionChange: (direction: WorkspaceCleanupSortDirection) => void - onRestoreIgnored: () => void -}): React.JSX.Element { - const updateFilter = ( - key: K, - value: WorkspaceCleanupFilters[K] - ): void => { - onFiltersChange({ ...filters, [key]: value }) - } - const hasHiddenControls = hasActiveWorkspaceCleanupPanelControls(filters, sortKey, sortDirection) - const resetPanelControls = (): void => { - onFiltersChange({ - ...filters, - time: 'all', - review: 'all', - git: 'all', - context: 'all' - }) - onSortKeyChange('activity') - onSortDirectionChange('asc') - } - - return ( -
-
- - updateFilter('query', event.target.value)} - placeholder={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.searchPlaceholder', - 'Search workspaces' - )} - className="h-8 pl-8 text-xs" - /> -
- - - - - - - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.efb3843e75', - 'Filter and sort workspaces' - )} - - - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.93b7381d50', - 'Filters' - )} - - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ageFilter', - 'Age' - )} - value={filters.time} - options={[ - ['all', 'Any age'], - ['30d', '30d+'], - ['90d', '90d+'], - ['archived', 'Archived'] - ]} - onChange={(value) => updateFilter('time', value)} - /> - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.reviewFilter', - 'Review' - )} - value={filters.review} - options={[ - ['all', 'Any review'], - ['no-review', 'No PR/MR'], - ['has-review', 'Has PR/MR'], - ['open-review', 'Open'], - ['closed-review', 'Closed'] - ]} - onChange={(value) => updateFilter('review', value)} - /> - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.gitFilter', - 'Git' - )} - value={filters.git} - options={[ - ['all', 'Any git'], - ['clean', 'Clean'], - ['dirty', 'Dirty'], - ['unpushed', 'Unpushed'], - ['unknown', 'Unknown'] - ]} - onChange={(value) => updateFilter('git', value)} - /> - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.contextFilter', - 'Context' - )} - value={filters.context} - options={[ - ['all', 'Any context'], - ['has-context', 'Has context'], - ['no-context', 'No context'] - ]} - onChange={(value) => updateFilter('context', value)} - /> - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.a615e24679', - 'Sort' - )} - - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.sortBy', - 'Sort by' - )} - value={sortKey} - options={[ - ['activity', 'Activity'], - ['name', 'Name'], - ['repo', 'Repo'], - ['review', 'Review'], - ['git', 'Git'] - ]} - onChange={onSortKeyChange} - /> - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.sortDirection', - 'Direction' - )} - value={sortDirection} - options={[ - ['asc', 'Ascending'], - ['desc', 'Descending'] - ]} - onChange={onSortDirectionChange} - /> - {showRestoreIgnored ? ( - <> - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.aaee139eab', - 'Restore ignored suggestions' - )} - - - ) : null} - {hasHiddenControls ? ( - <> - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e94b1f8bb4', - 'Clear filters' - )} - - - ) : null} - - -
- ) -} - -function WorkspaceCleanupMenuSub({ - label, - value, - options, - onChange -}: { - label: string - value: T - options: readonly (readonly [T, string])[] - onChange: (value: T) => void -}): React.JSX.Element { - const valueLabel = options.find(([optionValue]) => optionValue === value)?.[1] ?? value - return ( - - - - {label} - - {valueLabel} - - - - - onChange(next as T)}> - {options.map(([optionValue, optionLabel]) => ( - event.preventDefault()} - > - {optionLabel} - - ))} - - - - ) -} - -function CleanupViewNav({ - activeView, - counts, - onViewChange -}: { - activeView: WorkspaceCleanupView - counts: WorkspaceCleanupViewCounts - onViewChange: (view: WorkspaceCleanupView) => void -}): React.JSX.Element { - const items: { view: WorkspaceCleanupView; label: string }[] = [ - { - view: 'ready', - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4b93a235d8', - 'Suggested' - ) - }, - { - view: 'review', - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.d1094dd529', - 'Needs review' - ) - }, - { - view: 'protected', - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4f4782c02', - 'Not suggested' - ) - }, - { - view: 'hidden', - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', - 'Ignored' - ) - } - ] - - return ( - - ) -} - -function ConfirmRemove({ - candidates, - reviewInfoByWorktreeId, - progress, - onBack, - onCancel, - onConfirm -}: { - candidates: WorkspaceCleanupCandidate[] - reviewInfoByWorktreeId: ReadonlyMap - progress: WorkspaceCleanupRemovalProgress | null - onBack: () => void - onCancel: () => void - onConfirm: () => void -}): React.JSX.Element { - const count = candidates.length - const deleting = progress !== null - const progressValue = progress - ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) - : 0 - return ( - <> - -
-
-
- {deleting ? ( - - ) : ( - - )} -
-
- - {deleting - ? translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deletingCount', - 'Deleting workspaces: {{value0}}', - { value0: count } - ) - : translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteCount', - 'Delete workspaces: {{value0}}?', - { value0: count } - )} - - - {deleting - ? translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1d3503357d', - 'You can close this and come back while deletion continues.' - ) - : translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400', - "This permanently deletes their local files. You can't undo this." - )} - -
-
- -
-
-
- {progress ? ( -
-
- - - {formatWorkspaceCleanupRemovalProgress(progress)} - -
- -
- ) : null} -
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.selectedForDeletionCount', - 'Selected for deletion: {{value0}}', - { value0: count } - )} -
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.592fbab446', - 'Sorted by oldest activity' - )} -
-
- - {candidates.map((candidate, index) => ( - - ))} - -
- - - {!deleting ? ( - - ) : null} - - - ) -} - -function ConfirmRemoveRow({ - candidate, - reviewInfo, - last -}: { - candidate: WorkspaceCleanupCandidate - reviewInfo: WorkspaceCleanupReviewInfo - last: boolean -}): React.JSX.Element { - const dirtyLabel = getDirtyGitLabel(candidate) - const branchDiffersFromName = candidate.branch !== candidate.displayName - const contextPillLabel = getContextPillLabel(candidate) - const showGitMetadataChip = shouldShowGitMetadataChip(candidate) - const status = getCandidateStatus(candidate) - return ( -
-
- {candidate.displayName} - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc', - 'Last active' - )}{' '} - {formatRelativeTime(candidate.lastActivityAt)} - - {status.label} - {reviewInfo.label ? ( - {reviewInfo.label} - ) : null} - {contextPillLabel ? {contextPillLabel} : null} - {dirtyLabel && showGitMetadataChip ? ( - {dirtyLabel} - ) : null} -
-
- {candidate.repoName} - {branchDiffersFromName ? ( - <> - - {candidate.branch} - - ) : null} -
-
- {candidate.path} -
-
- ) -} - -function hasActiveWorkspaceCleanupFilters(filters: WorkspaceCleanupFilters): boolean { - return ( - filters.query.trim() !== '' || - filters.time !== 'all' || - filters.review !== 'all' || - filters.git !== 'all' || - filters.context !== 'all' - ) -} - -function hasActiveWorkspaceCleanupPanelControls( - filters: WorkspaceCleanupFilters, - sortKey: WorkspaceCleanupSortKey, - sortDirection: WorkspaceCleanupSortDirection -): boolean { - return ( - filters.time !== 'all' || - filters.review !== 'all' || - filters.git !== 'all' || - filters.context !== 'all' || - sortKey !== 'activity' || - sortDirection !== 'asc' - ) -} - -function formatWorkspaceCleanupRemovalProgress(progress: WorkspaceCleanupRemovalProgress): string { - const deletedText = translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4c2990886e', - '{{value0}}/{{value1}} deleted', - { - value0: progress.removedCount, - value1: progress.totalCount - } - ) - if (progress.failedCount === 0) { - return deletedText - } - return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.86ba852118', - '{{value0}}, {{value1}} failed', - { - value0: deletedText, - value1: progress.failedCount - } - ) -} - -function formatWorkspaceCleanupProgress(progress: WorkspaceCleanupScanProgress | null): string { - if (!progress || progress.scannedWorktreeCount === 0) { - return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe', - 'Finding inactive workspaces...' - ) - } - return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181', - 'Checked workspaces so far: {{value0}}', - { - value0: progress.scannedWorktreeCount - } - ) -} - -function SkeletonRows(): React.JSX.Element { - return ( -
- {[0, 1, 2].map((index) => ( -
- ))} -
- ) -} - -function EmptyState({ - title, - actionLabel, - onAction -}: { - title: string - actionLabel?: string - onAction?: () => void -}): React.JSX.Element { - return ( -
- {title} - {actionLabel && onAction ? ( - - ) : null} -
- ) -} - -function toggleSetMember(current: Set, value: string): Set { - const next = new Set(current) - if (next.has(value)) { - next.delete(value) - } else { - next.add(value) - } - return next -} diff --git a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.test.ts b/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.test.ts deleted file mode 100644 index 0cb2b0329d0..00000000000 --- a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Repo } from '../../../../shared/repo-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { countEstimatedInactiveWorkspaces } from './inactive-workspace-estimate' - -const NOW = 1_800_000_000_000 -const DAY_MS = 24 * 60 * 60 * 1000 - -const GIT_REPO = { id: 'repo-1', path: '/repo', displayName: 'Repo' } as Repo -const FOLDER_REPO = { - id: 'folder-1', - path: '/folder', - displayName: 'Folder', - kind: 'folder' -} as Repo - -const REPOS = new Map([ - [GIT_REPO.id, GIT_REPO], - [FOLDER_REPO.id, FOLDER_REPO] -]) - -function makeWorktree(overrides: Partial = {}): Worktree { - return { - id: 'repo-1::/repo-feature', - repoId: 'repo-1', - path: '/repo-feature', - isMainWorktree: false, - isArchived: false, - lastActivityAt: NOW - 90 * DAY_MS, - ...overrides - } as Worktree -} - -describe('countEstimatedInactiveWorkspaces', () => { - it('counts workspaces idle past the cleanup threshold', () => { - expect(countEstimatedInactiveWorkspaces([makeWorktree()], REPOS, NOW)).toBe(1) - }) - - it('does not count a missing activity stamp as ancient', () => { - const worktrees = [ - makeWorktree({ lastActivityAt: 0 }), - makeWorktree({ lastActivityAt: Number.NaN }) - ] - - expect(countEstimatedInactiveWorkspaces(worktrees, REPOS, NOW)).toBe(0) - }) - - it('falls back to the creation stamp when activity was never recorded', () => { - const recent = makeWorktree({ lastActivityAt: 0, createdAt: NOW - DAY_MS }) - const old = makeWorktree({ lastActivityAt: 0, createdAt: NOW - 90 * DAY_MS }) - - expect(countEstimatedInactiveWorkspaces([recent, old], REPOS, NOW)).toBe(1) - }) - - it('skips main worktrees, folder workspaces, and unknown repos', () => { - const worktrees = [ - makeWorktree({ isMainWorktree: true }), - makeWorktree({ repoId: FOLDER_REPO.id }), - makeWorktree({ repoId: 'gone' }) - ] - - expect(countEstimatedInactiveWorkspaces(worktrees, REPOS, NOW)).toBe(0) - }) - - it('uses the shorter archived threshold', () => { - const worktree = makeWorktree({ isArchived: true, lastActivityAt: NOW - 10 * DAY_MS }) - - expect(countEstimatedInactiveWorkspaces([worktree], REPOS, NOW)).toBe(1) - }) -}) diff --git a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts b/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts deleted file mode 100644 index 23524b5e904..00000000000 --- a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Repo } from '../../../../shared/repo-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { isFolderRepo } from '../../../../shared/repo-kind' -import { - getPersistedWorkspaceCleanupActivityAt, - isWorkspaceOldForCleanup -} from '../../../../shared/workspace-cleanup' - -/** - * Renderer-side estimate of how many workspaces the cleanup scan would list. It - * sees only Orca's persisted activity record, so it disagrees with the scan by - * design — the scan additionally reads each worktree's git history and cannot - * inspect disconnected remotes. - */ -export function countEstimatedInactiveWorkspaces( - worktrees: readonly Worktree[], - repoById: ReadonlyMap, - now: number -): number { - let count = 0 - for (const worktree of worktrees) { - const repo = repoById.get(worktree.repoId) - if (!repo || isFolderRepo(repo) || worktree.isMainWorktree) { - continue - } - // Why: an unstamped workspace (externally created, or never opened here) reads as - // epoch 0, which counted every one of them as idle and inflated the estimate far - // past what the scan lists. Unknown is not old. - const lastActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) - if ( - lastActivityAt > 0 && - isWorkspaceOldForCleanup({ isArchived: worktree.isArchived, lastActivityAt }, now) - ) { - count += 1 - } - } - return count -} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts new file mode 100644 index 00000000000..948928b9a93 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts @@ -0,0 +1,68 @@ +import { useCallback } from 'react' +import { useAppStore } from '@/store' +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState, + type WorkspaceCleanupSortField, + type WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' + +export type WorkspaceCleanupBrowseController = { + filters: WorkspaceCleanupFilterState + sort: WorkspaceCleanupSortState + patchFilters: ( + key: K, + value: Partial | WorkspaceCleanupFilterState[K] + ) => void + toggleSortField: (field: WorkspaceCleanupSortField) => void + clearFilters: () => void +} + +/** + * Single seam over the persisted browse slice so reopening the dialog keeps the + * user's filters and sort. + */ +export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseController { + const browse = useAppStore((s) => s.workspaceCleanupBrowse) + const updateBrowse = useAppStore((s) => s.updateWorkspaceCleanupBrowseState) + + const patchFilters = useCallback( + (key, value) => { + const current = browse.filters[key] + const next = + typeof current === 'object' && current !== null + ? { ...current, ...(value as object) } + : value + // Cast: a computed key over a union widens the spread result past + // WorkspaceCleanupFilterState even though `key` is constrained to it. + const filters = { ...browse.filters, [key]: next } as WorkspaceCleanupFilterState + updateBrowse({ ...browse, filters }) + }, + [browse, updateBrowse] + ) + + // Why: re-picking the active sort flips its direction. + const toggleSortField = useCallback( + (field: WorkspaceCleanupSortField) => { + const direction = + browse.sort.field === field && browse.sort.direction === 'asc' ? 'desc' : 'asc' + updateBrowse({ ...browse, sort: { field, direction } }) + }, + [browse, updateBrowse] + ) + + const clearFilters = useCallback(() => { + updateBrowse({ + ...browse, + filters: createDefaultWorkspaceCleanupFilterState() + }) + }, [browse, updateBrowse]) + + return { + filters: browse.filters, + sort: browse.sort, + patchFilters, + toggleSortField, + clearFilters + } +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts new file mode 100644 index 00000000000..194ed95240b --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react' +import { useAppStore } from '@/store' +import { + useWorkspaceCleanupRemoval, + type WorkspaceCleanupRemovalController +} from './use-workspace-cleanup-removal' +import { useWorkspaceCleanupScanLifecycle } from './use-workspace-cleanup-scan-lifecycle' + +const WORKSPACE_CLEANUP_CLOSE_LINGER_MS = 300 + +export type WorkspaceCleanupDialogLifecycle = { + open: boolean + mountedContent: boolean + loading: boolean + closeModal: () => void + selectedIds: Set + setSelectedIds: Dispatch>> + removal: WorkspaceCleanupRemovalController + startWorkspaceCleanupScan: (options?: { notifyWhenReady?: boolean }) => void +} + +/** Keeps scan/removal ownership mounted while the closed dialog drops its projection tree. */ +export function useWorkspaceCleanupDialogLifecycle(): WorkspaceCleanupDialogLifecycle { + const activeModal = useAppStore((s) => s.activeModal) + const closeModal = useAppStore((s) => s.closeModal) + const loading = useAppStore((s) => s.workspaceCleanupLoading) + const open = activeModal === 'workspace-cleanup' + const [selectedIds, setSelectedIds] = useState>(() => new Set()) + + const deselectRemovedIds = useCallback((removedIds: readonly string[]) => { + if (removedIds.length === 0) { + return + } + setSelectedIds((current) => { + const next = new Set(current) + for (const id of removedIds) { + next.delete(id) + } + return next + }) + }, []) + const removal = useWorkspaceCleanupRemoval({ onDeselect: deselectRemovedIds, closeModal }) + const { removalInFlightRef, resetForReopen, resetRowFailures } = removal + + const onFreshOpen = useCallback(() => { + if (!removalInFlightRef.current) { + resetForReopen() + setSelectedIds(new Set()) + } + }, [removalInFlightRef, resetForReopen]) + + const { startWorkspaceCleanupScan } = useWorkspaceCleanupScanLifecycle({ + open, + loading, + removalInFlight: removal.removalInFlight, + removalInFlightRef, + resetRowFailures, + onFreshOpen + }) + const [mountedContent, setMountedContent] = useState(open) + useEffect(() => { + if (open) { + setMountedContent(true) + return + } + const timer = window.setTimeout( + () => setMountedContent(false), + WORKSPACE_CLEANUP_CLOSE_LINGER_MS + ) + return () => window.clearTimeout(timer) + }, [open]) + + return { + open, + mountedContent, + loading, + closeModal, + selectedIds, + setSelectedIds, + removal, + startWorkspaceCleanupScan + } +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx new file mode 100644 index 00000000000..413493216ad --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx @@ -0,0 +1,298 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { + createDefaultWorkspaceCleanupFilterState, + DEFAULT_WORKSPACE_CLEANUP_SORT +} from '../../../../shared/workspace-cleanup-filter-model' +import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' +import type * as FacetModule from './workspace-cleanup-facets' +import type * as QueryModule from './workspace-cleanup-query' +import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture' + +const holders = vi.hoisted(() => ({ state: null as AppState | null })) +const counts = vi.hoisted(() => ({ facetCounts: 0, measured: 0, queries: 0 })) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: AppState) => T): T => { + if (!holders.state) { + throw new Error('Missing test state') + } + return selector(holders.state) + } +})) + +vi.mock('./workspace-cleanup-facets', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + countWorkspaceCleanupMeasuredRows: ( + ...args: Parameters + ) => { + counts.measured += 1 + return actual.countWorkspaceCleanupMeasuredRows(...args) + } + } +}) + +vi.mock('./workspace-cleanup-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + countWorkspaceCleanupFacetMatches: ( + ...args: Parameters + ) => { + counts.facetCounts += 1 + return actual.countWorkspaceCleanupFacetMatches(...args) + }, + runWorkspaceCleanupQuery: (...args: Parameters) => { + counts.queries += 1 + return actual.runWorkspaceCleanupQuery(...args) + } + } +}) + +import { useWorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows' + +function makeState(): AppState { + return { + worktreesByRepo: {}, + hostedReviewCache: {}, + repos: [], + settings: {}, + workspaceStatuses: cloneDefaultWorkspaceStatuses(), + lastVisitedAtByWorktreeId: {}, + agentStatusByPaneKey: {}, + tabsByWorktree: {}, + workspaceCleanupDismissals: {}, + workspaceSpaceAnalysis: null, + workspaceSpaceMeasurements: [] + } as unknown as AppState +} + +describe('useWorkspaceCleanupFacetRows hot paths', () => { + beforeEach(() => { + holders.state = makeState() + counts.facetCounts = 0 + counts.measured = 0 + counts.queries = 0 + }) + + it('does only the query work when the user types', () => { + const candidates = [makeFacetCandidate()] + const filters = createDefaultWorkspaceCleanupFilterState() + const view = renderHook( + ({ currentFilters }) => + useWorkspaceCleanupFacetRows({ + candidates, + filters: currentFilters, + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }), + { initialProps: { currentFilters: filters } } + ) + const initialCounts = { ...counts } + const reviewIndex = view.result.current.reviewInfoByWorktreeId + + view.rerender({ currentFilters: { ...filters, query: 'alpha' } }) + + expect(counts.queries).toBeGreaterThan(initialCounts.queries) + expect(counts.facetCounts).toBe(initialCounts.facetCounts) + expect(counts.measured).toBe(initialCounts.measured) + expect(view.result.current.reviewInfoByWorktreeId).toBe(reviewIndex) + }) + + it('keeps review joins stable during unrelated agent-status churn', () => { + const candidates = [makeFacetCandidate()] + const filters = createDefaultWorkspaceCleanupFilterState() + const view = renderHook(() => + useWorkspaceCleanupFacetRows({ + candidates, + filters, + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }) + ) + const reviewIndex = view.result.current.reviewInfoByWorktreeId + + holders.state = { ...holders.state!, agentStatusByPaneKey: {} } + view.rerender() + + expect(view.result.current.reviewInfoByWorktreeId).toBe(reviewIndex) + }) + + it('does not rebuild facets for count-only size progress', () => { + const candidates = [makeFacetCandidate()] + const filters = createDefaultWorkspaceCleanupFilterState() + const view = renderHook(() => + useWorkspaceCleanupFacetRows({ + candidates, + filters, + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }) + ) + const initialCounts = { ...counts } + + holders.state = { + ...holders.state!, + workspaceSpaceScanProgress: { + scanId: 'scan-1', + state: 'running', + startedAt: 1, + updatedAt: 2, + totalRepoCount: 1, + scannedRepoCount: 0, + totalWorktreeCount: 100, + scannedWorktreeCount: 20, + currentRepoDisplayName: 'Repo', + currentWorktreeDisplayName: 'alpha' + } + } + view.rerender() + + expect(counts).toEqual(initialCounts) + }) + + it('projects streamed size measurements before the full scan completes', () => { + const candidates = [makeFacetCandidate()] + const filters = createDefaultWorkspaceCleanupFilterState() + const view = renderHook(() => + useWorkspaceCleanupFacetRows({ + candidates, + filters, + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }) + ) + expect(view.result.current.rows[0]?.sizeBytes).toBeNull() + + holders.state = { + ...holders.state!, + workspaceSpaceMeasurements: [ + { worktreeId: candidates[0]!.worktreeId, status: 'ok', sizeBytes: 4_096 } + ] + } + view.rerender() + + expect(view.result.current.rows[0]?.sizeBytes).toBe(4_096) + expect(view.result.current.measuredSizeCount).toBe(1) + }) + + it('includes folder repositories discovered from cleanup candidates', () => { + const candidate = makeFacetCandidate({ repoId: 'folder-1', repoName: 'Loose files' }) + const view = renderHook(() => + useWorkspaceCleanupFacetRows({ + candidates: [candidate], + filters: createDefaultWorkspaceCleanupFilterState(), + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }) + ) + + expect(view.result.current.options.repos).toEqual([{ id: 'folder-1', label: 'Loose files' }]) + }) + + it('skips every downstream pass when a streaming tick changes no candidate', () => { + const candidates = [makeFacetCandidate(), makeFacetCandidate({ worktreeId: 'repo-1::/b' })] + const filters = createDefaultWorkspaceCleanupFilterState() + const view = renderHook( + ({ current }) => + useWorkspaceCleanupFacetRows({ + candidates: current, + filters, + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }), + { initialProps: { current: candidates } } + ) + const initialCounts = { ...counts } + const matched = view.result.current.facetMatchedWorktreeIds + const rows = view.result.current.rows + + // A no-op progress tick delivers a fresh array of the same candidate objects. + view.rerender({ current: [...candidates] }) + + expect(counts).toEqual(initialCounts) + expect(view.result.current.rows).toBe(rows) + expect(view.result.current.facetMatchedWorktreeIds).toBe(matched) + }) + + it('keeps facet identity for untouched rows when a tick replaces one candidate', () => { + const stable = makeFacetCandidate() + const replaced = makeFacetCandidate({ worktreeId: 'repo-1::/b' }) + const filters = createDefaultWorkspaceCleanupFilterState() + const view = renderHook( + ({ current }) => + useWorkspaceCleanupFacetRows({ + candidates: current, + filters, + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }), + { initialProps: { current: [stable, replaced] } } + ) + const stableFacet = view.result.current.rows.find((row) => row.worktreeId === stable.worktreeId) + + view.rerender({ + current: [stable, makeFacetCandidate({ worktreeId: 'repo-1::/b', displayName: 'renamed' })] + }) + + expect(view.result.current.rows.find((row) => row.worktreeId === stable.worktreeId)).toBe( + stableFacet + ) + expect( + view.result.current.rows.find((row) => row.worktreeId === 'repo-1::/b')?.displayName + ).toBe('renamed') + }) + + it('skips facet count and option passes while the filter panel is closed', () => { + const view = renderHook(() => + useWorkspaceCleanupFacetRows({ + candidates: [makeFacetCandidate()], + filters: createDefaultWorkspaceCleanupFilterState(), + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000, + facetPanelOpen: false + }) + ) + + expect(counts.facetCounts).toBe(0) + expect(view.result.current.options.repos).toEqual([]) + }) + + it('projects same-id streamed sizes only onto their owning hosts', () => { + const worktreeId = 'repo-1::/repo/alpha' + const candidates = [ + makeFacetCandidate({ worktreeId, displayName: 'local', executionHostId: 'local' }), + makeFacetCandidate({ + worktreeId, + displayName: 'remote', + connectionId: 'builder', + executionHostId: 'ssh:builder' + }) + ] + holders.state = { + ...holders.state!, + workspaceSpaceMeasurements: [ + { worktreeId, executionHostId: 'ssh:builder', status: 'ok', sizeBytes: 4_096 } + ] + } + const view = renderHook(() => + useWorkspaceCleanupFacetRows({ + candidates, + filters: createDefaultWorkspaceCleanupFilterState(), + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }) + ) + + expect( + view.result.current.rows.find((row) => row.displayName === 'local')?.sizeBytes + ).toBeNull() + expect(view.result.current.rows.find((row) => row.displayName === 'remote')?.sizeBytes).toBe( + 4_096 + ) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts new file mode 100644 index 00000000000..b957f715eaa --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts @@ -0,0 +1,289 @@ +import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' +import type { HostedReviewProvider } from '../../../../shared/hosted-review' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { + WorkspaceCleanupFilterState, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' +import { + countWorkspaceCleanupMeasuredRows, + type WorkspaceCleanupFacets +} from './workspace-cleanup-facets' +import { + countWorkspaceCleanupFacetMatches, + filterWorkspaceCleanupFacets, + runWorkspaceCleanupQuery +} from './workspace-cleanup-query' +import { + buildWorkspaceCleanupReviewLookup, + type WorkspaceCleanupReviewInfo +} from './workspace-cleanup-presentation' +import type { + WorkspaceCleanupFacetCounts, + WorkspaceCleanupFacetOptions +} from './workspace-cleanup-facet-panel-model' +import { + buildWorkspaceCleanupSizeIndex, + buildWorkspaceCleanupWorktreeIndex, + countWorkspaceCleanupCandidateIds +} from './workspace-cleanup-host-identity' +import { + computeWorkspaceCleanupFacetList, + computeWorkspaceCleanupReviewInfoIndex, + createWorkspaceCleanupFacetListCache, + createWorkspaceCleanupReviewInfoCache +} from './workspace-cleanup-facet-row-caches' + +export type WorkspaceCleanupFacetRows = { + rows: WorkspaceCleanupFacets[] + selectableWorktreeIds: string[] + facetMatchedWorktreeIds: ReadonlySet + matchedCount: number + totalCount: number + facetCounts: WorkspaceCleanupFacetCounts + options: WorkspaceCleanupFacetOptions + reviewInfoByWorktreeId: ReadonlyMap + sizeByWorktreeId: ReadonlyMap + /** 0 means the workspace-space scan has never produced a usable size. */ + measuredSizeCount: number + unmeasuredSizeCount: number +} + +const EMPTY_FACET_COUNTS: WorkspaceCleanupFacetCounts = Object.freeze({ + activity: 0, + size: 0, + status: 0, + agent: 0, + git: 0, + review: 0, + ticket: 0, + context: 0, + location: 0, + safety: 0 +}) + +const EMPTY_FACET_OPTIONS: WorkspaceCleanupFacetOptions = Object.freeze({ + workspaceStatuses: Object.freeze([]), + hostIds: Object.freeze([]), + repos: Object.freeze([]), + reviewProviders: Object.freeze([]) +}) + +/** + * Joins the scan rows with everything the flat list filters and sorts on: + * renderer-only signals (visits, live agents, review cache, dismissals) plus + * sizes from the EXISTING workspace-space scan — no second scanner. + * + * Per-candidate work is cached on candidate object identity (see + * workspace-cleanup-facet-row-caches) so streaming ticks touch only changed + * rows and no-op ticks skip every downstream pass. + */ +export function useWorkspaceCleanupFacetRows({ + candidates, + filters, + sort, + now, + facetPanelOpen = true +}: { + candidates: readonly WorkspaceCleanupCandidate[] + filters: WorkspaceCleanupFilterState + sort: WorkspaceCleanupSortState + now: number + /** Facet counts/options are only rendered inside the filter popover; pass + * false while it is closed to skip their O(N) passes entirely. */ + facetPanelOpen?: boolean +}): WorkspaceCleanupFacetRows { + const sources = useAppStore( + useShallow((s) => ({ + worktreesByRepo: s.worktreesByRepo, + hostedReviewCache: s.hostedReviewCache, + repos: s.repos, + settings: s.settings, + // Statuses are a top-level UI-slice field, not part of GlobalSettings. + workspaceStatuses: s.workspaceStatuses, + lastVisitedAtByWorktreeId: s.lastVisitedAtByWorktreeId, + agentStatusByPaneKey: s.agentStatusByPaneKey, + tabsByWorktree: s.tabsByWorktree, + dismissals: s.workspaceCleanupDismissals, + spaceWorktrees: s.workspaceSpaceAnalysis?.worktrees ?? null, + spaceMeasurements: s.workspaceSpaceMeasurements + })) + ) + const { hostedReviewCache, repos, settings, worktreesByRepo } = sources + const candidateIdCounts = useMemo( + () => countWorkspaceCleanupCandidateIds(candidates), + [candidates] + ) + // Why: each per-candidate cache lives in one memo together with the derived + // context it is keyed on — the memo deps ARE the cache invalidation, no refs + // (ref writes during render are unsafe under concurrent rendering), and + // interior fills are content-addressed and idempotent. + const reviewContext = useMemo( + () => ({ + sources: { hostedReviewCache, repos, settings, worktreesByRepo }, + lookup: buildWorkspaceCleanupReviewLookup({ repos, worktreesByRepo }), + cache: createWorkspaceCleanupReviewInfoCache() + }), + [hostedReviewCache, repos, settings, worktreesByRepo] + ) + const facetContext = useMemo( + () => ({ + worktreeById: buildWorkspaceCleanupWorktreeIndex(worktreesByRepo, repos), + workspaceStatuses: sources.workspaceStatuses, + cache: createWorkspaceCleanupFacetListCache() + }), + [repos, sources.workspaceStatuses, worktreesByRepo] + ) + const liveAgentStatusByWorktreeId = useMemo( + () => getLiveAgentStatusByWorktreeId(sources.agentStatusByPaneKey, sources.tabsByWorktree, now), + [now, sources.agentStatusByPaneKey, sources.tabsByWorktree] + ) + const dismissedWorktreeIds = useMemo( + () => new Set(Object.keys(sources.dismissals)), + [sources.dismissals] + ) + + const reviewInfoByWorktreeId = useMemo( + () => + computeWorkspaceCleanupReviewInfoIndex({ + candidates, + candidateIdCounts, + reviewSources: reviewContext.sources, + reviewLookup: reviewContext.lookup, + cache: reviewContext.cache + }), + [candidateIdCounts, candidates, reviewContext] + ) + + const completedSizeByWorktreeId = useMemo( + () => buildWorkspaceCleanupSizeIndex(sources.spaceWorktrees, candidates), + [candidates, sources.spaceWorktrees] + ) + const sizeByWorktreeId = useMemo(() => { + if (sources.spaceMeasurements.length === 0) { + return completedSizeByWorktreeId + } + return new Map([ + ...completedSizeByWorktreeId, + ...buildWorkspaceCleanupSizeIndex(sources.spaceMeasurements, candidates) + ]) + }, [candidates, completedSizeByWorktreeId, sources.spaceMeasurements]) + + const facets = useMemo( + () => + computeWorkspaceCleanupFacetList({ + candidates, + sources: { + worktreeById: facetContext.worktreeById, + workspaceStatuses: facetContext.workspaceStatuses, + sizeBytesByWorktreeId: sizeByWorktreeId, + lastVisitedAtByWorktreeId: sources.lastVisitedAtByWorktreeId, + liveAgentStatusByWorktreeId, + reviewInfoByWorktreeId, + dismissedWorktreeIds + }, + cache: facetContext.cache + }), + [ + candidates, + dismissedWorktreeIds, + facetContext, + liveAgentStatusByWorktreeId, + reviewInfoByWorktreeId, + sizeByWorktreeId, + sources.lastVisitedAtByWorktreeId + ] + ) + + const result = useMemo( + () => runWorkspaceCleanupQuery(facets, { filters, sort }, now), + [facets, filters, now, sort] + ) + const facetFilters = useMemo( + () => ({ + query: '', + activity: filters.activity, + size: filters.size, + status: filters.status, + agent: filters.agent, + git: filters.git, + review: filters.review, + ticket: filters.ticket, + context: filters.context, + location: filters.location, + safety: filters.safety + }), + [ + filters.activity, + filters.agent, + filters.context, + filters.git, + filters.location, + filters.review, + filters.safety, + filters.size, + filters.status, + filters.ticket + ] + ) + const facetCounts = useMemo( + () => + facetPanelOpen + ? countWorkspaceCleanupFacetMatches(facets, facetFilters, now) + : EMPTY_FACET_COUNTS, + [facetFilters, facetPanelOpen, facets, now] + ) + // Identity churn here is harmless: the only consumer reads the latest set + // inside a useEffectEvent body and never keys an effect on it. + const facetMatchedWorktreeIds = useMemo>( + () => + new Set(filterWorkspaceCleanupFacets(facets, facetFilters, now).map((row) => row.worktreeId)), + [facetFilters, facets, now] + ) + const measuredSizeCount = useMemo(() => countWorkspaceCleanupMeasuredRows(facets), [facets]) + const unmeasuredSizeCount = facets.length - measuredSizeCount + + const options = useMemo(() => { + if (!facetPanelOpen) { + return EMPTY_FACET_OPTIONS + } + const repoLabels = new Map() + for (const row of facets) { + if (!repoLabels.has(row.repoId)) { + repoLabels.set(row.repoId, row.repoName) + } + } + return { + workspaceStatuses: sources.workspaceStatuses.map((status) => ({ + id: status.id, + label: status.label + })), + hostIds: [...new Set(facets.map((row) => row.hostId))].sort(), + repos: [...repoLabels].map(([id, label]) => ({ id, label })), + reviewProviders: [ + ...new Set( + facets + .map((row) => row.review.provider) + .filter((provider): provider is HostedReviewProvider => provider !== null) + ) + ].sort() + } + }, [facetPanelOpen, facets, sources.workspaceStatuses]) + + return { + rows: result.rows, + selectableWorktreeIds: result.selectableWorktreeIds, + facetMatchedWorktreeIds, + matchedCount: result.matchedCount, + totalCount: result.totalCount, + facetCounts, + options, + reviewInfoByWorktreeId, + sizeByWorktreeId, + measuredSizeCount, + unmeasuredSizeCount + } +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx new file mode 100644 index 00000000000..c4cf958bbcf --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WorkspaceCleanupScanProgress, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' +import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture' +import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evidence' + +const holders = vi.hoisted(() => ({ cancelScan: vi.fn(), scan: vi.fn() })) + +type PendingScan = { + worktreeIds: string[] + onProgress?: (progress: WorkspaceCleanupScanProgress) => void + resolve: (result: WorkspaceCleanupScanResult) => void +} + +function deferredCandidate(worktreeId: string) { + return makeFacetCandidate({ + worktreeId, + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) +} + +describe('useWorkspaceCleanupGitEvidence', () => { + let pending: PendingScan[] + + beforeEach(() => { + pending = [] + holders.scan.mockReset() + holders.cancelScan.mockReset().mockResolvedValue(true) + holders.scan.mockImplementation( + ( + { worktreeIds }: { worktreeIds: string[] }, + onProgress?: (progress: WorkspaceCleanupScanProgress) => void + ) => { + return new Promise((resolve) => { + pending.push({ worktreeIds, onProgress, resolve }) + }) + } + ) + ;(window as unknown as { api: unknown }).api = { + workspaceCleanup: { + cancelScan: holders.cancelScan, + scan: holders.scan + } + } + }) + + it('bounds restarted passes and isolates them from stale settlements', async () => { + const candidates = ['a', 'b', 'c', 'd', 'e', 'f'].map(deferredCandidate) + const view = renderHook( + ({ enabled }: { enabled: boolean }) => + useWorkspaceCleanupGitEvidence({ enabled, candidates, scannedAt: 1 }), + { initialProps: { enabled: true } } + ) + + await waitFor(() => expect(view.result.current.totalCount).toBe(6)) + expect(view.result.current.pendingWorktreeIds.size).toBe(6) + expect(holders.scan).toHaveBeenCalledTimes(1) + expect(holders.scan).toHaveBeenCalledWith( + { + worktreeIds: ['a', 'b', 'c', 'd', 'e', 'f'], + scanId: expect.any(String) + }, + expect.any(Function) + ) + + view.rerender({ enabled: false }) + await waitFor(() => expect(view.result.current.pendingWorktreeIds.size).toBe(0)) + expect(holders.cancelScan).toHaveBeenCalledWith(expect.any(String)) + view.rerender({ enabled: true }) + await waitFor(() => expect(view.result.current.pendingWorktreeIds.size).toBe(6)) + expect(holders.scan).toHaveBeenCalledTimes(1) + expect(view.result.current.pendingWorktreeIds.size).toBe(6) + + await act(async () => { + pending[0]?.resolve({ + scannedAt: 2, + candidates: [makeFacetCandidate({ worktreeId: 'a' })], + errors: [] + }) + }) + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2)) + expect(view.result.current.pendingWorktreeIds.size).toBe(6) + expect(view.result.current.evidenceByWorktreeId.size).toBe(0) + + await act(async () => { + pending[1]?.onProgress?.({ + scanId: 'batch-2', + scannedAt: 3, + scannedWorktreeCount: 1, + totalWorktreeCount: 6, + candidates: [makeFacetCandidate({ worktreeId: 'a' })], + errors: [], + candidateMode: 'append' + }) + }) + await waitFor(() => expect(view.result.current.evidenceByWorktreeId.size).toBe(1)) + expect(view.result.current.pendingWorktreeIds.size).toBe(5) + }) + + it('chunks dispatches at the shared target batch limit so no queued id is dropped', async () => { + const first = Array.from({ length: 300 }, (_, i) => deferredCandidate(`a${i}`)) + const grown = [...first, ...Array.from({ length: 600 }, (_, i) => deferredCandidate(`b${i}`))] + const view = renderHook( + ({ candidates }: { candidates: ReturnType[] }) => + useWorkspaceCleanupGitEvidence({ enabled: true, candidates, scannedAt: 1 }), + { initialProps: { candidates: first } } + ) + + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(1)) + expect(pending[0]?.worktreeIds).toHaveLength(300) + + // 600 more ids queue while the first request is in flight. + view.rerender({ candidates: grown }) + await waitFor(() => expect(view.result.current.totalCount).toBe(900)) + expect(holders.scan).toHaveBeenCalledTimes(1) + + await act(async () => { + pending[0]?.resolve({ scannedAt: 1, candidates: [], errors: [] }) + }) + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2)) + expect(pending[1]?.worktreeIds).toHaveLength(500) + + await act(async () => { + pending[1]?.resolve({ scannedAt: 1, candidates: [], errors: [] }) + }) + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(3)) + expect(pending[2]?.worktreeIds).toHaveLength(100) + + const dispatched = pending.flatMap((request) => request.worktreeIds) + expect(new Set(dispatched).size).toBe(900) + }) + + it('keeps state identity stable when a progress frame changes nothing', async () => { + const candidates = [deferredCandidate('a')] + const view = renderHook(() => + useWorkspaceCleanupGitEvidence({ enabled: true, candidates, scannedAt: 1 }) + ) + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(1)) + const before = view.result.current + + await act(async () => { + pending[0]?.onProgress?.({ + scanId: 'noop', + scannedAt: 1, + scannedWorktreeCount: 0, + totalWorktreeCount: 1, + candidates: [makeFacetCandidate({ worktreeId: 'unrelated' })], + errors: [], + candidateMode: 'append' + }) + }) + expect(view.result.current).toBe(before) + }) + + it('restarts evidence collection when the settled scan snapshot changes', async () => { + const candidates = [deferredCandidate('a')] + const view = renderHook( + ({ scannedAt }: { scannedAt: number }) => + useWorkspaceCleanupGitEvidence({ enabled: true, candidates, scannedAt }), + { initialProps: { scannedAt: 1 } } + ) + + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(1)) + view.rerender({ scannedAt: 2 }) + expect(holders.scan).toHaveBeenCalledTimes(1) + + await act(async () => { + pending[0]?.resolve({ + scannedAt: 1, + candidates: [makeFacetCandidate({ worktreeId: 'a' })], + errors: [] + }) + }) + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2)) + expect(view.result.current.evidenceByWorktreeId.size).toBe(0) + expect(view.result.current.pendingWorktreeIds).toEqual(new Set(['a'])) + + await act(async () => { + pending[1]?.resolve({ + scannedAt: 2, + candidates: [makeFacetCandidate({ worktreeId: 'a' })], + errors: [] + }) + }) + await waitFor(() => expect(view.result.current.evidenceByWorktreeId.size).toBe(1)) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts new file mode 100644 index 00000000000..95377c8a5de --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useMountedRef } from '@/hooks/useMountedRef' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + selectWorkspaceCleanupGitEvidenceTargets, + WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS +} from './workspace-cleanup-git-evidence' + +export type WorkspaceCleanupGitEvidenceState = { + /** Focused re-scan results, keyed by worktree id. */ + evidenceByWorktreeId: ReadonlyMap + pendingWorktreeIds: ReadonlySet + checkedCount: number + totalCount: number +} + +const EMPTY_EVIDENCE: WorkspaceCleanupGitEvidenceState = { + evidenceByWorktreeId: new Map(), + pendingWorktreeIds: new Set(), + checkedCount: 0, + totalCount: 0 +} + +/** + * Fills in git evidence the broad scan deferred with one targeted batch scan. + * Only runs while a git-dependent filter or sort is active — the initial list + * render never waits on it, so an unfiltered browse stays instant. + */ +export function useWorkspaceCleanupGitEvidence({ + enabled, + candidates, + scannedAt +}: { + enabled: boolean + candidates: readonly WorkspaceCleanupCandidate[] + scannedAt: number | null +}): WorkspaceCleanupGitEvidenceState { + const mountedRef = useMountedRef() + const [state, setState] = useState(EMPTY_EVIDENCE) + const evidenceRef = useRef(new Map()) + const attemptedRef = useRef(new Set()) + const queueRef = useRef([]) + const queuedRef = useRef(new Set()) + const inFlightRef = useRef(new Set()) + // Why: restarted filters must share one physical RPC/subprocess cap. + const activeRequestWorktreeIdsRef = useRef(new Set()) + const activeScanIdRef = useRef(null) + const totalRef = useRef(0) + const enabledRef = useRef(enabled) + const scannedAtRef = useRef(scannedAt) + // Why: results that land after the user cleared the git filter belong to a + // stale pass and must not resurrect its pending banner. + const generationRef = useRef(0) + + // Why: streaming ticks call publish repeatedly; unchanged evidence must not + // mint new Map/Set identities or every subscriber re-renders per tick. + const publishDirtyRef = useRef(false) + const publish = useCallback(() => { + if (!mountedRef.current || !publishDirtyRef.current) { + return + } + publishDirtyRef.current = false + setState({ + evidenceByWorktreeId: new Map(evidenceRef.current), + pendingWorktreeIds: new Set([...queuedRef.current, ...inFlightRef.current]), + checkedCount: totalRef.current - queuedRef.current.size - inFlightRef.current.size, + totalCount: totalRef.current + }) + }, [mountedRef]) + + const pump = useCallback(() => { + if (activeRequestWorktreeIdsRef.current.size > 0 || queueRef.current.length === 0) { + publish() + return + } + const generation = generationRef.current + // Why: main truncates targeted scans at this shared limit; a larger request + // would silently drop the overflow ids while marking them attempted. + const worktreeIds = queueRef.current.slice(0, WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS) + queueRef.current = queueRef.current.slice(worktreeIds.length) + const scanId = crypto.randomUUID() + activeScanIdRef.current = scanId + for (const worktreeId of worktreeIds) { + queuedRef.current.delete(worktreeId) + inFlightRef.current.add(worktreeId) + activeRequestWorktreeIdsRef.current.add(worktreeId) + attemptedRef.current.add(worktreeId) + } + publishDirtyRef.current = true + const acceptCandidates = (nextCandidates: readonly WorkspaceCleanupCandidate[]): void => { + if (generation !== generationRef.current) { + return + } + for (const candidate of nextCandidates) { + if (!activeRequestWorktreeIdsRef.current.has(candidate.worktreeId)) { + continue + } + evidenceRef.current.set(candidate.worktreeId, candidate) + inFlightRef.current.delete(candidate.worktreeId) + publishDirtyRef.current = true + } + publish() + } + void window.api.workspaceCleanup + .scan({ worktreeIds, scanId }, (progress) => acceptCandidates(progress.candidates)) + .then((result) => acceptCandidates(result.candidates)) + .catch(() => { + // Why: a failed targeted scan leaves rows honestly "Not checked"; + // attemptedRef keeps it from retrying in a loop. + }) + .finally(() => { + if (activeScanIdRef.current === scanId) { + activeScanIdRef.current = null + } + for (const worktreeId of worktreeIds) { + activeRequestWorktreeIdsRef.current.delete(worktreeId) + if (generation === generationRef.current && inFlightRef.current.delete(worktreeId)) { + publishDirtyRef.current = true + } + } + pump() + }) + publish() + }, [publish]) + + useEffect(() => { + const wasEnabled = enabledRef.current + const snapshotChanged = scannedAtRef.current !== scannedAt + enabledRef.current = enabled + scannedAtRef.current = scannedAt + + if ((wasEnabled && !enabled) || (enabled && snapshotChanged)) { + generationRef.current += 1 + const activeScanId = activeScanIdRef.current + if (activeScanId) { + void window.api.workspaceCleanup.cancelScan?.(activeScanId) + } + queueRef.current = [] + queuedRef.current.clear() + inFlightRef.current.clear() + attemptedRef.current.clear() + evidenceRef.current.clear() + totalRef.current = 0 + publishDirtyRef.current = true + publish() + } + if (!enabled) { + return + } + // Why: the exclusion set must cover queued/in-flight ids too — a cap that + // counts them would permanently skip everything past it on a settled scan. + const targets = selectWorkspaceCleanupGitEvidenceTargets(candidates, { + resolvedWorktreeIds: new Set([ + ...attemptedRef.current, + ...queuedRef.current, + ...inFlightRef.current + ]), + maxTargets: Number.POSITIVE_INFINITY + }) + if (targets.length === 0) { + return + } + queueRef.current.push(...targets) + for (const target of targets) { + queuedRef.current.add(target) + } + totalRef.current += targets.length + publishDirtyRef.current = true + pump() + }, [candidates, enabled, pump, publish, scannedAt]) + + useEffect( + () => () => { + const activeScanId = activeScanIdRef.current + if (activeScanId) { + void window.api.workspaceCleanup.cancelScan?.(activeScanId) + } + }, + [] + ) + + return state +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts new file mode 100644 index 00000000000..2379be86965 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts @@ -0,0 +1,242 @@ +import { useCallback, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { useMountedRef } from '@/hooks/useMountedRef' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + startWorkspaceCleanupBackgroundRemoval, + type WorkspaceCleanupRemovalProgress +} from './workspace-cleanup-background-removal' +import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' + +export type WorkspaceCleanupRemovalController = { + confirming: boolean + confirmCandidates: WorkspaceCleanupCandidate[] + removalProgress: WorkspaceCleanupRemovalProgress | null + removalInFlight: boolean + /** Synchronous guard; state alone lags a second confirm click. */ + removalInFlightRef: { current: boolean } + rowFailures: Record + resetRowFailures: () => void + resetForReopen: () => void + openConfirmRemove: (candidates: readonly WorkspaceCleanupCandidate[]) => void + confirmRemove: () => void + cancelConfirmRemove: () => void + backToList: () => void +} + +/** + * Owns the destructive path: confirm gate, background batch, late settlement, + * and the queued-delete overlay each row shows in the sidebar. + */ +export function useWorkspaceCleanupRemoval({ + onDeselect, + closeModal +}: { + onDeselect: (removedIds: readonly string[]) => void + closeModal: () => void +}): WorkspaceCleanupRemovalController { + const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates) + const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion) + const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) + const mountedRef = useMountedRef() + + const [confirming, setConfirming] = useState(false) + const [confirmCandidates, setConfirmCandidates] = useState([]) + const [removalProgress, setRemovalProgress] = useState( + null + ) + // Why: `removalProgress` only arrives once the batch reports, so rendering + // needs its own in-flight flag; removalInFlightRef stays the synchronous guard. + const [removalInFlight, setRemovalInFlight] = useState(false) + const [rowFailures, setRowFailures] = useState>({}) + const removalInFlightRef = useRef(false) + // Why: the dialog stays mounted across cleanup runs, so late settlements from + // an earlier batch must not mutate a newer batch's row state. + const removalBatchIdRef = useRef(0) + + const resetRowFailures = useCallback(() => setRowFailures({}), []) + + const resetForReopen = useCallback(() => { + if (removalInFlightRef.current) { + return + } + setConfirming(false) + setRowFailures({}) + }, []) + + const clearQueuedDeleteState = useCallback( + (worktreeId: string) => { + const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId] + // Why: candidates that fail before removal starts would otherwise stay + // marked "Queued for deletion" in the sidebar; rows already in the + // 'deleting' phase or failed with an error keep their own state. + if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') { + clearWorktreeDeleteState(worktreeId) + } + }, + [clearWorktreeDeleteState] + ) + + const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { + const nextCandidates = filterWorkspaceCleanupRemovalCandidates( + candidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (nextCandidates.length === 0) { + return + } + setConfirmCandidates(nextCandidates) + setConfirming(true) + }, []) + + const cancelConfirmRemove = useCallback(() => { + if (removalProgress) { + closeModal() + return + } + setConfirming(false) + setConfirmCandidates([]) + }, [closeModal, removalProgress]) + + // Why: the header X reads as "leave this screen", not "abandon the dialog". The + // batch keeps running either way and the list shows each row's progress. + // Diverges from cancelConfirmRemove, which closes the dialog mid-batch: here + // removalProgress stays set until the batch settles, so re-entry stays blocked. + const backToList = useCallback(() => { + setConfirming(false) + setConfirmCandidates([]) + }, []) + + const settle = useCallback(() => { + setRemovalProgress(null) + setRemovalInFlight(false) + setConfirming(false) + setConfirmCandidates([]) + }, []) + + const confirmRemove = useCallback(() => { + if (confirmCandidates.length === 0 || removalInFlightRef.current) { + return + } + const removableCandidates = filterWorkspaceCleanupRemovalCandidates( + confirmCandidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (removableCandidates.length === 0) { + setConfirming(false) + setConfirmCandidates([]) + return + } + removalInFlightRef.current = true + setRemovalInFlight(true) + removalBatchIdRef.current += 1 + const removalBatchId = removalBatchIdRef.current + // Why: a hung late settlement retains these callbacks for the renderer's + // lifetime; capture only ids so it cannot pin the candidate objects. + const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId) + setRowFailures({}) + markWorktreesQueuedForDeletion(removableWorktreeIds) + const handleRemovalError = (): void => { + for (const worktreeId of removableWorktreeIds) { + clearWorktreeDeleteState(worktreeId) + } + if (mountedRef.current) { + settle() + } + removalInFlightRef.current = false + } + try { + const beginSnapshotPruneBatch = window.api.workspaceCleanup.beginRemovalSnapshotPruneBatch + const recordSnapshotPrune = window.api.workspaceCleanup.recordRemovalSnapshotPrune + const finishSnapshotPruneBatch = window.api.workspaceCleanup.finishRemovalSnapshotPruneBatch + const snapshotPruneBatch = + typeof beginSnapshotPruneBatch === 'function' && + typeof recordSnapshotPrune === 'function' && + typeof finishSnapshotPruneBatch === 'function' + ? (() => { + const batchId = crypto.randomUUID() + return { + batchId, + begin: () => beginSnapshotPruneBatch({ batchId }), + finish: () => finishSnapshotPruneBatch({ batchId }) + } + })() + : undefined + startWorkspaceCleanupBackgroundRemoval({ + candidates: removableCandidates, + removeCandidates, + snapshotPruneBatch, + onProgress: (progress) => { + if (mountedRef.current) { + setRemovalProgress(progress) + } + }, + onRowFailed: (failure) => { + clearQueuedDeleteState(failure.worktreeId) + }, + onResult: (result) => { + const nextFailures: Record = {} + for (const failure of result.failures) { + nextFailures[failure.worktreeId] = failure.message + // Why: defensively covers failures that never reached onRowFailed. + clearQueuedDeleteState(failure.worktreeId) + } + if (mountedRef.current) { + setRowFailures(nextFailures) + onDeselect(result.removedIds) + settle() + } + removalInFlightRef.current = false + }, + onLateResult: (result) => { + for (const failure of result.failures) { + // Why: a late failure can come from a hung preflight whose row never + // reached 'deleting'; clear its queued overlay like every other path. + clearQueuedDeleteState(failure.worktreeId) + } + if (!mountedRef.current || removalBatchIdRef.current !== removalBatchId) { + return + } + setRowFailures((current) => { + const next = { ...current } + for (const id of result.removedIds) { + delete next[id] + } + for (const failure of result.failures) { + next[failure.worktreeId] = failure.message + } + return next + }) + onDeselect(result.removedIds) + }, + onError: handleRemovalError + }) + } catch { + handleRemovalError() + } + }, [ + clearQueuedDeleteState, + clearWorktreeDeleteState, + confirmCandidates, + markWorktreesQueuedForDeletion, + mountedRef, + onDeselect, + removeCandidates, + settle + ]) + + return { + confirming, + confirmCandidates, + removalProgress, + removalInFlight, + removalInFlightRef, + rowFailures, + resetRowFailures, + resetForReopen, + openConfirmRemove, + confirmRemove, + cancelConfirmRemove, + backToList + } +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts new file mode 100644 index 00000000000..f2f04547bbc --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { + arrangeWorkspaceCleanupRowsByFrozenOrder, + createWorkspaceCleanupFrozenRowOrder, + extendWorkspaceCleanupFrozenRowOrder, + useWorkspaceCleanupRowOrder +} from './use-workspace-cleanup-row-order' +import type { WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model' + +type Row = { worktreeId: string } + +const row = (worktreeId: string): Row => ({ worktreeId }) +const ids = (rows: readonly Row[]): string[] => rows.map((entry) => entry.worktreeId) +const ascending: WorkspaceCleanupSortState = { field: 'name', direction: 'asc' } + +describe('workspace cleanup streaming row order', () => { + it('keeps existing rows in their frozen slots when streamed values would resort them', () => { + const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b'), row('c')], 'name:asc') + + // The live sort now says c < a < b (values changed mid-stream). + const arranged = arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('a'), row('b')], order) + + expect(ids(arranged)).toEqual(['a', 'b', 'c']) + }) + + it('appends unseen rows after the frozen ones and keeps them stable on later ticks', () => { + const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b')], 'name:asc') + + const firstRows = [row('a'), row('d'), row('b'), row('c')] + const firstTick = arrangeWorkspaceCleanupRowsByFrozenOrder(firstRows, order) + expect(ids(firstTick)).toEqual(['a', 'b', 'd', 'c']) + const extendedOrder = extendWorkspaceCleanupFrozenRowOrder(firstRows, order) + + // A later tick sorting d and c differently must not swap them anymore. + const secondTick = arrangeWorkspaceCleanupRowsByFrozenOrder( + [row('c'), row('d'), row('b'), row('a')], + extendedOrder + ) + expect(ids(secondTick)).toEqual(['a', 'b', 'd', 'c']) + expect(order.positions.has('d')).toBe(false) + }) + + it('holds slots for rows a filter temporarily hides', () => { + const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b'), row('c')], 'name:asc') + + expect(ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('a')], order))).toEqual([ + 'a', + 'c' + ]) + // Un-hiding b restores its original slot between a and c. + expect( + ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('b'), row('a')], order)) + ).toEqual(['a', 'b', 'c']) + }) + + it('grows the frozen order from empty during a cold-start stream', () => { + const order = createWorkspaceCleanupFrozenRowOrder([], 'name:asc') + const firstRows = [row('b'), row('d')] + + expect(ids(arrangeWorkspaceCleanupRowsByFrozenOrder(firstRows, order))).toEqual(['b', 'd']) + const extendedOrder = extendWorkspaceCleanupFrozenRowOrder(firstRows, order) + // New rows slot after the already-presented ones even when they sort earlier. + expect( + ids( + arrangeWorkspaceCleanupRowsByFrozenOrder( + [row('a'), row('b'), row('c'), row('d')], + extendedOrder + ) + ) + ).toEqual(['b', 'd', 'a', 'c']) + }) + + it('commits appended positions after each rendered stream tick', () => { + const view = renderHook( + ({ rows, streaming }: { rows: readonly Row[]; streaming: boolean }) => + useWorkspaceCleanupRowOrder({ rows, streaming, sort: ascending }), + { initialProps: { rows: [row('a'), row('b')], streaming: false } } + ) + + view.rerender({ rows: [row('a'), row('d'), row('b'), row('c')], streaming: true }) + expect(ids(view.result.current)).toEqual(['a', 'b', 'd', 'c']) + + view.rerender({ rows: [row('c'), row('d'), row('b'), row('a')], streaming: true }) + expect(ids(view.result.current)).toEqual(['a', 'b', 'd', 'c']) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.ts new file mode 100644 index 00000000000..ad530fa05ad --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.ts @@ -0,0 +1,123 @@ +import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' +import type { WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model' + +type OrderedRow = { worktreeId: string } + +export type WorkspaceCleanupFrozenRowOrder = { + sortSignature: string + positions: Map +} + +const UNCOMMITTED_ROW_ORDER: WorkspaceCleanupFrozenRowOrder = { + sortSignature: '', + positions: new Map() +} + +export function createWorkspaceCleanupFrozenRowOrder( + rows: readonly OrderedRow[], + sortSignature: string +): WorkspaceCleanupFrozenRowOrder { + return { + sortSignature, + positions: new Map(rows.map((row, index) => [row.worktreeId, index])) + } +} + +/** + * Rows already on screen keep their slot while a refresh streams updated + * values in; rows the order has not seen yet append after them (in the live + * sort among themselves) and claim the next slots, so later ticks cannot + * reshuffle them either. + */ +export function arrangeWorkspaceCleanupRowsByFrozenOrder( + sortedRows: readonly Row[], + order: WorkspaceCleanupFrozenRowOrder +): Row[] { + const known: Row[] = [] + const fresh: Row[] = [] + for (const row of sortedRows) { + if (order.positions.has(row.worktreeId)) { + known.push(row) + } else { + fresh.push(row) + } + } + known.sort( + (left, right) => + (order.positions.get(left.worktreeId) ?? 0) - (order.positions.get(right.worktreeId) ?? 0) + ) + return [...known, ...fresh] +} + +export function extendWorkspaceCleanupFrozenRowOrder( + rows: readonly OrderedRow[], + order: WorkspaceCleanupFrozenRowOrder +): WorkspaceCleanupFrozenRowOrder { + let positions: Map | null = null + for (const row of rows) { + if (order.positions.has(row.worktreeId)) { + continue + } + positions ??= new Map(order.positions) + if (!positions.has(row.worktreeId)) { + positions.set(row.worktreeId, positions.size) + } + } + return positions === null ? order : { ...order, positions } +} + +/** + * Streaming-order stability for the flat list: while a scan streams, existing + * rows must not reshuffle on every progress tick. The user's sort applies to + * the snapshot immediately; streamed value changes land in place; one calm + * re-sort happens when the scan settles — or at once if the user explicitly + * changes the sort mid-scan. + */ +export function useWorkspaceCleanupRowOrder({ + rows, + streaming, + sort +}: { + rows: readonly Row[] + streaming: boolean + sort: WorkspaceCleanupSortState +}): readonly Row[] { + // Why: the frozen order must be React-owned state — a ref read during render + // is not a valid memo input under concurrent rendering and could arrange the + // list from a different render pass's order. The idle capture stays in a ref + // because it is only read and written inside the commit effect. + const [frozenOrder, setFrozenOrder] = + useState(UNCOMMITTED_ROW_ORDER) + const idleOrderRef = useRef(UNCOMMITTED_ROW_ORDER) + const sortSignature = `${sort.field}:${sort.direction}` + const orderedRows = useMemo( + () => + !streaming || frozenOrder.sortSignature !== sortSignature + ? rows + : arrangeWorkspaceCleanupRowsByFrozenOrder(rows, frozenOrder), + [frozenOrder, rows, sortSignature, streaming] + ) + const commitFrozenOrder = useEffectEvent(() => { + // Why: only committed renders may advance the order a later stream holds. + if (!streaming) { + idleOrderRef.current = createWorkspaceCleanupFrozenRowOrder(rows, sortSignature) + setFrozenOrder((current) => + current === UNCOMMITTED_ROW_ORDER ? current : UNCOMMITTED_ROW_ORDER + ) + return + } + setFrozenOrder((current) => { + const base = + current !== UNCOMMITTED_ROW_ORDER && current.sortSignature === sortSignature + ? current + : idleOrderRef.current.sortSignature === sortSignature + ? idleOrderRef.current + : createWorkspaceCleanupFrozenRowOrder(rows, sortSignature) + return extendWorkspaceCleanupFrozenRowOrder(rows, base) + }) + }) + useEffect(() => { + commitFrozenOrder() + }, [rows, sortSignature, streaming]) + return orderedRows +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts new file mode 100644 index 00000000000..8537f2136ff --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useRef } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { formatWorkspaceCleanupReadyToast } from './workspace-cleanup-scan-notice' +import { isWorkspaceCleanupScanSupersededError } from '@/store/slices/workspace-cleanup-broad-scan-registry' + +export type WorkspaceCleanupScanLifecycle = { + startWorkspaceCleanupScan: (options?: { notifyWhenReady?: boolean }) => void +} + +/** + * Owns the dialog's scan cadence: seed the list from the persisted snapshot, + * kick one background rescan per open, and adopt — never duplicate — a scan + * already streaming into the store when the dialog reopens mid-flight. + */ +export function useWorkspaceCleanupScanLifecycle({ + open, + loading, + removalInFlight, + removalInFlightRef, + resetRowFailures, + onFreshOpen +}: { + open: boolean + loading: boolean + removalInFlight: boolean + removalInFlightRef: { current: boolean } + resetRowFailures: () => void + onFreshOpen: () => void +}): WorkspaceCleanupScanLifecycle { + const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup) + const hydrateCleanupFromCache = useAppStore((s) => s.hydrateWorkspaceCleanupFromCache) + const hydrateSpaceFromCache = useAppStore((s) => s.hydrateWorkspaceSpaceFromCache) + const openModal = useAppStore((s) => s.openModal) + const mountedRef = useMountedRef() + const openRef = useRef(open) + const wasOpenRef = useRef(false) + const autoScanAttemptedForOpenRef = useRef(false) + const latestReadyToastScanAtRef = useRef(null) + + useEffect(() => { + openRef.current = open + }, [open]) + + const startWorkspaceCleanupScan = useCallback( + (options: { notifyWhenReady?: boolean } = {}) => { + resetRowFailures() + void scanWorkspaceCleanup() + .then((result) => { + if (!mountedRef.current || !options.notifyWhenReady || openRef.current) { + return + } + if (latestReadyToastScanAtRef.current === result.scannedAt) { + return + } + latestReadyToastScanAtRef.current = result.scannedAt + const suggestedCount = result.candidates.filter( + (candidate) => candidate.selectedByDefault + ).length + toast.success( + translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63', + 'Workspace scan ready' + ), + { + description: formatWorkspaceCleanupReadyToast( + result.candidates.length, + suggestedCount + ), + action: { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764', + 'Review' + ), + onClick: () => openModal('workspace-cleanup') + } + } + ) + }) + .catch((err: unknown) => { + if (mountedRef.current && !isWorkspaceCleanupScanSupersededError(err)) { + toast.error( + translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.662b8ec3f8', + 'Workspace cleanup scan failed' + ), + { description: err instanceof Error ? err.message : String(err) } + ) + } + }) + }, + [mountedRef, openModal, resetRowFailures, scanWorkspaceCleanup] + ) + + const hydrateThenScan = useCallback(() => { + // Why: the cached snapshot must seed the list before the rescan starts so + // progress reconciles into full rows instead of a cold rebuild. + void Promise.allSettled([hydrateCleanupFromCache(), hydrateSpaceFromCache()]).then(() => { + startWorkspaceCleanupScan({ notifyWhenReady: true }) + }) + }, [hydrateCleanupFromCache, hydrateSpaceFromCache, startWorkspaceCleanupScan]) + + useEffect(() => { + if (!open) { + wasOpenRef.current = false + autoScanAttemptedForOpenRef.current = false + return + } + if (!wasOpenRef.current) { + wasOpenRef.current = true + autoScanAttemptedForOpenRef.current = false + onFreshOpen() + } + // Why: reopening mid-batch keeps the deletion progress view; a broad scan + // started here would be discarded by the removal's scan invalidation, so + // skip it while a removal batch is running. + if (removalInFlight || removalInFlightRef.current) { + return + } + if (autoScanAttemptedForOpenRef.current) { + return + } + autoScanAttemptedForOpenRef.current = true + if (loading) { + // Why: reopening mid-scan adopts the stream already feeding the store; + // starting another broad scan here would duplicate it once it settles. + return + } + hydrateThenScan() + }, [hydrateThenScan, loading, onFreshOpen, open, removalInFlight, removalInFlightRef]) + + return { startWorkspaceCleanupScan } +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts new file mode 100644 index 00000000000..1c45ddbc7ad --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import { + hasActiveWorkspaceCleanupFilters, + listActiveWorkspaceCleanupFacetGroups +} from './workspace-cleanup-active-facets' + +describe('listActiveWorkspaceCleanupFacetGroups', () => { + it('reports nothing for the default state', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([]) + expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(false) + }) + + it('names each edited group', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 45 + filters.git.states = ['dirty'] + expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual(['activity', 'git']) + }) + + it('treats multi-select order as meaningless', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.review.states = ['merged', 'closed'] + const reordered = createDefaultWorkspaceCleanupFilterState() + reordered.review.states = ['closed', 'merged'] + expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual( + listActiveWorkspaceCleanupFacetGroups(reordered) + ) + }) + + it('counts a text query as an active filter without naming a group', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.query = ' alpha ' + expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([]) + expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.ts new file mode 100644 index 00000000000..d9b359cff69 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.ts @@ -0,0 +1,70 @@ +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState +} from '../../../../shared/workspace-cleanup-filter-model' + +export type WorkspaceCleanupFacetGroupKey = keyof Omit + +const FACET_GROUP_KEYS: readonly WorkspaceCleanupFacetGroupKey[] = [ + 'activity', + 'size', + 'status', + 'agent', + 'git', + 'review', + 'ticket', + 'context', + 'location', + 'safety' +] + +/** + * Facet groups the user has moved off the default state. Drives the "N filters + * on" badge. + */ +export function listActiveWorkspaceCleanupFacetGroups( + filters: WorkspaceCleanupFilterState +): WorkspaceCleanupFacetGroupKey[] { + const defaults = createDefaultWorkspaceCleanupFilterState() + return FACET_GROUP_KEYS.filter((key) => !isGroupEqual(filters[key], defaults[key])) +} + +export function hasActiveWorkspaceCleanupFilters(filters: WorkspaceCleanupFilterState): boolean { + return ( + filters.query.trim().length > 0 || listActiveWorkspaceCleanupFacetGroups(filters).length > 0 + ) +} + +/** Selection order is not meaning, so array members compare as sets. */ +function isGroupEqual(left: unknown, right: unknown): boolean { + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false + } + const leftMembers = left.map(String).sort() + const rightMembers = right.map(String).sort() + return leftMembers.every((member, index) => member === rightMembers[index]) + } + if (isRecord(left) || isRecord(right)) { + if (!isRecord(left) || !isRecord(right)) { + return false + } + const leftKeys = Object.keys(left).sort() + const rightKeys = Object.keys(right).sort() + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key, index) => key === rightKeys[index] && isGroupEqual(trim(left[key]), trim(right[key])) + ) + ) + } + return left === right +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' +} + +function trim(value: unknown): unknown { + return typeof value === 'string' ? value.trim() : value +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts new file mode 100644 index 00000000000..04b088e43d9 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts @@ -0,0 +1,59 @@ +import { expect, it, vi } from 'vitest' +import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() } +})) + +async function settleBackgroundRemoval(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve() + } +} + +it('holds one snapshot prune batch across the sequential cleanup removals', async () => { + const first = makeCandidate() + const second = makeCandidate({ + worktreeId: 'repo-1::/repo/beta', + displayName: 'beta', + branch: 'beta', + path: '/repo/beta' + }) + const events: string[] = [] + const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => { + events.push(`remove:${worktreeIds[0]}`) + return { removedIds: [...worktreeIds], failures: [] } + }) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [first, second], + removeCandidates, + onProgress: vi.fn(), + snapshotPruneBatch: { + batchId: 'batch-1', + begin: async () => { + events.push('begin') + }, + finish: async () => { + events.push('finish') + } + } + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [first.worktreeId], { + approvedCandidates: [first], + snapshotPruneBatchId: 'batch-1' + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [second.worktreeId], { + approvedCandidates: [second], + snapshotPruneBatchId: 'batch-1' + }) + expect(events).toEqual([ + 'begin', + `remove:${first.worktreeId}`, + `remove:${second.worktreeId}`, + 'finish' + ]) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index 2384e4749b7..9e98435faac 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -50,6 +50,11 @@ export type WorkspaceCleanupBackgroundRemovalArgs = { onRowFailed?: (failure: WorkspaceCleanupFailure) => void removalTimeoutMs?: number removalSettlementGraceMs?: number + snapshotPruneBatch?: { + batchId: string + begin: () => Promise + finish: () => Promise + } } export function startWorkspaceCleanupBackgroundRemoval({ @@ -61,7 +66,8 @@ export function startWorkspaceCleanupBackgroundRemoval({ onError, onRowFailed, removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS, - removalSettlementGraceMs = DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS + removalSettlementGraceMs = DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS, + snapshotPruneBatch }: WorkspaceCleanupBackgroundRemovalArgs): void { if (candidates.length === 0) { try { @@ -166,71 +172,95 @@ export function startWorkspaceCleanupBackgroundRemoval({ } void (async () => { - while (queue.length > 0) { - const candidate = queue.shift() - if (!candidate) { - break - } - const blockers = findBlockingDescendants(candidate) - if (blockers.length > 0) { - skipBlockedAncestor(candidate, blockers) - continue - } + let snapshotPruneBatchActive = false + if (snapshotPruneBatch) { try { - const outcome = await waitForWorkspaceCleanupRemovalWithTimeout( - removeCandidates([candidate.worktreeId], { approvedCandidates: [candidate] }), - removalTimeoutMs, - removalSettlementGraceMs - ) - if (outcome.status === 'rejected') { - throw outcome.error + await snapshotPruneBatch.begin() + snapshotPruneBatchActive = true + } catch (error) { + console.warn('Failed to begin workspace cleanup snapshot prune batch:', error) + } + } + try { + while (queue.length > 0) { + const candidate = queue.shift() + if (!candidate) { + break } - if (outcome.status === 'unresolved') { - const timeoutFailure = getWorkspaceCleanupTimeoutFailure(candidate) - failedCandidates.push(candidate) - provisionallyBlocked.add(candidate) - pendingSettlementFailures.add(timeoutFailure) - reportFailures([timeoutFailure]) - // Why: renderer IPC cannot be cancelled at the timeout boundary; keep - // its authoritative settlement without unblocking ancestors early. - // After the batch ends, detach switches to the post-batch path which - // still holds skip state so ancestors can reclassify. - lateSettlementTrackers.push( - trackWorkspaceCleanupLateSettlement(outcome.settlement, candidate, (lateResult) => { - removeArrayEntry(failures, timeoutFailure) - pendingSettlementFailures.delete(timeoutFailure) - provisionallyBlocked.delete(candidate) - removedIds.push(...lateResult.removedIds) - preservedBranches.push(...(lateResult.preservedBranches ?? [])) - reportFailures(lateResult.failures) - if (lateResult.failures.length === 0) { - removeArrayEntry(failedCandidates, candidate) - } - resettleSkippedAncestors() - emitProgress() - }) - ) + const blockers = findBlockingDescendants(candidate) + if (blockers.length > 0) { + skipBlockedAncestor(candidate, blockers) continue } - const result = outcome.result - removedIds.push(...result.removedIds) - preservedBranches.push(...(result.preservedBranches ?? [])) - reportFailures(result.failures) - if (result.failures.length > 0) { - failedCandidates.push(candidate) - } - } catch (error: unknown) { - failedCandidates.push(candidate) - reportFailures([ - { - worktreeId: candidate.worktreeId, - displayName: candidate.displayName, - message: error instanceof Error ? error.message : String(error) + try { + const outcome = await waitForWorkspaceCleanupRemovalWithTimeout( + removeCandidates([candidate.worktreeId], { + approvedCandidates: [candidate], + ...(snapshotPruneBatchActive && snapshotPruneBatch + ? { snapshotPruneBatchId: snapshotPruneBatch.batchId } + : {}) + }), + removalTimeoutMs, + removalSettlementGraceMs + ) + if (outcome.status === 'rejected') { + throw outcome.error } - ]) - } finally { - processedCount += 1 - emitProgress() + if (outcome.status === 'unresolved') { + const timeoutFailure = getWorkspaceCleanupTimeoutFailure(candidate) + failedCandidates.push(candidate) + provisionallyBlocked.add(candidate) + pendingSettlementFailures.add(timeoutFailure) + reportFailures([timeoutFailure]) + // Why: renderer IPC cannot be cancelled at the timeout boundary; keep + // its authoritative settlement without unblocking ancestors early. + // After the batch ends, detach switches to the post-batch path which + // still holds skip state so ancestors can reclassify. + lateSettlementTrackers.push( + trackWorkspaceCleanupLateSettlement(outcome.settlement, candidate, (lateResult) => { + removeArrayEntry(failures, timeoutFailure) + pendingSettlementFailures.delete(timeoutFailure) + provisionallyBlocked.delete(candidate) + removedIds.push(...lateResult.removedIds) + preservedBranches.push(...(lateResult.preservedBranches ?? [])) + reportFailures(lateResult.failures) + if (lateResult.failures.length === 0) { + removeArrayEntry(failedCandidates, candidate) + } + resettleSkippedAncestors() + emitProgress() + }) + ) + continue + } + const result = outcome.result + removedIds.push(...result.removedIds) + preservedBranches.push(...(result.preservedBranches ?? [])) + reportFailures(result.failures) + if (result.failures.length > 0) { + failedCandidates.push(candidate) + } + } catch (error: unknown) { + failedCandidates.push(candidate) + reportFailures([ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: error instanceof Error ? error.message : String(error) + } + ]) + } finally { + processedCount += 1 + emitProgress() + } + } + } finally { + if (snapshotPruneBatchActive && snapshotPruneBatch) { + try { + await snapshotPruneBatch.finish() + } catch (error) { + console.warn('Failed to finish workspace cleanup snapshot prune batch:', error) + } } } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx new file mode 100644 index 00000000000..c70209dd0eb --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx @@ -0,0 +1,96 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupBrowseController } from './use-workspace-cleanup-browse-state' +import type { WorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows' +import { listActiveWorkspaceCleanupFacetGroups } from './workspace-cleanup-active-facets' +import { + WorkspaceCleanupEmptyState, + WorkspaceCleanupSizeScanBanner +} from './workspace-cleanup-dialog-notices' +import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar' +import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header' +import type { WorkspaceSpaceScanProgress } from '../../../../shared/workspace-space-types' + +export function WorkspaceCleanupBrowseToolbar({ + browse, + facetRows, + facetPanelOpen, + onFacetPanelOpenChange, + selectableCount, + selectedCount, + spaceScanning, + spaceProgress, + gitPendingCount, + gitCheckedTotal, + onRunSpaceScan, + onToggleSelectAll +}: { + browse: WorkspaceCleanupBrowseController + facetRows: WorkspaceCleanupFacetRows + facetPanelOpen: boolean + onFacetPanelOpenChange: (open: boolean) => void + selectableCount: number + selectedCount: number + spaceScanning: boolean + spaceProgress: WorkspaceSpaceScanProgress | null + gitPendingCount: number + gitCheckedTotal: number + onRunSpaceScan: () => void + onToggleSelectAll: (selectAll: boolean) => void +}): React.JSX.Element { + // Why: this toolbar re-renders per keystroke and per streaming tick; the + // group comparisons must not rerun unless the filter state itself changed. + const activeFacetGroupCount = React.useMemo( + () => listActiveWorkspaceCleanupFacetGroups(browse.filters).length, + [browse.filters] + ) + const activeFilters = browse.filters.query.trim().length > 0 || activeFacetGroupCount > 0 + return ( + <> + {spaceScanning || facetRows.unmeasuredSizeCount > 0 ? ( + + ) : null} + browse.patchFilters('query', query)} + onClearFilters={browse.clearFilters} + /> + + {browse.sort.field === 'size' && facetRows.measuredSizeCount === 0 && !spaceScanning ? ( + + ) : null} + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx index 68569c7aba9..7cfd365bc60 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx @@ -1,6 +1,8 @@ import React from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' + +/** Any row shape the flat list renders — raw candidates or enriched facet rows. */ +type WorkspaceCleanupListRow = { worktreeId: string } // Why: below this count plain rows keep the pre-virtualization DOM (natural // flow, no absolute positioning), so the common few-worktrees case is @@ -19,16 +21,18 @@ const WORKSPACE_CLEANUP_ROW_OVERSCAN = 8 * no scroll-margin bookkeeping is needed. Lists shorter than * WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS render plainly. */ -export function WorkspaceCleanupCandidateList({ +export function WorkspaceCleanupCandidateList({ rows, renderRow, + estimatedRowHeight = WORKSPACE_CLEANUP_ROW_ESTIMATE_PX, // Why: a state-held element, not a ref — the ScrollArea viewport is not // attached when this component first mounts, so a ref would leave the // virtualizer unobserved until some unrelated re-render. scrollElement }: { - rows: readonly WorkspaceCleanupCandidate[] - renderRow: (candidate: WorkspaceCleanupCandidate, index: number) => React.ReactNode + rows: readonly Row[] + renderRow: (row: Row, index: number) => React.ReactNode + estimatedRowHeight?: number scrollElement: HTMLDivElement | null }): React.JSX.Element { const virtualize = rows.length >= WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS @@ -37,7 +41,7 @@ export function WorkspaceCleanupCandidateList({ count: rows.length, enabled: virtualize && scrollElement !== null, getScrollElement: () => scrollElement, - estimateSize: () => WORKSPACE_CLEANUP_ROW_ESTIMATE_PX, + estimateSize: () => estimatedRowHeight, overscan: WORKSPACE_CLEANUP_ROW_OVERSCAN, // Why: stable worktree keys let the virtualizer carry row identity across // scan refreshes instead of remounting the window on every streamed row. diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts index 3cd5309ac54..7ed376b10b6 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts @@ -1,8 +1,20 @@ import { describe, expect, it } from 'vitest' -import { getDirtyGitLabel, shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' +import { + getCandidateFactStatus, + getDirtyGitLabel, + shouldShowGitMetadataChip +} from './workspace-cleanup-candidate-row-data' import { makeCandidate } from './workspace-cleanup-presentation-fixtures' describe('workspace cleanup candidate row data', () => { + it('shows facts instead of cleanup policy tiers', () => { + expect(getCandidateFactStatus(makeCandidate({ tier: 'ready' }))).toBeNull() + expect(getCandidateFactStatus(makeCandidate({ tier: 'review' }))).toBeNull() + expect( + getCandidateFactStatus(makeCandidate({ tier: 'ready', reasons: ['archived'] })) + ).toMatchObject({ label: 'Archived' }) + }) + it('does not duplicate git status blockers as a separate git icon label', () => { const gitStatusError = makeCandidate({ blockers: ['git-status-error'], @@ -29,6 +41,18 @@ describe('workspace cleanup candidate row data', () => { ).toBe(true) }) + it('keeps unpushed risk visible for archived rows', () => { + expect( + getCandidateFactStatus( + makeCandidate({ + tier: 'review', + reasons: ['archived'], + git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toMatchObject({ label: 'Unpushed commits' }) + }) + it('suppresses the git metadata chip when the status pill already names git risk', () => { expect( shouldShowGitMetadataChip( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts index 9e819d90017..42a7d0c58a5 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -23,10 +23,10 @@ export function getWorkspaceCleanupBlockerLabels(candidate: WorkspaceCleanupCand return candidate.blockers.map((blocker) => getWorkspaceCleanupBlockerLabel(blocker)) } -export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { +export function getCandidateFactStatus(candidate: WorkspaceCleanupCandidate): { label: string tone: StatusPillTone -} { +} | null { if (candidate.blockers.includes('dismissed')) { return { label: translate( @@ -36,20 +36,6 @@ export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { tone: 'neutral' } } - if (candidate.tier === 'ready') { - return { - label: candidate.reasons.includes('archived') - ? translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', - 'Archived' - ) - : translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.readyStatus', - 'Ready' - ), - tone: 'ready' - } - } if (candidate.blockers.length > 0) { return { label: getWorkspaceCleanupBlockerLabel(candidate.blockers[0]), tone: 'neutral' } } @@ -71,22 +57,16 @@ export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { tone: 'review' } } - if (candidate.tier === 'review') { + if (candidate.reasons.includes('archived')) { return { label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0a2e3c7cba', - 'Review' + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', + 'Archived' ), - tone: 'review' + tone: 'neutral' } } - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4f4782c02', - 'Not suggested' - ), - tone: 'neutral' - } + return null } export function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx index 7de51d662cb..2f8ecc9fb07 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx @@ -31,6 +31,103 @@ describe('CandidateRow', () => { container = null }) + it('does not present cleanup policy tiers as workspace facts', () => { + const candidate = makeCandidate({ tier: 'ready' }) + + act(() => { + root?.render( + + ) + }) + + expect(container?.textContent).not.toContain('Ready') + expect(container?.textContent).not.toContain('Status unavailable') + }) + + it('shows factual status, disk size, activity, and git state before expansion', () => { + const candidate = makeCandidate({ tier: 'ready' }) + + act(() => { + root?.render( + + ) + }) + + expect(container?.textContent).toContain('In progress') + expect(container?.textContent).toContain('4.00 MB') + expect(container?.textContent).toContain('1d') + expect(container?.textContent).toContain('Clean') + }) + + it('uses an external-link action to open the workspace', () => { + const candidate = makeCandidate() + + act(() => { + root?.render( + + ) + }) + + const openButton = container?.querySelector(`[aria-label="Open ${candidate.displayName}"]`) + expect(openButton?.querySelector('.lucide-external-link')).not.toBeNull() + }) + it('hides selection and remove controls while the workspace is already deleting', () => { const candidate = makeCandidate() diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index 2953f46fa5a..f8f68b03a9c 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -3,16 +3,17 @@ import { AlertTriangle, Check, ChevronDown, + CircleDot, Clock3, EyeOff, + ExternalLink, FileWarning, GitBranch, GitPullRequest, + HardDrive, Loader2, - Search, SquareTerminal, - Trash2, - type LucideIcon + Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -31,15 +32,15 @@ import { formatBranchSafetyDetails, formatContextDetails, formatGitStatus, - getCandidateStatus, + getCandidateFactStatus, getContextCount, getDirtyGitLabel, getReviewPillTone, getWorkspaceCleanupBlockerLabels, - shouldShowGitMetadataChip, - type StatusPillTone + shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' import { StatusPill } from './workspace-cleanup-status-pill' +import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip' export type WorkspaceCleanupDeletionPhase = 'deleting' | 'queued' @@ -48,9 +49,15 @@ type CandidateRowProps = { deletionPhase?: WorkspaceCleanupDeletionPhase expanded: boolean failure?: string + /** A focused git re-scan is in flight, so "Not checked" is provisional. */ + gitEvidencePending?: boolean last: boolean lastActivityLabel: string removing?: boolean + /** Joined from the workspace-space scan; null when that scan has not run. */ + sizeLabel?: string | null + /** User-configured status label; null when this broad-scan row is not in renderer state. */ + workspaceStatusLabel?: string | null reviewInfo: WorkspaceCleanupReviewInfo selected: boolean onIgnore: (candidate: WorkspaceCleanupCandidate) => void @@ -60,42 +67,6 @@ type CandidateRowProps = { onView: (candidate: WorkspaceCleanupCandidate) => void } -function MetadataIconChip({ - icon: Icon, - label, - value, - tone = 'neutral' -}: { - icon: LucideIcon - label: string - value?: string - tone?: StatusPillTone -}): React.JSX.Element { - return ( - - - - - - - {label} - - - ) -} - // Why: the cleanup list re-renders on every checkbox/expand/search keystroke; // memo keeps each unchanged row from re-rendering. Effective only while the // parent passes stable (useCallback) handlers — see WorkspaceCleanupDialog. @@ -106,9 +77,12 @@ export const CandidateRow = React.memo(function CandidateRow({ deletionPhase, expanded, failure, + gitEvidencePending = false, last, lastActivityLabel, removing = false, + sizeLabel = null, + workspaceStatusLabel = null, reviewInfo, selected, onIgnore, @@ -125,10 +99,13 @@ export const CandidateRow = React.memo(function CandidateRow({ const blockers = getWorkspaceCleanupBlockerLabels(candidate) const contextDetails = formatContextDetails(candidate) const branchSafetyDetails = formatBranchSafetyDetails(candidate) - const status = getCandidateStatus(candidate) + const factStatus = getCandidateFactStatus(candidate) const dirtyLabel = getDirtyGitLabel(candidate) + const gitLabel = getWorkspaceCleanupGitLabel(candidate) const showGitMetadataChip = shouldShowGitMetadataChip(candidate) const contextCount = getContextCount(candidate) + const sizeValue = + sizeLabel ?? translate('components.workspace.cleanup.browse.notMeasured', 'Not measured') const hasExpandableDetails = blockers.length > 0 || candidate.path.length > 0 || @@ -169,7 +146,9 @@ export const CandidateRow = React.memo(function CandidateRow({
- {candidate.displayName} + + {candidate.displayName} + {deletionPhase ? ( {deletionPhase === 'queued' @@ -182,10 +161,21 @@ export const CandidateRow = React.memo(function CandidateRow({ 'Deleting…' )} - ) : ( - {status.label} - )} - {factStatus.label} + ) : null} + {workspaceStatusLabel ? ( + + ) : null} + - {dirtyLabel && showGitMetadataChip ? ( - + + {gitEvidencePending ? ( + + + ) : dirtyLabel && showGitMetadataChip ? ( + ) : showGitMetadataChip ? ( - ) : null} {contextDetails ? ( - ) : null} {reviewInfo.label ? ( - onView(candidate)} > - + - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ee81adfcef', - 'View' - )} + {translate('components.workspace.cleanup.browse.openWorkspace', 'Open workspace')} {!ignored ? ( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx new file mode 100644 index 00000000000..f93672103c8 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture' +import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove' + +const listProbe = vi.hoisted(() => vi.fn()) + +vi.mock('./workspace-cleanup-candidate-list', () => ({ + WorkspaceCleanupCandidateList: ({ + rows, + renderRow + }: { + rows: readonly Row[] + renderRow: (row: Row, index: number) => ReactNode + }) => { + listProbe(rows.length) + return <>{rows.slice(0, 5).map(renderRow)} + } +})) + +vi.mock('@/components/ui/dialog', () => ({ + DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
+})) + +let container: HTMLDivElement +let root: Root + +describe('WorkspaceCleanupConfirmRemove', () => { + beforeEach(() => { + listProbe.mockClear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() + }) + + it('routes fleet-sized confirmations through the bounded row list', () => { + const candidates = Array.from({ length: 2_727 }, (_, index) => + makeFacetCandidate({ + worktreeId: `repo-1::/repo/worktree-${index}`, + displayName: `Workspace ${index}`, + path: `/repo/worktree-${index}` + }) + ) + + act(() => { + root.render( + + ) + }) + + expect(listProbe).toHaveBeenCalledWith(candidates.length) + expect(container.querySelectorAll('span.truncate.text-sm.font-medium')).toHaveLength(5) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx new file mode 100644 index 00000000000..0d946cc14e0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx @@ -0,0 +1,253 @@ +import React from 'react' +import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Progress } from '@/components/ui/progress' +import { ScrollArea } from '@/components/ui/scroll-area' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' +import { + getCandidateFactStatus, + getContextPillLabel, + getDirtyGitLabel, + getReviewPillTone, + shouldShowGitMetadataChip +} from './workspace-cleanup-candidate-row-data' +import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' +import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' +import { StatusPill } from './workspace-cleanup-status-pill' +import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' +import { + getWorkspaceCleanupCandidateHostId, + getWorkspaceCleanupHostIdentity +} from './workspace-cleanup-host-identity' + +const CONFIRM_REMOVE_ROW_ESTIMATE_PX = 76 + +const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = { + hasReview: false, + label: null, + state: null, + provider: null, + title: null +} + +export function WorkspaceCleanupConfirmRemove({ + candidates, + now, + reviewInfoByWorktreeId, + progress, + onBack, + onCancel, + onConfirm +}: { + candidates: WorkspaceCleanupCandidate[] + /** The dialog's "as of" clock; keeps labels consistent with the list view. */ + now: number + reviewInfoByWorktreeId: ReadonlyMap + progress: WorkspaceCleanupRemovalProgress | null + onBack: () => void + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + const [scrollElement, setScrollElement] = React.useState(null) + const count = candidates.length + const deleting = progress !== null + const progressValue = progress + ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) + : 0 + return ( + <> + +
+
+
+ {deleting ? ( + + ) : ( + + )} +
+
+ + {deleting + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deletingCount', + 'Deleting workspaces: {{value0}}', + { value0: count } + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteCount', + 'Delete workspaces: {{value0}}?', + { value0: count } + )} + + + {deleting + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1d3503357d', + 'You can close this and come back while deletion continues.' + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400', + "This permanently deletes their local files. You can't undo this." + )} + +
+
+ +
+
+
+ {progress ? ( +
+
+ + + {formatWorkspaceCleanupRemovalProgress(progress)} + +
+ +
+ ) : null} +
+
+ {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.selectedForDeletionCount', + 'Selected for deletion: {{value0}}', + { value0: count } + )} +
+
+ + ( + + )} + /> + +
+ + + {!deleting ? ( + + ) : null} + + + ) +} + +function ConfirmRemoveRow({ + candidate, + now, + reviewInfo, + last +}: { + candidate: WorkspaceCleanupCandidate + now: number + reviewInfo: WorkspaceCleanupReviewInfo + last: boolean +}): React.JSX.Element { + const dirtyLabel = getDirtyGitLabel(candidate) + const branchDiffersFromName = candidate.branch !== candidate.displayName + const contextPillLabel = getContextPillLabel(candidate) + const showGitMetadataChip = shouldShowGitMetadataChip(candidate) + const factStatus = getCandidateFactStatus(candidate) + return ( +
+
+ {candidate.displayName} + + {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc', + 'Last active' + )}{' '} + {formatWorkspaceCleanupRelativeTime(candidate.lastActivityAt, now)} + + {factStatus ? {factStatus.label} : null} + {reviewInfo.label ? ( + {reviewInfo.label} + ) : null} + {contextPillLabel ? {contextPillLabel} : null} + {dirtyLabel && showGitMetadataChip ? ( + {dirtyLabel} + ) : null} +
+
+ {candidate.repoName} + {branchDiffersFromName ? ( + <> + + {candidate.branch} + + ) : null} +
+
+ {candidate.path} +
+
+ ) +} + +function formatWorkspaceCleanupRemovalProgress(progress: WorkspaceCleanupRemovalProgress): string { + const deletedText = translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4c2990886e', + '{{value0}}/{{value1}} deleted', + { value0: progress.removedCount, value1: progress.totalCount } + ) + if (progress.failedCount === 0) { + return deletedText + } + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.86ba852118', + '{{value0}}, {{value1}} failed', + { value0: deletedText, value1: progress.failedCount } + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-header.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-header.tsx new file mode 100644 index 00000000000..a632903a8cc --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-header.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import { RefreshCcw, Trash2, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup' +import { WorkspaceCleanupFreshness } from './workspace-cleanup-freshness' + +export function WorkspaceCleanupDialogHeader({ + selectedCount, + deleteDisabled, + loading, + scannedAt, + scanProgress, + onDeleteSelected, + onRefresh, + onClose +}: { + selectedCount: number + deleteDisabled: boolean + loading: boolean + scannedAt: number | null + scanProgress: WorkspaceCleanupScanProgress | null + onDeleteSelected: () => void + onRefresh: () => void + onClose: () => void +}): React.JSX.Element { + const refreshLabel = translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7ae2ad30f4', + 'Refresh' + ) + return ( + +
+ + {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.b2c1331844', + 'Delete Inactive Workspaces' + )} + +
+ + + {translate('components.workspace.cleanup.browse.selectedCount', '{{value0}} selected', { + value0: selectedCount + })} + + + + + + + + {refreshLabel} + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx new file mode 100644 index 00000000000..8544d8eb415 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + WorkspaceCleanupInitialScanBanner, + WorkspaceCleanupSizeScanBanner +} from './workspace-cleanup-dialog-notices' + +afterEach(cleanup) + +describe('WorkspaceCleanupInitialScanBanner', () => { + it('puts determinate progress in the title with matching typography', () => { + render( + + ) + + expect(screen.getByText('Scanning workspaces (23/3048)')).toBeTruthy() + expect(screen.queryByText('Checked workspaces so far: 23')).toBeNull() + }) +}) + +describe('WorkspaceCleanupSizeScanBanner', () => { + it('explains why scanning is useful and exposes the action', () => { + render( + + ) + + expect(screen.getByText('Scan workspace sizes')).toBeTruthy() + expect( + screen.getByText('Scan disk usage to compare, sort, and filter workspaces by size.') + ).toBeTruthy() + expect(screen.getByRole('button', { name: 'Scan' })).toBeTruthy() + }) + + it('shows determinate progress and disables duplicate runs', () => { + render( + + ) + + expect( + (screen.getByRole('button', { name: 'Scanning 23/100' }) as HTMLButtonElement).disabled + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx new file mode 100644 index 00000000000..40592de7f5b --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx @@ -0,0 +1,148 @@ +import React from 'react' +import { AlertTriangle, HardDrive, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup' + +export function WorkspaceCleanupInitialScanBanner({ + progress +}: { + progress: WorkspaceCleanupScanProgress | null +}): React.JSX.Element { + const title = + progress && progress.totalWorktreeCount > 0 + ? translate( + 'components.workspace.cleanup.scan.progress', + 'Scanning workspaces ({{value0}}/{{value1}})', + { + value0: progress.scannedWorktreeCount, + value1: progress.totalWorktreeCount + } + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7eee951968', + 'Scanning workspaces' + ) + + return ( +
+ +
+
{title}
+
+ {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.47123d0108', + 'Collecting workspace information. You can close this and come back.' + )} +
+
+
+ ) +} + +export function WorkspaceCleanupSizeScanBanner({ + scanning, + scannedCount, + totalCount, + onRun +}: { + scanning: boolean + scannedCount: number + totalCount: number + onRun: () => void +}): React.JSX.Element { + const actionLabel = scanning + ? totalCount > 0 + ? translate( + 'components.workspace.cleanup.browse.measuringSizesProgress', + 'Scanning {{value0}}/{{value1}}', + { value0: scannedCount, value1: totalCount } + ) + : translate('components.workspace.cleanup.browse.measuringSizes', 'Scanning sizes') + : translate('components.workspace.cleanup.browse.measureSizes', 'Scan') + + return ( +
+
+ +
+
+ {translate( + 'components.workspace.cleanup.browse.measureSizesTitle', + 'Scan workspace sizes' + )} +
+
+ {translate( + 'components.workspace.cleanup.browse.measureSizesDescription', + 'Scan disk usage to compare, sort, and filter workspaces by size.' + )} +
+
+
+ +
+ ) +} + +export function WorkspaceCleanupNotice({ + tone = 'muted', + message +}: { + tone?: 'muted' | 'destructive' + message: string +}): React.JSX.Element { + if (tone === 'destructive') { + return ( +
+ {message} +
+ ) + } + return ( +
+ + {message} +
+ ) +} + +export function WorkspaceCleanupEmptyState({ + title, + description, + actionLabel, + onAction +}: { + title: string + description?: string + actionLabel?: string + onAction?: () => void +}): React.JSX.Element { + return ( +
+ {title} + {description ? {description} : null} + {actionLabel && onAction ? ( + + ) : null} +
+ ) +} + +export function WorkspaceCleanupSkeletonRows(): React.JSX.Element { + return ( +
+ {[0, 1, 2].map((index) => ( +
+ ))} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.test.tsx deleted file mode 100644 index ae35cbc9b6d..00000000000 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -// @vitest-environment happy-dom - -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' -import { useAppStore } from '@/store' -import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup' -import { - useWorkspaceCleanupDialogSession, - type WorkspaceCleanupDialogSession -} from './workspace-cleanup-dialog-session' -import { makeCandidate } from './workspace-cleanup-presentation-fixtures' - -const toastMocks = vi.hoisted(() => ({ - error: vi.fn(), - info: vi.fn(), - success: vi.fn() -})) - -vi.mock('sonner', () => ({ toast: toastMocks })) - -const initialAppState = useAppStore.getInitialState() -let container: HTMLDivElement -let root: Root -let currentSession: WorkspaceCleanupDialogSession | null - -function deferred(): { - promise: Promise - resolve: (value: T) => void -} { - let resolve!: (value: T) => void - const promise = new Promise((innerResolve) => { - resolve = innerResolve - }) - return { promise, resolve } -} - -function Probe(): null { - currentSession = useWorkspaceCleanupDialogSession() - return null -} - -function session(): WorkspaceCleanupDialogSession { - if (!currentSession) { - throw new Error('Workspace cleanup session probe is not mounted') - } - return currentSession -} - -async function renderProbe(): Promise { - await act(async () => root.render()) -} - -describe('useWorkspaceCleanupDialogSession', () => { - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - vi.clearAllMocks() - useAppStore.setState(initialAppState, true) - useAppStore.setState({ - activeModal: 'workspace-cleanup', - workspaceCleanupLoading: false, - deleteStateByWorktreeId: {} - }) - currentSession = null - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - }) - - afterEach(async () => { - await act(async () => root.unmount()) - container.remove() - currentSession = null - useAppStore.setState(initialAppState, true) - }) - - it('offers to reopen when a scan finishes after the dialog closes', async () => { - const candidate = makeCandidate() - const scan = deferred() - const scanWorkspaceCleanup = vi.fn(() => scan.promise) - useAppStore.setState({ scanWorkspaceCleanup }) - - await renderProbe() - expect(scanWorkspaceCleanup).toHaveBeenCalledOnce() - - await act(async () => useAppStore.getState().closeModal()) - expect(session().open).toBe(false) - - await act(async () => { - scan.resolve({ scannedAt: 42, candidates: [candidate], errors: [] }) - await scan.promise - }) - - expect(toastMocks.success).toHaveBeenCalledWith( - 'Inactive workspace scan ready', - expect.objectContaining({ - description: '1 inactive workspace found, with 1 cleanup suggestion.', - action: expect.objectContaining({ label: 'Review' }) - }) - ) - const options = toastMocks.success.mock.lastCall?.[1] as { - action?: { onClick: () => void } - } - - await act(async () => options.action?.onClick()) - - expect(useAppStore.getState().activeModal).toBe('workspace-cleanup') - expect(session().open).toBe(true) - }) - - it('keeps a deferred removal alive and visible across close and reopen', async () => { - const candidate = makeCandidate() - const removal = deferred() - const removeWorkspaceCleanupCandidates = vi.fn(() => removal.promise) - const scanWorkspaceCleanup = vi.fn().mockResolvedValue({ - scannedAt: 41, - candidates: [candidate], - errors: [] - } satisfies WorkspaceCleanupScanResult) - useAppStore.setState({ removeWorkspaceCleanupCandidates, scanWorkspaceCleanup }) - await renderProbe() - - await act(async () => session().openConfirmRemove([candidate])) - await act(async () => session().confirmRemove()) - - expect(removeWorkspaceCleanupCandidates).toHaveBeenCalledWith([candidate.worktreeId], { - approvedCandidates: [candidate] - }) - expect(session()).toMatchObject({ - open: true, - confirming: true, - removalInFlight: true, - removalProgress: { - totalCount: 1, - processedCount: 0, - removedCount: 0, - failedCount: 0 - } - }) - - await act(async () => session().close()) - - expect(session()).toMatchObject({ - open: false, - confirming: true, - removalInFlight: true - }) - expect(session().removalProgress?.processedCount).toBe(0) - - await act(async () => useAppStore.getState().openModal('workspace-cleanup')) - - expect(session()).toMatchObject({ - open: true, - confirming: true, - removalInFlight: true - }) - expect(session().removalProgress?.processedCount).toBe(0) - expect(scanWorkspaceCleanup).toHaveBeenCalledOnce() - - await act(async () => { - removal.resolve({ removedIds: [candidate.worktreeId], failures: [] }) - await removal.promise - await Promise.resolve() - }) - - expect(session()).toMatchObject({ - open: true, - confirming: false, - confirmCandidates: [], - removalInFlight: false, - removalProgress: null - }) - }) -}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.ts deleted file mode 100644 index 670e73fa4dc..00000000000 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { - useCallback, - useEffect, - useRef, - useState, - type Dispatch, - type RefObject, - type SetStateAction -} from 'react' -import { toast } from 'sonner' -import { useShallow } from 'zustand/react/shallow' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' -import { useMountedRef } from '@/hooks/useMountedRef' -import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' -import { - useWorkspaceCleanupRemovalSession, - type WorkspaceCleanupRemovalSession -} from './workspace-cleanup-removal-session' -import { useWorkspaceCleanupScanSession } from './workspace-cleanup-scan-session' -import type { - WorkspaceCleanupFilters, - WorkspaceCleanupSortDirection, - WorkspaceCleanupSortKey -} from './workspace-cleanup-presentation' -import type { WorkspaceCleanupView } from './workspace-cleanup-view-selection' - -export const DEFAULT_WORKSPACE_CLEANUP_FILTERS: WorkspaceCleanupFilters = { - query: '', - time: 'all', - review: 'all', - git: 'all', - context: 'all' -} - -type WorkspaceCleanupStateSetter = Dispatch> -type PublicRemovalSession = Omit< - WorkspaceCleanupRemovalSession, - 'clearRowFailures' | 'resetForOpen' -> - -export type WorkspaceCleanupDialogSession = PublicRemovalSession & { - open: boolean - loading: boolean - selectedIds: Set - setSelectedIds: WorkspaceCleanupStateSetter> - expandedRowIds: Set - setExpandedRowIds: WorkspaceCleanupStateSetter> - activeView: WorkspaceCleanupView - setActiveView: WorkspaceCleanupStateSetter - repoSelection: ReadonlySet - setRepoSelection: WorkspaceCleanupStateSetter> - filters: WorkspaceCleanupFilters - setFilters: WorkspaceCleanupStateSetter - sortKey: WorkspaceCleanupSortKey - setSortKey: WorkspaceCleanupStateSetter - sortDirection: WorkspaceCleanupSortDirection - setSortDirection: WorkspaceCleanupStateSetter - selectedDefaultsScanAtRef: RefObject - close: () => void - markCandidateViewed: (candidate: WorkspaceCleanupCandidate) => void - restoreDismissals: () => void - startScan: (options?: { notifyWhenReady?: boolean }) => void - ignoreCandidate: (candidate: WorkspaceCleanupCandidate) => void -} - -export function useWorkspaceCleanupDialogSession(): WorkspaceCleanupDialogSession { - const { - open, - loading, - openModal, - closeModal, - scanWorkspaceCleanup, - markCandidateViewed, - dismissCandidates, - resetDismissals, - removeCandidates, - markWorktreesQueuedForDeletion, - clearWorktreeDeleteState - } = useAppStore( - useShallow((state) => ({ - open: state.activeModal === 'workspace-cleanup', - loading: state.workspaceCleanupLoading, - openModal: state.openModal, - closeModal: state.closeModal, - scanWorkspaceCleanup: state.scanWorkspaceCleanup, - markCandidateViewed: state.markWorkspaceCleanupCandidateViewed, - dismissCandidates: state.dismissWorkspaceCleanupCandidates, - resetDismissals: state.resetWorkspaceCleanupDismissals, - removeCandidates: state.removeWorkspaceCleanupCandidates, - markWorktreesQueuedForDeletion: state.markWorktreesQueuedForDeletion, - clearWorktreeDeleteState: state.clearWorktreeDeleteState - })) - ) - const [selectedIds, setSelectedIds] = useState>(() => new Set()) - const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) - const [activeView, setActiveView] = useState('ready') - const [repoSelection, setRepoSelection] = useState>(() => new Set()) - const [filters, setFilters] = useState(DEFAULT_WORKSPACE_CLEANUP_FILTERS) - const [sortKey, setSortKey] = useState('activity') - const [sortDirection, setSortDirection] = useState('asc') - const selectedDefaultsScanAtRef = useRef(null) - const autoScanAttemptedForOpenRef = useRef(false) - const wasOpenRef = useRef(false) - const mountedRef = useMountedRef() - const removal = useWorkspaceCleanupRemovalSession({ - mountedRef, - setSelectedIds, - closeModal, - removeCandidates, - markWorktreesQueuedForDeletion, - clearWorktreeDeleteState - }) - const { clearRowFailures, resetForOpen, ...publicRemoval } = removal - const startScan = useWorkspaceCleanupScanSession({ - open, - mountedRef, - openModal, - scanWorkspaceCleanup, - clearRowFailures - }) - - useEffect(() => { - if (!open) { - wasOpenRef.current = false - autoScanAttemptedForOpenRef.current = false - return - } - if (!wasOpenRef.current) { - wasOpenRef.current = true - autoScanAttemptedForOpenRef.current = false - if (!removal.removalInFlightRef.current) { - setActiveView('ready') - setFilters(DEFAULT_WORKSPACE_CLEANUP_FILTERS) - setSortKey('activity') - setSortDirection('asc') - resetForOpen() - } - } - // Why: reopening mid-batch must preserve progress and avoid a scan the removal would invalidate. - if (!loading && !autoScanAttemptedForOpenRef.current && !removal.removalInFlightRef.current) { - autoScanAttemptedForOpenRef.current = true - startScan({ notifyWhenReady: true }) - } - }, [loading, open, removal.removalInFlightRef, resetForOpen, startScan]) - - const ignoreCandidate = useCallback( - (candidate: WorkspaceCleanupCandidate) => { - void dismissCandidates([candidate]) - .then(() => { - if (!mountedRef.current) { - return - } - setSelectedIds((current) => { - const next = new Set(current) - next.delete(candidate.worktreeId) - return next - }) - }) - .catch((error: unknown) => { - if (!mountedRef.current) { - return - } - toast.error( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7f451a3e2c', - 'Could not ignore cleanup suggestion' - ), - { description: error instanceof Error ? error.message : String(error) } - ) - }) - }, - [dismissCandidates, mountedRef] - ) - - const restoreDismissals = useCallback(() => { - void resetDismissals() - }, [resetDismissals]) - - return { - open, - loading, - selectedIds, - setSelectedIds, - expandedRowIds, - setExpandedRowIds, - activeView, - setActiveView, - repoSelection, - setRepoSelection, - filters, - setFilters, - sortKey, - setSortKey, - sortDirection, - setSortDirection, - selectedDefaultsScanAtRef, - close: closeModal, - markCandidateViewed, - restoreDismissals, - startScan, - ignoreCandidate, - ...publicRemoval - } -} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx new file mode 100644 index 00000000000..10049ceeb4e --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState +} from '../../../../shared/workspace-cleanup-filter-model' +import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets' +import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets' +import type { + WorkspaceCleanupFacetCounts, + WorkspaceCleanupFacetGroupProps, + WorkspaceCleanupFacetOptions +} from './workspace-cleanup-facet-panel-model' + +/** `Mock` erases T's type parameter, so keep the generic call signature and add the spy API. */ +type PatchMock = WorkspaceCleanupFacetGroupProps['onPatch'] & MockInstance + +function createPatchMock(): PatchMock { + return vi.fn() +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +const COUNTS: WorkspaceCleanupFacetCounts = { + activity: 3, + size: 4, + status: 8, + agent: 8, + git: 2, + review: 5, + ticket: 8, + context: 6, + location: 8, + safety: 7 +} + +const OPTIONS: WorkspaceCleanupFacetOptions = { + workspaceStatuses: [{ id: 'in-progress', label: 'In progress' }], + hostIds: ['local', 'ssh:box'], + repos: [ + { id: 'repo-1', label: 'Alpha' }, + { id: 'repo-2', label: 'Beta' } + ], + reviewProviders: ['github', 'gitlab'] +} + +function render(node: ReactNode): void { + act(() => root?.render(node)) +} + +function control(label: string): HTMLElement | null { + return container?.querySelector(`[aria-label="${label}"]`) ?? null +} + +/** React tracks the controlled value, so a bare `input.value =` is swallowed. */ +function typeInto(input: HTMLInputElement | null, value: string): void { + if (!input) { + return + } + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +function renderFacets(filters: WorkspaceCleanupFilterState, onPatch: PatchMock): void { + const props = { filters, counts: COUNTS, totalCount: 8, options: OPTIONS, onPatch } + render( + <> + + + + ) +} + +describe('workspace cleanup facet controls', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('shows a per-facet match count for every group', () => { + renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock()) + + const counts = [...(container?.querySelectorAll('[data-facet-count]') ?? [])].map( + (node) => node.textContent + ) + expect(counts).toHaveLength(10) + expect(counts).toContain('2/8') + expect(counts).toContain('3/8') + }) + + it('takes a user-chosen idle threshold in days rather than a fixed enum', () => { + const onPatch = createPatchMock() + renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch) + + const input = control('Idle for at least') as HTMLInputElement | null + expect(input?.type).toBe('number') + act(() => typeInto(input, '17')) + expect(onPatch).toHaveBeenCalledWith('activity', { idleMinDays: 17 }) + }) + + it('multi-selects git states', () => { + const onPatch = createPatchMock() + const filters = createDefaultWorkspaceCleanupFilterState() + filters.git.states = ['dirty'] + renderFacets(filters, onPatch) + + expect(control('Uncommitted changes')?.getAttribute('aria-checked')).toBe('true') + act(() => control('Unpushed commits')?.click()) + expect(onPatch).toHaveBeenCalledWith('git', { states: ['dirty', 'unpushed'] }) + }) + + it('offers the hosts and providers discovered in the fleet, not a hardcoded list', () => { + renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock()) + + expect(control('GitLab')).not.toBeNull() + expect(container?.textContent).toContain('Alpha') + expect(container?.textContent).toContain('In progress') + }) + + it('cycles a tri-state safety facet', () => { + const onPatch = createPatchMock() + renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch) + + act(() => container?.querySelector('[aria-label="Ignored: Only"]')?.click()) + expect(onPatch).toHaveBeenCalledWith('safety', { dismissed: 'only' }) + }) + + it('labels blocker matching without exposing model tokens', () => { + renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock()) + + expect(control('Blocker match: Has any')).not.toBeNull() + expect(control('Blocker match: Has none')).not.toBeNull() + expect(container?.textContent).not.toContain('any-of') + expect(container?.textContent).not.toContain('none-of') + }) + + it('toggles the unsized escape hatch for the opt-in space scan', () => { + const onPatch = createPatchMock() + renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch) + + const checkbox = control('Include unmeasured workspaces') + expect(checkbox?.getAttribute('data-state')).toBe('checked') + act(() => checkbox?.click()) + expect(onPatch).toHaveBeenCalledWith('size', { includeUnsized: false }) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx new file mode 100644 index 00000000000..7dc1c0d92e7 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx @@ -0,0 +1,225 @@ +import React from 'react' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { cn } from '@/lib/utils' +import { parseWorkspaceCleanupFacetNumber } from './workspace-cleanup-facet-panel-model' + +/** One facet group inside the filter panel, with its own match count. */ +export function FacetSection({ + title, + matchCount, + totalCount, + children +}: { + title: string + matchCount: number + totalCount: number + children: React.ReactNode +}): React.JSX.Element { + return ( +
+
+

+ {title} +

+ + {matchCount}/{totalCount} + +
+
{children}
+
+ ) +} + +/** Multi-select: empty selection leaves the facet unconstrained. */ +export function FacetToggleList({ + label, + values, + selected, + getLabel, + onChange +}: { + label?: string + values: readonly T[] + selected: readonly T[] + getLabel: (value: T) => string + onChange: (next: T[]) => void +}): React.JSX.Element { + const selectedSet = new Set(selected) + return ( + +
+ {values.map((value) => { + const active = selectedSet.has(value) + return ( + + ) + })} +
+
+ ) +} + +/** Single-choice segmented control for tri-state / presence / enum facets. */ +export function FacetChoice({ + label, + value, + options, + getLabel, + onChange +}: { + label: string + value: T + options: readonly T[] + getLabel?: (value: T) => string + onChange: (next: T) => void +}): React.JSX.Element { + return ( + +
+ {options.map((option) => ( + + ))} +
+
+ ) +} + +/** Free-form numeric threshold — deliberately not a fixed 30/90 enum. */ +export function FacetNumberField({ + label, + value, + placeholder, + suffix, + onChange +}: { + label: string + value: number | null + placeholder?: string + suffix?: string + onChange: (next: number | null) => void +}): React.JSX.Element { + return ( + +
+ onChange(parseWorkspaceCleanupFacetNumber(event.target.value))} + className="h-7 w-24 text-xs" + /> + {suffix ? {suffix} : null} +
+
+ ) +} + +export function FacetTextField({ + label, + value, + placeholder, + onChange +}: { + label: string + value: string + placeholder?: string + onChange: (next: string) => void +}): React.JSX.Element { + return ( + + onChange(event.target.value)} + className="h-7 text-xs" + /> + + ) +} + +export function FacetCheckbox({ + id, + label, + checked, + onChange +}: { + id: string + label: string + checked: boolean + onChange: (next: boolean) => void +}): React.JSX.Element { + const checkboxId = `workspace-cleanup-facet-${id}` + return ( +
+ onChange(next === true)} + /> + +
+ ) +} + +function FacetField({ + label, + children +}: { + label?: string + children: React.ReactNode +}): React.JSX.Element { + if (!label) { + return <>{children} + } + return ( +
+
{label}
+ {children} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts new file mode 100644 index 00000000000..8a5dd1a0ed9 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts @@ -0,0 +1,518 @@ +import { describe, expect, it } from 'vitest' +import { + buildWorkspaceCleanupFacetList, + countWorkspaceCleanupMeasuredRows +} from './workspace-cleanup-facets' +import { + buildWorkspaceCleanupSizeIndex, + buildWorkspaceCleanupWorktreeIndex, + getWorkspaceCleanupHostIdentity +} from './workspace-cleanup-host-identity' +import { + DAY, + FACET_NOW, + makeFacetCandidate, + makeFacets, + makeNamedFacets +} from './workspace-cleanup-facet.test.fixture' +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState +} from '../../../../shared/workspace-cleanup-filter-model' +import { + countWorkspaceCleanupFacetMatches, + filterWorkspaceCleanupFacets, + matchesWorkspaceCleanupFilters, + queryWorkspaceCleanupCandidates +} from './workspace-cleanup-query' +import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' + +function filters(patch: (state: WorkspaceCleanupFilterState) => void): WorkspaceCleanupFilterState { + const state = createDefaultWorkspaceCleanupFilterState() + patch(state) + return state +} + +const ANY = filters((state) => { + state.safety.dismissed = 'any' +}) + +describe('facet building', () => { + it('strips the ref prefix and falls back to local host', () => { + const facets = makeFacets({ worktree: { branch: 'refs/heads/feature/x' } }) + expect(facets.branch).toBe('feature/x') + expect(facets.hostId).toBe('local') + }) + + it('preserves a disconnected candidate execution host', () => { + const facets = makeFacets({ + candidate: { connectionId: 'builder', executionHostId: 'ssh:builder' }, + worktree: null + }) + expect(facets.hostId).toBe('ssh:builder') + }) + + it('treats a missing size entry as unsized rather than zero', () => { + expect(makeFacets().sizeBytes).toBeNull() + expect(makeFacets({ sizeBytes: 0 }).sizeBytes).toBe(0) + }) + + it('treats a missing visit stamp as never opened', () => { + expect(makeFacets().lastVisitedAt).toBeNull() + expect(makeFacets({ lastVisitedAt: FACET_NOW }).lastVisitedAt).toBe(FACET_NOW) + }) + + it('resolves the visible workspace status without changing statusless filter semantics', () => { + expect(makeFacets().workspaceStatus).toBeNull() + expect(makeFacets().workspaceStatusLabel).toBe('In progress') + expect(makeFacets({ worktree: { workspaceStatus: 'in-review' } }).workspaceStatusLabel).toBe( + 'In review' + ) + expect(makeFacets({ worktree: null }).workspaceStatusLabel).toBeNull() + }) + + it('only trusts sizes from an ok space scan', () => { + const scanned = [ + { worktreeId: 'a', status: 'ok', sizeBytes: 10 }, + { worktreeId: 'b', status: 'permission-denied', sizeBytes: 99 } + ] as WorkspaceSpaceWorktree[] + const index = buildWorkspaceCleanupSizeIndex(scanned) + expect(index.get('a')).toBe(10) + expect(index.has('b')).toBe(false) + }) + + it('counts measurements only for rows in the current fleet', () => { + const rows = [makeFacets({ sizeBytes: 10 }), makeNamedFacets('unsized')] + expect(countWorkspaceCleanupMeasuredRows(rows)).toBe(1) + }) + + it('indexes worktrees across repos by host and id', () => { + const index = buildWorkspaceCleanupWorktreeIndex({ + 'repo-1': [{ id: 'w1', hostId: 'local' }], + 'repo-2': [{ id: 'w2', hostId: 'ssh:builder' }] + }) + expect([...index.keys()]).toEqual([ + getWorkspaceCleanupHostIdentity('local', 'w1'), + getWorkspaceCleanupHostIdentity('ssh:builder', 'w2') + ]) + }) + + it('keeps same-id worktrees and sizes isolated by host', () => { + const candidates = [ + makeFacetCandidate({ executionHostId: 'local' }), + makeFacetCandidate({ executionHostId: 'ssh:builder', connectionId: 'builder' }) + ] + const sizes = buildWorkspaceCleanupSizeIndex( + [ + { + worktreeId: candidates[0]!.worktreeId, + executionHostId: 'local', + status: 'ok', + sizeBytes: 10 + }, + { + worktreeId: candidates[1]!.worktreeId, + executionHostId: 'ssh:builder', + status: 'ok', + sizeBytes: 20 + } + ], + candidates + ) + + expect(sizes.get(getWorkspaceCleanupHostIdentity('local', candidates[0]!.worktreeId))).toBe(10) + expect( + sizes.get(getWorkspaceCleanupHostIdentity('ssh:builder', candidates[1]!.worktreeId)) + ).toBe(20) + expect(sizes.has(candidates[0]!.worktreeId)).toBe(false) + }) + + it('does not apply a legacy size to duplicate candidate ids', () => { + const candidates = [ + makeFacetCandidate({ executionHostId: 'local' }), + makeFacetCandidate({ executionHostId: 'ssh:builder', connectionId: 'builder' }) + ] + const sizes = buildWorkspaceCleanupSizeIndex( + [{ worktreeId: candidates[0]!.worktreeId, status: 'ok', sizeBytes: 10 }], + candidates + ) + + expect(sizes.size).toBe(0) + }) + + it('does not apply duplicate legacy size rows to one current candidate', () => { + const candidate = makeFacetCandidate({ executionHostId: 'local' }) + const sizes = buildWorkspaceCleanupSizeIndex( + [ + { worktreeId: candidate.worktreeId, status: 'ok', sizeBytes: 10 }, + { worktreeId: candidate.worktreeId, status: 'ok', sizeBytes: 20 } + ], + [candidate] + ) + + expect(sizes.size).toBe(0) + }) + + it('does not assign legacy worktree metadata with multi-host repo ownership', () => { + const index = buildWorkspaceCleanupWorktreeIndex( + { 'repo-1': [{ id: 'w1', repoId: 'repo-1' }] }, + [ + { id: 'repo-1', executionHostId: 'local' }, + { id: 'repo-1', executionHostId: 'ssh:builder' } + ] + ) + + expect(index.size).toBe(0) + }) + + it('counts a workspace with nothing attached as completely empty', () => { + expect(makeFacets().isCompletelyEmpty).toBe(true) + expect(makeFacets({ worktree: { comment: 'keep' } }).isCompletelyEmpty).toBe(false) + expect(makeFacets({ review: {} }).isCompletelyEmpty).toBe(false) + expect(makeFacets({ worktree: { linkedIssue: 12 } }).isCompletelyEmpty).toBe(false) + }) +}) + +describe('query search', () => { + it('matches branch, host, status, provider, and blockers', () => { + const facets = makeFacets({ + candidate: { blockers: ['live-agent'] }, + worktree: { + branch: 'refs/heads/fix/login', + hostId: 'ssh:builder', + workspaceStatus: 'in-review' + }, + review: { provider: 'gitlab', label: 'MR #7' } + }) + for (const term of ['fix/login', 'ssh:builder', 'in-review', 'gitlab', 'live-agent']) { + expect(matchesWorkspaceCleanupFilters(facets, { ...ANY, query: term }, FACET_NOW)).toBe(true) + } + expect(matchesWorkspaceCleanupFilters(facets, { ...ANY, query: 'nope' }, FACET_NOW)).toBe(false) + }) +}) + +describe('activity filter', () => { + it('uses the user-chosen day threshold against the chosen signal', () => { + const recentlyVisited = makeFacets({ lastVisitedAt: FACET_NOW - 5 * DAY }) + const staleVisit = makeFacets({ lastVisitedAt: FACET_NOW - 45 * DAY }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.activity.idleSignal = 'last-visited' + s.activity.idleMinDays = 30 + }) + expect(matchesWorkspaceCleanupFilters(recentlyVisited, state, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(staleVisit, state, FACET_NOW)).toBe(true) + }) + + it('reads a missing signal as maximally idle', () => { + const state = filters((s) => { + s.safety.dismissed = 'any' + s.activity.idleMinDays = 365 + }) + expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true) + }) + + it('separates background activity from a real visit', () => { + const busyButUnopened = makeFacets({ candidate: { lastActivityAt: FACET_NOW } }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.activity.neverVisited = true + }) + expect(matchesWorkspaceCleanupFilters(busyButUnopened, state, FACET_NOW)).toBe(true) + expect( + matchesWorkspaceCleanupFilters(makeFacets({ lastVisitedAt: FACET_NOW }), state, FACET_NOW) + ).toBe(false) + }) +}) + +describe('size filter', () => { + it('keeps unsized rows unless the user opts out', () => { + const unsized = makeFacets() + expect(matchesWorkspaceCleanupFilters(unsized, ANY, FACET_NOW)).toBe(true) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.size.includeUnsized = false + }) + expect(matchesWorkspaceCleanupFilters(unsized, state, FACET_NOW)).toBe(false) + }) + + it('applies min and max byte bounds', () => { + const state = filters((s) => { + s.safety.dismissed = 'any' + s.size.minBytes = 100 + s.size.maxBytes = 200 + }) + expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 150 }), state, FACET_NOW)).toBe( + true + ) + expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 99 }), state, FACET_NOW)).toBe( + false + ) + expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 201 }), state, FACET_NOW)).toBe( + false + ) + }) +}) + +describe('status filter', () => { + it('matches open workspace-status strings and statusless rows separately', () => { + const inReview = makeFacets({ worktree: { workspaceStatus: 'in-review' } }) + const statusless = makeFacets() + const state = filters((s) => { + s.safety.dismissed = 'any' + s.status.workspaceStatuses = ['in-review'] + s.status.matchStatusless = false + }) + expect(matchesWorkspaceCleanupFilters(inReview, state, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(statusless, state, FACET_NOW)).toBe(false) + state.status.matchStatusless = true + expect(matchesWorkspaceCleanupFilters(statusless, state, FACET_NOW)).toBe(true) + }) + + it('excludes statusless rows even when no named status is selected', () => { + const state = filters((s) => { + s.safety.dismissed = 'any' + s.status.workspaceStatuses = [] + s.status.matchStatusless = false + }) + expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(false) + expect( + matchesWorkspaceCleanupFilters( + makeFacets({ worktree: { workspaceStatus: 'in-review' } }), + state, + FACET_NOW + ) + ).toBe(true) + }) + + it('applies tri-state flags', () => { + const archived = makeFacets({ worktree: { isArchived: true } }) + const only = filters((s) => { + s.safety.dismissed = 'any' + s.status.archived = 'only' + }) + const exclude = filters((s) => { + s.safety.dismissed = 'any' + s.status.archived = 'exclude' + }) + expect(matchesWorkspaceCleanupFilters(archived, only, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(archived, exclude, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), exclude, FACET_NOW)).toBe(true) + }) +}) + +describe('agent, git, review, ticket, context, and location filters', () => { + it('filters by live agent rollup and retained done agents', () => { + const state = filters((s) => { + s.safety.dismissed = 'any' + s.agent.states = ['permission'] + }) + expect( + matchesWorkspaceCleanupFilters(makeFacets({ agentStatus: 'permission' }), state, FACET_NOW) + ).toBe(true) + expect( + matchesWorkspaceCleanupFilters(makeFacets({ agentStatus: 'working' }), state, FACET_NOW) + ).toBe(false) + expect(makeFacets().agentState).toBe('idle') + + const retained = filters((s) => { + s.safety.dismissed = 'any' + s.agent.retainedDoneAgents = 'only' + }) + const withRetained = makeFacets({ + candidate: { + localContext: { + ...makeFacetCandidate().localContext, + retainedDoneAgentCount: 2 + } + } + }) + expect(matchesWorkspaceCleanupFilters(withRetained, retained, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(makeFacets(), retained, FACET_NOW)).toBe(false) + }) + + it('filters by git state, ahead counts, branch text, prunable and locked', () => { + const unpushed = makeFacets({ + candidate: { git: { clean: true, upstreamAhead: 3, upstreamBehind: 1, checkedAt: 1 } } + }) + expect(unpushed.gitState).toBe('unpushed') + const state = filters((s) => { + s.safety.dismissed = 'any' + s.git.states = ['unpushed'] + s.git.minAhead = 2 + }) + expect(matchesWorkspaceCleanupFilters(unpushed, state, FACET_NOW)).toBe(true) + state.git.minAhead = 4 + expect(matchesWorkspaceCleanupFilters(unpushed, state, FACET_NOW)).toBe(false) + + const branchState = filters((s) => { + s.safety.dismissed = 'any' + s.git.branchQuery = 'ALPH' + }) + expect(matchesWorkspaceCleanupFilters(makeFacets(), branchState, FACET_NOW)).toBe(true) + + const prunable = filters((s) => { + s.safety.dismissed = 'any' + s.git.prunable = 'only' + }) + expect( + matchesWorkspaceCleanupFilters( + makeFacets({ worktree: { prunable: true } }), + prunable, + FACET_NOW + ) + ).toBe(true) + expect(matchesWorkspaceCleanupFilters(makeFacets(), prunable, FACET_NOW)).toBe(false) + }) + + it('keeps review filtering provider-general and treats draft as a state', () => { + const draftMr = makeFacets({ review: { provider: 'gitlab', state: 'draft', label: 'MR #7' } }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.review.presence = 'some' + s.review.states = ['draft'] + s.review.providers = ['gitlab', 'bitbucket'] + }) + expect(matchesWorkspaceCleanupFilters(draftMr, state, FACET_NOW)).toBe(true) + state.review.providers = ['github'] + expect(matchesWorkspaceCleanupFilters(draftMr, state, FACET_NOW)).toBe(false) + + const none = filters((s) => { + s.safety.dismissed = 'any' + s.review.presence = 'none' + }) + expect(matchesWorkspaceCleanupFilters(makeFacets(), none, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(draftMr, none, FACET_NOW)).toBe(false) + }) + + it('filters by ticket source', () => { + const linear = makeFacets({ worktree: { linkedLinearIssue: 'STA-1' } }) + expect(linear.ticketSources).toEqual(['linear']) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.ticket.presence = 'some' + s.ticket.sources = ['linear'] + }) + expect(matchesWorkspaceCleanupFilters(linear, state, FACET_NOW)).toBe(true) + expect( + matchesWorkspaceCleanupFilters(makeFacets({ worktree: { linkedIssue: 9 } }), state, FACET_NOW) + ).toBe(false) + }) + + it('filters by local context and completely-empty', () => { + const busy = makeFacets({ + candidate: { + localContext: { ...makeFacetCandidate().localContext, terminalTabCount: 2 } + } + }) + const hasContext = filters((s) => { + s.safety.dismissed = 'any' + s.context.presence = 'some' + }) + expect(matchesWorkspaceCleanupFilters(busy, hasContext, FACET_NOW)).toBe(true) + const empty = filters((s) => { + s.safety.dismissed = 'any' + s.context.completelyEmpty = true + }) + expect(matchesWorkspaceCleanupFilters(busy, empty, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), empty, FACET_NOW)).toBe(true) + }) + + it('filters by execution host, repo, and path prefix', () => { + const remote = makeFacets({ worktree: { hostId: 'ssh:builder' } }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.location.hostIds = ['ssh:builder'] + s.location.repoIds = ['repo-1'] + s.location.pathPrefix = '/repo/' + }) + expect(matchesWorkspaceCleanupFilters(remote, state, FACET_NOW)).toBe(true) + state.location.pathPrefix = '/other/' + expect(matchesWorkspaceCleanupFilters(remote, state, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(false) + }) +}) + +describe('safety filter', () => { + it('supports any-of and none-of blocker selection', () => { + const live = makeFacets({ + candidate: { blockers: ['live-agent', 'pinned'], tier: 'protected' } + }) + const anyOf = filters((s) => { + s.safety.dismissed = 'any' + s.safety.blockers = ['live-agent'] + s.safety.blockerMode = 'any-of' + }) + const noneOf = filters((s) => { + s.safety.dismissed = 'any' + s.safety.blockers = ['live-agent'] + s.safety.blockerMode = 'none-of' + }) + expect(matchesWorkspaceCleanupFilters(live, anyOf, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(live, noneOf, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), noneOf, FACET_NOW)).toBe(true) + }) + + it('includes dismissed rows by default and narrows them on demand', () => { + const dismissed = makeFacets({ dismissed: true }) + expect( + matchesWorkspaceCleanupFilters( + dismissed, + createDefaultWorkspaceCleanupFilterState(), + FACET_NOW + ) + ).toBe(true) + const exclude = filters((s) => { + s.safety.dismissed = 'exclude' + }) + expect(matchesWorkspaceCleanupFilters(dismissed, exclude, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), exclude, FACET_NOW)).toBe(true) + const only = filters((s) => { + s.safety.dismissed = 'only' + }) + expect(matchesWorkspaceCleanupFilters(dismissed, only, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(makeFacets(), only, FACET_NOW)).toBe(false) + }) + + it('restricts to rows the policy would actually let the user delete', () => { + const blocked = makeFacets({ candidate: { blockers: ['dirty-files'], tier: 'review' } }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.safety.selectableOnly = true + }) + expect(matchesWorkspaceCleanupFilters(blocked, state, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true) + }) +}) + +describe('query pipeline', () => { + it('reports counts and selectable ids', () => { + const rows = [ + makeNamedFacets('alpha'), + makeNamedFacets('beta', { candidate: { blockers: ['dirty-files'], tier: 'review' } }) + ] + expect(filterWorkspaceCleanupFacets(rows, ANY, FACET_NOW)).toHaveLength(2) + const result = queryWorkspaceCleanupCandidates( + buildWorkspaceCleanupFacetList([makeFacetCandidate()]).map((f) => f.candidate), + { filters: ANY, sort: { field: 'name', direction: 'asc' } }, + {}, + FACET_NOW + ) + expect(result.totalCount).toBe(1) + expect(result.matchedCount).toBe(1) + expect(result.selectableWorktreeIds).toEqual(['repo-1::/repo/alpha']) + }) + + it('counts per-facet matches independently of the other groups', () => { + const rows = [makeNamedFacets('alpha', { sizeBytes: 10 }), makeNamedFacets('beta')] + const state = filters((s) => { + s.safety.dismissed = 'any' + s.size.includeUnsized = false + s.git.states = ['dirty'] + }) + const counts = countWorkspaceCleanupFacetMatches(rows, state, FACET_NOW) + expect(counts.size).toBe(1) + expect(counts.git).toBe(0) + expect(counts.safety).toBe(2) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts new file mode 100644 index 00000000000..9ea2cd09adf --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts @@ -0,0 +1,175 @@ +import { translate } from '@/i18n/i18n' +import type { HostedReviewProvider } from '../../../../shared/hosted-review' +import type { WorkspaceCleanupTier } from '../../../../shared/workspace-cleanup' +import type { + WorkspaceCleanupAgentState, + WorkspaceCleanupBlockerMode, + WorkspaceCleanupGitState, + WorkspaceCleanupIdleSignal, + WorkspaceCleanupPresence, + WorkspaceCleanupReviewState, + WorkspaceCleanupSortField, + WorkspaceCleanupTicketSource, + WorkspaceCleanupTriState +} from '../../../../shared/workspace-cleanup-filter-model' + +export function getWorkspaceCleanupSortFieldLabel(field: WorkspaceCleanupSortField): string { + switch (field) { + case 'last-activity': + return translate('components.workspace.cleanup.browse.sort.lastActivity', 'Last activity') + case 'last-visited': + return translate('components.workspace.cleanup.browse.sort.lastVisited', 'Last opened') + case 'created': + return translate('components.workspace.cleanup.browse.sort.created', 'Created') + case 'size': + return translate('components.workspace.cleanup.browse.sort.size', 'Size') + case 'name': + return translate('components.workspace.cleanup.browse.sort.name', 'Workspace') + case 'repo': + return translate('components.workspace.cleanup.browse.sort.repo', 'Repository') + case 'path': + return translate('components.workspace.cleanup.browse.sort.path', 'Path') + case 'host': + return translate('components.workspace.cleanup.browse.sort.host', 'Host') + case 'workspace-status': + return translate('components.workspace.cleanup.browse.sort.workspaceStatus', 'Status') + case 'agent': + return translate('components.workspace.cleanup.browse.sort.agent', 'Agent') + case 'git': + return translate('components.workspace.cleanup.browse.sort.git', 'Git') + case 'ahead': + return translate('components.workspace.cleanup.browse.sort.ahead', 'Ahead') + case 'behind': + return translate('components.workspace.cleanup.browse.sort.behind', 'Behind') + case 'branch': + return translate('components.workspace.cleanup.browse.sort.branch', 'Branch') + case 'review': + return translate('components.workspace.cleanup.browse.sort.review', 'Review') + case 'ticket': + return translate('components.workspace.cleanup.browse.sort.ticket', 'Ticket') + case 'local-context': + return translate('components.workspace.cleanup.browse.sort.localContext', 'Open context') + case 'tier': + return translate('components.workspace.cleanup.browse.sort.tier', 'Safety') + case 'blocker-count': + return translate('components.workspace.cleanup.browse.sort.blockerCount', 'Blockers') + } +} + +export function getWorkspaceCleanupGitStateLabel(state: WorkspaceCleanupGitState): string { + switch (state) { + case 'clean': + return translate('components.workspace.cleanup.browse.git.clean', 'Clean') + case 'dirty': + return translate('components.workspace.cleanup.browse.git.dirty', 'Uncommitted changes') + case 'unpushed': + return translate('components.workspace.cleanup.browse.git.unpushed', 'Unpushed commits') + case 'unknown': + return translate('components.workspace.cleanup.browse.git.unknown', 'Not checked') + } +} + +export function getWorkspaceCleanupAgentStateLabel(state: WorkspaceCleanupAgentState): string { + switch (state) { + case 'working': + return translate('components.workspace.cleanup.browse.agent.working', 'Working') + case 'permission': + return translate('components.workspace.cleanup.browse.agent.permission', 'Waiting on you') + case 'idle': + return translate('components.workspace.cleanup.browse.agent.idle', 'Idle') + } +} + +export function getWorkspaceCleanupReviewStateLabel(state: WorkspaceCleanupReviewState): string { + switch (state) { + case 'open': + return translate('components.workspace.cleanup.browse.review.open', 'Open') + case 'draft': + return translate('components.workspace.cleanup.browse.review.draft', 'Draft') + case 'merged': + return translate('components.workspace.cleanup.browse.review.merged', 'Merged') + case 'closed': + return translate('components.workspace.cleanup.browse.review.closed', 'Closed') + case 'unknown': + return translate('components.workspace.cleanup.browse.review.unknown', 'Unknown') + } +} + +/** Provider-general: GitLab MRs and Azure DevOps PRs are first-class here. */ +export function getWorkspaceCleanupReviewProviderLabel(provider: HostedReviewProvider): string { + switch (provider) { + case 'github': + return 'GitHub' + case 'gitlab': + return 'GitLab' + case 'bitbucket': + return 'Bitbucket' + case 'azure-devops': + return 'Azure DevOps' + case 'gitea': + return 'Gitea' + case 'unsupported': + return translate('components.workspace.cleanup.browse.review.otherProvider', 'Other') + } +} + +export function getWorkspaceCleanupTicketSourceLabel(source: WorkspaceCleanupTicketSource): string { + switch (source) { + case 'work-item': + return translate('components.workspace.cleanup.browse.ticket.workItem', 'Work item') + case 'linear': + return translate('components.workspace.cleanup.browse.ticket.linear', 'Linear') + case 'issue': + return translate('components.workspace.cleanup.browse.ticket.issue', 'Issue') + } +} + +export function getWorkspaceCleanupTierLabel(tier: WorkspaceCleanupTier): string { + switch (tier) { + case 'ready': + return translate('components.workspace.cleanup.browse.tier.ready', 'Ready') + case 'review': + return translate('components.workspace.cleanup.browse.tier.review', 'Needs review') + case 'protected': + return translate('components.workspace.cleanup.browse.tier.protected', 'Protected') + } +} + +export function getWorkspaceCleanupIdleSignalLabel(signal: WorkspaceCleanupIdleSignal): string { + switch (signal) { + case 'last-visited': + return translate('components.workspace.cleanup.browse.idleSignal.lastVisited', 'Not opened') + case 'last-activity': + return translate('components.workspace.cleanup.browse.idleSignal.lastActivity', 'No activity') + case 'created': + return translate('components.workspace.cleanup.browse.idleSignal.created', 'Created before') + } +} + +export function getWorkspaceCleanupTriStateLabel(value: WorkspaceCleanupTriState): string { + switch (value) { + case 'any': + return translate('components.workspace.cleanup.browse.triState.any', 'Any') + case 'only': + return translate('components.workspace.cleanup.browse.triState.only', 'Only') + case 'exclude': + return translate('components.workspace.cleanup.browse.triState.exclude', 'Exclude') + } +} + +export function getWorkspaceCleanupPresenceLabel(value: WorkspaceCleanupPresence): string { + switch (value) { + case 'any': + return translate('components.workspace.cleanup.browse.presence.any', 'Any') + case 'some': + return translate('components.workspace.cleanup.browse.presence.some', 'Has one') + case 'none': + return translate('components.workspace.cleanup.browse.presence.none', 'Has none') + } +} + +export function getWorkspaceCleanupBlockerModeLabel(value: WorkspaceCleanupBlockerMode): string { + return value === 'any-of' + ? translate('components.workspace.cleanup.browse.blockerModeAny', 'Has any') + : translate('components.workspace.cleanup.browse.blockerModeNone', 'Has none') +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts new file mode 100644 index 00000000000..988a31c1489 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts @@ -0,0 +1,214 @@ +import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' +import type { + WorkspaceCleanupActivityFilter, + WorkspaceCleanupAgentFilter, + WorkspaceCleanupContextFilterState, + WorkspaceCleanupGitFilterState, + WorkspaceCleanupLocationFilter, + WorkspaceCleanupPresence, + WorkspaceCleanupReviewFilterState, + WorkspaceCleanupSafetyFilter, + WorkspaceCleanupSizeFilter, + WorkspaceCleanupStatusFilter, + WorkspaceCleanupTicketFilter, + WorkspaceCleanupTriState +} from '../../../../shared/workspace-cleanup-filter-model' + +export const WORKSPACE_CLEANUP_DAY_MS = 24 * 60 * 60 * 1000 + +export function matchesWorkspaceCleanupTriState( + filter: WorkspaceCleanupTriState, + value: boolean +): boolean { + return filter === 'any' || (filter === 'only' ? value : !value) +} + +export function matchesWorkspaceCleanupPresence( + filter: WorkspaceCleanupPresence, + value: boolean +): boolean { + return filter === 'any' || (filter === 'some' ? value : !value) +} + +export function matchesWorkspaceCleanupActivity( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupActivityFilter, + now: number +): boolean { + if (filter.neverVisited && facets.lastVisitedAt !== null) { + return false + } + if (filter.idleMinDays === null) { + return true + } + const signalAt = getIdleSignalAt(facets, filter) + // Why: a missing signal means Orca has no evidence of recent use, so it + // reads as maximally idle rather than being filtered out. + return signalAt === null || now - signalAt >= filter.idleMinDays * WORKSPACE_CLEANUP_DAY_MS +} + +export function matchesWorkspaceCleanupSize( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupSizeFilter +): boolean { + if (facets.sizeBytes === null) { + return filter.includeUnsized + } + if (filter.minBytes !== null && facets.sizeBytes < filter.minBytes) { + return false + } + return filter.maxBytes === null || facets.sizeBytes <= filter.maxBytes +} + +export function matchesWorkspaceCleanupStatus( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupStatusFilter +): boolean { + if (!matchesWorkspaceStatusList(facets, filter)) { + return false + } + return ( + matchesWorkspaceCleanupTriState(filter.archived, facets.isArchived) && + matchesWorkspaceCleanupTriState(filter.pinned, facets.isPinned) && + matchesWorkspaceCleanupTriState(filter.unread, facets.isUnread) && + matchesWorkspaceCleanupTriState(filter.comment, facets.hasComment) + ) +} + +export function matchesWorkspaceCleanupAgent( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupAgentFilter +): boolean { + if (filter.states.length > 0 && !filter.states.includes(facets.agentState)) { + return false + } + return matchesWorkspaceCleanupTriState( + filter.retainedDoneAgents, + facets.retainedDoneAgentCount > 0 + ) +} + +export function matchesWorkspaceCleanupGit( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupGitFilterState +): boolean { + if (filter.states.length > 0 && !filter.states.includes(facets.gitState)) { + return false + } + if (filter.minAhead !== null && (facets.upstreamAhead ?? 0) < filter.minAhead) { + return false + } + if (filter.minBehind !== null && (facets.upstreamBehind ?? 0) < filter.minBehind) { + return false + } + const branchQuery = filter.branchQuery.trim().toLowerCase() + if (branchQuery && !facets.branch.toLowerCase().includes(branchQuery)) { + return false + } + return ( + matchesWorkspaceCleanupTriState(filter.prunable, facets.isPrunable) && + matchesWorkspaceCleanupTriState(filter.locked, facets.isLocked) + ) +} + +export function matchesWorkspaceCleanupReview( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupReviewFilterState +): boolean { + if (!matchesWorkspaceCleanupPresence(filter.presence, facets.review.hasReview)) { + return false + } + if (filter.states.length > 0) { + if (facets.reviewState === null || !filter.states.includes(facets.reviewState)) { + return false + } + } + if (filter.providers.length === 0) { + return true + } + return facets.review.provider !== null && filter.providers.includes(facets.review.provider) +} + +export function matchesWorkspaceCleanupTicket( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupTicketFilter +): boolean { + if (!matchesWorkspaceCleanupPresence(filter.presence, facets.ticketSources.length > 0)) { + return false + } + return ( + filter.sources.length === 0 || + filter.sources.some((source) => facets.ticketSources.includes(source)) + ) +} + +export function matchesWorkspaceCleanupContext( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupContextFilterState +): boolean { + if (!matchesWorkspaceCleanupPresence(filter.presence, facets.hasLocalContext)) { + return false + } + return !filter.completelyEmpty || facets.isCompletelyEmpty +} + +export function matchesWorkspaceCleanupLocation( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupLocationFilter +): boolean { + if (filter.hostIds.length > 0 && !filter.hostIds.includes(facets.hostId)) { + return false + } + if (filter.repoIds.length > 0 && !filter.repoIds.includes(facets.repoId)) { + return false + } + const prefix = filter.pathPrefix.trim() + return prefix.length === 0 || facets.path.startsWith(prefix) +} + +export function matchesWorkspaceCleanupSafety( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupSafetyFilter +): boolean { + if (filter.tiers.length > 0 && !filter.tiers.includes(facets.tier)) { + return false + } + if (!matchesWorkspaceCleanupTriState(filter.dismissed, facets.isDismissed)) { + return false + } + if (filter.selectableOnly && !facets.isSelectable) { + return false + } + if (filter.blockers.length === 0) { + return true + } + const hit = filter.blockers.some((blocker) => facets.blockers.includes(blocker)) + return filter.blockerMode === 'any-of' ? hit : !hit +} + +function matchesWorkspaceStatusList( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupStatusFilter +): boolean { + if (facets.workspaceStatus === null) { + return filter.matchStatusless + } + return ( + filter.workspaceStatuses.length === 0 || + filter.workspaceStatuses.includes(facets.workspaceStatus) + ) +} + +function getIdleSignalAt( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupActivityFilter +): number | null { + switch (filter.idleSignal) { + case 'last-visited': + return facets.lastVisitedAt + case 'last-activity': + return facets.lastActivityAt + case 'created': + return facets.createdAt + } +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-panel-model.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-panel-model.ts new file mode 100644 index 00000000000..60a097a00e1 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-panel-model.ts @@ -0,0 +1,46 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { HostedReviewProvider } from '../../../../shared/hosted-review' +import type { WorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' + +export type WorkspaceCleanupFacetCounts = Record< + keyof Omit, + number +> + +/** Facet values discovered from the loaded fleet, not a hardcoded enum. */ +export type WorkspaceCleanupFacetOptions = { + workspaceStatuses: readonly { id: string; label: string }[] + hostIds: readonly ExecutionHostId[] + repos: readonly { id: string; label: string }[] + reviewProviders: readonly HostedReviewProvider[] +} + +export type WorkspaceCleanupFacetGroupProps = { + filters: WorkspaceCleanupFilterState + counts: WorkspaceCleanupFacetCounts + totalCount: number + options: WorkspaceCleanupFacetOptions + onPatch: ( + key: K, + value: Partial | WorkspaceCleanupFilterState[K] + ) => void +} + +export const WORKSPACE_CLEANUP_MEGABYTE = 1024 * 1024 + +export function toMegabytes(bytes: number | null): number | null { + return bytes === null ? null : Math.round(bytes / WORKSPACE_CLEANUP_MEGABYTE) +} + +export function fromMegabytes(megabytes: number | null): number | null { + return megabytes === null ? null : Math.round(megabytes * WORKSPACE_CLEANUP_MEGABYTE) +} + +export function parseWorkspaceCleanupFacetNumber(raw: string): number | null { + const trimmed = raw.trim() + if (trimmed === '') { + return null + } + const parsed = Number(trimmed) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts new file mode 100644 index 00000000000..e6949e35e57 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts @@ -0,0 +1,143 @@ +import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' +import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/types' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + buildWorkspaceCleanupFacets, + type WorkspaceCleanupFacetSources, + type WorkspaceCleanupFacets +} from './workspace-cleanup-facets' +import { + getWorkspaceCleanupReviewInfo, + type WorkspaceCleanupRendererStateInputs, + type WorkspaceCleanupReviewInfo, + type WorkspaceCleanupReviewLookup +} from './workspace-cleanup-presentation' +import { + getWorkspaceCleanupCandidateHostId, + getWorkspaceCleanupHostIdentity +} from './workspace-cleanup-host-identity' + +/** + * Streaming ticks replace only the candidate objects they touched, so all + * per-candidate work here is cached on candidate object identity; unchanged + * rows reuse their previous facet/review objects and the row array itself + * keeps identity when nothing changed, which is what lets React.memo and + * downstream memos hold. + * + * The caches are owned by useMemo keyed on their invalidation inputs (never + * refs — ref writes during render are unsafe under concurrent rendering). + * Filling them during render is safe because they are content-addressed and + * idempotent: a replayed or discarded render stores the same value for the + * same key. + */ +export type WorkspaceCleanupReviewInfoCache = WeakMap< + WorkspaceCleanupCandidate, + WorkspaceCleanupReviewInfo +> + +export function createWorkspaceCleanupReviewInfoCache(): WorkspaceCleanupReviewInfoCache { + return new WeakMap() +} + +export function computeWorkspaceCleanupReviewInfoIndex(args: { + candidates: readonly WorkspaceCleanupCandidate[] + candidateIdCounts: ReadonlyMap + reviewSources: WorkspaceCleanupRendererStateInputs + reviewLookup: WorkspaceCleanupReviewLookup + cache: WorkspaceCleanupReviewInfoCache +}): Map { + const { candidates, candidateIdCounts, reviewSources, reviewLookup, cache } = args + const infos = new Map() + for (const candidate of candidates) { + let info = cache.get(candidate) + if (info === undefined) { + info = getWorkspaceCleanupReviewInfo(candidate, reviewSources, reviewLookup) + cache.set(candidate, info) + } + infos.set( + getWorkspaceCleanupHostIdentity( + getWorkspaceCleanupCandidateHostId(candidate), + candidate.worktreeId + ), + info + ) + if (candidateIdCounts.get(candidate.worktreeId) === 1) { + infos.set(candidate.worktreeId, info) + } + } + return infos +} + +type FacetCacheEntry = { + facet: WorkspaceCleanupFacets + sizeBytes: number | null | undefined + lastVisitedAt: number | undefined + agentState: LiveAgentWorktreeStatus | undefined + review: WorkspaceCleanupReviewInfo | undefined + isDismissed: boolean +} + +export type WorkspaceCleanupFacetListCache = { + byCandidate: WeakMap + lastList: WorkspaceCleanupFacets[] +} + +export function createWorkspaceCleanupFacetListCache(): WorkspaceCleanupFacetListCache { + return { byCandidate: new WeakMap(), lastList: [] } +} + +export function computeWorkspaceCleanupFacetList(args: { + candidates: readonly WorkspaceCleanupCandidate[] + sources: Required> & { + workspaceStatuses: readonly WorkspaceStatusDefinition[] + } + cache: WorkspaceCleanupFacetListCache +}): WorkspaceCleanupFacets[] { + const { candidates, sources, cache } = args + const list = candidates.map((candidate) => { + const hostIdentity = getWorkspaceCleanupHostIdentity( + getWorkspaceCleanupCandidateHostId(candidate), + candidate.worktreeId + ) + // Per-id projections mirror buildWorkspaceCleanupFacets' lookups; the + // cached facet is valid only while every projected input is unchanged. + const sizeBytes = + sources.sizeBytesByWorktreeId.get(hostIdentity) ?? + sources.sizeBytesByWorktreeId.get(candidate.worktreeId) + const lastVisitedAt = sources.lastVisitedAtByWorktreeId[candidate.worktreeId] + const agentState = sources.liveAgentStatusByWorktreeId.get(candidate.worktreeId) + const review = + sources.reviewInfoByWorktreeId.get(hostIdentity) ?? + sources.reviewInfoByWorktreeId.get(candidate.worktreeId) + const isDismissed = sources.dismissedWorktreeIds.has(candidate.worktreeId) + const cached = cache.byCandidate.get(candidate) + if ( + cached && + cached.sizeBytes === sizeBytes && + cached.lastVisitedAt === lastVisitedAt && + cached.agentState === agentState && + cached.review === review && + cached.isDismissed === isDismissed + ) { + return cached.facet + } + const facet = buildWorkspaceCleanupFacets(candidate, sources) + cache.byCandidate.set(candidate, { + facet, + sizeBytes, + lastVisitedAt, + agentState, + review, + isDismissed + }) + return facet + }) + // Why: reusing the previous array identity when no row changed lets every + // downstream memo skip its O(N) pass on no-op streaming ticks. + const previous = cache.lastList + if (previous.length === list.length && list.every((facet, index) => facet === previous[index])) { + return previous + } + cache.lastList = list + return list +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts new file mode 100644 index 00000000000..4da525a1352 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { DAY, FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture' +import { + isWorkspaceCleanupAbsentLastSortField, + sortWorkspaceCleanupFacets +} from './workspace-cleanup-facet-sort' +import type { WorkspaceCleanupSortField } from '../../../../shared/workspace-cleanup-filter-model' + +function names( + rows: ReturnType[], + field: WorkspaceCleanupSortField, + direction: 'asc' | 'desc' +): string[] { + return sortWorkspaceCleanupFacets(rows, { field, direction }).map((row) => row.displayName) +} + +describe('absent values', () => { + it('sinks unsized workspaces to the bottom in both directions', () => { + const rows = [ + makeNamedFacets('unsized'), + makeNamedFacets('small', { sizeBytes: 10 }), + makeNamedFacets('big', { sizeBytes: 900 }) + ] + expect(names(rows, 'size', 'desc')).toEqual(['big', 'small', 'unsized']) + expect(names(rows, 'size', 'asc')).toEqual(['small', 'big', 'unsized']) + }) + + it('sinks never-visited workspaces to the bottom in both directions', () => { + const rows = [ + makeNamedFacets('never'), + makeNamedFacets('old', { lastVisitedAt: FACET_NOW - 50 * DAY }), + makeNamedFacets('recent', { lastVisitedAt: FACET_NOW }) + ] + expect(names(rows, 'last-visited', 'asc')).toEqual(['old', 'recent', 'never']) + expect(names(rows, 'last-visited', 'desc')).toEqual(['recent', 'old', 'never']) + }) + + it('sinks review-less and ticket-less workspaces to the bottom', () => { + const rows = [ + makeNamedFacets('none'), + makeNamedFacets('merged', { review: { state: 'merged' } }), + makeNamedFacets('open', { review: { state: 'open' } }) + ] + expect(names(rows, 'review', 'desc')).toEqual(['open', 'merged', 'none']) + + const ticketRows = [ + makeNamedFacets('untracked'), + makeNamedFacets('linear', { worktree: { linkedLinearIssue: 'STA-1' } }) + ] + expect(names(ticketRows, 'ticket', 'asc')).toEqual(['linear', 'untracked']) + }) + + it('marks exactly the optional-value fields as absent-last', () => { + expect(isWorkspaceCleanupAbsentLastSortField('size')).toBe(true) + expect(isWorkspaceCleanupAbsentLastSortField('name')).toBe(false) + }) +}) + +describe('ranked fields', () => { + it('orders git states from safest to riskiest', () => { + const rows = [ + makeNamedFacets('unpushed', { + candidate: { git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } } + }), + makeNamedFacets('clean'), + makeNamedFacets('dirty', { + candidate: { git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } } + }), + makeNamedFacets('unknown', { + candidate: { git: { clean: null, upstreamAhead: 0, upstreamBehind: 0, checkedAt: null } } + }) + ] + expect(names(rows, 'git', 'asc')).toEqual(['clean', 'unknown', 'dirty', 'unpushed']) + }) + + it('orders tiers ready to protected and agents idle to permission', () => { + const tierRows = [ + makeNamedFacets('protectedRow', { candidate: { tier: 'protected' } }), + makeNamedFacets('readyRow', { candidate: { tier: 'ready' } }), + makeNamedFacets('reviewRow', { candidate: { tier: 'review' } }) + ] + expect(names(tierRows, 'tier', 'asc')).toEqual(['readyRow', 'reviewRow', 'protectedRow']) + + const agentRows = [ + makeNamedFacets('permission', { agentStatus: 'permission' }), + makeNamedFacets('idle'), + makeNamedFacets('working', { agentStatus: 'working' }) + ] + expect(names(agentRows, 'agent', 'desc')).toEqual(['permission', 'working', 'idle']) + }) + + it('breaks equal blocker counts by the worst blocker', () => { + const rows = [ + makeNamedFacets('softest', { candidate: { blockers: ['git-status-error'] } }), + makeNamedFacets('hardest', { candidate: { blockers: ['main-worktree'] } }) + ] + expect(names(rows, 'blocker-count', 'desc')).toEqual(['hardest', 'softest']) + }) +}) + +describe('tie-breaks', () => { + it('falls back to activity, repo, name, then worktree id', () => { + const rows = [ + makeNamedFacets('zeta', { candidate: { lastActivityAt: FACET_NOW - DAY } }), + makeNamedFacets('alpha', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } }) + ] + // Same size on both sides, so only the tie-break chain can order them. + expect(names(rows, 'size', 'desc')).toEqual(['alpha', 'zeta']) + }) + + it('keeps tie-broken rows in the same order when direction flips', () => { + const rows = [ + makeNamedFacets('b', { candidate: { lastActivityAt: FACET_NOW - DAY } }), + makeNamedFacets('a', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } }) + ] + expect(names(rows, 'git', 'asc')).toEqual(names(rows, 'git', 'desc')) + }) + + it('does not mutate the input array', () => { + const rows = [makeNamedFacets('b'), makeNamedFacets('a')] + const before = rows.map((row) => row.displayName) + sortWorkspaceCleanupFacets(rows, { field: 'name', direction: 'asc' }) + expect(rows.map((row) => row.displayName)).toEqual(before) + }) +}) + +describe('text fields', () => { + it('sorts by name, repo, path, branch, and host', () => { + const rows = [makeNamedFacets('charlie'), makeNamedFacets('alpha'), makeNamedFacets('bravo')] + expect(names(rows, 'name', 'asc')).toEqual(['alpha', 'bravo', 'charlie']) + expect(names(rows, 'path', 'desc')).toEqual(['charlie', 'bravo', 'alpha']) + expect(names(rows, 'branch', 'asc')).toEqual(['alpha', 'bravo', 'charlie']) + + const hostRows = [ + makeNamedFacets('remote', { worktree: { hostId: 'ssh:builder' } }), + makeNamedFacets('here', { worktree: { hostId: 'local' } }) + ] + expect(names(hostRows, 'host', 'asc')).toEqual(['here', 'remote']) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts new file mode 100644 index 00000000000..50787aa6bd9 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts @@ -0,0 +1,190 @@ +import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' +import { + getWorkspaceCleanupAgentRank, + getWorkspaceCleanupBlockerSeverity, + getWorkspaceCleanupGitRank, + getWorkspaceCleanupReviewRank, + getWorkspaceCleanupTierRank +} from '../../../../shared/workspace-cleanup-facet-rankings' +import type { + WorkspaceCleanupSortDirectionState, + WorkspaceCleanupSortField, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' + +/** + * Fields whose value can be absent. Absent rows always sink to the bottom in + * BOTH directions — flipping direction should reorder the rows that carry the + * signal, not promote the ones that carry nothing. + */ +const ABSENT_LAST_FIELDS: ReadonlySet = new Set([ + 'size', + 'last-visited', + 'created', + 'ahead', + 'behind', + 'workspace-status', + 'review', + 'ticket' +]) + +export function isWorkspaceCleanupAbsentLastSortField(field: WorkspaceCleanupSortField): boolean { + return ABSENT_LAST_FIELDS.has(field) +} + +export function sortWorkspaceCleanupFacets( + facets: readonly WorkspaceCleanupFacets[], + sort: WorkspaceCleanupSortState +): WorkspaceCleanupFacets[] { + const multiplier = sort.direction === 'asc' ? 1 : -1 + return facets.toSorted( + (left, right) => + comparePrimary(left, right, sort.field, multiplier) || compareTieBreaks(left, right) + ) +} + +export function compareWorkspaceCleanupFacets( + left: WorkspaceCleanupFacets, + right: WorkspaceCleanupFacets, + field: WorkspaceCleanupSortField, + direction: WorkspaceCleanupSortDirectionState +): number { + const multiplier = direction === 'asc' ? 1 : -1 + return comparePrimary(left, right, field, multiplier) || compareTieBreaks(left, right) +} + +function comparePrimary( + left: WorkspaceCleanupFacets, + right: WorkspaceCleanupFacets, + field: WorkspaceCleanupSortField, + multiplier: number +): number { + const absence = compareAbsence(left, right, field) + if (absence !== 0) { + return absence + } + return compareNatural(left, right, field) * multiplier +} + +/** Non-zero only when exactly one side is absent for an absent-last field. */ +function compareAbsence( + left: WorkspaceCleanupFacets, + right: WorkspaceCleanupFacets, + field: WorkspaceCleanupSortField +): number { + if (!ABSENT_LAST_FIELDS.has(field)) { + return 0 + } + const leftAbsent = isAbsent(left, field) + const rightAbsent = isAbsent(right, field) + if (leftAbsent === rightAbsent) { + return 0 + } + return leftAbsent ? 1 : -1 +} + +function isAbsent(facets: WorkspaceCleanupFacets, field: WorkspaceCleanupSortField): boolean { + if (field === 'size') { + return facets.sizeBytes === null + } + if (field === 'last-visited') { + return facets.lastVisitedAt === null + } + if (field === 'created') { + return facets.createdAt === null + } + if (field === 'ahead') { + return facets.upstreamAhead === null + } + if (field === 'behind') { + return facets.upstreamBehind === null + } + if (field === 'workspace-status') { + return facets.workspaceStatus === null + } + if (field === 'review') { + return !facets.review.hasReview + } + if (field === 'ticket') { + return facets.ticketSources.length === 0 + } + return false +} + +function compareNatural( + left: WorkspaceCleanupFacets, + right: WorkspaceCleanupFacets, + field: WorkspaceCleanupSortField +): number { + switch (field) { + case 'last-activity': + return left.lastActivityAt - right.lastActivityAt + case 'last-visited': + return (left.lastVisitedAt ?? 0) - (right.lastVisitedAt ?? 0) + case 'created': + return (left.createdAt ?? 0) - (right.createdAt ?? 0) + case 'size': + return (left.sizeBytes ?? 0) - (right.sizeBytes ?? 0) + case 'name': + return left.displayName.localeCompare(right.displayName) + case 'repo': + return left.repoName.localeCompare(right.repoName) + case 'path': + return left.path.localeCompare(right.path) + case 'host': + return left.hostId.localeCompare(right.hostId) + case 'workspace-status': + return (left.workspaceStatus ?? '').localeCompare(right.workspaceStatus ?? '') + case 'agent': + return ( + getWorkspaceCleanupAgentRank(left.agentState) - + getWorkspaceCleanupAgentRank(right.agentState) + ) + case 'git': + return getWorkspaceCleanupGitRank(left.gitState) - getWorkspaceCleanupGitRank(right.gitState) + case 'ahead': + return (left.upstreamAhead ?? 0) - (right.upstreamAhead ?? 0) + case 'behind': + return (left.upstreamBehind ?? 0) - (right.upstreamBehind ?? 0) + case 'branch': + return left.branch.localeCompare(right.branch) + case 'review': + return ( + getWorkspaceCleanupReviewRank(left.reviewState) - + getWorkspaceCleanupReviewRank(right.reviewState) || + (left.review.label ?? '').localeCompare(right.review.label ?? '') + ) + case 'ticket': + return ( + left.ticketSources.length - right.ticketSources.length || + (left.ticketSources[0] ?? '').localeCompare(right.ticketSources[0] ?? '') + ) + case 'local-context': + return left.localContextCount - right.localContextCount + case 'tier': + return getWorkspaceCleanupTierRank(left.tier) - getWorkspaceCleanupTierRank(right.tier) + case 'blocker-count': + return ( + left.blockerCount - right.blockerCount || + getMaxBlockerSeverity(left) - getMaxBlockerSeverity(right) + ) + } +} + +/** Direction-independent so a flipped sort never reshuffles equal rows. */ +function compareTieBreaks(left: WorkspaceCleanupFacets, right: WorkspaceCleanupFacets): number { + return ( + left.lastActivityAt - right.lastActivityAt || + left.repoName.localeCompare(right.repoName) || + left.displayName.localeCompare(right.displayName) || + left.worktreeId.localeCompare(right.worktreeId) + ) +} + +function getMaxBlockerSeverity(facets: WorkspaceCleanupFacets): number { + let max = 0 + for (const blocker of facets.blockers) { + max = Math.max(max, getWorkspaceCleanupBlockerSeverity(blocker)) + } + return max +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet.test.fixture.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet.test.fixture.ts new file mode 100644 index 00000000000..07e38b48141 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet.test.fixture.ts @@ -0,0 +1,126 @@ +import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' +import { + buildWorkspaceCleanupFacets, + type WorkspaceCleanupFacetSources, + type WorkspaceCleanupFacets, + type WorkspaceCleanupWorktreeFacts +} from './workspace-cleanup-facets' +import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' + +export const FACET_NOW = 1_700_000_000_000 +export const DAY = 24 * 60 * 60 * 1000 + +export function makeFacetCandidate( + overrides: Partial = {} +): WorkspaceCleanupCandidate { + return { + worktreeId: 'repo-1::/repo/alpha', + repoId: 'repo-1', + repoName: 'Repo', + connectionId: null, + displayName: 'alpha', + branch: 'refs/heads/alpha', + path: '/repo/alpha', + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: FACET_NOW - 40 * DAY, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: FACET_NOW }, + fingerprint: 'fingerprint', + ...overrides + } +} + +export function makeWorktreeFacts( + overrides: Partial = {} +): WorkspaceCleanupWorktreeFacts { + return { + id: 'repo-1::/repo/alpha', + branch: 'refs/heads/alpha', + comment: '', + isArchived: false, + isPinned: false, + isUnread: false, + linkedIssue: null, + linkedLinearIssue: null, + ...overrides + } +} + +export function makeReviewInfo( + overrides: Partial = {} +): WorkspaceCleanupReviewInfo { + return { + hasReview: true, + label: 'PR #42', + state: 'open', + provider: 'github', + title: 'Alpha cleanup', + ...overrides + } +} + +export type FacetFixtureInput = { + candidate?: Partial + worktree?: Partial | null + sizeBytes?: number + lastVisitedAt?: number + agentStatus?: LiveAgentWorktreeStatus + review?: Partial + dismissed?: boolean +} + +/** Builds through the real facet builder so tests exercise the production join. */ +export function makeFacets(input: FacetFixtureInput = {}): WorkspaceCleanupFacets { + const candidate = makeFacetCandidate(input.candidate) + const id = candidate.worktreeId + const sources: WorkspaceCleanupFacetSources = { + workspaceStatuses: cloneDefaultWorkspaceStatuses(), + worktreeById: + input.worktree === null + ? new Map() + : new Map([[id, makeWorktreeFacts({ id, ...input.worktree })]]), + sizeBytesByWorktreeId: + input.sizeBytes === undefined ? new Map() : new Map([[id, input.sizeBytes]]), + lastVisitedAtByWorktreeId: + input.lastVisitedAt === undefined ? {} : { [id]: input.lastVisitedAt }, + liveAgentStatusByWorktreeId: + input.agentStatus === undefined ? new Map() : new Map([[id, input.agentStatus]]), + reviewInfoByWorktreeId: + input.review === undefined ? new Map() : new Map([[id, makeReviewInfo(input.review)]]), + dismissedWorktreeIds: new Set(input.dismissed ? [id] : []) + } + return buildWorkspaceCleanupFacets(candidate, sources) +} + +/** Distinct worktree/display identity so tie-break ordering is observable. */ +export function makeNamedFacets( + name: string, + input: FacetFixtureInput = {} +): WorkspaceCleanupFacets { + return makeFacets({ + ...input, + candidate: { + worktreeId: `repo-1::/repo/${name}`, + displayName: name, + path: `/repo/${name}`, + branch: `refs/heads/${name}`, + ...input.candidate + }, + worktree: + input.worktree === null + ? null + : { id: `repo-1::/repo/${name}`, branch: `refs/heads/${name}`, ...input.worktree } + }) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts new file mode 100644 index 00000000000..7207931377f --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -0,0 +1,239 @@ +import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/types' +import { getWorkspaceStatus } from '../../../../shared/workspace-statuses' +import { + canSelectWorkspaceCleanupCandidate, + type WorkspaceCleanupBlocker, + type WorkspaceCleanupCandidate, + type WorkspaceCleanupTier +} from '../../../../shared/workspace-cleanup' +import { getWorkspaceCleanupGitState } from './workspace-cleanup-filter-sort' +import type { + WorkspaceCleanupAgentState, + WorkspaceCleanupGitState, + WorkspaceCleanupReviewState, + WorkspaceCleanupTicketSource +} from '../../../../shared/workspace-cleanup-filter-model' +import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' +import { + getWorkspaceCleanupCandidateHostId, + getWorkspaceCleanupHostIdentity, + type WorkspaceCleanupWorktreeFacts +} from './workspace-cleanup-host-identity' +export type { WorkspaceCleanupWorktreeFacts } from './workspace-cleanup-host-identity' + +export type WorkspaceCleanupFacetSources = { + worktreeById?: ReadonlyMap + workspaceStatuses?: readonly WorkspaceStatusDefinition[] + /** Absent entry = the space scan never ran for that worktree. */ + sizeBytesByWorktreeId?: ReadonlyMap + lastVisitedAtByWorktreeId?: Readonly> + liveAgentStatusByWorktreeId?: ReadonlyMap + reviewInfoByWorktreeId?: ReadonlyMap + dismissedWorktreeIds?: ReadonlySet +} + +export type WorkspaceCleanupFacets = { + candidate: WorkspaceCleanupCandidate + worktreeId: string + repoId: string + repoName: string + displayName: string + path: string + branch: string + hostId: ExecutionHostId + tier: WorkspaceCleanupTier + blockers: readonly WorkspaceCleanupBlocker[] + blockerCount: number + isDismissed: boolean + isSelectable: boolean + /** Background signal: ambient PTY/agent churn bumps this without a human. */ + lastActivityAt: number + createdAt: number | null + /** Honest "user opened it" signal; null when Orca never recorded a visit. */ + lastVisitedAt: number | null + sizeBytes: number | null + workspaceStatus: string | null + workspaceStatusLabel: string | null + isArchived: boolean + isPinned: boolean + isUnread: boolean + hasComment: boolean + agentState: WorkspaceCleanupAgentState + retainedDoneAgentCount: number + gitState: WorkspaceCleanupGitState + upstreamAhead: number | null + upstreamBehind: number | null + isPrunable: boolean + isLocked: boolean + review: WorkspaceCleanupReviewInfo + reviewState: WorkspaceCleanupReviewState | null + ticketSources: readonly WorkspaceCleanupTicketSource[] + localContextCount: number + hasLocalContext: boolean + isCompletelyEmpty: boolean + searchText: string +} + +const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = { + hasReview: false, + label: null, + state: null, + provider: null, + title: null +} + +export function buildWorkspaceCleanupFacets( + candidate: WorkspaceCleanupCandidate, + sources: WorkspaceCleanupFacetSources = {} +): WorkspaceCleanupFacets { + const hostIdentity = getWorkspaceCleanupHostIdentity( + getWorkspaceCleanupCandidateHostId(candidate), + candidate.worktreeId + ) + const worktree = + sources.worktreeById?.get(hostIdentity) ?? + sources.worktreeById?.get(candidate.worktreeId) ?? + null + const review = + sources.reviewInfoByWorktreeId?.get(hostIdentity) ?? + sources.reviewInfoByWorktreeId?.get(candidate.worktreeId) ?? + EMPTY_REVIEW_INFO + const ticketSources = getTicketSources(worktree) + const localContextCount = getLocalContextCount(candidate) + const hasComment = (worktree?.comment ?? '').trim().length > 0 + const branch = getBranchDisplayName(worktree?.branch ?? candidate.branch) + const facets: Omit = { + candidate, + worktreeId: candidate.worktreeId, + repoId: candidate.repoId, + repoName: candidate.repoName, + displayName: candidate.displayName, + path: candidate.path, + branch, + hostId: worktree?.hostId ?? getWorkspaceCleanupCandidateHostId(candidate), + tier: candidate.tier, + blockers: candidate.blockers, + blockerCount: candidate.blockers.length, + isDismissed: + (sources.dismissedWorktreeIds?.has(candidate.worktreeId) ?? false) || + candidate.blockers.includes('dismissed'), + isSelectable: canSelectWorkspaceCleanupCandidate(candidate), + lastActivityAt: candidate.lastActivityAt, + createdAt: toFiniteOrNull(worktree?.createdAt ?? candidate.createdAt), + lastVisitedAt: toFiniteOrNull(sources.lastVisitedAtByWorktreeId?.[candidate.worktreeId]), + sizeBytes: toFiniteOrNull( + sources.sizeBytesByWorktreeId?.get(hostIdentity) ?? + sources.sizeBytesByWorktreeId?.get(candidate.worktreeId) + ), + workspaceStatus: normalizeStatus(worktree?.workspaceStatus), + workspaceStatusLabel: getWorkspaceStatusLabel(worktree, sources.workspaceStatuses), + isArchived: worktree?.isArchived ?? candidate.reasons.includes('archived'), + isPinned: worktree?.isPinned ?? candidate.blockers.includes('pinned'), + isUnread: worktree?.isUnread ?? false, + hasComment, + agentState: sources.liveAgentStatusByWorktreeId?.get(candidate.worktreeId) ?? 'idle', + retainedDoneAgentCount: candidate.localContext.retainedDoneAgentCount, + gitState: getWorkspaceCleanupGitState(candidate), + upstreamAhead: toFiniteOrNull(candidate.git.upstreamAhead), + upstreamBehind: toFiniteOrNull(candidate.git.upstreamBehind), + isPrunable: worktree?.prunable ?? false, + isLocked: worktree?.locked ?? false, + review, + reviewState: review.hasReview ? (review.state ?? 'unknown') : null, + ticketSources, + localContextCount, + hasLocalContext: localContextCount > 0, + isCompletelyEmpty: + localContextCount === 0 && !review.hasReview && ticketSources.length === 0 && !hasComment + } + return { ...facets, searchText: buildSearchText(facets) } +} + +export function buildWorkspaceCleanupFacetList( + candidates: readonly WorkspaceCleanupCandidate[], + sources: WorkspaceCleanupFacetSources = {} +): WorkspaceCleanupFacets[] { + return candidates.map((candidate) => buildWorkspaceCleanupFacets(candidate, sources)) +} + +export function countWorkspaceCleanupMeasuredRows(rows: readonly WorkspaceCleanupFacets[]): number { + return rows.reduce((count, row) => count + (row.sizeBytes === null ? 0 : 1), 0) +} + +function getLocalContextCount(candidate: WorkspaceCleanupCandidate): number { + const context = candidate.localContext + return ( + context.terminalTabCount + + context.cleanEditorTabCount + + context.browserTabCount + + context.diffCommentCount + + context.retainedDoneAgentCount + ) +} + +function getTicketSources( + worktree: WorkspaceCleanupWorktreeFacts | null +): WorkspaceCleanupTicketSource[] { + if (!worktree) { + return [] + } + const sources: WorkspaceCleanupTicketSource[] = [] + if (worktree.linkedWorkItem != null) { + sources.push('work-item') + } + if ((worktree.linkedLinearIssue ?? '').length > 0) { + sources.push('linear') + } + if (worktree.linkedIssue != null) { + sources.push('issue') + } + return sources +} + +function buildSearchText(facets: Omit): string { + return [ + facets.displayName, + facets.repoName, + facets.branch, + facets.path, + facets.hostId, + facets.workspaceStatus, + facets.workspaceStatusLabel, + facets.review.label, + facets.review.title, + facets.review.provider, + facets.gitState, + facets.tier, + ...facets.ticketSources, + ...facets.blockers + ] + .filter((value): value is string => Boolean(value)) + .join(' ') + .toLowerCase() +} + +function normalizeStatus(status: string | undefined): string | null { + const trimmed = (status ?? '').trim() + return trimmed.length > 0 ? trimmed : null +} + +function getWorkspaceStatusLabel( + worktree: WorkspaceCleanupWorktreeFacts | null, + statuses: readonly WorkspaceStatusDefinition[] | undefined +): string | null { + if (!worktree || !statuses?.length) { + return null + } + const statusId = getWorkspaceStatus({ workspaceStatus: worktree.workspaceStatus }, statuses) + return statuses.find((status) => status.id === statusId)?.label ?? statusId +} + +function toFiniteOrNull(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function getBranchDisplayName(branch: string): string { + return branch.replace(/^refs\/heads\//, '') || 'HEAD' +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx new file mode 100644 index 00000000000..8575c3f710c --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx @@ -0,0 +1,44 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar' + +afterEach(cleanup) + +describe('WorkspaceCleanupFilterBar', () => { + it('keeps size measurement out of the browse controls', () => { + render( + + ) + + expect(screen.queryByRole('button', { name: 'Scan' })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx new file mode 100644 index 00000000000..5cb4e389af5 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { Loader2, RotateCcw, Search, SlidersHorizontal } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { ScrollArea } from '@/components/ui/scroll-area' +import { translate } from '@/i18n/i18n' +import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets' +import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets' +import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model' + +export type WorkspaceCleanupGitEvidenceProgress = { + pendingCount: number + totalCount: number +} + +export function WorkspaceCleanupFilterBar({ + facetProps, + facetPanelOpen, + onFacetPanelOpenChange, + activeFacetGroupCount, + matchedCount, + hasActiveFilters, + gitEvidence, + onQueryChange, + onClearFilters +}: { + facetProps: WorkspaceCleanupFacetGroupProps + facetPanelOpen: boolean + onFacetPanelOpenChange: (open: boolean) => void + activeFacetGroupCount: number + matchedCount: number + hasActiveFilters: boolean + gitEvidence: WorkspaceCleanupGitEvidenceProgress + onQueryChange: (query: string) => void + onClearFilters: () => void +}): React.JSX.Element { + const { filters, totalCount } = facetProps + return ( +
+
+ + onQueryChange(event.target.value)} + placeholder={translate( + 'components.workspace.cleanup.browse.searchPlaceholder', + 'Search name, repo, branch, path, host' + )} + aria-label={translate( + 'components.workspace.cleanup.browse.searchLabel', + 'Search workspaces' + )} + className="h-8 pl-8 text-xs" + /> +
+ + + + + + + + + + +
+ + {translate( + 'components.workspace.cleanup.browse.showingCount', + 'Showing {{value0}} of {{value1}}', + { value0: matchedCount, value1: totalCount } + )} + + +
+
+
+ + + {translate( + 'components.workspace.cleanup.browse.showingCount', + 'Showing {{value0}} of {{value1}}', + { value0: matchedCount, value1: totalCount } + )} + + + {gitEvidence.pendingCount > 0 ? ( + + + {translate( + 'components.workspace.cleanup.browse.checkingGit', + 'Checking git status: {{value0}} left', + { value0: gitEvidence.pendingCount } + )} + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts index ff597290bfd..e839de0c288 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts @@ -1,4 +1,5 @@ import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { WorkspaceCleanupGitState } from '../../../../shared/workspace-cleanup-filter-model' import type { WorkspaceCleanupContextFilter, WorkspaceCleanupFilters, @@ -94,20 +95,35 @@ export function sortWorkspaceCleanupCandidates( }) } +const GIT_STATE_LABELS: Record = { + unpushed: 'Unpushed', + unknown: 'Unknown', + clean: 'Clean', + dirty: 'Dirty' +} + export function getWorkspaceCleanupGitLabel(candidate: WorkspaceCleanupCandidate): string { + return GIT_STATE_LABELS[getWorkspaceCleanupGitState(candidate)] +} + +/** Unpushed outranks unknown: a lost commit is worse than an unreadable status. */ +export function getWorkspaceCleanupGitState( + candidate: WorkspaceCleanupCandidate +): WorkspaceCleanupGitState { if (hasUnpushedCommits(candidate)) { - return 'Unpushed' + return 'unpushed' } if (isGitStatusUnknown(candidate)) { - return 'Unknown' + return 'unknown' } if (candidate.git.clean === true) { - return 'Clean' + return 'clean' } - if (candidate.git.clean === false) { - return 'Dirty' - } - return 'Unknown' + return candidate.git.clean === false ? 'dirty' : 'unknown' +} + +export function hasWorkspaceCleanupUnpushedCommits(candidate: WorkspaceCleanupCandidate): boolean { + return hasUnpushedCommits(candidate) } function compareWorkspaceCleanupCandidates( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx new file mode 100644 index 00000000000..bbbeaac5e73 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createDefaultWorkspaceCleanupFilterState, + DEFAULT_WORKSPACE_CLEANUP_SORT, + type WorkspaceCleanupFilterState +} from '../../../../shared/workspace-cleanup-filter-model' +import { CandidateRow } from './workspace-cleanup-candidate-row' +import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' +import { FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture' +import { runWorkspaceCleanupQuery } from './workspace-cleanup-query' +import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +// One row per bucket the retired tab partition used to hide behind a tab. +const FLEET: WorkspaceCleanupFacets[] = [ + makeNamedFacets('ready-one', { candidate: { tier: 'ready' }, sizeBytes: 4 * 1024 * 1024 }), + makeNamedFacets('needs-review', { candidate: { tier: 'review' } }), + makeNamedFacets('protected-one', { + candidate: { tier: 'protected', blockers: ['pinned'], selectedByDefault: false } + }), + makeNamedFacets('ignored-one', { dismissed: true }) +] + +function renderRows(rows: readonly WorkspaceCleanupFacets[]): void { + act(() => + root?.render( + ( + + )} + /> + ) + ) +} + +function renderedNames(): string[] { + // Buttons share `text-sm font-medium`, so scope to the row's name span. + return [...(container?.querySelectorAll('span.truncate.text-sm.font-medium') ?? [])].map( + (node) => node.textContent ?? '' + ) +} + +function query(filters: WorkspaceCleanupFilterState = createDefaultWorkspaceCleanupFilterState()) { + return runWorkspaceCleanupQuery( + FLEET, + { filters, sort: DEFAULT_WORKSPACE_CLEANUP_SORT }, + FACET_NOW + ) +} + +describe('workspace cleanup flat list', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('renders every tier in one list rather than per-tab groups', () => { + const result = query() + + renderRows(result.rows) + + expect(result.totalCount).toBe(FLEET.length) + expect(result.matchedCount).toBe(FLEET.length) + expect(renderedNames()).toEqual( + expect.arrayContaining(['ready-one', 'needs-review', 'protected-one', 'ignored-one']) + ) + }) + + it('switches the visible rows when filters are applied', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.safety.tiers = ['ready'] + filters.safety.dismissed = 'exclude' + renderRows(query(filters).rows) + expect(renderedNames()).toEqual(['ready-one']) + + filters.safety.tiers = [] + filters.safety.dismissed = 'only' + renderRows(query(filters).rows) + expect(renderedNames()).toEqual(['ignored-one']) + + filters.safety.dismissed = 'any' + filters.safety.tiers = ['protected'] + renderRows(query(filters).rows) + expect(renderedNames()).toEqual(['protected-one']) + }) + + it('reports only the rows the user could actually queue for deletion', () => { + expect(query().selectableWorktreeIds).not.toContain('repo-1::/repo/protected-one') + }) + + it('shows status and disk-size facts without expanding a row', () => { + renderRows(query().rows) + + expect(container?.textContent).toContain('In progress') + expect(container?.textContent).toContain('4.00 MB') + expect(container?.textContent).toContain('Not measured') + const sizedRows = [...(container?.querySelectorAll('[aria-label^="Size on disk"]') ?? [])] + expect(sizedRows).toHaveLength(FLEET.length) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-freshness.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-freshness.tsx new file mode 100644 index 00000000000..7a78c9fd597 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-freshness.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { Loader2 } from 'lucide-react' +import { useNow } from '@/components/dashboard/useNow' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup' +import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' + +/** + * Fixed-slot freshness line: "Updated 2h ago" for the data on screen plus a + * non-blocking "Refreshing" affordance while a background rescan streams. + */ +export function WorkspaceCleanupFreshness({ + scannedAt, + refreshing, + progress +}: { + scannedAt: number | null + refreshing: boolean + progress: WorkspaceCleanupScanProgress | null +}): React.JSX.Element | null { + const now = useNow(30_000) + if (scannedAt === null && !refreshing) { + return null + } + return ( + + {scannedAt !== null ? ( + + {translate('components.workspace.cleanup.browse.updatedAgo', 'Updated {{value0}}', { + value0: formatWorkspaceCleanupRelativeTime(scannedAt, now).toLowerCase() + })} + + ) : null} + {refreshing ? ( + + + ) : null} + + ) +} + +function formatWorkspaceCleanupRefreshingLabel( + progress: WorkspaceCleanupScanProgress | null +): string { + if (!progress || progress.totalWorktreeCount === 0) { + return translate('components.workspace.cleanup.browse.refreshing', 'Refreshing…') + } + return translate( + 'components.workspace.cleanup.browse.refreshingProgress', + 'Refreshing {{value0}}/{{value1}}', + { value0: progress.scannedWorktreeCount, value1: progress.totalWorktreeCount } + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts new file mode 100644 index 00000000000..bfcc1a087a4 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture' +import { + applyWorkspaceCleanupGitEvidence, + hasWorkspaceCleanupGitEvidence, + needsWorkspaceCleanupGitEvidence, + selectWorkspaceCleanupGitEvidenceTargets +} from './workspace-cleanup-git-evidence' + +const CLEAN_SORT = { field: 'name', direction: 'asc' } as const + +function deferredCandidate(worktreeId: string) { + return makeFacetCandidate({ + worktreeId, + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) +} + +describe('needsWorkspaceCleanupGitEvidence', () => { + it('stays off for a filter and sort that never read git', () => { + expect( + needsWorkspaceCleanupGitEvidence(createDefaultWorkspaceCleanupFilterState(), CLEAN_SORT) + ).toBe(false) + }) + + it.each([ + [ + 'git state', + (f: ReturnType) => { + f.git.states = ['dirty'] + } + ], + [ + 'ahead threshold', + (f: ReturnType) => { + f.git.minAhead = 1 + } + ], + [ + 'behind threshold', + (f: ReturnType) => { + f.git.minBehind = 1 + } + ], + [ + 'git-derived blocker', + (f: ReturnType) => { + f.safety.blockers = ['unpushed-commits'] + } + ] + ])('turns on for a %s filter', (_label, mutate) => { + const filters = createDefaultWorkspaceCleanupFilterState() + mutate(filters) + expect(needsWorkspaceCleanupGitEvidence(filters, CLEAN_SORT)).toBe(true) + }) + + it.each(['git', 'ahead', 'behind'] as const)('turns on for the %s sort', (field) => { + expect( + needsWorkspaceCleanupGitEvidence(createDefaultWorkspaceCleanupFilterState(), { + field, + direction: 'asc' + }) + ).toBe(true) + }) + + it('ignores a blocker filter that does not depend on git', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.safety.blockers = ['pinned'] + expect(needsWorkspaceCleanupGitEvidence(filters, CLEAN_SORT)).toBe(false) + }) +}) + +describe('selectWorkspaceCleanupGitEvidenceTargets', () => { + it('only targets rows the host deferred', () => { + const targets = selectWorkspaceCleanupGitEvidenceTargets([ + deferredCandidate('a'), + makeFacetCandidate({ worktreeId: 'b' }) + ]) + expect(targets).toEqual(['a']) + expect(hasWorkspaceCleanupGitEvidence(makeFacetCandidate({ worktreeId: 'b' }))).toBe(true) + }) + + it('skips rows already attempted and caps the batch', () => { + const candidates = ['a', 'b', 'c'].map(deferredCandidate) + expect( + selectWorkspaceCleanupGitEvidenceTargets(candidates, { + resolvedWorktreeIds: new Set(['a']) + }) + ).toEqual(['b', 'c']) + expect(selectWorkspaceCleanupGitEvidenceTargets(candidates, { maxTargets: 2 })).toEqual([ + 'a', + 'b' + ]) + }) +}) + +describe('applyWorkspaceCleanupGitEvidence', () => { + it('replaces the deferred row with the focused re-scan result', () => { + const deferred = deferredCandidate('a') + const refreshed = makeFacetCandidate({ + worktreeId: 'a', + git: { clean: false, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 5 } + }) + const [row] = applyWorkspaceCleanupGitEvidence([deferred], new Map([['a', refreshed]])) + expect(row.git.clean).toBe(false) + expect(row.git.checkedAt).toBe(5) + }) + + it('returns the input untouched when nothing was re-scanned', () => { + const candidates = [deferredCandidate('a')] + expect(applyWorkspaceCleanupGitEvidence(candidates, new Map())).toBe(candidates) + }) + + it('merges only git facts into the current broad-scan row', () => { + const current = deferredCandidate('a') + current.displayName = 'current name' + current.blockers = ['dismissed'] + current.fingerprint = 'current-fingerprint' + const refreshed = makeFacetCandidate({ + worktreeId: 'a', + displayName: 'stale name', + blockers: ['dirty-files'], + fingerprint: 'focused-fingerprint', + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 5 } + }) + + const [row] = applyWorkspaceCleanupGitEvidence([current], new Map([['a', refreshed]])) + + expect(row.displayName).toBe('current name') + expect(row.fingerprint).toBe('current-fingerprint') + expect(row.blockers).toEqual(expect.arrayContaining(['dismissed', 'dirty-files'])) + expect(row.git).toEqual(refreshed.git) + }) + + it('keeps newer broad-scan git evidence', () => { + const current = makeFacetCandidate({ + worktreeId: 'a', + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 10 } + }) + const older = makeFacetCandidate({ + worktreeId: 'a', + git: { clean: false, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 5 } + }) + + expect(applyWorkspaceCleanupGitEvidence([current], new Map([['a', older]]))[0]).toBe(current) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts new file mode 100644 index 00000000000..24fb5de87ff --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts @@ -0,0 +1,101 @@ +import type { + WorkspaceCleanupBlocker, + WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' +import { + applyWorkspaceCleanupPolicy, + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT +} from '../../../../shared/workspace-cleanup' +import type { + WorkspaceCleanupFilterState, + WorkspaceCleanupSortField, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' + +/** + * The host defers git evidence for rows with no inactivity reason, so their + * `git.checkedAt` is null and the facet layer reads them as `unknown`. A + * git-dependent filter or sort would then quietly mislabel most of the list, + * so the dialog re-scans those rows on demand before trusting the answer. + */ +export function hasWorkspaceCleanupGitEvidence(candidate: WorkspaceCleanupCandidate): boolean { + return candidate.git.checkedAt !== null +} + +const GIT_SORT_FIELDS: ReadonlySet = new Set(['git', 'ahead', 'behind']) + +const GIT_DERIVED_BLOCKERS: ReadonlySet = new Set([ + 'dirty-files', + 'unpushed-commits', + 'git-status-error', + 'unknown-base' +]) + +/** Bounds how many worktrees one Git-evidence batch can inspect across a huge fleet. */ +export const WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS = WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT + +export function needsWorkspaceCleanupGitEvidence( + filters: WorkspaceCleanupFilterState, + sort: WorkspaceCleanupSortState +): boolean { + return ( + filters.git.states.length > 0 || + filters.git.minAhead !== null || + filters.git.minBehind !== null || + filters.safety.blockers.some((blocker) => GIT_DERIVED_BLOCKERS.has(blocker)) || + GIT_SORT_FIELDS.has(sort.field) + ) +} + +/** + * Ids still missing git evidence, capped and in list order so the rows the user + * is looking at resolve first. + */ +export function selectWorkspaceCleanupGitEvidenceTargets( + candidates: readonly WorkspaceCleanupCandidate[], + options: { + resolvedWorktreeIds?: ReadonlySet + maxTargets?: number + } = {} +): string[] { + const resolved = options.resolvedWorktreeIds ?? new Set() + const max = options.maxTargets ?? WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS + const targets: string[] = [] + for (const candidate of candidates) { + if (targets.length >= max) { + break + } + if (!hasWorkspaceCleanupGitEvidence(candidate) && !resolved.has(candidate.worktreeId)) { + targets.push(candidate.worktreeId) + } + } + return targets +} + +/** Focused re-scan results win over the deferred broad-scan row. */ +export function applyWorkspaceCleanupGitEvidence( + candidates: readonly WorkspaceCleanupCandidate[], + evidenceByWorktreeId: ReadonlyMap +): readonly WorkspaceCleanupCandidate[] { + if (evidenceByWorktreeId.size === 0) { + return candidates + } + return candidates.map((candidate) => { + const evidence = evidenceByWorktreeId.get(candidate.worktreeId) + if ( + evidence === undefined || + (candidate.git.checkedAt !== null && + candidate.git.checkedAt >= (evidence.git.checkedAt ?? Number.NEGATIVE_INFINITY)) + ) { + return candidate + } + return applyWorkspaceCleanupPolicy({ + ...candidate, + blockers: [ + ...candidate.blockers.filter((blocker) => !GIT_DERIVED_BLOCKERS.has(blocker)), + ...evidence.blockers.filter((blocker) => GIT_DERIVED_BLOCKERS.has(blocker)) + ], + git: evidence.git + }) + }) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx new file mode 100644 index 00000000000..3db2300c4c5 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx @@ -0,0 +1,209 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import { getExecutionHostLabel } from '../../../../shared/execution-host' +import { + WORKSPACE_CLEANUP_BLOCKER_MODE_VALUES, + WORKSPACE_CLEANUP_BLOCKER_VALUES, + WORKSPACE_CLEANUP_GIT_STATE_VALUES, + WORKSPACE_CLEANUP_PRESENCE_VALUES, + WORKSPACE_CLEANUP_REVIEW_STATE_VALUES, + WORKSPACE_CLEANUP_TICKET_SOURCE_VALUES, + WORKSPACE_CLEANUP_TIER_VALUES, + WORKSPACE_CLEANUP_TRI_STATE_VALUES +} from '../../../../shared/workspace-cleanup-facet-rankings' +import { getWorkspaceCleanupBlockerLabel } from './workspace-cleanup-candidate-labels' +import { + FacetCheckbox, + FacetChoice, + FacetNumberField, + FacetSection, + FacetTextField, + FacetToggleList +} from './workspace-cleanup-facet-controls' +import { + getWorkspaceCleanupBlockerModeLabel, + getWorkspaceCleanupGitStateLabel, + getWorkspaceCleanupPresenceLabel, + getWorkspaceCleanupReviewProviderLabel, + getWorkspaceCleanupReviewStateLabel, + getWorkspaceCleanupTicketSourceLabel, + getWorkspaceCleanupTierLabel, + getWorkspaceCleanupTriStateLabel +} from './workspace-cleanup-facet-labels' +import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model' + +/** Git, review/PR, tickets, location/host, and safety blockers. */ +export function WorkspaceCleanupGitReviewFacets({ + filters, + counts, + totalCount, + options, + onPatch +}: WorkspaceCleanupFacetGroupProps): React.JSX.Element { + return ( + <> + + onPatch('git', { states })} + /> + onPatch('git', { minAhead })} + /> + onPatch('git', { minBehind })} + /> + onPatch('git', { branchQuery })} + /> + onPatch('git', { prunable })} + /> + onPatch('git', { locked })} + /> + + + + onPatch('review', { presence })} + /> + onPatch('review', { states })} + /> + {options.reviewProviders.length > 0 ? ( + onPatch('review', { providers })} + /> + ) : null} + + + + onPatch('ticket', { presence })} + /> + onPatch('ticket', { sources })} + /> + + + + onPatch('location', { hostIds })} + /> + {options.repos.length > 1 ? ( + repo.id)} + selected={filters.location.repoIds} + getLabel={(id) => options.repos.find((repo) => repo.id === id)?.label ?? id} + onChange={(repoIds) => onPatch('location', { repoIds })} + /> + ) : null} + onPatch('location', { pathPrefix })} + /> + + + + onPatch('safety', { tiers })} + /> + onPatch('safety', { blockerMode })} + /> + onPatch('safety', { blockers })} + /> + onPatch('safety', { dismissed })} + /> + onPatch('safety', { selectableOnly })} + /> + + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts new file mode 100644 index 00000000000..5f01b485678 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts @@ -0,0 +1,137 @@ +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { + WorkspaceSpaceWorktree, + WorkspaceSpaceWorktreeMeasurement +} from '../../../../shared/workspace-space-types' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' + +/** Structural subset of `Worktree` kept in sync with the persisted source. */ +export type WorkspaceCleanupWorktreeFacts = Pick & + Partial< + Pick< + Worktree, + | 'repoId' + | 'workspaceStatus' + | 'isArchived' + | 'isPinned' + | 'isUnread' + | 'comment' + | 'hostId' + | 'branch' + | 'createdAt' + | 'linkedWorkItem' + | 'linkedLinearIssue' + | 'linkedIssue' + | 'locked' + | 'prunable' + > + > + +export function getWorkspaceCleanupCandidateHostId( + candidate: Pick +): ExecutionHostId { + return ( + normalizeExecutionHostId(candidate.executionHostId) ?? + (candidate.connectionId + ? toSshExecutionHostId(candidate.connectionId) + : LOCAL_EXECUTION_HOST_ID) + ) +} + +export function getWorkspaceCleanupHostIdentity(hostId: string, id: string): string { + return `${hostId}\0${id}` +} + +function countIds( + rows: readonly Row[], + include: (row: Row) => boolean = () => true +): Map { + const counts = new Map() + for (const row of rows) { + if (include(row)) { + counts.set(row.worktreeId, (counts.get(row.worktreeId) ?? 0) + 1) + } + } + return counts +} + +export function countWorkspaceCleanupCandidateIds( + candidates: readonly Pick[] +): Map { + return countIds(candidates) +} + +/** Only `ok` scans carry a trustworthy byte count; everything else stays unsized. */ +export function buildWorkspaceCleanupSizeIndex( + worktrees: + | readonly (WorkspaceSpaceWorktree | WorkspaceSpaceWorktreeMeasurement)[] + | null + | undefined, + candidates?: readonly Pick< + WorkspaceCleanupCandidate, + 'connectionId' | 'executionHostId' | 'worktreeId' + >[] +): Map { + const index = new Map() + const candidateCounts = candidates ? countWorkspaceCleanupCandidateIds(candidates) : null + const legacyCounts = countIds( + worktrees ?? [], + (entry) => !normalizeExecutionHostId(entry.executionHostId) + ) + for (const entry of worktrees ?? []) { + if (entry.status === 'ok' && Number.isFinite(entry.sizeBytes)) { + const hostId = normalizeExecutionHostId(entry.executionHostId) + if (hostId) { + index.set(getWorkspaceCleanupHostIdentity(hostId, entry.worktreeId), entry.sizeBytes) + } else if ( + legacyCounts.get(entry.worktreeId) === 1 && + (!candidateCounts || candidateCounts.get(entry.worktreeId) === 1) + ) { + index.set(entry.worktreeId, entry.sizeBytes) + } + } + } + return index +} + +export function buildWorkspaceCleanupWorktreeIndex( + worktreesByRepo: Readonly>, + repos: readonly Pick[] = [] +): Map { + const index = new Map() + const repoHostsById = new Map>() + for (const repo of repos) { + const hosts = repoHostsById.get(repo.id) ?? new Set() + hosts.add(getRepoExecutionHostId(repo)) + repoHostsById.set(repo.id, hosts) + } + const legacyCounts = new Map() + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + if (!normalizeExecutionHostId(worktree.hostId)) { + legacyCounts.set(worktree.id, (legacyCounts.get(worktree.id) ?? 0) + 1) + } + } + } + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + const explicitHostId = normalizeExecutionHostId(worktree.hostId) + const repoHosts = worktree.repoId ? repoHostsById.get(worktree.repoId) : undefined + const derivedHostId = repoHosts?.size === 1 ? [...repoHosts][0] : null + const hostId = + explicitHostId ?? (legacyCounts.get(worktree.id) === 1 ? derivedHostId : undefined) + if (hostId) { + index.set(getWorkspaceCleanupHostIdentity(hostId, worktree.id), worktree) + } + } + } + return index +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-lifecycle-facets.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-lifecycle-facets.tsx new file mode 100644 index 00000000000..a65332eabb6 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-lifecycle-facets.tsx @@ -0,0 +1,198 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import { + WORKSPACE_CLEANUP_AGENT_STATE_VALUES, + WORKSPACE_CLEANUP_IDLE_SIGNAL_VALUES, + WORKSPACE_CLEANUP_PRESENCE_VALUES, + WORKSPACE_CLEANUP_TRI_STATE_VALUES +} from '../../../../shared/workspace-cleanup-facet-rankings' +import { + FacetCheckbox, + FacetChoice, + FacetNumberField, + FacetSection, + FacetToggleList +} from './workspace-cleanup-facet-controls' +import { + getWorkspaceCleanupAgentStateLabel, + getWorkspaceCleanupIdleSignalLabel, + getWorkspaceCleanupPresenceLabel, + getWorkspaceCleanupTriStateLabel +} from './workspace-cleanup-facet-labels' +import { + fromMegabytes, + toMegabytes, + type WorkspaceCleanupFacetGroupProps +} from './workspace-cleanup-facet-panel-model' + +/** Activity, size, workspace status, agent state, and local context. */ +export function WorkspaceCleanupLifecycleFacets({ + filters, + counts, + totalCount, + options, + onPatch +}: WorkspaceCleanupFacetGroupProps): React.JSX.Element { + return ( + <> + + onPatch('activity', { idleSignal })} + /> + onPatch('activity', { idleMinDays })} + /> + onPatch('activity', { neverVisited })} + /> + + + + onPatch('size', { minBytes: fromMegabytes(megabytes) })} + /> + onPatch('size', { maxBytes: fromMegabytes(megabytes) })} + /> + onPatch('size', { includeUnsized })} + /> + + + + {options.workspaceStatuses.length > 0 ? ( + status.id)} + selected={filters.status.workspaceStatuses} + getLabel={(id) => + options.workspaceStatuses.find((status) => status.id === id)?.label ?? id + } + onChange={(workspaceStatuses) => onPatch('status', { workspaceStatuses })} + /> + ) : null} + onPatch('status', { matchStatusless })} + /> + onPatch('status', { archived })} + /> + onPatch('status', { pinned })} + /> + onPatch('status', { unread })} + /> + onPatch('status', { comment })} + /> + + + + onPatch('agent', { states })} + /> + onPatch('agent', { retainedDoneAgents })} + /> + + + + onPatch('context', { presence })} + /> + onPatch('context', { completelyEmpty })} + /> + + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx new file mode 100644 index 00000000000..5fea9257680 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import type { LucideIcon } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import type { StatusPillTone } from './workspace-cleanup-candidate-row-data' + +export function WorkspaceCleanupMetadataChip({ + icon: Icon, + label, + value, + tone = 'neutral' +}: { + icon: LucideIcon + label: string + value?: string + tone?: StatusPillTone +}): React.JSX.Element { + return ( + + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts index f6e1aab4488..2f24f47fb0c 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts @@ -16,6 +16,7 @@ import { makeReview, makeState } from './workspace-cleanup-presentation-fixtures' +import type { Repo } from '../../../../shared/repo-types' describe('workspace cleanup presentation', () => { it('finds hosted review details from renderer state', () => { @@ -34,6 +35,76 @@ describe('workspace cleanup presentation', () => { }) }) + it('host-qualifies same-id repo, worktree, and review cache joins', () => { + const base = makeState() + const localRepo = { ...base.repos[0]!, executionHostId: 'local' as const } + const remoteRepo: Repo = { + ...localRepo, + connectionId: 'builder', + executionHostId: 'ssh:builder' + } + const worktreeId = 'repo-1::/repo/alpha' + const localWorktree = { + ...base.worktreesByRepo['repo-1']![0]!, + hostId: 'local' as const, + linkedPR: 11 + } + const remoteWorktree = { + ...localWorktree, + hostId: 'ssh:builder' as const, + linkedPR: 22 + } + const localKey = getHostedReviewCacheKey('/repo', 'alpha', {}, 'repo-1', null, 'local', true) + const remoteKey = getHostedReviewCacheKey( + '/repo', + 'alpha', + {}, + 'repo-1', + 'builder', + 'ssh:builder', + true + ) + const state = makeState({ + repos: [localRepo, remoteRepo], + worktreesByRepo: { 'repo-1': [localWorktree, remoteWorktree] }, + hostedReviewCache: { + [localKey]: { data: makeReview({ number: 11, title: 'Local review' }), fetchedAt: NOW }, + [remoteKey]: { data: makeReview({ number: 22, title: 'Remote review' }), fetchedAt: NOW } + } + }) + + expect( + getWorkspaceCleanupReviewInfo( + makeCandidate({ + worktreeId, + connectionId: 'builder', + executionHostId: 'ssh:builder' + }), + state + ) + ).toMatchObject({ label: 'PR #22', title: 'Remote review' }) + }) + + it('does not guess a host for legacy same-id review joins', () => { + const base = makeState() + const duplicateRepo = { + ...base.repos[0]!, + connectionId: 'builder', + executionHostId: 'ssh:builder' as const + } + const state = makeState({ + repos: [base.repos[0]!, duplicateRepo], + worktreesByRepo: { + 'repo-1': [ + { ...base.worktreesByRepo['repo-1']![0]!, linkedPR: 11 }, + { ...base.worktreesByRepo['repo-1']![0]!, linkedPR: 22 } + ] + } + }) + + expect(getWorkspaceCleanupReviewInfo(makeCandidate(), state).hasReview).toBe(false) + }) + it('filters by time, review, git, and context', () => { const openReview: WorkspaceCleanupReviewInfo = { hasReview: true, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts index 1accef346ca..f3506b07e20 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts @@ -1,11 +1,12 @@ -import { getWorktreeMapFromState } from '@/store/selectors' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { HostedReviewInfo, HostedReviewProvider } from '../../../../shared/hosted-review' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { getWorkspaceCleanupCandidateHostId } from './workspace-cleanup-host-identity' export type WorkspaceCleanupSortKey = 'activity' | 'name' | 'repo' | 'review' | 'git' export type WorkspaceCleanupSortDirection = 'asc' | 'desc' @@ -40,6 +41,11 @@ export type WorkspaceCleanupRendererStateInputs = Pick< 'worktreesByRepo' | 'hostedReviewCache' | 'repos' | 'settings' > +export type WorkspaceCleanupReviewLookup = { + reposById: ReadonlyMap + worktreesByRepoAndId: ReadonlyMap +} + export { filterWorkspaceCleanupCandidates, getWorkspaceCleanupGitLabel, @@ -50,10 +56,11 @@ export { export function getWorkspaceCleanupReviewInfo( candidate: WorkspaceCleanupCandidate, - state: WorkspaceCleanupRendererStateInputs + state: WorkspaceCleanupRendererStateInputs, + lookup: WorkspaceCleanupReviewLookup = buildWorkspaceCleanupReviewLookup(state) ): WorkspaceCleanupReviewInfo { - const worktree = getWorktreeMapFromState(state).get(candidate.worktreeId) ?? null - const repo = state.repos.find((entry) => entry.id === candidate.repoId) ?? null + const repo = findCandidateRepo(candidate, lookup) + const worktree = findCandidateWorktree(candidate, repo, lookup) const hostedReview = getCachedHostedReview(candidate, worktree, repo, state) if (hostedReview) { return { @@ -85,6 +92,27 @@ export function getWorkspaceCleanupReviewInfo( } } +export function buildWorkspaceCleanupReviewLookup( + state: Pick +): WorkspaceCleanupReviewLookup { + const reposById = new Map() + for (const repo of state.repos) { + const matches = reposById.get(repo.id) ?? [] + matches.push(repo) + reposById.set(repo.id, matches) + } + const worktreesByRepoAndId = new Map() + for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { + for (const worktree of worktrees) { + const key = `${repoId}\0${worktree.id}` + const matches = worktreesByRepoAndId.get(key) ?? [] + matches.push(worktree) + worktreesByRepoAndId.set(key, matches) + } + } + return { reposById, worktreesByRepoAndId } +} + function getCachedHostedReview( candidate: WorkspaceCleanupCandidate, worktree: Worktree | null, @@ -99,11 +127,43 @@ function getCachedHostedReview( getBranchDisplayName(worktree?.branch ?? candidate.branch), state.settings, repo.id, - repo.connectionId + repo.connectionId, + repo.executionHostId, + true ) return state.hostedReviewCache[cacheKey]?.data ?? null } +function findCandidateRepo( + candidate: WorkspaceCleanupCandidate, + lookup: WorkspaceCleanupReviewLookup +): Repo | null { + const matches = lookup.reposById.get(candidate.repoId) ?? [] + if (!candidate.executionHostId && !candidate.connectionId) { + return matches.length === 1 ? matches[0] : null + } + const hostId = getWorkspaceCleanupCandidateHostId(candidate) + const hostMatches = matches.filter((repo) => getRepoExecutionHostId(repo) === hostId) + return hostMatches.length === 1 ? hostMatches[0] : null +} + +function findCandidateWorktree( + candidate: WorkspaceCleanupCandidate, + repo: Repo | null, + lookup: WorkspaceCleanupReviewLookup +): Worktree | null { + const matches = + lookup.worktreesByRepoAndId.get(`${candidate.repoId}\0${candidate.worktreeId}`) ?? [] + const hostId = getWorkspaceCleanupCandidateHostId(candidate) + const hostMatches = matches.filter((worktree) => worktree.hostId === hostId) + if (hostMatches.length === 1) { + return hostMatches[0] + } + const legacyMatches = matches.filter((worktree) => !worktree.hostId) + const repoMatches = lookup.reposById.get(candidate.repoId) ?? [] + return legacyMatches.length === 1 && repo && repoMatches.length === 1 ? legacyMatches[0] : null +} + function getLinkedReviewFallback(worktree: Worktree | null): { label: string provider: HostedReviewProvider diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts new file mode 100644 index 00000000000..6a722d15ab0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts @@ -0,0 +1,142 @@ +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + buildWorkspaceCleanupFacetList, + type WorkspaceCleanupFacetSources, + type WorkspaceCleanupFacets +} from './workspace-cleanup-facets' +import { + matchesWorkspaceCleanupActivity, + matchesWorkspaceCleanupAgent, + matchesWorkspaceCleanupContext, + matchesWorkspaceCleanupGit, + matchesWorkspaceCleanupLocation, + matchesWorkspaceCleanupReview, + matchesWorkspaceCleanupSafety, + matchesWorkspaceCleanupSize, + matchesWorkspaceCleanupStatus, + matchesWorkspaceCleanupTicket +} from './workspace-cleanup-facet-matchers' +import { sortWorkspaceCleanupFacets } from './workspace-cleanup-facet-sort' +import type { + WorkspaceCleanupFilterState, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' + +export type WorkspaceCleanupQuery = { + filters: WorkspaceCleanupFilterState + sort: WorkspaceCleanupSortState +} + +export type WorkspaceCleanupQueryResult = { + /** Sorted rows that passed every facet. */ + rows: WorkspaceCleanupFacets[] + /** Rows the user could actually queue for deletion, for the select-all affordance. */ + selectableWorktreeIds: string[] + totalCount: number + matchedCount: number +} + +export function matchesWorkspaceCleanupFilters( + facets: WorkspaceCleanupFacets, + filters: WorkspaceCleanupFilterState, + now: number = Date.now() +): boolean { + const query = filters.query.trim().toLowerCase() + return matchesWorkspaceCleanupFiltersWithQuery(facets, filters, now, query) +} + +function matchesWorkspaceCleanupFiltersWithQuery( + facets: WorkspaceCleanupFacets, + filters: WorkspaceCleanupFilterState, + now: number, + query: string +): boolean { + if (query && !facets.searchText.includes(query)) { + return false + } + return ( + matchesWorkspaceCleanupSafety(facets, filters.safety) && + matchesWorkspaceCleanupActivity(facets, filters.activity, now) && + matchesWorkspaceCleanupSize(facets, filters.size) && + matchesWorkspaceCleanupStatus(facets, filters.status) && + matchesWorkspaceCleanupAgent(facets, filters.agent) && + matchesWorkspaceCleanupGit(facets, filters.git) && + matchesWorkspaceCleanupReview(facets, filters.review) && + matchesWorkspaceCleanupTicket(facets, filters.ticket) && + matchesWorkspaceCleanupContext(facets, filters.context) && + matchesWorkspaceCleanupLocation(facets, filters.location) + ) +} + +export function filterWorkspaceCleanupFacets( + facets: readonly WorkspaceCleanupFacets[], + filters: WorkspaceCleanupFilterState, + now: number = Date.now() +): WorkspaceCleanupFacets[] { + const query = filters.query.trim().toLowerCase() + return facets.filter((entry) => + matchesWorkspaceCleanupFiltersWithQuery(entry, filters, now, query) + ) +} + +export function runWorkspaceCleanupQuery( + facets: readonly WorkspaceCleanupFacets[], + query: WorkspaceCleanupQuery, + now: number = Date.now() +): WorkspaceCleanupQueryResult { + const matched = filterWorkspaceCleanupFacets(facets, query.filters, now) + const rows = sortWorkspaceCleanupFacets(matched, query.sort) + return { + rows, + selectableWorktreeIds: rows.filter((row) => row.isSelectable).map((row) => row.worktreeId), + totalCount: facets.length, + matchedCount: rows.length + } +} + +/** One-shot convenience for callers holding raw scan candidates. */ +export function queryWorkspaceCleanupCandidates( + candidates: readonly WorkspaceCleanupCandidate[], + query: WorkspaceCleanupQuery, + sources: WorkspaceCleanupFacetSources = {}, + now: number = Date.now() +): WorkspaceCleanupQueryResult { + return runWorkspaceCleanupQuery(buildWorkspaceCleanupFacetList(candidates, sources), query, now) +} + +/** + * Per-facet-group match counts for badge/count rendering. Each entry counts the + * rows that would survive if only that group were applied, so a UI can show how + * much each facet is costing without re-running the whole pipeline per group. + */ +export function countWorkspaceCleanupFacetMatches( + facets: readonly WorkspaceCleanupFacets[], + filters: WorkspaceCleanupFilterState, + now: number = Date.now() +): Record, number> { + return { + activity: count(facets, (row) => matchesWorkspaceCleanupActivity(row, filters.activity, now)), + size: count(facets, (row) => matchesWorkspaceCleanupSize(row, filters.size)), + status: count(facets, (row) => matchesWorkspaceCleanupStatus(row, filters.status)), + agent: count(facets, (row) => matchesWorkspaceCleanupAgent(row, filters.agent)), + git: count(facets, (row) => matchesWorkspaceCleanupGit(row, filters.git)), + review: count(facets, (row) => matchesWorkspaceCleanupReview(row, filters.review)), + ticket: count(facets, (row) => matchesWorkspaceCleanupTicket(row, filters.ticket)), + context: count(facets, (row) => matchesWorkspaceCleanupContext(row, filters.context)), + location: count(facets, (row) => matchesWorkspaceCleanupLocation(row, filters.location)), + safety: count(facets, (row) => matchesWorkspaceCleanupSafety(row, filters.safety)) + } +} + +function count( + facets: readonly WorkspaceCleanupFacets[], + predicate: (facets: WorkspaceCleanupFacets) => boolean +): number { + let total = 0 + for (const entry of facets) { + if (predicate(entry)) { + total += 1 + } + } + return total +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-relative-time.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-relative-time.ts new file mode 100644 index 00000000000..1391ec7327b --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-relative-time.ts @@ -0,0 +1,27 @@ +import { translate } from '@/i18n/i18n' + +/** Compact "how long ago" label for cleanup rows; 0 means Orca never recorded it. */ +export function formatWorkspaceCleanupRelativeTime(timestamp: number, now = Date.now()): string { + if (!timestamp) { + return translate('components.workspace.cleanup.relativeTime.never', 'Never') + } + const deltaMs = now - timestamp + if (deltaMs < 60_000) { + return translate('components.workspace.cleanup.relativeTime.justNow', 'Just now') + } + const minutes = Math.floor(deltaMs / 60_000) + if (minutes < 60) { + return translate('components.workspace.cleanup.relativeTime.minutesAgo', '{{value0}}m ago', { + value0: minutes + }) + } + const hours = Math.floor(minutes / 60) + if (hours < 48) { + return translate('components.workspace.cleanup.relativeTime.hoursAgo', '{{value0}}h ago', { + value0: hours + }) + } + return translate('components.workspace.cleanup.relativeTime.daysAgo', '{{value0}}d ago', { + value0: Math.floor(hours / 24) + }) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-session.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-session.ts deleted file mode 100644 index e202ac8bfe5..00000000000 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-session.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { - useCallback, - useRef, - useState, - type Dispatch, - type RefObject, - type SetStateAction -} from 'react' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' -import type { AppState } from '@/store/types' -import { useAppStore } from '@/store' -import { - startWorkspaceCleanupBackgroundRemoval, - type WorkspaceCleanupRemovalProgress -} from './workspace-cleanup-background-removal' -import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' - -type WorkspaceCleanupSelectedIdsSetter = Dispatch>> - -export type WorkspaceCleanupRemovalSession = { - confirming: boolean - confirmCandidates: WorkspaceCleanupCandidate[] - removalProgress: WorkspaceCleanupRemovalProgress | null - removalInFlight: boolean - rowFailures: Record - removalInFlightRef: RefObject - clearRowFailures: () => void - resetForOpen: () => void - applyScanDefaults: ( - candidates: readonly WorkspaceCleanupCandidate[], - deletingWorktreeIds: ReadonlySet - ) => void - openConfirmRemove: (candidates: readonly WorkspaceCleanupCandidate[]) => void - cancelConfirmRemove: () => void - backToWorkspaceCleanupList: () => void - confirmRemove: () => void -} - -export function useWorkspaceCleanupRemovalSession({ - mountedRef, - setSelectedIds, - closeModal, - removeCandidates, - markWorktreesQueuedForDeletion, - clearWorktreeDeleteState -}: { - mountedRef: RefObject - setSelectedIds: WorkspaceCleanupSelectedIdsSetter - closeModal: AppState['closeModal'] - removeCandidates: AppState['removeWorkspaceCleanupCandidates'] - markWorktreesQueuedForDeletion: AppState['markWorktreesQueuedForDeletion'] - clearWorktreeDeleteState: AppState['clearWorktreeDeleteState'] -}): WorkspaceCleanupRemovalSession { - const [confirming, setConfirming] = useState(false) - const [confirmCandidates, setConfirmCandidates] = useState([]) - const [removalProgress, setRemovalProgress] = useState( - null - ) - // Why: progress arrives after the click; the ref synchronously rejects a second batch. - const [removalInFlight, setRemovalInFlight] = useState(false) - const [rowFailures, setRowFailures] = useState>({}) - const removalInFlightRef = useRef(false) - // Why: an older batch's late settlement must not mutate a newer batch's UI. - const removalBatchIdRef = useRef(0) - - const clearRowFailures = useCallback(() => setRowFailures({}), []) - - const resetForOpen = useCallback(() => { - setConfirming(false) - setRowFailures({}) - setSelectedIds(new Set()) - }, [setSelectedIds]) - - const applyScanDefaults = useCallback( - ( - candidates: readonly WorkspaceCleanupCandidate[], - deletingWorktreeIds: ReadonlySet - ) => { - if (removalInFlightRef.current) { - return - } - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(candidates, deletingWorktreeIds)) - setConfirming(false) - setRowFailures({}) - }, - [setSelectedIds] - ) - - const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { - const nextCandidates = filterWorkspaceCleanupRemovalCandidates( - candidates, - useAppStore.getState().deleteStateByWorktreeId - ) - if (nextCandidates.length === 0) { - return - } - setConfirmCandidates(nextCandidates) - setConfirming(true) - }, []) - - const cancelConfirmRemove = useCallback(() => { - if (removalProgress) { - closeModal() - return - } - setConfirming(false) - setConfirmCandidates([]) - }, [closeModal, removalProgress]) - - // Why: Back exposes per-row progress without cancelling the batch; Close dismisses the modal. - const backToWorkspaceCleanupList = useCallback(() => { - setConfirming(false) - setConfirmCandidates([]) - }, []) - - const clearQueuedDeleteState = useCallback( - (worktreeId: string) => { - const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId] - // Why: rows that fail before removal starts must not stay queued in the sidebar. - if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') { - clearWorktreeDeleteState(worktreeId) - } - }, - [clearWorktreeDeleteState] - ) - - const deselectRemovedIds = useCallback( - (removedIds: readonly string[]) => { - if (removedIds.length === 0) { - return - } - setSelectedIds((current) => { - const next = new Set(current) - for (const id of removedIds) { - next.delete(id) - } - return next - }) - }, - [setSelectedIds] - ) - - const confirmRemove = useCallback(() => { - if (confirmCandidates.length === 0 || removalInFlightRef.current) { - return - } - const removableCandidates = filterWorkspaceCleanupRemovalCandidates( - confirmCandidates, - useAppStore.getState().deleteStateByWorktreeId - ) - if (removableCandidates.length === 0) { - setConfirming(false) - setConfirmCandidates([]) - return - } - removalInFlightRef.current = true - setRemovalInFlight(true) - removalBatchIdRef.current += 1 - const removalBatchId = removalBatchIdRef.current - // Why: hung late settlements retain callbacks, so capture ids instead of candidate objects. - const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId) - setRowFailures({}) - markWorktreesQueuedForDeletion(removableWorktreeIds) - startWorkspaceCleanupBackgroundRemoval({ - candidates: removableCandidates, - removeCandidates, - onProgress: (progress) => { - if (mountedRef.current) { - setRemovalProgress(progress) - } - }, - onRowFailed: (failure) => { - clearQueuedDeleteState(failure.worktreeId) - }, - onResult: (result) => { - const nextFailures: Record = {} - for (const failure of result.failures) { - nextFailures[failure.worktreeId] = failure.message - clearQueuedDeleteState(failure.worktreeId) - } - if (mountedRef.current) { - setRowFailures(nextFailures) - deselectRemovedIds(result.removedIds) - setRemovalProgress(null) - setRemovalInFlight(false) - setConfirming(false) - setConfirmCandidates([]) - } - removalInFlightRef.current = false - }, - onLateResult: (result) => { - for (const failure of result.failures) { - // Why: a late preflight failure can leave a row that never started stuck as queued. - clearQueuedDeleteState(failure.worktreeId) - } - if (!mountedRef.current || removalBatchIdRef.current !== removalBatchId) { - return - } - setRowFailures((current) => { - const next = { ...current } - for (const id of result.removedIds) { - delete next[id] - } - for (const failure of result.failures) { - next[failure.worktreeId] = failure.message - } - return next - }) - deselectRemovedIds(result.removedIds) - }, - onError: () => { - // Why: the batch driver is gone, so nothing will settle these rows later; clear - // unconditionally (not just queued ones) or a hung 'deleting' row stays stuck in - // the sidebar and filterWorkspaceCleanupRemovalCandidates blocks every retry. - for (const worktreeId of removableWorktreeIds) { - clearWorktreeDeleteState(worktreeId) - } - if (mountedRef.current) { - setRemovalProgress(null) - setRemovalInFlight(false) - setConfirming(false) - setConfirmCandidates([]) - } - removalInFlightRef.current = false - } - }) - }, [ - clearQueuedDeleteState, - clearWorktreeDeleteState, - confirmCandidates, - deselectRemovedIds, - markWorktreesQueuedForDeletion, - mountedRef, - removeCandidates - ]) - - return { - confirming, - confirmCandidates, - removalProgress, - removalInFlight, - rowFailures, - removalInFlightRef, - clearRowFailures, - resetForOpen, - applyScanDefaults, - openConfirmRemove, - cancelConfirmRemove, - backToWorkspaceCleanupList, - confirmRemove - } -} - -function getDefaultSelectedWorkspaceCleanupIds( - candidates: readonly WorkspaceCleanupCandidate[], - deletingWorktreeIds: ReadonlySet -): Set { - return new Set( - candidates - .filter( - (candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId) - ) - .map((candidate) => candidate.worktreeId) - ) -} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx new file mode 100644 index 00000000000..e706c60b486 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx @@ -0,0 +1,98 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { formatBytes } from '../status-bar/workspace-space-format' +import { CandidateRow, type WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' +import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' +import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices' +import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' +import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' + +export type WorkspaceCleanupRowListState = { + rows: readonly WorkspaceCleanupFacets[] + /** The dialog's "as of" clock; keeps row labels consistent with filter bucketing. */ + now: number + /** Rows the scan produced before filtering; separates "empty fleet" from "no matches". */ + scannedCount: number + hasScanned: boolean + loading: boolean + deletionPhaseByWorktreeId: Record + deletingWorktreeIds: ReadonlySet + expandedRowIds: ReadonlySet + selectedIds: ReadonlySet + gitPendingWorktreeIds: ReadonlySet + rowFailures: Record + removalInFlight: boolean + scrollElement: HTMLDivElement | null + onClearFilters: () => void + onToggleExpanded: (worktreeId: string) => void + onToggleSelected: (worktreeId: string) => void + onView: (candidate: WorkspaceCleanupCandidate) => void + onIgnore: (candidate: WorkspaceCleanupCandidate) => void + onRemove: (candidate: WorkspaceCleanupCandidate) => void +} + +export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): React.JSX.Element { + const { rows, loading, hasScanned, scannedCount } = props + const settled = !loading && hasScanned + return ( + <> + {settled && scannedCount === 0 ? ( + + ) : null} + {hasScanned && scannedCount > 0 && rows.length === 0 ? ( + + ) : null} + ( + 1 && index === rows.length - 1} + expanded={props.expandedRowIds.has(row.worktreeId)} + lastActivityLabel={formatWorkspaceCleanupRelativeTime(row.lastActivityAt, props.now)} + sizeLabel={row.sizeBytes === null ? null : formatBytes(row.sizeBytes)} + workspaceStatusLabel={row.workspaceStatusLabel} + gitEvidencePending={props.gitPendingWorktreeIds.has(row.worktreeId)} + deletionPhase={props.deletionPhaseByWorktreeId[row.worktreeId]} + // Why: a background rescan must not lock rows; only an actual + // removal batch or this row's own deletion disables it. + removing={props.removalInFlight || props.deletingWorktreeIds.has(row.worktreeId)} + selected={ + props.selectedIds.has(row.worktreeId) && + !props.deletingWorktreeIds.has(row.worktreeId) + } + failure={props.rowFailures[row.worktreeId]} + onToggleExpanded={props.onToggleExpanded} + onToggleSelected={props.onToggleSelected} + onView={props.onView} + onIgnore={props.onIgnore} + onRemove={props.onRemove} + /> + )} + /> + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts new file mode 100644 index 00000000000..e05d4b2caa7 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts @@ -0,0 +1,121 @@ +import { translate } from '@/i18n/i18n' +import type { + WorkspaceCleanupScanError, + WorkspaceCleanupScanProgress +} from '../../../../shared/workspace-cleanup' + +function isDisconnectedRemoteScanError(message: string): boolean { + return ( + message === 'SSH provider is unavailable.' || + message === 'Remote workspaces are not connected. Reconnect and refresh to check them.' + ) +} + +export function formatWorkspaceCleanupScanNotice( + errors: readonly WorkspaceCleanupScanError[], + repoNameById: ReadonlyMap +): string | null { + const visibleErrors = errors.filter( + (error) => !isDisconnectedRemoteScanError(error.message ?? '') + ) + if (visibleErrors.length === 0) { + return null + } + if (visibleErrors.length === 1) { + const error = visibleErrors[0] + const repoName = formatScanErrorRepoName(error, repoNameById) + return translate( + 'components.workspace.cleanup.scan.singleError', + 'Could not check {{value0}}: {{value1}}. Some workspaces may be missing. Refresh to try again.', + { value0: repoName, value1: formatScanErrorReason(error.message) } + ) + } + const repoNames = visibleErrors + .slice(0, 3) + .map((error) => formatScanErrorRepoName(error, repoNameById)) + .join(', ') + const moreCount = visibleErrors.length - 3 + const suffix = + moreCount > 0 + ? translate('components.workspace.cleanup.scan.moreErrors', ', +{{value0}} more', { + value0: moreCount + }) + : '' + return translate( + 'components.workspace.cleanup.scan.multipleErrors', + 'Could not check {{value0}} repositories ({{value1}}{{value2}}). Some workspaces may be missing. Refresh to try again.', + { value0: visibleErrors.length, value1: repoNames, value2: suffix } + ) +} + +export function formatWorkspaceCleanupScanProgress( + progress: WorkspaceCleanupScanProgress | null +): string { + if (!progress || progress.scannedWorktreeCount === 0) { + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe', + 'Finding workspaces...' + ) + } + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181', + 'Checked workspaces so far: {{value0}}', + { value0: progress.scannedWorktreeCount } + ) +} + +export function formatWorkspaceCleanupReadyToast( + workspaceCount: number, + suggestedCount: number +): string { + if (workspaceCount === 0) { + return translate('components.workspace.cleanup.scan.noWorkspaces', 'No workspaces found.') + } + if (workspaceCount === 1) { + return suggestedCount === 1 + ? translate( + 'components.workspace.cleanup.scan.readyOneOne', + '1 workspace found, with 1 cleanup suggestion.' + ) + : translate( + 'components.workspace.cleanup.scan.readyOneMany', + '1 workspace found, with {{value0}} cleanup suggestions.', + { value0: suggestedCount } + ) + } + return suggestedCount === 1 + ? translate( + 'components.workspace.cleanup.scan.readyManyOne', + '{{value0}} workspaces found, with 1 cleanup suggestion.', + { value0: workspaceCount } + ) + : translate( + 'components.workspace.cleanup.scan.readyManyMany', + '{{value0}} workspaces found, with {{value1}} cleanup suggestions.', + { value0: workspaceCount, value1: suggestedCount } + ) +} + +function formatScanErrorRepoName( + error: Partial, + repoNameById: ReadonlyMap +): string { + const repoName = error.repoName?.trim() + if (repoName) { + return repoName + } + const fallback = error.repoId ? repoNameById.get(error.repoId)?.trim() : '' + return ( + fallback || translate('components.workspace.cleanup.scan.fallbackRepository', 'a repository') + ) +} + +function formatScanErrorReason(message: string | undefined): string { + if (!message || message === 'Could not scan workspace cleanup for this repository.') { + return translate( + 'components.workspace.cleanup.scan.gitListFailed', + 'Git could not list worktrees' + ) + } + return message.replace(/\.$/, '') +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-session.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-session.ts deleted file mode 100644 index fc4cd59b1fd..00000000000 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-session.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useCallback, useEffect, useRef, type RefObject } from 'react' -import { toast } from 'sonner' -import type { AppState } from '@/store/types' -import { translate } from '@/i18n/i18n' - -export function useWorkspaceCleanupScanSession({ - open, - mountedRef, - openModal, - scanWorkspaceCleanup, - clearRowFailures -}: { - open: boolean - mountedRef: RefObject - openModal: AppState['openModal'] - scanWorkspaceCleanup: AppState['scanWorkspaceCleanup'] - clearRowFailures: () => void -}): (options?: { notifyWhenReady?: boolean }) => void { - const openRef = useRef(open) - const latestReadyToastScanAtRef = useRef(null) - - useEffect(() => { - openRef.current = open - }, [open]) - - return useCallback( - (options: { notifyWhenReady?: boolean } = {}) => { - clearRowFailures() - void scanWorkspaceCleanup() - .then((result) => { - if (!mountedRef.current || !options.notifyWhenReady || openRef.current) { - return - } - if (latestReadyToastScanAtRef.current === result.scannedAt) { - return - } - latestReadyToastScanAtRef.current = result.scannedAt - const suggestedCount = result.candidates.filter( - (candidate) => candidate.selectedByDefault - ).length - toast.success( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63', - 'Inactive workspace scan ready' - ), - { - description: formatWorkspaceCleanupReadyToastDescription( - result.candidates.length, - suggestedCount - ), - action: { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764', - 'Review' - ), - onClick: () => openModal('workspace-cleanup') - } - } - ) - }) - .catch((error: unknown) => { - if (!mountedRef.current) { - return - } - toast.error( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.662b8ec3f8', - 'Workspace cleanup scan failed' - ), - { description: error instanceof Error ? error.message : String(error) } - ) - }) - }, - [clearRowFailures, mountedRef, openModal, scanWorkspaceCleanup] - ) -} - -function formatWorkspaceCleanupReadyToastDescription( - inactiveCount: number, - suggestedCount: number -): string { - if (inactiveCount === 0) { - return 'No inactive workspaces found.' - } - const inactiveNoun = inactiveCount === 1 ? 'workspace' : 'workspaces' - const suggestedNoun = suggestedCount === 1 ? 'suggestion' : 'suggestions' - return `${inactiveCount} inactive ${inactiveNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.` -} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts new file mode 100644 index 00000000000..b32e789a5b7 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts @@ -0,0 +1,38 @@ +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' + +export function formatVanishedSelectionNotice(count: number): string { + return count === 1 + ? translate( + 'components.workspace.cleanup.browse.selectionVanishedOne', + '1 selected workspace no longer exists.' + ) + : translate( + 'components.workspace.cleanup.browse.selectionVanished', + '{{value0}} selected workspaces no longer exist.', + { value0: count } + ) +} + +export function getDefaultSelectedWorkspaceCleanupIds( + candidates: readonly WorkspaceCleanupCandidate[], + deletingWorktreeIds: ReadonlySet = new Set() +): Set { + return new Set( + candidates + .filter( + (candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId) + ) + .map((candidate) => candidate.worktreeId) + ) +} + +export function toggleSetMember(current: Set, value: string): Set { + const next = new Set(current) + if (next.has(value)) { + next.delete(value) + } else { + next.add(value) + } + return next +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx new file mode 100644 index 00000000000..0300865b531 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model' +import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header' + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect: () => void }) => ( + + ), + DropdownMenuLabel: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function render(sort: WorkspaceCleanupSortState, handlers: Record = {}): void { + act(() => + root?.render( + + ) + ) +} + +function sortOption(label: string): HTMLButtonElement | undefined { + return [...(container?.querySelectorAll('[data-sort-option]') ?? [])].find( + (button) => button.textContent?.trim() === label + ) +} + +describe('WorkspaceCleanupSortHeader', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('shows the active sort in one menu trigger', () => { + render({ field: 'size', direction: 'desc' }) + + expect(container?.querySelector('[aria-label="Sort by Size"]')?.textContent).toContain( + 'Sort by Size' + ) + }) + + it('routes a menu choice through the sort toggle', () => { + const onToggleSortField = vi.fn() + render({ field: 'last-activity', direction: 'asc' }, { onToggleSortField }) + + act(() => sortOption('Repository')?.click()) + + expect(onToggleSortField).toHaveBeenCalledWith('repo') + }) + + it('select-all reads the query result rather than the rendered page', () => { + const onToggleSelectAll = vi.fn() + render({ field: 'name', direction: 'asc' }, { onToggleSelectAll }) + + const checkbox = container?.querySelector('[role="checkbox"]') + expect(checkbox?.getAttribute('aria-checked')).toBe('false') + act(() => checkbox?.click()) + + expect(onToggleSelectAll).toHaveBeenCalledWith(true) + }) + + it('exposes a partial selection as mixed', () => { + render({ field: 'name', direction: 'asc' }, { selectableCount: 3, selectedCount: 1 }) + + expect( + container?.querySelector('[role="checkbox"]')?.getAttribute('aria-checked') + ).toBe('mixed') + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx new file mode 100644 index 00000000000..2e8de45c1a8 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import { ArrowDown, ArrowUp, Check, ChevronsUpDown, Minus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' +import { WORKSPACE_CLEANUP_SORT_FIELD_VALUES } from '../../../../shared/workspace-cleanup-facet-rankings' +import type { + WorkspaceCleanupSortField, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' +import { getWorkspaceCleanupSortFieldLabel } from './workspace-cleanup-facet-labels' + +export function WorkspaceCleanupSortHeader({ + sort, + selectableCount, + selectedCount, + onToggleSortField, + onToggleSelectAll +}: { + sort: WorkspaceCleanupSortState + selectableCount: number + selectedCount: number + onToggleSortField: (field: WorkspaceCleanupSortField) => void + onToggleSelectAll: (selectAll: boolean) => void +}): React.JSX.Element { + const allSelected = selectableCount > 0 && selectedCount >= selectableCount + const someSelected = selectableCount > 0 && selectedCount > 0 && !allSelected + return ( +
+ + + {translate( + 'components.workspace.cleanup.browse.selectAll', + 'Select all matching workspaces' + )} + + + + + + + + {translate('components.workspace.cleanup.browse.sortBy', 'Sort by')} + + {WORKSPACE_CLEANUP_SORT_FIELD_VALUES.map((field) => ( + onToggleSortField(field)}> + {getWorkspaceCleanupSortFieldLabel(field)} + {sort.field === field ? : null} + + ))} + + +
+ ) +} + +function SortDirectionIcon({ direction }: { direction: 'asc' | 'desc' }): React.JSX.Element { + return direction === 'asc' ? ( +