From b38e88a5cc9797b24fa53fd7af6f368cde19564b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:49:57 +0000 Subject: [PATCH 1/3] release: v1.4.104-rc.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 80d60b83506..835dd18ca6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.104-rc.3", + "version": "1.4.104-rc.4", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 8ed890374ad0e7fa13c84966f1cb1433b967e519 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:09:05 -0700 Subject: [PATCH 2/3] Refresh project worktrees created outside Orca (#6241) Co-authored-by: Orca --- src/main/index.ts | 14 +- src/main/ipc/repos.ts | 43 ++- src/main/ipc/settings.ts | 2 + .../worktree-base-directory-event-filter.ts | 102 +++++++ .../worktree-base-directory-watch-targets.ts | 202 +++++++++++++ .../worktree-base-directory-watcher.test.ts | 255 ++++++++++++++++ .../ipc/worktree-base-directory-watcher.ts | 286 ++++++++++++++++++ src/main/ipc/worktree-common-git-directory.ts | 65 ++++ src/main/persistence.ts | 15 +- src/main/runtime/orca-runtime.ts | 13 +- .../runtime/rpc/methods/repo-update-schema.ts | 63 ++++ src/main/runtime/rpc/methods/repo.ts | 54 +--- .../window/attach-main-window-services.ts | 7 + src/preload/api-types.ts | 7 +- .../ImportedWorktreesVisibilityLine.tsx | 3 +- .../NewExternalWorktreesInboxLine.test.tsx | 86 ++++++ .../sidebar/NewExternalWorktreesInboxLine.tsx | 207 +++++++++++++ .../SuppressExternalWorktreeInboxDialog.tsx | 88 ++++++ .../WorktreeList.lineage-child-card.test.ts | 8 +- .../src/components/sidebar/WorktreeList.tsx | 257 +++++++++++++++- .../sidebar/WorktreeVisibilityDialog.tsx | 9 +- .../components/sidebar/host-section-rows.ts | 1 + .../imported-worktrees-card-actions.test.ts | 11 +- .../imported-worktrees-card-actions.ts | 20 +- .../imported-worktrees-card-candidates.ts | 18 +- ...w-external-worktrees-inbox-actions.test.ts | 102 +++++++ .../new-external-worktrees-inbox-actions.ts | 148 +++++++++ ...xternal-worktrees-inbox-candidates.test.ts | 107 +++++++ ...new-external-worktrees-inbox-candidates.ts | 57 ++++ .../components/sidebar/worktree-drag-units.ts | 2 + .../sidebar/worktree-list-groups.test.ts | 215 +++++++++++++ .../sidebar/worktree-list-groups.ts | 74 ++++- .../sidebar/worktree-list-virtual-rows.ts | 2 +- src/renderer/src/i18n/locales/en.json | 25 ++ src/renderer/src/i18n/locales/es.json | 25 ++ src/renderer/src/i18n/locales/ja.json | 25 ++ src/renderer/src/i18n/locales/ko.json | 25 ++ src/renderer/src/i18n/locales/zh.json | 25 ++ src/renderer/src/store/slices/repos.ts | 41 ++- src/shared/external-worktree-inbox.test.ts | 120 ++++++++ src/shared/external-worktree-inbox.ts | 96 ++++++ src/shared/types.ts | 6 + src/shared/worktree-ownership.test.ts | 27 ++ src/shared/worktree-ownership.ts | 12 +- 44 files changed, 2864 insertions(+), 106 deletions(-) create mode 100644 src/main/ipc/worktree-base-directory-event-filter.ts create mode 100644 src/main/ipc/worktree-base-directory-watch-targets.ts create mode 100644 src/main/ipc/worktree-base-directory-watcher.test.ts create mode 100644 src/main/ipc/worktree-base-directory-watcher.ts create mode 100644 src/main/ipc/worktree-common-git-directory.ts create mode 100644 src/main/runtime/rpc/methods/repo-update-schema.ts create mode 100644 src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx create mode 100644 src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx create mode 100644 src/renderer/src/components/sidebar/SuppressExternalWorktreeInboxDialog.tsx create mode 100644 src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.test.ts create mode 100644 src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.ts create mode 100644 src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.test.ts create mode 100644 src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts create mode 100644 src/shared/external-worktree-inbox.test.ts create mode 100644 src/shared/external-worktree-inbox.ts diff --git a/src/main/index.ts b/src/main/index.ts index c1a4624e079..f8ab73b4aa2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,7 @@ import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/stor import { killAllPty } from './ipc/pty' import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init' import { closeAllWatchers } from './ipc/filesystem-watcher' +import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' import { initObservability, shutdownObservability } from './observability' import { startSpan } from './observability/tracer' @@ -986,9 +987,16 @@ function shutdownWatchersOnce(): Promise { if (!watcherShutdownPromise) { // Why: @parcel/watcher tears down native async work during unsubscribe. // Electron must wait for that cleanup before Node's environment exits. - watcherShutdownPromise = closeAllWatchers() - .catch((error) => { - console.error('[filesystem-watcher] shutdown failed:', error) + watcherShutdownPromise = Promise.allSettled([ + closeAllWatchers(), + disposeWorktreeBaseDirectoryWatchers() + ]) + .then((results) => { + for (const result of results) { + if (result.status === 'rejected') { + console.error('[filesystem-watcher] shutdown failed:', result.reason) + } + } }) .then(() => { watcherShutdownDone = true diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index e3959bb48af..df2c13c8646 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -79,6 +79,7 @@ import { getSshGitUsername } from '../git/git-username' import { getActiveMultiplexer } from './ssh' import { normalizeSparseDirectories } from './sparse-checkout-directories' import { track } from '../telemetry/client' +import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import type { RepoMethod } from '../../shared/telemetry-events' import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' @@ -1926,10 +1927,17 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v | 'forkSyncMode' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' | 'projectGroupId' | 'projectGroupOrder' > - > & { sourceControlAi?: Repo['sourceControlAi'] | null } + > & { + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: + | Repo['externalWorktreeDiscoverySuppressedAt'] + | null + } } ) => { // Why: validate the persisted preference string at the IPC boundary @@ -2009,6 +2017,38 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) { delete updates.externalWorktreeVisibilityPromptDismissedAt } + // Why: null is the transport sentinel for clearing discovery suppression. + if ( + 'externalWorktreeDiscoverySuppressedAt' in updates && + updates.externalWorktreeDiscoverySuppressedAt === null + ) { + updates.externalWorktreeDiscoverySuppressedAt = undefined + } else if ( + 'externalWorktreeDiscoverySuppressedAt' in updates && + updates.externalWorktreeDiscoverySuppressedAt !== undefined && + (typeof updates.externalWorktreeDiscoverySuppressedAt !== 'number' || + !Number.isFinite(updates.externalWorktreeDiscoverySuppressedAt)) + ) { + delete updates.externalWorktreeDiscoverySuppressedAt + } + if ( + 'externalWorktreeInboxBaselinePaths' in updates && + updates.externalWorktreeInboxBaselinePaths !== undefined + ) { + const value = updates.externalWorktreeInboxBaselinePaths as unknown + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { + delete updates.externalWorktreeInboxBaselinePaths + } + } + if ( + 'importedExternalWorktreePaths' in updates && + updates.importedExternalWorktreePaths !== undefined + ) { + const value = updates.importedExternalWorktreePaths as unknown + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { + delete updates.importedExternalWorktreePaths + } + } // Why: null is the transport sentinel for clearing Source Control AI. // Other invalid fields are deleted; this one must flow as undefined. if ('sourceControlAi' in updates && updates.sourceControlAi === null) { @@ -2525,6 +2565,7 @@ function notifyReposChanged(mainWindow: BrowserWindow): void { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('repos:changed') } + scheduleCurrentWorktreeBaseDirectoryWatcherSync() } function notifySparsePresetsChanged(mainWindow: BrowserWindow, repoId: string): void { diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 93a611fe8eb..585a3a6ee3c 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -18,6 +18,7 @@ import { normalizeUiLanguage } from '../../shared/ui-language' import { applyAppIcon } from '../app-icon' import { normalizeTerminalCustomThemes } from '../../shared/terminal-custom-themes' import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation' +import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -114,6 +115,7 @@ export function registerSettingsHandlers( ('nestWorkspaces' in sanitizedArgs && before.nestWorkspaces !== result.nestWorkspaces) ) { void prepareLocalWorktreeRootsForRepos(store) + scheduleCurrentWorktreeBaseDirectoryWatcherSync() } if (APPEARANCE_MENU_KEYS.some((key) => key in sanitizedArgs)) { rebuildAppMenu() diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts new file mode 100644 index 00000000000..4c9d294cf21 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-event-filter.ts @@ -0,0 +1,102 @@ +import { + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../../shared/cross-platform-path' + +type WorktreeBaseWatcherEvent = { + type: 'create' | 'update' | 'delete' + path: string +} + +export type WorktreeBaseWatchKind = 'base' | 'git-common' + +export type WorktreeBaseRepoWatchConfig = { + repoId: string + repoName: string + nestWorkspaces: boolean +} + +export type WorktreeBaseWatchTarget = { + key: string + kind: WorktreeBaseWatchKind + path: string + connectionId?: string + repos: Map +} + +export function pathRelativeToWorktreeWatchRoot( + rootPath: string, + candidatePath: string +): string[] | null { + const relativePath = relativePathInsideRoot(rootPath, candidatePath) + if (relativePath === null) { + return null + } + return relativePath.split(/[\\/]+/).filter(Boolean) +} + +function isRootCompletionEvent(parts: string[], config: WorktreeBaseRepoWatchConfig): boolean { + if (config.nestWorkspaces) { + return ( + parts.length === 2 && + normalizeRuntimePathForComparison(parts[0]) === + normalizeRuntimePathForComparison(config.repoName) + ) + } + return parts.length === 1 +} + +// Why: root creation can arrive before Git finishes registration; the `.git` +// marker is the checkout-complete signal, while deeper file churn is ignored. +function isGitMarkerCompletionEvent(parts: string[], config: WorktreeBaseRepoWatchConfig): boolean { + if (config.nestWorkspaces) { + return ( + parts.length === 3 && + normalizeRuntimePathForComparison(parts[0]) === + normalizeRuntimePathForComparison(config.repoName) && + parts[2] === '.git' + ) + } + return parts.length === 2 && parts[1] === '.git' +} + +function matchingBaseRepoIds( + target: WorktreeBaseWatchTarget, + eventPath: string, + eventType: string +): string[] { + const repoIds: string[] = [] + const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath) + if (!parts) { + return repoIds + } + + for (const config of target.repos.values()) { + if ( + isGitMarkerCompletionEvent(parts, config) || + (eventType === 'delete' && isRootCompletionEvent(parts, config)) + ) { + repoIds.push(config.repoId) + } + } + return repoIds +} + +// Why: Git records linked worktrees under the common dir's `worktrees` +// metadata, which is lower churn than watching checkout contents. +function matchingGitCommonRepoIds(target: WorktreeBaseWatchTarget, eventPath: string): string[] { + const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath) + if (!parts || parts[0] !== 'worktrees') { + return [] + } + return [...target.repos.keys()] +} + +export function matchingWorktreeBaseRepoIds( + target: WorktreeBaseWatchTarget, + event: WorktreeBaseWatcherEvent +): string[] { + return target.kind === 'git-common' + ? matchingGitCommonRepoIds(target, event.path) + : matchingBaseRepoIds(target, event.path, event.type) +} diff --git a/src/main/ipc/worktree-base-directory-watch-targets.ts b/src/main/ipc/worktree-base-directory-watch-targets.ts new file mode 100644 index 00000000000..22cf8da8540 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watch-targets.ts @@ -0,0 +1,202 @@ +import { normalize } from 'path' +import { realpath, stat } from 'fs/promises' +import type { Stats } from 'fs' +import type { Store } from '../persistence' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { GlobalSettings, Repo } from '../../shared/types' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { isFolderRepo } from '../../shared/repo-kind' +import { + isRuntimePathAbsolute, + isWindowsAbsolutePathLike, + getRuntimePathBasename, + normalizeRuntimePathForComparison, + resolveRuntimePath +} from '../../shared/cross-platform-path' +import { isWslUncPath } from '../../shared/wsl-paths' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + computeWorkspaceRoot, + getWorktreePathSettings, + hasRepoWorktreeBasePath +} from './worktree-logic' +import { resolveWorktreeCommonGitDirectory } from './worktree-common-git-directory' +import type { + WorktreeBaseRepoWatchConfig, + WorktreeBaseWatchKind, + WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' + +const missingRootWarnings = new Set() +const skippedWslWarnings = new Set() + +function normalizeWatchKey(pathValue: string): string { + return normalizeRuntimePathForComparison(normalize(pathValue)) +} + +async function canonicalizeExistingPath( + pathValue: string, + connectionId: string | undefined +): Promise { + if (connectionId) { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + return normalize(pathValue) + } + try { + return await provider.realpath(pathValue) + } catch { + return normalize(pathValue) + } + } + try { + return await realpath(pathValue) + } catch { + return normalize(pathValue) + } +} + +function isDirectoryStat(value: Stats | FileStat | undefined): boolean { + if (!value) { + return false + } + return 'type' in value ? value.type === 'directory' : value.isDirectory() +} + +async function addTarget( + targets: Map, + kind: WorktreeBaseWatchKind, + pathValue: string, + config: WorktreeBaseRepoWatchConfig, + connectionId?: string +): Promise { + const watchedPath = await canonicalizeExistingPath(pathValue, connectionId) + const key = `${kind}:${connectionId ?? 'local'}:${normalizeWatchKey(watchedPath)}` + const existing = targets.get(key) + if (existing) { + existing.repos.set(config.repoId, config) + return + } + targets.set(key, { + key, + kind, + path: watchedPath, + ...(connectionId ? { connectionId } : {}), + repos: new Map([[config.repoId, config]]) + }) +} + +function getRemoteProvider(connectionId: string | undefined): IFilesystemProvider | undefined { + return connectionId ? getSshFilesystemProvider(connectionId) : undefined +} + +function isRuntimePathAbsoluteForRepo(repoPath: string, pathValue: string): boolean { + const pathFlavor = + isWindowsAbsolutePathLike(repoPath) || isWindowsAbsolutePathLike(pathValue) + ? 'windows' + : 'posix' + return isRuntimePathAbsolute(pathValue, pathFlavor) +} + +function getBaseWatchLayout( + repo: Repo, + pathSettings: Pick, + connectionId: string | undefined +): { workspaceRoot: string; nestWorkspaces: boolean } { + if ( + connectionId && + !hasRepoWorktreeBasePath(repo) && + isRuntimePathAbsoluteForRepo(repo.path, pathSettings.workspaceDir) + ) { + // Why: SSH creates default worktrees beside the remote repo when the + // global workspace dir is a desktop-local absolute path. + return { workspaceRoot: resolveRuntimePath(repo.path, '..'), nestWorkspaces: false } + } + + return { + workspaceRoot: computeWorkspaceRoot(repo.path, pathSettings), + nestWorkspaces: pathSettings.nestWorkspaces + } +} + +async function maybeAddBaseTarget( + targets: Map, + repo: Repo, + settings: GlobalSettings, + connectionId?: string +): Promise { + const pathSettings = getWorktreePathSettings(repo, settings) + const { workspaceRoot, nestWorkspaces } = getBaseWatchLayout(repo, pathSettings, connectionId) + // Why: WSL UNC roots are unreliable for native watching; avoid project-level polling. + if (isWslUncPath(workspaceRoot) || isWslUncPath(repo.path)) { + const key = `${repo.id}:${workspaceRoot}` + if (!skippedWslWarnings.has(key)) { + skippedWslWarnings.add(key) + console.warn( + `[worktree-base-watcher] skipping WSL worktree root watcher for ${workspaceRoot}` + ) + } + return + } + + const config = { + repoId: repo.id, + repoName: getRuntimePathBasename(repo.path).replace(/\.git$/, ''), + nestWorkspaces + } + const remoteProvider = getRemoteProvider(connectionId) + if (connectionId && !remoteProvider) { + return + } + try { + const rootStat = remoteProvider + ? await remoteProvider.stat(workspaceRoot) + : await stat(workspaceRoot) + if (isDirectoryStat(rootStat)) { + await addTarget(targets, 'base', workspaceRoot, config, connectionId) + } + } catch { + const key = normalizeWatchKey(workspaceRoot) + if (!missingRootWarnings.has(key)) { + missingRootWarnings.add(key) + console.warn(`[worktree-base-watcher] worktree root unavailable: ${workspaceRoot}`) + } + } + + const commonDir = await resolveWorktreeCommonGitDirectory( + repo, + remoteProvider + ? { + stat: (path) => remoteProvider.stat(path), + readFile: async (path) => (await remoteProvider.readFile(path)).content + } + : undefined + ) + if (commonDir) { + await addTarget(targets, 'git-common', commonDir, config, connectionId) + } +} + +export async function buildWorktreeBaseDirectoryWatchTargets( + store: Store +): Promise> { + const settings = store.getSettings() + const targets = new Map() + for (const repo of store.getRepos()) { + if (isFolderRepo(repo)) { + continue + } + const executionHostId = getRepoExecutionHostId(repo) + if (executionHostId === LOCAL_EXECUTION_HOST_ID) { + await maybeAddBaseTarget(targets, repo, settings) + } else if (repo.connectionId) { + await maybeAddBaseTarget(targets, repo, settings, repo.connectionId) + } + } + return targets +} + +export function clearWorktreeBaseDirectoryWatchTargetWarnings(): void { + missingRootWarnings.clear() + skippedWslWarnings.clear() +} diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts new file mode 100644 index 00000000000..6b052603fb3 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -0,0 +1,255 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join, sep } from 'path' +import type { Event as WatcherEvent, SubscribeCallback } from '@parcel/watcher' +import type { GlobalSettings, Repo } from '../../shared/types' + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(async () => ''), + realpath: vi.fn(async (path: string) => path), + stat: vi.fn(async () => ({ isDirectory: () => true })) +})) + +vi.mock('@parcel/watcher', () => ({ + subscribe: vi.fn() +})) + +vi.mock('./worktree-remote', () => ({ + notifyWorktreesChanged: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: vi.fn() +})) + +import { subscribe } from '@parcel/watcher' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { notifyWorktreesChanged } from './worktree-remote' +import { + disposeWorktreeBaseDirectoryWatchers, + syncWorktreeBaseDirectoryWatchers +} from './worktree-base-directory-watcher' +import { matchingWorktreeBaseRepoIds } from './worktree-base-directory-event-filter' + +type WatcherCallback = SubscribeCallback + +const watcherCallbacks = new Map() +const unsubscribeMocks = new Map>() +const absolutePath = (...parts: string[]): string => join(sep, ...parts) +const WORKTREE_ROOT = absolutePath('workspace', 'worktrees') +const PROJECT_ROOT = absolutePath('workspace', 'projects', 'project') +const PROJECT_GIT_COMMON_DIR = join(PROJECT_ROOT, '.git') + +const settings = { + workspaceDir: WORKTREE_ROOT, + nestWorkspaces: true +} as GlobalSettings + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: PROJECT_ROOT, + displayName: 'Project', + badgeColor: '#000000', + addedAt: 1, + ...overrides + } as Repo +} + +function makeStore(repos: Repo[]) { + return { + getSettings: () => settings, + getRepos: () => repos + } +} + +function makeWindow(options: { destroyed?: () => boolean } = {}) { + return { + isDestroyed: () => options.destroyed?.() ?? false, + webContents: { send: vi.fn() } + } +} + +function emit(root: string, events: WatcherEvent[]): void { + const callback = watcherCallbacks.get(root) + if (!callback) { + throw new Error(`No watcher callback for ${root}`) + } + callback(null, events) +} + +describe('worktree base directory watcher', () => { + beforeEach(() => { + vi.useFakeTimers() + watcherCallbacks.clear() + unsubscribeMocks.clear() + vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined) + vi.mocked(subscribe).mockImplementation(async (root, callback) => { + const unsubscribe = vi.fn(async () => {}) + watcherCallbacks.set(root, callback) + unsubscribeMocks.set(root, unsubscribe) + return { unsubscribe } + }) + }) + + afterEach(async () => { + await disposeWorktreeBaseDirectoryWatchers() + vi.useRealTimers() + vi.clearAllMocks() + }) + + it('coalesces nested worktree completion events into one targeted notification', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + + emit(WORKTREE_ROOT, [ + { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104') }, + { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') } + ] as WatcherEvent[]) + + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + }) + + it('drops pending worktree notifications after the window is destroyed', async () => { + let destroyed = false + await syncWorktreeBaseDirectoryWatchers( + makeStore([makeRepo()]) as never, + makeWindow({ destroyed: () => destroyed }) as never + ) + + emit(WORKTREE_ROOT, [ + { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') } + ] as WatcherEvent[]) + destroyed = true + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + }) + + it('ignores deep checkout churn below candidate roots', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + + emit(WORKTREE_ROOT, [ + { type: 'update', path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts') } + ] as WatcherEvent[]) + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + }) + + it('uses Git common-dir worktree metadata as a low-churn completion signal', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + + emit(PROJECT_GIT_COMMON_DIR, [ + { type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir') } + ] as WatcherEvent[]) + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + }) + + it('does not install local desktop watchers for runtime or folder repos', async () => { + await syncWorktreeBaseDirectoryWatchers( + makeStore([ + makeRepo({ id: 'runtime', executionHostId: 'runtime:dev' }), + makeRepo({ id: 'folder', kind: 'folder' }) + ]) as never, + makeWindow() as never + ) + + expect(subscribe).not.toHaveBeenCalled() + }) + + it('uses the remote sibling root for default SSH worktree roots', async () => { + const remoteCallbacks = new Map void>() + const remoteUnwatch = vi.fn() + const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => { + remoteCallbacks.set(root, callback) + return remoteUnwatch + }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })), + realpath: vi.fn(async (path: string) => path), + readFile: vi.fn(async () => ({ content: '', isBinary: false })), + watch: remoteWatch + } as never) + + await syncWorktreeBaseDirectoryWatchers( + makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never, + makeWindow() as never + ) + + expect(subscribe).not.toHaveBeenCalled() + expect(remoteWatch).toHaveBeenCalledWith('/home/alice', expect.any(Function)) + remoteCallbacks.get('/home/alice')?.([ + { + kind: 'create', + absolutePath: '/home/alice/external-5104/.git' + } + ] as never[]) + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + await disposeWorktreeBaseDirectoryWatchers() + expect(remoteUnwatch).toHaveBeenCalled() + }) + + it('unsubscribes roots that disappear after repo settings change', async () => { + const repo = makeRepo() + const store = makeStore([repo]) + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never) + + const otherRoot = absolutePath('workspace', 'other-worktrees') + repo.worktreeBasePath = otherRoot + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never) + + expect(unsubscribeMocks.get(WORKTREE_ROOT)).toHaveBeenCalled() + expect(watcherCallbacks.has(otherRoot)).toBe(true) + }) + + it('unsubscribes a watcher that finishes installing after disposal starts', async () => { + let resolveSubscribe: (subscription: { unsubscribe: () => Promise }) => void = () => {} + const unsubscribe = vi.fn(async () => {}) + vi.mocked(subscribe).mockImplementationOnce( + async () => + new Promise((resolve) => { + resolveSubscribe = resolve + }) + ) + + const syncPromise = syncWorktreeBaseDirectoryWatchers( + makeStore([makeRepo()]) as never, + makeWindow() as never + ) + await vi.waitFor(() => expect(subscribe).toHaveBeenCalled()) + const disposePromise = disposeWorktreeBaseDirectoryWatchers() + resolveSubscribe({ unsubscribe }) + await syncPromise + await disposePromise + + expect(unsubscribe).toHaveBeenCalled() + }) + + it('matches flat workspace .git marker events without matching sibling churn', () => { + const target = { + key: `base:${WORKTREE_ROOT}`, + kind: 'base' as const, + path: WORKTREE_ROOT, + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]) + } + + expect( + matchingWorktreeBaseRepoIds(target, { + type: 'create', + path: join(WORKTREE_ROOT, 'external-5104', '.git') + } as WatcherEvent) + ).toEqual(['repo-1']) + expect( + matchingWorktreeBaseRepoIds(target, { + type: 'update', + path: join(WORKTREE_ROOT, 'external-5104', 'src', 'file.ts') + } as WatcherEvent) + ).toEqual([]) + }) +}) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts new file mode 100644 index 00000000000..f7b23103d2b --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -0,0 +1,286 @@ +import type { BrowserWindow } from 'electron' +import type { AsyncSubscription } from '@parcel/watcher' +import type { FsChangeEvent } from '../../shared/types' +import type { Store } from '../persistence' +import { notifyWorktreesChanged } from './worktree-remote' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + matchingWorktreeBaseRepoIds, + type WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' +import { + buildWorktreeBaseDirectoryWatchTargets, + clearWorktreeBaseDirectoryWatchTargetWarnings +} from './worktree-base-directory-watch-targets' + +type ActiveWatch = WorktreeBaseWatchTarget & { + mainWindow: BrowserWindow + subscription: Pick + notifyTimer: ReturnType | null + pendingRepoIds: Set + disposed: boolean +} + +const WATCH_DEBOUNCE_MS = 250 +const activeWatches = new Map() +let syncGeneration = 0 +let scheduledSync: ReturnType | null = null +let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null + +function scheduleNotification(watch: ActiveWatch, repoIds: readonly string[]): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + watch.pendingRepoIds.clear() + return + } + for (const repoId of repoIds) { + watch.pendingRepoIds.add(repoId) + } + if (watch.notifyTimer) { + clearTimeout(watch.notifyTimer) + } + watch.notifyTimer = setTimeout(() => { + watch.notifyTimer = null + if (watch.disposed || watch.mainWindow.isDestroyed()) { + watch.pendingRepoIds.clear() + return + } + const pending = [...watch.pendingRepoIds] + watch.pendingRepoIds.clear() + for (const repoId of pending) { + notifyWorktreesChanged(watch.mainWindow, repoId) + } + }, WATCH_DEBOUNCE_MS) +} + +function collectMatchingRepoIds( + watch: ActiveWatch, + eventType: 'create' | 'update' | 'delete', + eventPath: string, + repoIds: Set +): void { + for (const repoId of matchingWorktreeBaseRepoIds(watch, { type: eventType, path: eventPath })) { + repoIds.add(repoId) + } +} + +function handleLocalWatchEvents( + watch: ActiveWatch, + error: Error | null, + events: { type: 'create' | 'update' | 'delete'; path: string }[] +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + if (error) { + console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) + scheduleNotification(watch, [...watch.repos.keys()]) + return + } + const repoIds = new Set() + for (const event of events) { + collectMatchingRepoIds(watch, event.type, event.path, repoIds) + } + if (repoIds.size > 0) { + scheduleNotification(watch, [...repoIds]) + } +} + +function handleRemoteWatchEvents(watch: ActiveWatch, events: FsChangeEvent[]): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + const repoIds = new Set() + for (const event of events) { + if (event.kind === 'overflow') { + scheduleNotification(watch, [...watch.repos.keys()]) + return + } + if (event.kind === 'rename') { + if (event.oldAbsolutePath) { + collectMatchingRepoIds(watch, 'delete', event.oldAbsolutePath, repoIds) + } + collectMatchingRepoIds(watch, 'create', event.absolutePath, repoIds) + continue + } + collectMatchingRepoIds(watch, event.kind, event.absolutePath, repoIds) + } + if (repoIds.size > 0) { + scheduleNotification(watch, [...repoIds]) + } +} + +async function subscribeTarget( + target: WorktreeBaseWatchTarget, + mainWindow: BrowserWindow +): Promise { + let activeWatch: ActiveWatch | null = null + if (target.connectionId) { + const provider = getSshFilesystemProvider(target.connectionId) + if (!provider) { + throw new Error(`SSH filesystem provider unavailable for ${target.connectionId}`) + } + const unwatch = await provider.watch(target.path, (events) => { + const currentWatch = activeWatches.get(target.key) ?? activeWatch + if (!currentWatch || currentWatch.disposed) { + return + } + handleRemoteWatchEvents(currentWatch, events) + }) + activeWatch = { + ...target, + mainWindow, + subscription: { unsubscribe: async () => unwatch() }, + notifyTimer: null, + pendingRepoIds: new Set(), + disposed: false + } + return activeWatch + } + + const watcher = await import('@parcel/watcher') + const subscription = await watcher.subscribe( + target.path, + (error, events) => { + const currentWatch = activeWatches.get(target.key) ?? activeWatch + if (!currentWatch || currentWatch.disposed) { + return + } + handleLocalWatchEvents(currentWatch, error, events) + }, + { + ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/.cache/**'], + ...(process.platform === 'win32' ? { backend: 'windows' as const } : {}) + } + ) + activeWatch = { + ...target, + mainWindow, + subscription, + notifyTimer: null, + pendingRepoIds: new Set(), + disposed: false + } + return activeWatch +} + +async function replaceWatch( + target: WorktreeBaseWatchTarget, + mainWindow: BrowserWindow, + generation: number +): Promise { + const previous = activeWatches.get(target.key) + if (previous) { + previous.repos = target.repos + previous.mainWindow = mainWindow + return + } + try { + const activeWatch = await subscribeTarget(target, mainWindow) + if (generation !== syncGeneration) { + activeWatch.disposed = true + await activeWatch.subscription.unsubscribe().catch((error) => { + console.warn(`[worktree-base-watcher] failed to unwatch stale ${target.path}:`, error) + }) + return + } + activeWatches.set(target.key, activeWatch) + } catch (error) { + console.warn(`[worktree-base-watcher] failed to watch ${target.path}:`, error) + } +} + +async function removeWatch(key: string): Promise { + const watch = activeWatches.get(key) + if (!watch) { + return + } + activeWatches.delete(key) + watch.disposed = true + if (watch.notifyTimer) { + clearTimeout(watch.notifyTimer) + } + await watch.subscription.unsubscribe().catch((error) => { + console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error) + }) +} + +export async function syncWorktreeBaseDirectoryWatchers( + store: Store, + mainWindow: BrowserWindow +): Promise { + const generation = ++syncGeneration + const targets = await buildWorktreeBaseDirectoryWatchTargets(store) + if (generation !== syncGeneration) { + return + } + for (const key of activeWatches.keys()) { + if (generation !== syncGeneration) { + return + } + if (!targets.has(key)) { + await removeWatch(key) + if (generation !== syncGeneration) { + return + } + } + } + for (const target of targets.values()) { + if (generation !== syncGeneration) { + return + } + await replaceWatch(target, mainWindow, generation) + if (generation !== syncGeneration) { + return + } + } +} + +export function setWorktreeBaseDirectoryWatcherSyncContext( + store: Store, + mainWindow: BrowserWindow +): void { + latestSyncContext = { store, mainWindow } + // Why: older integration tests use lean BrowserWindow stubs; real windows still + // clear this context on close so stale watcher syncs cannot target dead chrome. + if (typeof mainWindow.once === 'function') { + mainWindow.once('closed', () => { + if (latestSyncContext?.mainWindow === mainWindow) { + latestSyncContext = null + } + }) + } +} + +export function scheduleWorktreeBaseDirectoryWatcherSync( + store: Store, + mainWindow: BrowserWindow +): void { + if (scheduledSync) { + clearTimeout(scheduledSync) + } + scheduledSync = setTimeout(() => { + scheduledSync = null + if (mainWindow.isDestroyed()) { + return + } + void syncWorktreeBaseDirectoryWatchers(store, mainWindow) + }, 100) +} + +export function scheduleCurrentWorktreeBaseDirectoryWatcherSync(): void { + if (!latestSyncContext || latestSyncContext.mainWindow.isDestroyed()) { + return + } + scheduleWorktreeBaseDirectoryWatcherSync(latestSyncContext.store, latestSyncContext.mainWindow) +} + +export async function disposeWorktreeBaseDirectoryWatchers(): Promise { + syncGeneration++ + latestSyncContext = null + if (scheduledSync) { + clearTimeout(scheduledSync) + scheduledSync = null + } + await Promise.all([...activeWatches.keys()].map((key) => removeWatch(key))) + clearWorktreeBaseDirectoryWatchTargetWarnings() +} diff --git a/src/main/ipc/worktree-common-git-directory.ts b/src/main/ipc/worktree-common-git-directory.ts new file mode 100644 index 00000000000..2ba706a0734 --- /dev/null +++ b/src/main/ipc/worktree-common-git-directory.ts @@ -0,0 +1,65 @@ +import { readFile, stat } from 'fs/promises' +import type { Repo } from '../../shared/types' +import { + getRuntimePathBasename, + normalizeRuntimePathSeparators, + resolveRuntimePath +} from '../../shared/cross-platform-path' +import type { FileStat } from '../providers/types' + +type GitDirectoryStat = Awaited> | FileStat + +type GitDirectoryAccess = { + stat?: (path: string) => Promise + readFile?: (path: string) => Promise +} + +function isDirectoryStat(value: GitDirectoryStat): boolean { + return 'type' in value ? value.type === 'directory' : value.isDirectory() +} + +function isFileStat(value: GitDirectoryStat): boolean { + return 'type' in value ? value.type === 'file' : value.isFile() +} + +function runtimeDirname(pathValue: string): string { + const normalized = normalizeRuntimePathSeparators(pathValue).replace(/\/+$/, '') + const index = normalized.lastIndexOf('/') + if (index < 0) { + return '.' + } + if (index === 0) { + return '/' + } + return normalized.slice(0, index) +} + +export async function resolveWorktreeCommonGitDirectory( + repo: Repo, + access: GitDirectoryAccess = {} +): Promise { + const dotGitPath = resolveRuntimePath(repo.path, '.git') + const statPath = access.stat ?? stat + const readText = access.readFile ?? ((path: string) => readFile(path, 'utf8')) + try { + const dotGitStat = await statPath(dotGitPath) + if (isDirectoryStat(dotGitStat)) { + return dotGitPath + } + if (!isFileStat(dotGitStat)) { + return null + } + const content = await readText(dotGitPath) + const gitDir = content.match(/^gitdir:\s*(.+)\s*$/m)?.[1]?.trim() + if (!gitDir) { + return null + } + const resolvedGitDir = resolveRuntimePath(repo.path, gitDir) + return getRuntimePathBasename(runtimeDirname(resolvedGitDir)) === 'worktrees' + ? runtimeDirname(runtimeDirname(resolvedGitDir)) + : resolvedGitDir + } catch (error) { + console.warn(`[worktree-base-watcher] cannot resolve git common dir for ${repo.id}:`, error) + return null + } +} diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 30a45bce8af..ce475f0487f 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3776,11 +3776,16 @@ export class Store { | 'forkSyncMode' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' | 'projectGroupId' | 'projectGroupOrder' | 'projectHostSetupMethod' > - > & { sourceControlAi?: Repo['sourceControlAi'] | null } + > & { + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + } ): Repo | null { const repo = this.state.repos.find((r) => r.id === id) if (!repo) { @@ -3830,6 +3835,14 @@ export class Store { // time visibility changes so later hide/show choices keep legacy safety. repo.externalWorktreeVisibilityLegacy = externalWorktreeVisibilityLegacy } + if ( + 'externalWorktreeDiscoverySuppressedAt' in sanitizedUpdates && + (sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === undefined || + sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === null) + ) { + delete repo.externalWorktreeDiscoverySuppressedAt + delete sanitizedUpdates.externalWorktreeDiscoverySuppressedAt + } if ( 'sourceControlAi' in sanitizedUpdates && (sanitizedUpdates.sourceControlAi === undefined || sanitizedUpdates.sourceControlAi === null) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1856a27ab42..94716e89400 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9817,10 +9817,15 @@ export class OrcaRuntimeService { | 'issueSourcePreference' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' | 'projectGroupId' | 'projectGroupOrder' > - > & { sourceControlAi?: Repo['sourceControlAi'] | null } + > & { + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + } ): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -9830,6 +9835,12 @@ export class OrcaRuntimeService { if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) { sanitizedUpdates.worktreeBasePath = undefined } + if ( + 'externalWorktreeDiscoverySuppressedAt' in updates && + updates.externalWorktreeDiscoverySuppressedAt === null + ) { + sanitizedUpdates.externalWorktreeDiscoverySuppressedAt = undefined + } if ('sourceControlAi' in updates && updates.sourceControlAi === null) { sanitizedUpdates.sourceControlAi = null } diff --git a/src/main/runtime/rpc/methods/repo-update-schema.ts b/src/main/runtime/rpc/methods/repo-update-schema.ts new file mode 100644 index 00000000000..6fdb59c3ae9 --- /dev/null +++ b/src/main/runtime/rpc/methods/repo-update-schema.ts @@ -0,0 +1,63 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString } from '../schemas' +import { sanitizeRepoIcon } from '../../../../shared/repo-icon' +import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' +import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' + +export const RepoSourceControlAiOverrides = z + .unknown() + .optional() + .transform((value) => + value === undefined + ? undefined + : value === null + ? null + : normalizeRepoSourceControlAiOverrides(value) + ) + +const RepoBadgeColor = z + .unknown() + .optional() + .transform((value) => + value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined) + ) + +const RepoUpstream = z + .object({ + owner: z.string().min(1), + repo: z.string().min(1) + }) + .nullable() + .optional() + +export function createRepoUpdateSchema( + selectorShape: T +): z.ZodObject }> { + return z.object({ + ...selectorShape, + updates: z.object({ + displayName: OptionalString, + badgeColor: RepoBadgeColor, + repoIcon: z + .unknown() + .transform((value) => sanitizeRepoIcon(value)) + .optional(), + upstream: RepoUpstream, + hookSettings: z.unknown().optional(), + worktreeBaseRef: OptionalString, + worktreeBasePath: OptionalString, + kind: z.enum(['git', 'folder']).optional(), + symlinkPaths: z.array(z.string()).optional(), + issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(), + forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(), + externalWorktreeVisibility: z.enum(['hide', 'show']).optional(), + externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), + externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(), + importedExternalWorktreePaths: z.array(z.string()).optional(), + externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(), + projectGroupId: OptionalString.nullable().optional(), + projectGroupOrder: OptionalFiniteNumber, + sourceControlAi: RepoSourceControlAiOverrides + }) + }) as z.ZodObject }> +} diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 0fcf5226444..455192a8c11 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -1,11 +1,9 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' -import { sanitizeRepoIcon } from '../../../../shared/repo-icon' -import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' -import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods' import { FOLDER_WORKSPACE_METHODS } from './folder-workspace' +import { createRepoUpdateSchema } from './repo-update-schema' const RepoSelector = z.object({ repo: requiredString('Missing repo selector') @@ -32,55 +30,7 @@ const RepoSetBaseRef = z.object({ ref: requiredString('Missing base ref') }) -const RepoSourceControlAiOverrides = z - .unknown() - .optional() - .transform((value) => - value === undefined - ? undefined - : value === null - ? null - : normalizeRepoSourceControlAiOverrides(value) - ) - -const RepoBadgeColor = z - .unknown() - .optional() - .transform((value) => - value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined) - ) - -const RepoUpstream = z - .object({ - owner: z.string().min(1), - repo: z.string().min(1) - }) - .nullable() - .optional() - -const RepoUpdate = RepoSelector.extend({ - updates: z.object({ - displayName: OptionalString, - badgeColor: RepoBadgeColor, - repoIcon: z - .unknown() - .transform((value) => sanitizeRepoIcon(value)) - .optional(), - upstream: RepoUpstream, - hookSettings: z.unknown().optional(), - worktreeBaseRef: OptionalString, - worktreeBasePath: OptionalString, - kind: z.enum(['git', 'folder']).optional(), - symlinkPaths: z.array(z.string()).optional(), - issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(), - forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(), - externalWorktreeVisibility: z.enum(['hide', 'show']).optional(), - externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), - projectGroupId: OptionalString.nullable().optional(), - projectGroupOrder: OptionalFiniteNumber, - sourceControlAi: RepoSourceControlAiOverrides - }) -}) +const RepoUpdate = createRepoUpdateSchema(RepoSelector.shape) const RepoSearchRefs = z.object({ repo: requiredString('Missing repo selector'), diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index eace514bcc9..4b4a0e4c89b 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -37,6 +37,10 @@ import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-rel import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' +import { + scheduleWorktreeBaseDirectoryWatcherSync, + setWorktreeBaseDirectoryWatcherSyncContext +} from '../ipc/worktree-base-directory-watcher' let appReloadHandlerTokenCounter = 0 let activeAppReloadHandlerToken: number | null = null @@ -60,6 +64,9 @@ export function attachMainWindowServices( registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload) registerRepoHandlers(mainWindow, store) registerWorktreeHandlers(mainWindow, store, runtime) + // Why: repo/settings mutations resync watchers through this attached main-window context. + setWorktreeBaseDirectoryWatcherSyncContext(store, mainWindow) + scheduleWorktreeBaseDirectoryWatcherSync(store, mainWindow) registerWorkspaceCleanupHandlers(store, { runtime, getLocalPtyProvider }) registerPtyHandlers( mainWindow, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1f13ad234d7..7a485016fe6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -789,11 +789,16 @@ export type PreloadApi = { | 'issueSourcePreference' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' | 'projectGroupId' | 'projectGroupOrder' | 'forkSyncMode' > - > & { sourceControlAi?: Repo['sourceControlAi'] | null } + > & { + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + } }) => Promise pickFolder: () => Promise pickFolders: () => Promise diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx index 4699f98a395..3c82858579e 100644 --- a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { ChevronRight, EyeOff, X } from 'lucide-react' +import { ChevronRight, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -143,7 +143,6 @@ export default function ImportedWorktreesVisibilityLine({ aria-hidden="true" /> -