mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/issue-5918-automation-setup-hook
# Conflicts: # src/main/runtime/orca-runtime.test.ts # src/main/runtime/orca-runtime.ts # src/renderer/src/lib/worktree-activation.ts
This commit is contained in:
@@ -42,14 +42,14 @@ jobs:
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
|
||||
|
||||
# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
|
||||
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
|
||||
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
# AWS_REGION: us-east-1
|
||||
# BEDROCK_MODEL_ID: <bedrock-model-id>
|
||||
|
||||
|
||||
# for Google Vertex AI (https://docs.pullfrog.com/vertex)
|
||||
# VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
|
||||
# GOOGLE_CLOUD_PROJECT: my-project
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.104-rc.3",
|
||||
"version": "1.4.104-rc.6",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
export type GitRuntimeOptions = {
|
||||
wslDistro?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export function gitOptionsForWorktree(
|
||||
cwd: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): { cwd: string; wslDistro?: string } {
|
||||
return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd }
|
||||
): { cwd: string; wslDistro?: string; signal?: AbortSignal } {
|
||||
return {
|
||||
cwd,
|
||||
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
|
||||
...(options.signal ? { signal: options.signal } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,6 +836,7 @@ type GitStreamOptions = {
|
||||
cwd: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
wslDistro?: string
|
||||
signal?: AbortSignal
|
||||
/** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */
|
||||
maxBuffer?: number
|
||||
/**
|
||||
@@ -863,6 +864,10 @@ export async function gitStreamStdout(
|
||||
const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER
|
||||
return withGitSpan({ args, cwd: options.cwd }, async () => {
|
||||
return new Promise<GitStreamResult>((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(createAbortError())
|
||||
return
|
||||
}
|
||||
const child = gitSpawn(args, {
|
||||
cwd: options.cwd,
|
||||
env: nonInteractiveGitEnv(options.env),
|
||||
@@ -887,6 +892,7 @@ export async function gitStreamStdout(
|
||||
child.stderr?.off('data', onStderrData)
|
||||
child.off('error', onError)
|
||||
child.off('close', onClose)
|
||||
options.signal?.removeEventListener('abort', onAbort)
|
||||
// Flush any bytes the decoders were holding for an incomplete sequence.
|
||||
stdoutDecoder.end()
|
||||
stderrDecoder.end()
|
||||
@@ -953,11 +959,19 @@ export async function gitStreamStdout(
|
||||
}
|
||||
finish(new Error(`git exited with ${code}: ${stderr}`))
|
||||
}
|
||||
function onAbort(): void {
|
||||
killSpawnedCommandTree(child)
|
||||
finish(createAbortError())
|
||||
}
|
||||
|
||||
child.stdout?.on('data', onStdoutData)
|
||||
child.stderr?.on('data', onStderrData)
|
||||
child.on('error', onError)
|
||||
child.on('close', onClose)
|
||||
options.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
if (options.signal?.aborted) {
|
||||
onAbort()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ export async function getStatus(
|
||||
options: GetStatusOptions = {}
|
||||
): Promise<GitStatusResult> {
|
||||
gitDiffReadDedupe.clear()
|
||||
if (options.signal) {
|
||||
return runGetStatus(worktreePath, options)
|
||||
}
|
||||
// Why: dedupe only concurrent identical reads; after settle, callers must
|
||||
// execute a fresh status read rather than observing a cached result.
|
||||
const cacheKey = getStatusReadKey(worktreePath, options)
|
||||
@@ -190,6 +193,7 @@ async function runGetStatus(
|
||||
// Why: status polling is read-like; avoid refreshing the index and racing
|
||||
// terminal Git commands on `.git/worktrees/*/index.lock`.
|
||||
env: gitOptionalLocksDisabledEnv(),
|
||||
signal: options.signal,
|
||||
onStdout: (chunk) => parser.update(chunk, limit)
|
||||
})
|
||||
if (!stoppedEarly) {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { execFileSync } from 'child_process'
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import * as path from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GitRunner from './runner'
|
||||
|
||||
// Why: spy on the git runner so we can count rev-parse invocations while still
|
||||
// running real git, proving the main-entry-equals-repoPath early return skips
|
||||
// the extra subprocess for an ordinary repo scanned at its own root.
|
||||
const revParseTopLevelCalls = { count: 0 }
|
||||
vi.mock('./runner', async () => {
|
||||
const actual = await vi.importActual<typeof GitRunner>('./runner')
|
||||
return {
|
||||
...actual,
|
||||
gitExecFileAsync: (args: string[], options?: unknown) => {
|
||||
if (args[0] === 'rev-parse' && args.includes('--show-toplevel')) {
|
||||
revParseTopLevelCalls.count += 1
|
||||
}
|
||||
return (actual.gitExecFileAsync as (a: string[], o?: unknown) => Promise<unknown>)(
|
||||
args,
|
||||
options
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Imported after vi.mock so worktree.ts binds to the spied runner.
|
||||
const { listWorktrees } = await import('./worktree')
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
}
|
||||
|
||||
async function createCommittedRepo(root: string, name: string): Promise<string> {
|
||||
const repoPath = path.join(root, name)
|
||||
execFileSync('git', ['init', '--quiet', repoPath])
|
||||
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
|
||||
git(repoPath, ['config', 'user.email', 'test@example.com'])
|
||||
git(repoPath, ['config', 'user.name', 'Test User'])
|
||||
await writeFile(path.join(repoPath, 'README.md'), `${name}\n`)
|
||||
git(repoPath, ['add', 'README.md'])
|
||||
git(repoPath, ['commit', '--quiet', '-m', 'initial'])
|
||||
return realpath(repoPath)
|
||||
}
|
||||
|
||||
async function createSeparateGitDirRepo(): Promise<{
|
||||
gitDirPath: string
|
||||
worktreePath: string
|
||||
}> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-separate-git-dir-'))
|
||||
tempRoots.push(root)
|
||||
const sourcePath = await createCommittedRepo(root, 'source')
|
||||
const requestedWorktreePath = path.join(root, 'worktree')
|
||||
const requestedGitDirPath = path.join(root, 'git-store', 'project.git')
|
||||
await mkdir(path.dirname(requestedGitDirPath), { recursive: true })
|
||||
|
||||
execFileSync('git', [
|
||||
'clone',
|
||||
'--quiet',
|
||||
`--separate-git-dir=${requestedGitDirPath}`,
|
||||
sourcePath,
|
||||
requestedWorktreePath
|
||||
])
|
||||
|
||||
return {
|
||||
gitDirPath: await realpath(requestedGitDirPath),
|
||||
worktreePath: await realpath(requestedWorktreePath)
|
||||
}
|
||||
}
|
||||
|
||||
async function createNormalRepo(): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-normal-worktree-'))
|
||||
tempRoots.push(root)
|
||||
return createCommittedRepo(root, 'repo')
|
||||
}
|
||||
|
||||
async function createBareRepo(): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-bare-worktree-'))
|
||||
tempRoots.push(root)
|
||||
const repoPath = path.join(root, 'repo.git')
|
||||
execFileSync('git', ['init', '--bare', '--quiet', repoPath])
|
||||
return realpath(repoPath)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
revParseTopLevelCalls.count = 0
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('git worktree separate git dir paths', () => {
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'normalizes the main worktree path for a separate-git-dir repo',
|
||||
async () => {
|
||||
const { gitDirPath, worktreePath } = await createSeparateGitDirRepo()
|
||||
|
||||
const worktrees = await listWorktrees(worktreePath)
|
||||
const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree)
|
||||
|
||||
expect(mainWorktree).toMatchObject({
|
||||
path: worktreePath,
|
||||
isMainWorktree: true
|
||||
})
|
||||
expect(mainWorktree?.path).not.toBe(gitDirPath)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'fires exactly one rev-parse for a separate-git-dir repo',
|
||||
async () => {
|
||||
const { worktreePath } = await createSeparateGitDirRepo()
|
||||
|
||||
await listWorktrees(worktreePath)
|
||||
|
||||
expect(revParseTopLevelCalls.count).toBe(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')('leaves a normal repo main path unchanged', async () => {
|
||||
const repoPath = await createNormalRepo()
|
||||
|
||||
const worktrees = await listWorktrees(repoPath)
|
||||
const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree)
|
||||
|
||||
expect(mainWorktree).toMatchObject({
|
||||
path: repoPath,
|
||||
isMainWorktree: true
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'leaves an ordinary repo reached via a symlinked path unchanged',
|
||||
async () => {
|
||||
// A symlink alias of the checkout root defeats the path-string gate
|
||||
// (git reports the realpath toplevel), so the git-common-dir gate must
|
||||
// still skip the rewrite since the main entry is a real working root.
|
||||
const repoPath = await createNormalRepo()
|
||||
const linkRoot = await mkdtemp(path.join(tmpdir(), 'orca-symlink-worktree-'))
|
||||
tempRoots.push(linkRoot)
|
||||
const linkedRepoPath = path.join(linkRoot, 'linked-repo')
|
||||
await symlink(repoPath, linkedRepoPath)
|
||||
|
||||
const worktrees = await listWorktrees(linkedRepoPath)
|
||||
const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree)
|
||||
|
||||
expect(mainWorktree).toMatchObject({
|
||||
path: repoPath,
|
||||
isMainWorktree: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'leaves the main entry unchanged when scanned via a linked worktree',
|
||||
async () => {
|
||||
// A linked worktree has a `.git` *pointer file* just like a
|
||||
// separate-git-dir checkout, but its porcelain main entry is the real
|
||||
// main working root — never the Git directory. The git-common-dir gate
|
||||
// must skip the rewrite so the main entry is not overwritten with the
|
||||
// linked worktree's own toplevel (which would collide / drop the main).
|
||||
const repoPath = await createNormalRepo()
|
||||
const linkedWorktreePath = path.join(path.dirname(repoPath), 'linked')
|
||||
git(repoPath, ['worktree', 'add', '--quiet', linkedWorktreePath, '-b', 'feature'])
|
||||
const resolvedLinked = await realpath(linkedWorktreePath)
|
||||
|
||||
const worktrees = await listWorktrees(resolvedLinked)
|
||||
const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree)
|
||||
|
||||
expect(mainWorktree).toMatchObject({
|
||||
path: repoPath,
|
||||
isMainWorktree: true
|
||||
})
|
||||
// The main entry must not be rewritten to the linked worktree's path.
|
||||
expect(mainWorktree?.path).not.toBe(resolvedLinked)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')('does not throw for a bare repo', async () => {
|
||||
const repoPath = await createBareRepo()
|
||||
|
||||
const worktrees = await listWorktrees(repoPath)
|
||||
const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree)
|
||||
|
||||
expect(mainWorktree).toMatchObject({
|
||||
path: repoPath,
|
||||
isBare: true,
|
||||
isMainWorktree: true
|
||||
})
|
||||
})
|
||||
})
|
||||
+117
-4
@@ -14,6 +14,7 @@ import type {
|
||||
RemoveWorktreeResult
|
||||
} from '../../shared/types'
|
||||
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
|
||||
import { resolveGitDir } from './status'
|
||||
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
|
||||
@@ -29,6 +30,7 @@ type SparseWorktreeCreateError = Error & {
|
||||
|
||||
export type GitWorktreeExecOptions = {
|
||||
wslDistro?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type AddWorktreeOptions = GitWorktreeExecOptions & {
|
||||
@@ -69,8 +71,12 @@ const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
|
||||
function gitExecOptions(
|
||||
cwd: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): { cwd: string; wslDistro?: string } {
|
||||
return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd }
|
||||
): { cwd: string; wslDistro?: string; signal?: AbortSignal } {
|
||||
return {
|
||||
cwd,
|
||||
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
|
||||
...(options.signal ? { signal: options.signal } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): string | undefined {
|
||||
@@ -103,6 +109,12 @@ function isUnsupportedWorktreeListZError(error: unknown): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
|
||||
return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test(
|
||||
getErrorText(error)
|
||||
)
|
||||
}
|
||||
|
||||
function isBranchCheckedOutInWorktreeError(error: unknown): boolean {
|
||||
return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test(
|
||||
getErrorText(error)
|
||||
@@ -348,6 +360,103 @@ function looksLikeWindowsPath(pathValue: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\')
|
||||
}
|
||||
|
||||
function resolveRevParsePath(repoPath: string, value: string): string {
|
||||
if (posix.isAbsolute(value) || win32.isAbsolute(value)) {
|
||||
return value
|
||||
}
|
||||
// Old git ignores `--path-format=absolute`, so a relative toplevel/git-dir
|
||||
// must be resolved against the scanned repo path.
|
||||
return looksLikeWindowsPath(repoPath)
|
||||
? win32.resolve(repoPath, value)
|
||||
: posix.resolve(repoPath, value)
|
||||
}
|
||||
|
||||
type RepoLocation = { topLevel: string; commonDir: string }
|
||||
|
||||
function parseRepoLocation(repoPath: string, output: string): RepoLocation | undefined {
|
||||
// Old git (pre `--path-format`) echoes the unrecognized flag to stdout and
|
||||
// exits 0 rather than erroring, so drop any echoed `-`-prefixed lines and
|
||||
// read the two trailing path lines (toplevel, then git-common-dir). Strip only
|
||||
// the trailing CR, not surrounding spaces — git paths may legitimately start
|
||||
// or end with a space.
|
||||
const lines = output
|
||||
.split('\n')
|
||||
.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
|
||||
.filter((line) => line.length > 0 && !line.startsWith('-'))
|
||||
if (lines.length < 2) {
|
||||
return undefined
|
||||
}
|
||||
const [topLevel, commonDir] = lines.slice(-2)
|
||||
return {
|
||||
topLevel: resolveRevParsePath(repoPath, topLevel),
|
||||
commonDir: resolveRevParsePath(repoPath, commonDir)
|
||||
}
|
||||
}
|
||||
|
||||
async function readRepoLocation(
|
||||
repoPath: string,
|
||||
resolveBasePath: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): Promise<RepoLocation | undefined> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
|
||||
gitExecOptions(repoPath, options)
|
||||
)
|
||||
return parseRepoLocation(resolveBasePath, stdout)
|
||||
} catch (error) {
|
||||
if (!isUnsupportedRevParsePathFormatError(error)) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['rev-parse', '--show-toplevel', '--git-common-dir'],
|
||||
gitExecOptions(repoPath, options)
|
||||
)
|
||||
return parseRepoLocation(resolveBasePath, stdout)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeMainWorktreePath(
|
||||
repoPath: string,
|
||||
worktrees: GitWorktreeInfo[],
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree)
|
||||
const mainWorktree = worktrees[mainIndex]
|
||||
// Compare in the Git-output space: under WSL the porcelain/rev-parse paths are
|
||||
// Linux while repoPath is a UNC path, so without translating, a UNC repoPath
|
||||
// never matches the early-return and fires a needless rev-parse on every poll.
|
||||
// Use the platform-independent UNC parser so the comparison holds regardless
|
||||
// of host OS; the runner still receives the original repoPath for WSL routing.
|
||||
const wslRepo = parseWslUncPath(repoPath)
|
||||
const comparablePath = wslRepo ? wslRepo.linuxPath : repoPath
|
||||
if (!mainWorktree || areWorktreePathsEqual(mainWorktree.path, comparablePath)) {
|
||||
return worktrees
|
||||
}
|
||||
|
||||
const location = await readRepoLocation(repoPath, comparablePath, options)
|
||||
if (!location) {
|
||||
return worktrees
|
||||
}
|
||||
|
||||
// Why: only a separate-git-dir/submodule main worktree reports the Git
|
||||
// directory as the main entry — i.e. the main entry equals git-common-dir.
|
||||
// A linked worktree's main entry is a real working root, so gating on this
|
||||
// equality avoids overwriting it with the linked worktree's own toplevel.
|
||||
if (!areWorktreePathsEqual(mainWorktree.path, location.commonDir)) {
|
||||
return worktrees
|
||||
}
|
||||
|
||||
const normalized = [...worktrees]
|
||||
normalized[mainIndex] = { ...mainWorktree, path: location.topLevel }
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the porcelain output of `git worktree list --porcelain`.
|
||||
*/
|
||||
@@ -441,7 +550,11 @@ async function readWorktreeList(
|
||||
cwd: repoPath,
|
||||
...options
|
||||
})
|
||||
return parseWorktreeList(stdout, { nulDelimited: true })
|
||||
return normalizeMainWorktreePath(
|
||||
repoPath,
|
||||
parseWorktreeList(stdout, { nulDelimited: true }),
|
||||
options
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isUnsupportedWorktreeListZError(error)) {
|
||||
throw error
|
||||
@@ -454,7 +567,7 @@ async function readWorktreeList(
|
||||
cwd: repoPath,
|
||||
...options
|
||||
})
|
||||
return parseWorktreeList(stdout)
|
||||
return normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout), options)
|
||||
}
|
||||
|
||||
async function readTranslatedWorktreeGraph(
|
||||
|
||||
@@ -1020,6 +1020,40 @@ describe('runHook', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSetupRunnerScript', () => {
|
||||
const makeRepo = (setupAgentStartupPolicy?: 'start-immediately' | 'wait-for-setup') =>
|
||||
({
|
||||
id: 'test-id',
|
||||
path: '/test/repo',
|
||||
displayName: 'Test Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: Date.now(),
|
||||
hookSettings: {
|
||||
mode: 'auto',
|
||||
setupAgentStartupPolicy,
|
||||
scripts: { setup: '', archive: '' }
|
||||
}
|
||||
}) as unknown as Repo
|
||||
|
||||
it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => {
|
||||
gitExecFileSyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n')
|
||||
const { createSetupRunnerScript } = await import('./hooks')
|
||||
|
||||
expect(
|
||||
createSetupRunnerScript(makeRepo(), '/test/worktree', 'echo setup').waitForAgentStartup
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
createSetupRunnerScript(makeRepo('start-immediately'), '/test/worktree', 'echo setup')
|
||||
.waitForAgentStartup
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
createSetupRunnerScript(makeRepo('wait-for-setup'), '/test/worktree', 'echo setup')
|
||||
.waitForAgentStartup
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRunSetupForCreate', () => {
|
||||
const makeRepo = (setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default') =>
|
||||
({
|
||||
|
||||
+10
-3
@@ -6,6 +6,7 @@ import { parse } from 'yaml'
|
||||
import { getDefaultRepoHookSettings } from '../shared/constants'
|
||||
import { getRuntimePathBasename } from '../shared/cross-platform-path'
|
||||
import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy'
|
||||
import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy'
|
||||
import { gitExecFileSync } from './git/runner'
|
||||
import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl'
|
||||
import type {
|
||||
@@ -517,7 +518,8 @@ export function createSetupRunnerScript(
|
||||
worktreePath,
|
||||
script,
|
||||
'setup-runner',
|
||||
getHookRuntimeTarget(projectRuntime)
|
||||
getHookRuntimeTarget(projectRuntime),
|
||||
shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -575,7 +577,8 @@ function createWorktreeRunnerScript(
|
||||
worktreePath: string,
|
||||
script: string,
|
||||
runnerBaseName: 'setup-runner' | 'issue-command-runner',
|
||||
runtimeTarget?: HookRuntimeTarget
|
||||
runtimeTarget?: HookRuntimeTarget,
|
||||
waitForAgentStartup?: boolean
|
||||
): WorktreeSetupLaunch {
|
||||
const envVars = getSetupEnvVars(repo, worktreePath)
|
||||
// Why: WSL worktrees run on a Linux filesystem even though process.platform
|
||||
@@ -618,7 +621,11 @@ function createWorktreeRunnerScript(
|
||||
}
|
||||
}
|
||||
|
||||
return { runnerScriptPath, envVars }
|
||||
return {
|
||||
runnerScriptPath,
|
||||
envVars,
|
||||
...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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'
|
||||
@@ -986,9 +987,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
|
||||
|
||||
@@ -172,6 +172,7 @@ vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({
|
||||
}))
|
||||
import { LocalPtyProvider } from '../providers/local-pty-provider'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
|
||||
import {
|
||||
registerPtyHandlers,
|
||||
registerSshPtyProvider,
|
||||
@@ -796,6 +797,20 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('uses sequenced startup env as the MiMo launch hint when command is a wrapper', async () => {
|
||||
const env = await spawnAndGetEnv(
|
||||
{ [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'mimo --prompt hi' },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'bash -lc wait-wrapper'
|
||||
)
|
||||
|
||||
expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1)
|
||||
expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
|
||||
expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
|
||||
})
|
||||
|
||||
it('does not inject MiMo overlay for non-mimo launches', async () => {
|
||||
await spawnAndGetEnv()
|
||||
|
||||
@@ -897,6 +912,29 @@ describe('registerPtyHandlers', () => {
|
||||
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses sequenced startup env as the OMP launch hint when command is a wrapper', async () => {
|
||||
const env = await spawnAndGetEnv(
|
||||
{
|
||||
PI_CODING_AGENT_DIR: '/tmp/user-omp-agent',
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume'
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'powershell wait-wrapper'
|
||||
)
|
||||
|
||||
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'/tmp/user-omp-agent',
|
||||
'omp'
|
||||
)
|
||||
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
|
||||
'/tmp/user-omp-agent/extensions/orca-agent-status.ts'
|
||||
)
|
||||
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
|
||||
})
|
||||
|
||||
it('mirrors the original Pi source dir when launched from an Orca overlay shell', async () => {
|
||||
const env = await spawnAndGetEnv({
|
||||
PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
|
||||
@@ -1347,6 +1385,29 @@ describe('registerPtyHandlers', () => {
|
||||
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses sequenced startup env as the daemon OMP launch hint when command is a wrapper', async () => {
|
||||
const env = await daemonSpawnAndGetEnv(
|
||||
{
|
||||
PI_CODING_AGENT_DIR: '/user/.omp/agent',
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume'
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ command: 'powershell wait-wrapper' }
|
||||
)
|
||||
|
||||
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'/user/.omp/agent',
|
||||
'omp'
|
||||
)
|
||||
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
|
||||
'/user/.omp/agent/extensions/orca-agent-status.ts'
|
||||
)
|
||||
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
|
||||
})
|
||||
|
||||
it('injects the selected Codex home on the daemon path', async () => {
|
||||
const env = await daemonSpawnAndGetEnv({}, () => TEST_CODEX_HOME)
|
||||
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
|
||||
|
||||
+12
-3
@@ -86,6 +86,7 @@ import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
|
||||
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
|
||||
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
|
||||
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
|
||||
import { resolveSetupAgentSequenceLaunchCommand } from '../../shared/setup-agent-sequencing'
|
||||
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import {
|
||||
assertFolderWorkspacePathUsable,
|
||||
@@ -751,9 +752,10 @@ export function buildPtyHostEnv(
|
||||
// in lock-step across spawn paths without pushing process.env onto the
|
||||
// IPC wire unnecessarily.
|
||||
const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv)
|
||||
const piAgentKind = detectPiAgentKindFromCommand(opts.launchCommand)
|
||||
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand)
|
||||
const piAgentKind = detectPiAgentKindFromCommand(launchCommandHint)
|
||||
const hasLaunchCommand =
|
||||
typeof opts.launchCommand === 'string' && opts.launchCommand.trim().length > 0
|
||||
typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0
|
||||
const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand
|
||||
// Why: source shadows are agent-scoped. Trusting the other kind's source
|
||||
// would reintroduce the exact Pi/OMP extension-state shadowing this PR fixes.
|
||||
@@ -783,7 +785,7 @@ export function buildPtyHostEnv(
|
||||
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
|
||||
}
|
||||
}
|
||||
if (isMimoLaunchCommand(opts.launchCommand)) {
|
||||
if (isMimoLaunchCommand(launchCommandHint)) {
|
||||
const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv)
|
||||
Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome))
|
||||
if (baseEnv.MIMOCODE_HOME) {
|
||||
@@ -1880,6 +1882,9 @@ export function registerPtyHandlers(
|
||||
if (args.command !== undefined) {
|
||||
spawnOptions.command = args.command
|
||||
}
|
||||
if (args.commandDelivery !== undefined) {
|
||||
spawnOptions.commandDelivery = args.commandDelivery
|
||||
}
|
||||
if (args.startupCommandDelivery !== undefined) {
|
||||
spawnOptions.startupCommandDelivery = args.startupCommandDelivery
|
||||
}
|
||||
@@ -2256,6 +2261,7 @@ export function registerPtyHandlers(
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
command?: string
|
||||
commandDelivery?: 'renderer' | 'provider'
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchAgent?: TuiAgent
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
@@ -2557,6 +2563,9 @@ export function registerPtyHandlers(
|
||||
if (args.command !== undefined) {
|
||||
spawnOptions.command = args.command
|
||||
}
|
||||
if (args.commandDelivery !== undefined) {
|
||||
spawnOptions.commandDelivery = args.commandDelivery
|
||||
}
|
||||
if (args.startupCommandDelivery !== undefined) {
|
||||
spawnOptions.startupCommandDelivery = args.startupCommandDelivery
|
||||
}
|
||||
|
||||
+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,213 @@
|
||||
import type { IGitProvider } from '../providers/types'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import type { Repo, Worktree } from '../../shared/types'
|
||||
import {
|
||||
applyWorkspaceCleanupPolicy,
|
||||
createWorkspaceCleanupFingerprint,
|
||||
getWorkspaceCleanupInactivityReasons,
|
||||
isWorkspaceOldForCleanup,
|
||||
type WorkspaceCleanupBlocker,
|
||||
type WorkspaceCleanupCandidate,
|
||||
type WorkspaceCleanupReason
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import {
|
||||
createEmptyWorkspaceCleanupGitEvidence,
|
||||
readWorkspaceCleanupGitEvidence
|
||||
} from './workspace-cleanup-git-evidence'
|
||||
import { appendWorkspaceCleanupItems } from './workspace-cleanup-scan-primitives'
|
||||
|
||||
export async function buildWorkspaceCleanupCandidate(args: {
|
||||
repo: Repo
|
||||
worktree: Worktree
|
||||
scannedAt: number
|
||||
provider: IGitProvider | null
|
||||
skipGit: boolean
|
||||
forceGitCheck: boolean
|
||||
}): Promise<WorkspaceCleanupCandidate> {
|
||||
const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck } = args
|
||||
const blockers: WorkspaceCleanupBlocker[] = []
|
||||
const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(worktree, scannedAt)
|
||||
const repoIsFolder = isFolderRepo(repo)
|
||||
|
||||
if (worktree.isMainWorktree) {
|
||||
blockers.push('main-worktree')
|
||||
}
|
||||
if (repoIsFolder) {
|
||||
blockers.push('folder-repo')
|
||||
}
|
||||
if (worktree.isPinned) {
|
||||
blockers.push('pinned')
|
||||
}
|
||||
|
||||
const localContext = buildWorkspaceCleanupLocalContext(worktree)
|
||||
const shouldReadGit = shouldReadWorkspaceCleanupGitEvidence({
|
||||
repoIsFolder,
|
||||
blockers,
|
||||
worktree,
|
||||
skipGit,
|
||||
forceGitCheck
|
||||
})
|
||||
|
||||
const gitEvidence = !shouldReadGit
|
||||
? createEmptyWorkspaceCleanupGitEvidence()
|
||||
: await readWorkspaceCleanupGitEvidence(worktree, repo, provider)
|
||||
appendWorkspaceCleanupItems(blockers, gitEvidence.blockers)
|
||||
|
||||
const candidateWithoutFingerprint: WorkspaceCleanupCandidate = {
|
||||
worktreeId: worktree.id,
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
displayName: worktree.displayName,
|
||||
branch: shortWorkspaceCleanupBranchName(worktree.branch),
|
||||
path: worktree.path,
|
||||
tier: 'review',
|
||||
selectedByDefault: false,
|
||||
reasons,
|
||||
blockers: uniqueWorkspaceCleanupBlockers(blockers),
|
||||
lastActivityAt: worktree.lastActivityAt,
|
||||
...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}),
|
||||
localContext,
|
||||
git: {
|
||||
clean: gitEvidence.clean,
|
||||
upstreamAhead: gitEvidence.upstreamAhead,
|
||||
upstreamBehind: gitEvidence.upstreamBehind,
|
||||
checkedAt: gitEvidence.checkedAt
|
||||
},
|
||||
fingerprint: ''
|
||||
}
|
||||
|
||||
const fingerprint = createWorkspaceCleanupFingerprint({
|
||||
branch: candidateWithoutFingerprint.branch,
|
||||
head: worktree.head,
|
||||
gitClean: gitEvidence.clean,
|
||||
lastActivityAt: worktree.lastActivityAt
|
||||
})
|
||||
|
||||
return applyWorkspaceCleanupPolicy({
|
||||
...candidateWithoutFingerprint,
|
||||
reasons: uniqueWorkspaceCleanupReasons(reasons),
|
||||
blockers: uniqueWorkspaceCleanupBlockers(blockers),
|
||||
fingerprint
|
||||
})
|
||||
}
|
||||
|
||||
export function buildWorkspaceCleanupCandidateFromError(
|
||||
repo: Repo,
|
||||
worktree: Worktree,
|
||||
scannedAt: number
|
||||
): WorkspaceCleanupCandidate {
|
||||
return applyWorkspaceCleanupPolicy({
|
||||
worktreeId: worktree.id,
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
displayName: worktree.displayName,
|
||||
branch: shortWorkspaceCleanupBranchName(worktree.branch),
|
||||
path: worktree.path,
|
||||
tier: 'protected',
|
||||
selectedByDefault: false,
|
||||
reasons: getWorkspaceCleanupInactivityReasonsForWorkspace(worktree, scannedAt),
|
||||
blockers: ['git-status-error'],
|
||||
lastActivityAt: worktree.lastActivityAt,
|
||||
...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}),
|
||||
localContext: buildWorkspaceCleanupLocalContext(worktree),
|
||||
git: {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: scannedAt
|
||||
},
|
||||
fingerprint: createWorkspaceCleanupFingerprint({
|
||||
branch: shortWorkspaceCleanupBranchName(worktree.branch),
|
||||
head: worktree.head,
|
||||
gitClean: null,
|
||||
lastActivityAt: worktree.lastActivityAt
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function buildWorkspaceCleanupLocalContext(
|
||||
worktree: Pick<Worktree, 'diffComments'>
|
||||
): WorkspaceCleanupCandidate['localContext'] {
|
||||
return {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: worktree.diffComments?.length ?? 0,
|
||||
newestDiffCommentAt: getNewestWorkspaceCleanupDiffCommentAt(worktree.diffComments),
|
||||
retainedDoneAgentCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
export function getNewestWorkspaceCleanupDiffCommentAt(
|
||||
diffComments: Worktree['diffComments'] | undefined
|
||||
): number | null {
|
||||
if (!diffComments || diffComments.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Why: persisted diff notes can grow large enough for spread-based Math.max
|
||||
// to exceed the JavaScript argument limit during cleanup scans.
|
||||
let newest = diffComments[0]?.createdAt ?? null
|
||||
for (let index = 1; index < diffComments.length; index += 1) {
|
||||
const createdAt = diffComments[index]?.createdAt
|
||||
if (createdAt !== undefined && (newest === null || createdAt > newest)) {
|
||||
newest = createdAt
|
||||
}
|
||||
}
|
||||
return newest
|
||||
}
|
||||
|
||||
export function isWorkspaceInactiveForCleanup(
|
||||
workspace: Pick<Worktree, 'isArchived' | 'lastActivityAt'>,
|
||||
scannedAt: number
|
||||
): boolean {
|
||||
return isWorkspaceOldForCleanup(workspace, scannedAt)
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupInactivityReasonsForWorkspace(
|
||||
workspace: Pick<Worktree, 'isArchived' | 'lastActivityAt'>,
|
||||
scannedAt: number
|
||||
): WorkspaceCleanupReason[] {
|
||||
return getWorkspaceCleanupInactivityReasons(workspace, scannedAt)
|
||||
}
|
||||
|
||||
export function shortWorkspaceCleanupBranchName(branch: string): string {
|
||||
return branch.replace(/^refs\/heads\//, '') || 'HEAD'
|
||||
}
|
||||
|
||||
function shouldReadWorkspaceCleanupGitEvidence(args: {
|
||||
repoIsFolder: boolean
|
||||
blockers: WorkspaceCleanupBlocker[]
|
||||
worktree: Worktree
|
||||
skipGit: boolean
|
||||
forceGitCheck: boolean
|
||||
}): boolean {
|
||||
const { repoIsFolder, blockers, worktree, skipGit, forceGitCheck } = args
|
||||
if ((skipGit && !forceGitCheck) || repoIsFolder || worktree.isMainWorktree) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
blockers.includes('pinned') ||
|
||||
blockers.includes('main-worktree') ||
|
||||
blockers.includes('folder-repo')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: inactivity is the only recommendation signal now. Git is read only
|
||||
// to keep the destructive path from deleting dirty or local-only branch work.
|
||||
return true
|
||||
}
|
||||
|
||||
function uniqueWorkspaceCleanupBlockers(
|
||||
blockers: WorkspaceCleanupBlocker[]
|
||||
): WorkspaceCleanupBlocker[] {
|
||||
return [...new Set(blockers)]
|
||||
}
|
||||
|
||||
function uniqueWorkspaceCleanupReasons(
|
||||
reasons: WorkspaceCleanupReason[]
|
||||
): WorkspaceCleanupReason[] {
|
||||
return [...new Set(reasons)]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { basename } from 'node:path'
|
||||
import type { Store } from '../persistence'
|
||||
import type { Repo, WorktreeMeta } from '../../shared/types'
|
||||
import {
|
||||
applyWorkspaceCleanupPolicy,
|
||||
createWorkspaceCleanupFingerprint,
|
||||
type WorkspaceCleanupCandidate
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import {
|
||||
getNewestWorkspaceCleanupDiffCommentAt,
|
||||
getWorkspaceCleanupInactivityReasonsForWorkspace,
|
||||
isWorkspaceInactiveForCleanup
|
||||
} from './workspace-cleanup-candidate'
|
||||
|
||||
export function synthesizeDisconnectedSshCleanupCandidates(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
scannedAt: number,
|
||||
targetWorktreeId?: string
|
||||
): WorkspaceCleanupCandidate[] {
|
||||
const repoWorktreePrefix = `${repo.id}::`
|
||||
if (targetWorktreeId) {
|
||||
if (!targetWorktreeId.startsWith(repoWorktreePrefix)) {
|
||||
return []
|
||||
}
|
||||
// Why: focused delete preflight names one workspace already; walking all
|
||||
// persisted metadata is unnecessary for disconnected SSH repos.
|
||||
const meta = store.getWorktreeMeta(targetWorktreeId)
|
||||
return meta ? [createDisconnectedSshCandidate(repo, scannedAt, targetWorktreeId, meta)] : []
|
||||
}
|
||||
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
const allMeta = store.getAllWorktreeMeta()
|
||||
for (const worktreeId in allMeta) {
|
||||
if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) {
|
||||
continue
|
||||
}
|
||||
const meta = allMeta[worktreeId]
|
||||
if (!meta || !isWorkspaceInactiveForCleanup(meta, scannedAt)) {
|
||||
continue
|
||||
}
|
||||
candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta))
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function createDisconnectedSshCandidate(
|
||||
repo: Repo,
|
||||
scannedAt: number,
|
||||
worktreeId: string,
|
||||
meta: WorktreeMeta
|
||||
): WorkspaceCleanupCandidate {
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
const path = parsed?.worktreePath ?? worktreeId
|
||||
const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(meta, scannedAt)
|
||||
return applyWorkspaceCleanupPolicy({
|
||||
worktreeId,
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
displayName: meta.displayName || basename(path),
|
||||
branch: basename(path),
|
||||
path,
|
||||
tier: 'protected',
|
||||
selectedByDefault: false,
|
||||
reasons,
|
||||
blockers: ['ssh-disconnected'],
|
||||
lastActivityAt: meta.lastActivityAt,
|
||||
...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
|
||||
localContext: {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: meta.diffComments?.length ?? 0,
|
||||
newestDiffCommentAt: getNewestWorkspaceCleanupDiffCommentAt(meta.diffComments),
|
||||
retainedDoneAgentCount: 0
|
||||
},
|
||||
git: {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: null
|
||||
},
|
||||
fingerprint: createWorkspaceCleanupFingerprint({
|
||||
branch: basename(path),
|
||||
head: '',
|
||||
gitClean: null,
|
||||
lastActivityAt: meta.lastActivityAt
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { getStatus } from '../git/status'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import type { IGitProvider } from '../providers/types'
|
||||
import type { GitStatusResult, Repo, Worktree } from '../../shared/types'
|
||||
import type { WorkspaceCleanupBlocker } from '../../shared/workspace-cleanup'
|
||||
import {
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
withWorkspaceCleanupTimeout
|
||||
} from './workspace-cleanup-scan-primitives'
|
||||
|
||||
export type WorkspaceCleanupGitEvidence = {
|
||||
clean: boolean | null
|
||||
upstreamAhead: number | null
|
||||
upstreamBehind: number | null
|
||||
checkedAt: number | null
|
||||
blockers: WorkspaceCleanupBlocker[]
|
||||
}
|
||||
|
||||
export function createEmptyWorkspaceCleanupGitEvidence(): WorkspaceCleanupGitEvidence {
|
||||
return {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: null,
|
||||
blockers: []
|
||||
}
|
||||
}
|
||||
|
||||
export async function readWorkspaceCleanupGitEvidence(
|
||||
worktree: Worktree,
|
||||
repo: Repo,
|
||||
provider: IGitProvider | null
|
||||
): Promise<WorkspaceCleanupGitEvidence> {
|
||||
const blockers: WorkspaceCleanupBlocker[] = []
|
||||
let status: GitStatusResult
|
||||
const checkedAt = Date.now()
|
||||
|
||||
try {
|
||||
status = await withWorkspaceCleanupTimeout(
|
||||
(signal) =>
|
||||
repo.connectionId
|
||||
? provider!.getStatus(worktree.path, { signal })
|
||||
: getStatus(worktree.path, { signal }),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out reading git status.'
|
||||
)
|
||||
} catch {
|
||||
return {
|
||||
...createEmptyWorkspaceCleanupGitEvidence(),
|
||||
blockers: ['git-status-error']
|
||||
}
|
||||
}
|
||||
|
||||
if (status.upstreamStatus === undefined) {
|
||||
return {
|
||||
...createEmptyWorkspaceCleanupGitEvidence(),
|
||||
blockers: ['git-status-error']
|
||||
}
|
||||
}
|
||||
|
||||
const clean = status.entries.length === 0
|
||||
if (!clean) {
|
||||
blockers.push('dirty-files')
|
||||
}
|
||||
|
||||
const upstreamAhead = status.upstreamStatus.hasUpstream ? status.upstreamStatus.ahead : null
|
||||
const upstreamBehind = status.upstreamStatus.hasUpstream ? status.upstreamStatus.behind : null
|
||||
if (upstreamAhead !== null && upstreamAhead > 0) {
|
||||
blockers.push('unpushed-commits')
|
||||
}
|
||||
if (clean && upstreamAhead === null) {
|
||||
const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider)
|
||||
if (unpushedCommitCount === null) {
|
||||
blockers.push('unknown-base')
|
||||
} else if (unpushedCommitCount > 0) {
|
||||
blockers.push('unpushed-commits')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clean,
|
||||
upstreamAhead,
|
||||
upstreamBehind,
|
||||
checkedAt,
|
||||
blockers: uniqueWorkspaceCleanupGitBlockers(blockers)
|
||||
}
|
||||
}
|
||||
|
||||
async function readUnpushedCommitCount(
|
||||
worktree: Worktree,
|
||||
repo: Repo,
|
||||
provider: IGitProvider | null
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const result = await withWorkspaceCleanupTimeout(
|
||||
(signal) =>
|
||||
repo.connectionId
|
||||
? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path, {
|
||||
signal
|
||||
})
|
||||
: gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], {
|
||||
cwd: worktree.path,
|
||||
signal
|
||||
}),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out checking unpushed commits.'
|
||||
)
|
||||
const count = Number.parseInt(result.stdout.trim(), 10)
|
||||
return Number.isFinite(count) ? count : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueWorkspaceCleanupGitBlockers(
|
||||
blockers: WorkspaceCleanupBlocker[]
|
||||
): WorkspaceCleanupBlocker[] {
|
||||
return [...new Set(blockers)]
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { basename } from 'node:path'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import type { WorkspaceCleanupScanError } from '../../shared/workspace-cleanup'
|
||||
|
||||
export const WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS = 8_000
|
||||
|
||||
export function appendWorkspaceCleanupItems<T>(target: T[], entries: readonly T[]): void {
|
||||
// Why: cleanup can aggregate generated-size worktree batches; spreading
|
||||
// those batches into push can exceed JavaScript's argument limit.
|
||||
for (const entry of entries) {
|
||||
target.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
export async function mapWorkspaceCleanupWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = []
|
||||
let nextIndex = 0
|
||||
const workerCount = Math.min(limit, items.length)
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
results[index] = await fn(items[index])
|
||||
}
|
||||
})
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
export async function withWorkspaceCleanupTimeout<T>(
|
||||
run: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string
|
||||
): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
let timeoutId: NodeJS.Timeout | undefined
|
||||
const timeoutPromise = new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
controller.abort()
|
||||
reject(new Error(message))
|
||||
}, timeoutMs)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([run(controller.signal), timeoutPromise])
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function toWorkspaceCleanupErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
export function createWorkspaceCleanupScanError(
|
||||
repo: Repo,
|
||||
message: string
|
||||
): WorkspaceCleanupScanError {
|
||||
return {
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName || basename(repo.path),
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
// Why: git errors often include absolute paths or command output. Keep the
|
||||
// cause useful without leaking raw local/remote filesystem details to the UI.
|
||||
export function toSafeWorkspaceCleanupRepoScanError(error: unknown): string {
|
||||
const message = toWorkspaceCleanupErrorMessage(error)
|
||||
if (message === 'Timed out listing SSH worktrees.') {
|
||||
return 'Timed out listing remote worktrees.'
|
||||
}
|
||||
if (message === 'Timed out listing worktrees.') {
|
||||
return 'Timed out listing worktrees.'
|
||||
}
|
||||
if (message.startsWith('Timed out ')) {
|
||||
return message.replace(/\.$/, '')
|
||||
}
|
||||
|
||||
const lower = message.toLowerCase()
|
||||
if (lower.includes('not a git repository') || lower.includes('not a git worktree')) {
|
||||
return 'Repository is not a git checkout.'
|
||||
}
|
||||
if (
|
||||
lower.includes('enoent') ||
|
||||
lower.includes('no such file') ||
|
||||
lower.includes('cannot find') ||
|
||||
lower.includes('does not exist')
|
||||
) {
|
||||
return 'Repository folder was not found.'
|
||||
}
|
||||
if (lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied')) {
|
||||
return 'Repository folder is not accessible.'
|
||||
}
|
||||
return 'Git could not list worktrees.'
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { Store } from '../persistence'
|
||||
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import type { IGitProvider } from '../providers/types'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import type { GitWorktreeInfo, Repo } from '../../shared/types'
|
||||
import { mergeWorktree } from './worktree-logic'
|
||||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import type {
|
||||
WorkspaceCleanupCandidate,
|
||||
WorkspaceCleanupScanError,
|
||||
WorkspaceCleanupScanArgs,
|
||||
WorkspaceCleanupScanProgress,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import {
|
||||
buildWorkspaceCleanupCandidate,
|
||||
buildWorkspaceCleanupCandidateFromError,
|
||||
isWorkspaceInactiveForCleanup
|
||||
} from './workspace-cleanup-candidate'
|
||||
import { synthesizeDisconnectedSshCleanupCandidates } from './workspace-cleanup-disconnected-ssh'
|
||||
import {
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
appendWorkspaceCleanupItems,
|
||||
createWorkspaceCleanupScanError,
|
||||
mapWorkspaceCleanupWithConcurrency,
|
||||
toSafeWorkspaceCleanupRepoScanError,
|
||||
withWorkspaceCleanupTimeout
|
||||
} from './workspace-cleanup-scan-primitives'
|
||||
|
||||
const WORKTREE_SCAN_CONCURRENCY = 3
|
||||
|
||||
type WorkspaceCleanupScanOptions = {
|
||||
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
|
||||
}
|
||||
|
||||
type WorkspaceCleanupScanRepoProgress = {
|
||||
onWorktreesDiscovered?: (count: number) => void
|
||||
onCandidateScanned?: (candidate: WorkspaceCleanupCandidate) => void
|
||||
onErrors?: (errors: WorkspaceCleanupScanError[]) => void
|
||||
}
|
||||
|
||||
type WorkspaceCleanupProgressEmitter = {
|
||||
addDiscovered: (count: number) => void
|
||||
addCandidate: (candidate: WorkspaceCleanupCandidate) => void
|
||||
addErrors: (errors: WorkspaceCleanupScanError[]) => void
|
||||
}
|
||||
|
||||
export async function scanWorkspaceCleanup(
|
||||
store: Store,
|
||||
args: WorkspaceCleanupScanArgs = {},
|
||||
options: WorkspaceCleanupScanOptions = {}
|
||||
): Promise<WorkspaceCleanupScanResult> {
|
||||
const scannedAt = Date.now()
|
||||
const parsedTarget = args.worktreeId ? splitWorktreeId(args.worktreeId) : null
|
||||
if (args.worktreeId && !parsedTarget) {
|
||||
return { scannedAt, candidates: [], errors: [] }
|
||||
}
|
||||
const repos = parsedTarget
|
||||
? store.getRepos().filter((repo) => repo.id === parsedTarget.repoId)
|
||||
: store.getRepos()
|
||||
const progress = createProgressEmitter(args.scanId, scannedAt, options)
|
||||
const errors: WorkspaceCleanupScanResult['errors'] = []
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
|
||||
for (const repo of repos) {
|
||||
const result = await scanRepoWorkspaces({
|
||||
store,
|
||||
repo,
|
||||
scannedAt,
|
||||
targetWorktreeId: args.worktreeId,
|
||||
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []),
|
||||
onWorktreesDiscovered: progress.addDiscovered,
|
||||
onCandidateScanned: progress.addCandidate,
|
||||
onErrors: progress.addErrors
|
||||
})
|
||||
appendWorkspaceCleanupItems(candidates, result.candidates)
|
||||
appendWorkspaceCleanupItems(errors, result.errors)
|
||||
}
|
||||
|
||||
return { scannedAt, candidates, errors }
|
||||
}
|
||||
|
||||
async function scanRepoWorkspaces(
|
||||
args: {
|
||||
store: Store
|
||||
repo: Repo
|
||||
scannedAt: number
|
||||
targetWorktreeId?: string
|
||||
skipGitWorktreeIds: Set<string>
|
||||
} & WorkspaceCleanupScanRepoProgress
|
||||
): Promise<WorkspaceCleanupScanResult> {
|
||||
const {
|
||||
store,
|
||||
repo,
|
||||
scannedAt,
|
||||
targetWorktreeId,
|
||||
skipGitWorktreeIds,
|
||||
onWorktreesDiscovered,
|
||||
onCandidateScanned,
|
||||
onErrors
|
||||
} = args
|
||||
const errors: WorkspaceCleanupScanResult['errors'] = []
|
||||
const repoIsFolder = isFolderRepo(repo)
|
||||
let provider: IGitProvider | null = null
|
||||
let gitWorktrees: GitWorktreeInfo[] = []
|
||||
|
||||
try {
|
||||
const discovered = await listCleanupGitWorktrees(repo, repoIsFolder)
|
||||
provider = discovered.provider
|
||||
gitWorktrees = discovered.gitWorktrees
|
||||
} catch (error) {
|
||||
return handleRepoWorktreeListError({ repo, targetWorktreeId, scannedAt, error, onErrors })
|
||||
}
|
||||
|
||||
if (repo.connectionId && !provider) {
|
||||
const candidates = targetWorktreeId
|
||||
? synthesizeDisconnectedSshCleanupCandidates(store, repo, scannedAt, targetWorktreeId)
|
||||
: []
|
||||
onWorktreesDiscovered?.(candidates.length)
|
||||
for (const candidate of candidates) {
|
||||
onCandidateScanned?.(candidate)
|
||||
}
|
||||
return { scannedAt, candidates, errors: [] }
|
||||
}
|
||||
|
||||
const worktrees = gitWorktrees
|
||||
.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
})
|
||||
.filter((worktree) => {
|
||||
if (targetWorktreeId) {
|
||||
return worktree.id === targetWorktreeId
|
||||
}
|
||||
return (
|
||||
!repoIsFolder &&
|
||||
!worktree.isMainWorktree &&
|
||||
isWorkspaceInactiveForCleanup(worktree, scannedAt)
|
||||
)
|
||||
})
|
||||
|
||||
onWorktreesDiscovered?.(worktrees.length)
|
||||
|
||||
const candidates = await mapWorkspaceCleanupWithConcurrency(
|
||||
worktrees,
|
||||
WORKTREE_SCAN_CONCURRENCY,
|
||||
async (worktree) => {
|
||||
const candidate = await buildWorkspaceCleanupCandidate({
|
||||
repo,
|
||||
worktree,
|
||||
scannedAt,
|
||||
provider,
|
||||
skipGit: skipGitWorktreeIds.has(worktree.id),
|
||||
forceGitCheck: Boolean(targetWorktreeId)
|
||||
}).catch((error) => {
|
||||
console.error('Workspace cleanup candidate scan failed', error)
|
||||
return buildWorkspaceCleanupCandidateFromError(repo, worktree, scannedAt)
|
||||
})
|
||||
onCandidateScanned?.(candidate)
|
||||
return candidate
|
||||
}
|
||||
)
|
||||
|
||||
return { scannedAt, candidates, errors }
|
||||
}
|
||||
|
||||
async function listCleanupGitWorktrees(
|
||||
repo: Repo,
|
||||
repoIsFolder: boolean
|
||||
): Promise<{ provider: IGitProvider | null; gitWorktrees: GitWorktreeInfo[] }> {
|
||||
if (repoIsFolder) {
|
||||
return { provider: null, gitWorktrees: [createFolderWorktree(repo)] }
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId) ?? null
|
||||
if (!provider) {
|
||||
// Why: cleanup should reflect only workspaces Orca can currently inspect.
|
||||
return { provider: null, gitWorktrees: [] }
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
gitWorktrees: await withWorkspaceCleanupTimeout(
|
||||
(signal) => provider.listWorktrees(repo.path, { signal }),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out listing SSH worktrees.'
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: null,
|
||||
gitWorktrees: await withWorkspaceCleanupTimeout(
|
||||
(signal) => listRepoWorktrees(repo, { signal }),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out listing worktrees.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRepoWorktreeListError(args: {
|
||||
repo: Repo
|
||||
targetWorktreeId?: string
|
||||
scannedAt: number
|
||||
error: unknown
|
||||
onErrors?: (errors: WorkspaceCleanupScanError[]) => void
|
||||
}): WorkspaceCleanupScanResult {
|
||||
const { repo, targetWorktreeId, scannedAt, error, onErrors } = args
|
||||
console.error('Workspace cleanup repo scan failed', error)
|
||||
if (repo.connectionId && !targetWorktreeId) {
|
||||
// Why: broad cleanup only shows remote workspaces Orca can inspect now.
|
||||
// A connected SSH repo that fails mid-scan is omitted, not bannered.
|
||||
return { scannedAt, candidates: [], errors: [] }
|
||||
}
|
||||
const errors = [createWorkspaceCleanupScanError(repo, toSafeWorkspaceCleanupRepoScanError(error))]
|
||||
onErrors?.(errors)
|
||||
return { scannedAt, candidates: [], errors }
|
||||
}
|
||||
|
||||
function createProgressEmitter(
|
||||
scanId: string | undefined,
|
||||
scannedAt: number,
|
||||
options: WorkspaceCleanupScanOptions
|
||||
): WorkspaceCleanupProgressEmitter {
|
||||
const errors: WorkspaceCleanupScanError[] = []
|
||||
let totalWorktreeCount = 0
|
||||
let scannedWorktreeCount = 0
|
||||
const emit = (
|
||||
candidates: WorkspaceCleanupCandidate[],
|
||||
candidateMode: WorkspaceCleanupScanProgress['candidateMode'] = 'snapshot'
|
||||
): void => {
|
||||
if (!scanId) {
|
||||
return
|
||||
}
|
||||
options.onProgress?.({
|
||||
scanId,
|
||||
scannedAt,
|
||||
totalWorktreeCount,
|
||||
scannedWorktreeCount,
|
||||
candidates,
|
||||
errors: [...errors],
|
||||
candidateMode
|
||||
})
|
||||
}
|
||||
return {
|
||||
addDiscovered: (count) => {
|
||||
totalWorktreeCount += count
|
||||
emit([], 'append')
|
||||
},
|
||||
addCandidate: (candidate) => {
|
||||
scannedWorktreeCount += 1
|
||||
emit([candidate], 'append')
|
||||
},
|
||||
addErrors: (newErrors) => {
|
||||
appendWorkspaceCleanupItems(errors, newErrors)
|
||||
emit([], 'append')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
Repo,
|
||||
WorktreeMeta
|
||||
} from '../../shared/types'
|
||||
import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup'
|
||||
|
||||
const {
|
||||
listRepoWorktreesMock,
|
||||
@@ -171,6 +172,58 @@ describe('workspace cleanup scan', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports cleanup scan progress as inactive workspaces are checked', async () => {
|
||||
listRepoWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/repo-feature-a',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature-a',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
{
|
||||
path: '/repo-feature-b',
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/feature-b',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
const progress: unknown[] = []
|
||||
|
||||
const result = await scanWorkspaceCleanup(
|
||||
makeStore(),
|
||||
{ scanId: 'scan-1' },
|
||||
{ onProgress: (event) => progress.push(event) }
|
||||
)
|
||||
|
||||
expect(result.candidates).toHaveLength(2)
|
||||
expect(progress[0]).toMatchObject({
|
||||
scanId: 'scan-1',
|
||||
scannedWorktreeCount: 0,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: []
|
||||
})
|
||||
const candidateProgress = progress.filter(
|
||||
(event): event is WorkspaceCleanupScanProgress =>
|
||||
(event as WorkspaceCleanupScanProgress).candidateMode === 'append' &&
|
||||
(event as WorkspaceCleanupScanProgress).candidates.length > 0
|
||||
)
|
||||
expect(candidateProgress).toHaveLength(2)
|
||||
expect(candidateProgress.every((event) => event.candidates.length === 1)).toBe(true)
|
||||
const progressWorktreeIds = candidateProgress.flatMap((event) =>
|
||||
event.candidates.map((candidate) => candidate.worktreeId)
|
||||
)
|
||||
expect(progressWorktreeIds).toEqual(
|
||||
expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b'])
|
||||
)
|
||||
expect(progress.at(-1)).toMatchObject({
|
||||
scanId: 'scan-1',
|
||||
scannedWorktreeCount: 2,
|
||||
totalWorktreeCount: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps raw scan errors out of renderer-facing results', async () => {
|
||||
listRepoWorktreesMock.mockRejectedValue(new Error('fatal: path /Users/alice/private failed'))
|
||||
|
||||
@@ -185,6 +238,30 @@ describe('workspace cleanup scan', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('aborts a hung worktree list when the cleanup timeout fires', async () => {
|
||||
let signal: AbortSignal | undefined
|
||||
listRepoWorktreesMock.mockImplementation((_repo: Repo, options?: { signal?: AbortSignal }) => {
|
||||
signal = options?.signal
|
||||
return new Promise<GitWorktreeInfo[]>(() => {})
|
||||
})
|
||||
|
||||
const scan = scanWorkspaceCleanup(makeStore())
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
|
||||
await expect(scan).resolves.toEqual({
|
||||
scannedAt: NOW,
|
||||
candidates: [],
|
||||
errors: [
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
repoName: 'Repo',
|
||||
message: 'Timed out listing worktrees.'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('skips disconnected remote workspaces without a scan warning', async () => {
|
||||
const result = await scanWorkspaceCleanup(
|
||||
makeStore({
|
||||
@@ -263,8 +340,12 @@ describe('workspace cleanup scan', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo')
|
||||
expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature')
|
||||
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.candidates[0]).toMatchObject({
|
||||
connectionId: 'ssh-1',
|
||||
@@ -287,7 +368,9 @@ describe('workspace cleanup scan', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo')
|
||||
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.candidates).toEqual([])
|
||||
})
|
||||
@@ -324,6 +407,52 @@ describe('workspace cleanup scan', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('only inspects the target repo during focused scans', async () => {
|
||||
const repoTwo = { ...REPO, id: 'repo-2', path: '/repo-two', displayName: 'Repo Two' }
|
||||
listRepoWorktreesMock.mockImplementation((repo: Repo) =>
|
||||
Promise.resolve([
|
||||
{
|
||||
path: `${repo.path}-feature`,
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = await scanWorkspaceCleanup(
|
||||
makeStore({
|
||||
repos: [REPO, repoTwo]
|
||||
}),
|
||||
{ worktreeId: 'repo-2::/repo-two-feature' }
|
||||
)
|
||||
|
||||
expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
expect(listRepoWorktreesMock).toHaveBeenCalledWith(repoTwo, {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(result.candidates).toHaveLength(1)
|
||||
expect(result.candidates[0]).toMatchObject({
|
||||
worktreeId: 'repo-2::/repo-two-feature',
|
||||
repoId: 'repo-2'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns no focused scan rows when the encoded repo id is unknown', async () => {
|
||||
const result = await scanWorkspaceCleanup(makeStore(), {
|
||||
worktreeId: 'missing-repo::/repo-feature'
|
||||
})
|
||||
|
||||
expect(listRepoWorktreesMock).not.toHaveBeenCalled()
|
||||
expect(getStatusMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({
|
||||
scannedAt: NOW,
|
||||
candidates: [],
|
||||
errors: []
|
||||
})
|
||||
})
|
||||
|
||||
it('honors renderer git deferrals without hiding the workspace', async () => {
|
||||
const result = await scanWorkspaceCleanup(makeStore(), {
|
||||
skipGitWorktreeIds: ['repo-1::/repo-feature']
|
||||
@@ -353,7 +482,7 @@ describe('workspace cleanup scan', () => {
|
||||
expect(getStatusMock).toHaveBeenCalledTimes(1)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['rev-list', '--count', 'HEAD', '--not', '--remotes'],
|
||||
{ cwd: '/repo-feature' }
|
||||
{ cwd: '/repo-feature', signal: expect.any(AbortSignal) }
|
||||
)
|
||||
expect(result.candidates[0]).toMatchObject({
|
||||
tier: 'ready',
|
||||
|
||||
@@ -1,52 +1,20 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { ipcMain } from 'electron'
|
||||
import { basename } from 'node:path'
|
||||
import type { Store } from '../persistence'
|
||||
import { getStatus } from '../git/status'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import type { IGitProvider, IPtyProvider } from '../providers/types'
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { listRegisteredPtys } from '../memory/pty-registry'
|
||||
import { getSshPtyProvider } from './pty'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import type {
|
||||
GitStatusResult,
|
||||
GitWorktreeInfo,
|
||||
Repo,
|
||||
Worktree,
|
||||
WorktreeMeta
|
||||
} from '../../shared/types'
|
||||
import { mergeWorktree } from './worktree-logic'
|
||||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import {
|
||||
WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
|
||||
applyWorkspaceCleanupPolicy,
|
||||
createWorkspaceCleanupFingerprint,
|
||||
getWorkspaceCleanupInactivityReasons,
|
||||
isWorkspaceOldForCleanup,
|
||||
type WorkspaceCleanupBlocker,
|
||||
type WorkspaceCleanupCandidate,
|
||||
type WorkspaceCleanupDismissArgs,
|
||||
type WorkspaceCleanupLocalProcessArgs,
|
||||
type WorkspaceCleanupLocalProcessResult,
|
||||
type WorkspaceCleanupReason,
|
||||
type WorkspaceCleanupScanError,
|
||||
type WorkspaceCleanupScanArgs,
|
||||
type WorkspaceCleanupScanResult
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import { scanWorkspaceCleanup } from './workspace-cleanup-scan'
|
||||
|
||||
const GIT_READ_TIMEOUT_MS = 8_000
|
||||
const WORKTREE_SCAN_CONCURRENCY = 3
|
||||
|
||||
type GitEvidence = {
|
||||
clean: boolean | null
|
||||
upstreamAhead: number | null
|
||||
upstreamBehind: number | null
|
||||
checkedAt: number | null
|
||||
blockers: WorkspaceCleanupBlocker[]
|
||||
}
|
||||
export { scanWorkspaceCleanup }
|
||||
|
||||
type WorkspaceCleanupHandlerDeps = {
|
||||
runtime?: OrcaRuntimeService
|
||||
@@ -64,8 +32,12 @@ export function registerWorkspaceCleanupHandlers(
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:scan',
|
||||
(_event, args?: WorkspaceCleanupScanArgs): Promise<WorkspaceCleanupScanResult> =>
|
||||
scanWorkspaceCleanup(store, args ?? {})
|
||||
(event, args?: WorkspaceCleanupScanArgs): Promise<WorkspaceCleanupScanResult> =>
|
||||
scanWorkspaceCleanup(store, args ?? {}, {
|
||||
onProgress: args?.scanId
|
||||
? (progress) => event.sender.send('workspaceCleanup:scanProgress', progress)
|
||||
: undefined
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => {
|
||||
@@ -187,557 +159,3 @@ function normalizeRemotePath(path: string): string {
|
||||
function isPathWithin(candidatePath: string, parentPath: string): boolean {
|
||||
return candidatePath === parentPath || candidatePath.startsWith(`${parentPath}/`)
|
||||
}
|
||||
|
||||
export async function scanWorkspaceCleanup(
|
||||
store: Store,
|
||||
args: WorkspaceCleanupScanArgs = {}
|
||||
): Promise<WorkspaceCleanupScanResult> {
|
||||
const scannedAt = Date.now()
|
||||
const repos = store.getRepos()
|
||||
const errors: WorkspaceCleanupScanResult['errors'] = []
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
|
||||
for (const repo of repos) {
|
||||
const result = await scanRepoWorkspaces({
|
||||
store,
|
||||
repo,
|
||||
scannedAt,
|
||||
targetWorktreeId: args.worktreeId,
|
||||
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? [])
|
||||
})
|
||||
appendListItems(candidates, result.candidates)
|
||||
appendListItems(errors, result.errors)
|
||||
}
|
||||
|
||||
return { scannedAt, candidates, errors }
|
||||
}
|
||||
|
||||
async function scanRepoWorkspaces(args: {
|
||||
store: Store
|
||||
repo: Repo
|
||||
scannedAt: number
|
||||
targetWorktreeId?: string
|
||||
skipGitWorktreeIds: Set<string>
|
||||
}): Promise<WorkspaceCleanupScanResult> {
|
||||
const { store, repo, scannedAt, targetWorktreeId, skipGitWorktreeIds } = args
|
||||
const errors: WorkspaceCleanupScanResult['errors'] = []
|
||||
let provider: IGitProvider | null = null
|
||||
let gitWorktrees: GitWorktreeInfo[] = []
|
||||
const repoIsFolder = isFolderRepo(repo)
|
||||
|
||||
try {
|
||||
if (repoIsFolder) {
|
||||
gitWorktrees = [createFolderWorktree(repo)]
|
||||
} else if (repo.connectionId) {
|
||||
provider = getSshGitProvider(repo.connectionId) ?? null
|
||||
if (!provider) {
|
||||
// Why: cleanup should reflect only workspaces Orca can currently
|
||||
// inspect. Disconnected SSH repos are skipped in broad scans.
|
||||
return {
|
||||
scannedAt,
|
||||
candidates: targetWorktreeId
|
||||
? synthesizeDisconnectedSshCandidates(store, repo, scannedAt, targetWorktreeId)
|
||||
: [],
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
gitWorktrees = await withTimeout(
|
||||
provider.listWorktrees(repo.path),
|
||||
GIT_READ_TIMEOUT_MS,
|
||||
'Timed out listing SSH worktrees.'
|
||||
)
|
||||
} else {
|
||||
gitWorktrees = await withTimeout(
|
||||
listRepoWorktrees(repo),
|
||||
GIT_READ_TIMEOUT_MS,
|
||||
'Timed out listing worktrees.'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Workspace cleanup repo scan failed', error)
|
||||
if (repo.connectionId && !targetWorktreeId) {
|
||||
// Why: broad cleanup only shows remote workspaces Orca can inspect now.
|
||||
// A connected SSH repo that fails mid-scan is omitted, not bannered.
|
||||
return { scannedAt, candidates: [], errors: [] }
|
||||
}
|
||||
errors.push(createScanError(repo, toSafeRepoScanError(error)))
|
||||
return { scannedAt, candidates: [], errors }
|
||||
}
|
||||
|
||||
const worktrees = gitWorktrees
|
||||
.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
})
|
||||
.filter((worktree) => {
|
||||
if (targetWorktreeId) {
|
||||
return worktree.id === targetWorktreeId
|
||||
}
|
||||
return (
|
||||
!repoIsFolder &&
|
||||
!worktree.isMainWorktree &&
|
||||
isWorkspaceInactiveForCleanup(worktree, scannedAt)
|
||||
)
|
||||
})
|
||||
|
||||
const candidates = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, (worktree) =>
|
||||
buildCandidate({
|
||||
repo,
|
||||
worktree,
|
||||
scannedAt,
|
||||
provider,
|
||||
skipGit: skipGitWorktreeIds.has(worktree.id),
|
||||
forceGitCheck: Boolean(targetWorktreeId)
|
||||
}).catch((error) => {
|
||||
console.error('Workspace cleanup candidate scan failed', error)
|
||||
return buildCandidateFromError(repo, worktree, scannedAt, toErrorMessage(error))
|
||||
})
|
||||
)
|
||||
|
||||
return { scannedAt, candidates, errors }
|
||||
}
|
||||
|
||||
async function buildCandidate(args: {
|
||||
repo: Repo
|
||||
worktree: Worktree
|
||||
scannedAt: number
|
||||
provider: IGitProvider | null
|
||||
skipGit: boolean
|
||||
forceGitCheck: boolean
|
||||
}): Promise<WorkspaceCleanupCandidate> {
|
||||
const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck } = args
|
||||
const blockers: WorkspaceCleanupBlocker[] = []
|
||||
const reasons = getInactivityReasons(worktree, scannedAt)
|
||||
const repoIsFolder = isFolderRepo(repo)
|
||||
|
||||
if (worktree.isMainWorktree) {
|
||||
blockers.push('main-worktree')
|
||||
}
|
||||
if (repoIsFolder) {
|
||||
blockers.push('folder-repo')
|
||||
}
|
||||
if (worktree.isPinned) {
|
||||
blockers.push('pinned')
|
||||
}
|
||||
|
||||
const localContext = buildLocalContext(worktree)
|
||||
const shouldReadGit = shouldReadGitEvidence({
|
||||
repoIsFolder,
|
||||
blockers,
|
||||
worktree,
|
||||
skipGit,
|
||||
forceGitCheck
|
||||
})
|
||||
|
||||
const gitEvidence = !shouldReadGit
|
||||
? createEmptyGitEvidence()
|
||||
: await readGitEvidence(worktree, repo, provider)
|
||||
appendListItems(blockers, gitEvidence.blockers)
|
||||
|
||||
const candidateWithoutFingerprint: WorkspaceCleanupCandidate = {
|
||||
worktreeId: worktree.id,
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
displayName: worktree.displayName,
|
||||
branch: shortBranchName(worktree.branch),
|
||||
path: worktree.path,
|
||||
tier: 'review',
|
||||
selectedByDefault: false,
|
||||
reasons,
|
||||
blockers: uniqueBlockers(blockers),
|
||||
lastActivityAt: worktree.lastActivityAt,
|
||||
...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}),
|
||||
localContext,
|
||||
git: {
|
||||
clean: gitEvidence.clean,
|
||||
upstreamAhead: gitEvidence.upstreamAhead,
|
||||
upstreamBehind: gitEvidence.upstreamBehind,
|
||||
checkedAt: gitEvidence.checkedAt
|
||||
},
|
||||
fingerprint: ''
|
||||
}
|
||||
|
||||
const fingerprint = createWorkspaceCleanupFingerprint({
|
||||
branch: candidateWithoutFingerprint.branch,
|
||||
head: worktree.head,
|
||||
gitClean: gitEvidence.clean,
|
||||
lastActivityAt: worktree.lastActivityAt
|
||||
})
|
||||
|
||||
return applyWorkspaceCleanupPolicy({
|
||||
...candidateWithoutFingerprint,
|
||||
reasons: uniqueReasons(reasons),
|
||||
blockers: uniqueBlockers(blockers),
|
||||
fingerprint
|
||||
})
|
||||
}
|
||||
|
||||
function shouldReadGitEvidence(args: {
|
||||
repoIsFolder: boolean
|
||||
blockers: WorkspaceCleanupBlocker[]
|
||||
worktree: Worktree
|
||||
skipGit: boolean
|
||||
forceGitCheck: boolean
|
||||
}): boolean {
|
||||
const { repoIsFolder, blockers, worktree, skipGit, forceGitCheck } = args
|
||||
if ((skipGit && !forceGitCheck) || repoIsFolder || worktree.isMainWorktree) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
blockers.includes('pinned') ||
|
||||
blockers.includes('main-worktree') ||
|
||||
blockers.includes('folder-repo')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: inactivity is the only recommendation signal now. Git is read only
|
||||
// to keep the destructive path from deleting dirty or local-only branch work.
|
||||
return true
|
||||
}
|
||||
|
||||
function appendListItems<T>(target: T[], entries: readonly T[]): void {
|
||||
// Why: cleanup can aggregate generated-size worktree batches; spreading
|
||||
// those batches into push can exceed JavaScript's argument limit.
|
||||
for (const entry of entries) {
|
||||
target.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
async function readGitEvidence(
|
||||
worktree: Worktree,
|
||||
repo: Repo,
|
||||
provider: IGitProvider | null
|
||||
): Promise<GitEvidence> {
|
||||
const blockers: WorkspaceCleanupBlocker[] = []
|
||||
let status: GitStatusResult
|
||||
const checkedAt = Date.now()
|
||||
|
||||
try {
|
||||
status = await withTimeout(
|
||||
repo.connectionId ? provider!.getStatus(worktree.path) : getStatus(worktree.path),
|
||||
GIT_READ_TIMEOUT_MS,
|
||||
'Timed out reading git status.'
|
||||
)
|
||||
} catch {
|
||||
return {
|
||||
...createEmptyGitEvidence(),
|
||||
blockers: ['git-status-error']
|
||||
}
|
||||
}
|
||||
|
||||
if (status.upstreamStatus === undefined) {
|
||||
return {
|
||||
...createEmptyGitEvidence(),
|
||||
blockers: ['git-status-error']
|
||||
}
|
||||
}
|
||||
|
||||
const clean = status.entries.length === 0
|
||||
if (!clean) {
|
||||
blockers.push('dirty-files')
|
||||
}
|
||||
|
||||
const upstreamAhead = status.upstreamStatus.hasUpstream ? status.upstreamStatus.ahead : null
|
||||
const upstreamBehind = status.upstreamStatus.hasUpstream ? status.upstreamStatus.behind : null
|
||||
if (upstreamAhead !== null && upstreamAhead > 0) {
|
||||
blockers.push('unpushed-commits')
|
||||
}
|
||||
if (clean && upstreamAhead === null) {
|
||||
const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider)
|
||||
if (unpushedCommitCount === null) {
|
||||
blockers.push('unknown-base')
|
||||
} else if (unpushedCommitCount > 0) {
|
||||
blockers.push('unpushed-commits')
|
||||
}
|
||||
}
|
||||
|
||||
const evidence: GitEvidence = {
|
||||
clean,
|
||||
upstreamAhead,
|
||||
upstreamBehind,
|
||||
checkedAt,
|
||||
blockers
|
||||
}
|
||||
|
||||
return { ...evidence, blockers: uniqueBlockers(blockers) }
|
||||
}
|
||||
|
||||
async function readUnpushedCommitCount(
|
||||
worktree: Worktree,
|
||||
repo: Repo,
|
||||
provider: IGitProvider | null
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const result = await withTimeout(
|
||||
repo.connectionId
|
||||
? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path)
|
||||
: gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], {
|
||||
cwd: worktree.path
|
||||
}),
|
||||
GIT_READ_TIMEOUT_MS,
|
||||
'Timed out checking unpushed commits.'
|
||||
)
|
||||
const count = Number.parseInt(result.stdout.trim(), 10)
|
||||
return Number.isFinite(count) ? count : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildCandidateFromError(
|
||||
repo: Repo,
|
||||
worktree: Worktree,
|
||||
scannedAt: number,
|
||||
_message: string
|
||||
): WorkspaceCleanupCandidate {
|
||||
return applyWorkspaceCleanupPolicy({
|
||||
worktreeId: worktree.id,
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
displayName: worktree.displayName,
|
||||
branch: shortBranchName(worktree.branch),
|
||||
path: worktree.path,
|
||||
tier: 'protected',
|
||||
selectedByDefault: false,
|
||||
reasons: getInactivityReasons(worktree, scannedAt),
|
||||
blockers: ['git-status-error'],
|
||||
lastActivityAt: worktree.lastActivityAt,
|
||||
...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}),
|
||||
localContext: buildLocalContext(worktree),
|
||||
git: {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: scannedAt
|
||||
},
|
||||
fingerprint: createWorkspaceCleanupFingerprint({
|
||||
branch: shortBranchName(worktree.branch),
|
||||
head: worktree.head,
|
||||
gitClean: null,
|
||||
lastActivityAt: worktree.lastActivityAt
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function synthesizeDisconnectedSshCandidates(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
scannedAt: number,
|
||||
targetWorktreeId?: string
|
||||
): WorkspaceCleanupCandidate[] {
|
||||
const repoWorktreePrefix = `${repo.id}::`
|
||||
if (targetWorktreeId) {
|
||||
if (!targetWorktreeId.startsWith(repoWorktreePrefix)) {
|
||||
return []
|
||||
}
|
||||
// Why: focused delete preflight names one workspace already; walking all
|
||||
// persisted metadata is unnecessary for disconnected SSH repos.
|
||||
const meta = store.getWorktreeMeta(targetWorktreeId)
|
||||
return meta ? [createDisconnectedSshCandidate(repo, scannedAt, targetWorktreeId, meta)] : []
|
||||
}
|
||||
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
const allMeta = store.getAllWorktreeMeta()
|
||||
for (const worktreeId in allMeta) {
|
||||
if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) {
|
||||
continue
|
||||
}
|
||||
const meta = allMeta[worktreeId]
|
||||
if (!meta || !isWorkspaceInactiveForCleanup(meta, scannedAt)) {
|
||||
continue
|
||||
}
|
||||
candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta))
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function createDisconnectedSshCandidate(
|
||||
repo: Repo,
|
||||
scannedAt: number,
|
||||
worktreeId: string,
|
||||
meta: WorktreeMeta
|
||||
): WorkspaceCleanupCandidate {
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
const path = parsed?.worktreePath ?? worktreeId
|
||||
const reasons = getInactivityReasons(meta, scannedAt)
|
||||
return applyWorkspaceCleanupPolicy({
|
||||
worktreeId,
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
displayName: meta.displayName || basename(path),
|
||||
branch: basename(path),
|
||||
path,
|
||||
tier: 'protected',
|
||||
selectedByDefault: false,
|
||||
reasons,
|
||||
blockers: ['ssh-disconnected'],
|
||||
lastActivityAt: meta.lastActivityAt,
|
||||
...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
|
||||
localContext: {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: meta.diffComments?.length ?? 0,
|
||||
newestDiffCommentAt: getNewestDiffCommentAt(meta.diffComments),
|
||||
retainedDoneAgentCount: 0
|
||||
},
|
||||
git: {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: null
|
||||
},
|
||||
fingerprint: createWorkspaceCleanupFingerprint({
|
||||
branch: basename(path),
|
||||
head: '',
|
||||
gitClean: null,
|
||||
lastActivityAt: meta.lastActivityAt
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildLocalContext(worktree: Worktree): WorkspaceCleanupCandidate['localContext'] {
|
||||
return {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: worktree.diffComments?.length ?? 0,
|
||||
newestDiffCommentAt: getNewestDiffCommentAt(worktree.diffComments),
|
||||
retainedDoneAgentCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
function getNewestDiffCommentAt(diffComments: Worktree['diffComments'] | undefined): number | null {
|
||||
if (!diffComments || diffComments.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Why: persisted diff notes can grow large enough for spread-based Math.max
|
||||
// to exceed the JavaScript argument limit during cleanup scans.
|
||||
let newest = diffComments[0]?.createdAt ?? null
|
||||
for (let index = 1; index < diffComments.length; index += 1) {
|
||||
const createdAt = diffComments[index]?.createdAt
|
||||
if (createdAt !== undefined && (newest === null || createdAt > newest)) {
|
||||
newest = createdAt
|
||||
}
|
||||
}
|
||||
return newest
|
||||
}
|
||||
|
||||
function createEmptyGitEvidence(): GitEvidence {
|
||||
return {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: null,
|
||||
blockers: []
|
||||
}
|
||||
}
|
||||
|
||||
function shortBranchName(branch: string): string {
|
||||
return branch.replace(/^refs\/heads\//, '') || 'HEAD'
|
||||
}
|
||||
|
||||
function uniqueBlockers(blockers: WorkspaceCleanupBlocker[]): WorkspaceCleanupBlocker[] {
|
||||
return [...new Set(blockers)]
|
||||
}
|
||||
|
||||
function uniqueReasons(reasons: WorkspaceCleanupReason[]): WorkspaceCleanupReason[] {
|
||||
return [...new Set(reasons)]
|
||||
}
|
||||
|
||||
function isWorkspaceInactiveForCleanup(
|
||||
workspace: Pick<Worktree, 'isArchived' | 'lastActivityAt'>,
|
||||
scannedAt: number
|
||||
): boolean {
|
||||
return isWorkspaceOldForCleanup(workspace, scannedAt)
|
||||
}
|
||||
|
||||
function getInactivityReasons(
|
||||
workspace: Pick<Worktree, 'isArchived' | 'lastActivityAt'>,
|
||||
scannedAt: number
|
||||
): WorkspaceCleanupReason[] {
|
||||
return getWorkspaceCleanupInactivityReasons(workspace, scannedAt)
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = []
|
||||
let nextIndex = 0
|
||||
const workerCount = Math.min(limit, items.length)
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
results[index] = await fn(items[index])
|
||||
}
|
||||
})
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout | undefined
|
||||
const timeoutPromise = new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise])
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function createScanError(repo: Repo, message: string): WorkspaceCleanupScanError {
|
||||
return {
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName || basename(repo.path),
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
// Why: git errors often include absolute paths or command output. Keep the
|
||||
// cause useful without leaking raw local/remote filesystem details to the UI.
|
||||
function toSafeRepoScanError(error: unknown): string {
|
||||
const message = toErrorMessage(error)
|
||||
if (message === 'Timed out listing SSH worktrees.') {
|
||||
return 'Timed out listing remote worktrees.'
|
||||
}
|
||||
if (message === 'Timed out listing worktrees.') {
|
||||
return 'Timed out listing worktrees.'
|
||||
}
|
||||
if (message.startsWith('Timed out ')) {
|
||||
return message.replace(/\.$/, '')
|
||||
}
|
||||
|
||||
const lower = message.toLowerCase()
|
||||
if (lower.includes('not a git repository') || lower.includes('not a git worktree')) {
|
||||
return 'Repository is not a git checkout.'
|
||||
}
|
||||
if (
|
||||
lower.includes('enoent') ||
|
||||
lower.includes('no such file') ||
|
||||
lower.includes('cannot find') ||
|
||||
lower.includes('does not exist')
|
||||
) {
|
||||
return 'Repository folder was not found.'
|
||||
}
|
||||
if (lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied')) {
|
||||
return 'Repository folder is not accessible.'
|
||||
}
|
||||
return 'Git could not list worktrees.'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,12 @@ import { createWorktreeLinkedPaths } from './worktree-symlinks'
|
||||
import { normalizeSparseDirectories } from './sparse-checkout-directories'
|
||||
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
|
||||
import {
|
||||
buildSetupRunnerCommand,
|
||||
getSetupRunnerCommandPlatformForPath
|
||||
} from '../../shared/setup-runner-command'
|
||||
import { createSequencedSetupAgentCommands } from '../../shared/setup-agent-sequencing'
|
||||
import { shouldWaitForSetupBeforeAgentStartup } from '../../shared/setup-agent-startup-policy'
|
||||
import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing'
|
||||
import {
|
||||
markCodexProjectTrusted,
|
||||
@@ -122,6 +127,7 @@ type RemoteWorktreeCreateBasePlan = {
|
||||
|
||||
type StagedStartupResult = {
|
||||
startupTerminal?: CreateWorktreeResult['startupTerminal']
|
||||
activationSetup?: CreateWorktreeResult['setup']
|
||||
didSpawnSetup: boolean
|
||||
warning?: string
|
||||
}
|
||||
@@ -230,6 +236,26 @@ async function spawnLocalStartupAndSetupTerminals(args: {
|
||||
let startupTerminalHandle: string | null = null
|
||||
let startupTerminal: CreateWorktreeResult['startupTerminal']
|
||||
|
||||
let sequencedStartup = startup
|
||||
let wrappedSetupCommandStr: string | undefined
|
||||
if (startup && setup?.waitForAgentStartup === true) {
|
||||
const platform = getSetupRunnerCommandPlatformForPath(
|
||||
setup.runnerScriptPath,
|
||||
process.platform === 'win32' ? 'windows' : 'posix'
|
||||
)
|
||||
const sequenced = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: setup.runnerScriptPath,
|
||||
startupCommand: startup.command,
|
||||
platform
|
||||
})
|
||||
sequencedStartup = {
|
||||
...startup,
|
||||
command: sequenced.startupCommand,
|
||||
...(sequenced.startupEnv ? { env: { ...startup.env, ...sequenced.startupEnv } } : {})
|
||||
}
|
||||
wrappedSetupCommandStr = sequenced.setupCommand
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: after `git worktree add` and metadata registration, a runtime-owned
|
||||
// PTY can begin booting the selected agent while setup runs in a sibling
|
||||
@@ -249,12 +275,13 @@ async function spawnLocalStartupAndSetupTerminals(args: {
|
||||
}
|
||||
}
|
||||
const terminal = await runtime.createTerminal(`id:${worktree.id}`, {
|
||||
command: startup.command,
|
||||
env: startup.env,
|
||||
...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}),
|
||||
command: sequencedStartup.command,
|
||||
...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}),
|
||||
env: sequencedStartup.env,
|
||||
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
|
||||
...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}),
|
||||
startupCommandDelivery: startup.startupCommandDelivery,
|
||||
telemetry: startup.telemetry,
|
||||
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
|
||||
telemetry: sequencedStartup.telemetry,
|
||||
activate: true
|
||||
})
|
||||
startupTerminalHandle = terminal.handle
|
||||
@@ -272,10 +299,15 @@ async function spawnLocalStartupAndSetupTerminals(args: {
|
||||
let didSpawnSetup = false
|
||||
if (setup) {
|
||||
try {
|
||||
const setupCommand = buildSetupRunnerCommand(
|
||||
setup.runnerScriptPath,
|
||||
process.platform === 'win32' ? 'windows' : 'posix'
|
||||
)
|
||||
const setupCommand =
|
||||
wrappedSetupCommandStr ??
|
||||
buildSetupRunnerCommand(
|
||||
setup.runnerScriptPath,
|
||||
getSetupRunnerCommandPlatformForPath(
|
||||
setup.runnerScriptPath,
|
||||
process.platform === 'win32' ? 'windows' : 'posix'
|
||||
)
|
||||
)
|
||||
const setupLaunchMode =
|
||||
(settings as Partial<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
|
||||
.setupScriptLaunchMode ?? 'new-tab'
|
||||
@@ -307,6 +339,16 @@ async function spawnLocalStartupAndSetupTerminals(args: {
|
||||
}
|
||||
|
||||
return {
|
||||
...(setup && !didSpawnSetup
|
||||
? {
|
||||
activationSetup: {
|
||||
...setup,
|
||||
...(startupTerminalHandle && wrappedSetupCommandStr
|
||||
? { command: wrappedSetupCommandStr }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(startupTerminal ? { startupTerminal } : {}),
|
||||
didSpawnSetup,
|
||||
...(warning ? { warning } : {})
|
||||
@@ -1072,7 +1114,10 @@ async function createRemoteSetupRunnerScript(
|
||||
)
|
||||
return {
|
||||
runnerScriptPath,
|
||||
envVars: getSetupRunnerEnvVars(repo, worktreePath)
|
||||
envVars: getSetupRunnerEnvVars(repo, worktreePath),
|
||||
...(shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy)
|
||||
? { waitForAgentStartup: true }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2390,7 +2435,11 @@ export async function createLocalWorktree(
|
||||
return {
|
||||
worktree: { ...worktree, workspaceLineage },
|
||||
...(workspaceLineage ? { workspaceLineage } : {}),
|
||||
...(setup && !stagedStartup.didSpawnSetup ? { setup } : {}),
|
||||
...(stagedStartup.activationSetup
|
||||
? { setup: stagedStartup.activationSetup }
|
||||
: setup && !stagedStartup.didSpawnSetup
|
||||
? { setup }
|
||||
: {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(addResult.localBaseRefRefresh
|
||||
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
|
||||
|
||||
@@ -940,6 +940,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
1,
|
||||
'id:repo-1::/workspace/improve-dashboard',
|
||||
{
|
||||
claudeAgentTeamsSourceCommand: 'claude --prefill test',
|
||||
command: 'claude --prefill test',
|
||||
env: { ORCA_AGENT_MODE: 'direct' },
|
||||
launchAgent: 'claude',
|
||||
@@ -957,7 +958,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
'id:repo-1::/workspace/improve-dashboard',
|
||||
{
|
||||
title: 'Setup',
|
||||
command: 'bash /workspace/repo/.git/orca/setup-runner.sh',
|
||||
command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh'),
|
||||
env: {
|
||||
ORCA_ROOT_PATH: '/workspace/repo',
|
||||
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
|
||||
@@ -965,6 +966,15 @@ describe('registerWorktreeHandlers', () => {
|
||||
activate: false
|
||||
}
|
||||
)
|
||||
const startupCreateCall = runtimeStub.createTerminal.mock.calls[0]
|
||||
const setupCreateCall = runtimeStub.createTerminal.mock.calls[1]
|
||||
if (!startupCreateCall || !setupCreateCall) {
|
||||
throw new Error('expected startup and setup terminal calls')
|
||||
}
|
||||
const startupCommand = (startupCreateCall[1] as { command: string }).command
|
||||
const setupCommand = (setupCreateCall[1] as { command: string }).command
|
||||
expect(startupCommand).toBe('claude --prefill test')
|
||||
expect(setupCommand).toBe('bash /workspace/repo/.git/orca/setup-runner.sh')
|
||||
expect(result.setup).toBeUndefined()
|
||||
expect(result.startupTerminal).toEqual({ spawned: true, surface: 'visible' })
|
||||
expect(result.timing?.phases.map((phase) => phase.phase)).toEqual(
|
||||
@@ -977,6 +987,57 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the wrapped setup command when startup spawned but setup creation failed', async () => {
|
||||
addWorktreeMock.mockResolvedValue({})
|
||||
listWorktreesMock.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
head: 'def',
|
||||
branch: 'improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||||
createSetupRunnerScriptMock.mockReturnValueOnce({
|
||||
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/workspace/repo',
|
||||
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
|
||||
},
|
||||
waitForAgentStartup: true
|
||||
})
|
||||
runtimeStub.createTerminal
|
||||
.mockResolvedValueOnce({ handle: 'term-startup', surface: 'visible' })
|
||||
.mockRejectedValueOnce(new Error('setup creation failed'))
|
||||
|
||||
const result = (await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard',
|
||||
createdWithAgent: 'claude',
|
||||
startup: {
|
||||
command: 'claude --prefill test',
|
||||
env: { ORCA_AGENT_MODE: 'direct' },
|
||||
telemetry: {
|
||||
agent_kind: 'claude',
|
||||
launch_source: 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
}
|
||||
})) as { setup?: { command?: string; runnerScriptPath: string } }
|
||||
|
||||
expect(result.setup).toEqual(
|
||||
expect.objectContaining({
|
||||
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
|
||||
command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh')
|
||||
})
|
||||
)
|
||||
expect(result.setup?.command).toContain('printf')
|
||||
})
|
||||
|
||||
it('checks out a selected existing local branch exactly', async () => {
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce([
|
||||
|
||||
+14
-1
@@ -3790,11 +3790,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) {
|
||||
@@ -3844,6 +3849,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)
|
||||
|
||||
@@ -103,11 +103,14 @@ export class SshGitProvider implements IGitProvider {
|
||||
const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache
|
||||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}
|
||||
return (await this.mux.request('git.status', {
|
||||
const request = {
|
||||
worktreePath,
|
||||
...includeIgnoredArgs,
|
||||
...upstreamCacheBypassArgs
|
||||
})) as GitStatusResult
|
||||
}
|
||||
return (await (options?.signal
|
||||
? this.mux.request('git.status', request, { signal: options.signal })
|
||||
: this.mux.request('git.status', request))) as GitStatusResult
|
||||
}
|
||||
|
||||
async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise<string[]> {
|
||||
|
||||
@@ -102,6 +102,28 @@ describe('SshPtyProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards provider command delivery to the relay', async () => {
|
||||
mux.request.mockResolvedValue({ id: 'pty-provider-command' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
command: 'echo from-runtime',
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
|
||||
expect(mux.request).toHaveBeenCalledWith('pty.spawn', {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: undefined,
|
||||
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
|
||||
command: 'echo from-runtime',
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('injects the relay-backed Orca CLI bridge into remote PTY env', async () => {
|
||||
mux.request.mockResolvedValue({ id: 'pty-bridge' })
|
||||
provider = new SshPtyProvider('conn-1', mux as never, {
|
||||
|
||||
@@ -133,12 +133,10 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
cwd: opts.cwd,
|
||||
env: this.withRemoteCliBridgeEnv(opts.env, opts.envToDelete),
|
||||
// Why: the relay's plugin-overlay env augmenter needs to know which
|
||||
// Pi-compatible agent is being launched (`pi` vs `omp`) so it mirrors
|
||||
// the right `~/.<kind>/agent` source dir on the remote disk. The
|
||||
// relay does not execute `command` itself — the user types it into
|
||||
// the shell — but receiving it as a hint lets overlay resolution be
|
||||
// per-launch instead of always-Pi.
|
||||
// Pi-compatible agent is being launched, while commandDelivery tells it
|
||||
// whether to submit the command itself for runtime-owned background PTYs.
|
||||
...(opts.command ? { command: opts.command } : {}),
|
||||
...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}),
|
||||
...(opts.startupCommandDelivery
|
||||
? { startupCommandDelivery: opts.startupCommandDelivery }
|
||||
: {})
|
||||
|
||||
@@ -30,6 +30,7 @@ export type PtySpawnOptions = {
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
command?: string
|
||||
commandDelivery?: 'renderer' | 'provider'
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
/** Orca worktree identity. When present, the local provider scopes shell
|
||||
* history to this worktree so ArrowUp only surfaces local commands. */
|
||||
@@ -168,6 +169,7 @@ export type IFilesystemProvider = {
|
||||
export type GitProviderStatusOptions = {
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type IGitProvider = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { areWorktreePathsEqual } from './ipc/worktree-logic'
|
||||
|
||||
type LocalRepoWorktreeListOptions = {
|
||||
wslDistro?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean {
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
} from '../../shared/constants'
|
||||
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import type { RpcRequest } from './rpc/core'
|
||||
@@ -2911,7 +2912,13 @@ describe('OrcaRuntimeService', () => {
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
connectionId: 'ssh-1',
|
||||
hookSettings: {
|
||||
mode: 'auto' as const,
|
||||
setupRunPolicy: 'run-by-default' as const,
|
||||
setupAgentStartupPolicy: 'wait-for-setup' as const,
|
||||
scripts: { setup: '', archive: '' }
|
||||
}
|
||||
}
|
||||
const parent = {
|
||||
path: '/remote/repo-parent',
|
||||
@@ -3078,7 +3085,13 @@ describe('OrcaRuntimeService', () => {
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
connectionId: 'ssh-1',
|
||||
hookSettings: {
|
||||
mode: 'auto' as const,
|
||||
setupRunPolicy: 'run-by-default' as const,
|
||||
setupAgentStartupPolicy: 'wait-for-setup' as const,
|
||||
scripts: { setup: '', archive: '' }
|
||||
}
|
||||
}
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const remoteStore = {
|
||||
@@ -3288,7 +3301,13 @@ describe('OrcaRuntimeService', () => {
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
connectionId: 'ssh-1',
|
||||
hookSettings: {
|
||||
mode: 'auto' as const,
|
||||
setupRunPolicy: 'run-by-default' as const,
|
||||
setupAgentStartupPolicy: 'wait-for-setup' as const,
|
||||
scripts: { setup: '', archive: '' }
|
||||
}
|
||||
}
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const remoteStore = {
|
||||
@@ -3375,15 +3394,16 @@ describe('OrcaRuntimeService', () => {
|
||||
startup: { command: 'claude' }
|
||||
})
|
||||
|
||||
expect(result.setup).toMatchObject({
|
||||
runnerScriptPath: '/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh'
|
||||
})
|
||||
// Why: runtime now provisions setup itself (fire-and-forget) and omits it
|
||||
// from the RPC result so the caller does not double-spawn. The async spawn
|
||||
// means we wait for both the agent and setup PTYs before asserting.
|
||||
expect(result.setup).toBeUndefined()
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
cwd: '/remote/mobile-setup',
|
||||
command: 'claude',
|
||||
command: expect.stringContaining('exec claude'),
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
@@ -3391,10 +3411,21 @@ describe('OrcaRuntimeService', () => {
|
||||
2,
|
||||
expect.objectContaining({
|
||||
cwd: '/remote/mobile-setup',
|
||||
command: 'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh',
|
||||
command: expect.stringContaining(
|
||||
'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh'
|
||||
),
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command
|
||||
const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command
|
||||
const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
|
||||
expect(nonceMatch?.[1]).toBeTruthy()
|
||||
const markerPath = `/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh.${nonceMatch![1]}.done`
|
||||
expect(setupCommand).toContain('printf')
|
||||
expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`)
|
||||
expect(startupCommand).toContain(markerPath)
|
||||
expect(setupCommand).toContain(markerPath)
|
||||
expect(revealTerminalSession).toHaveBeenLastCalledWith(
|
||||
result.worktree.id,
|
||||
expect.objectContaining({
|
||||
@@ -6424,6 +6455,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect.objectContaining({
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
command: 'codex',
|
||||
commandDelivery: 'provider',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
preAllocatedHandle: expect.stringMatching(/^term_/)
|
||||
})
|
||||
@@ -6704,6 +6736,49 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves Claude Agent Teams for sequenced Claude launches', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
claudeAgentTeamsMode: 'in-process' as const
|
||||
})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command:
|
||||
'bash -lc \'echo Waiting for setup to finish before starting agent... >&2; exec claude "hello"\'',
|
||||
claudeAgentTeamsSourceCommand: 'claude "hello"',
|
||||
launchAgent: 'claude',
|
||||
launchConfig: {
|
||||
agentCommand: 'claude',
|
||||
agentArgs: '',
|
||||
agentEnv: { CLAUDE_PROFILE: 'captured' }
|
||||
}
|
||||
})
|
||||
|
||||
const sequencedClaude = spawn.mock.calls[0]?.[0] as {
|
||||
command?: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
expect(sequencedClaude.command).toBe(
|
||||
'bash -lc \'echo Waiting for setup to finish before starting agent... >&2; exec claude "hello"\''
|
||||
)
|
||||
expect(sequencedClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1')
|
||||
expect(sequencedClaude.env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]).toBe(
|
||||
'claude --teammate-mode in-process "hello"'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores captured native Claude Agent Teams mode with fresh service env', async () => {
|
||||
setPlatform('linux')
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
@@ -18704,15 +18779,9 @@ describe('OrcaRuntimeService', () => {
|
||||
repoId: 'repo-1',
|
||||
path: '/tmp/workspaces/runtime-hook-skip',
|
||||
branch: 'runtime-hook-skip'
|
||||
}),
|
||||
setup: {
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(result.setup).toBeUndefined()
|
||||
expect(activateWorktree).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
@@ -18754,6 +18823,166 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sequences setup before startup for opted-in local headless worktree creates', async () => {
|
||||
const waitRepo = {
|
||||
...store.getRepo('repo-1')!,
|
||||
hookSettings: {
|
||||
mode: 'auto' as const,
|
||||
setupRunPolicy: 'run-by-default' as const,
|
||||
setupAgentStartupPolicy: 'wait-for-setup' as const,
|
||||
scripts: { setup: '', archive: '' }
|
||||
}
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [waitRepo],
|
||||
getRepo: (id: string) => (id === 'repo-1' ? waitRepo : undefined)
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-startup' })
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-headless-startup' })
|
||||
.mockResolvedValueOnce({ id: 'pty-headless-setup' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-headless-startup-setup')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-headless-startup-setup')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
setup: 'pnpm worktree:setup'
|
||||
}
|
||||
})
|
||||
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-startup-setup'
|
||||
},
|
||||
waitForAgentStartup: true
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-headless-startup-setup',
|
||||
head: 'def',
|
||||
branch: 'runtime-headless-startup-setup',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-headless-startup-setup',
|
||||
setupDecision: 'run',
|
||||
startup: { command: 'claude' }
|
||||
})
|
||||
|
||||
expect(createSetupRunnerScript).toHaveBeenCalled()
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
// Why: runtime now provisions setup fire-and-forget, so the setup PTY spawns
|
||||
// on a later tick. The wait-for-setup guarantee is enforced by the shell
|
||||
// nonce/marker in the wrapped commands below, not by JS spawn ordering.
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command
|
||||
const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command
|
||||
const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
|
||||
expect(nonceMatch?.[1]).toBeTruthy()
|
||||
expect(startupCommand).toContain('exec claude')
|
||||
expect(setupCommand).toContain('printf')
|
||||
expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`)
|
||||
expect(result.setup).toBeUndefined()
|
||||
})
|
||||
|
||||
it('starts setup and startup side by side by default for local headless worktree creates', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-parallel' })
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-headless-parallel-startup' })
|
||||
.mockResolvedValueOnce({ id: 'pty-headless-parallel-setup' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-headless-parallel')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-headless-parallel')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
setup: 'pnpm worktree:setup'
|
||||
}
|
||||
})
|
||||
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-parallel'
|
||||
}
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-headless-parallel',
|
||||
head: 'def',
|
||||
branch: 'runtime-headless-parallel',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-headless-parallel',
|
||||
setupDecision: 'run',
|
||||
startup: { command: 'claude' }
|
||||
})
|
||||
|
||||
// Why: setup now spawns fire-and-forget on a later tick; wait for both PTYs.
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
expect(spawn).toHaveBeenNthCalledWith(1, expect.objectContaining({ command: 'claude' }))
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ command: 'bash /tmp/repo/.git/orca/setup-runner.sh' })
|
||||
)
|
||||
})
|
||||
|
||||
it('creates the first terminal for CLI-created worktrees without activating them', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const activateWorktree = vi.fn()
|
||||
@@ -19449,7 +19678,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' })
|
||||
})
|
||||
|
||||
it('honors split setup placement for local startup-draft worktrees', async () => {
|
||||
it('honors split setup placement for opted-in local startup-draft worktrees', async () => {
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -19506,7 +19735,8 @@ describe('OrcaRuntimeService', () => {
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split'
|
||||
}
|
||||
},
|
||||
waitForAgentStartup: true
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
@@ -19531,7 +19761,7 @@ describe('OrcaRuntimeService', () => {
|
||||
1,
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/workspaces/runtime-startup-setup-split',
|
||||
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
|
||||
command: expect.stringContaining('codex'),
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
@@ -19539,7 +19769,7 @@ describe('OrcaRuntimeService', () => {
|
||||
2,
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/workspaces/runtime-startup-setup-split',
|
||||
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
|
||||
command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'),
|
||||
env: expect.objectContaining({
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split',
|
||||
@@ -19548,8 +19778,19 @@ describe('OrcaRuntimeService', () => {
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command
|
||||
const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command
|
||||
const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
|
||||
expect(nonceMatch?.[1]).toBeTruthy()
|
||||
const markerPath = `/tmp/repo/.git/orca/setup-runner.sh.${nonceMatch![1]}.done`
|
||||
expect(startupCommand).toContain('--dangerously-bypass-approvals-and-sandbox')
|
||||
expect(setupCommand).toContain('printf')
|
||||
expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`)
|
||||
expect(startupCommand).toContain(markerPath)
|
||||
expect(setupCommand).toContain(markerPath)
|
||||
const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record<string, string> }).env ?? {}
|
||||
const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record<string, string> }).env ?? {}
|
||||
expect(result.setup).toBeUndefined()
|
||||
expect(mainEnv.ORCA_TAB_ID).toBeDefined()
|
||||
expect(mainEnv.ORCA_PANE_KEY).toBeDefined()
|
||||
expect(setupEnv.ORCA_TAB_ID).toBe(mainEnv.ORCA_TAB_ID)
|
||||
@@ -19566,6 +19807,84 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('passes the wrapped setup command to activation when startup spawned but setup did not', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-startup-main' })
|
||||
.mockRejectedValueOnce(new Error('setup spawn failed'))
|
||||
const activateWorktree = vi.fn()
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree,
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-startup-main' }),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-retry')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-retry')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
setup: 'pnpm worktree:setup'
|
||||
}
|
||||
})
|
||||
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-retry'
|
||||
},
|
||||
waitForAgentStartup: true
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-startup-setup-retry',
|
||||
head: 'def',
|
||||
branch: 'runtime-startup-setup-retry',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-startup-setup-retry',
|
||||
setupDecision: 'run',
|
||||
activate: true,
|
||||
startup: { command: 'claude' }
|
||||
})
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(2)
|
||||
expect(activateWorktree).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh')
|
||||
}),
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
const activationSetup = activateWorktree.mock.calls[0]?.[2] as { command?: string } | undefined
|
||||
expect(activationSetup?.command).toContain('printf')
|
||||
})
|
||||
|
||||
it('lets explicit startup draft agents override the desktop default', async () => {
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([])
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
|
||||
@@ -154,7 +154,14 @@ import { parsePtySessionId } from '../../shared/pty-session-id-format'
|
||||
import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
|
||||
import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
|
||||
import {
|
||||
buildSetupRunnerCommand,
|
||||
getSetupRunnerCommandPlatformForPath
|
||||
} from '../../shared/setup-runner-command'
|
||||
import {
|
||||
createSequencedSetupAgentCommands,
|
||||
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
|
||||
} from '../../shared/setup-agent-sequencing'
|
||||
import { TASK_PROVIDERS } from '../../shared/task-providers'
|
||||
import { FIRST_PANE_ID } from '../../shared/pane-key'
|
||||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id'
|
||||
@@ -991,6 +998,7 @@ type RuntimePtyController = {
|
||||
rows: number
|
||||
cwd?: string
|
||||
command?: string
|
||||
commandDelivery?: 'renderer' | 'provider'
|
||||
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
@@ -9821,10 +9829,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')
|
||||
@@ -9834,6 +9847,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
|
||||
}
|
||||
@@ -11961,10 +11980,16 @@ export class OrcaRuntimeService {
|
||||
primaryTerminalHandle?: string | null
|
||||
hasStartupTerminal: boolean
|
||||
setupCommandPlatform: 'windows' | 'posix'
|
||||
}): Promise<void> {
|
||||
// Why: when the agent startup is sequenced to wait for setup
|
||||
// (waitForAgentStartup), the startup PTY runs a wrapper that already embeds
|
||||
// the setup command. Pass that wrapped command through so the Setup tab runs
|
||||
// the same script the agent is waiting on instead of a bare runner.
|
||||
wrappedSetupCommand?: string
|
||||
}): Promise<{ setupSpawned: boolean }> {
|
||||
if (!this.ptyController?.spawn) {
|
||||
return
|
||||
return { setupSpawned: false }
|
||||
}
|
||||
let setupSpawned = false
|
||||
try {
|
||||
const defaultTabHandles = await this.createDefaultTabTerminals(
|
||||
args.worktreeSelector,
|
||||
@@ -11983,10 +12008,9 @@ export class OrcaRuntimeService {
|
||||
primaryTerminalHandle = terminal.handle
|
||||
}
|
||||
if (args.setup) {
|
||||
const setupCommand = buildSetupRunnerCommand(
|
||||
args.setup.runnerScriptPath,
|
||||
args.setupCommandPlatform
|
||||
)
|
||||
const setupCommand =
|
||||
args.wrappedSetupCommand ??
|
||||
buildSetupRunnerCommand(args.setup.runnerScriptPath, args.setupCommandPlatform)
|
||||
const shouldSplitSetup =
|
||||
primaryTerminalHandle &&
|
||||
(setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal')
|
||||
@@ -12002,6 +12026,7 @@ export class OrcaRuntimeService {
|
||||
command: setupCommand,
|
||||
env: args.setup.envVars
|
||||
}))
|
||||
setupSpawned = true
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
@@ -12009,6 +12034,7 @@ export class OrcaRuntimeService {
|
||||
`[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}`
|
||||
)
|
||||
}
|
||||
return { setupSpawned }
|
||||
}
|
||||
|
||||
private async waitForStartupFollowupReady(
|
||||
@@ -12799,12 +12825,11 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision)
|
||||
if (shouldRunSetup && hooks?.scripts.setup) {
|
||||
if (this.authoritativeWindowId !== null) {
|
||||
const shouldUseSetupRunner = this.authoritativeWindowId !== null || Boolean(effectiveStartup)
|
||||
if (shouldUseSetupRunner) {
|
||||
try {
|
||||
// Why: CLI-created worktrees must use the same runner-script path as the
|
||||
// renderer create flow so repo-committed `orca.yaml` setup hooks run in
|
||||
// the visible first terminal instead of a hidden background shell with
|
||||
// different failure and prompt behavior.
|
||||
// Why: setup+startup must share the terminal runner path even without
|
||||
// a renderer window, so the startup shell can wait on setup completion.
|
||||
setup = createSetupRunnerScript(
|
||||
repo,
|
||||
worktreePath,
|
||||
@@ -12847,11 +12872,39 @@ export class OrcaRuntimeService {
|
||||
this.notifyWorktreesChanged(repo.id)
|
||||
const shouldActivate = args.activate === true || args.runHooks === true
|
||||
let didSpawnStartup = false
|
||||
// Why: tracks whether runtime itself launched the setup script (via
|
||||
// provisionManagedWorktreeTerminals). When true, renderer activation and the
|
||||
// RPC return value must omit setup so the client does not spawn it a second
|
||||
// time. Mirrors the wait-for-agent setup contract from #6298.
|
||||
let didSpawnSetup = false
|
||||
let startupTerminalHandle: string | null = null
|
||||
let startupTerminalTabId: string | null = null
|
||||
let startupTerminalPaneKey: string | null = null
|
||||
let startupTerminalPtyId: string | null = null
|
||||
if (effectiveStartup && this.ptyController?.spawn) {
|
||||
|
||||
let sequencedStartup = effectiveStartup
|
||||
let wrappedSetupCommandStr: string | undefined
|
||||
if (effectiveStartup && setup?.waitForAgentStartup === true) {
|
||||
const platform = getSetupRunnerCommandPlatformForPath(
|
||||
setup.runnerScriptPath,
|
||||
process.platform === 'win32' ? 'windows' : 'posix'
|
||||
)
|
||||
const sequenced = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: setup.runnerScriptPath,
|
||||
startupCommand: effectiveStartup.command,
|
||||
platform
|
||||
})
|
||||
sequencedStartup = {
|
||||
...effectiveStartup,
|
||||
command: sequenced.startupCommand,
|
||||
...(sequenced.startupEnv
|
||||
? { env: { ...effectiveStartup.env, ...sequenced.startupEnv } }
|
||||
: {})
|
||||
}
|
||||
wrappedSetupCommandStr = sequenced.setupCommand
|
||||
}
|
||||
|
||||
if (sequencedStartup && this.ptyController?.spawn) {
|
||||
try {
|
||||
// Why: automation startup must not depend on a renderer TerminalPane
|
||||
// mounting. Runtime-spawned PTYs run immediately and the UI adopts the
|
||||
@@ -12861,12 +12914,15 @@ export class OrcaRuntimeService {
|
||||
this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath)
|
||||
}
|
||||
const terminal = await this.createTerminal(`id:${worktree.id}`, {
|
||||
command: effectiveStartup.command,
|
||||
env: effectiveStartup.env,
|
||||
...(effectiveStartup.launchConfig ? { launchConfig: effectiveStartup.launchConfig } : {}),
|
||||
command: sequencedStartup.command,
|
||||
...(setup && effectiveStartup
|
||||
? { claudeAgentTeamsSourceCommand: effectiveStartup.command }
|
||||
: {}),
|
||||
env: sequencedStartup.env,
|
||||
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
|
||||
...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}),
|
||||
startupCommandDelivery: effectiveStartup.startupCommandDelivery,
|
||||
telemetry: effectiveStartup.telemetry
|
||||
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
|
||||
telemetry: sequencedStartup.telemetry
|
||||
})
|
||||
if (effectiveDraftPaste) {
|
||||
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
|
||||
@@ -12894,9 +12950,11 @@ export class OrcaRuntimeService {
|
||||
const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(setup || defaultTabs)
|
||||
if (runtimeWillProvisionTerminals) {
|
||||
// Why: once runtime spawned the startup PTY, renderer activation may see
|
||||
// an existing terminal and skip setup/default tabs. Keep those side
|
||||
// effects best-effort so they cannot delay the startup agent.
|
||||
void this.provisionManagedWorktreeTerminals({
|
||||
// an existing terminal and skip setup/default tabs. Await provisioning so
|
||||
// a failed setup spawn falls back to renderer activation (which still
|
||||
// carries the wrapped command for retry); #6298's wait-for-setup
|
||||
// guarantee is enforced by the shell marker, not by spawn timing.
|
||||
const provisioned = await this.provisionManagedWorktreeTerminals({
|
||||
worktreeSelector: `id:${worktree.id}`,
|
||||
worktreeId: worktree.id,
|
||||
worktreePath,
|
||||
@@ -12908,10 +12966,26 @@ export class OrcaRuntimeService {
|
||||
? isWindowsAbsolutePathLike(setup.runnerScriptPath)
|
||||
? 'windows'
|
||||
: 'posix'
|
||||
: 'posix'
|
||||
: 'posix',
|
||||
// Why: carry the wait-for-agent wrapped setup command (#6298) so the
|
||||
// Setup tab runs the same script the sequenced agent waits on.
|
||||
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
|
||||
})
|
||||
didSpawnSetup = provisioned.setupSpawned
|
||||
}
|
||||
const activationSetup = runtimeWillProvisionTerminals ? undefined : setup
|
||||
// Why: when runtime spawned setup, omit it from activation. When setup
|
||||
// spawn failed, fall through with the wrapped command so renderer
|
||||
// activation retries it.
|
||||
const activationSetup = didSpawnSetup
|
||||
? undefined
|
||||
: setup
|
||||
? {
|
||||
...setup,
|
||||
...(didSpawnStartup && wrappedSetupCommandStr
|
||||
? { command: wrappedSetupCommandStr }
|
||||
: {})
|
||||
}
|
||||
: undefined
|
||||
const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : defaultTabs
|
||||
if (effectiveStartup && !didSpawnStartup) {
|
||||
this.notifyActivateWorktree(
|
||||
@@ -12945,8 +13019,14 @@ export class OrcaRuntimeService {
|
||||
? isWindowsAbsolutePathLike(setup.runnerScriptPath)
|
||||
? 'windows'
|
||||
: 'posix'
|
||||
: 'posix'
|
||||
: 'posix',
|
||||
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
|
||||
})
|
||||
// Why: runtime owns setup spawning here, so the RPC result must omit setup
|
||||
// to keep the headless/mobile caller from launching it a second time.
|
||||
if (setup) {
|
||||
didSpawnSetup = true
|
||||
}
|
||||
} else if (this.ptyController?.spawn) {
|
||||
try {
|
||||
await this.createTerminal(`id:${worktree.id}`)
|
||||
@@ -12958,6 +13038,16 @@ export class OrcaRuntimeService {
|
||||
console.warn(`[worktree-create] ${warning}`)
|
||||
}
|
||||
}
|
||||
const returnedSetup = didSpawnSetup
|
||||
? undefined
|
||||
: setup
|
||||
? {
|
||||
...setup,
|
||||
...(didSpawnStartup && wrappedSetupCommandStr
|
||||
? { command: wrappedSetupCommandStr }
|
||||
: {})
|
||||
}
|
||||
: undefined
|
||||
return {
|
||||
worktree: {
|
||||
...worktree,
|
||||
@@ -12968,7 +13058,7 @@ export class OrcaRuntimeService {
|
||||
git: created
|
||||
},
|
||||
...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}),
|
||||
...(setup ? { setup } : {}),
|
||||
...(returnedSetup ? { setup: returnedSetup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(warning ? { warning } : {}),
|
||||
...(addResult.localBaseRefRefresh
|
||||
@@ -13089,11 +13179,32 @@ export class OrcaRuntimeService {
|
||||
|
||||
let warning = result.warning
|
||||
let didSpawnStartup = false
|
||||
// Why: same no-double-spawn contract as the local path — once runtime
|
||||
// provisions setup, omit it from activation and the RPC result.
|
||||
let didSpawnSetup = false
|
||||
let startupTerminalHandle: string | null = null
|
||||
let startupTerminalTabId: string | null = null
|
||||
let startupTerminalPaneKey: string | null = null
|
||||
let startupTerminalPtyId: string | null = null
|
||||
if (args.startup && this.ptyController?.spawn) {
|
||||
|
||||
let sequencedStartup = args.startup
|
||||
let wrappedSetupCommandStr: string | undefined
|
||||
if (args.startup && result.setup?.waitForAgentStartup === true) {
|
||||
const platform = getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix')
|
||||
const sequenced = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: result.setup.runnerScriptPath,
|
||||
startupCommand: args.startup.command,
|
||||
platform
|
||||
})
|
||||
sequencedStartup = {
|
||||
...args.startup,
|
||||
command: sequenced.startupCommand,
|
||||
...(sequenced.startupEnv ? { env: { ...args.startup.env, ...sequenced.startupEnv } } : {})
|
||||
}
|
||||
wrappedSetupCommandStr = sequenced.setupCommand
|
||||
}
|
||||
|
||||
if (sequencedStartup && this.ptyController?.spawn) {
|
||||
try {
|
||||
const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent
|
||||
if (startupTrustAgent) {
|
||||
@@ -13104,12 +13215,15 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
}
|
||||
const terminal = await this.createTerminal(`path:${result.worktree.path}`, {
|
||||
command: args.startup.command,
|
||||
env: args.startup.env,
|
||||
...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}),
|
||||
command: sequencedStartup.command,
|
||||
...(result.setup && args.startup
|
||||
? { claudeAgentTeamsSourceCommand: args.startup.command }
|
||||
: {}),
|
||||
env: sequencedStartup.env,
|
||||
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
|
||||
...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}),
|
||||
startupCommandDelivery: args.startup.startupCommandDelivery,
|
||||
telemetry: args.startup.telemetry
|
||||
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
|
||||
telemetry: sequencedStartup.telemetry
|
||||
})
|
||||
if (args.startupDraftPaste) {
|
||||
this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste)
|
||||
@@ -13136,8 +13250,9 @@ export class OrcaRuntimeService {
|
||||
didSpawnStartup && Boolean(result.setup || result.defaultTabs)
|
||||
if (runtimeWillProvisionTerminals) {
|
||||
// Why: remote/mobile task creates spawn the agent terminal in runtime,
|
||||
// so renderer activation may not materialize setup/default tabs.
|
||||
void this.provisionManagedWorktreeTerminals({
|
||||
// so renderer activation may not materialize setup/default tabs. Await so
|
||||
// a failed setup spawn falls back to renderer activation for retry.
|
||||
const provisioned = await this.provisionManagedWorktreeTerminals({
|
||||
worktreeSelector: `path:${result.worktree.path}`,
|
||||
worktreeId: result.worktree.id,
|
||||
worktreePath: result.worktree.path,
|
||||
@@ -13149,10 +13264,25 @@ export class OrcaRuntimeService {
|
||||
? isWindowsAbsolutePathLike(result.setup.runnerScriptPath)
|
||||
? 'windows'
|
||||
: 'posix'
|
||||
: 'posix'
|
||||
: 'posix',
|
||||
// Why: carry the wait-for-agent wrapped setup command (#6298) so the
|
||||
// remote Setup tab runs the same script the sequenced agent waits on.
|
||||
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
|
||||
})
|
||||
didSpawnSetup = provisioned.setupSpawned
|
||||
}
|
||||
const activationSetup = runtimeWillProvisionTerminals ? undefined : result.setup
|
||||
// Why: omit setup from activation when runtime spawned it; on spawn
|
||||
// failure fall through with the wrapped command so renderer retries.
|
||||
const activationSetup = didSpawnSetup
|
||||
? undefined
|
||||
: result.setup
|
||||
? {
|
||||
...result.setup,
|
||||
...(didSpawnStartup && wrappedSetupCommandStr
|
||||
? { command: wrappedSetupCommandStr }
|
||||
: {})
|
||||
}
|
||||
: undefined
|
||||
const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : result.defaultTabs
|
||||
if (args.startup && !didSpawnStartup) {
|
||||
this.notifyActivateWorktree(
|
||||
@@ -13192,8 +13322,14 @@ export class OrcaRuntimeService {
|
||||
? isWindowsAbsolutePathLike(result.setup.runnerScriptPath)
|
||||
? 'windows'
|
||||
: 'posix'
|
||||
: 'posix'
|
||||
: 'posix',
|
||||
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
|
||||
})
|
||||
// Why: runtime owns setup spawning here, so omit setup from the RPC result
|
||||
// to keep the headless/mobile caller from launching it a second time.
|
||||
if (result.setup) {
|
||||
didSpawnSetup = true
|
||||
}
|
||||
} else if (!shouldActivate && this.ptyController?.spawn) {
|
||||
try {
|
||||
await this.createTerminal(`path:${result.worktree.path}`)
|
||||
@@ -13205,10 +13341,27 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
const returnedSetup = didSpawnSetup
|
||||
? undefined
|
||||
: result.setup
|
||||
? {
|
||||
...result.setup,
|
||||
...(didSpawnStartup && wrappedSetupCommandStr
|
||||
? { command: wrappedSetupCommandStr }
|
||||
: {})
|
||||
}
|
||||
: undefined
|
||||
const resultForRenderer = returnedSetup
|
||||
? { ...result, setup: returnedSetup }
|
||||
: (() => {
|
||||
const { setup: _setup, ...resultWithoutSetup } = result
|
||||
return resultWithoutSetup
|
||||
})()
|
||||
|
||||
const resultWithStartupTerminal =
|
||||
didSpawnStartup && startupTerminalHandle
|
||||
? {
|
||||
...result,
|
||||
...resultForRenderer,
|
||||
startupTerminal: {
|
||||
spawned: true,
|
||||
handle: startupTerminalHandle,
|
||||
@@ -13218,7 +13371,7 @@ export class OrcaRuntimeService {
|
||||
surface: 'background' as const
|
||||
}
|
||||
}
|
||||
: result
|
||||
: resultForRenderer
|
||||
|
||||
return warning ? { ...resultWithStartupTerminal, warning } : resultWithStartupTerminal
|
||||
}
|
||||
@@ -14717,6 +14870,7 @@ export class OrcaRuntimeService {
|
||||
worktreeSelector?: string,
|
||||
opts: {
|
||||
command?: string
|
||||
claudeAgentTeamsSourceCommand?: string
|
||||
env?: Record<string, string>
|
||||
launchConfig?: WorktreeStartupLaunch['launchConfig']
|
||||
launchToken?: string
|
||||
@@ -14778,14 +14932,16 @@ export class OrcaRuntimeService {
|
||||
...opts.env,
|
||||
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
|
||||
}
|
||||
const claudeAgentTeamsSourceCommand =
|
||||
opts.claudeAgentTeamsSourceCommand?.trim() || opts.command?.trim() || undefined
|
||||
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
|
||||
const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode(
|
||||
opts.launchConfig,
|
||||
opts.command,
|
||||
claudeAgentTeamsSourceCommand,
|
||||
claudeAgentTeamsMode
|
||||
)
|
||||
const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({
|
||||
command: opts.command,
|
||||
command: claudeAgentTeamsSourceCommand,
|
||||
mode: effectiveClaudeAgentTeamsMode,
|
||||
baseEnv: {
|
||||
...process.env,
|
||||
@@ -14802,6 +14958,13 @@ export class OrcaRuntimeService {
|
||||
shimBin
|
||||
}).env
|
||||
})
|
||||
const sequencedStartupCommand =
|
||||
agentTeamsPlan &&
|
||||
claudeAgentTeamsSourceCommand &&
|
||||
opts.command &&
|
||||
claudeAgentTeamsSourceCommand !== opts.command
|
||||
? agentTeamsPlan.command
|
||||
: undefined
|
||||
const effectiveLaunchConfig =
|
||||
opts.launchConfig && agentTeamsPlan
|
||||
? {
|
||||
@@ -14817,9 +14980,17 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
: opts.launchConfig
|
||||
// Why: setup/agent sequencing wraps the PTY launch in a wait shell before
|
||||
// Claude Agent Teams runs. Preserve the direct Claude command separately
|
||||
// so the wrapper can exec the teammate-mode variant after setup completes.
|
||||
const env = this.buildTerminalWorkspaceEnv(
|
||||
workspace,
|
||||
baseEnv,
|
||||
{
|
||||
...baseEnv,
|
||||
...(sequencedStartupCommand
|
||||
? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand }
|
||||
: {})
|
||||
},
|
||||
paneKey,
|
||||
tabId,
|
||||
agentTeamsPlan?.env
|
||||
@@ -14828,7 +14999,8 @@ export class OrcaRuntimeService {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: workspace.path,
|
||||
command: agentTeamsPlan?.command ?? opts.command,
|
||||
command: sequencedStartupCommand ? opts.command : (agentTeamsPlan?.command ?? opts.command),
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: opts.startupCommandDelivery,
|
||||
env,
|
||||
envToDelete: agentTeamsPlan?.envToDelete,
|
||||
@@ -15673,6 +15845,7 @@ export class OrcaRuntimeService {
|
||||
rows: 40,
|
||||
cwd: workspace.path,
|
||||
command: opts.command,
|
||||
commandDelivery: 'provider',
|
||||
env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId),
|
||||
envToDelete: opts.envToDelete,
|
||||
connectionId: workspace.connectionId,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2551,10 +2551,12 @@ describe('createMainWindow', () => {
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('ignores duplicate ready-to-show events after startup maximize has already run', () => {
|
||||
function createStartupRevealWindowFixture() {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
on: vi.fn(),
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
setZoomLevel: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
@@ -2581,6 +2583,23 @@ describe('createMainWindow', () => {
|
||||
return browserWindowInstance
|
||||
})
|
||||
|
||||
return { browserWindowInstance, windowHandlers }
|
||||
}
|
||||
|
||||
function createStartupRevealStore(savedMaximized: boolean) {
|
||||
return {
|
||||
getUI: () =>
|
||||
({
|
||||
windowMaximized: savedMaximized
|
||||
}) as never,
|
||||
getSettings: () => ({ windowBackgroundBlur: false }) as never,
|
||||
updateUI: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
it('ignores duplicate ready-to-show events after startup maximize has already run', () => {
|
||||
const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture()
|
||||
|
||||
createMainWindow({
|
||||
getUI: () =>
|
||||
({
|
||||
@@ -2597,6 +2616,98 @@ describe('createMainWindow', () => {
|
||||
expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reveals the startup window on Windows when ready-to-show never fires', () => {
|
||||
vi.useFakeTimers()
|
||||
const { browserWindowInstance } = createStartupRevealWindowFixture()
|
||||
|
||||
withPlatform('win32', () => {
|
||||
createMainWindow(null)
|
||||
vi.advanceTimersByTime(9_999)
|
||||
expect(browserWindowInstance.show).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
|
||||
expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels the Windows startup reveal fallback after ready-to-show', () => {
|
||||
vi.useFakeTimers()
|
||||
const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture()
|
||||
|
||||
withPlatform('win32', () => {
|
||||
createMainWindow(null)
|
||||
windowHandlers['ready-to-show']()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not install the startup reveal fallback off Windows', () => {
|
||||
vi.useFakeTimers()
|
||||
const { browserWindowInstance } = createStartupRevealWindowFixture()
|
||||
|
||||
withPlatform('linux', () => {
|
||||
createMainWindow(null)
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(browserWindowInstance.show).not.toHaveBeenCalled()
|
||||
expect(browserWindowInstance.maximize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the headless E2E window hidden when the Windows fallback fires', () => {
|
||||
vi.useFakeTimers()
|
||||
const previousHeadless = process.env.ORCA_E2E_HEADLESS
|
||||
process.env.ORCA_E2E_HEADLESS = '1'
|
||||
const { browserWindowInstance } = createStartupRevealWindowFixture()
|
||||
|
||||
try {
|
||||
withPlatform('win32', () => {
|
||||
createMainWindow(createStartupRevealStore(true) as never)
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(browserWindowInstance.show).not.toHaveBeenCalled()
|
||||
expect(browserWindowInstance.maximize).not.toHaveBeenCalled()
|
||||
})
|
||||
} finally {
|
||||
if (previousHeadless === undefined) {
|
||||
delete process.env.ORCA_E2E_HEADLESS
|
||||
} else {
|
||||
process.env.ORCA_E2E_HEADLESS = previousHeadless
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('clears the Windows startup reveal fallback when the window is closed', () => {
|
||||
vi.useFakeTimers()
|
||||
const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture()
|
||||
|
||||
withPlatform('win32', () => {
|
||||
createMainWindow(createStartupRevealStore(true) as never)
|
||||
windowHandlers.closed()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(browserWindowInstance.show).not.toHaveBeenCalled()
|
||||
expect(browserWindowInstance.maximize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show or maximize a destroyed window when the Windows fallback fires', () => {
|
||||
vi.useFakeTimers()
|
||||
const { browserWindowInstance } = createStartupRevealWindowFixture()
|
||||
|
||||
withPlatform('win32', () => {
|
||||
createMainWindow(createStartupRevealStore(true) as never)
|
||||
browserWindowInstance.isDestroyed.mockReturnValue(true)
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(browserWindowInstance.show).not.toHaveBeenCalled()
|
||||
expect(browserWindowInstance.maximize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('minimize to tray on close (win32)', () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
|
||||
@@ -330,11 +330,34 @@ export function createMainWindow(
|
||||
// handler re-runs maximize() from the persisted savedMaximized flag, snapping
|
||||
// the window back to full-screen after the user already resized it (#591).
|
||||
let handledInitialReadyToShow = false
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
let initialRevealFallbackTimer: ReturnType<typeof setTimeout> | null =
|
||||
process.platform === 'win32'
|
||||
? setTimeout(() => {
|
||||
// Why: GPU/driver failures on Windows can prevent ready-to-show forever,
|
||||
// leaving the only app window hidden while the main process stays alive.
|
||||
initialRevealFallbackTimer = null
|
||||
revealInitialWindow()
|
||||
}, 10_000)
|
||||
: null
|
||||
initialRevealFallbackTimer?.unref?.()
|
||||
|
||||
const clearInitialRevealFallbackTimer = (): void => {
|
||||
if (initialRevealFallbackTimer) {
|
||||
clearTimeout(initialRevealFallbackTimer)
|
||||
initialRevealFallbackTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const revealInitialWindow = (): void => {
|
||||
if (mainWindow.isDestroyed()) {
|
||||
clearInitialRevealFallbackTimer()
|
||||
return
|
||||
}
|
||||
if (handledInitialReadyToShow) {
|
||||
return
|
||||
}
|
||||
handledInitialReadyToShow = true
|
||||
clearInitialRevealFallbackTimer()
|
||||
|
||||
// Why: in E2E headless mode, the window stays hidden to avoid stealing
|
||||
// focus and screen real estate during test runs. Playwright interacts
|
||||
@@ -347,7 +370,8 @@ export function createMainWindow(
|
||||
mainWindow.maximize()
|
||||
}
|
||||
mainWindow.show()
|
||||
})
|
||||
}
|
||||
mainWindow.on('ready-to-show', revealInitialWindow)
|
||||
|
||||
// Why: persist window bounds so the app restores to the user's last
|
||||
// position/size instead of maximizing on every launch. Debounce to avoid
|
||||
@@ -1117,6 +1141,7 @@ export function createMainWindow(
|
||||
|
||||
ipcMain.on(confirmCloseChannel, onConfirmClose)
|
||||
mainWindow.on('closed', () => {
|
||||
clearInitialRevealFallbackTimer()
|
||||
// Why: default-deny the Cmd+B carve-out after the window is gone so a
|
||||
// stale-true flag can't leak past subsequent state transitions. Paired
|
||||
// with the webContents lifecycle resets above.
|
||||
|
||||
@@ -391,6 +391,7 @@ import type {
|
||||
WorkspaceCleanupLocalProcessArgs,
|
||||
WorkspaceCleanupLocalProcessResult,
|
||||
WorkspaceCleanupScanArgs,
|
||||
WorkspaceCleanupScanProgress,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../shared/workspace-cleanup'
|
||||
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
|
||||
@@ -788,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[]>
|
||||
@@ -989,7 +995,10 @@ export type PreloadApi = {
|
||||
) => () => void
|
||||
}
|
||||
workspaceCleanup: {
|
||||
scan: (args?: WorkspaceCleanupScanArgs) => Promise<WorkspaceCleanupScanResult>
|
||||
scan: (
|
||||
args?: WorkspaceCleanupScanArgs,
|
||||
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
|
||||
) => Promise<WorkspaceCleanupScanResult>
|
||||
dismiss: (args: WorkspaceCleanupDismissArgs) => Promise<void>
|
||||
clearDismissals: () => Promise<void>
|
||||
hasKillableLocalProcesses: (
|
||||
|
||||
+19
-1
@@ -79,6 +79,7 @@ import type {
|
||||
RateLimitState
|
||||
} from '../shared/rate-limit-types'
|
||||
import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types'
|
||||
import type { WorkspaceCleanupScanProgress } from '../shared/workspace-cleanup'
|
||||
import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports'
|
||||
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
|
||||
import type { TaskSourceContext } from '../shared/task-source-context'
|
||||
@@ -662,7 +663,24 @@ const api = {
|
||||
} satisfies PreloadApi['worktrees'],
|
||||
|
||||
workspaceCleanup: {
|
||||
scan: (args) => ipcRenderer.invoke('workspaceCleanup:scan', args),
|
||||
scan: (args, onProgress) => {
|
||||
if (!onProgress) {
|
||||
return ipcRenderer.invoke('workspaceCleanup:scan', args)
|
||||
}
|
||||
const scanId = args?.scanId ?? crypto.randomUUID()
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
progress: WorkspaceCleanupScanProgress
|
||||
): void => {
|
||||
if (progress.scanId === scanId) {
|
||||
onProgress(progress)
|
||||
}
|
||||
}
|
||||
ipcRenderer.on('workspaceCleanup:scanProgress', listener)
|
||||
return ipcRenderer
|
||||
.invoke('workspaceCleanup:scan', { ...args, scanId })
|
||||
.finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener))
|
||||
},
|
||||
dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args),
|
||||
clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'),
|
||||
hasKillableLocalProcesses: (args) =>
|
||||
|
||||
@@ -1660,6 +1660,97 @@ describe('GitHandler', () => {
|
||||
expect(result[0].isMainWorktree).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'normalizes the main worktree path for a separate-git-dir repo',
|
||||
async () => {
|
||||
const sourcePath = path.join(tmpDir, 'source')
|
||||
const worktreePath = path.join(tmpDir, 'worktree')
|
||||
const gitDirPath = path.join(tmpDir, 'git-store', 'project.git')
|
||||
mkdirSync(sourcePath)
|
||||
mkdirSync(path.dirname(gitDirPath), { recursive: true })
|
||||
gitInit(sourcePath)
|
||||
writeFileSync(path.join(sourcePath, 'file.txt'), 'hello')
|
||||
gitCommit(sourcePath, 'initial')
|
||||
|
||||
execFileSync('git', [
|
||||
'clone',
|
||||
'--quiet',
|
||||
`--separate-git-dir=${gitDirPath}`,
|
||||
sourcePath,
|
||||
worktreePath
|
||||
])
|
||||
|
||||
const result = (await dispatcher.callRequest('git.listWorktrees', {
|
||||
repoPath: await fs.realpath(worktreePath)
|
||||
})) as Record<string, unknown>[]
|
||||
const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true)
|
||||
|
||||
expect(mainWorktree).toMatchObject({
|
||||
path: await fs.realpath(worktreePath),
|
||||
isMainWorktree: true
|
||||
})
|
||||
expect(mainWorktree?.path).not.toBe(await fs.realpath(gitDirPath))
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'leaves an ordinary repo reached via a symlinked path unchanged',
|
||||
async () => {
|
||||
// A symlink alias defeats the path-string gate (git reports the
|
||||
// realpath toplevel); the git-common-dir gate must still skip the
|
||||
// rewrite for an ordinary repo so the reported path is untouched.
|
||||
const repoPath = path.join(tmpDir, 'plain-repo')
|
||||
mkdirSync(repoPath)
|
||||
gitInit(repoPath)
|
||||
writeFileSync(path.join(repoPath, 'file.txt'), 'hello')
|
||||
gitCommit(repoPath, 'initial')
|
||||
const linkedRepoPath = path.join(tmpDir, 'linked-repo')
|
||||
symlinkSync(repoPath, linkedRepoPath)
|
||||
|
||||
const result = (await dispatcher.callRequest('git.listWorktrees', {
|
||||
repoPath: linkedRepoPath
|
||||
})) as Record<string, unknown>[]
|
||||
const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true)
|
||||
|
||||
expect(mainWorktree).toMatchObject({
|
||||
path: await fs.realpath(repoPath),
|
||||
isMainWorktree: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'leaves the main entry unchanged when scanned via a linked worktree',
|
||||
async () => {
|
||||
// A linked worktree has a `.git` pointer file like a separate-git-dir
|
||||
// checkout, but its porcelain main entry is the real main working root.
|
||||
// The git-common-dir gate must skip the rewrite so the main entry is
|
||||
// not overwritten with the linked worktree's own toplevel.
|
||||
const repoPath = path.join(tmpDir, 'main-repo')
|
||||
mkdirSync(repoPath)
|
||||
gitInit(repoPath)
|
||||
writeFileSync(path.join(repoPath, 'file.txt'), 'hello')
|
||||
gitCommit(repoPath, 'initial')
|
||||
const linkedWorktreePath = path.join(tmpDir, 'linked-wt')
|
||||
execFileSync('git', ['worktree', 'add', '--quiet', linkedWorktreePath, '-b', 'feature'], {
|
||||
cwd: repoPath,
|
||||
stdio: 'pipe'
|
||||
})
|
||||
const resolvedLinked = await fs.realpath(linkedWorktreePath)
|
||||
|
||||
const result = (await dispatcher.callRequest('git.listWorktrees', {
|
||||
repoPath: resolvedLinked
|
||||
})) as Record<string, unknown>[]
|
||||
const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true)
|
||||
|
||||
expect(mainWorktree).toMatchObject({
|
||||
path: await fs.realpath(repoPath),
|
||||
isMainWorktree: true
|
||||
})
|
||||
expect(mainWorktree?.path).not.toBe(resolvedLinked)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'lists worktrees whose paths contain newlines',
|
||||
async () => {
|
||||
|
||||
+120
-2
@@ -20,6 +20,7 @@ import {
|
||||
} from './git-handler-ops'
|
||||
import { commitCompare as commitCompareOp, commitDiffEntry } from './git-handler-commit-diff-ops'
|
||||
import {
|
||||
areRelayWorktreePathsEqual,
|
||||
commitChangesRelay,
|
||||
addWorktreeOp,
|
||||
removeWorktreeOp,
|
||||
@@ -52,6 +53,64 @@ const execFileAsync = promisify(execFile)
|
||||
const MAX_GIT_BUFFER = 10 * 1024 * 1024
|
||||
const BULK_CHUNK_SIZE = 100
|
||||
|
||||
function getErrorText(error: unknown): string {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const parts: string[] = []
|
||||
if ('message' in error && typeof error.message === 'string') {
|
||||
parts.push(error.message)
|
||||
}
|
||||
if ('stderr' in error && typeof error.stderr === 'string') {
|
||||
parts.push(error.stderr)
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
|
||||
return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test(
|
||||
getErrorText(error)
|
||||
)
|
||||
}
|
||||
|
||||
function isWindowsAbsolutePath(value: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')
|
||||
}
|
||||
|
||||
function resolveRelayPath(repoPath: string, value: string): string {
|
||||
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) {
|
||||
return value
|
||||
}
|
||||
// Old git ignores `--path-format=absolute`, so a relative toplevel/git-dir
|
||||
// must be resolved against the scanned repo path. Mirror worktree.ts's
|
||||
// resolveRevParsePath: pick the win32/posix resolver from the repoPath shape.
|
||||
return isWindowsAbsolutePath(repoPath)
|
||||
? path.win32.resolve(repoPath, value)
|
||||
: path.posix.resolve(repoPath, value)
|
||||
}
|
||||
|
||||
type RelayRepoLocation = { topLevel: string; commonDir: string }
|
||||
|
||||
function parseRelayRepoLocation(repoPath: string, output: string): RelayRepoLocation | undefined {
|
||||
// Old git (pre `--path-format`) echoes the unrecognized flag to stdout and
|
||||
// exits 0 rather than erroring, so drop any echoed `-`-prefixed lines and
|
||||
// read the two trailing path lines (toplevel, then git-common-dir). Strip only
|
||||
// the trailing CR, not surrounding spaces — git paths may legitimately start
|
||||
// or end with a space.
|
||||
const lines = output
|
||||
.split('\n')
|
||||
.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
|
||||
.filter((line) => line.length > 0 && !line.startsWith('-'))
|
||||
if (lines.length < 2) {
|
||||
return undefined
|
||||
}
|
||||
const [topLevel, commonDir] = lines.slice(-2)
|
||||
return {
|
||||
topLevel: resolveRelayPath(repoPath, topLevel),
|
||||
commonDir: resolveRelayPath(repoPath, commonDir)
|
||||
}
|
||||
}
|
||||
|
||||
function execFileWithStdin(
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -998,11 +1057,70 @@ export class GitHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async readRepoLocation(repoPath: string): Promise<RelayRepoLocation | undefined> {
|
||||
try {
|
||||
const { stdout } = await this.git(
|
||||
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
|
||||
repoPath
|
||||
)
|
||||
return parseRelayRepoLocation(repoPath, stdout)
|
||||
} catch (error) {
|
||||
if (!isUnsupportedRevParsePathFormatError(error)) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await this.git(
|
||||
['rev-parse', '--show-toplevel', '--git-common-dir'],
|
||||
repoPath
|
||||
)
|
||||
return parseRelayRepoLocation(repoPath, stdout)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async normalizeMainWorktreePath(
|
||||
repoPath: string,
|
||||
worktrees: Record<string, unknown>[]
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree === true)
|
||||
const mainWorktree = worktrees[mainIndex]
|
||||
const mainPath = typeof mainWorktree?.path === 'string' ? mainWorktree.path : ''
|
||||
// Expand `~` so the early-return matches git's absolute porcelain path for
|
||||
// legacy SSH repos stored with a tilde, sparing them a rev-parse per poll.
|
||||
const resolvedRepoPath = expandTilde(repoPath)
|
||||
if (!mainPath || areRelayWorktreePathsEqual(mainPath, resolvedRepoPath)) {
|
||||
return worktrees
|
||||
}
|
||||
|
||||
const location = await this.readRepoLocation(resolvedRepoPath)
|
||||
if (!location) {
|
||||
return worktrees
|
||||
}
|
||||
|
||||
// Why: only a separate-git-dir/submodule main worktree reports the Git
|
||||
// directory as the main entry — i.e. the main entry equals git-common-dir.
|
||||
// A linked worktree's main entry is a real working root, so gating on this
|
||||
// equality avoids overwriting it with the linked worktree's own toplevel.
|
||||
if (!areRelayWorktreePathsEqual(mainPath, location.commonDir)) {
|
||||
return worktrees
|
||||
}
|
||||
|
||||
const normalized = [...worktrees]
|
||||
normalized[mainIndex] = { ...mainWorktree, path: location.topLevel }
|
||||
return normalized
|
||||
}
|
||||
|
||||
private async listWorktrees(params: Record<string, unknown>) {
|
||||
const repoPath = params.repoPath as string
|
||||
try {
|
||||
const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath)
|
||||
return parseWorktreeList(stdout, { nulDelimited: true })
|
||||
return this.normalizeMainWorktreePath(
|
||||
repoPath,
|
||||
parseWorktreeList(stdout, { nulDelimited: true })
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isUnsupportedWorktreeListZError(error)) {
|
||||
return []
|
||||
@@ -1013,7 +1131,7 @@ export class GitHandler {
|
||||
// Git rejects it. Fall back to the original line-block parser there.
|
||||
try {
|
||||
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath)
|
||||
return parseWorktreeList(stdout)
|
||||
return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
|
||||
import {
|
||||
resolveSetupAgentSequenceLaunchCommand,
|
||||
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
|
||||
} from '../shared/setup-agent-sequencing'
|
||||
|
||||
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
|
||||
mockPtySpawn: vi.fn(),
|
||||
@@ -165,8 +169,34 @@ describe('PtyHandler', () => {
|
||||
expect(handler.activePtyCount).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps SSH spawn commands as hints unless provider delivery is requested', async () => {
|
||||
await dispatcher.callRequest('pty.spawn', { command: 'echo renderer-owned' })
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
const term = mockPtySpawn.mock.results[0]?.value
|
||||
expect(term.write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits provider-delivered spawn commands to the relay shell', async () => {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
command: 'echo provider-owned',
|
||||
commandDelivery: 'provider'
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(49)
|
||||
const term = mockPtySpawn.mock.results[0]?.value
|
||||
expect(handler.retainedStartupCommandCount).toBe(1)
|
||||
expect(term.write).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
const submit = process.platform === 'win32' ? '\r' : '\n'
|
||||
expect(term.write).toHaveBeenCalledWith(`echo provider-owned${submit}`)
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'enables shell-ready marker env for delivery-hinted startup commands',
|
||||
'emits shell-ready markers for renderer-delivered startup commands',
|
||||
async () => {
|
||||
const oldShell = process.env.SHELL
|
||||
const oldHome = process.env.HOME
|
||||
@@ -177,6 +207,7 @@ describe('PtyHandler', () => {
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: { HOME: homeDir },
|
||||
command: 'echo renderer-owned',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
} finally {
|
||||
@@ -197,11 +228,12 @@ describe('PtyHandler', () => {
|
||||
| { env?: Record<string, string> }
|
||||
| undefined
|
||||
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'enables shell-ready marker env for Codex native prefill commands',
|
||||
'emits shell-ready markers for renderer-delivered Codex native prefill commands',
|
||||
async () => {
|
||||
const oldShell = process.env.SHELL
|
||||
const oldHome = process.env.HOME
|
||||
@@ -232,6 +264,197 @@ describe('PtyHandler', () => {
|
||||
| { env?: Record<string, string> }
|
||||
| undefined
|
||||
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'enables shell-ready marker env for provider-delivered startup commands',
|
||||
async () => {
|
||||
const oldShell = process.env.SHELL
|
||||
const oldHome = process.env.HOME
|
||||
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-env-'))
|
||||
|
||||
process.env.SHELL = '/bin/bash'
|
||||
process.env.HOME = homeDir
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: { HOME: homeDir },
|
||||
command: 'echo provider-owned',
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
} finally {
|
||||
if (oldShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = oldShell
|
||||
}
|
||||
if (oldHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = oldHome
|
||||
}
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
|
||||
| { env?: Record<string, string> }
|
||||
| undefined
|
||||
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'uses the sequenced startup command hint for provider shell-ready detection',
|
||||
async () => {
|
||||
const oldShell = process.env.SHELL
|
||||
const oldHome = process.env.HOME
|
||||
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-sequenced-ready-env-'))
|
||||
|
||||
process.env.SHELL = '/bin/bash'
|
||||
process.env.HOME = homeDir
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: {
|
||||
HOME: homeDir,
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'"
|
||||
},
|
||||
command: 'bash -lc wait-for-setup-wrapper',
|
||||
commandDelivery: 'provider'
|
||||
})
|
||||
} finally {
|
||||
if (oldShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = oldShell
|
||||
}
|
||||
if (oldHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = oldHome
|
||||
}
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
|
||||
| { env?: Record<string, string> }
|
||||
| undefined
|
||||
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'waits for the shell-ready marker before provider-delivered startup commands',
|
||||
async () => {
|
||||
let dataCallback: ((data: string) => void) | undefined
|
||||
const term = {
|
||||
...mockPtyInstance,
|
||||
onData: vi.fn((cb: (data: string) => void) => {
|
||||
dataCallback = cb
|
||||
}),
|
||||
onExit: vi.fn()
|
||||
}
|
||||
mockPtySpawn.mockReturnValue(term)
|
||||
const oldShell = process.env.SHELL
|
||||
const oldHome = process.env.HOME
|
||||
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-spawn-'))
|
||||
|
||||
process.env.SHELL = '/bin/bash'
|
||||
process.env.HOME = homeDir
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: { HOME: homeDir },
|
||||
command: 'echo after-ready',
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
} finally {
|
||||
if (oldShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = oldShell
|
||||
}
|
||||
if (oldHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = oldHome
|
||||
}
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(1499)
|
||||
expect(term.write).not.toHaveBeenCalled()
|
||||
|
||||
dataCallback?.('\x1b]777;orca-shell-ready\x07user@remote $ ')
|
||||
vi.advanceTimersByTime(49)
|
||||
expect(term.write).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
|
||||
expect(term.write).toHaveBeenCalledWith('echo after-ready\n')
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
vi.advanceTimersByTime(8)
|
||||
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
|
||||
id: 'pty-1',
|
||||
data: 'user@remote $ '
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'flushes held shell-ready marker bytes when provider delivery falls back',
|
||||
async () => {
|
||||
let dataCallback: ((data: string) => void) | undefined
|
||||
const term = {
|
||||
...mockPtyInstance,
|
||||
onData: vi.fn((cb: (data: string) => void) => {
|
||||
dataCallback = cb
|
||||
}),
|
||||
onExit: vi.fn()
|
||||
}
|
||||
mockPtySpawn.mockReturnValue(term)
|
||||
const oldShell = process.env.SHELL
|
||||
const oldHome = process.env.HOME
|
||||
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-fallback-spawn-'))
|
||||
|
||||
process.env.SHELL = '/bin/bash'
|
||||
process.env.HOME = homeDir
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: { HOME: homeDir },
|
||||
command: 'echo fallback',
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
} finally {
|
||||
if (oldShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = oldShell
|
||||
}
|
||||
if (oldHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = oldHome
|
||||
}
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
dataCallback?.('\x1b]777;orca-shell-ready')
|
||||
vi.advanceTimersByTime(1500)
|
||||
|
||||
expect(term.write).toHaveBeenCalledWith('echo fallback\n')
|
||||
vi.advanceTimersByTime(8)
|
||||
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
|
||||
id: 'pty-1',
|
||||
data: '\x1b]777;orca-shell-ready'
|
||||
})
|
||||
|
||||
const result = await dispatcher.callRequest('pty.attach', {
|
||||
id: 'pty-1',
|
||||
suppressReplayNotification: true
|
||||
})
|
||||
expect(result).toEqual({ replay: '\x1b]777;orca-shell-ready' })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -252,6 +475,42 @@ describe('PtyHandler', () => {
|
||||
expect(killSpy).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('does not submit provider-delivered commands for stale spawn responses', async () => {
|
||||
const killSpy = vi.fn()
|
||||
const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() }
|
||||
mockPtySpawn.mockReturnValue(term)
|
||||
|
||||
await dispatcher.callRequest(
|
||||
'pty.spawn',
|
||||
{ command: 'echo stale', commandDelivery: 'provider' },
|
||||
{ isStale: () => true }
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(term.write).not.toHaveBeenCalled()
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
expect(killSpy).toHaveBeenCalledWith('SIGTERM')
|
||||
})
|
||||
|
||||
it('releases pending provider-delivered commands on shutdown before delivery', async () => {
|
||||
const killSpy = vi.fn()
|
||||
const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() }
|
||||
mockPtySpawn.mockReturnValue(term)
|
||||
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
command: 'echo stop-before-run',
|
||||
commandDelivery: 'provider'
|
||||
})
|
||||
expect(handler.retainedStartupCommandCount).toBe(1)
|
||||
|
||||
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
expect(term.write).not.toHaveBeenCalled()
|
||||
expect(killSpy).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('increments PTY ids on each spawn', async () => {
|
||||
const r1 = await dispatcher.callRequest('pty.spawn', {})
|
||||
const r2 = await dispatcher.callRequest('pty.spawn', {})
|
||||
@@ -759,6 +1018,20 @@ describe('PtyHandler', () => {
|
||||
expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi')
|
||||
})
|
||||
|
||||
it('lets relay env augmenters resolve the original sequenced startup command hint', async () => {
|
||||
handler.addEnvAugmenter((ctx) => ({
|
||||
SEEN_LAUNCH_COMMAND_HINT: resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) ?? ''
|
||||
}))
|
||||
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
command: 'powershell wait-wrapper',
|
||||
env: { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' }
|
||||
})
|
||||
|
||||
const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
|
||||
expect(spawnEnv.env.SEEN_LAUNCH_COMMAND_HINT).toBe('omp --resume')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'wraps bash spawns to restore overlay env after remote startup files',
|
||||
async () => {
|
||||
|
||||
+135
-14
@@ -13,6 +13,13 @@ import {
|
||||
import { getRelayShellLaunchConfig } from './pty-shell-launch'
|
||||
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
|
||||
import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery'
|
||||
import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
|
||||
import {
|
||||
createShellReadyScanState,
|
||||
drainShellReadyHeldBytes,
|
||||
scanForShellReady,
|
||||
type ShellReadyScanState
|
||||
} from '../main/shell-ready-marker-scanner'
|
||||
|
||||
// Why: node-pty is a native addon that may not be installed on the remote.
|
||||
// Dynamic import keeps the require() lazy so loadPty() returns null gracefully
|
||||
@@ -53,12 +60,21 @@ type ManagedPty = {
|
||||
paneKey?: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
startupCommand?: ManagedStartupCommand
|
||||
}
|
||||
|
||||
type PendingPtyOutput = {
|
||||
data: string
|
||||
}
|
||||
|
||||
type ManagedStartupCommand = {
|
||||
command: string
|
||||
delivered: boolean
|
||||
waitForShellReady: boolean
|
||||
scanState: ShellReadyScanState | null
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
function disposeManagedPty(managed: ManagedPty): void {
|
||||
if (managed.disposed) {
|
||||
return
|
||||
@@ -98,6 +114,8 @@ const INTERACTIVE_OUTPUT_WINDOW_MS = 100
|
||||
const INTERACTIVE_OUTPUT_MAX_CHARS = 1024
|
||||
const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS
|
||||
const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024
|
||||
const STARTUP_COMMAND_WRITE_DELAY_MS = 50
|
||||
const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500
|
||||
const ALLOWED_SIGNALS = new Set([
|
||||
'SIGINT',
|
||||
'SIGTERM',
|
||||
@@ -222,14 +240,71 @@ export class PtyHandler {
|
||||
return { ...baseEnv, ...augmented }
|
||||
}
|
||||
|
||||
private clearStartupCommandTimer(managed: ManagedPty): void {
|
||||
if (managed.startupCommand?.timer) {
|
||||
clearTimeout(managed.startupCommand.timer)
|
||||
managed.startupCommand.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
private appendReplayBuffer(managed: ManagedPty, data: string): void {
|
||||
managed.buffered += data
|
||||
if (managed.buffered.length > REPLAY_BUFFER_MAX) {
|
||||
managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX)
|
||||
}
|
||||
}
|
||||
|
||||
private releaseStartupCommand(managed: ManagedPty): void {
|
||||
this.clearStartupCommandTimer(managed)
|
||||
managed.startupCommand = undefined
|
||||
}
|
||||
|
||||
private scheduleStartupCommandDelivery(managed: ManagedPty, delayMs: number): void {
|
||||
const startup = managed.startupCommand
|
||||
if (!startup || startup.delivered || managed.disposed) {
|
||||
return
|
||||
}
|
||||
this.clearStartupCommandTimer(managed)
|
||||
startup.timer = setTimeout(() => {
|
||||
startup.timer = null
|
||||
this.deliverStartupCommand(managed)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
private deliverStartupCommand(managed: ManagedPty): void {
|
||||
const startup = managed.startupCommand
|
||||
if (!startup || startup.delivered || managed.disposed) {
|
||||
return
|
||||
}
|
||||
startup.delivered = true
|
||||
this.clearStartupCommandTimer(managed)
|
||||
if (startup.scanState) {
|
||||
const heldBytes = drainShellReadyHeldBytes(startup.scanState)
|
||||
if (heldBytes) {
|
||||
this.appendReplayBuffer(managed, heldBytes)
|
||||
this.enqueuePtyOutput(managed.id, heldBytes)
|
||||
}
|
||||
}
|
||||
const submit = process.platform === 'win32' ? '\r' : '\n'
|
||||
const endsWithSubmit = startup.command.endsWith('\r') || startup.command.endsWith('\n')
|
||||
const payload = endsWithSubmit ? startup.command : `${startup.command}${submit}`
|
||||
managed.startupCommand = undefined
|
||||
managed.pty.write(payload)
|
||||
}
|
||||
|
||||
/** Wire onData/onExit listeners for a managed PTY and store it. */
|
||||
private wireAndStore(managed: ManagedPty): void {
|
||||
this.ptys.set(managed.id, managed)
|
||||
managed.pty.onData((data: string) => {
|
||||
managed.buffered += data
|
||||
if (managed.buffered.length > REPLAY_BUFFER_MAX) {
|
||||
managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX)
|
||||
const startup = managed.startupCommand
|
||||
if (startup?.waitForShellReady && startup.scanState && !startup.delivered) {
|
||||
const scanned = scanForShellReady(startup.scanState, data)
|
||||
data = scanned.output
|
||||
if (scanned.matched) {
|
||||
this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS)
|
||||
}
|
||||
}
|
||||
this.appendReplayBuffer(managed, data)
|
||||
this.enqueuePtyOutput(managed.id, data)
|
||||
})
|
||||
managed.pty.onExit(({ exitCode }: { exitCode: number }) => {
|
||||
@@ -253,6 +328,7 @@ export class PtyHandler {
|
||||
clearTimeout(managed.killTimer)
|
||||
managed.killTimer = undefined
|
||||
}
|
||||
this.clearStartupCommandTimer(managed)
|
||||
this.flushPtyOutput(managed.id)
|
||||
this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode })
|
||||
this.notifyExitListener(managed)
|
||||
@@ -428,20 +504,26 @@ export class PtyHandler {
|
||||
// dirs) override renderer-supplied env so live remote paths and hook coords
|
||||
// win over local userData paths. The context lets overlay augmenters derive
|
||||
// per-PTY OpenCode/Pi directories from the stable paneKey when present.
|
||||
// `command` is forwarded by ssh-pty-provider.ts only as a hint for
|
||||
// overlay resolution — the relay still launches a login shell and the
|
||||
// command is typed in via pty.data writes.
|
||||
// `command` is usually forwarded by ssh-pty-provider.ts only as a hint
|
||||
// for overlay resolution; runtime-owned PTYs opt into relay delivery
|
||||
// because no renderer TerminalPane exists to type the command.
|
||||
const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
|
||||
const command = typeof params.command === 'string' ? params.command : undefined
|
||||
const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer'
|
||||
const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined
|
||||
const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command })
|
||||
// Why: only explicit shell-ready hints are trusted here; native Codex
|
||||
// prefill detection still auto-enables readiness through the predicate.
|
||||
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, {
|
||||
emitReadyMarker: shouldUseShellReadyStartupDelivery({
|
||||
command,
|
||||
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command)
|
||||
const shouldEmitShellReadyMarker =
|
||||
launchCommandHint !== undefined &&
|
||||
shouldUseShellReadyStartupDelivery({
|
||||
command: launchCommandHint,
|
||||
startupCommandDelivery:
|
||||
params.startupCommandDelivery === 'shell-ready' ? 'shell-ready' : undefined
|
||||
})
|
||||
// Why: renderer- and provider-delivered startup commands both use this
|
||||
// marker; the side responsible for delivery also strips it from output.
|
||||
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, {
|
||||
emitReadyMarker: shouldEmitShellReadyMarker
|
||||
})
|
||||
|
||||
// Why: SSH exec channels give the relay a minimal environment without
|
||||
@@ -454,7 +536,9 @@ export class PtyHandler {
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env: { ...spawnEnv, ...shellLaunch.env }
|
||||
// Why: relay shells inherit process.env; never let an ambient Orca marker
|
||||
// enable shell-ready behavior unless this spawn explicitly requested it.
|
||||
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
|
||||
})
|
||||
|
||||
// Why: capture the renderer-supplied paneKey on the managed entry so the
|
||||
@@ -470,13 +554,28 @@ export class PtyHandler {
|
||||
buffered: '',
|
||||
paneKey,
|
||||
tabId,
|
||||
worktreeId
|
||||
worktreeId,
|
||||
...(shouldProviderDeliverCommand
|
||||
? {
|
||||
startupCommand: {
|
||||
command,
|
||||
delivered: false,
|
||||
waitForShellReady: shellLaunch.env.ORCA_SHELL_READY_MARKER === '1',
|
||||
scanState:
|
||||
shellLaunch.env.ORCA_SHELL_READY_MARKER === '1'
|
||||
? createShellReadyScanState()
|
||||
: null,
|
||||
timer: null
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
this.wireAndStore(managed)
|
||||
if (context?.isStale()) {
|
||||
// Why: if the client reconnected while pty.spawn was in flight, the
|
||||
// response is discarded and no renderer can own this PTY. Shut it down
|
||||
// immediately so it does not linger as an unreachable remote shell.
|
||||
this.releaseStartupCommand(managed)
|
||||
term.kill('SIGTERM')
|
||||
managed.killTimer = setTimeout(() => {
|
||||
const still = this.ptys.get(id)
|
||||
@@ -491,6 +590,13 @@ export class PtyHandler {
|
||||
this.ptys.delete(id)
|
||||
}
|
||||
}, 5000)
|
||||
} else if (managed.startupCommand) {
|
||||
this.scheduleStartupCommandDelivery(
|
||||
managed,
|
||||
managed.startupCommand.waitForShellReady
|
||||
? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS
|
||||
: STARTUP_COMMAND_WRITE_DELAY_MS
|
||||
)
|
||||
}
|
||||
return { id }
|
||||
}
|
||||
@@ -562,6 +668,7 @@ export class PtyHandler {
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
this.releaseStartupCommand(managed)
|
||||
this.flushPtyOutput(id)
|
||||
managed.pty.kill('SIGKILL')
|
||||
// Why: SIGKILL has already reaped the child; release the ptmx fd on the
|
||||
@@ -581,6 +688,7 @@ export class PtyHandler {
|
||||
this.ptys.delete(id)
|
||||
this.clearPtyFlowState(id)
|
||||
} else {
|
||||
this.releaseStartupCommand(managed)
|
||||
managed.pty.kill('SIGTERM')
|
||||
|
||||
// Why: Some processes ignore SIGTERM (e.g. a hung child, a custom signal
|
||||
@@ -761,7 +869,9 @@ export class PtyHandler {
|
||||
cols: entry.cols,
|
||||
rows: entry.rows,
|
||||
cwd: entry.cwd,
|
||||
env: { ...spawnEnv, ...shellLaunch.env }
|
||||
// Why: revived shells should not inherit an ambient shell-ready marker
|
||||
// because no provider-delivered startup command is waiting on it.
|
||||
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
|
||||
})
|
||||
this.wireAndStore({
|
||||
id: entry.id,
|
||||
@@ -821,6 +931,7 @@ export class PtyHandler {
|
||||
clearTimeout(managed.killTimer)
|
||||
managed.killTimer = undefined
|
||||
}
|
||||
this.clearStartupCommandTimer(managed)
|
||||
// Why: SIGKILL (not SIGTERM) before destroy. The relay process is
|
||||
// exiting; any SIGTERM-ignoring remote shell (editor with unsaved
|
||||
// buffers, a hung child with a bad handler, a process in
|
||||
@@ -843,6 +954,16 @@ export class PtyHandler {
|
||||
return this.ptys.size
|
||||
}
|
||||
|
||||
get retainedStartupCommandCount(): number {
|
||||
let count = 0
|
||||
for (const managed of this.ptys.values()) {
|
||||
if (managed.startupCommand) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
get graceTimerActive(): boolean {
|
||||
return this.graceTimer !== null
|
||||
}
|
||||
|
||||
+5
-2
@@ -57,6 +57,7 @@ import {
|
||||
import { assertPluginSourceUnderByteCap } from './plugin-source-limit'
|
||||
import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env'
|
||||
import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind'
|
||||
import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
|
||||
import { pickRemoteCliEnv } from './remote-cli-env'
|
||||
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
|
||||
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
|
||||
@@ -536,8 +537,10 @@ async function main(): Promise<void> {
|
||||
// Why: source-dir defaulting is keyed on which Pi-compatible agent is
|
||||
// being launched (Pi vs OMP). Install Orca's guarded extension into that
|
||||
// real remote agent dir without redirecting PI_CODING_AGENT_DIR.
|
||||
const kind = detectPiAgentKindFromCommand(ctx.command)
|
||||
const hasLaunchCommand = typeof ctx.command === 'string' && ctx.command.trim().length > 0
|
||||
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command)
|
||||
const kind = detectPiAgentKindFromCommand(launchCommandHint)
|
||||
const hasLaunchCommand =
|
||||
typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0
|
||||
const shouldPrepareOmpShadow = kind === 'omp' || !hasLaunchCommand
|
||||
if (kind === 'pi') {
|
||||
const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'pi')
|
||||
|
||||
@@ -150,6 +150,8 @@ function renderCard(
|
||||
requiresExplicitSetupChoice={false}
|
||||
setupDecision={null}
|
||||
onSetupDecisionChange={() => {}}
|
||||
setupAgentStartupPolicy="start-immediately"
|
||||
onSetupAgentStartupPolicyChange={() => {}}
|
||||
shouldWaitForSetupCheck={false}
|
||||
resolvedSetupDecision={null}
|
||||
createError={null}
|
||||
@@ -266,6 +268,58 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
|
||||
expect(onChanges).toEqual([true])
|
||||
})
|
||||
|
||||
it('shows the setup startup policy toggle only when setup is available', () => {
|
||||
current = renderCard({
|
||||
advancedOpen: true,
|
||||
setupControlsEnabled: true,
|
||||
setupConfig: {
|
||||
source: 'yaml',
|
||||
command: '# defaultTabs[1]\npnpm dev',
|
||||
kind: 'default-tabs'
|
||||
}
|
||||
})
|
||||
expect(current.container.textContent).not.toContain(
|
||||
'Wait for setup to complete before starting agent'
|
||||
)
|
||||
|
||||
act(() => current?.root.unmount())
|
||||
current?.container.remove()
|
||||
|
||||
current = renderCard({
|
||||
advancedOpen: true,
|
||||
setupControlsEnabled: true,
|
||||
setupConfig: {
|
||||
source: 'yaml',
|
||||
command: 'pnpm install',
|
||||
kind: 'setup'
|
||||
}
|
||||
})
|
||||
expect(current.container.textContent).toContain(
|
||||
'Wait for setup to complete before starting agent'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits the setup startup policy toggle value', () => {
|
||||
const changes: string[] = []
|
||||
current = renderCard({
|
||||
advancedOpen: true,
|
||||
setupControlsEnabled: true,
|
||||
setupConfig: {
|
||||
source: 'yaml',
|
||||
command: 'pnpm install',
|
||||
kind: 'setup'
|
||||
},
|
||||
onSetupAgentStartupPolicyChange: (next) => changes.push(next)
|
||||
})
|
||||
|
||||
const waitSwitch = current.container.querySelector<HTMLElement>(
|
||||
'[role="switch"][aria-label="Wait for setup to complete before starting agent"]'
|
||||
)
|
||||
expect(waitSwitch).toBeTruthy()
|
||||
act(() => waitSwitch?.click())
|
||||
expect(changes).toEqual(['wait-for-setup'])
|
||||
})
|
||||
|
||||
it('does not disable folder workspace creation when only source lookup needs SSH', () => {
|
||||
current = renderCard({
|
||||
eligibleRepos: [
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { SettingsSwitch } from '@/components/settings/SettingsFormControls'
|
||||
import type RepoCombobox from '@/components/repo/RepoCombobox'
|
||||
import AgentCombobox from '@/components/agent/AgentCombobox'
|
||||
import { getAgentCatalog } from '@/lib/agent-catalog'
|
||||
@@ -35,6 +36,7 @@ import type {
|
||||
GitHubWorkItem,
|
||||
GitLabWorkItem,
|
||||
LinearIssue,
|
||||
SetupAgentStartupPolicy,
|
||||
SparsePreset,
|
||||
TuiAgent
|
||||
} from '../../../shared/types'
|
||||
@@ -116,6 +118,8 @@ type NewWorkspaceComposerCardProps = {
|
||||
requiresExplicitSetupChoice: boolean
|
||||
setupDecision: 'run' | 'skip' | null
|
||||
onSetupDecisionChange: (value: 'run' | 'skip') => void
|
||||
setupAgentStartupPolicy: SetupAgentStartupPolicy
|
||||
onSetupAgentStartupPolicyChange: (value: SetupAgentStartupPolicy) => void
|
||||
shouldWaitForSetupCheck: boolean
|
||||
resolvedSetupDecision: 'run' | 'skip' | null
|
||||
createError: WorkspaceCreateErrorDisplay | null
|
||||
@@ -354,6 +358,8 @@ export default function NewWorkspaceComposerCard({
|
||||
requiresExplicitSetupChoice,
|
||||
setupDecision,
|
||||
onSetupDecisionChange,
|
||||
setupAgentStartupPolicy,
|
||||
onSetupAgentStartupPolicyChange,
|
||||
shouldWaitForSetupCheck,
|
||||
resolvedSetupDecision,
|
||||
createError,
|
||||
@@ -421,6 +427,10 @@ export default function NewWorkspaceComposerCard({
|
||||
? 'Run commands now'
|
||||
: 'Run setup now'
|
||||
const setupSkipButtonLabel = setupConfig?.kind === 'setup' ? 'Skip for now' : 'Skip commands'
|
||||
// Why: defaultTabs launch commands can be long-running too, but they are not
|
||||
// the setup command this setting gates agent startup on.
|
||||
const showSetupAgentStartupPolicy =
|
||||
setupControlsEnabled && setupConfig !== null && setupConfig.kind !== 'default-tabs'
|
||||
|
||||
const handleSetDefaultAgent = React.useCallback(
|
||||
(next: TuiAgent | 'blank' | null) => {
|
||||
@@ -1004,6 +1014,39 @@ export default function NewWorkspaceComposerCard({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showSetupAgentStartupPolicy ? (
|
||||
<div className="flex items-start justify-between gap-3 rounded-md border border-border/60 bg-muted/25 p-3">
|
||||
<span className="min-w-0 space-y-1">
|
||||
<span className="block text-xs font-medium text-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
|
||||
'Wait for setup to complete before starting agent'
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgentHelp',
|
||||
'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<SettingsSwitch
|
||||
checked={setupAgentStartupPolicy === 'wait-for-setup'}
|
||||
onChange={() =>
|
||||
onSetupAgentStartupPolicyChange(
|
||||
setupAgentStartupPolicy === 'wait-for-setup'
|
||||
? 'start-immediately'
|
||||
: 'wait-for-setup'
|
||||
)
|
||||
}
|
||||
ariaLabel={translate(
|
||||
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
|
||||
'Wait for setup to complete before starting agent'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useRepoById } from '@/store/selectors'
|
||||
import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color'
|
||||
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
|
||||
import {
|
||||
AI_VAULT_SESSION_DRAG_END_EVENT,
|
||||
AI_VAULT_SESSION_DRAG_START_EVENT,
|
||||
writeAiVaultSessionDragData
|
||||
} from '@/lib/ai-vault-session-drag'
|
||||
@@ -82,8 +83,13 @@ export function VaultSessionRow({
|
||||
? translate('auto.components.right.sidebar.AiVaultSessionRow.hideDetails', 'Hide Details')
|
||||
: translate('auto.components.right.sidebar.AiVaultSessionRow.showDetails', 'Show Details')
|
||||
const startResumeDrag = useCallback(
|
||||
(event: React.DragEvent<HTMLButtonElement>): void => {
|
||||
(event: React.DragEvent<HTMLElement>): void => {
|
||||
event.stopPropagation()
|
||||
const target = event.target
|
||||
if (target instanceof Element && target.closest('[data-ai-vault-session-actions]')) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (resumeDisabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
@@ -106,12 +112,20 @@ export function VaultSessionRow({
|
||||
<ContextMenuTrigger asChild className="block w-full min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
'group/session-row flex w-full min-w-0 cursor-pointer flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55',
|
||||
'group/session-row flex w-full min-w-0 flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55',
|
||||
resumeDisabled ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing',
|
||||
!detailsExpanded && 'min-h-[98px]'
|
||||
)}
|
||||
// Why: users naturally drag the session row itself; matching that
|
||||
// gesture avoids hidden affordances and text-selection false starts.
|
||||
draggable={!resumeDisabled}
|
||||
onClick={() => {
|
||||
onToggleDetails()
|
||||
}}
|
||||
onDragStart={startResumeDrag}
|
||||
onDragEnd={() => {
|
||||
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT))
|
||||
}}
|
||||
>
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-1">
|
||||
<div
|
||||
@@ -140,7 +154,6 @@ export function VaultSessionRow({
|
||||
onOpenLog={onOpenLog}
|
||||
onRevealLog={onRevealLog}
|
||||
onOpenCwd={onOpenCwd}
|
||||
onStartResumeDrag={startResumeDrag}
|
||||
/>
|
||||
</div>
|
||||
{detailsExpanded && worktreeInfo ? (
|
||||
|
||||
@@ -110,6 +110,8 @@ import {
|
||||
buildChecksPanelGitStatusContextKey,
|
||||
readChecksPanelPublishActionGitStatus,
|
||||
readChecksPanelGitStatusSnapshot,
|
||||
readChecksPanelRefreshGitIdentitySnapshot,
|
||||
hasChecksPanelGitStatusBranchChanged,
|
||||
shouldClearChecksPanelGitStatusSnapshot,
|
||||
shouldCoalesceChecksPanelGitStatusSnapshotRefresh,
|
||||
shouldCommitChecksPanelGitStatusSnapshot,
|
||||
@@ -397,6 +399,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
|
||||
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
|
||||
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
|
||||
const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
|
||||
// Why: the sidebar stays mounted when closed (for performance). Gate
|
||||
@@ -426,6 +429,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
const commentsRef = useRef<PRComment[]>([])
|
||||
const [emptyRefreshing, setEmptyRefreshing] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const refreshInFlightRef = useRef(false)
|
||||
const [conflictDetailsRefreshing, setConflictDetailsRefreshing] = useState(false)
|
||||
const createPrInFlightRef = useRef<string | null>(null)
|
||||
const [isCreatingPr, setIsCreatingPr] = useState(false)
|
||||
@@ -590,6 +594,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
pollIntervalRef.current = 30_000
|
||||
prevChecksRef.current = ''
|
||||
conflictSummaryRefreshKeyRef.current = null
|
||||
refreshInFlightRef.current = false
|
||||
refreshRequestKeyRef.current = null
|
||||
if (gitStatusSnapshotRetryTimerRef.current) {
|
||||
clearTimeout(gitStatusSnapshotRetryTimerRef.current)
|
||||
@@ -1303,6 +1308,17 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
}
|
||||
void (async () => {
|
||||
const status = await getRuntimeGitStatus(context)
|
||||
if (
|
||||
!stale &&
|
||||
shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey)
|
||||
) {
|
||||
// Why: the Checks tab can be the only visible git surface; commit
|
||||
// branch identity before branch-scoped upstream refresh can fail.
|
||||
updateWorktreeGitIdentity(activeWorktreeId, {
|
||||
head: status.head,
|
||||
branch: status.branch ?? (status.head ? null : undefined)
|
||||
})
|
||||
}
|
||||
let freshRemoteStatus = status.upstreamStatus
|
||||
if (activeWorktreePushTarget) {
|
||||
freshRemoteStatus = await getRuntimeGitUpstreamStatus(context, activeWorktreePushTarget)
|
||||
@@ -1324,7 +1340,11 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
setGitStatusSnapshot({
|
||||
contextKey: requestContextKey,
|
||||
hasUncommittedChanges: status.entries.length > 0,
|
||||
remoteStatus
|
||||
remoteStatus,
|
||||
gitIdentity: {
|
||||
head: status.head,
|
||||
branch: status.branch ?? (status.head ? null : undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1385,7 +1405,8 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
repoConnectionId,
|
||||
remoteStatusInvalidation,
|
||||
runtimeEnvironmentId,
|
||||
sshConnectionStatus
|
||||
sshConnectionStatus,
|
||||
updateWorktreeGitIdentity
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1839,6 +1860,12 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
if (!repo || !branch) {
|
||||
return
|
||||
}
|
||||
if (refreshInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
// Why: React has not disabled the button until the next render, so a rapid
|
||||
// double-click must not start duplicate git status/upstream subprocesses.
|
||||
refreshInFlightRef.current = true
|
||||
const initialRequestKey = checksPanelAsyncResultKey(
|
||||
prCacheKey,
|
||||
branch,
|
||||
@@ -1853,7 +1880,6 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
const refreshProvider = isGitLabReviewContext ? 'gitlab' : 'github'
|
||||
let refreshOutcome = 'started'
|
||||
setIsRefreshing(true)
|
||||
setGitStatusRefreshNonce((value) => value + 1)
|
||||
recordChecksPanelPRRefreshBreadcrumb({
|
||||
event: 'start',
|
||||
provider: refreshProvider,
|
||||
@@ -1867,6 +1893,81 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
refreshState: prCacheKey ? useAppStore.getState().prRefreshStates[prCacheKey] : null
|
||||
})
|
||||
try {
|
||||
if (activeWorktreeId && activeWorktreePath && !isFolder) {
|
||||
const snapshotIdentity = readChecksPanelRefreshGitIdentitySnapshot({
|
||||
snapshot: gitStatusSnapshot,
|
||||
contextKey: panelContextKey,
|
||||
currentBranch: branch
|
||||
})
|
||||
if (snapshotIdentity.kind === 'changed') {
|
||||
updateWorktreeGitIdentity(activeWorktreeId, {
|
||||
head: snapshotIdentity.head,
|
||||
branch: snapshotIdentity.branch
|
||||
})
|
||||
// Why: this click discovered a terminal branch switch. Let the
|
||||
// branch-keyed render/effects restart instead of refreshing old PR data.
|
||||
refreshOutcome = 'branch-changed'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const statusContext = {
|
||||
settings: ownerSettings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath: activeWorktreePath,
|
||||
connectionId: activeConnectionId ?? undefined
|
||||
}
|
||||
const status = await getRuntimeGitStatus(statusContext)
|
||||
const observedBranch = status.branch ?? (status.head ? null : undefined)
|
||||
updateWorktreeGitIdentity(activeWorktreeId, {
|
||||
head: status.head,
|
||||
branch: observedBranch
|
||||
})
|
||||
if (
|
||||
observedBranch !== undefined &&
|
||||
hasChecksPanelGitStatusBranchChanged({
|
||||
observedBranch,
|
||||
currentBranch: branch
|
||||
})
|
||||
) {
|
||||
// Why: this click discovered a terminal branch switch. Let the
|
||||
// branch-keyed render/effects restart instead of refreshing old PR data.
|
||||
refreshOutcome = 'branch-changed'
|
||||
return
|
||||
}
|
||||
let freshRemoteStatus = status.upstreamStatus
|
||||
if (activeWorktreePushTarget) {
|
||||
freshRemoteStatus = await getRuntimeGitUpstreamStatus(
|
||||
statusContext,
|
||||
activeWorktreePushTarget
|
||||
)
|
||||
} else if (
|
||||
!freshRemoteStatus ||
|
||||
(freshRemoteStatus.ahead > 0 &&
|
||||
freshRemoteStatus.behind > 0 &&
|
||||
freshRemoteStatus.behindCommitsArePatchEquivalent === undefined)
|
||||
) {
|
||||
freshRemoteStatus = await getRuntimeGitUpstreamStatus(statusContext)
|
||||
}
|
||||
if (
|
||||
isCurrentRequest() &&
|
||||
shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, panelContextKey)
|
||||
) {
|
||||
// Why: the explicit Refresh click already paid for this status read;
|
||||
// commit it so empty-state Publish/Create eligibility is fresh.
|
||||
setGitStatusSnapshot({
|
||||
contextKey: panelContextKey,
|
||||
hasUncommittedChanges: status.entries.length > 0,
|
||||
remoteStatus: freshRemoteStatus,
|
||||
gitIdentity: {
|
||||
head: status.head,
|
||||
branch: observedBranch
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ChecksPanel] pre-refresh git identity refresh failed', error)
|
||||
}
|
||||
}
|
||||
if (isGitLabReviewContext) {
|
||||
const refreshedReview = await refreshHostedReviewCard(fetchHostedReviewForBranch, {
|
||||
repoPath: repo.path,
|
||||
@@ -2042,13 +2143,17 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
currentRequest: isCurrentRequest()
|
||||
})
|
||||
if (isCurrentRequest()) {
|
||||
refreshInFlightRef.current = false
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
repo,
|
||||
branch,
|
||||
activeConnectionId,
|
||||
activeWorktreeId,
|
||||
activeWorktreePath,
|
||||
activeWorktreePushTarget,
|
||||
activeGitLabReview,
|
||||
prNumber,
|
||||
pr?.checksStatus,
|
||||
@@ -2063,13 +2168,18 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
linkedBitbucketPR,
|
||||
linkedGiteaPR,
|
||||
linkedGitLabMR,
|
||||
isFolder,
|
||||
isGitLabReviewContext,
|
||||
gitStatusSnapshot,
|
||||
panelContextKey,
|
||||
fetchPRForBranch,
|
||||
fetchPRChecks,
|
||||
fetchPRComments,
|
||||
fetchHostedReviewForBranch,
|
||||
expireGitHubPRRefreshState,
|
||||
isCurrentAsyncResult
|
||||
isCurrentAsyncResult,
|
||||
ownerSettings,
|
||||
updateWorktreeGitIdentity
|
||||
])
|
||||
|
||||
const handleEntryRefresh = useCallback(
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type React from 'react'
|
||||
import {
|
||||
ChevronDown,
|
||||
GripVertical,
|
||||
LocateFixed,
|
||||
MoreHorizontal,
|
||||
PanelTopOpen,
|
||||
Play
|
||||
} from 'lucide-react'
|
||||
import { ChevronDown, LocateFixed, MoreHorizontal, PanelTopOpen, Play } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
@@ -16,7 +8,6 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AI_VAULT_SESSION_DRAG_END_EVENT } from '@/lib/ai-vault-session-drag'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import { agentLabel } from './ai-vault-session-filters'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -58,8 +49,7 @@ export function SessionRowTrailingActions({
|
||||
onCopyPath,
|
||||
onOpenLog,
|
||||
onRevealLog,
|
||||
onOpenCwd,
|
||||
onStartResumeDrag
|
||||
onOpenCwd
|
||||
}: {
|
||||
session: AiVaultSession
|
||||
detailsExpanded: boolean
|
||||
@@ -78,75 +68,17 @@ export function SessionRowTrailingActions({
|
||||
onOpenLog: () => void
|
||||
onRevealLog: () => void
|
||||
onOpenCwd?: () => void
|
||||
onStartResumeDrag: (event: React.DragEvent<HTMLButtonElement>) => void
|
||||
}) {
|
||||
// Track drag state to cleanup on unmount
|
||||
const isDraggingRef = useRef(false)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: React.DragEvent<HTMLButtonElement>) => {
|
||||
isDraggingRef.current = true
|
||||
onStartResumeDrag(event)
|
||||
},
|
||||
[onStartResumeDrag]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
isDraggingRef.current = false
|
||||
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT))
|
||||
}, [])
|
||||
|
||||
const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo)
|
||||
|
||||
// Cleanup drag state on unmount if a drag was in progress
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isDraggingRef.current) {
|
||||
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT))
|
||||
isDraggingRef.current = false
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1"
|
||||
data-ai-vault-session-actions="true"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={HOVER_ACTION_GROUP_CLASS}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.dragToResume',
|
||||
'Drag to resume in a new tab'
|
||||
)}
|
||||
disabled={resumeDisabled}
|
||||
draggable={!resumeDisabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
data-testid="ai-vault-session-drag-handle"
|
||||
// Why: the card is for inspection. Drag-to-resume lives on a
|
||||
// quiet handle so the row does not look movable by default.
|
||||
className="cursor-grab can-hover:pointer-events-none active:cursor-grabbing group-hover/session-row:pointer-events-auto group-focus-within/session-row:pointer-events-auto focus-visible:pointer-events-auto"
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.dragToResume',
|
||||
'Drag to resume in a new tab'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onJumpToOriginalPane ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildChecksPanelGitStatusContextKey,
|
||||
readChecksPanelPublishActionGitStatus,
|
||||
readChecksPanelRefreshGitIdentitySnapshot,
|
||||
readChecksPanelGitStatusSnapshot,
|
||||
hasChecksPanelGitStatusBranchChanged,
|
||||
shouldClearChecksPanelGitStatusSnapshot,
|
||||
shouldCoalesceChecksPanelGitStatusSnapshotRefresh,
|
||||
shouldCommitChecksPanelGitStatusSnapshot,
|
||||
@@ -17,6 +19,10 @@ const SNAPSHOT: ChecksPanelGitStatusSnapshot = {
|
||||
hasUpstream: false,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
},
|
||||
gitIdentity: {
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/checks'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +194,82 @@ describe('readChecksPanelPublishActionGitStatus', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readChecksPanelRefreshGitIdentitySnapshot', () => {
|
||||
it('treats refs/heads display differences as the same branch identity', () => {
|
||||
expect(
|
||||
readChecksPanelRefreshGitIdentitySnapshot({
|
||||
snapshot: SNAPSHOT,
|
||||
contextKey: SNAPSHOT.contextKey,
|
||||
currentBranch: 'feature/checks'
|
||||
})
|
||||
).toEqual({ kind: 'same' })
|
||||
})
|
||||
|
||||
it('reports missing when the snapshot is from a different execution context', () => {
|
||||
expect(
|
||||
readChecksPanelRefreshGitIdentitySnapshot({
|
||||
snapshot: SNAPSHOT,
|
||||
contextKey: 'runtime:env-2::repo::worktree::branch',
|
||||
currentBranch: 'feature/checks'
|
||||
})
|
||||
).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('reports changed when the observed branch is a different branch', () => {
|
||||
expect(
|
||||
readChecksPanelRefreshGitIdentitySnapshot({
|
||||
snapshot: {
|
||||
...SNAPSHOT,
|
||||
gitIdentity: {
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/feature/next'
|
||||
}
|
||||
},
|
||||
contextKey: SNAPSHOT.contextKey,
|
||||
currentBranch: 'feature/checks'
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'changed',
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/feature/next'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports missing when the snapshot lacks branch/head identity', () => {
|
||||
expect(
|
||||
readChecksPanelRefreshGitIdentitySnapshot({
|
||||
snapshot: {
|
||||
contextKey: SNAPSHOT.contextKey,
|
||||
hasUncommittedChanges: false,
|
||||
remoteStatus: undefined
|
||||
},
|
||||
contextKey: SNAPSHOT.contextKey,
|
||||
currentBranch: 'feature/checks'
|
||||
})
|
||||
).toEqual({ kind: 'missing' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasChecksPanelGitStatusBranchChanged', () => {
|
||||
it('does not treat refs/heads display differences as branch changes', () => {
|
||||
expect(
|
||||
hasChecksPanelGitStatusBranchChanged({
|
||||
observedBranch: 'refs/heads/feature/checks',
|
||||
currentBranch: 'feature/checks'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats detached HEAD as changed from the rendered branch', () => {
|
||||
expect(
|
||||
hasChecksPanelGitStatusBranchChanged({
|
||||
observedBranch: null,
|
||||
currentBranch: 'feature/checks'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldCommitChecksPanelGitStatusSnapshot', () => {
|
||||
it('suppresses stale status refresh completions from an older execution boundary', () => {
|
||||
expect(
|
||||
|
||||
@@ -19,6 +19,10 @@ export type ChecksPanelGitStatusSnapshot = {
|
||||
contextKey: string
|
||||
hasUncommittedChanges: boolean
|
||||
remoteStatus: GitUpstreamStatus | undefined
|
||||
gitIdentity?: {
|
||||
head?: string
|
||||
branch?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type ChecksPanelGitStatusInputs = {
|
||||
@@ -26,6 +30,19 @@ export type ChecksPanelGitStatusInputs = {
|
||||
remoteStatus: GitUpstreamStatus | undefined
|
||||
}
|
||||
|
||||
export type ChecksPanelRefreshGitIdentitySnapshot =
|
||||
| {
|
||||
kind: 'missing'
|
||||
}
|
||||
| {
|
||||
kind: 'same'
|
||||
}
|
||||
| {
|
||||
kind: 'changed'
|
||||
head?: string
|
||||
branch: string | null
|
||||
}
|
||||
|
||||
export function buildChecksPanelGitStatusContextKey(
|
||||
input: ChecksPanelGitStatusContextInput
|
||||
): string {
|
||||
@@ -118,3 +135,44 @@ export function readChecksPanelPublishActionGitStatus(input: {
|
||||
remoteStatus: input.fallbackRemoteStatus
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalBranchIdentity(branch: string | null | undefined): string {
|
||||
return (branch ?? '').replace(/^refs\/heads\//, '').trim()
|
||||
}
|
||||
|
||||
export function readChecksPanelRefreshGitIdentitySnapshot(input: {
|
||||
snapshot: ChecksPanelGitStatusSnapshot | null
|
||||
contextKey: string
|
||||
currentBranch: string
|
||||
}): ChecksPanelRefreshGitIdentitySnapshot {
|
||||
if (
|
||||
!input.snapshot ||
|
||||
input.snapshot.contextKey !== input.contextKey ||
|
||||
!input.snapshot.gitIdentity ||
|
||||
input.snapshot.gitIdentity.branch === undefined
|
||||
) {
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
|
||||
if (
|
||||
canonicalBranchIdentity(input.snapshot.gitIdentity.branch) ===
|
||||
canonicalBranchIdentity(input.currentBranch)
|
||||
) {
|
||||
return { kind: 'same' }
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'changed',
|
||||
head: input.snapshot.gitIdentity.head,
|
||||
branch: input.snapshot.gitIdentity.branch
|
||||
}
|
||||
}
|
||||
|
||||
export function hasChecksPanelGitStatusBranchChanged(input: {
|
||||
observedBranch: string | null | undefined
|
||||
currentBranch: string
|
||||
}): boolean {
|
||||
return (
|
||||
canonicalBranchIdentity(input.observedBranch) !== canonicalBranchIdentity(input.currentBranch)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '../../store'
|
||||
import { normalizeSettingsSearchQuery } from './settings-search'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type AppearanceAdvancedDisclosureProps = {
|
||||
/** Optional override label; defaults to "Advanced". */
|
||||
label?: string
|
||||
showTopBorder?: boolean
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Inline "Advanced" disclosure for low-frequency controls. An active settings
|
||||
* search force-opens it so matching controls stay reachable instead of being
|
||||
* hidden behind a collapsed trigger. */
|
||||
export function AppearanceAdvancedDisclosure({
|
||||
label,
|
||||
showTopBorder = true,
|
||||
className,
|
||||
contentClassName,
|
||||
children
|
||||
}: AppearanceAdvancedDisclosureProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
|
||||
const [open, setOpen] = useState(false)
|
||||
const expanded = open || isSearching
|
||||
|
||||
return (
|
||||
<div className={cn('mt-3 pt-2', showTopBorder && 'border-t border-border/50', className)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
// Why: while searching the disclosure is forced open, so disable the
|
||||
// toggle's collapse affordance rather than letting it fight the search.
|
||||
disabled={isSearching}
|
||||
className="flex w-full items-center gap-2 py-1 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 text-muted-foreground transition-transform',
|
||||
expanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
{label ??
|
||||
translate('auto.components.settings.AppearanceAdvancedDisclosure.advanced', 'Advanced')}
|
||||
</button>
|
||||
{expanded ? <div className={cn('pt-1', contentClassName)}>{children}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import type React from 'react'
|
||||
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { UIZoomControl } from './UIZoomControl'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
|
||||
import { ShortcutHintList } from './AppearanceShortcutHintList'
|
||||
import {
|
||||
FontAutocomplete,
|
||||
SettingsRow,
|
||||
SettingsSegmentedControl,
|
||||
SettingsSwitchRow
|
||||
} from './SettingsFormControls'
|
||||
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
|
||||
import {
|
||||
getLanguageEntries,
|
||||
getSystemTrayEntries,
|
||||
getThemeEntries,
|
||||
getTitlebarEntries,
|
||||
getTypographyEntries,
|
||||
getZoomEntries
|
||||
} from './appearance-search'
|
||||
import {
|
||||
getUiLanguageChoiceLabel,
|
||||
SHOW_UI_LANGUAGE_SETTING,
|
||||
UI_LANGUAGE_CHOICES
|
||||
} from '@/i18n/supported-languages'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { UiLanguage } from '../../../../shared/ui-language'
|
||||
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
|
||||
|
||||
type AppearanceInterfaceSectionProps = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
applyTheme: (theme: 'system' | 'dark' | 'light') => void
|
||||
fontSuggestions: string[]
|
||||
isDesktopWindows: boolean
|
||||
forceVisiblePrimary?: boolean
|
||||
}
|
||||
|
||||
export function AppearanceInterfaceSection({
|
||||
settings,
|
||||
updateSettings,
|
||||
applyTheme,
|
||||
fontSuggestions,
|
||||
isDesktopWindows,
|
||||
forceVisiblePrimary = false
|
||||
}: AppearanceInterfaceSectionProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
|
||||
const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
|
||||
const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out')
|
||||
const languageEntry = getLanguageEntries()[0]
|
||||
const systemTrayEntry = getSystemTrayEntries({ showSystemTray: true })[0]
|
||||
const themeEntry = getThemeEntries()[0]
|
||||
const themeLabel = translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme')
|
||||
const titlebarEntry = getTitlebarEntries()[0]
|
||||
const typographyEntry = getTypographyEntries()[0]
|
||||
const zoomEntry = getZoomEntries()[0]
|
||||
const advancedEntries = [
|
||||
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
|
||||
...getTitlebarEntries(),
|
||||
...getSystemTrayEntries({ showSystemTray: isDesktopWindows })
|
||||
]
|
||||
const showAdvanced = !isSearching || matchesSettingsSearch(searchQuery, advancedEntries)
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border/40">
|
||||
<SearchableSetting
|
||||
title={themeLabel}
|
||||
description={themeEntry?.description}
|
||||
keywords={themeEntry?.keywords ?? ['dark', 'light', 'system']}
|
||||
forceVisible={forceVisiblePrimary}
|
||||
>
|
||||
<SettingsRow
|
||||
label={themeLabel}
|
||||
control={
|
||||
<SettingsSegmentedControl
|
||||
ariaLabel={themeLabel}
|
||||
value={settings.theme}
|
||||
onChange={(option) => {
|
||||
updateSettings({ theme: option })
|
||||
applyTheme(option)
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: 'system',
|
||||
label: translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System')
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark')
|
||||
},
|
||||
{
|
||||
value: 'light',
|
||||
label: translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
|
||||
description={zoomEntry?.description}
|
||||
keywords={zoomEntry?.keywords ?? ['zoom', 'scale', 'shortcut']}
|
||||
forceVisible={forceVisiblePrimary}
|
||||
>
|
||||
<SettingsRow
|
||||
label={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
|
||||
// Why: keep only the shortcut hint — the control itself makes "scale the
|
||||
// interface" obvious, but the keyboard gesture and its terminal-pane
|
||||
// exception are not discoverable from the buttons alone.
|
||||
description={
|
||||
<>
|
||||
<ShortcutHintList combos={zoomInKeyCombos} /> /{' '}
|
||||
<ShortcutHintList combos={zoomOutKeyCombos} />{' '}
|
||||
{translate(
|
||||
'auto.components.settings.AppearancePane.ef89200c1f',
|
||||
'when not in a terminal pane.'
|
||||
)}
|
||||
</>
|
||||
}
|
||||
control={<UIZoomControl />}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate('auto.components.settings.AppearancePane.102d6b5f9b', 'IDE Font')}
|
||||
description={typographyEntry?.description}
|
||||
keywords={typographyEntry?.keywords ?? ['font', 'typeface', 'typography']}
|
||||
forceVisible={forceVisiblePrimary}
|
||||
>
|
||||
<SettingsRow
|
||||
label={translate('auto.components.settings.AppearancePane.102d6b5f9b', 'IDE Font')}
|
||||
control={
|
||||
<FontAutocomplete
|
||||
value={settings.appFontFamily}
|
||||
suggestions={fontSuggestions}
|
||||
placeholder={DEFAULT_APP_FONT_FAMILY}
|
||||
onChange={(value) =>
|
||||
updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY })
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
{showAdvanced ? (
|
||||
<AppearanceAdvancedDisclosure showTopBorder={false}>
|
||||
<div className="divide-y divide-border/40">
|
||||
{SHOW_UI_LANGUAGE_SETTING ? (
|
||||
<SearchableSetting
|
||||
title={translate('settings.appearance.language.title', 'Language')}
|
||||
description={languageEntry?.description}
|
||||
keywords={languageEntry?.keywords ?? []}
|
||||
>
|
||||
<SettingsRow
|
||||
label={translate('settings.appearance.language.title', 'Language')}
|
||||
control={
|
||||
<Select
|
||||
value={settings.uiLanguage}
|
||||
onValueChange={(value) => updateSettings({ uiLanguage: value as UiLanguage })}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="min-w-[220px]"
|
||||
aria-label={translate('settings.appearance.language.title', 'Language')}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UI_LANGUAGE_CHOICES.map((choice) => (
|
||||
<SelectItem key={choice.value} value={choice.value}>
|
||||
{getUiLanguageChoiceLabel(choice, translate)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AppearancePane.9868f39007',
|
||||
'Titlebar App Name'
|
||||
)}
|
||||
description={titlebarEntry?.description}
|
||||
keywords={titlebarEntry?.keywords ?? ['titlebar', 'orca', 'app', 'name']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.9868f39007',
|
||||
'Titlebar App Name'
|
||||
)}
|
||||
checked={settings.showTitlebarAppName}
|
||||
onChange={() =>
|
||||
updateSettings({ showTitlebarAppName: !settings.showTitlebarAppName })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
{isDesktopWindows ? (
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AppearancePane.2edf606c46',
|
||||
'Minimize to Tray on Close'
|
||||
)}
|
||||
description={systemTrayEntry?.description}
|
||||
keywords={systemTrayEntry?.keywords ?? ['tray', 'minimize', 'close']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.2edf606c46',
|
||||
'Minimize to Tray on Close'
|
||||
)}
|
||||
// Why: platform constraint + "close keeps Orca running" consequence are
|
||||
// both non-obvious from the label alone.
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.b707773a0d',
|
||||
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
|
||||
)}
|
||||
checked={settings.minimizeToTrayOnClose === true}
|
||||
onChange={() =>
|
||||
updateSettings({ minimizeToTrayOnClose: !settings.minimizeToTrayOnClose })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
</div>
|
||||
</AppearanceAdvancedDisclosure>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,10 +10,18 @@ import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
state: {
|
||||
availableStatusBarToggles: [] as {
|
||||
description: string
|
||||
id: 'ports'
|
||||
keywords: string[]
|
||||
title: string
|
||||
toggleDescription: string
|
||||
}[],
|
||||
settingsSearchQuery: 'automations',
|
||||
statusBarItems: [],
|
||||
toggleStatusBarItem: vi.fn(),
|
||||
recordFeatureInteraction: vi.fn()
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
setWorktreeCardMode: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -26,7 +34,7 @@ vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../status-bar/use-available-status-bar-toggles', () => ({
|
||||
useAvailableStatusBarToggles: () => []
|
||||
useAvailableStatusBarToggles: () => mocks.state.availableStatusBarToggles
|
||||
}))
|
||||
|
||||
vi.mock('./TerminalAppearanceSection', () => ({
|
||||
@@ -85,6 +93,7 @@ vi.mock('../ui/select', async () => {
|
||||
})
|
||||
|
||||
import { AppearancePane } from './AppearancePane'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
|
||||
const mountedRoots: Root[] = []
|
||||
|
||||
@@ -130,16 +139,18 @@ async function renderAppearancePane(
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<AppearancePane
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
applyTheme={vi.fn()}
|
||||
fontSuggestions={[]}
|
||||
terminalFontSuggestions={[]}
|
||||
systemPrefersDark={false}
|
||||
ghostty={createGhosttyStub() as never}
|
||||
warpThemes={createWarpThemesStub() as never}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<AppearancePane
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
applyTheme={vi.fn()}
|
||||
fontSuggestions={[]}
|
||||
terminalFontSuggestions={[]}
|
||||
systemPrefersDark={false}
|
||||
ghostty={createGhosttyStub() as never}
|
||||
warpThemes={createWarpThemesStub() as never}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nextProvider>
|
||||
)
|
||||
})
|
||||
@@ -159,7 +170,22 @@ describe('AppearancePane', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.state.availableStatusBarToggles = []
|
||||
mocks.state.settingsSearchQuery = 'automations'
|
||||
// UIZoomControl reads window.api.ui on mount; the inline-expansion pane can
|
||||
// render the full Interface section, so provide a minimal renderer bridge
|
||||
// without clobbering happy-dom's window.location.
|
||||
;(window as unknown as { api: unknown }).api = {
|
||||
ui: {
|
||||
getZoomLevel: () => 0,
|
||||
onTerminalZoom: () => () => {},
|
||||
set: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { api?: unknown }).api
|
||||
})
|
||||
|
||||
it('renders the language dropdown with system, english, chinese, korean, japanese, and spanish options', async () => {
|
||||
@@ -236,4 +262,117 @@ describe('AppearancePane', () => {
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({ showAutomationsButton: true })
|
||||
})
|
||||
|
||||
it('changes workspace card layout from the Appearance sidebar controls', async () => {
|
||||
mocks.state.settingsSearchQuery = 'workspace card layout'
|
||||
const settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
compactWorktreeCards: false
|
||||
}
|
||||
|
||||
const container = await renderAppearancePane(settings)
|
||||
const compactButton = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('button[role="radio"]')
|
||||
).find((button) => button.textContent === 'Compact')
|
||||
|
||||
expect(compactButton).toBeDefined()
|
||||
|
||||
await act(async () => {
|
||||
compactButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(mocks.state.setWorktreeCardMode).toHaveBeenCalledWith('Compact')
|
||||
})
|
||||
|
||||
it('renders the three top-level section rows and no Code & Markdown row when not searching', async () => {
|
||||
mocks.state.settingsSearchQuery = ''
|
||||
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
|
||||
|
||||
expect(container.textContent).toContain('Interface')
|
||||
expect(container.textContent).toContain('Terminal')
|
||||
expect(container.textContent).toContain('Window & Sidebar')
|
||||
// Code & Markdown is intentionally omitted — Orca has no Appearance-level
|
||||
// code/markdown settings, so the row would be empty.
|
||||
expect(container.textContent).not.toContain('Code & Markdown')
|
||||
})
|
||||
|
||||
it('keeps the app icon control at the bottom of the pane, after the section rows', async () => {
|
||||
mocks.state.settingsSearchQuery = ''
|
||||
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll('button'))
|
||||
const interfaceRow = buttons.find((button) => button.textContent?.includes('Interface'))
|
||||
const appIconImage = container.querySelector<HTMLImageElement>('img[alt="Selected app icon"]')
|
||||
|
||||
expect(interfaceRow).toBeDefined()
|
||||
expect(appIconImage).not.toBeNull()
|
||||
// The App Icon block sits after the Interface section row in document order.
|
||||
expect(
|
||||
interfaceRow &&
|
||||
appIconImage &&
|
||||
interfaceRow.compareDocumentPosition(appIconImage) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reveals an advanced sidebar control when its search matches, even though it is hidden by default', async () => {
|
||||
// The Show Tasks Button toggle lives behind the Window & Sidebar Advanced
|
||||
// disclosure; with no search it stays collapsed, but a matching query must
|
||||
// force the disclosure open so the control is reachable.
|
||||
mocks.state.settingsSearchQuery = ''
|
||||
const collapsedContainer = await renderAppearancePane(getDefaultSettings('/tmp'))
|
||||
expect(
|
||||
collapsedContainer.querySelector('button[role="switch"][aria-label="Show Tasks Button"]')
|
||||
).toBeNull()
|
||||
|
||||
mocks.state.settingsSearchQuery = 'tasks'
|
||||
const searchedContainer = await renderAppearancePane(getDefaultSettings('/tmp'))
|
||||
expect(
|
||||
searchedContainer.querySelector('button[role="switch"][aria-label="Show Tasks Button"]')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps description-only search matches visible after helper text is hidden', async () => {
|
||||
mocks.state.settingsSearchQuery = 'app window'
|
||||
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
|
||||
|
||||
expect(container.textContent).toContain('Theme')
|
||||
expect(container.textContent).not.toContain('Advanced')
|
||||
})
|
||||
|
||||
it('shows useful primary rows for a Window & Sidebar section-label search', async () => {
|
||||
mocks.state.settingsSearchQuery = 'Window & Sidebar'
|
||||
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
|
||||
|
||||
expect(container.textContent).toContain('Left Sidebar Appearance')
|
||||
expect(container.textContent).toContain('Status Bar')
|
||||
expect(container.textContent).not.toContain('Advanced')
|
||||
})
|
||||
|
||||
it('expands status bar controls for a section-label search', async () => {
|
||||
mocks.state.availableStatusBarToggles = [
|
||||
{
|
||||
id: 'ports',
|
||||
title: 'Ports',
|
||||
description: 'Show live workspace ports in the status bar.',
|
||||
toggleDescription: 'Show Ports in the status bar.',
|
||||
keywords: ['status bar', 'ports']
|
||||
}
|
||||
]
|
||||
mocks.state.settingsSearchQuery = 'status bar'
|
||||
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
|
||||
|
||||
expect(container.querySelector('button[role="switch"][aria-label="Ports"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('collapses sibling sections so only the Interface section is expanded by default', async () => {
|
||||
mocks.state.settingsSearchQuery = ''
|
||||
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
|
||||
|
||||
const expanded = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('button[aria-expanded="true"]')
|
||||
).filter((button) => button.getAttribute('aria-controls')?.startsWith('appearance-section-'))
|
||||
|
||||
expect(expanded).toHaveLength(1)
|
||||
expect(expanded[0]?.textContent).toContain('Interface')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
/* eslint-disable max-lines -- Why: AppearancePane keeps theme, typography, zoom, and status-bar
|
||||
visibility settings together so the searchable settings rows share one filtered surface. */
|
||||
import type React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { AppWindow, PanelLeft, TerminalSquare } from 'lucide-react'
|
||||
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
||||
import { Separator } from '../ui/separator'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { UIZoomControl } from './UIZoomControl'
|
||||
import { AppearanceSection } from './AppearanceSection'
|
||||
import { AppearanceInterfaceSection } from './AppearanceInterfaceSection'
|
||||
import { AppearanceWindowSidebarSection } from './AppearanceWindowSidebarSection'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useShortcutKeyComboDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
|
||||
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
|
||||
import {
|
||||
FontAutocomplete,
|
||||
SettingsRow,
|
||||
SettingsSegmentedControl,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSwitchRow
|
||||
} from './SettingsFormControls'
|
||||
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
|
||||
import { normalizeAppIconId } from '../../../../shared/app-icon'
|
||||
import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles'
|
||||
import {
|
||||
getAppIconEntries,
|
||||
getAppearancePaneSearchEntries,
|
||||
getLanguageEntries,
|
||||
getLayoutEntries,
|
||||
getLeftSidebarAppearanceEntry,
|
||||
getSidebarEntries,
|
||||
getStatusBarEntries,
|
||||
getStatusBarToggles,
|
||||
getSystemTrayEntries,
|
||||
getThemeEntries,
|
||||
getTitlebarEntries,
|
||||
@@ -42,17 +28,15 @@ import { TerminalAppearanceSection } from './TerminalAppearanceSection'
|
||||
import type { UseGhosttyImportReturn } from './useGhosttyImport'
|
||||
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
|
||||
import { AppIconSelector } from './AppIconSelector'
|
||||
import { normalizeAppIconId } from '../../../../shared/app-icon'
|
||||
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import {
|
||||
getUiLanguageChoiceLabel,
|
||||
SHOW_UI_LANGUAGE_SETTING,
|
||||
UI_LANGUAGE_CHOICES
|
||||
} from '@/i18n/supported-languages'
|
||||
import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { UiLanguage } from '../../../../shared/ui-language'
|
||||
import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting'
|
||||
import { getWorkspaceCardLayoutEntry } from './appearance-sidebar-search'
|
||||
import {
|
||||
getLeftSidebarAppearanceEntry,
|
||||
getWorkspaceCardLayoutEntry
|
||||
} from './appearance-sidebar-search'
|
||||
export { getAppearancePaneSearchEntries }
|
||||
|
||||
type AppearancePaneProps = {
|
||||
@@ -66,28 +50,16 @@ type AppearancePaneProps = {
|
||||
warpThemes: UseWarpThemeImportReturn
|
||||
}
|
||||
|
||||
function ShortcutHintList({ combos }: { combos: ShortcutKeyComboDetails[] }): React.JSX.Element {
|
||||
if (combos.length === 0) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.settings.AppearancePane.3057983501', 'Unassigned')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
type AppearanceSectionKey = 'interface' | 'terminal' | 'window'
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-1 align-middle">
|
||||
{combos.map((combo) => (
|
||||
<ShortcutKeyCombo
|
||||
key={combo.keys.join('-')}
|
||||
keys={combo.keys}
|
||||
doubleTap={combo.doubleTap}
|
||||
className="inline-flex gap-0.5"
|
||||
separatorClassName="text-[10px] text-muted-foreground"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
function resolveThemeSummary(theme: GlobalSettings['theme']): string {
|
||||
if (theme === 'system') {
|
||||
return translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System')
|
||||
}
|
||||
if (theme === 'light') {
|
||||
return translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light')
|
||||
}
|
||||
return translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark')
|
||||
}
|
||||
|
||||
export function AppearancePane({
|
||||
@@ -101,479 +73,165 @@ export function AppearancePane({
|
||||
warpThemes
|
||||
}: AppearancePaneProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
|
||||
const isWebClient = isWebClientLocation()
|
||||
// Why: the system tray behavior is desktop-Electron Windows-only; a Windows
|
||||
// browser web client has no local tray to control.
|
||||
const isDesktopWindows = getRendererAppPlatform() === 'win32' && !isWebClient
|
||||
const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
|
||||
const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out')
|
||||
const statusBarItems = useAppStore((state) => state.statusBarItems)
|
||||
const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles())
|
||||
const terminalAppearanceSearchEntries = getTerminalAppearanceSearchEntries({
|
||||
showWarpImport: !isWebClient
|
||||
|
||||
const [manuallyOpenSection, setManuallyOpenSection] = useState<AppearanceSectionKey | null>(
|
||||
'interface'
|
||||
)
|
||||
const interfaceTitle = translate(
|
||||
'auto.components.settings.AppearancePane.interfaceTitle',
|
||||
'Interface'
|
||||
)
|
||||
const terminalTitle = translate(
|
||||
'auto.components.settings.AppearancePane.terminalTitle',
|
||||
'Terminal'
|
||||
)
|
||||
const windowSidebarTitle = translate(
|
||||
'auto.components.settings.AppearancePane.windowSidebarTitle',
|
||||
'Window & Sidebar'
|
||||
)
|
||||
const windowSidebarSummary = translate(
|
||||
'auto.components.settings.AppearancePane.windowSidebarSummary',
|
||||
'Sidebar, status bar, and file explorer'
|
||||
)
|
||||
|
||||
// Search-entry buckets per section so a query can force-open the matching one.
|
||||
const interfaceSearchEntries = [
|
||||
{ title: interfaceTitle },
|
||||
...getThemeEntries(),
|
||||
...getZoomEntries(),
|
||||
...getTypographyEntries(),
|
||||
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
|
||||
...getTitlebarEntries(),
|
||||
...getSystemTrayEntries({ showSystemTray: isDesktopWindows })
|
||||
]
|
||||
const terminalSearchEntries = [
|
||||
{ title: terminalTitle },
|
||||
...getTerminalAppearanceSearchEntries({ showWarpImport: !isWebClient })
|
||||
]
|
||||
const windowSearchEntries = [
|
||||
{
|
||||
title: windowSidebarTitle,
|
||||
description: windowSidebarSummary
|
||||
},
|
||||
...getStatusBarEntries(),
|
||||
...getSidebarEntries(),
|
||||
...getLayoutEntries(),
|
||||
getLeftSidebarAppearanceEntry(),
|
||||
getWorkspaceCardLayoutEntry()
|
||||
]
|
||||
|
||||
const interfaceMatches = matchesSettingsSearch(searchQuery, interfaceSearchEntries)
|
||||
const terminalMatches = matchesSettingsSearch(searchQuery, terminalSearchEntries)
|
||||
const windowMatches = matchesSettingsSearch(searchQuery, windowSearchEntries)
|
||||
const interfaceLabelMatches = matchesSettingsSearch(searchQuery, { title: interfaceTitle })
|
||||
const terminalLabelMatches = matchesSettingsSearch(searchQuery, { title: terminalTitle })
|
||||
const windowLabelMatches = matchesSettingsSearch(searchQuery, {
|
||||
title: windowSidebarTitle,
|
||||
description: windowSidebarSummary
|
||||
})
|
||||
const systemTrayEntries = getSystemTrayEntries({ showSystemTray: isDesktopWindows })
|
||||
const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry()
|
||||
const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry()
|
||||
const visibleSections = [
|
||||
matchesSettingsSearch(searchQuery, getThemeEntries()) ||
|
||||
(SHOW_UI_LANGUAGE_SETTING && matchesSettingsSearch(searchQuery, getLanguageEntries())) ||
|
||||
matchesSettingsSearch(searchQuery, getZoomEntries()) ||
|
||||
matchesSettingsSearch(searchQuery, getTypographyEntries()) ? (
|
||||
<section key="interface" className="divide-y divide-border/40">
|
||||
{matchesSettingsSearch(searchQuery, getThemeEntries()) ? (
|
||||
<SearchableSetting
|
||||
title={translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme')}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.0f28e7b30c',
|
||||
'Choose how Orca looks in the app window.'
|
||||
)}
|
||||
keywords={getThemeEntries()[0]?.keywords ?? ['dark', 'light', 'system']}
|
||||
>
|
||||
<SettingsRow
|
||||
label={translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme')}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.0f28e7b30c',
|
||||
'Choose how Orca looks in the app window.'
|
||||
)}
|
||||
control={
|
||||
<SettingsSegmentedControl
|
||||
ariaLabel={translate(
|
||||
'auto.components.settings.AppearancePane.932ff1fbff',
|
||||
'Theme'
|
||||
)}
|
||||
value={settings.theme}
|
||||
onChange={(option) => {
|
||||
updateSettings({ theme: option })
|
||||
applyTheme(option)
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: 'system',
|
||||
label: translate(
|
||||
'auto.components.settings.AppearancePane.fb0e0b4453',
|
||||
'System'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark')
|
||||
},
|
||||
{
|
||||
value: 'light',
|
||||
label: translate(
|
||||
'auto.components.settings.AppearancePane.fd89b5487c',
|
||||
'Light'
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
const appIconMatches = matchesSettingsSearch(searchQuery, getAppIconEntries())
|
||||
|
||||
{SHOW_UI_LANGUAGE_SETTING && matchesSettingsSearch(searchQuery, getLanguageEntries()) ? (
|
||||
<SearchableSetting
|
||||
title={translate('settings.appearance.language.title', 'Language')}
|
||||
description={translate(
|
||||
'settings.appearance.language.description',
|
||||
'Choose the language used by the Orca interface.'
|
||||
)}
|
||||
keywords={getLanguageEntries()[0]?.keywords ?? []}
|
||||
>
|
||||
<SettingsRow
|
||||
label={translate('settings.appearance.language.title', 'Language')}
|
||||
description={translate(
|
||||
'settings.appearance.language.description',
|
||||
'Choose the language used by the Orca interface.'
|
||||
)}
|
||||
control={
|
||||
<Select
|
||||
value={settings.uiLanguage}
|
||||
onValueChange={(value) => updateSettings({ uiLanguage: value as UiLanguage })}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="min-w-[220px]"
|
||||
aria-label={translate('settings.appearance.language.title', 'Language')}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UI_LANGUAGE_CHOICES.map((choice) => (
|
||||
<SelectItem key={choice.value} value={choice.value}>
|
||||
{getUiLanguageChoiceLabel(choice, translate)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
// While searching, force-open every section that contains a match so its
|
||||
// controls (including advanced ones) are revealed; otherwise the accordion
|
||||
// shows exactly one manually-chosen section.
|
||||
function isSectionOpen(key: AppearanceSectionKey): boolean {
|
||||
if (isSearching) {
|
||||
return key === 'interface'
|
||||
? interfaceMatches
|
||||
: key === 'terminal'
|
||||
? terminalMatches
|
||||
: windowMatches
|
||||
}
|
||||
return manuallyOpenSection === key
|
||||
}
|
||||
|
||||
{matchesSettingsSearch(searchQuery, getZoomEntries()) ? (
|
||||
<SearchableSetting
|
||||
title={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.622e1c3465',
|
||||
'Scale the entire application interface.'
|
||||
)}
|
||||
keywords={getZoomEntries()[0]?.keywords ?? ['zoom', 'scale', 'shortcut']}
|
||||
>
|
||||
<SettingsRow
|
||||
label={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
|
||||
description={
|
||||
<>
|
||||
{translate(
|
||||
'auto.components.settings.AppearancePane.f687711a9b',
|
||||
'Scale the entire application interface. Use'
|
||||
)}{' '}
|
||||
<ShortcutHintList combos={zoomInKeyCombos} /> /{' '}
|
||||
<ShortcutHintList combos={zoomOutKeyCombos} />{' '}
|
||||
{translate(
|
||||
'auto.components.settings.AppearancePane.ef89200c1f',
|
||||
'when not in a terminal pane.'
|
||||
)}
|
||||
</>
|
||||
}
|
||||
control={<UIZoomControl />}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
function toggleSection(key: AppearanceSectionKey): void {
|
||||
setManuallyOpenSection((current) => (current === key ? null : key))
|
||||
}
|
||||
|
||||
{matchesSettingsSearch(searchQuery, getTypographyEntries()) ? (
|
||||
<SearchableSetting
|
||||
title={translate('auto.components.settings.AppearancePane.102d6b5f9b', 'IDE Font')}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.42554f615f',
|
||||
'Choose the font used by the Orca interface.'
|
||||
)}
|
||||
keywords={getTypographyEntries()[0]?.keywords ?? ['font', 'typeface', 'typography']}
|
||||
>
|
||||
<SettingsRow
|
||||
alignTop
|
||||
label={translate('auto.components.settings.AppearancePane.102d6b5f9b', 'IDE Font')}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.42554f615f',
|
||||
'Choose the font used by the Orca interface.'
|
||||
)}
|
||||
control={
|
||||
<FontAutocomplete
|
||||
value={settings.appFontFamily}
|
||||
suggestions={fontSuggestions}
|
||||
placeholder={DEFAULT_APP_FONT_FAMILY}
|
||||
onChange={(value) =>
|
||||
updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY })
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, terminalAppearanceSearchEntries) ? (
|
||||
<TerminalAppearanceSection
|
||||
key="terminal-appearance"
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
systemPrefersDark={systemPrefersDark}
|
||||
terminalFontSuggestions={terminalFontSuggestions}
|
||||
ghostty={ghostty}
|
||||
warpThemes={warpThemes}
|
||||
/>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, getLayoutEntries()) ? (
|
||||
<section key="layout" className="space-y-3">
|
||||
<SettingsSubsectionHeader
|
||||
title={translate('auto.components.settings.AppearancePane.d496901cd0', 'File Explorer')}
|
||||
/>
|
||||
const interfaceSummary = `${resolveThemeSummary(settings.theme)} · ${
|
||||
settings.appFontFamily ||
|
||||
translate('auto.components.settings.AppearancePane.interfaceDefaultFont', 'Default font')
|
||||
}`
|
||||
const terminalSummary = `${
|
||||
settings.terminalFontFamily ||
|
||||
translate('auto.components.settings.AppearancePane.terminalDefaultFont', 'Default font')
|
||||
} · ${settings.terminalFontSize}px`
|
||||
|
||||
<div className="divide-y divide-border/40">
|
||||
<SearchableSetting
|
||||
title={
|
||||
getLayoutEntries()[0]?.title ??
|
||||
translate(
|
||||
'auto.components.settings.AppearancePane.0fafabcf35',
|
||||
'Show Git-Ignored Files'
|
||||
)
|
||||
}
|
||||
description={
|
||||
getLayoutEntries()[0]?.description ??
|
||||
translate(
|
||||
'auto.components.settings.AppearancePane.75f07ab60c',
|
||||
'Show files matched by .gitignore in the file explorer.'
|
||||
)
|
||||
}
|
||||
keywords={getLayoutEntries()[0]?.keywords ?? ['git', 'gitignore', 'ignored']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.0fafabcf35',
|
||||
'Show Git-Ignored Files'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.e9f2ca5582',
|
||||
'Turn off to hide files matched by .gitignore from the file explorer.'
|
||||
)}
|
||||
checked={settings.showGitIgnoredFiles ?? true}
|
||||
onChange={() =>
|
||||
updateSettings({ showGitIgnoredFiles: !(settings.showGitIgnoredFiles ?? true) })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, getTitlebarEntries()) ? (
|
||||
<section key="titlebar" className="space-y-3">
|
||||
<SettingsSubsectionHeader
|
||||
title={translate('auto.components.settings.AppearancePane.6a272ca553', 'Titlebar')}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.4de76f6902',
|
||||
'Control what appears in the application titlebar.'
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{interfaceMatches ? (
|
||||
<AppearanceSection
|
||||
id="interface"
|
||||
icon={<AppWindow aria-hidden="true" />}
|
||||
title={interfaceTitle}
|
||||
summary={interfaceSummary}
|
||||
open={isSectionOpen('interface')}
|
||||
onToggle={() => toggleSection('interface')}
|
||||
>
|
||||
<AppearanceInterfaceSection
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
applyTheme={applyTheme}
|
||||
fontSuggestions={fontSuggestions}
|
||||
isDesktopWindows={isDesktopWindows}
|
||||
forceVisiblePrimary={interfaceLabelMatches}
|
||||
/>
|
||||
</AppearanceSection>
|
||||
) : null}
|
||||
|
||||
<div className="divide-y divide-border/40">
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AppearancePane.9868f39007',
|
||||
'Titlebar App Name'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.2df8f79aa5',
|
||||
'Show Orca in the titlebar.'
|
||||
)}
|
||||
keywords={getTitlebarEntries()[0]?.keywords ?? ['titlebar', 'orca', 'app', 'name']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.9868f39007',
|
||||
'Titlebar App Name'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.2df8f79aa5',
|
||||
'Show Orca in the titlebar.'
|
||||
)}
|
||||
checked={settings.showTitlebarAppName}
|
||||
onChange={() =>
|
||||
updateSettings({ showTitlebarAppName: !settings.showTitlebarAppName })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
</section>
|
||||
) : null,
|
||||
isDesktopWindows && matchesSettingsSearch(searchQuery, systemTrayEntries) ? (
|
||||
<section key="system-tray" className="space-y-3">
|
||||
<SettingsSubsectionHeader
|
||||
title={translate('auto.components.settings.AppearancePane.872af9556e', 'System Tray')}
|
||||
/>
|
||||
{/* Why: Code & Markdown is intentionally omitted. Orca has no Appearance-level
|
||||
code/markdown settings — the Monaco editor reuses the terminal font and
|
||||
there is no markdown-style or line-number setting — so a fourth row would
|
||||
be empty. We surface only the three sections that hold real controls
|
||||
rather than fabricate settings. */}
|
||||
|
||||
<div className="divide-y divide-border/40">
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AppearancePane.2edf606c46',
|
||||
'Minimize to Tray on Close'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.b707773a0d',
|
||||
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
|
||||
)}
|
||||
keywords={systemTrayEntries[0]?.keywords ?? ['tray', 'minimize', 'close']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.2edf606c46',
|
||||
'Minimize to Tray on Close'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.b707773a0d',
|
||||
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
|
||||
)}
|
||||
checked={settings.minimizeToTrayOnClose === true}
|
||||
onChange={() =>
|
||||
updateSettings({ minimizeToTrayOnClose: !settings.minimizeToTrayOnClose })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, getStatusBarEntries()) ? (
|
||||
<section key="status-bar" className="space-y-3">
|
||||
<SettingsSubsectionHeader
|
||||
title={translate('auto.components.settings.AppearancePane.3e4175e5c6', 'Status Bar')}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.ea943d0db0',
|
||||
'Choose which indicators appear at the bottom of the window. You can also right-click the status bar for the same toggles.'
|
||||
)}
|
||||
/>
|
||||
{terminalMatches ? (
|
||||
<AppearanceSection
|
||||
id="terminal"
|
||||
icon={<TerminalSquare aria-hidden="true" />}
|
||||
title={terminalTitle}
|
||||
summary={terminalSummary}
|
||||
open={isSectionOpen('terminal')}
|
||||
onToggle={() => toggleSection('terminal')}
|
||||
>
|
||||
<TerminalAppearanceSection
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
systemPrefersDark={systemPrefersDark}
|
||||
terminalFontSuggestions={terminalFontSuggestions}
|
||||
ghostty={ghostty}
|
||||
warpThemes={warpThemes}
|
||||
forceVisiblePrimary={terminalLabelMatches}
|
||||
/>
|
||||
</AppearanceSection>
|
||||
) : null}
|
||||
|
||||
<div className="divide-y divide-border/40">
|
||||
{visibleStatusBarToggles.map((toggle) => {
|
||||
const enabled = statusBarItems.includes(toggle.id)
|
||||
return (
|
||||
<SearchableSetting
|
||||
key={toggle.id}
|
||||
title={toggle.title}
|
||||
description={toggle.description}
|
||||
keywords={toggle.keywords}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={toggle.title}
|
||||
description={toggle.toggleDescription}
|
||||
checked={enabled}
|
||||
onChange={() => {
|
||||
if (toggle.id === 'resource-usage') {
|
||||
recordFeatureInteraction('resource-manager')
|
||||
} else if (toggle.id === 'ports') {
|
||||
recordFeatureInteraction('ports')
|
||||
} else if (toggle.id === 'ssh') {
|
||||
recordFeatureInteraction('ssh')
|
||||
} else if (
|
||||
toggle.id === 'claude' ||
|
||||
toggle.id === 'codex' ||
|
||||
toggle.id === 'gemini' ||
|
||||
toggle.id === 'opencode-go'
|
||||
) {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
}
|
||||
toggleStatusBarItem(toggle.id)
|
||||
}}
|
||||
ariaLabel={toggle.title}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, getSidebarEntries()) ? (
|
||||
<section key="sidebar" className="space-y-3">
|
||||
<SettingsSubsectionHeader
|
||||
title={translate('auto.components.settings.AppearancePane.dc29f3cc0d', 'Sidebar')}
|
||||
/>
|
||||
{windowMatches ? (
|
||||
<AppearanceSection
|
||||
id="window"
|
||||
icon={<PanelLeft aria-hidden="true" />}
|
||||
title={windowSidebarTitle}
|
||||
summary={windowSidebarSummary}
|
||||
open={isSectionOpen('window')}
|
||||
onToggle={() => toggleSection('window')}
|
||||
>
|
||||
<AppearanceWindowSidebarSection
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
forceVisiblePrimary={windowLabelMatches}
|
||||
/>
|
||||
</AppearanceSection>
|
||||
) : null}
|
||||
|
||||
<div className="divide-y divide-border/40">
|
||||
<SearchableSetting
|
||||
title={leftSidebarAppearanceEntry.title}
|
||||
description={leftSidebarAppearanceEntry.description}
|
||||
keywords={leftSidebarAppearanceEntry.keywords}
|
||||
className="space-y-2"
|
||||
>
|
||||
<LeftSidebarAppearanceSetting settings={settings} updateSettings={updateSettings} />
|
||||
</SearchableSetting>
|
||||
|
||||
{/* Why: this setting lives with the sidebar layout controls; Settings only
|
||||
points people to it so we do not create a second stateful control. */}
|
||||
<SearchableSetting
|
||||
title={workspaceCardLayoutEntry.title}
|
||||
description={workspaceCardLayoutEntry.description}
|
||||
keywords={workspaceCardLayoutEntry.keywords}
|
||||
>
|
||||
<SettingsRow
|
||||
label={workspaceCardLayoutEntry.title}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.workspaceCardLayoutGuidance',
|
||||
'Use the workspace sidebar options menu > Card layout > Compact.'
|
||||
)}
|
||||
control={null}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AppearancePane.cf81907069',
|
||||
'Show Tasks Button'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.661942ab7f',
|
||||
'Show the Tasks button at the top of the left sidebar.'
|
||||
)}
|
||||
keywords={getSidebarEntries()[0]?.keywords ?? ['tasks', 'sidebar', 'button']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.cf81907069',
|
||||
'Show Tasks Button'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.661942ab7f',
|
||||
'Show the Tasks button at the top of the left sidebar.'
|
||||
)}
|
||||
checked={settings.showTasksButton !== false}
|
||||
onChange={() =>
|
||||
updateSettings({ showTasksButton: !(settings.showTasksButton !== false) })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AppearancePane.511f270ebb',
|
||||
'Show Automations Button'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.fa882a3e6b',
|
||||
'Show the Automations button at the top of the left sidebar.'
|
||||
)}
|
||||
keywords={getSidebarEntries()[1]?.keywords ?? ['automations', 'automation', 'schedule']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.511f270ebb',
|
||||
'Show Automations Button'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.fa882a3e6b',
|
||||
'Show the Automations button at the top of the left sidebar.'
|
||||
)}
|
||||
checked={settings.showAutomationsButton !== false}
|
||||
onChange={() =>
|
||||
updateSettings({
|
||||
showAutomationsButton: !(settings.showAutomationsButton !== false)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AppearancePane.9da1020447',
|
||||
'Show Orca Mobile Button'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.5db6ba961f',
|
||||
'Show the Orca Mobile button at the top of the left sidebar.'
|
||||
)}
|
||||
keywords={getSidebarEntries()[2]?.keywords ?? ['mobile', 'phone', 'sidebar']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.9da1020447',
|
||||
'Show Orca Mobile Button'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.61d842eca0',
|
||||
'Show the Orca Mobile shortcut in the sidebar. It remains available from Toolbox.'
|
||||
)}
|
||||
checked={settings.showMobileButton !== false}
|
||||
onChange={() =>
|
||||
updateSettings({ showMobileButton: !(settings.showMobileButton !== false) })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, getAppIconEntries()) ? (
|
||||
<section key="app-icon" className="space-y-3">
|
||||
{/* App icon stays at the bottom of Appearance as a small easter egg,
|
||||
matching production — not buried inside Interface advanced. */}
|
||||
{appIconMatches ? (
|
||||
<SearchableSetting
|
||||
title={translate('auto.components.settings.AppearancePane.ca1590d42f', 'App Icon')}
|
||||
description={translate(
|
||||
@@ -585,25 +243,14 @@ export function AppearancePane({
|
||||
entry.description ?? '',
|
||||
...(entry.keywords ?? [])
|
||||
])}
|
||||
className="max-w-none py-2"
|
||||
className="max-w-none px-1 pt-2"
|
||||
>
|
||||
<AppIconSelector
|
||||
value={normalizeAppIconId(settings.appIcon)}
|
||||
onChange={(appIcon) => updateSettings({ appIcon })}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</section>
|
||||
) : null
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{visibleSections.map((section, index) => (
|
||||
<div key={index} className="space-y-6">
|
||||
{index > 0 ? <Separator /> : null}
|
||||
{section}
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type React from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type AppearanceSectionProps = {
|
||||
/** Stable id used for the accordion toggle + aria wiring. */
|
||||
id: string
|
||||
icon: React.ReactNode
|
||||
title: React.ReactNode
|
||||
/** Plain-language current value shown in the collapsed summary row. */
|
||||
summary: React.ReactNode
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Compact summary row that expands its section inline. The parent owns the
|
||||
* open state so opening one row can collapse the previously open one
|
||||
* (accordion behavior) and search can force a section open. */
|
||||
export function AppearanceSection({
|
||||
id,
|
||||
icon,
|
||||
title,
|
||||
summary,
|
||||
open,
|
||||
onToggle,
|
||||
children
|
||||
}: AppearanceSectionProps): React.JSX.Element {
|
||||
const contentId = `appearance-section-${id}`
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-border/50 bg-card transition-colors',
|
||||
open && 'border-ring/40'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-secondary text-foreground [&_svg]:size-4">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold">{title}</span>
|
||||
{!open ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">{summary}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-[18px] shrink-0 text-muted-foreground transition-transform',
|
||||
open && 'rotate-90 text-foreground'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-rows,opacity,border-color] duration-200 ease-out motion-reduce:transition-none',
|
||||
open
|
||||
? 'grid-rows-[1fr] border-t border-border/50 opacity-100'
|
||||
: 'grid-rows-[0fr] border-t border-transparent opacity-0'
|
||||
)}
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div id={contentId} role="region" className="px-4 pt-1 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type React from 'react'
|
||||
|
||||
import { type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
|
||||
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/** Renders the primary keyboard shortcut combo inline, or an "Unassigned"
|
||||
* hint when the action has no binding. Platform-aware glyphs come from
|
||||
* ShortcutKeyCombo. */
|
||||
export function ShortcutHintList({
|
||||
combos
|
||||
}: {
|
||||
combos: ShortcutKeyComboDetails[]
|
||||
}): React.JSX.Element {
|
||||
if (combos.length === 0) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.settings.AppearancePane.3057983501', 'Unassigned')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const primaryCombo = combos[0]
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center align-middle">
|
||||
<ShortcutKeyCombo
|
||||
keys={primaryCombo.keys}
|
||||
doubleTap={primaryCombo.doubleTap}
|
||||
className="inline-flex gap-0.5"
|
||||
separatorClassName="text-[10px] text-muted-foreground"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import type React from 'react'
|
||||
|
||||
import type { GlobalSettings, StatusBarItem } from '../../../../shared/types'
|
||||
import type { FeatureInteractionId } from '../../../../shared/feature-interaction-catalog'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
SettingsRow,
|
||||
SettingsSegmentedControl,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSwitchRow
|
||||
} from './SettingsFormControls'
|
||||
import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles'
|
||||
import { getLayoutEntries, getSidebarEntries, getStatusBarToggles } from './appearance-search'
|
||||
import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting'
|
||||
import {
|
||||
getLeftSidebarAppearanceEntry,
|
||||
getWorkspaceCardLayoutEntry
|
||||
} from './appearance-sidebar-search'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
|
||||
|
||||
type AppearanceWindowSidebarSectionProps = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
forceVisiblePrimary?: boolean
|
||||
}
|
||||
|
||||
function recordStatusBarToggleInteraction(
|
||||
id: StatusBarItem,
|
||||
recordFeatureInteraction: (feature: FeatureInteractionId) => void
|
||||
): void {
|
||||
if (id === 'resource-usage') {
|
||||
recordFeatureInteraction('resource-manager')
|
||||
} else if (id === 'ports') {
|
||||
recordFeatureInteraction('ports')
|
||||
} else if (id === 'ssh') {
|
||||
recordFeatureInteraction('ssh')
|
||||
} else if (id === 'claude' || id === 'codex' || id === 'gemini' || id === 'opencode-go') {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
}
|
||||
}
|
||||
|
||||
export function AppearanceWindowSidebarSection({
|
||||
settings,
|
||||
updateSettings,
|
||||
forceVisiblePrimary = false
|
||||
}: AppearanceWindowSidebarSectionProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
|
||||
const statusBarItems = useAppStore((state) => state.statusBarItems)
|
||||
const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
const setWorktreeCardMode = useAppStore((state) => state.setWorktreeCardMode)
|
||||
const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles())
|
||||
const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry()
|
||||
const sidebarEntries = getSidebarEntries()
|
||||
const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry()
|
||||
const layoutEntries = getLayoutEntries()
|
||||
const statusBarTitle = translate(
|
||||
'auto.components.settings.AppearancePane.3e4175e5c6',
|
||||
'Status Bar'
|
||||
)
|
||||
const statusBarDescription = translate(
|
||||
'auto.components.settings.AppearancePane.statusBarDescription',
|
||||
'Choose which indicators appear in the status bar.'
|
||||
)
|
||||
const statusBarKeywords = ['status bar', 'indicators']
|
||||
const statusBarSectionMatches = matchesSettingsSearch(searchQuery, {
|
||||
title: statusBarTitle,
|
||||
description: statusBarDescription,
|
||||
keywords: statusBarKeywords
|
||||
})
|
||||
const statusBarControlMatches = visibleStatusBarToggles.some((toggle) =>
|
||||
matchesSettingsSearch(searchQuery, {
|
||||
title: toggle.title,
|
||||
description: toggle.description,
|
||||
keywords: toggle.keywords
|
||||
})
|
||||
)
|
||||
const sidebarAdvancedMatches = matchesSettingsSearch(searchQuery, [
|
||||
workspaceCardLayoutEntry,
|
||||
...sidebarEntries
|
||||
])
|
||||
const fileExplorerAdvancedMatches = matchesSettingsSearch(searchQuery, layoutEntries)
|
||||
const showStatusBarControls = !isSearching || statusBarSectionMatches || statusBarControlMatches
|
||||
const showSidebarAdvanced = !isSearching || sidebarAdvancedMatches
|
||||
const showFileExplorerAdvanced = !isSearching || fileExplorerAdvancedMatches
|
||||
const showAdvanced = showSidebarAdvanced || showFileExplorerAdvanced
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="divide-y divide-border/40">
|
||||
<SearchableSetting
|
||||
title={leftSidebarAppearanceEntry.title}
|
||||
description={leftSidebarAppearanceEntry.description}
|
||||
keywords={leftSidebarAppearanceEntry.keywords}
|
||||
className="space-y-2"
|
||||
forceVisible={forceVisiblePrimary}
|
||||
>
|
||||
<LeftSidebarAppearanceSetting settings={settings} updateSettings={updateSettings} />
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={statusBarTitle}
|
||||
keywords={statusBarKeywords}
|
||||
forceVisible={forceVisiblePrimary || statusBarSectionMatches || statusBarControlMatches}
|
||||
>
|
||||
<SettingsRow label={statusBarTitle} description={statusBarDescription} control={null} />
|
||||
{showStatusBarControls ? (
|
||||
<div className="ml-4 divide-y divide-border/40 border-t border-border/40">
|
||||
{visibleStatusBarToggles.map((toggle) => {
|
||||
const enabled = statusBarItems.includes(toggle.id)
|
||||
return (
|
||||
<SearchableSetting
|
||||
key={toggle.id}
|
||||
title={toggle.title}
|
||||
description={toggle.description}
|
||||
keywords={toggle.keywords}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={toggle.title}
|
||||
description={toggle.toggleDescription}
|
||||
checked={enabled}
|
||||
onChange={() => {
|
||||
recordStatusBarToggleInteraction(toggle.id, recordFeatureInteraction)
|
||||
toggleStatusBarItem(toggle.id)
|
||||
}}
|
||||
ariaLabel={toggle.title}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
|
||||
{showAdvanced ? (
|
||||
<AppearanceAdvancedDisclosure contentClassName="ml-4 pt-4">
|
||||
<div className="space-y-4">
|
||||
{showSidebarAdvanced ? (
|
||||
<div className="space-y-3">
|
||||
<SettingsSubsectionHeader
|
||||
title={translate('auto.components.settings.AppearancePane.dc29f3cc0d', 'Sidebar')}
|
||||
/>
|
||||
<div className="ml-4 divide-y divide-border/40">
|
||||
{/* Why: this setting lives with the sidebar layout controls; Settings only
|
||||
names that ownership so we do not create a second stateful control. */}
|
||||
<SearchableSetting
|
||||
title={workspaceCardLayoutEntry.title}
|
||||
description={workspaceCardLayoutEntry.description}
|
||||
keywords={workspaceCardLayoutEntry.keywords}
|
||||
>
|
||||
<SettingsRow
|
||||
label={workspaceCardLayoutEntry.title}
|
||||
description={workspaceCardLayoutEntry.description}
|
||||
control={
|
||||
<SettingsSegmentedControl
|
||||
value={settings.compactWorktreeCards ? 'compact' : 'detailed'}
|
||||
onChange={(value) =>
|
||||
setWorktreeCardMode(value === 'compact' ? 'Compact' : 'Default')
|
||||
}
|
||||
ariaLabel={workspaceCardLayoutEntry.title}
|
||||
options={[
|
||||
{
|
||||
value: 'detailed',
|
||||
label: translate(
|
||||
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.cc17bd443b',
|
||||
'Detailed'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'compact',
|
||||
label: translate(
|
||||
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb',
|
||||
'Compact'
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AppearancePane.cf81907069',
|
||||
'Show Tasks Button'
|
||||
)}
|
||||
description={sidebarEntries[0]?.description}
|
||||
keywords={sidebarEntries[0]?.keywords ?? ['tasks', 'sidebar', 'button']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.cf81907069',
|
||||
'Show Tasks Button'
|
||||
)}
|
||||
checked={settings.showTasksButton !== false}
|
||||
onChange={() =>
|
||||
updateSettings({ showTasksButton: !(settings.showTasksButton !== false) })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AppearancePane.511f270ebb',
|
||||
'Show Automations Button'
|
||||
)}
|
||||
description={sidebarEntries[1]?.description}
|
||||
keywords={
|
||||
sidebarEntries[1]?.keywords ?? ['automations', 'automation', 'schedule']
|
||||
}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.511f270ebb',
|
||||
'Show Automations Button'
|
||||
)}
|
||||
checked={settings.showAutomationsButton !== false}
|
||||
onChange={() =>
|
||||
updateSettings({
|
||||
showAutomationsButton: !(settings.showAutomationsButton !== false)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AppearancePane.9da1020447',
|
||||
'Show Orca Mobile Button'
|
||||
)}
|
||||
description={sidebarEntries[2]?.description}
|
||||
keywords={sidebarEntries[2]?.keywords ?? ['mobile', 'phone', 'sidebar']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.9da1020447',
|
||||
'Show Orca Mobile Button'
|
||||
)}
|
||||
// Why: clarify where the shortcut still lives after hiding it, so users
|
||||
// don't think the feature is gone.
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.61d842eca0',
|
||||
'Show the Orca Mobile shortcut in the sidebar. It remains available from Toolbox.'
|
||||
)}
|
||||
checked={settings.showMobileButton !== false}
|
||||
onChange={() =>
|
||||
updateSettings({ showMobileButton: !(settings.showMobileButton !== false) })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showFileExplorerAdvanced ? (
|
||||
<div className="space-y-3">
|
||||
<SettingsSubsectionHeader
|
||||
title={translate(
|
||||
'auto.components.settings.AppearancePane.d496901cd0',
|
||||
'File Explorer'
|
||||
)}
|
||||
/>
|
||||
<div className="ml-4 divide-y divide-border/40">
|
||||
<SearchableSetting
|
||||
title={
|
||||
layoutEntries[0]?.title ??
|
||||
translate(
|
||||
'auto.components.settings.AppearancePane.0fafabcf35',
|
||||
'Show Git-Ignored Files'
|
||||
)
|
||||
}
|
||||
description={layoutEntries[0]?.description}
|
||||
keywords={layoutEntries[0]?.keywords ?? ['git', 'gitignore', 'ignored']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.0fafabcf35',
|
||||
'Show Git-Ignored Files'
|
||||
)}
|
||||
// Why: define what "git-ignored" matches; the location (file explorer)
|
||||
// is obvious from the section header.
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.gitIgnoredGlossary',
|
||||
'Files matched by .gitignore.'
|
||||
)}
|
||||
checked={settings.showGitIgnoredFiles ?? true}
|
||||
onChange={() =>
|
||||
updateSettings({
|
||||
showGitIgnoredFiles: !(settings.showGitIgnoredFiles ?? true)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AppearanceAdvancedDisclosure>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getLocalCommandSourcePolicyNotice } from './RepositoryHooksSection'
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { getLocalCommandSourcePolicyNotice, RepositoryHooksSection } from './RepositoryHooksSection'
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
settings: {},
|
||||
settingsSearchQuery: ''
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-hooks-client', () => ({
|
||||
readRuntimeIssueCommand: vi.fn().mockResolvedValue({ command: '', exists: false }),
|
||||
writeRuntimeIssueCommand: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
kind: 'git',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
gitUsername: ''
|
||||
}
|
||||
|
||||
function renderRepositoryHooksSection(args: {
|
||||
onUpdateHookSettings: (settings: NonNullable<Repo['hookSettings']>) => void
|
||||
}): { container: HTMLDivElement; root: Root } {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(RepositoryHooksSection, {
|
||||
repo,
|
||||
yamlHooks: null,
|
||||
hasHooksFile: false,
|
||||
hooksInspectionReady: true,
|
||||
mayNeedUpdate: false,
|
||||
copiedTemplate: false,
|
||||
forceVisible: true,
|
||||
onCopyTemplate: () => {},
|
||||
onUpdateHookSettings: args.onUpdateHookSettings
|
||||
})
|
||||
)
|
||||
})
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
let rendered: { container: HTMLDivElement; root: Root } | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => rendered?.root.unmount())
|
||||
rendered?.container.remove()
|
||||
rendered = null
|
||||
})
|
||||
|
||||
describe('getLocalCommandSourcePolicyNotice', () => {
|
||||
it('does not show a notice when no local scripts are saved', () => {
|
||||
@@ -72,3 +133,25 @@ describe('getLocalCommandSourcePolicyNotice', () => {
|
||||
).toEqual({ kind: 'action', policy: 'run-both', label: 'Run both' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('RepositoryHooksSection setup startup policy', () => {
|
||||
it('persists wait-for-setup when the repository toggle is checked', () => {
|
||||
const updates: NonNullable<Repo['hookSettings']>[] = []
|
||||
rendered = renderRepositoryHooksSection({
|
||||
onUpdateHookSettings: (settings) => updates.push(settings)
|
||||
})
|
||||
|
||||
const waitSwitch = rendered.container.querySelector<HTMLElement>(
|
||||
'[role="switch"][aria-label="Wait for setup to complete before starting agent"]'
|
||||
)
|
||||
expect(waitSwitch).toBeTruthy()
|
||||
|
||||
act(() => waitSwitch?.click())
|
||||
|
||||
expect(updates.at(-1)).toMatchObject({
|
||||
setupAgentStartupPolicy: 'wait-for-setup',
|
||||
setupRunPolicy: 'run-by-default',
|
||||
scripts: { setup: '', archive: '' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
OrcaHooks,
|
||||
Repo,
|
||||
RepoHookSettings,
|
||||
SetupAgentStartupPolicy,
|
||||
SetupRunPolicy
|
||||
} from '../../../../shared/types'
|
||||
import { AlertTriangle, ChevronRight, Plus } from 'lucide-react'
|
||||
@@ -14,6 +15,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '../ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { SettingsSwitch } from './SettingsFormControls'
|
||||
import { useAppStore } from '@/store'
|
||||
import { readRuntimeIssueCommand, writeRuntimeIssueCommand } from '@/runtime/runtime-hooks-client'
|
||||
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
|
||||
@@ -39,7 +41,7 @@ type PolicyOption<P> = { policy: P; label: string; description: string }
|
||||
const LOCAL_HOOK_NAMES = ['setup', 'archive'] as const
|
||||
type LocalHookName = (typeof LOCAL_HOOK_NAMES)[number]
|
||||
type HookSettingsPolicyDraft = Partial<
|
||||
Pick<RepoHookSettings, 'setupRunPolicy' | 'commandSourcePolicy'>
|
||||
Pick<RepoHookSettings, 'setupRunPolicy' | 'setupAgentStartupPolicy' | 'commandSourcePolicy'>
|
||||
>
|
||||
|
||||
// Why: this is a literal issue-command template token, not app data for i18next to fill.
|
||||
@@ -75,6 +77,7 @@ function areHookSettingsDraftsEqual(a: RepoHookSettings, b: RepoHookSettings): b
|
||||
return (
|
||||
a.mode === b.mode &&
|
||||
a.setupRunPolicy === b.setupRunPolicy &&
|
||||
a.setupAgentStartupPolicy === b.setupAgentStartupPolicy &&
|
||||
a.commandSourcePolicy === b.commandSourcePolicy &&
|
||||
a.scripts.setup === b.scripts.setup &&
|
||||
a.scripts.archive === b.scripts.archive
|
||||
@@ -770,6 +773,8 @@ export function RepositoryHooksSection({
|
||||
|
||||
const selectedSetupRunPolicy: SetupRunPolicy =
|
||||
hookSettingsDraft.setupRunPolicy ?? 'run-by-default'
|
||||
const selectedSetupAgentStartupPolicy: SetupAgentStartupPolicy =
|
||||
hookSettingsDraft.setupAgentStartupPolicy ?? 'start-immediately'
|
||||
const setupRunPolicyOptions = getSetupRunPolicyOptions()
|
||||
const commandSourcePolicyOptions = getCommandSourcePolicyOptions()
|
||||
const localHookFields = getLocalHookFields()
|
||||
@@ -1043,26 +1048,59 @@ export function RepositoryHooksSection({
|
||||
forceVisible={forceVisible}
|
||||
keywords={['setup run policy', 'ask', 'run by default', 'skip by default']}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
|
||||
<div className="min-w-0">
|
||||
<h5 className="text-sm font-semibold">
|
||||
{translate(
|
||||
'auto.components.settings.RepositoryHooksSection.793dcee97d',
|
||||
'When to run'
|
||||
<div className="space-y-4 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h5 className="text-sm font-semibold">
|
||||
{translate(
|
||||
'auto.components.settings.RepositoryHooksSection.793dcee97d',
|
||||
'When to run'
|
||||
)}
|
||||
</h5>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RepositoryHooksSection.21fb607a87',
|
||||
'Default behavior when a new worktree is created.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<SegmentedPolicyToggle
|
||||
options={setupRunPolicyOptions}
|
||||
selected={selectedSetupRunPolicy}
|
||||
onSelect={(policy) => updateHookSettingsPolicyDraft({ setupRunPolicy: policy })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 border-t border-border/60 pt-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h5 className="text-sm font-semibold">
|
||||
{translate(
|
||||
'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent',
|
||||
'Wait for setup to complete before starting agent'
|
||||
)}
|
||||
</h5>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgentHelp',
|
||||
'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsSwitch
|
||||
checked={selectedSetupAgentStartupPolicy === 'wait-for-setup'}
|
||||
onChange={() =>
|
||||
updateHookSettingsPolicyDraft({
|
||||
setupAgentStartupPolicy:
|
||||
selectedSetupAgentStartupPolicy === 'wait-for-setup'
|
||||
? 'start-immediately'
|
||||
: 'wait-for-setup'
|
||||
})
|
||||
}
|
||||
ariaLabel={translate(
|
||||
'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent',
|
||||
'Wait for setup to complete before starting agent'
|
||||
)}
|
||||
</h5>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RepositoryHooksSection.21fb607a87',
|
||||
'Default behavior when a new worktree is created.'
|
||||
)}
|
||||
</p>
|
||||
/>
|
||||
</div>
|
||||
<SegmentedPolicyToggle
|
||||
options={setupRunPolicyOptions}
|
||||
selected={selectedSetupRunPolicy}
|
||||
onSelect={(policy) => updateHookSettingsPolicyDraft({ setupRunPolicy: policy })}
|
||||
/>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
|
||||
|
||||
@@ -76,11 +76,19 @@ export function SettingsRow({
|
||||
}: SettingsRowProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex gap-4 py-2', alignTop ? 'items-start' : 'items-center justify-between')}
|
||||
className={cn(
|
||||
'flex gap-4',
|
||||
description ? 'py-3' : 'py-2',
|
||||
alignTop ? 'items-start' : 'items-center justify-between'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<Label id={labelId}>{label}</Label>
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
<div className={cn('min-w-0 flex-1', description ? 'space-y-1' : 'space-y-0.5')}>
|
||||
<Label id={labelId} className="select-text">
|
||||
{label}
|
||||
</Label>
|
||||
{description ? (
|
||||
<p className="select-text text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0">{control}</div>
|
||||
</div>
|
||||
@@ -218,16 +226,18 @@ type SettingsSubsectionHeaderProps = {
|
||||
title: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Consistent subsection header: h3 text-sm font-semibold + optional muted description. */
|
||||
export function SettingsSubsectionHeader({
|
||||
title,
|
||||
description,
|
||||
action
|
||||
action,
|
||||
className
|
||||
}: SettingsSubsectionHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className={cn('flex items-start justify-between gap-3', className)}>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import {
|
||||
DEFAULT_TERMINAL_FONT_WEIGHT,
|
||||
TERMINAL_FONT_WEIGHT_MAX,
|
||||
TERMINAL_FONT_WEIGHT_MIN,
|
||||
TERMINAL_FONT_WEIGHT_STEP,
|
||||
normalizeTerminalFontWeight
|
||||
} from '../../../../shared/terminal-fonts'
|
||||
import {
|
||||
fontFamilyHasKnownLigatures,
|
||||
resolveTerminalLigaturesEnabled
|
||||
} from '../../../../shared/terminal-ligatures'
|
||||
import { NumberField, SettingsRow, SettingsSegmentedControl } from './SettingsFormControls'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { clampNumber } from '@/lib/terminal-theme'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getTerminalAdvancedTypographySearchEntries } from './terminal-typography-search'
|
||||
|
||||
type TerminalAdvancedTypographyControlsProps = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
}
|
||||
|
||||
/** Low-frequency terminal typography knobs (weight, line height, ligatures).
|
||||
* Split out of the primary font controls so the default Terminal scan stays
|
||||
* compact while these stay searchable inside the Advanced disclosure. */
|
||||
export function TerminalAdvancedTypographyControls({
|
||||
settings,
|
||||
updateSettings
|
||||
}: TerminalAdvancedTypographyControlsProps): React.JSX.Element {
|
||||
const searchEntries = getTerminalAdvancedTypographySearchEntries()
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border/40">
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.4aae5db258',
|
||||
'Font Weight'
|
||||
)}
|
||||
description={searchEntries[0]?.description}
|
||||
keywords={searchEntries[0]?.keywords ?? ['terminal', 'typography', 'weight']}
|
||||
>
|
||||
<NumberField
|
||||
label={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.4aae5db258',
|
||||
'Font Weight'
|
||||
)}
|
||||
description=""
|
||||
value={normalizeTerminalFontWeight(settings.terminalFontWeight)}
|
||||
defaultValue={DEFAULT_TERMINAL_FONT_WEIGHT}
|
||||
min={TERMINAL_FONT_WEIGHT_MIN}
|
||||
max={TERMINAL_FONT_WEIGHT_MAX}
|
||||
step={TERMINAL_FONT_WEIGHT_STEP}
|
||||
suffix="100-900"
|
||||
onChange={(value) =>
|
||||
updateSettings({ terminalFontWeight: normalizeTerminalFontWeight(value) })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.c084eb7d4c',
|
||||
'Line Height'
|
||||
)}
|
||||
description={searchEntries[1]?.description}
|
||||
keywords={
|
||||
searchEntries[1]?.keywords ?? ['terminal', 'typography', 'line height', 'spacing']
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
label={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.c084eb7d4c',
|
||||
'Line Height'
|
||||
)}
|
||||
description=""
|
||||
value={settings.terminalLineHeight}
|
||||
defaultValue={1}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
suffix="1-3"
|
||||
onChange={(value) => updateSettings({ terminalLineHeight: clampNumber(value, 1, 3) })}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.be8da35e7f',
|
||||
'Font Ligatures'
|
||||
)}
|
||||
description={searchEntries[2]?.description}
|
||||
keywords={
|
||||
searchEntries[2]?.keywords ?? [
|
||||
'terminal',
|
||||
'typography',
|
||||
'ligatures',
|
||||
'ligature',
|
||||
'fira code',
|
||||
'jetbrains mono',
|
||||
'cascadia code',
|
||||
'iosevka',
|
||||
'calt',
|
||||
'font features'
|
||||
]
|
||||
}
|
||||
>
|
||||
<SettingsRow
|
||||
label={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.be8da35e7f',
|
||||
'Font Ligatures'
|
||||
)}
|
||||
// Why: "ligatures" is jargon; the per-state gloss tells the user whether
|
||||
// Auto resolves on/off for their current font.
|
||||
description={
|
||||
settings.terminalLigatures === 'on'
|
||||
? translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.7234abcd08',
|
||||
'Always on. Fonts without ligatures simply render as-is.'
|
||||
)
|
||||
: settings.terminalLigatures === 'off'
|
||||
? translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.04569feb07',
|
||||
'Always off, even for fonts that ship them.'
|
||||
)
|
||||
: fontFamilyHasKnownLigatures(settings.terminalFontFamily)
|
||||
? translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.400e950ca5',
|
||||
'Auto - enabled for "{{value0}}".',
|
||||
{ value0: settings.terminalFontFamily }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.4b1f29598e',
|
||||
'Auto - disabled for "{{value0}}".',
|
||||
{ value0: settings.terminalFontFamily || 'the current font' }
|
||||
)
|
||||
}
|
||||
control={
|
||||
<SettingsSegmentedControl
|
||||
ariaLabel={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.be8da35e7f',
|
||||
'Font Ligatures'
|
||||
)}
|
||||
value={settings.terminalLigatures ?? 'auto'}
|
||||
onChange={(option) => updateSettings({ terminalLigatures: option })}
|
||||
options={[
|
||||
{
|
||||
value: 'auto',
|
||||
label: translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.bc9ff84d61',
|
||||
'Auto'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'on',
|
||||
label: translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.84bd22f2cd',
|
||||
'On'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'off',
|
||||
label: translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.870377082f',
|
||||
'Off'
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.31f6e61085',
|
||||
'Ligatures are currently'
|
||||
)}{' '}
|
||||
{resolveTerminalLigaturesEnabled(settings.terminalLigatures, settings.terminalFontFamily)
|
||||
? translate('auto.components.settings.TerminalAppearanceSection.4e7d41a9f0', 'enabled')
|
||||
: translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.4415beb958',
|
||||
'disabled'
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -97,8 +97,8 @@ vi.mock('./SettingsFormControls', () => ({
|
||||
}) {
|
||||
return options?.map((option) => option.label) ?? null
|
||||
},
|
||||
SettingsSubsectionHeader: function SettingsSubsectionHeader() {
|
||||
return null
|
||||
SettingsSubsectionHeader: function SettingsSubsectionHeader({ action }: { action?: unknown }) {
|
||||
return action ?? null
|
||||
},
|
||||
SettingsSwitchRow: function SettingsSwitchRow() {
|
||||
return null
|
||||
@@ -322,6 +322,30 @@ function findWarpThemeImportModal(node: unknown): ReactElementLike | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function findComponentByTypeName(node: unknown, targetTypeName: string): ReactElementLike | null {
|
||||
if (node == null) {
|
||||
return null
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findComponentByTypeName(child, targetTypeName)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
const el = node as ReactElementLike
|
||||
const typeName = typeof el.type === 'function' ? el.type.name : String(el.type)
|
||||
if (typeName === targetTypeName) {
|
||||
return el
|
||||
}
|
||||
if (el.props?.children) {
|
||||
return findComponentByTypeName(el.props.children, targetTypeName)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
describe('TerminalAppearanceSection ghostty import wiring', () => {
|
||||
beforeEach(() => {
|
||||
mockStateValues.length = 0
|
||||
@@ -421,6 +445,68 @@ describe('TerminalAppearanceSection ghostty import wiring', () => {
|
||||
expect(findTerminalThemeCatalogSection(darkPhraseElement)?.props.preferredTarget).toBe('dark')
|
||||
})
|
||||
|
||||
it('does not open advanced typography for primary terminal font searches', () => {
|
||||
mockSettingsSearchQuery = 'font size'
|
||||
|
||||
const element = TerminalAppearanceSection({
|
||||
settings: {} as never,
|
||||
updateSettings: () => {},
|
||||
systemPrefersDark: true,
|
||||
terminalFontSuggestions: [],
|
||||
ghostty: ghosttyMock,
|
||||
warpThemes: warpThemesMock
|
||||
})
|
||||
|
||||
expect(findComponentByTypeName(element, 'TerminalAdvancedTypographyControls')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show primary typography chrome for unrelated terminal searches', () => {
|
||||
mockSettingsSearchQuery = 'cursor opacity'
|
||||
|
||||
const element = TerminalAppearanceSection({
|
||||
settings: {} as never,
|
||||
updateSettings: () => {},
|
||||
systemPrefersDark: true,
|
||||
terminalFontSuggestions: [],
|
||||
ghostty: ghosttyMock,
|
||||
warpThemes: warpThemesMock
|
||||
})
|
||||
|
||||
expect(findComponentByTypeName(element, 'TerminalFontSizeSetting')).toBeNull()
|
||||
expect(findButtons(element).some((button) => button.text === 'Import from Ghostty')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the Ghostty import button for Ghostty-only searches', () => {
|
||||
mockSettingsSearchQuery = 'ghostty'
|
||||
|
||||
const element = TerminalAppearanceSection({
|
||||
settings: {} as never,
|
||||
updateSettings: () => {},
|
||||
systemPrefersDark: true,
|
||||
terminalFontSuggestions: [],
|
||||
ghostty: ghosttyMock,
|
||||
warpThemes: warpThemesMock
|
||||
})
|
||||
|
||||
expect(findButtons(element).some((button) => button.text === 'Import from Ghostty')).toBe(true)
|
||||
})
|
||||
|
||||
it('opens typography advanced inside the typography section for advanced searches', () => {
|
||||
mockSettingsSearchQuery = 'line height'
|
||||
|
||||
const element = TerminalAppearanceSection({
|
||||
settings: {} as never,
|
||||
updateSettings: () => {},
|
||||
systemPrefersDark: true,
|
||||
terminalFontSuggestions: [],
|
||||
ghostty: ghosttyMock,
|
||||
warpThemes: warpThemesMock
|
||||
})
|
||||
|
||||
expect(findComponentByTypeName(element, 'TerminalAdvancedTypographyControls')).not.toBeNull()
|
||||
expect(findComponentByTypeName(element, 'TerminalFontSizeSetting')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('hides the theme import affordance on paired web clients', () => {
|
||||
vi.stubGlobal('window', {
|
||||
__ORCA_WEB_CLIENT__: true,
|
||||
|
||||
@@ -2,32 +2,42 @@ import { useState } from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import {
|
||||
matchesSettingsSearch,
|
||||
normalizeSettingsSearchQuery,
|
||||
scoreSettingsSearch,
|
||||
type SettingsSearchEntry
|
||||
} from './settings-search'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
getTerminalAdvancedTypographySearchEntries,
|
||||
getTerminalCursorSearchEntries,
|
||||
getTerminalDarkThemeSearchEntries,
|
||||
getTerminalGhosttyImportSearchEntries,
|
||||
getTerminalLightThemeSearchEntries,
|
||||
getTerminalPaneAppearanceSearchEntries,
|
||||
getTerminalThemeTargetSearchEntries,
|
||||
getTerminalTypographySearchEntries,
|
||||
getTerminalWarpImportSearchEntries,
|
||||
getTerminalWindowSearchEntries,
|
||||
getTerminalYamlImportSearchEntries
|
||||
getTerminalYamlImportSearchEntries,
|
||||
getTerminalTypographySearchEntries,
|
||||
getTerminalWindowSearchEntries
|
||||
} from './terminal-search'
|
||||
import { Button } from '../ui/button'
|
||||
import { SettingsRow, SettingsSubsectionHeader } from './SettingsFormControls'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { FontAutocomplete } from './SettingsFormControls'
|
||||
import { TerminalFontSizeSetting } from './TerminalFontSizeSetting'
|
||||
import { TerminalAdvancedTypographyControls } from './TerminalAdvancedTypographyControls'
|
||||
import { TerminalThemeCatalogSection } from './TerminalThemeSections'
|
||||
import { TerminalWindowSection } from './TerminalWindowSection'
|
||||
import { TerminalTypographyAppearanceSection } from './TerminalTypographyAppearanceSection'
|
||||
import { TerminalCursorAppearanceSection } from './TerminalCursorAppearanceSection'
|
||||
import { TerminalPaneAppearanceSection } from './TerminalPaneAppearanceSection'
|
||||
import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure'
|
||||
import { GhosttyImportModal } from './GhosttyImportModal'
|
||||
import type { UseGhosttyImportReturn } from './useGhosttyImport'
|
||||
import { WarpThemeImportModal } from './WarpThemeImportModal'
|
||||
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
|
||||
import { isWebClientLocation } from '@/hooks/useSettingsNavigationMetadata'
|
||||
import ghosttyIcon from '../../../../../resources/ghostty.svg'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type TerminalAppearanceSectionProps = {
|
||||
settings: GlobalSettings
|
||||
@@ -36,6 +46,7 @@ type TerminalAppearanceSectionProps = {
|
||||
terminalFontSuggestions: string[]
|
||||
ghostty: UseGhosttyImportReturn
|
||||
warpThemes: UseWarpThemeImportReturn
|
||||
forceVisiblePrimary?: boolean
|
||||
}
|
||||
|
||||
type TerminalThemeTarget = 'dark' | 'light'
|
||||
@@ -64,88 +75,194 @@ export function TerminalAppearanceSection({
|
||||
systemPrefersDark,
|
||||
terminalFontSuggestions,
|
||||
ghostty,
|
||||
warpThemes
|
||||
warpThemes,
|
||||
forceVisiblePrimary = false
|
||||
}: TerminalAppearanceSectionProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
|
||||
const [themeSearch, setThemeSearch] = useState('')
|
||||
const [previewFontFamily, setPreviewFontFamily] = useState<string | null>(null)
|
||||
const showWarpThemeImport = !isWebClientLocation()
|
||||
const darkThemeSearchEntries = getTerminalDarkThemeSearchEntries()
|
||||
const lightThemeSearchEntries = getTerminalLightThemeSearchEntries()
|
||||
const darkThemeSearchScore = scoreSettingsSearch(searchQuery, darkThemeSearchEntries)
|
||||
const lightThemeSearchScore = scoreSettingsSearch(searchQuery, lightThemeSearchEntries)
|
||||
const terminalTypographyEntries = getTerminalTypographySearchEntries()
|
||||
const ghosttyImportEntries = getTerminalGhosttyImportSearchEntries()
|
||||
const themeCatalogSearchEntries = [
|
||||
...getTerminalThemeTargetSearchEntries(),
|
||||
...darkThemeSearchEntries,
|
||||
...lightThemeSearchEntries,
|
||||
...(showWarpThemeImport
|
||||
? [...getTerminalWarpImportSearchEntries(), ...getTerminalYamlImportSearchEntries()]
|
||||
: [])
|
||||
]
|
||||
const darkThemeTargetScore = scoreThemeTargetIntent(searchQuery, darkThemeSearchEntries)
|
||||
const lightThemeTargetScore = scoreThemeTargetIntent(searchQuery, lightThemeSearchEntries)
|
||||
const darkThemeMatches = darkThemeSearchScore > 0
|
||||
const lightThemeMatches = lightThemeSearchScore > 0
|
||||
const themeTargetMatches = matchesSettingsSearch(
|
||||
searchQuery,
|
||||
getTerminalThemeTargetSearchEntries()
|
||||
)
|
||||
const themeImportMatches =
|
||||
showWarpThemeImport &&
|
||||
(matchesSettingsSearch(searchQuery, getTerminalWarpImportSearchEntries()) ||
|
||||
matchesSettingsSearch(searchQuery, getTerminalYamlImportSearchEntries()))
|
||||
const showTerminalThemeCatalog =
|
||||
darkThemeMatches || lightThemeMatches || themeTargetMatches || themeImportMatches
|
||||
const preferredThemeTarget = getPreferredThemeTarget(darkThemeTargetScore, lightThemeTargetScore)
|
||||
|
||||
const visibleSections = [
|
||||
matchesSettingsSearch(searchQuery, getTerminalGhosttyImportSearchEntries()) ||
|
||||
matchesSettingsSearch(searchQuery, getTerminalTypographySearchEntries()) ? (
|
||||
<TerminalTypographyAppearanceSection
|
||||
key="typography"
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
systemPrefersDark={systemPrefersDark}
|
||||
terminalFontSuggestions={terminalFontSuggestions}
|
||||
ghostty={ghostty}
|
||||
previewFontFamily={previewFontFamily}
|
||||
setPreviewFontFamily={setPreviewFontFamily}
|
||||
/>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, getTerminalCursorSearchEntries()) ? (
|
||||
<TerminalCursorAppearanceSection
|
||||
key="cursor"
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, getTerminalPaneAppearanceSearchEntries()) ? (
|
||||
<TerminalPaneAppearanceSection
|
||||
key="pane-appearance"
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, getTerminalWindowSearchEntries()) ? (
|
||||
<TerminalWindowSection key="window" settings={settings} updateSettings={updateSettings} />
|
||||
) : null,
|
||||
showTerminalThemeCatalog ? (
|
||||
<TerminalThemeCatalogSection
|
||||
key={`theme-catalog-${preferredThemeTarget ?? 'manual'}`}
|
||||
settings={settings}
|
||||
systemPrefersDark={systemPrefersDark}
|
||||
themeSearch={themeSearch}
|
||||
setThemeSearch={setThemeSearch}
|
||||
updateSettings={updateSettings}
|
||||
previewFontFamily={previewFontFamily}
|
||||
importedHighlightSignal={warpThemes.importSignal}
|
||||
warpThemes={warpThemes}
|
||||
showThemeImport={showWarpThemeImport}
|
||||
preferredTarget={preferredThemeTarget}
|
||||
/>
|
||||
) : null
|
||||
].filter(Boolean)
|
||||
// Why: low-frequency knobs are force-opened during search; render each group
|
||||
// only when its own search matches so an active query never leaves a dangling header.
|
||||
const typographyMatches = matchesSettingsSearch(
|
||||
searchQuery,
|
||||
getTerminalAdvancedTypographySearchEntries()
|
||||
)
|
||||
const cursorMatches = matchesSettingsSearch(searchQuery, getTerminalCursorSearchEntries())
|
||||
const paneMatches = matchesSettingsSearch(searchQuery, getTerminalPaneAppearanceSearchEntries())
|
||||
const windowMatches = matchesSettingsSearch(searchQuery, getTerminalWindowSearchEntries())
|
||||
const themeCatalogMatches = matchesSettingsSearch(searchQuery, themeCatalogSearchEntries)
|
||||
const previewAdvancedMatches = cursorMatches || paneMatches || windowMatches
|
||||
const showThemeCatalog = !isSearching || themeCatalogMatches || previewAdvancedMatches
|
||||
const primaryTypographyMatches = matchesSettingsSearch(
|
||||
searchQuery,
|
||||
terminalTypographyEntries.slice(0, 2)
|
||||
)
|
||||
const ghosttyImportMatches = matchesSettingsSearch(searchQuery, ghosttyImportEntries)
|
||||
const showPrimaryTypography =
|
||||
!isSearching ||
|
||||
forceVisiblePrimary ||
|
||||
primaryTypographyMatches ||
|
||||
typographyMatches ||
|
||||
ghosttyImportMatches
|
||||
const showGhosttyImport = !isSearching || forceVisiblePrimary || ghosttyImportMatches
|
||||
const showTypographyAdvancedDisclosure = !isSearching || typographyMatches
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{visibleSections.map((section, index) => (
|
||||
<div key={index} className="space-y-6">
|
||||
{index > 0 ? <div className="h-px bg-border/60" /> : null}
|
||||
{section}
|
||||
const advancedGroups = [
|
||||
cursorMatches
|
||||
? {
|
||||
key: 'cursor',
|
||||
node: (
|
||||
<TerminalCursorAppearanceSection settings={settings} updateSettings={updateSettings} />
|
||||
)
|
||||
}
|
||||
: null,
|
||||
paneMatches
|
||||
? {
|
||||
key: 'pane',
|
||||
node: (
|
||||
<TerminalPaneAppearanceSection settings={settings} updateSettings={updateSettings} />
|
||||
)
|
||||
}
|
||||
: null,
|
||||
windowMatches
|
||||
? {
|
||||
key: 'window',
|
||||
node: <TerminalWindowSection settings={settings} updateSettings={updateSettings} />
|
||||
}
|
||||
: null
|
||||
].filter((group): group is { key: string; node: React.JSX.Element } => group !== null)
|
||||
const showAdvancedDisclosure = !isSearching || advancedGroups.length > 0
|
||||
const previewAdvancedContent = showAdvancedDisclosure ? (
|
||||
<AppearanceAdvancedDisclosure
|
||||
showTopBorder={false}
|
||||
className="mt-0 pt-2"
|
||||
contentClassName="ml-4 pt-4"
|
||||
>
|
||||
{advancedGroups.map((group, index) => (
|
||||
<div
|
||||
key={group.key}
|
||||
className={index > 0 ? 'mt-2 border-t border-border/60 pt-4' : undefined}
|
||||
>
|
||||
{group.node}
|
||||
</div>
|
||||
))}
|
||||
</AppearanceAdvancedDisclosure>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Primary: font + theme + previews. The expanded section column is far
|
||||
narrower than the xl breakpoint, so the preview grids inside the
|
||||
theme catalog already stack full-width below their controls. */}
|
||||
{showPrimaryTypography ? (
|
||||
<section className="space-y-3 pt-2">
|
||||
<SettingsSubsectionHeader
|
||||
className="items-center"
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.048aac8a64',
|
||||
'Terminal Typography'
|
||||
)}
|
||||
action={
|
||||
showGhosttyImport ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => void ghostty.handleClick()}
|
||||
>
|
||||
<img src={ghosttyIcon} alt="" aria-hidden="true" className="size-4" />
|
||||
{translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.855a76343a',
|
||||
'Import from Ghostty'
|
||||
)}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="ml-4 divide-y divide-border/40 border-y border-border/40">
|
||||
<TerminalFontSizeSetting
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
forceVisible={forceVisiblePrimary}
|
||||
/>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.a408266e67',
|
||||
'Font Family'
|
||||
)}
|
||||
description={terminalTypographyEntries[1]?.description}
|
||||
keywords={
|
||||
terminalTypographyEntries[1]?.keywords ?? ['terminal', 'typography', 'font']
|
||||
}
|
||||
forceVisible={forceVisiblePrimary}
|
||||
>
|
||||
<SettingsRow
|
||||
label={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.a408266e67',
|
||||
'Font Family'
|
||||
)}
|
||||
control={
|
||||
<FontAutocomplete
|
||||
value={settings.terminalFontFamily}
|
||||
suggestions={terminalFontSuggestions}
|
||||
onChange={(value) => updateSettings({ terminalFontFamily: value })}
|
||||
onPreviewFontFamily={setPreviewFontFamily}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
|
||||
{showTypographyAdvancedDisclosure ? (
|
||||
<div className="ml-4">
|
||||
<AppearanceAdvancedDisclosure showTopBorder={false} contentClassName="ml-4">
|
||||
<TerminalAdvancedTypographyControls
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
</AppearanceAdvancedDisclosure>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{showThemeCatalog ? (
|
||||
<TerminalThemeCatalogSection
|
||||
key={`theme-catalog-${preferredThemeTarget ?? 'manual'}`}
|
||||
settings={settings}
|
||||
systemPrefersDark={systemPrefersDark}
|
||||
themeSearch={themeSearch}
|
||||
setThemeSearch={setThemeSearch}
|
||||
updateSettings={updateSettings}
|
||||
previewFontFamily={previewFontFamily}
|
||||
importedHighlightSignal={warpThemes.importSignal}
|
||||
warpThemes={warpThemes}
|
||||
showThemeImport={showWarpThemeImport}
|
||||
preferredTarget={preferredThemeTarget}
|
||||
advancedContent={previewAdvancedContent}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<GhosttyImportModal
|
||||
open={ghostty.open}
|
||||
onOpenChange={ghostty.handleOpenChange}
|
||||
|
||||
@@ -26,13 +26,9 @@ export function TerminalCursorAppearanceSection({
|
||||
'auto.components.settings.TerminalAppearanceSection.abcb4dd019',
|
||||
'Terminal Cursor'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.d455f2ef4f',
|
||||
'Default cursor appearance for Orca terminal panes.'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="divide-y divide-border/40">
|
||||
<div className="ml-4 divide-y divide-border/40">
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.db270cc9a9',
|
||||
@@ -44,15 +40,12 @@ export function TerminalCursorAppearanceSection({
|
||||
)}
|
||||
keywords={['terminal', 'cursor', 'bar', 'block', 'underline']}
|
||||
>
|
||||
{/* Why: Bar/Block/Underline options convey the meaning; helper text pruned. */}
|
||||
<SettingsRow
|
||||
label={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.db270cc9a9',
|
||||
'Cursor Shape'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.d455f2ef4f',
|
||||
'Default cursor appearance for Orca terminal panes.'
|
||||
)}
|
||||
control={
|
||||
<SettingsSegmentedControl
|
||||
ariaLabel={translate(
|
||||
@@ -105,10 +98,6 @@ export function TerminalCursorAppearanceSection({
|
||||
'auto.components.settings.TerminalAppearanceSection.74736cc9b1',
|
||||
'Blinking Cursor'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.2de6b5a699',
|
||||
'Uses the blinking variant of the selected cursor shape.'
|
||||
)}
|
||||
checked={settings.terminalCursorBlink}
|
||||
onChange={() => updateSettings({ terminalCursorBlink: !settings.terminalCursorBlink })}
|
||||
/>
|
||||
@@ -130,10 +119,7 @@ export function TerminalCursorAppearanceSection({
|
||||
'auto.components.settings.TerminalAppearanceSection.b9f1804422',
|
||||
'Cursor Opacity'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.04cdf85dec',
|
||||
'Opacity of the terminal cursor.'
|
||||
)}
|
||||
description=""
|
||||
value={settings.terminalCursorOpacity ?? 1}
|
||||
defaultValue={1}
|
||||
min={0}
|
||||
|
||||
@@ -8,10 +8,12 @@ import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function TerminalFontSizeSetting({
|
||||
settings,
|
||||
updateSettings
|
||||
updateSettings,
|
||||
forceVisible = false
|
||||
}: {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
forceVisible?: boolean
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<SearchableSetting
|
||||
@@ -21,16 +23,15 @@ export function TerminalFontSizeSetting({
|
||||
'Default terminal font size for new panes and live updates.'
|
||||
)}
|
||||
keywords={['terminal', 'typography', 'text size']}
|
||||
forceVisible={forceVisible}
|
||||
>
|
||||
{/* Why: helper text dropped per the copy audit — "Font Size" + px control
|
||||
is self-evident; the search index keeps the longer description. */}
|
||||
<SettingsRow
|
||||
label={translate(
|
||||
'auto.components.settings.TerminalFontSizeSetting.a4a352b1e9',
|
||||
'Font Size'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalFontSizeSetting.0f4c92e595',
|
||||
'Default terminal font size for new panes and live updates.'
|
||||
)}
|
||||
control={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -22,13 +22,9 @@ export function TerminalPaneAppearanceSection({
|
||||
'auto.components.settings.TerminalAppearanceSection.e1a5c25555',
|
||||
'Terminal Panes'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.1b79379d4f',
|
||||
'Control inactive pane dimming and split divider thickness.'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="divide-y divide-border/40">
|
||||
<div className="ml-4 divide-y divide-border/40">
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.a6fdd6a3b1',
|
||||
@@ -45,9 +41,10 @@ export function TerminalPaneAppearanceSection({
|
||||
'auto.components.settings.TerminalAppearanceSection.a6fdd6a3b1',
|
||||
'Inactive Pane Opacity'
|
||||
)}
|
||||
// Why: clarify which panes get dimmed; tightened per the copy audit.
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.db632cb50e',
|
||||
'Opacity applied to panes that are not currently active.'
|
||||
'auto.components.settings.TerminalAppearanceSection.dimUnfocusedPanes',
|
||||
'Dim unfocused panes.'
|
||||
)}
|
||||
value={paneStyleOptions.inactivePaneOpacity}
|
||||
defaultValue={0.8}
|
||||
@@ -78,10 +75,7 @@ export function TerminalPaneAppearanceSection({
|
||||
'auto.components.settings.TerminalAppearanceSection.f27a99978d',
|
||||
'Divider Thickness'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalAppearanceSection.a14a427ae4',
|
||||
'Thickness of the pane divider line.'
|
||||
)}
|
||||
description=""
|
||||
value={paneStyleOptions.dividerThicknessPx}
|
||||
defaultValue={1}
|
||||
min={1}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import { useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import {
|
||||
ColorField,
|
||||
@@ -29,6 +29,7 @@ type TerminalThemeCatalogSectionProps = {
|
||||
warpThemes: UseWarpThemeImportReturn
|
||||
showThemeImport: boolean
|
||||
preferredTarget?: TerminalThemeTarget
|
||||
advancedContent?: ReactNode
|
||||
}
|
||||
|
||||
export function TerminalThemeCatalogSection({
|
||||
@@ -41,7 +42,8 @@ export function TerminalThemeCatalogSection({
|
||||
importedHighlightSignal,
|
||||
warpThemes,
|
||||
showThemeImport,
|
||||
preferredTarget
|
||||
preferredTarget,
|
||||
advancedContent
|
||||
}: TerminalThemeCatalogSectionProps): React.JSX.Element {
|
||||
const [target, setTarget] = useState<TerminalThemeTarget>(preferredTarget ?? 'dark')
|
||||
const themeOptions = getAvailableTerminalThemeOptions(settings)
|
||||
@@ -78,25 +80,23 @@ export function TerminalThemeCatalogSection({
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<SettingsSubsectionHeader
|
||||
className="items-center"
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalThemeSections.catalog_title',
|
||||
'Terminal Themes'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalThemeSections.catalog_description',
|
||||
'Choose terminal themes and divider colors for dark and light mode.'
|
||||
)}
|
||||
action={
|
||||
showThemeImport ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<WarpThemeImportButton warpThemes={warpThemes} />
|
||||
<YamlThemeImportButton warpThemes={warpThemes} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{showThemeImport ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<WarpThemeImportButton warpThemes={warpThemes} />
|
||||
<YamlThemeImportButton warpThemes={warpThemes} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div>
|
||||
<div className="ml-4 grid gap-4">
|
||||
<div className={advancedContent ? 'border-b border-border/40' : undefined}>
|
||||
<div className="space-y-3">
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
@@ -237,6 +237,8 @@ export function TerminalThemeCatalogSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{advancedContent ? <div className="-mt-4">{advancedContent}</div> : null}
|
||||
|
||||
<TerminalSettingsPreview
|
||||
title={
|
||||
isLightTarget
|
||||
|
||||
@@ -59,132 +59,126 @@ export function TerminalWindowSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
|
||||
'Background Opacity'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalWindowSection.03acb60aa0',
|
||||
'Controls the transparency of the terminal background.'
|
||||
)}
|
||||
keywords={['opacity', 'transparency', 'background', 'alpha']}
|
||||
>
|
||||
<NumberField
|
||||
label={translate(
|
||||
<div className="ml-4 space-y-4">
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
|
||||
'Background Opacity'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalWindowSection.809f37738d',
|
||||
'Controls the transparency of the terminal background. 1 is fully opaque, 0 is fully transparent.'
|
||||
'auto.components.settings.TerminalWindowSection.03acb60aa0',
|
||||
'Controls the transparency of the terminal background.'
|
||||
)}
|
||||
value={settings.terminalBackgroundOpacity ?? 1}
|
||||
defaultValue={1}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
suffix="0 to 1"
|
||||
onChange={(value) =>
|
||||
updateSettings({ terminalBackgroundOpacity: clampNumber(value, 0, 1) })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
keywords={['opacity', 'transparency', 'background', 'alpha']}
|
||||
>
|
||||
<NumberField
|
||||
label={translate(
|
||||
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
|
||||
'Background Opacity'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalWindowSection.809f37738d',
|
||||
'Controls the transparency of the terminal background. 1 is fully opaque, 0 is fully transparent.'
|
||||
)}
|
||||
value={settings.terminalBackgroundOpacity ?? 1}
|
||||
defaultValue={1}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
suffix="0 to 1"
|
||||
onChange={(value) =>
|
||||
updateSettings({ terminalBackgroundOpacity: clampNumber(value, 0, 1) })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalWindowSection.2b82242f43',
|
||||
'Window Blur'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalWindowSection.97950bb087',
|
||||
'Apply background blur to the terminal window. Requires restart.'
|
||||
)}
|
||||
keywords={['window', 'blur', 'background', 'transparency', 'vibrancy']}
|
||||
className="space-y-3 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.2b82242f43',
|
||||
'Window Blur'
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.97950bb087',
|
||||
'Apply background blur to the terminal window. Requires restart.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={settings.windowBackgroundBlur ?? false}
|
||||
onClick={() => updateSettings({ windowBackgroundBlur: !settings.windowBackgroundBlur })}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
(settings.windowBackgroundBlur ?? false) ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
(settings.windowBackgroundBlur ?? false) ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{blurPendingRestart ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-300">
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalWindowSection.2b82242f43',
|
||||
'Window Blur'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalWindowSection.97950bb087',
|
||||
'Apply background blur to the terminal window. Requires restart.'
|
||||
)}
|
||||
keywords={['window', 'blur', 'background', 'transparency', 'vibrancy']}
|
||||
className="space-y-3 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.c65bb9ce63',
|
||||
'Restart required'
|
||||
'auto.components.settings.TerminalWindowSection.2b82242f43',
|
||||
'Window Blur'
|
||||
)}
|
||||
</p>
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.53ce336e15',
|
||||
'Restart Orca to apply the window blur change.'
|
||||
'auto.components.settings.TerminalWindowSection.97950bb087',
|
||||
'Apply background blur to the terminal window. Requires restart.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="shrink-0 gap-1.5"
|
||||
disabled={relaunchingBlur}
|
||||
onClick={() => void handleRelaunch()}
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={settings.windowBackgroundBlur ?? false}
|
||||
onClick={() =>
|
||||
updateSettings({ windowBackgroundBlur: !settings.windowBackgroundBlur })
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
(settings.windowBackgroundBlur ?? false)
|
||||
? 'bg-foreground'
|
||||
: 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<RotateCw className={`size-3 ${relaunchingBlur ? 'animate-spin' : ''}`} />
|
||||
{relaunchingBlur
|
||||
? translate(
|
||||
'auto.components.settings.TerminalWindowSection.907131d741',
|
||||
'Restarting…'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.TerminalWindowSection.8abdab9f7c',
|
||||
'Restart now'
|
||||
)}
|
||||
</Button>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
(settings.windowBackgroundBlur ?? false) ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalWindowSection.36b8402015',
|
||||
'Horizontal Padding'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalWindowSection.25e2f8e8e1',
|
||||
'Horizontal padding around the terminal grid in pixels.'
|
||||
)}
|
||||
keywords={['padding', 'horizontal', 'spacing', 'margin']}
|
||||
>
|
||||
<NumberField
|
||||
label={translate(
|
||||
{blurPendingRestart ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-300">
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.c65bb9ce63',
|
||||
'Restart required'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.53ce336e15',
|
||||
'Restart Orca to apply the window blur change.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="shrink-0 gap-1.5"
|
||||
disabled={relaunchingBlur}
|
||||
onClick={() => void handleRelaunch()}
|
||||
>
|
||||
<RotateCw className={`size-3 ${relaunchingBlur ? 'animate-spin' : ''}`} />
|
||||
{relaunchingBlur
|
||||
? translate(
|
||||
'auto.components.settings.TerminalWindowSection.907131d741',
|
||||
'Restarting…'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.TerminalWindowSection.8abdab9f7c',
|
||||
'Restart now'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalWindowSection.36b8402015',
|
||||
'Horizontal Padding'
|
||||
)}
|
||||
@@ -192,29 +186,26 @@ export function TerminalWindowSection({
|
||||
'auto.components.settings.TerminalWindowSection.25e2f8e8e1',
|
||||
'Horizontal padding around the terminal grid in pixels.'
|
||||
)}
|
||||
value={settings.terminalPaddingX ?? 4}
|
||||
defaultValue={4}
|
||||
min={0}
|
||||
max={512}
|
||||
step={1}
|
||||
suffix="px"
|
||||
onChange={(value) => updateSettings({ terminalPaddingX: Math.max(0, value) })}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
keywords={['padding', 'horizontal', 'spacing', 'margin']}
|
||||
>
|
||||
<NumberField
|
||||
label={translate(
|
||||
'auto.components.settings.TerminalWindowSection.36b8402015',
|
||||
'Horizontal Padding'
|
||||
)}
|
||||
description=""
|
||||
value={settings.terminalPaddingX ?? 4}
|
||||
defaultValue={4}
|
||||
min={0}
|
||||
max={512}
|
||||
step={1}
|
||||
suffix="px"
|
||||
onChange={(value) => updateSettings({ terminalPaddingX: Math.max(0, value) })}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalWindowSection.1afcc1d973',
|
||||
'Vertical Padding'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalWindowSection.1846f6ee6a',
|
||||
'Vertical padding around the terminal grid in pixels.'
|
||||
)}
|
||||
keywords={['padding', 'vertical', 'spacing', 'margin']}
|
||||
>
|
||||
<NumberField
|
||||
label={translate(
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalWindowSection.1afcc1d973',
|
||||
'Vertical Padding'
|
||||
)}
|
||||
@@ -222,133 +213,140 @@ export function TerminalWindowSection({
|
||||
'auto.components.settings.TerminalWindowSection.1846f6ee6a',
|
||||
'Vertical padding around the terminal grid in pixels.'
|
||||
)}
|
||||
value={settings.terminalPaddingY ?? 4}
|
||||
defaultValue={4}
|
||||
min={0}
|
||||
max={512}
|
||||
step={1}
|
||||
suffix="px"
|
||||
onChange={(value) => updateSettings({ terminalPaddingY: Math.max(0, value) })}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalWindowSection.3530908ef9',
|
||||
'Hide Mouse While Typing'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
|
||||
'Hide the mouse cursor when typing in the terminal.'
|
||||
)}
|
||||
keywords={['mouse', 'hide', 'typing', 'cursor']}
|
||||
className="flex items-center justify-between gap-4 py-2"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Label>
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.3530908ef9',
|
||||
'Hide Mouse While Typing'
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
|
||||
'Hide the mouse cursor when typing in the terminal.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={settings.terminalMouseHideWhileTyping ?? false}
|
||||
onClick={() =>
|
||||
updateSettings({
|
||||
terminalMouseHideWhileTyping: !settings.terminalMouseHideWhileTyping
|
||||
})
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
(settings.terminalMouseHideWhileTyping ?? false)
|
||||
? 'bg-foreground'
|
||||
: 'bg-muted-foreground/30'
|
||||
}`}
|
||||
keywords={['padding', 'vertical', 'spacing', 'margin']}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
(settings.terminalMouseHideWhileTyping ?? false) ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalWindowSection.63f8d9336e',
|
||||
'Color Overrides'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalWindowSection.e86e09b5c7',
|
||||
'Override individual terminal colors.'
|
||||
)}
|
||||
keywords={['color', 'override', 'ansi', 'palette', 'theme']}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setColorOverridesExpanded((prev) => !prev)}
|
||||
className="flex items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
<span className={`transition-transform ${colorOverridesExpanded ? 'rotate-90' : ''}`}>
|
||||
▶
|
||||
</span>
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.63f8d9336e',
|
||||
'Color Overrides'
|
||||
<NumberField
|
||||
label={translate(
|
||||
'auto.components.settings.TerminalWindowSection.1afcc1d973',
|
||||
'Vertical Padding'
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={`grid overflow-hidden transition-all duration-300 ease-out ${
|
||||
colorOverridesExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
description=""
|
||||
value={settings.terminalPaddingY ?? 4}
|
||||
defaultValue={4}
|
||||
min={0}
|
||||
max={512}
|
||||
step={1}
|
||||
suffix="px"
|
||||
onChange={(value) => updateSettings({ terminalPaddingY: Math.max(0, value) })}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalWindowSection.3530908ef9',
|
||||
'Hide Mouse While Typing'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
|
||||
'Hide the mouse cursor when typing in the terminal.'
|
||||
)}
|
||||
keywords={['mouse', 'hide', 'typing', 'cursor']}
|
||||
className="flex items-center justify-between gap-4 py-2"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
{/* Why: helper text dropped per copy audit — near-verbatim restatement
|
||||
of the label; the search index keeps the longer phrasing. */}
|
||||
<Label>
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.3530908ef9',
|
||||
'Hide Mouse While Typing'
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={settings.terminalMouseHideWhileTyping ?? false}
|
||||
onClick={() =>
|
||||
updateSettings({
|
||||
terminalMouseHideWhileTyping: !settings.terminalMouseHideWhileTyping
|
||||
})
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
(settings.terminalMouseHideWhileTyping ?? false)
|
||||
? 'bg-foreground'
|
||||
: 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<div className="min-h-0 space-y-4">
|
||||
{COLOR_OVERRIDE_GROUPS.map((group) => (
|
||||
<div key={group.label} className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{group.label}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{group.keys.map((item) => (
|
||||
<ColorField
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
description={item.description}
|
||||
value={settings.terminalColorOverrides?.[item.key] ?? ''}
|
||||
fallback=""
|
||||
onChange={(value) =>
|
||||
updateSettings({
|
||||
terminalColorOverrides: {
|
||||
...settings.terminalColorOverrides,
|
||||
[item.key]: value || undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
(settings.terminalMouseHideWhileTyping ?? false)
|
||||
? 'translate-x-4'
|
||||
: 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.TerminalWindowSection.63f8d9336e',
|
||||
'Color Overrides'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalWindowSection.e86e09b5c7',
|
||||
'Override individual terminal colors.'
|
||||
)}
|
||||
keywords={['color', 'override', 'ansi', 'palette', 'theme']}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setColorOverridesExpanded((prev) => !prev)}
|
||||
className="flex items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
<span className={`transition-transform ${colorOverridesExpanded ? 'rotate-90' : ''}`}>
|
||||
▶
|
||||
</span>
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.63f8d9336e',
|
||||
'Color Overrides'
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={`grid overflow-hidden transition-all duration-300 ease-out ${
|
||||
colorOverridesExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="min-h-0 space-y-4">
|
||||
{COLOR_OVERRIDE_GROUPS.map((group) => (
|
||||
<div key={group.label} className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{group.label}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{group.keys.map((item) => (
|
||||
<ColorField
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
description={item.description}
|
||||
value={settings.terminalColorOverrides?.[item.key] ?? ''}
|
||||
fallback=""
|
||||
onChange={(value) =>
|
||||
updateSettings({
|
||||
terminalColorOverrides: {
|
||||
...settings.terminalColorOverrides,
|
||||
[item.key]: value || undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => updateSettings({ terminalColorOverrides: undefined })}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.03c855d15f',
|
||||
'Reset all color overrides'
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => updateSettings({ terminalColorOverrides: undefined })}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.settings.TerminalWindowSection.03c855d15f',
|
||||
'Reset all color overrides'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,6 +245,25 @@ export function getSystemTrayEntries(options: SystemTraySearchOptions = {}): Set
|
||||
return shouldShowSystemTrayEntries(options) ? getSystemTrayEntryCatalog() : []
|
||||
}
|
||||
|
||||
const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
|
||||
{
|
||||
title: translate('auto.components.settings.AppearancePane.interfaceTitle', 'Interface')
|
||||
},
|
||||
{
|
||||
title: translate('auto.components.settings.AppearancePane.terminalTitle', 'Terminal')
|
||||
},
|
||||
{
|
||||
title: translate(
|
||||
'auto.components.settings.AppearancePane.windowSidebarTitle',
|
||||
'Window & Sidebar'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.AppearancePane.windowSidebarSummary',
|
||||
'Sidebar, status bar, and file explorer'
|
||||
)
|
||||
}
|
||||
])
|
||||
|
||||
type AppearancePaneSearchOptions = {
|
||||
showWarpImport?: boolean
|
||||
showSystemTray?: boolean
|
||||
@@ -254,6 +273,7 @@ function buildAppearancePaneSearchEntries(
|
||||
options: AppearancePaneSearchOptions
|
||||
): SettingsSearchEntry[] {
|
||||
return [
|
||||
...getAppearanceSectionEntries(),
|
||||
...getThemeEntries(),
|
||||
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
|
||||
...getTypographyEntries(),
|
||||
|
||||
@@ -43,7 +43,7 @@ export const getWorkspaceCardLayoutEntry = createLocalizedCatalog(
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.appearance.search.workspaceCardLayout.description',
|
||||
'Switch between compact and detailed workspace cards from the workspace sidebar options menu.'
|
||||
'Workspace cards can use compact or detailed layouts.'
|
||||
),
|
||||
keywords: [
|
||||
...translateSearchKeyword(
|
||||
|
||||
@@ -117,6 +117,10 @@ export function getRepositoryGitHooksSearchEntries(repo: Repo): SettingsSearchEn
|
||||
'auto.components.settings.repository.search.f9d84b7971',
|
||||
'setup run policy'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.repository.search.waitForSetupBeforeAgent',
|
||||
'wait for setup before starting agent'
|
||||
),
|
||||
...translateSearchKeyword('auto.components.settings.repository.search.80c490b012', 'ask'),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.repository.search.a69c5cbe90',
|
||||
|
||||
@@ -180,6 +180,8 @@ describe('getTerminalPaneSearchEntries', () => {
|
||||
|
||||
expect(getSidebarEntries()).toContainEqual(entry)
|
||||
expect(getAppearancePaneSearchEntries()).toContainEqual(entry)
|
||||
expect(entry.description).toBe('Workspace cards can use compact or detailed layouts.')
|
||||
expect(entry.description).not.toContain('options menu')
|
||||
})
|
||||
|
||||
it.each(['compact', 'compact display', 'workspace cards', 'sidebar', 'card layout'])(
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
|
||||
|
||||
export {
|
||||
getTerminalAdvancedTypographySearchEntries,
|
||||
getTerminalTypographySearchEntries,
|
||||
getTerminalRenderingSearchEntries,
|
||||
getTerminalCursorSearchEntries
|
||||
|
||||
@@ -2,7 +2,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { translateSearchKeyword } from './settings-search-keywords'
|
||||
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
|
||||
|
||||
export const getTerminalTypographySearchEntries = createLocalizedCatalog(() => [
|
||||
const getTerminalTypographySearchEntryCatalog = createLocalizedCatalog(() => [
|
||||
{
|
||||
title: translate('auto.components.settings.terminal.search.5930244899', 'Font Size'),
|
||||
description: translate(
|
||||
@@ -100,6 +100,14 @@ export const getTerminalTypographySearchEntries = createLocalizedCatalog(() => [
|
||||
}
|
||||
])
|
||||
|
||||
export const getTerminalTypographySearchEntries = createLocalizedCatalog(() => [
|
||||
...getTerminalTypographySearchEntryCatalog()
|
||||
])
|
||||
|
||||
export const getTerminalAdvancedTypographySearchEntries = createLocalizedCatalog(() =>
|
||||
getTerminalTypographySearchEntryCatalog().slice(2)
|
||||
)
|
||||
|
||||
export const getTerminalRenderingSearchEntries = createLocalizedCatalog(() => [
|
||||
{
|
||||
title: translate('auto.components.settings.terminal.search.13a2502dfc', 'GPU Acceleration'),
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type * as UseNotificationDispatchModule from './use-notification-dispatch
|
||||
import { getEagerPtyBufferHandle } from './pty-dispatcher'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
|
||||
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing'
|
||||
|
||||
// Repro command:
|
||||
// pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -t "OpenTUI-style small ANSI redraw"
|
||||
@@ -2879,6 +2880,64 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the sequenced startup command hint for SSH shell-ready detection', async () => {
|
||||
const pendingTimeouts: (() => void)[] = []
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
globalThis.setTimeout = vi.fn((fn: () => void) => {
|
||||
pendingTimeouts.push(fn)
|
||||
return 999 as unknown as ReturnType<typeof setTimeout>
|
||||
}) as unknown as typeof setTimeout
|
||||
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const transport = createMockTransport('pty-id')
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-ssh-1'
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }]
|
||||
}
|
||||
|
||||
const wrapperCommand = 'bash -lc wait-for-setup-wrapper'
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({
|
||||
startup: {
|
||||
command: wrapperCommand,
|
||||
env: {
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
capturedDataCallback.current?.('user@remote $ ')
|
||||
for (const fn of pendingTimeouts.splice(0)) {
|
||||
fn()
|
||||
}
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
|
||||
capturedDataCallback.current?.('\x1b]777;orca-shell-ready\x07user@remote $ ')
|
||||
for (const fn of pendingTimeouts.splice(0)) {
|
||||
fn()
|
||||
}
|
||||
|
||||
expect(transport.sendInput).toHaveBeenCalledWith(`${wrapperCommand}\r`)
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
}
|
||||
})
|
||||
|
||||
it('drops agent status without retaining when OSC 133 reports the command finished', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
|
||||
@@ -6215,6 +6274,249 @@ describe('connectPanePty', () => {
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('abandons a stalled hidden restore and drains pending foreground chunks warning-first', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const capturedDataCallback: {
|
||||
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>
|
||||
const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>()
|
||||
getMainBufferSnapshot.mockReturnValue(snapshot.promise)
|
||||
const hidden = 'hidden-codex-output\r\n'
|
||||
const firstLive = 'first-live-output\r\n'
|
||||
const secondLive = 'second-live-output\r\n'
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false },
|
||||
startup: { command: 'codex' }
|
||||
})
|
||||
const disposable = connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
expect(capturedDataCallback.current).not.toBeNull()
|
||||
vi.useFakeTimers()
|
||||
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
|
||||
;(deps.isVisibleRef as { current: boolean }).current = true
|
||||
capturedDataCallback.current?.(firstLive, {
|
||||
seq: hidden.length + firstLive.length,
|
||||
rawLength: firstLive.length
|
||||
})
|
||||
await flushAsyncTicks(4)
|
||||
capturedDataCallback.current?.(secondLive, {
|
||||
seq: hidden.length + firstLive.length + secondLive.length,
|
||||
rawLength: secondLive.length
|
||||
})
|
||||
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 })
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(firstLive, expect.any(Function))
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(secondLive, expect.any(Function))
|
||||
|
||||
vi.advanceTimersByTime(749)
|
||||
await flushAsyncTicks(4)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('main recovery was unavailable'),
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
vi.advanceTimersByTime(0)
|
||||
await flushAsyncTicks(10)
|
||||
|
||||
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
|
||||
const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable'))
|
||||
const combinedLiveIndex = written.indexOf(firstLive + secondLive)
|
||||
expect(warningIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(combinedLiveIndex).toBeGreaterThan(warningIndex)
|
||||
|
||||
snapshot.resolve({
|
||||
data: 'late-snapshot-state\r\n',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
seq: hidden.length + firstLive.length + secondLive.length
|
||||
})
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
'late-snapshot-state\r\n',
|
||||
expect.any(Function)
|
||||
)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('falls back after repeated null hidden restore retries and drains blocked foreground', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const capturedDataCallback: {
|
||||
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>
|
||||
getMainBufferSnapshot.mockResolvedValue(null)
|
||||
const hidden = 'hidden-codex-output\r\n'
|
||||
const live = 'visible-after-null-retries\r\n'
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false },
|
||||
startup: { command: 'codex' }
|
||||
})
|
||||
const disposable = connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
expect(capturedDataCallback.current).not.toBeNull()
|
||||
vi.useFakeTimers()
|
||||
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
|
||||
;(deps.isVisibleRef as { current: boolean }).current = true
|
||||
capturedDataCallback.current?.(live, {
|
||||
seq: hidden.length + live.length,
|
||||
rawLength: live.length
|
||||
})
|
||||
await flushAsyncTicks(10)
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function))
|
||||
vi.advanceTimersByTime(50)
|
||||
vi.advanceTimersByTime(0)
|
||||
await flushAsyncTicks(10)
|
||||
}
|
||||
|
||||
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
|
||||
const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable'))
|
||||
const liveIndex = written.indexOf(live)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(4)
|
||||
expect(warningIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(liveIndex).toBeGreaterThan(warningIndex)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('drops pending foreground overflow when a stalled hidden restore falls back', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const capturedDataCallback: {
|
||||
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>
|
||||
getMainBufferSnapshot.mockReturnValue(
|
||||
createDeferred<{ data: string; cols: number; rows: number; seq: number }>().promise
|
||||
)
|
||||
const hidden = 'hidden-codex-output\r\n'
|
||||
const liveOverflow = 'v'.repeat(512 * 1024 + 1)
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false },
|
||||
startup: { command: 'codex' }
|
||||
})
|
||||
const disposable = connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
expect(capturedDataCallback.current).not.toBeNull()
|
||||
vi.useFakeTimers()
|
||||
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
|
||||
;(deps.isVisibleRef as { current: boolean }).current = true
|
||||
capturedDataCallback.current?.(liveOverflow, {
|
||||
seq: hidden.length + liveOverflow.length,
|
||||
rawLength: liveOverflow.length
|
||||
})
|
||||
await flushAsyncTicks(4)
|
||||
|
||||
vi.advanceTimersByTime(750)
|
||||
await vi.runAllTimersAsync()
|
||||
await flushAsyncTicks(20)
|
||||
vi.advanceTimersByTime(0)
|
||||
await flushAsyncTicks(10)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('main recovery was unavailable'),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(liveOverflow, expect.any(Function))
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('coalesces tiny pending foreground chunks when stalled hidden restore falls back', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const capturedDataCallback: {
|
||||
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>
|
||||
getMainBufferSnapshot.mockReturnValue(
|
||||
createDeferred<{ data: string; cols: number; rows: number; seq: number }>().promise
|
||||
)
|
||||
const hidden = 'hidden-codex-output\r\n'
|
||||
const chunkCount = 2_000
|
||||
const liveChunk = 'x'
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false },
|
||||
startup: { command: 'codex' }
|
||||
})
|
||||
const disposable = connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
expect(capturedDataCallback.current).not.toBeNull()
|
||||
vi.useFakeTimers()
|
||||
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
|
||||
;(deps.isVisibleRef as { current: boolean }).current = true
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
capturedDataCallback.current?.(liveChunk, {
|
||||
seq: hidden.length + index + 1,
|
||||
rawLength: liveChunk.length
|
||||
})
|
||||
}
|
||||
await flushAsyncTicks(4)
|
||||
|
||||
vi.advanceTimersByTime(750)
|
||||
vi.advanceTimersByTime(0)
|
||||
await flushAsyncTicks(10)
|
||||
|
||||
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
|
||||
const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable'))
|
||||
const combinedLive = liveChunk.repeat(chunkCount)
|
||||
const liveWrites = written.filter((data) => data === combinedLive)
|
||||
expect(warningIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(liveWrites).toHaveLength(1)
|
||||
expect(written.indexOf(combinedLive)).toBeGreaterThan(warningIndex)
|
||||
|
||||
disposable.dispose()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps foreground output when hidden-backlog snapshot recovery is unavailable', async () => {
|
||||
const pendingTimeouts: {
|
||||
canceled: boolean
|
||||
@@ -6324,6 +6626,106 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a newer same-PTY hidden restore after a timed-out snapshot resolves late', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const capturedDataCallback: {
|
||||
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>
|
||||
const firstSnapshot = createDeferred<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
}>()
|
||||
const secondSnapshot = createDeferred<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
}>()
|
||||
getMainBufferSnapshot
|
||||
.mockReturnValueOnce(firstSnapshot.promise)
|
||||
.mockReturnValueOnce(secondSnapshot.promise)
|
||||
const firstHidden = 'first-hidden-output\r\n'
|
||||
const firstLive = 'first-live-output\r\n'
|
||||
const secondHidden = 'second-hidden-output\r\n'
|
||||
const secondLive = 'second-live-output\r\n'
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false },
|
||||
startup: { command: 'codex' }
|
||||
})
|
||||
const disposable = connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
expect(capturedDataCallback.current).not.toBeNull()
|
||||
vi.useFakeTimers()
|
||||
capturedDataCallback.current?.(firstHidden, {
|
||||
seq: firstHidden.length,
|
||||
rawLength: firstHidden.length
|
||||
})
|
||||
;(deps.isVisibleRef as { current: boolean }).current = true
|
||||
capturedDataCallback.current?.(firstLive, {
|
||||
seq: firstHidden.length + firstLive.length,
|
||||
rawLength: firstLive.length
|
||||
})
|
||||
await flushAsyncTicks(4)
|
||||
vi.advanceTimersByTime(750)
|
||||
vi.advanceTimersByTime(0)
|
||||
await flushAsyncTicks(10)
|
||||
|
||||
pane.terminal.write.mockClear()
|
||||
;(deps.isVisibleRef as { current: boolean }).current = false
|
||||
capturedDataCallback.current?.(secondHidden, {
|
||||
seq: firstHidden.length + firstLive.length + secondHidden.length,
|
||||
rawLength: secondHidden.length
|
||||
})
|
||||
;(deps.isVisibleRef as { current: boolean }).current = true
|
||||
capturedDataCallback.current?.(secondLive, {
|
||||
seq: firstHidden.length + firstLive.length + secondHidden.length + secondLive.length,
|
||||
rawLength: secondLive.length
|
||||
})
|
||||
await flushAsyncTicks(4)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
|
||||
|
||||
firstSnapshot.resolve({
|
||||
data: 'stale-first-snapshot\r\n',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
seq: firstHidden.length + firstLive.length
|
||||
})
|
||||
await flushAsyncTicks(10)
|
||||
secondSnapshot.resolve({
|
||||
data: 'fresh-second-snapshot\r\n',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
seq: firstHidden.length + firstLive.length + secondHidden.length + secondLive.length
|
||||
})
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
'stale-first-snapshot\r\n',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
'fresh-second-snapshot\r\n',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(secondLive, expect.any(Function))
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('ignores an async hidden-backlog snapshot if the pane changes PTYs first', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('old-pty-id')
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from './layout-serialization'
|
||||
import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan'
|
||||
import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-startup-delivery'
|
||||
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
|
||||
import { getSystemPrefersDark } from '@/lib/terminal-theme'
|
||||
import {
|
||||
mode2031SequenceFor,
|
||||
@@ -141,6 +142,7 @@ const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000
|
||||
const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024
|
||||
const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50
|
||||
const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3
|
||||
const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750
|
||||
const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256
|
||||
const CURSOR_SHOW_SEQUENCE = '\x1b[?25h'
|
||||
const CURSOR_HIDE_SEQUENCE = '\x1b[?25l'
|
||||
@@ -811,6 +813,7 @@ export function connectPanePty(
|
||||
let unregisterBacklogRecovery: (() => void) | null = null
|
||||
let unregisterDocumentVisibilityRecovery: (() => void) | null = null
|
||||
let cleanupHiddenOutputRestoreDeferredRetry = (): void => {}
|
||||
let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {}
|
||||
let unregisterE2ePtyDataInjection = (): void => {}
|
||||
let startupInjectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let sshShellReadyFallbackTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -2107,6 +2110,37 @@ export function connectPanePty(
|
||||
if (geometryReportObserver && pane.container instanceof Element) {
|
||||
geometryReportObserver.observe(pane.container)
|
||||
}
|
||||
|
||||
// Why: the deferred-rAF fit can spawn the PTY at a stale width when the pane's
|
||||
// real (e.g. split/narrower) layout has not settled by the first frame — the
|
||||
// PTY is born at the wide window width while xterm later reflows to the pane
|
||||
// width. The corrective onResize is then dropped (cols already matched at fit
|
||||
// time, or isRendererPtyResizeAuthoritative() was false mid-mount), pinning
|
||||
// process.stdout.columns forever and garbling TUIs. Re-fit once layout has
|
||||
// settled and force the PTY to xterm's dimensions; the initial spawn-time sync
|
||||
// is authoritative by definition, so it bypasses the visibility gate (but not
|
||||
// the mobile-fit override, which legitimately parks the PTY at phone dims).
|
||||
const reconcilePtySizeAfterSpawn = (
|
||||
ptyId: string,
|
||||
spawnCols: number,
|
||||
spawnRows: number
|
||||
): void => {
|
||||
requestAnimationFrame(() => {
|
||||
if (disposed || transport.getPtyId() !== ptyId) {
|
||||
return
|
||||
}
|
||||
if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) {
|
||||
return
|
||||
}
|
||||
safeFit(pane)
|
||||
const cols = pane.terminal.cols
|
||||
const rows = pane.terminal.rows
|
||||
if (cols > 0 && rows > 0 && (cols !== spawnCols || rows !== spawnRows)) {
|
||||
transport.resize(cols, rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Defer PTY spawn/attach to next frame so FitAddon has time to calculate
|
||||
// the correct terminal dimensions from the laid-out container.
|
||||
connectFrame = requestAnimationFrame(() => {
|
||||
@@ -2196,10 +2230,14 @@ export function connectPanePty(
|
||||
? { command: paneStartup.command }
|
||||
: null
|
||||
: null
|
||||
const startupShellReadyCommandHint = resolveSetupAgentSequenceLaunchCommand(
|
||||
paneStartup?.env ?? {},
|
||||
paneStartup?.command
|
||||
)
|
||||
const shouldWaitForSshShellReady =
|
||||
Boolean(connectionId) &&
|
||||
shouldUseShellReadyStartupDelivery({
|
||||
command: paneStartup?.command,
|
||||
command: startupShellReadyCommandHint,
|
||||
startupCommandDelivery: paneStartup?.startupCommandDelivery
|
||||
}) &&
|
||||
!shouldDeliverStartupViaTerminalPaste
|
||||
@@ -2482,6 +2520,9 @@ export function connectPanePty(
|
||||
// viable delivery target and must not wait for a future pane.
|
||||
clearRegisteredStartupLaunchConfig()
|
||||
}
|
||||
if (resolvedPtyId) {
|
||||
reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows)
|
||||
}
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number' && resolvedPtyId) {
|
||||
if (!isRemoteRuntimePtyId(resolvedPtyId)) {
|
||||
@@ -2619,6 +2660,7 @@ export function connectPanePty(
|
||||
let hiddenOutputRestoreRetryDeferred = false
|
||||
let hiddenOutputRestoreScheduled = false
|
||||
let hiddenOutputRestoreDeferredRetryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let hiddenOutputRestoreForegroundDeadlineTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let hiddenOutputRestoreDeferredRetryAttempts = 0
|
||||
// Why: hidden recovery state belongs to one PTY stream. Reattach/restart
|
||||
// can reuse the pane object for a different session before visibility.
|
||||
@@ -3031,6 +3073,7 @@ export function connectPanePty(
|
||||
hiddenOutputRestorePendingChunks = []
|
||||
hiddenOutputRestorePendingChars = 0
|
||||
hiddenOutputRestorePendingOverflow = true
|
||||
armHiddenOutputRestoreForegroundDeadline()
|
||||
return
|
||||
}
|
||||
const pending: PendingHiddenOutputRestoreChunk = { data }
|
||||
@@ -3042,6 +3085,7 @@ export function connectPanePty(
|
||||
}
|
||||
hiddenOutputRestorePendingChunks.push(pending)
|
||||
hiddenOutputRestorePendingChars += data.length
|
||||
armHiddenOutputRestoreForegroundDeadline()
|
||||
}
|
||||
|
||||
function getChunkDataAfterSnapshot(
|
||||
@@ -3110,34 +3154,10 @@ export function connectPanePty(
|
||||
hiddenOutputRestoreScheduled = false
|
||||
cancelScheduledHiddenOutputRestore(pane.terminal)
|
||||
clearHiddenOutputRestoreDeferredRetryTimer()
|
||||
clearHiddenOutputRestoreForegroundDeadlineTimer()
|
||||
hiddenOutputRestoreDeferredRetryAttempts = 0
|
||||
}
|
||||
|
||||
function drainPendingLiveChunksWithoutSnapshot(): void {
|
||||
if (hiddenOutputRestorePendingOverflow) {
|
||||
hiddenOutputRestorePendingChunks = []
|
||||
hiddenOutputRestorePendingChars = 0
|
||||
hiddenOutputRestorePendingOverflow = false
|
||||
return
|
||||
}
|
||||
// Why: once snapshot retries are exhausted, these bounded chunks are the
|
||||
// only known visible-era PTY bytes; replay them without overlap trimming.
|
||||
while (hiddenOutputRestorePendingChunks.length > 0) {
|
||||
const chunks = hiddenOutputRestorePendingChunks
|
||||
hiddenOutputRestorePendingChunks = []
|
||||
hiddenOutputRestorePendingChars = 0
|
||||
for (const chunk of chunks) {
|
||||
writePtyOutputToXterm(chunk.data, true)
|
||||
}
|
||||
if (hiddenOutputRestorePendingOverflow) {
|
||||
hiddenOutputRestorePendingChunks = []
|
||||
hiddenOutputRestorePendingChars = 0
|
||||
hiddenOutputRestorePendingOverflow = false
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearHiddenOutputRestoreDeferredRetryTimer(): void {
|
||||
if (hiddenOutputRestoreDeferredRetryTimer === null) {
|
||||
return
|
||||
@@ -3147,6 +3167,81 @@ export function connectPanePty(
|
||||
}
|
||||
cleanupHiddenOutputRestoreDeferredRetry = clearHiddenOutputRestoreDeferredRetryTimer
|
||||
|
||||
function clearHiddenOutputRestoreForegroundDeadlineTimer(): void {
|
||||
if (hiddenOutputRestoreForegroundDeadlineTimer === null) {
|
||||
return
|
||||
}
|
||||
clearTimeout(hiddenOutputRestoreForegroundDeadlineTimer)
|
||||
hiddenOutputRestoreForegroundDeadlineTimer = null
|
||||
}
|
||||
cleanupHiddenOutputRestoreForegroundDeadline = clearHiddenOutputRestoreForegroundDeadlineTimer
|
||||
|
||||
function armHiddenOutputRestoreForegroundDeadline(): void {
|
||||
if (
|
||||
disposed ||
|
||||
hiddenOutputRestoreForegroundDeadlineTimer !== null ||
|
||||
!shouldWritePtyOutputForeground(deps.isVisibleRef.current) ||
|
||||
(hiddenOutputRestorePendingChunks.length === 0 && !hiddenOutputRestorePendingOverflow)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const ptyId = hiddenOutputRestorePtyId
|
||||
if (ptyId === null || transport.getPtyId() !== ptyId) {
|
||||
return
|
||||
}
|
||||
const deadlineGeneration = hiddenOutputRestoreGeneration
|
||||
// Why: only foreground-visible output blocked behind recovery gets a
|
||||
// deadline; hidden-time restore work can continue without user impact.
|
||||
hiddenOutputRestoreForegroundDeadlineTimer = setTimeout(() => {
|
||||
hiddenOutputRestoreForegroundDeadlineTimer = null
|
||||
if (
|
||||
disposed ||
|
||||
hiddenOutputRestoreGeneration !== deadlineGeneration ||
|
||||
hiddenOutputRestorePtyId !== ptyId ||
|
||||
!shouldWritePtyOutputForeground(deps.isVisibleRef.current)
|
||||
) {
|
||||
return
|
||||
}
|
||||
abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId)
|
||||
}, HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
function abandonHiddenOutputRestoreAndDrainPendingForeground(expectedPtyId: string): void {
|
||||
if (transport.getPtyId() !== expectedPtyId || hiddenOutputRestorePtyId !== expectedPtyId) {
|
||||
resetHiddenOutputRestoreIfPtyChanged()
|
||||
return
|
||||
}
|
||||
const pendingChunks = hiddenOutputRestorePendingOverflow
|
||||
? []
|
||||
: hiddenOutputRestorePendingChunks.slice()
|
||||
const hadPendingOverflow = hiddenOutputRestorePendingOverflow
|
||||
hiddenOutputRestoreGeneration += 1
|
||||
hiddenOutputRestoreInFlight = null
|
||||
hiddenOutputRestoreNeeded = false
|
||||
hiddenOutputRestorePtyId = null
|
||||
hiddenOutputRestorePendingChunks = []
|
||||
hiddenOutputRestorePendingChars = 0
|
||||
hiddenOutputRestorePendingOverflow = false
|
||||
hiddenOutputRestoreFreshSnapshotNeeded = false
|
||||
hiddenOutputRestoreRetryDeferred = false
|
||||
hiddenOutputRestoreScheduled = false
|
||||
hiddenStartupRendererQueryPending = ''
|
||||
hiddenRendererStateDirty = false
|
||||
cancelScheduledHiddenOutputRestore(pane.terminal)
|
||||
clearHiddenOutputRestoreDeferredRetryTimer()
|
||||
clearHiddenOutputRestoreForegroundDeadlineTimer()
|
||||
hiddenOutputRestoreDeferredRetryAttempts = 0
|
||||
|
||||
writeRestoreUnavailableWarning()
|
||||
if (hadPendingOverflow) {
|
||||
return
|
||||
}
|
||||
const pendingData = pendingChunks.map((chunk) => chunk.data).join('')
|
||||
if (pendingData) {
|
||||
writePtyOutputToXterm(pendingData, true)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleHiddenOutputRestoreDeferredRetry(): void {
|
||||
if (
|
||||
disposed ||
|
||||
@@ -3156,9 +3251,13 @@ export function connectPanePty(
|
||||
return
|
||||
}
|
||||
if (hiddenOutputRestoreDeferredRetryAttempts >= HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX) {
|
||||
writeRestoreUnavailableWarning()
|
||||
drainPendingLiveChunksWithoutSnapshot()
|
||||
clearHiddenOutputRestoreState()
|
||||
const ptyId = hiddenOutputRestorePtyId
|
||||
if (ptyId !== null) {
|
||||
abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId)
|
||||
} else {
|
||||
clearHiddenOutputRestoreState()
|
||||
writeRestoreUnavailableWarning()
|
||||
}
|
||||
return
|
||||
}
|
||||
hiddenOutputRestoreDeferredRetryAttempts += 1
|
||||
@@ -3284,6 +3383,7 @@ export function connectPanePty(
|
||||
}
|
||||
hiddenOutputRestorePtyId = ptyId
|
||||
if (hiddenOutputRestoreInFlight) {
|
||||
armHiddenOutputRestoreForegroundDeadline()
|
||||
return true
|
||||
}
|
||||
if (!opts?.bypassScheduler) {
|
||||
@@ -3357,14 +3457,15 @@ export function connectPanePty(
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
hiddenOutputRestoreGeneration !== restoreGeneration ||
|
||||
transport.getPtyId() !== currentPtyId ||
|
||||
hiddenOutputRestorePtyId !== currentPtyId
|
||||
) {
|
||||
const restoreGenerationChanged = hiddenOutputRestoreGeneration !== restoreGeneration
|
||||
const restorePtyChanged =
|
||||
transport.getPtyId() !== currentPtyId || hiddenOutputRestorePtyId !== currentPtyId
|
||||
if (restoreGenerationChanged || restorePtyChanged) {
|
||||
// Why: the snapshot belongs to the requested PTY; after reattach,
|
||||
// replaying it would show stale/cleared output in the new terminal.
|
||||
if (hiddenOutputRestorePtyId === currentPtyId) {
|
||||
// A stale generation may be an abandoned timeout while a newer
|
||||
// restore for the same PTY owns the current hidden-recovery state.
|
||||
if (restorePtyChanged && hiddenOutputRestorePtyId === currentPtyId) {
|
||||
clearHiddenOutputRestoreState()
|
||||
}
|
||||
return
|
||||
@@ -3383,6 +3484,7 @@ export function connectPanePty(
|
||||
if (drainPendingLiveChunksAfterSnapshot(snapshot.seq) && !needsFreshSnapshot) {
|
||||
hiddenOutputRestoreNeeded = false
|
||||
hiddenOutputRestorePtyId = null
|
||||
clearHiddenOutputRestoreForegroundDeadlineTimer()
|
||||
return
|
||||
}
|
||||
if (!shouldWritePtyOutputForeground(deps.isVisibleRef.current)) {
|
||||
@@ -3394,10 +3496,16 @@ export function connectPanePty(
|
||||
}
|
||||
hiddenOutputRestoreNeeded = true
|
||||
}
|
||||
})().finally(() => {
|
||||
hiddenOutputRestoreInFlight = null
|
||||
})()
|
||||
const hiddenOutputRestoreTask = hiddenOutputRestoreInFlight
|
||||
let trackedHiddenOutputRestore: Promise<void>
|
||||
trackedHiddenOutputRestore = hiddenOutputRestoreTask.finally(() => {
|
||||
if (hiddenOutputRestoreInFlight === trackedHiddenOutputRestore) {
|
||||
hiddenOutputRestoreInFlight = null
|
||||
}
|
||||
if (hiddenOutputRestorePendingChunks.length > 0 || hiddenOutputRestorePendingOverflow) {
|
||||
hiddenOutputRestoreNeeded = true
|
||||
armHiddenOutputRestoreForegroundDeadline()
|
||||
}
|
||||
if (
|
||||
!hiddenOutputRestoreRetryDeferred &&
|
||||
@@ -3407,6 +3515,7 @@ export function connectPanePty(
|
||||
requestHiddenOutputRestoreIfNeeded()
|
||||
}
|
||||
})
|
||||
hiddenOutputRestoreInFlight = trackedHiddenOutputRestore
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -4301,6 +4410,7 @@ export function connectPanePty(
|
||||
clearTerminalBellNotificationTimer()
|
||||
clearReattachIdleAgentCursorResetTimer()
|
||||
cleanupHiddenOutputRestoreDeferredRetry()
|
||||
cleanupHiddenOutputRestoreForegroundDeadline()
|
||||
unregisterBacklogRecovery?.()
|
||||
unregisterBacklogRecovery = null
|
||||
unregisterDocumentVisibilityRecovery?.()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user