Merge remote-tracking branch 'origin/main' into brennanb2025/issue-5796-codex-auto-approval-notifications

This commit is contained in:
brennanb2025
2026-06-28 13:29:01 -07:00
45 changed files with 2865 additions and 107 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.104-rc.3",
"version": "1.4.104-rc.5",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+11 -3
View File
@@ -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'
@@ -1000,9 +1001,16 @@ function shutdownWatchersOnce(): Promise<void> {
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
+42 -1
View File
@@ -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 {
+2
View File
@@ -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()
@@ -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<string, WorktreeBaseRepoWatchConfig>
}
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)
}
@@ -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<string>()
const skippedWslWarnings = new Set<string>()
function normalizeWatchKey(pathValue: string): string {
return normalizeRuntimePathForComparison(normalize(pathValue))
}
async function canonicalizeExistingPath(
pathValue: string,
connectionId: string | undefined
): Promise<string> {
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<string, WorktreeBaseWatchTarget>,
kind: WorktreeBaseWatchKind,
pathValue: string,
config: WorktreeBaseRepoWatchConfig,
connectionId?: string
): Promise<void> {
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<GlobalSettings, 'workspaceDir' | 'nestWorkspaces'>,
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<string, WorktreeBaseWatchTarget>,
repo: Repo,
settings: GlobalSettings,
connectionId?: string
): Promise<void> {
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<Map<string, WorktreeBaseWatchTarget>> {
const settings = store.getSettings()
const targets = new Map<string, WorktreeBaseWatchTarget>()
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()
}
@@ -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<string, WatcherCallback>()
const unsubscribeMocks = new Map<string, ReturnType<typeof vi.fn>>()
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> = {}): 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<string, (events: never[]) => 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> }) => 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([])
})
})
@@ -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<AsyncSubscription, 'unsubscribe'>
notifyTimer: ReturnType<typeof setTimeout> | null
pendingRepoIds: Set<string>
disposed: boolean
}
const WATCH_DEBOUNCE_MS = 250
const activeWatches = new Map<string, ActiveWatch>()
let syncGeneration = 0
let scheduledSync: ReturnType<typeof setTimeout> | 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<string>
): 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<string>()
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<string>()
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<ActiveWatch> {
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<void> {
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<void> {
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<void> {
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<void> {
syncGeneration++
latestSyncContext = null
if (scheduledSync) {
clearTimeout(scheduledSync)
scheduledSync = null
}
await Promise.all([...activeWatches.keys()].map((key) => removeWatch(key)))
clearWorktreeBaseDirectoryWatchTargetWarnings()
}
@@ -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<ReturnType<typeof stat>> | FileStat
type GitDirectoryAccess = {
stat?: (path: string) => Promise<GitDirectoryStat>
readFile?: (path: string) => Promise<string>
}
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<string | null> {
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
}
}
+14 -1
View File
@@ -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)
+12 -1
View File
@@ -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<Repo> {
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
}
@@ -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<T extends z.ZodRawShape>(
selectorShape: T
): z.ZodObject<T & { updates: z.ZodObject<z.ZodRawShape> }> {
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<T & { updates: z.ZodObject<z.ZodRawShape> }>
}
+2 -52
View File
@@ -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'),
@@ -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,
+6 -1
View File
@@ -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<Repo>
pickFolder: () => Promise<string | null>
pickFolders: () => Promise<string[]>
@@ -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"
/>
</Button>
<EyeOff className="size-3 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{lineText}</span>
{onKeepHidden ? (
<Tooltip>
@@ -0,0 +1,86 @@
// @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 NewExternalWorktreesInboxLine from './NewExternalWorktreesInboxLine'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<span data-testid="tooltip-content">{children}</span>
)
}))
const roots: Root[] = []
async function renderLine(): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
<NewExternalWorktreesInboxLine
repoDisplayName="orca"
inboxWorktrees={[
{
id: 'external-1',
displayName: 'payments-refactor',
branch: 'refs/heads/payments-refactor',
path: '/worktrees/orca/payments-refactor'
}
]}
pending={false}
error={null}
onImportWorktree={vi.fn()}
onKeepHidden={vi.fn()}
onImportAll={vi.fn()}
onSuppress={vi.fn()}
/>
)
})
return container
}
describe('NewExternalWorktreesInboxLine', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
roots.splice(0).forEach((root) => {
act(() => root.unmount())
})
document.body.replaceChildren()
vi.clearAllMocks()
})
it('keeps suppress as a hover-revealed header icon instead of expanded text action', async () => {
const container = await renderLine()
const suppressButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Hide external worktrees permanently for orca"]'
)
expect(suppressButton).not.toBeNull()
expect(suppressButton?.className).toContain('can-hover:group-hover:opacity-100')
expect(container.textContent).toContain("Don't show again")
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand new externally-created worktrees for orca"]'
)
await act(async () => {
expandButton?.click()
})
expect(container.textContent).toContain('payments-refactor')
const textButtons = [...container.querySelectorAll('button')].filter(
(button) => button.textContent === "Don't show again"
)
expect(textButtons).toHaveLength(0)
})
})
@@ -0,0 +1,207 @@
import React, { useState } from 'react'
import { ChevronRight, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { NewExternalWorktreeInboxPreview } from './new-external-worktrees-inbox-candidates'
type NewExternalWorktreesInboxLineProps = {
repoDisplayName: string
inboxWorktrees: readonly NewExternalWorktreeInboxPreview[]
pending: boolean
error: string | null
onImportWorktree?: (worktreeId: string) => void
onKeepHidden?: () => void
onImportAll?: () => void
onSuppress?: () => void
className?: string
}
export default function NewExternalWorktreesInboxLine({
repoDisplayName,
inboxWorktrees,
pending,
error,
onImportWorktree,
onKeepHidden,
onImportAll,
onSuppress,
className
}: NewExternalWorktreesInboxLineProps): React.JSX.Element | null {
const [isExpanded, setIsExpanded] = useState(false)
const inboxCount = inboxWorktrees.length
const suppressLabel = translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.c3e8a1f4b2',
"Don't show again"
)
const suppressAriaLabel = translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.9f2d4c8b17',
'Hide external worktrees permanently for {{value0}}',
{ value0: repoDisplayName }
)
if (inboxCount === 0) {
return null
}
return (
<section
aria-busy={pending}
className={cn('mx-1 my-0.5 ml-3 text-worktree-sidebar-foreground', className)}
>
<div
className={cn(
'group flex min-h-7 min-w-0 items-center gap-1.5 rounded-md px-1.5 text-[11px] leading-none text-muted-foreground transition-colors',
'hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground'
)}
>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={pending}
aria-expanded={isExpanded}
aria-label={
isExpanded
? translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.d9f7b2a14c',
'Collapse new externally-created worktrees for {{value0}}',
{ value0: repoDisplayName }
)
: translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.e2c4a8d91f',
'Expand new externally-created worktrees for {{value0}}',
{ value0: repoDisplayName }
)
}
onClick={() => setIsExpanded((value) => !value)}
className="shrink-0 rounded-[4px] text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground"
>
<ChevronRight
className={cn('size-3 transition-transform', isExpanded && 'rotate-90')}
aria-hidden="true"
/>
</Button>
<span className="min-w-0 flex-1 truncate">
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.7c4e9b2a81',
'New externally-created worktrees'
)}
</span>
<span className="relative inline-grid size-6 shrink-0 place-items-center">
<span
className={cn(
'inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full border border-border px-1.5 text-[10px] font-medium leading-none text-muted-foreground transition-opacity',
onSuppress &&
'can-hover:group-hover:opacity-0 can-hover:group-focus-within:opacity-0 [@media(hover:none)]:opacity-0'
)}
>
{inboxCount}
</span>
{onSuppress ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={pending}
aria-label={suppressAriaLabel}
onClick={onSuppress}
className="absolute inset-0 text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground can-hover:pointer-events-none can-hover:opacity-0 can-hover:group-hover:pointer-events-auto can-hover:group-hover:opacity-100 can-hover:group-focus-within:pointer-events-auto can-hover:group-focus-within:opacity-100"
>
<X className="size-3" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{suppressLabel}
</TooltipContent>
</Tooltip>
) : null}
</span>
</div>
{isExpanded ? (
<div className="ml-4 mt-0.5 border-l border-worktree-sidebar-border pb-1 pl-2">
<p className="px-1.5 py-1 text-[10px] leading-4 text-muted-foreground">
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.4d7a1c9e53',
'These worktrees were created outside of Orca.'
)}
</p>
<ul className="grid gap-0.5">
{inboxWorktrees.map((worktree) => (
<li
key={worktree.id ?? worktree.path ?? worktree.displayName}
className="flex min-h-7 min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-xs hover:bg-worktree-sidebar-accent"
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{worktree.displayName}</div>
{worktree.path ? (
<div className="truncate font-mono text-[10px] text-muted-foreground">
{worktree.path}
</div>
) : null}
</div>
{onImportWorktree && worktree.id ? (
<Button
type="button"
variant="outline"
size="xs"
disabled={pending}
onClick={() => onImportWorktree(worktree.id!)}
>
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.8b3f2e1d74',
'Import'
)}
</Button>
) : null}
</li>
))}
</ul>
<div className="grid gap-1 px-1.5 pb-1 pt-1">
<div className="flex flex-wrap gap-1.5">
{onKeepHidden ? (
<Button
type="button"
variant="outline"
size="xs"
disabled={pending}
onClick={onKeepHidden}
>
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.1c9e7a4b28',
'Keep hidden'
)}
</Button>
) : null}
{onImportAll ? (
<Button
type="button"
variant="default"
size="xs"
disabled={pending}
onClick={onImportAll}
>
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.6f2d8c1e95',
'Import all'
)}
</Button>
) : null}
</div>
</div>
</div>
) : null}
{error ? (
<p className="px-1.5 pb-1 pt-0.5 text-[11px] leading-4 text-destructive" role="alert">
{error}
</p>
) : null}
</section>
)
}
@@ -0,0 +1,88 @@
import React from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { translate } from '@/i18n/i18n'
type SuppressExternalWorktreeInboxDialogProps = {
open: boolean
repoDisplayName: string
pending: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
onOpenRecovery: () => void
}
export default function SuppressExternalWorktreeInboxDialog({
open,
repoDisplayName,
pending,
onOpenChange,
onConfirm,
onOpenRecovery
}: SuppressExternalWorktreeInboxDialogProps): React.JSX.Element {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.sidebar.SuppressExternalWorktreeInboxDialog.a4c2d8f1b0',
'Hide external worktrees?'
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.sidebar.SuppressExternalWorktreeInboxDialog.6e91b3c4d2',
'External worktrees will not be shown in the sidebar or this list anymore for {{value0}}, including ones created later.',
{ value0: repoDisplayName }
)}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
{translate(
'auto.components.sidebar.SuppressExternalWorktreeInboxDialog.1f8a5d9e73',
'You can turn this back on later from project settings.'
)}
<button
type="button"
className="mt-1 block font-medium text-foreground underline underline-offset-2"
onClick={onOpenRecovery}
>
{translate(
'auto.components.sidebar.SuppressExternalWorktreeInboxDialog.8c0b2e7a41',
'Open Non-Orca worktrees settings'
)}
</button>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
disabled={pending}
onClick={() => onOpenChange(false)}
>
{translate(
'auto.components.sidebar.SuppressExternalWorktreeInboxDialog.5d1c9f0a82',
'Cancel'
)}
</Button>
<Button type="button" disabled={pending} onClick={onConfirm}>
{translate(
'auto.components.sidebar.SuppressExternalWorktreeInboxDialog.3b7e4a1c96',
'Hide external worktrees'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -33,13 +33,17 @@ function makeFolderWorkspacePathStatusMockState(): Record<string, unknown> {
}
vi.mock('@/store', () => {
const getMockState = (): Record<string, unknown> => ({
detectedWorktreesByRepo: {},
...mockStore.state
})
const useAppStore = ((selector: (state: Record<string, unknown>) => unknown) =>
selector(mockStore.state)) as ((
selector(getMockState())) as ((
selector: (state: Record<string, unknown>) => unknown
) => unknown) & {
getState: () => Record<string, unknown>
}
useAppStore.getState = () => mockStore.state
useAppStore.getState = () => getMockState()
return { useAppStore }
})
@@ -215,13 +215,29 @@ import {
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 {
importNewExternalWorktreeInboxPaths,
keepNewExternalWorktreeInboxHidden,
suppressNewExternalWorktreeInbox,
type NewExternalWorktreesInboxActionState
} from './new-external-worktrees-inbox-actions'
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
import { buildImportedWorktreesCardCandidates } from './imported-worktrees-card-candidates'
import {
buildImportedWorktreesCardCandidates,
getHiddenImportedWorktrees
} from './imported-worktrees-card-candidates'
import {
buildNewExternalWorktreesInboxCandidates,
toNewExternalWorktreeInboxPreview
} from './new-external-worktrees-inbox-candidates'
import {
WORKTREE_SECTION_HEADER_PADDING_LEFT,
LINEAGE_CHILDREN_INLINE_OFFSET,
@@ -470,6 +486,9 @@ function getRenderRowSidebarKey(row: RenderRow): string | null {
if (row.type === 'imported-worktrees-card') {
return row.key
}
if (row.type === 'new-external-worktrees-inbox') {
return row.key
}
return null
}
@@ -589,6 +608,11 @@ type VirtualizedWorktreeViewportProps = {
handleShowImportedWorktrees: (projectId: string) => void
handleKeepImportedWorktreesHidden: (projectId: string) => void
importedWorktreeCardActionState: ReadonlyMap<string, ImportedWorktreeCardActionState>
handleImportNewExternalWorktree: (projectId: string, worktreeId: string) => void
handleImportAllNewExternalWorktrees: (projectId: string) => void
handleKeepNewExternalWorktreeInboxHidden: (projectId: string) => void
handleOpenSuppressExternalWorktreeInbox: (projectId: string) => void
newExternalWorktreeInboxActionState: ReadonlyMap<string, NewExternalWorktreesInboxActionState>
handleRemoveProject: (repo: Repo) => void
handleCreateGroupFromRepo: (repo: Repo) => void
handleMoveProjectToGroup: (repo: Repo, groupId: string) => void
@@ -1126,6 +1150,9 @@ export function getRenderRowKey(row: RenderRow): string {
if (row.type === 'imported-worktrees-card') {
return `imported:${row.key}`
}
if (row.type === 'new-external-worktrees-inbox') {
return `inbox:${row.key}`
}
if (row.type === 'pending-creation') {
return `pending:${row.creationId}`
}
@@ -1148,6 +1175,7 @@ export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup
if (
row.type === 'host-header' ||
row.type === 'imported-worktrees-card' ||
row.type === 'new-external-worktrees-inbox' ||
row.type === 'pending-creation' ||
row.type === 'folder-workspace'
) {
@@ -1219,6 +1247,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
handleShowImportedWorktrees,
handleKeepImportedWorktreesHidden,
importedWorktreeCardActionState,
handleImportNewExternalWorktree,
handleImportAllNewExternalWorktrees,
handleKeepNewExternalWorktreeInboxHidden,
handleOpenSuppressExternalWorktreeInbox,
newExternalWorktreeInboxActionState,
handleRemoveProject,
handleCreateGroupFromRepo,
handleMoveProjectToGroup,
@@ -2307,6 +2340,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
projectGroups,
new Set(),
new Map(),
new Map(),
[],
projectGrouping
).filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item')
@@ -4738,6 +4772,36 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
}
if (row.type === 'new-external-worktrees-inbox') {
const actionState = newExternalWorktreeInboxActionState.get(row.repo.id)
return (
<div
key={vItem.key}
role="presentation"
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-virtual-row-start={vItem.start}
data-index={vItem.index}
ref={measureVirtualRowElement}
className="absolute left-0 right-0 top-0"
style={{ transform: getVirtualRowTransform(vItem.start) }}
>
<NewExternalWorktreesInboxLine
repoDisplayName={row.repo.displayName}
inboxWorktrees={row.inboxWorktrees.map(toNewExternalWorktreeInboxPreview)}
pending={actionState?.pending ?? false}
error={actionState?.error ?? null}
onImportWorktree={(worktreeId) =>
handleImportNewExternalWorktree(row.repo.id, worktreeId)
}
onKeepHidden={() => handleKeepNewExternalWorktreeInboxHidden(row.repo.id)}
onImportAll={() => handleImportAllNewExternalWorktrees(row.repo.id)}
onSuppress={() => handleOpenSuppressExternalWorktreeInbox(row.repo.id)}
/>
</div>
)
}
if (row.type === 'pending-creation') {
return (
<div
@@ -5448,6 +5512,12 @@ const WorktreeList = React.memo(function WorktreeList({
const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState<
Map<string, ImportedWorktreeCardActionState>
>(new Map())
const [newExternalWorktreeInboxActionState, setNewExternalWorktreeInboxActionState] = useState<
Map<string, NewExternalWorktreesInboxActionState>
>(new Map())
const [suppressExternalWorktreeInboxRepoId, setSuppressExternalWorktreeInboxRepoId] = useState<
string | null
>(null)
const importedWorktreesByRepo = useMemo(() => {
const forceVisibleRepoIds = new Set(
[...importedWorktreeCardActionState.entries()]
@@ -5461,6 +5531,15 @@ const WorktreeList = React.memo(function WorktreeList({
forceVisibleRepoIds
})
}, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, visibleReposForRows])
const newExternalWorktreesInboxByRepo = useMemo(
() =>
buildNewExternalWorktreesInboxCandidates({
repos: visibleReposForRows,
detectedWorktreesByRepo,
filterRepoIds
}),
[detectedWorktreesByRepo, filterRepoIds, visibleReposForRows]
)
const placeholderRepoIds = useMemo(() => {
return getEmptyProjectPlaceholderRepoIds({
groupBy,
@@ -5537,6 +5616,7 @@ const WorktreeList = React.memo(function WorktreeList({
visibleProjectGroupsForRows,
placeholderRepoIds,
importedWorktreesByRepo,
newExternalWorktreesInboxByRepo,
pendingCreations,
projectGrouping,
visibleFolderWorkspacesForRows,
@@ -5559,6 +5639,7 @@ const WorktreeList = React.memo(function WorktreeList({
visibleFolderWorkspacesForRows,
placeholderRepoIds,
importedWorktreesByRepo,
newExternalWorktreesInboxByRepo,
pendingCreations,
hostLabelById
]
@@ -5626,6 +5707,8 @@ const WorktreeList = React.memo(function WorktreeList({
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
@@ -5814,15 +5897,147 @@ const WorktreeList = React.memo(function WorktreeList({
const handleKeepImportedWorktreesHidden = useCallback(
async (projectId: string) => {
const repo = repos.find((candidate) => candidate.id === projectId)
let detected = detectedWorktreesByRepo[projectId]
// Why: baseline seeding depends on authoritative hidden paths; do not
// dismiss the initial prompt on a stale/non-authoritative 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
setCardState: setImportedWorktreeCardState,
hiddenWorktreePaths: hiddenWorktrees.map((worktree) => worktree.path),
existingBaselinePaths: repo?.externalWorktreeInboxBaselinePaths
})
},
[setImportedWorktreeCardState, updateRepo]
[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 handleImportNewExternalWorktree = useCallback(
async (projectId: string, worktreeId: string) => {
const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? []
const worktree = inboxWorktrees.find((candidate) => candidate.id === worktreeId)
if (!worktree) {
return
}
const args = getNewExternalWorktreeInboxActionArgs(projectId, [worktree.path])
if (!args) {
return
}
await importNewExternalWorktreeInboxPaths(args)
},
[getNewExternalWorktreeInboxActionArgs, newExternalWorktreesInboxByRepo]
)
const handleImportAllNewExternalWorktrees = useCallback(
async (projectId: string) => {
const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? []
const args = getNewExternalWorktreeInboxActionArgs(
projectId,
inboxWorktrees.map((worktree) => worktree.path)
)
if (!args) {
return
}
await importNewExternalWorktreeInboxPaths(args)
},
[getNewExternalWorktreeInboxActionArgs, newExternalWorktreesInboxByRepo]
)
const handleKeepNewExternalWorktreeInboxHidden = useCallback(
async (projectId: string) => {
const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? []
const args = getNewExternalWorktreeInboxActionArgs(
projectId,
inboxWorktrees.map((worktree) => worktree.path)
)
if (!args) {
return
}
await keepNewExternalWorktreeInboxHidden(args)
},
[getNewExternalWorktreeInboxActionArgs, newExternalWorktreesInboxByRepo]
)
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', {
@@ -6371,6 +6586,37 @@ const WorktreeList = React.memo(function WorktreeList({
}}
onSubmit={handleSubmitProjectGroupName}
/>
<SuppressExternalWorktreeInboxDialog
open={suppressExternalWorktreeInboxRepoId !== null}
repoDisplayName={
suppressExternalWorktreeInboxRepoId
? (repos.find((repo) => 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 projectId = suppressExternalWorktreeInboxRepoId
setSuppressExternalWorktreeInboxRepoId(null)
handleOpenWorktreeVisibility(projectId)
}}
/>
<ProjectGroupDeleteDialog
open={projectGroupDeleteDialog !== null}
groupName={projectGroupDeleteDialog?.groupName ?? ''}
@@ -6404,6 +6650,11 @@ const WorktreeList = React.memo(function WorktreeList({
handleShowImportedWorktrees={handleShowImportedWorktrees}
handleKeepImportedWorktreesHidden={handleKeepImportedWorktreesHidden}
importedWorktreeCardActionState={importedWorktreeCardActionState}
handleImportNewExternalWorktree={handleImportNewExternalWorktree}
handleImportAllNewExternalWorktrees={handleImportAllNewExternalWorktrees}
handleKeepNewExternalWorktreeInboxHidden={handleKeepNewExternalWorktreeInboxHidden}
handleOpenSuppressExternalWorktreeInbox={handleOpenSuppressExternalWorktreeInbox}
newExternalWorktreeInboxActionState={newExternalWorktreeInboxActionState}
handleRemoveProject={handleRemoveProject}
handleCreateGroupFromRepo={handleCreateGroupFromRepo}
handleMoveProjectToGroup={handleMoveProjectToGroup}
@@ -49,7 +49,14 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
if (!repoId) {
return
}
await updateRepo(repoId, { externalWorktreeVisibility: showOther ? 'hide' : 'show' })
await updateRepo(repoId, {
externalWorktreeVisibility: showOther ? 'hide' : 'show',
// Why: showing hidden externals again should re-enable the inbox if the
// user previously opted out of discovery prompts for this repo.
// Why: null is the transport sentinel for clearing on remote runtime paths
// where `undefined` is stripped before persistence.
...(!showOther ? { externalWorktreeDiscoverySuppressedAt: null } : {})
})
await fetchWorktrees(repoId)
closeModal()
}, [closeModal, fetchWorktrees, repoId, showOther, updateRepo])
@@ -73,6 +73,7 @@ function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId
return getRepoHostId(row.repo, defaultHostId)
case 'pending-creation':
case 'imported-worktrees-card':
case 'new-external-worktrees-inbox':
return getRepoHostId(row.repo, defaultHostId)
case 'folder-workspace':
return getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, defaultHostId)
@@ -87,10 +87,17 @@ describe('imported worktrees card actions', () => {
})
it('dismisses the card when keep-hidden update succeeds', async () => {
await keepImportedWorktreesHiddenCard({ projectId, updateRepo, setCardState })
await keepImportedWorktreesHiddenCard({
projectId,
updateRepo,
setCardState,
hiddenWorktreePaths: ['/scratch/external'],
existingBaselinePaths: ['/scratch/old']
})
expect(updateRepo).toHaveBeenCalledWith(projectId, {
externalWorktreeVisibilityPromptDismissedAt: expect.any(Number)
externalWorktreeVisibilityPromptDismissedAt: expect.any(Number),
externalWorktreeInboxBaselinePaths: ['/scratch/old', '/scratch/external']
})
expect(setCardState).toHaveBeenLastCalledWith(projectId, null)
})
@@ -1,4 +1,5 @@
import type { Repo } from '../../../../shared/types'
import { mergeExternalWorktreeInboxPaths } from '../../../../shared/external-worktree-inbox'
export type ImportedWorktreeCardActionState = {
pending: boolean
@@ -13,9 +14,16 @@ type ImportedWorktreeCardActionDeps = {
updateRepo: (
projectId: string,
updates: Partial<
Pick<Repo, 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt'>
Pick<
Repo,
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
>
>
) => Promise<boolean>
hiddenWorktreePaths?: readonly string[]
existingBaselinePaths?: readonly string[]
fetchWorktrees: (
projectId: string,
options?: { requireAuthoritative?: boolean }
@@ -63,7 +71,15 @@ export async function keepImportedWorktreesHiddenCard(
): Promise<void> {
args.setCardState(args.projectId, { pending: true, error: null })
const updated = await args.updateRepo(args.projectId, {
externalWorktreeVisibilityPromptDismissedAt: Date.now()
externalWorktreeVisibilityPromptDismissedAt: Date.now(),
...(args.hiddenWorktreePaths && args.hiddenWorktreePaths.length > 0
? {
externalWorktreeInboxBaselinePaths: mergeExternalWorktreeInboxPaths(
args.existingBaselinePaths,
args.hiddenWorktreePaths
)
}
: {})
})
if (!updated) {
args.setCardState(args.projectId, {
@@ -1,9 +1,5 @@
import type {
DetectedWorktree,
DetectedWorktreeListResult,
Repo,
Worktree
} from '../../../../shared/types'
import type { DetectedWorktreeListResult, Repo, Worktree } from '../../../../shared/types'
import { getHiddenExternalWorktrees } from '../../../../shared/external-worktree-inbox'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import {
effectiveExternalWorktreeVisibility,
@@ -13,14 +9,8 @@ import type { ImportedWorktreesCardCandidate } from './worktree-list-groups'
export function getHiddenImportedWorktrees(
detected: DetectedWorktreeListResult | undefined
): DetectedWorktree[] {
if (detected?.authoritative !== true) {
return []
}
return detected.worktrees.filter(
(worktree) =>
!worktree.visible && !worktree.selectedCheckout && worktree.ownership !== 'orca-managed'
)
): ReturnType<typeof getHiddenExternalWorktrees> {
return getHiddenExternalWorktrees(detected)
}
export function buildImportedWorktreesCardCandidates(args: {
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/types'
import {
importNewExternalWorktreeInboxPaths,
keepNewExternalWorktreeInboxHidden,
suppressNewExternalWorktreeInbox
} from './new-external-worktrees-inbox-actions'
const projectId = 'repo-1'
const repo: Pick<Repo, 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths'> = {
externalWorktreeInboxBaselinePaths: ['/scratch/old'],
importedExternalWorktreePaths: []
}
describe('new external worktree inbox actions', () => {
it('imports inbox worktrees into the sidebar allowlist', async () => {
const updateRepo = vi.fn().mockResolvedValue(true)
const fetchWorktrees = vi.fn().mockResolvedValue(true)
const setInboxState = vi.fn()
await importNewExternalWorktreeInboxPaths({
projectId,
repo,
worktreePaths: ['/scratch/new'],
updateRepo,
fetchWorktrees,
setInboxState
})
expect(updateRepo).toHaveBeenCalledWith(projectId, {
importedExternalWorktreePaths: ['/scratch/new'],
externalWorktreeInboxBaselinePaths: ['/scratch/old', '/scratch/new']
})
expect(fetchWorktrees).toHaveBeenCalledWith(projectId, { requireAuthoritative: true })
expect(setInboxState).toHaveBeenLastCalledWith(projectId, null)
})
it('rolls import path lists back with explicit empty arrays when refresh fails', async () => {
const updateRepo = vi.fn().mockResolvedValue(true)
const fetchWorktrees = vi.fn().mockResolvedValue(false)
const setInboxState = vi.fn()
await importNewExternalWorktreeInboxPaths({
projectId,
repo: {},
worktreePaths: ['/scratch/new'],
updateRepo,
fetchWorktrees,
setInboxState
})
expect(updateRepo).toHaveBeenNthCalledWith(1, projectId, {
importedExternalWorktreePaths: ['/scratch/new'],
externalWorktreeInboxBaselinePaths: ['/scratch/new']
})
expect(updateRepo).toHaveBeenNthCalledWith(2, projectId, {
importedExternalWorktreePaths: [],
externalWorktreeInboxBaselinePaths: []
})
})
it('extends the inbox baseline when keeping a batch hidden', async () => {
const updateRepo = vi.fn().mockResolvedValue(true)
const setInboxState = vi.fn()
await keepNewExternalWorktreeInboxHidden({
projectId,
repo,
worktreePaths: ['/scratch/new'],
updateRepo,
fetchWorktrees: vi.fn(),
setInboxState
})
expect(setInboxState).toHaveBeenNthCalledWith(1, projectId, { pending: true, error: null })
expect(updateRepo).toHaveBeenCalledWith(projectId, {
externalWorktreeInboxBaselinePaths: ['/scratch/old', '/scratch/new']
})
expect(setInboxState).toHaveBeenLastCalledWith(projectId, null)
})
it('permanently suppresses the inbox and baselines the current batch', async () => {
const updateRepo = vi.fn().mockResolvedValue(true)
const setInboxState = vi.fn()
const suppressed = await suppressNewExternalWorktreeInbox({
projectId,
repo,
worktreePaths: ['/scratch/new'],
updateRepo,
setInboxState
})
expect(suppressed).toBe(true)
expect(updateRepo).toHaveBeenCalledWith(projectId, {
externalWorktreeDiscoverySuppressedAt: expect.any(Number),
externalWorktreeInboxBaselinePaths: ['/scratch/old', '/scratch/new']
})
expect(setInboxState).toHaveBeenLastCalledWith(projectId, null)
})
})
@@ -0,0 +1,148 @@
import type { Repo } from '../../../../shared/types'
import { mergeExternalWorktreeInboxPaths } from '../../../../shared/external-worktree-inbox'
import { translate } from '@/i18n/i18n'
export type NewExternalWorktreesInboxActionState = {
pending: boolean
error: string | null
}
type RepoExternalWorktreeInboxUpdate = Partial<
Pick<
Repo,
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'externalWorktreeDiscoverySuppressedAt'
>
>
type NewExternalWorktreesInboxActionDeps = {
projectId: string
repo: Pick<Repo, 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths'>
worktreePaths: readonly string[]
setInboxState: (projectId: string, state: NewExternalWorktreesInboxActionState | null) => void
updateRepo: (projectId: string, updates: RepoExternalWorktreeInboxUpdate) => Promise<boolean>
fetchWorktrees: (
projectId: string,
options?: { requireAuthoritative?: boolean }
) => Promise<boolean>
}
function newExternalWorktreeInboxKeepHiddenError(): string {
return translate(
'auto.components.sidebar.newExternalWorktreesInboxActions.a11c2f6d89',
'Could not keep external worktrees hidden. Try again.'
)
}
function newExternalWorktreeInboxImportError(): string {
return translate(
'auto.components.sidebar.newExternalWorktreesInboxActions.b7e4d1a062',
'Could not import external worktrees. Try again.'
)
}
function newExternalWorktreeInboxSuppressError(): string {
return translate(
'auto.components.sidebar.newExternalWorktreesInboxActions.c94f0b3a15',
'Could not hide external worktrees permanently. Try again.'
)
}
function rollbackPathList(paths: readonly string[] | undefined): string[] {
return [...(paths ?? [])]
}
async function refreshAfterRepoInboxUpdate(
args: NewExternalWorktreesInboxActionDeps,
updates: RepoExternalWorktreeInboxUpdate,
rollbackUpdates: RepoExternalWorktreeInboxUpdate
): Promise<boolean> {
args.setInboxState(args.projectId, { pending: true, error: null })
const updated = await args.updateRepo(args.projectId, updates)
if (!updated) {
args.setInboxState(args.projectId, {
pending: false,
error: newExternalWorktreeInboxImportError()
})
return false
}
const refreshed = await args.fetchWorktrees(args.projectId, { requireAuthoritative: true })
if (!refreshed) {
await args.updateRepo(args.projectId, rollbackUpdates)
args.setInboxState(args.projectId, {
pending: false,
error: newExternalWorktreeInboxImportError()
})
return false
}
args.setInboxState(args.projectId, null)
return true
}
export async function keepNewExternalWorktreeInboxHidden(
args: NewExternalWorktreesInboxActionDeps
): Promise<void> {
args.setInboxState(args.projectId, { pending: true, error: null })
const baseline = mergeExternalWorktreeInboxPaths(
args.repo.externalWorktreeInboxBaselinePaths,
args.worktreePaths
)
const updated = await args.updateRepo(args.projectId, {
externalWorktreeInboxBaselinePaths: baseline
})
if (!updated) {
args.setInboxState(args.projectId, {
pending: false,
error: newExternalWorktreeInboxKeepHiddenError()
})
return
}
args.setInboxState(args.projectId, null)
}
export async function importNewExternalWorktreeInboxPaths(
args: NewExternalWorktreesInboxActionDeps
): Promise<void> {
const importedExternalWorktreePaths = mergeExternalWorktreeInboxPaths(
args.repo.importedExternalWorktreePaths,
args.worktreePaths
)
const externalWorktreeInboxBaselinePaths = mergeExternalWorktreeInboxPaths(
args.repo.externalWorktreeInboxBaselinePaths,
args.worktreePaths
)
await refreshAfterRepoInboxUpdate(
args,
{ importedExternalWorktreePaths, externalWorktreeInboxBaselinePaths },
{
importedExternalWorktreePaths: rollbackPathList(args.repo.importedExternalWorktreePaths),
externalWorktreeInboxBaselinePaths: rollbackPathList(
args.repo.externalWorktreeInboxBaselinePaths
)
}
)
}
export async function suppressNewExternalWorktreeInbox(
args: Omit<NewExternalWorktreesInboxActionDeps, 'fetchWorktrees'>
): Promise<boolean> {
args.setInboxState(args.projectId, { pending: true, error: null })
const externalWorktreeInboxBaselinePaths = mergeExternalWorktreeInboxPaths(
args.repo.externalWorktreeInboxBaselinePaths,
args.worktreePaths
)
const updated = await args.updateRepo(args.projectId, {
externalWorktreeDiscoverySuppressedAt: Date.now(),
externalWorktreeInboxBaselinePaths
})
if (!updated) {
args.setInboxState(args.projectId, {
pending: false,
error: newExternalWorktreeInboxSuppressError()
})
return false
}
args.setInboxState(args.projectId, null)
return true
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import type {
DetectedWorktree,
DetectedWorktreeListResult,
Repo,
Worktree
} from '../../../../shared/types'
import { buildNewExternalWorktreesInboxCandidates } from './new-external-worktrees-inbox-candidates'
const repo: Repo = {
id: 'repo-1',
path: '/repo',
displayName: 'orca',
badgeColor: '#000000',
addedAt: Date.UTC(2026, 4, 24),
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityPromptDismissedAt: 1,
externalWorktreeInboxBaselinePaths: ['/scratch/old-one']
}
const visibleWorktree: Worktree = {
id: 'repo-1::/repo',
repoId: repo.id,
path: '/repo',
displayName: 'main',
branch: 'refs/heads/main',
head: 'abc123',
isBare: false,
isMainWorktree: true,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
function detectedWorktree(overrides: Partial<DetectedWorktree> = {}): DetectedWorktree {
return {
...visibleWorktree,
id: 'repo-1::/scratch/new-one',
path: '/scratch/new-one',
displayName: 'new-one',
isMainWorktree: false,
ownership: 'external',
selectedCheckout: false,
visible: false,
...overrides
}
}
function detectedResult(worktrees: DetectedWorktree[]): DetectedWorktreeListResult {
return {
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees
}
}
describe('buildNewExternalWorktreesInboxCandidates', () => {
it('builds inbox candidates only after the initial prompt is dismissed', () => {
const detectedWorktreesByRepo = {
[repo.id]: detectedResult([detectedWorktree()])
}
expect(
buildNewExternalWorktreesInboxCandidates({
repos: [repo],
visibleWorktrees: [visibleWorktree],
detectedWorktreesByRepo
}).get(repo.id)
).toMatchObject({
repo: { id: repo.id },
inboxWorktrees: [{ id: 'repo-1::/scratch/new-one' }]
})
expect(
buildNewExternalWorktreesInboxCandidates({
repos: [{ ...repo, externalWorktreeVisibilityPromptDismissedAt: undefined }],
visibleWorktrees: [visibleWorktree],
detectedWorktreesByRepo
}).size
).toBe(0)
})
it('suppresses inbox candidates when discovery is permanently hidden or visibility is show', () => {
const detectedWorktreesByRepo = { [repo.id]: detectedResult([detectedWorktree()]) }
expect(
buildNewExternalWorktreesInboxCandidates({
repos: [{ ...repo, externalWorktreeDiscoverySuppressedAt: 1 }],
detectedWorktreesByRepo
}).size
).toBe(0)
expect(
buildNewExternalWorktreesInboxCandidates({
repos: [{ ...repo, externalWorktreeVisibility: 'show' }],
detectedWorktreesByRepo
}).size
).toBe(0)
})
})
@@ -0,0 +1,57 @@
import type {
DetectedWorktree,
DetectedWorktreeListResult,
Repo,
Worktree
} from '../../../../shared/types'
import { getNewExternalWorktreeInboxWorktrees } from '../../../../shared/external-worktree-inbox'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { NewExternalWorktreesInboxCandidate } from './worktree-list-groups'
export function buildNewExternalWorktreesInboxCandidates(args: {
repos: readonly Repo[]
visibleWorktrees?: readonly Worktree[]
detectedWorktreesByRepo: Readonly<Record<string, DetectedWorktreeListResult | undefined>>
filterRepoIds?: readonly string[]
}): Map<string, NewExternalWorktreesInboxCandidate> {
const visibleRepoIds = args.visibleWorktrees
? new Set(args.visibleWorktrees.map((worktree) => worktree.repoId))
: null
const filterRepoIds = args.filterRepoIds?.length ? new Set(args.filterRepoIds) : null
const candidates = new Map<string, NewExternalWorktreesInboxCandidate>()
for (const repo of args.repos) {
if (filterRepoIds && !filterRepoIds.has(repo.id)) {
continue
}
if (visibleRepoIds && !visibleRepoIds.has(repo.id)) {
continue
}
if (!isGitRepoKind(repo)) {
continue
}
const inboxWorktrees = getNewExternalWorktreeInboxWorktrees(
args.detectedWorktreesByRepo[repo.id],
repo
)
if (inboxWorktrees.length > 0) {
candidates.set(repo.id, { repo, inboxWorktrees })
}
}
return candidates
}
export type NewExternalWorktreeInboxPreview = Pick<
DetectedWorktree,
'id' | 'displayName' | 'path' | 'branch'
>
export function toNewExternalWorktreeInboxPreview(
worktree: DetectedWorktree
): NewExternalWorktreeInboxPreview {
return {
id: worktree.id,
displayName: worktree.displayName,
path: worktree.path,
branch: worktree.branch
}
}
@@ -10,6 +10,7 @@ type WorktreeDragUnitRow =
| { type: 'header'; key: string }
| { type: 'item'; worktree: { id: string }; depth: number; sectionKey: string }
| { type: 'imported-worktrees-card' }
| { type: 'new-external-worktrees-inbox' }
| { type: 'pending-creation' }
| { type: 'folder-workspace' }
@@ -32,6 +33,7 @@ export function getWorktreeDragUnitGroups(
if (
row.type === 'host-header' ||
row.type === 'imported-worktrees-card' ||
row.type === 'new-external-worktrees-inbox' ||
row.type === 'pending-creation' ||
row.type === 'folder-workspace'
) {
@@ -351,6 +351,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{ projects: [project], projectHostSetups }
)
@@ -441,6 +442,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{
projects: projection.projects,
@@ -461,6 +463,95 @@ describe('buildRows with pinned worktrees', () => {
])
})
it('keeps mixed-host project item order while inserting inbox rows before worktrees', () => {
const localRepo: Repo = {
...repo,
id: 'local-sample-app',
displayName: 'sample-app',
gitRemoteIdentity: {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'https://git.company.test/team/sample-app.git'
}
}
const sshRepo: Repo = {
...repo,
id: 'ssh-sample-app',
path: '/home/alice/src/sample-app',
displayName: 'sample-app',
connectionId: 'build server',
gitRemoteIdentity: {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'https://git.company.test/team/sample-app.git'
}
}
const localFirst: Worktree = {
...worktree,
id: 'wt-local-first',
repoId: localRepo.id,
path: '/Users/alice/work/sample-app-a'
}
const sshWorktree: Worktree = {
...worktree,
id: 'wt-ssh',
repoId: sshRepo.id,
path: '/home/alice/src/sample-app-b'
}
const localSecond: Worktree = {
...worktree,
id: 'wt-local-second',
repoId: localRepo.id,
path: '/Users/alice/work/sample-app-c'
}
const projection = projectHostSetupProjectionFromRepos([localRepo, sshRepo])
const rows = buildRows(
'repo',
[localFirst, sshWorktree, localSecond],
new Map([
[localRepo.id, localRepo],
[sshRepo.id, sshRepo]
]),
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([
[localFirst.id, localFirst],
[sshWorktree.id, sshWorktree],
[localSecond.id, localSecond]
]),
false,
undefined,
[],
new Set(),
new Map(),
new Map([
[
localRepo.id,
{ repo: localRepo, inboxWorktrees: [makeDetectedWorktree({ id: 'local-inbox' })] }
],
[sshRepo.id, { repo: sshRepo, inboxWorktrees: [makeDetectedWorktree({ id: 'ssh-inbox' })] }]
]),
[],
{
projects: projection.projects,
projectHostSetups: projection.setups
}
)
expect(rows).toMatchObject([
{ type: 'header' },
{ type: 'new-external-worktrees-inbox', repo: { id: localRepo.id } },
{ type: 'new-external-worktrees-inbox', repo: { id: sshRepo.id } },
{ type: 'item', worktree: { id: localFirst.id } },
{ type: 'item', worktree: { id: sshWorktree.id } },
{ type: 'item', worktree: { id: localSecond.id } }
])
})
it('orders project identity headers by the manual repo order anchor', () => {
const analyticsProject: Project = {
...project,
@@ -519,6 +610,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{
projects: [project, analyticsProject],
@@ -575,6 +667,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{
projects: [{ ...project, sourceRepoIds: [repo.id, repoB.id] }],
@@ -643,6 +736,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{
projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, remoteRepo.id] }],
@@ -722,6 +816,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{
projects: [{ ...project, sourceRepoIds: [windowsRepo.id, wslRepo.id] }],
@@ -780,6 +875,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{ projects: [project], projectHostSetups: [projectHostSetups[0]!, runtimeSetup] },
[],
@@ -821,6 +917,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{
projects: [{ ...project, sourceRepoIds: [repo.id] }],
@@ -1035,6 +1132,117 @@ describe('buildRows with pinned worktrees', () => {
expect(rows.some((row) => row.type === 'imported-worktrees-card')).toBe(false)
})
it('emits a new external worktrees inbox row before repo worktree rows', () => {
const inboxWorktrees = [
makeDetectedWorktree({ id: 'inbox-1', displayName: 'payments-refactor' }),
makeDetectedWorktree({ id: 'inbox-2', displayName: 'auth-cache-debug' })
]
const rows = buildRows(
'repo',
[worktree],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map(),
new Map([[repo.id, { repo, inboxWorktrees }]])
)
expect(rows).toMatchObject([
{ type: 'header', key: 'repo:repo-1' },
{
type: 'new-external-worktrees-inbox',
key: 'new-external-worktrees-inbox:repo-1',
repo: { id: 'repo-1' },
inboxWorktrees: [{ id: 'inbox-1' }, { id: 'inbox-2' }]
},
{ type: 'item', worktree: { id: 'wt-1' } }
])
})
it('suppresses the new external worktrees inbox row when the repo group is collapsed', () => {
const rows = buildRows(
'repo',
[worktree],
repoMap,
null,
new Set(['repo:repo-1']),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map(),
new Map([[repo.id, { repo, inboxWorktrees: [makeDetectedWorktree()] }]])
)
expect(rows).toMatchObject([{ type: 'header', key: 'repo:repo-1' }])
})
it('emits a repo header and inbox row when no visible worktree rows remain', () => {
const rows = buildRows(
'repo',
[],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map(),
false,
undefined,
[],
new Set(),
new Map(),
new Map([[repo.id, { repo, inboxWorktrees: [makeDetectedWorktree()] }]])
)
expect(rows).toMatchObject([
{ type: 'header', key: 'repo:repo-1' },
{
type: 'new-external-worktrees-inbox',
key: 'new-external-worktrees-inbox:repo-1'
}
])
})
it('does not emit new external worktrees inbox rows outside repo grouping', () => {
const rows = buildRows(
'workspace-status',
[worktree],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map(),
new Map([[repo.id, { repo, inboxWorktrees: [makeDetectedWorktree()] }]])
)
expect(rows.some((row) => row.type === 'new-external-worktrees-inbox')).toBe(false)
})
it('emits imported worktree cards in repo groups when visible rows are pinned', () => {
const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' }
const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true }
@@ -2186,6 +2394,7 @@ describe('project groups', () => {
[group],
new Set(),
new Map(),
new Map(),
[],
undefined,
[folderWorkspace]
@@ -2263,6 +2472,7 @@ describe('project groups', () => {
[rootGroup, childGroup],
new Set(),
new Map(),
new Map(),
[],
undefined,
[folderWorkspace]
@@ -2332,6 +2542,7 @@ describe('project groups', () => {
[group],
new Set(),
new Map(),
new Map(),
[],
undefined,
[folderWorkspace]
@@ -2774,6 +2985,7 @@ describe('buildRows pending creations', () => {
[],
new Set(),
new Map(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
@@ -2805,6 +3017,7 @@ describe('buildRows pending creations', () => {
[],
new Set(),
new Map(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
@@ -2828,6 +3041,7 @@ describe('buildRows pending creations', () => {
[],
new Set(),
new Map(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
@@ -2854,6 +3068,7 @@ describe('buildRows pending creations', () => {
[],
new Set(),
new Map(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
@@ -82,6 +82,18 @@ export type ImportedWorktreesCardRow = {
placement: 'repo-group' | 'pinned-fallback'
}
export type NewExternalWorktreesInboxCandidate = {
repo: Repo
inboxWorktrees: DetectedWorktree[]
}
export type NewExternalWorktreesInboxRow = {
type: 'new-external-worktrees-inbox'
key: string
repo: Repo
inboxWorktrees: DetectedWorktree[]
}
export type PendingCreationRow = {
type: 'pending-creation'
key: string
@@ -108,6 +120,7 @@ export type Row =
| GroupHeaderRow
| WorktreeRow
| ImportedWorktreesCardRow
| NewExternalWorktreesInboxRow
| PendingCreationRow
| FolderWorkspaceRow
@@ -471,6 +484,17 @@ function buildImportedWorktreesCardRow(
}
}
function buildNewExternalWorktreesInboxRow(
candidate: NewExternalWorktreesInboxCandidate
): NewExternalWorktreesInboxRow {
return {
type: 'new-external-worktrees-inbox',
key: `new-external-worktrees-inbox:${candidate.repo.id}`,
repo: candidate.repo,
inboxWorktrees: candidate.inboxWorktrees
}
}
function buildWorktreeRow(
worktree: Worktree,
repoMap: Map<string, Repo>,
@@ -801,6 +825,10 @@ export function buildRows(
projectGroups: readonly ProjectGroup[] = [],
placeholderRepoIds: ReadonlySet<string> = new Set(),
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map(),
newExternalWorktreesInboxByRepo: ReadonlyMap<
string,
NewExternalWorktreesInboxCandidate
> = new Map(),
pendingCreations: readonly PendingCreationRef[] = [],
projectGrouping?: ProjectGroupingModel,
folderWorkspaces: readonly FolderWorkspace[] = [],
@@ -927,6 +955,22 @@ export function buildRows(
}
}
}
if (groupBy === 'repo') {
for (const [repoId, candidate] of newExternalWorktreesInboxByRepo) {
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
const key = grouping.key
if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) {
grouped.set(key, {
label: grouping.label,
items: [],
repo: grouping.repo ?? candidate.repo,
repoIds: new Set([repoId])
})
} else if (grouped.has(key)) {
addRepoIdToGroup(grouped.get(key)!, repoId)
}
}
}
if (groupBy === 'repo') {
for (const repoId of pendingByRepo.keys()) {
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
@@ -1050,6 +1094,12 @@ export function buildRows(
result.push(buildImportedWorktreesCardRow(candidate, 'repo-group'))
}
}
for (const repoId of repoIds) {
const candidate = newExternalWorktreesInboxByRepo.get(repoId)
if (candidate) {
result.push(buildNewExternalWorktreesInboxRow(candidate))
}
}
// Why: surface in-progress creates at the top of their own repo so the
// new workspace appears where it will land, not flashed to the very top
// of the sidebar.
@@ -1064,13 +1114,23 @@ export function buildRows(
groupBy === 'repo'
? getMixedHostContextLabels(group, repoMap, projectIndex, hostLabelById)
: undefined
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups,
groupDepth: projectGroupDepth,
sectionKey: key,
hostContextLabelByRepoId
})
if (groupBy === 'repo') {
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups,
groupDepth: projectGroupDepth,
sectionKey: key,
hostContextLabelByRepoId
})
} else {
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups,
groupDepth: projectGroupDepth,
sectionKey: key,
hostContextLabelByRepoId
})
}
}
}
}
@@ -60,7 +60,7 @@ export function estimateRenderRowSize(
if (row?.type === 'lineage-group') {
return 100 + Math.max(0, row.rows.length - 1) * 96
}
if (row?.type === 'imported-worktrees-card') {
if (row?.type === 'imported-worktrees-card' || row?.type === 'new-external-worktrees-inbox') {
return IMPORTED_WORKTREES_LINE_ROW_HEIGHT
}
if (row?.type === 'pending-creation') {
+25
View File
@@ -4336,6 +4336,31 @@
},
"WorktreeCardStatusSlot": {
"branchIdentity": "Branch"
},
"NewExternalWorktreesInboxLine": {
"9f2d4c8b17": "Hide external worktrees permanently for {{value0}}",
"d9f7b2a14c": "Collapse new externally-created worktrees for {{value0}}",
"e2c4a8d91f": "Expand new externally-created worktrees for {{value0}}",
"7c4e9b2a81": "New externally-created worktrees",
"5e1b8d3f62": "Hide external worktrees permanently",
"4d7a1c9e53": "These worktrees were created outside of Orca.",
"8b3f2e1d74": "Import",
"1c9e7a4b28": "Keep hidden",
"6f2d8c1e95": "Import all",
"c3e8a1f4b2": "Don't show again"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "Could not keep external worktrees hidden. Try again.",
"b7e4d1a062": "Could not import external worktrees. Try again.",
"c94f0b3a15": "Could not hide external worktrees permanently. Try again."
},
"SuppressExternalWorktreeInboxDialog": {
"a4c2d8f1b0": "Hide external worktrees?",
"6e91b3c4d2": "External worktrees will not be shown in the sidebar or this list anymore for {{value0}}, including ones created later.",
"1f8a5d9e73": "You can turn this back on later from project settings.",
"8c0b2e7a41": "Open Non-Orca worktrees settings",
"5d1c9f0a82": "Cancel",
"3b7e4a1c96": "Hide external worktrees"
}
},
"shared": {
+25
View File
@@ -4336,6 +4336,31 @@
},
"WorktreeCardStatusSlot": {
"branchIdentity": "Rama"
},
"NewExternalWorktreesInboxLine": {
"9f2d4c8b17": "Ocultar árboles de trabajo externos permanentemente para {{value0}}",
"d9f7b2a14c": "Contraer árboles de trabajo creados externamente para {{value0}}",
"e2c4a8d91f": "Expandir árboles de trabajo creados externamente para {{value0}}",
"7c4e9b2a81": "Nuevos árboles de trabajo creados externamente",
"5e1b8d3f62": "Ocultar árboles de trabajo externos permanentemente",
"4d7a1c9e53": "Estos árboles de trabajo se crearon fuera de Orca.",
"8b3f2e1d74": "Importar",
"1c9e7a4b28": "Mantener ocultos",
"6f2d8c1e95": "Importar todo",
"c3e8a1f4b2": "No volver a mostrar"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "No se pudieron mantener ocultos los árboles de trabajo externos. Inténtalo de nuevo.",
"b7e4d1a062": "No se pudieron importar los árboles de trabajo externos. Inténtalo de nuevo.",
"c94f0b3a15": "No se pudieron ocultar permanentemente los árboles de trabajo externos. Inténtalo de nuevo."
},
"SuppressExternalWorktreeInboxDialog": {
"a4c2d8f1b0": "¿Ocultar árboles de trabajo externos?",
"6e91b3c4d2": "Los árboles de trabajo externos ya no se mostrarán en la barra lateral ni en esta lista para {{value0}}, incluidos los que se creen más adelante.",
"1f8a5d9e73": "Puedes volver a activarlo más tarde desde los ajustes del proyecto.",
"8c0b2e7a41": "Abrir ajustes de árboles de trabajo no pertenecientes a Orca",
"5d1c9f0a82": "Cancelar",
"3b7e4a1c96": "Ocultar árboles de trabajo externos"
}
},
"shared": {
+25
View File
@@ -4336,6 +4336,31 @@
},
"WorktreeCardStatusSlot": {
"branchIdentity": "ブランチ"
},
"NewExternalWorktreesInboxLine": {
"9f2d4c8b17": "{{value0}} の外部ワークツリーを完全に非表示にする",
"d9f7b2a14c": "{{value0}} の外部で作成された新しいワークツリーを折りたたむ",
"e2c4a8d91f": "{{value0}} の外部で作成された新しいワークツリーを展開",
"7c4e9b2a81": "外部で作成された新しいワークツリー",
"5e1b8d3f62": "外部ワークツリーを完全に非表示にする",
"4d7a1c9e53": "これらのワークツリーは Orca の外部で作成されました。",
"8b3f2e1d74": "インポート",
"1c9e7a4b28": "非表示のままにする",
"6f2d8c1e95": "すべてインポート",
"c3e8a1f4b2": "今後表示しない"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "外部ワークツリーを非表示のままにできませんでした。もう一度お試しください。",
"b7e4d1a062": "外部ワークツリーをインポートできませんでした。もう一度お試しください。",
"c94f0b3a15": "外部ワークツリーを永続的に非表示にできませんでした。もう一度お試しください。"
},
"SuppressExternalWorktreeInboxDialog": {
"a4c2d8f1b0": "外部ワークツリーを非表示にしますか?",
"6e91b3c4d2": "{{value0}} では、今後作成されるものを含め、外部ワークツリーはサイドバーにもこのリストにも表示されません。",
"1f8a5d9e73": "後でプロジェクト設定から再度有効にできます。",
"8c0b2e7a41": "Non-Orca ワークツリー設定を開く",
"5d1c9f0a82": "キャンセル",
"3b7e4a1c96": "外部ワークツリーを非表示"
}
},
"shared": {
+25
View File
@@ -4336,6 +4336,31 @@
},
"WorktreeCardStatusSlot": {
"branchIdentity": "브랜치"
},
"NewExternalWorktreesInboxLine": {
"9f2d4c8b17": "{{value0}}의 외부 워크트리를 영구적으로 숨기기",
"d9f7b2a14c": "{{value0}}의 외부에서 생성된 새 워크트리 접기",
"e2c4a8d91f": "{{value0}}의 외부에서 생성된 새 워크트리 펼치기",
"7c4e9b2a81": "외부에서 생성된 새 워크트리",
"5e1b8d3f62": "외부 워크트리를 영구적으로 숨기기",
"4d7a1c9e53": "이 워크트리는 Orca 외부에서 생성되었습니다.",
"8b3f2e1d74": "가져오기",
"1c9e7a4b28": "숨긴 상태 유지",
"6f2d8c1e95": "모두 가져오기",
"c3e8a1f4b2": "다시 표시하지 않기"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "외부 워크트리를 숨김 상태로 유지할 수 없습니다. 다시 시도하세요.",
"b7e4d1a062": "외부 워크트리를 가져올 수 없습니다. 다시 시도하세요.",
"c94f0b3a15": "외부 워크트리를 영구적으로 숨길 수 없습니다. 다시 시도하세요."
},
"SuppressExternalWorktreeInboxDialog": {
"a4c2d8f1b0": "외부 워크트리를 숨길까요?",
"6e91b3c4d2": "{{value0}}에서는 나중에 생성되는 항목을 포함해 외부 워크트리가 사이드바나 이 목록에 더 이상 표시되지 않습니다.",
"1f8a5d9e73": "나중에 프로젝트 설정에서 다시 켤 수 있습니다.",
"8c0b2e7a41": "Non-Orca 워크트리 설정 열기",
"5d1c9f0a82": "취소",
"3b7e4a1c96": "외부 워크트리 숨기기"
}
},
"shared": {
+25
View File
@@ -4336,6 +4336,31 @@
},
"WorktreeCardStatusSlot": {
"branchIdentity": "分支"
},
"NewExternalWorktreesInboxLine": {
"9f2d4c8b17": "永久隐藏 {{value0}} 的外部工作树",
"d9f7b2a14c": "折叠 {{value0}} 的外部创建的新工作树",
"e2c4a8d91f": "展开 {{value0}} 的外部创建的新工作树",
"7c4e9b2a81": "外部创建的新工作树",
"5e1b8d3f62": "永久隐藏外部工作树",
"4d7a1c9e53": "这些工作树是在 Orca 外部创建的。",
"8b3f2e1d74": "导入",
"1c9e7a4b28": "保持隐藏",
"6f2d8c1e95": "全部导入",
"c3e8a1f4b2": "不再显示"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "无法保持外部工作树隐藏。请重试。",
"b7e4d1a062": "无法导入外部工作树。请重试。",
"c94f0b3a15": "无法永久隐藏外部工作树。请重试。"
},
"SuppressExternalWorktreeInboxDialog": {
"a4c2d8f1b0": "隐藏外部工作树?",
"6e91b3c4d2": "对于 {{value0}},外部工作树将不再显示在侧边栏或此列表中,包括之后创建的工作树。",
"1f8a5d9e73": "之后可以在项目设置中重新开启。",
"8c0b2e7a41": "打开 Non-Orca 工作树设置",
"5d1c9f0a82": "取消",
"3b7e4a1c96": "隐藏外部工作树"
}
},
"shared": {
+29 -12
View File
@@ -80,7 +80,7 @@ const ERROR_TOAST_DURATION = 60_000
const SAFE_AUTO_FORK_SYNC_COOLDOWN_MS = 10 * 60 * 1000
const safeAutoForkSyncAttempts = new Map<string, { attemptedAt: number; promise?: Promise<void> }>()
type RepoUpdate = Partial<
export type RepoUpdate = Partial<
Pick<
Repo,
| 'displayName'
@@ -96,10 +96,15 @@ type RepoUpdate = Partial<
| 'forkSyncMode'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'projectGroupId'
| 'projectGroupOrder'
>
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
> & {
sourceControlAi?: Repo['sourceControlAi'] | null
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
}
type ProjectUpdate = ProjectUpdateArgs['updates']
@@ -2574,18 +2579,30 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
if (updatedRepo) {
return repoWithFetchedOwner(updatedRepo, target)
}
if (sanitizedUpdates.sourceControlAi === null) {
const { sourceControlAi: _sourceControlAi, ...repoWithoutSourceControlAi } = r
const { sourceControlAi: _clearedSourceControlAi, ...updatesWithoutSourceControlAi } =
sanitizedUpdates
return { ...repoWithoutSourceControlAi, ...updatesWithoutSourceControlAi }
let mergedRepo: Repo = r
const {
sourceControlAi,
externalWorktreeDiscoverySuppressedAt,
...updatesWithoutClearSentinels
} = sanitizedUpdates
mergedRepo = { ...mergedRepo, ...updatesWithoutClearSentinels }
if (sourceControlAi === null) {
const { sourceControlAi: _sourceControlAi, ...repoWithoutSourceControlAi } =
mergedRepo
mergedRepo = repoWithoutSourceControlAi
} else if (sourceControlAi !== undefined) {
mergedRepo = { ...mergedRepo, sourceControlAi }
}
const { sourceControlAi, ...updatesWithoutSourceControlAi } = sanitizedUpdates
return {
...r,
...updatesWithoutSourceControlAi,
...(sourceControlAi !== undefined ? { sourceControlAi } : {})
if (externalWorktreeDiscoverySuppressedAt === null) {
const {
externalWorktreeDiscoverySuppressedAt: _suppressedAt,
...repoWithoutSuppression
} = mergedRepo
mergedRepo = repoWithoutSuppression
} else if (externalWorktreeDiscoverySuppressedAt !== undefined) {
mergedRepo = { ...mergedRepo, externalWorktreeDiscoverySuppressedAt }
}
return mergedRepo
})
return {
repos: nextRepos,
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import type { DetectedWorktree, DetectedWorktreeListResult, Repo } from './types'
import {
getHiddenExternalWorktrees,
getNewExternalWorktreeInboxWorktrees,
mergeExternalWorktreeInboxPaths,
shouldOfferNewExternalWorktreeInbox
} from './external-worktree-inbox'
import { EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT } from './worktree-ownership'
const repo: Repo = {
id: 'repo-1',
path: '/repo',
displayName: 'orca',
badgeColor: '#000000',
addedAt: Date.UTC(2026, 4, 24),
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityPromptDismissedAt: 1
}
function detectedWorktree(overrides: Partial<DetectedWorktree> = {}): DetectedWorktree {
return {
id: 'repo-1::/repo-worktree',
repoId: repo.id,
path: '/repo-worktree',
displayName: 'repo-worktree',
branch: 'refs/heads/feature',
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
ownership: 'external',
selectedCheckout: false,
visible: false,
...overrides
}
}
function detectedResult(worktrees: DetectedWorktree[]): DetectedWorktreeListResult {
return {
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees
}
}
describe('external worktree inbox', () => {
it('merges inbox paths without duplicates when paths match after normalization', () => {
expect(mergeExternalWorktreeInboxPaths(['/repo/one/'], ['/repo/one', '/repo/two'])).toEqual([
'/repo/one/',
'/repo/two'
])
})
it('offers the inbox only after the initial prompt is dismissed and discovery is not suppressed', () => {
expect(shouldOfferNewExternalWorktreeInbox(repo)).toBe(true)
expect(
shouldOfferNewExternalWorktreeInbox({
...repo,
externalWorktreeVisibilityPromptDismissedAt: undefined
})
).toBe(false)
expect(
shouldOfferNewExternalWorktreeInbox({
...repo,
externalWorktreeDiscoverySuppressedAt: 1
})
).toBe(false)
expect(
shouldOfferNewExternalWorktreeInbox({
...repo,
externalWorktreeVisibility: 'show'
})
).toBe(false)
})
it('returns only hidden external worktrees outside the inbox baseline', () => {
const hidden = detectedWorktree({ id: 'hidden', path: '/scratch/new-one' })
const baselined = detectedWorktree({ id: 'baselined', path: '/scratch/old-one' })
const detected = detectedResult([
hidden,
baselined,
detectedWorktree({ id: 'visible', visible: true }),
detectedWorktree({ id: 'orca-managed', ownership: 'orca-managed' })
])
expect(getHiddenExternalWorktrees(detected)).toEqual([hidden, baselined])
expect(
getNewExternalWorktreeInboxWorktrees(detected, {
...repo,
externalWorktreeInboxBaselinePaths: ['/scratch/old-one']
})
).toEqual([hidden])
})
it('suppresses non-authoritative detected results', () => {
expect(
getNewExternalWorktreeInboxWorktrees(detectedResult([detectedWorktree()]), {
...repo,
addedAt: EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT + 1
})
).toEqual([detectedWorktree()])
expect(
getNewExternalWorktreeInboxWorktrees(
{ ...detectedResult([detectedWorktree()]), authoritative: false },
repo
)
).toEqual([])
})
})
+96
View File
@@ -0,0 +1,96 @@
import { normalizeRuntimePathForComparison } from './cross-platform-path'
import type { DetectedWorktree, DetectedWorktreeListResult, Repo } from './types'
import {
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from './worktree-ownership'
export function normalizeExternalWorktreeInboxPath(path: string): string {
return normalizeRuntimePathForComparison(path)
}
export function areExternalWorktreeInboxPathsEqual(leftPath: string, rightPath: string): boolean {
return (
normalizeExternalWorktreeInboxPath(leftPath) === normalizeExternalWorktreeInboxPath(rightPath)
)
}
export function mergeExternalWorktreeInboxPaths(
existing: readonly string[] | undefined,
additions: readonly string[]
): string[] {
const seen = new Set((existing ?? []).map((path) => normalizeExternalWorktreeInboxPath(path)))
const merged = [...(existing ?? [])]
for (const path of additions) {
const normalized = normalizeExternalWorktreeInboxPath(path)
if (!normalized || seen.has(normalized)) {
continue
}
seen.add(normalized)
merged.push(path)
}
return merged
}
export function getHiddenExternalWorktrees(
detected: DetectedWorktreeListResult | undefined
): DetectedWorktree[] {
if (detected?.authoritative !== true) {
return []
}
return detected.worktrees.filter(
(worktree) =>
!worktree.visible && !worktree.selectedCheckout && worktree.ownership !== 'orca-managed'
)
}
export function isExternalWorktreeDiscoverySuppressed(
repo: Pick<Repo, 'externalWorktreeDiscoverySuppressedAt'>
): boolean {
return typeof repo.externalWorktreeDiscoverySuppressedAt === 'number'
}
export function hasCompletedInitialExternalWorktreeImportPrompt(
repo: Pick<Repo, 'externalWorktreeVisibilityPromptDismissedAt'>
): boolean {
return typeof repo.externalWorktreeVisibilityPromptDismissedAt === 'number'
}
export function shouldOfferNewExternalWorktreeInbox(repo: Repo): boolean {
if (isExternalWorktreeDiscoverySuppressed(repo)) {
return false
}
if (!hasCompletedInitialExternalWorktreeImportPrompt(repo)) {
return false
}
return (
effectiveExternalWorktreeVisibility(repo, isLegacyRepoForExternalWorktreeVisibility(repo)) ===
'hide'
)
}
export function getNewExternalWorktreeInboxWorktrees(
detected: DetectedWorktreeListResult | undefined,
repo: Repo
): DetectedWorktree[] {
if (!shouldOfferNewExternalWorktreeInbox(repo)) {
return []
}
const baseline = new Set(
(repo.externalWorktreeInboxBaselinePaths ?? []).map((path) =>
normalizeExternalWorktreeInboxPath(path)
)
)
return getHiddenExternalWorktrees(detected).filter(
(worktree) => !baseline.has(normalizeExternalWorktreeInboxPath(worktree.path))
)
}
export function isExplicitlyImportedExternalWorktreePath(
worktreePath: string,
repo: { importedExternalWorktreePaths?: readonly string[] }
): boolean {
return (repo.importedExternalWorktreePaths ?? []).some((path) =>
areExternalWorktreeInboxPathsEqual(path, worktreePath)
)
}
+6
View File
@@ -264,6 +264,12 @@ export type Repo = {
externalWorktreeVisibilityLegacy?: boolean
/** One-shot guard for the optional existing-user visibility prompt. */
externalWorktreeVisibilityPromptDismissedAt?: number
/** Hidden external worktree paths acknowledged by Keep hidden on the inbox. */
externalWorktreeInboxBaselinePaths?: string[]
/** External worktree paths explicitly imported while global visibility stays hide. */
importedExternalWorktreePaths?: string[]
/** User permanently opted out of the new-external-worktree inbox for this repo. */
externalWorktreeDiscoverySuppressedAt?: number
/** Paths (relative to the primary checkout) that should be symlinked into
* newly created worktrees of this repo. Consumed only when the global
* `experimentalWorktreeSymlinks` flag is on — the per-repo list is the
+27
View File
@@ -292,6 +292,33 @@ describe('external worktree visibility policy', () => {
expect(isLegacyRepoForExternalWorktreeVisibility(makeRepo({ addedAt: Number.NaN }))).toBe(true)
})
it('shows explicitly imported external worktrees while repo visibility stays hide', () => {
const repo = makeRepo({
externalWorktreeVisibility: 'hide',
importedExternalWorktreePaths: ['/scratch/imported']
})
expect(
shouldShowWorktree({
repo,
worktree: makeWorktree({ path: '/scratch/imported', isMainWorktree: false }),
ownership: 'external',
isLegacyRepoForVisibility: false,
isSelectedCheckout: false,
importedExternalWorktreePaths: repo.importedExternalWorktreePaths
})
).toBe(true)
expect(
shouldShowWorktree({
repo,
worktree: makeWorktree({ path: '/scratch/other', isMainWorktree: false }),
ownership: 'external',
isLegacyRepoForVisibility: false,
isSelectedCheckout: false,
importedExternalWorktreePaths: repo.importedExternalWorktreePaths
})
).toBe(false)
})
it('keeps unknown legacy rows visible for legacy repos after hiding external rows', () => {
const repo = makeRepo({
addedAt: EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT - 1,
+11 -1
View File
@@ -8,6 +8,7 @@ import {
resolveRuntimePath
} from './cross-platform-path'
import { parseWslUncPath } from './wsl-paths'
import { isExplicitlyImportedExternalWorktreePath } from './external-worktree-inbox'
import type {
DetectedWorktree,
ExternalWorktreeVisibility,
@@ -191,7 +192,8 @@ export function toDetectedWorktree(args: {
ownership,
repo: args.repo,
isLegacyRepoForVisibility,
isSelectedCheckout: selectedCheckout
isSelectedCheckout: selectedCheckout,
importedExternalWorktreePaths: args.repo.importedExternalWorktreePaths
})
return {
@@ -208,6 +210,7 @@ export function shouldShowWorktree(args: {
repo: Repo
isLegacyRepoForVisibility: boolean
isSelectedCheckout: boolean
importedExternalWorktreePaths?: readonly string[] | undefined
}): boolean {
if (args.isSelectedCheckout) {
return true
@@ -215,6 +218,13 @@ export function shouldShowWorktree(args: {
if (args.ownership === 'orca-managed') {
return true
}
if (
isExplicitlyImportedExternalWorktreePath(args.worktree.path, {
importedExternalWorktreePaths: args.importedExternalWorktreePaths
})
) {
return true
}
if (args.ownership === 'unknown-legacy' && args.isLegacyRepoForVisibility) {
return true
}