mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/issue-5796-codex-auto-approval-notifications
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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> }>
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user