Add default tab templates from orca.yaml
This commit is contained in:
parent
5d769d19b9
commit
372469b752
|
|
@ -2,7 +2,7 @@
|
|||
import type { Repo } from '../shared/types'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { parseOrcaYaml } from './hooks'
|
||||
import { getDefaultTabsLaunch, parseOrcaYaml } from './hooks'
|
||||
|
||||
// Mock fs and path used by loadHooks
|
||||
vi.mock('fs', () => ({
|
||||
|
|
@ -142,6 +142,44 @@ describe('parseOrcaYaml', () => {
|
|||
issueCommand: 'claude -p "Read issue #{{issue}}"'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses default terminal tabs from orca.yaml', () => {
|
||||
const yaml = [
|
||||
'defaultTabs:',
|
||||
' - title: Claude',
|
||||
' color: "#f97316"',
|
||||
' command: claude',
|
||||
' - title: LocalHost',
|
||||
' color: "#9ca3af"',
|
||||
' command: pnpm dev',
|
||||
' - title: Notes'
|
||||
].join('\n')
|
||||
|
||||
expect(parseOrcaYaml(yaml)).toEqual({
|
||||
scripts: {},
|
||||
defaultTabs: [
|
||||
{ title: 'Claude', color: '#f97316', command: 'claude' },
|
||||
{ title: 'LocalHost', color: '#9ca3af', command: 'pnpm dev' },
|
||||
{ title: 'Notes' }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('drops invalid default tab entries and unsafe color values', () => {
|
||||
const yaml = [
|
||||
'defaultTabs:',
|
||||
' - title: Server',
|
||||
' color: "red"',
|
||||
' command: pnpm dev',
|
||||
' - 42',
|
||||
' - title: ""'
|
||||
].join('\n')
|
||||
|
||||
expect(parseOrcaYaml(yaml)).toEqual({
|
||||
scripts: {},
|
||||
defaultTabs: [{ title: 'Server', command: 'pnpm dev' }]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasUnrecognizedOrcaYamlKeys', () => {
|
||||
|
|
@ -174,7 +212,15 @@ describe('hasUnrecognizedOrcaYamlKeys', () => {
|
|||
it('returns false when the file contains only recognised keys', async () => {
|
||||
const fs = await import('fs')
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'scripts:\n setup: |\n pnpm install\nissueCommand: |\n claude -p "test"\n'
|
||||
[
|
||||
'scripts:',
|
||||
' setup: |',
|
||||
' pnpm install',
|
||||
'issueCommand: |',
|
||||
' claude -p "test"',
|
||||
'defaultTabs:',
|
||||
' - title: Claude'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const { hasUnrecognizedOrcaYamlKeys } = await import('./hooks')
|
||||
|
|
@ -834,3 +880,47 @@ describe('shouldRunSetupForCreate', () => {
|
|||
expect(shouldRunSetupForCreate(makeRepo('run-by-default'), 'skip')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultTabsLaunch', () => {
|
||||
const makeRepo = (setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default') =>
|
||||
({
|
||||
id: 'test-id',
|
||||
path: '/test/repo',
|
||||
displayName: 'Test Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: Date.now(),
|
||||
hookSettings: {
|
||||
mode: 'auto',
|
||||
setupRunPolicy,
|
||||
scripts: { setup: '', archive: '' }
|
||||
}
|
||||
}) as unknown as Repo
|
||||
|
||||
it('opts into default tab command execution through the setup decision', () => {
|
||||
const hooks = {
|
||||
scripts: {},
|
||||
defaultTabs: [{ title: 'Server', command: 'pnpm dev' }]
|
||||
}
|
||||
|
||||
expect(getDefaultTabsLaunch(hooks, makeRepo('skip-by-default'), 'run')).toEqual({
|
||||
tabs: hooks.defaultTabs,
|
||||
runCommands: true
|
||||
})
|
||||
expect(getDefaultTabsLaunch(hooks, makeRepo('run-by-default'), 'skip')).toEqual({
|
||||
tabs: hooks.defaultTabs,
|
||||
runCommands: false
|
||||
})
|
||||
})
|
||||
|
||||
it('creates commandless default tabs without requiring setup approval', () => {
|
||||
const hooks = {
|
||||
scripts: {},
|
||||
defaultTabs: [{ title: 'Notes' }]
|
||||
}
|
||||
|
||||
expect(getDefaultTabsLaunch(hooks, makeRepo('ask'))).toEqual({
|
||||
tabs: hooks.defaultTabs,
|
||||
runCommands: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { exec, execFile } from 'child_process'
|
||||
import { parse } from 'yaml'
|
||||
import { getDefaultRepoHookSettings } from '../shared/constants'
|
||||
import { getRuntimePathBasename } from '../shared/cross-platform-path'
|
||||
import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy'
|
||||
|
|
@ -9,10 +10,12 @@ import { gitExecFileSync } from './git/runner'
|
|||
import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl'
|
||||
import type {
|
||||
HookCommandSourcePolicy,
|
||||
OrcaDefaultTabTemplate,
|
||||
OrcaHooks,
|
||||
Repo,
|
||||
SetupDecision,
|
||||
SetupRunPolicy,
|
||||
WorktreeDefaultTabsLaunch,
|
||||
WorktreeSetupLaunch
|
||||
} from '../shared/types'
|
||||
|
||||
|
|
@ -26,88 +29,79 @@ function getHookShell(): string | undefined {
|
|||
return '/bin/bash'
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
const DEFAULT_TAB_COLOR_RE = /^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?$/
|
||||
|
||||
function normalizeDefaultTabs(value: unknown): OrcaDefaultTabTemplate[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.map((entry) => {
|
||||
const record = asRecord(entry)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const title = asTrimmedString(record.title)
|
||||
const command = asTrimmedString(record.command)
|
||||
const color = asTrimmedString(record.color)
|
||||
const normalizedColor = color && DEFAULT_TAB_COLOR_RE.test(color) ? color : undefined
|
||||
if (!title && !command && !normalizedColor) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...(title ? { title } : {}),
|
||||
...(normalizedColor ? { color: normalizedColor } : {}),
|
||||
...(command ? { command } : {})
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is OrcaDefaultTabTemplate => entry !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a simple orca.yaml file. Handles only the supported `scripts:` and
|
||||
* `issueCommand:` keys with multiline string values (YAML block scalar `|`).
|
||||
* Parse the supported project defaults from `orca.yaml`.
|
||||
*/
|
||||
export function parseOrcaYaml(content: string): OrcaHooks | null {
|
||||
const hooks: OrcaHooks = { scripts: {} }
|
||||
const lines = content.split(/\r?\n/)
|
||||
|
||||
let currentSection: 'scripts' | 'issueCommand' | null = null
|
||||
let currentKey: 'setup' | 'archive' | null = null
|
||||
let issueCommandValue = ''
|
||||
|
||||
for (const line of lines) {
|
||||
const topLevelKeyMatch = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(\|)?\s*(.*)$/)
|
||||
if (topLevelKeyMatch) {
|
||||
if (currentSection === 'scripts' && currentKey) {
|
||||
hooks.scripts[currentKey] = issueCommandValue.trimEnd()
|
||||
} else if (currentSection === 'issueCommand') {
|
||||
hooks.issueCommand = issueCommandValue.trimEnd() || undefined
|
||||
}
|
||||
|
||||
const [, key, blockScalar, rest] = topLevelKeyMatch
|
||||
currentKey = null
|
||||
issueCommandValue = ''
|
||||
|
||||
if (key === 'scripts') {
|
||||
currentSection = 'scripts'
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === 'issueCommand') {
|
||||
currentSection = 'issueCommand'
|
||||
if (blockScalar) {
|
||||
continue
|
||||
}
|
||||
hooks.issueCommand = rest.trim() || undefined
|
||||
currentSection = null
|
||||
continue
|
||||
}
|
||||
|
||||
currentSection = null
|
||||
continue
|
||||
}
|
||||
|
||||
if (currentSection === 'scripts') {
|
||||
// Indented key like " setup: |" or " archive: |" or " setup: echo hello"
|
||||
const keyMatch = line.match(/^ (setup|archive):\s*(\|)?\s*(.*)$/)
|
||||
if (keyMatch) {
|
||||
// Save previous key
|
||||
if (currentKey) {
|
||||
hooks.scripts[currentKey] = issueCommandValue.trimEnd()
|
||||
}
|
||||
currentKey = keyMatch[1] as 'setup' | 'archive'
|
||||
issueCommandValue = keyMatch[3] ? `${keyMatch[3]}\n` : ''
|
||||
continue
|
||||
}
|
||||
|
||||
// Content line (indented by 4+ spaces under a key)
|
||||
if (currentKey && line.startsWith(' ')) {
|
||||
issueCommandValue += `${line.slice(4)}\n`
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (currentSection === 'issueCommand' && line.startsWith(' ')) {
|
||||
// Why: `issueCommand` is a top-level scalar in `orca.yaml`, so its block
|
||||
// content must stay separate from the `scripts:` parser rather than being
|
||||
// shoehorned into that section's indentation rules.
|
||||
issueCommandValue += `${line.slice(2)}\n`
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSection === 'scripts' && currentKey) {
|
||||
hooks.scripts[currentKey] = issueCommandValue.trimEnd()
|
||||
} else if (currentSection === 'issueCommand') {
|
||||
hooks.issueCommand = issueCommandValue.trimEnd() || undefined
|
||||
}
|
||||
|
||||
if (!hooks.scripts.setup && !hooks.scripts.archive && !hooks.issueCommand) {
|
||||
let root: unknown
|
||||
try {
|
||||
root = parse(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return hooks
|
||||
|
||||
const record = asRecord(root)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scriptsRecord = asRecord(record.scripts)
|
||||
const setup = scriptsRecord ? asTrimmedString(scriptsRecord.setup) : undefined
|
||||
const archive = scriptsRecord ? asTrimmedString(scriptsRecord.archive) : undefined
|
||||
const issueCommand = asTrimmedString(record.issueCommand)
|
||||
const defaultTabs = normalizeDefaultTabs(record.defaultTabs)
|
||||
|
||||
if (!setup && !archive && !issueCommand && defaultTabs.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
scripts: {
|
||||
...(setup ? { setup } : {}),
|
||||
...(archive ? { archive } : {})
|
||||
},
|
||||
...(issueCommand ? { issueCommand } : {}),
|
||||
...(defaultTabs.length > 0 ? { defaultTabs } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -139,7 +133,7 @@ export function hasHooksFile(repoPath: string): boolean {
|
|||
// return `null` from `parseOrcaYaml` and show a confusing "could not be parsed"
|
||||
// error. Detecting well-formed but unrecognised keys lets the UI suggest an
|
||||
// update instead of implying the file is broken.
|
||||
const RECOGNIZED_ORCA_YAML_KEYS = new Set(['scripts', 'issueCommand'])
|
||||
const RECOGNIZED_ORCA_YAML_KEYS = new Set(['scripts', 'issueCommand', 'defaultTabs'])
|
||||
|
||||
/**
|
||||
* Return true when `orca.yaml` contains at least one top-level key that this
|
||||
|
|
@ -339,6 +333,34 @@ export function shouldRunSetupForCreate(repo: Repo, decision: SetupDecision = 'i
|
|||
return policy === 'run-by-default'
|
||||
}
|
||||
|
||||
export function getDefaultTabCommandTrustContent(hooks: OrcaHooks | null): string {
|
||||
const commands = (hooks?.defaultTabs ?? [])
|
||||
.map((tab, index) => {
|
||||
const command = tab.command?.trim()
|
||||
if (!command) {
|
||||
return null
|
||||
}
|
||||
const label = tab.title ? ` ${tab.title}` : ''
|
||||
return `# defaultTabs[${index + 1}]${label}\n${command}`
|
||||
})
|
||||
.filter((entry): entry is string => entry !== null)
|
||||
return [hooks?.scripts.setup?.trim(), ...commands].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
export function getDefaultTabsLaunch(
|
||||
hooks: OrcaHooks | null,
|
||||
repo: Repo,
|
||||
decision: SetupDecision = 'inherit'
|
||||
): WorktreeDefaultTabsLaunch | undefined {
|
||||
const tabs = hooks?.defaultTabs ?? []
|
||||
if (tabs.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const hasCommands = tabs.some((tab) => Boolean(tab.command?.trim()))
|
||||
const runCommands = hasCommands ? shouldRunSetupForCreate(repo, decision) : false
|
||||
return { tabs, runCommands }
|
||||
}
|
||||
|
||||
export function getSetupCommandSource(
|
||||
repo: Repo,
|
||||
worktreePath?: string
|
||||
|
|
|
|||
|
|
@ -35,9 +35,11 @@ import {
|
|||
buildPosixRunnerScript,
|
||||
buildWindowsRunnerScript,
|
||||
createSetupRunnerScript,
|
||||
getDefaultTabsLaunch,
|
||||
getEffectiveHooks,
|
||||
getEffectiveHooksFromConfig,
|
||||
getSetupRunnerEnvVars,
|
||||
loadHooks,
|
||||
parseOrcaYaml,
|
||||
shouldRunSetupForCreate
|
||||
} from '../hooks'
|
||||
|
|
@ -721,12 +723,18 @@ async function readRemoteEffectiveHooks(
|
|||
fsProvider: IFilesystemProvider,
|
||||
hooksRootPath: string
|
||||
): Promise<ReturnType<typeof getEffectiveHooksFromConfig>> {
|
||||
return getEffectiveHooksFromConfig(repo, await readRemoteOrcaYaml(fsProvider, hooksRootPath))
|
||||
}
|
||||
|
||||
async function readRemoteOrcaYaml(
|
||||
fsProvider: IFilesystemProvider,
|
||||
hooksRootPath: string
|
||||
): Promise<ReturnType<typeof parseOrcaYaml>> {
|
||||
try {
|
||||
const result = await fsProvider.readFile(joinWorktreeRelativePath(hooksRootPath, 'orca.yaml'))
|
||||
const yamlHooks = result.isBinary ? null : parseOrcaYaml(result.content)
|
||||
return getEffectiveHooksFromConfig(repo, yamlHooks)
|
||||
return result.isBinary ? null : parseOrcaYaml(result.content)
|
||||
} catch {
|
||||
return getEffectiveHooksFromConfig(repo, null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1177,8 +1185,20 @@ export async function createRemoteWorktree(
|
|||
// `symlinkPaths` configured have them silently ignored here.
|
||||
|
||||
let setup: CreateWorktreeResult['setup']
|
||||
let defaultTabs: CreateWorktreeResult['defaultTabs']
|
||||
if (fsProvider) {
|
||||
const hooks = await readRemoteEffectiveHooks(repo, fsProvider, created.path)
|
||||
const yamlHooks = await readRemoteOrcaYaml(fsProvider, created.path)
|
||||
const hooks = getEffectiveHooksFromConfig(repo, yamlHooks)
|
||||
try {
|
||||
defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, args.setupDecision)
|
||||
} catch (error) {
|
||||
// Why: default tab commands share setup's run policy. If the target branch
|
||||
// adds commands without a renderer decision, create the tabs but don't run them.
|
||||
console.warn(`[hooks] default tab commands skipped for ${created.path}:`, error)
|
||||
defaultTabs = yamlHooks?.defaultTabs
|
||||
? { tabs: yamlHooks.defaultTabs, runCommands: false }
|
||||
: undefined
|
||||
}
|
||||
const setupScript = hooks?.scripts.setup
|
||||
let shouldLaunchSetup = false
|
||||
if (setupScript) {
|
||||
|
|
@ -1210,6 +1230,7 @@ export async function createRemoteWorktree(
|
|||
return {
|
||||
worktree,
|
||||
...(setup ? { setup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(localBaseRefRefresh ? { localBaseRefRefresh } : {})
|
||||
}
|
||||
}
|
||||
|
|
@ -1610,7 +1631,20 @@ export async function createLocalWorktree(
|
|||
// disabling setup with no UI signal. See #1280 for the original gate and
|
||||
// the regression this replaced.
|
||||
let setup: CreateWorktreeResult['setup']
|
||||
const setupScript = getEffectiveHooks(repo, worktreePath)?.scripts.setup
|
||||
let defaultTabs: CreateWorktreeResult['defaultTabs']
|
||||
const createdYamlHooks = loadHooks(worktreePath)
|
||||
const createdEffectiveHooks = getEffectiveHooksFromConfig(repo, createdYamlHooks)
|
||||
try {
|
||||
defaultTabs = getDefaultTabsLaunch(createdYamlHooks, repo, args.setupDecision)
|
||||
} catch (error) {
|
||||
// Why: default tab commands share setup's run policy. If the target branch
|
||||
// adds commands without a renderer decision, create the tabs but don't run them.
|
||||
console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error)
|
||||
defaultTabs = createdYamlHooks?.defaultTabs
|
||||
? { tabs: createdYamlHooks.defaultTabs, runCommands: false }
|
||||
: undefined
|
||||
}
|
||||
const setupScript = createdEffectiveHooks?.scripts.setup
|
||||
let shouldLaunchSetup = false
|
||||
if (setupScript) {
|
||||
try {
|
||||
|
|
@ -1644,6 +1678,7 @@ export async function createLocalWorktree(
|
|||
return {
|
||||
worktree,
|
||||
...(setup ? { setup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } : {})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,6 +220,10 @@ vi.mock('../hooks', () => ({
|
|||
createSetupRunnerScript: vi.fn(),
|
||||
getEffectiveHooks: vi.fn().mockReturnValue(null),
|
||||
getEffectiveHooksFromConfig: vi.fn().mockReturnValue(null),
|
||||
getDefaultTabCommandTrustContent: vi.fn(
|
||||
(hooks: { scripts?: { setup?: string } } | null) => hooks?.scripts?.setup?.trim() ?? ''
|
||||
),
|
||||
getDefaultTabsLaunch: vi.fn().mockReturnValue(undefined),
|
||||
getSetupRunnerEnvVars: (_repo: never, worktreePath: string) => ({
|
||||
ORCA_ROOT_PATH: '/remote/repo',
|
||||
ORCA_WORKTREE_PATH: worktreePath
|
||||
|
|
@ -8044,7 +8048,13 @@ describe('OrcaRuntimeService', () => {
|
|||
}
|
||||
}
|
||||
})
|
||||
expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup)
|
||||
expect(activateWorktree).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
expect.any(String),
|
||||
result.setup,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('passes setup payloads through when explicitly activating CLI-created worktrees', async () => {
|
||||
|
|
@ -8096,7 +8106,13 @@ describe('OrcaRuntimeService', () => {
|
|||
activate: true
|
||||
})
|
||||
|
||||
expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup)
|
||||
expect(activateWorktree).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
expect.any(String),
|
||||
result.setup,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('follows normal setup policy for CLI-created worktrees without activating them', async () => {
|
||||
|
|
@ -8731,7 +8747,13 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(detectRemoteAgentsMock).not.toHaveBeenCalled()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(metaById[result.worktree.id]?.createdWithAgent).toBeUndefined()
|
||||
expect(activateWorktree).toHaveBeenCalledWith('repo-1', result.worktree.id, undefined)
|
||||
expect(activateWorktree).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
result.worktree.id,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('detects agents on the SSH host before launching remote startup drafts', async () => {
|
||||
|
|
@ -9679,7 +9701,13 @@ describe('OrcaRuntimeService', () => {
|
|||
activate: true
|
||||
})
|
||||
|
||||
expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined)
|
||||
expect(activateWorktree).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
expect.any(String),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('stamps createdAt alongside lastActivityAt so CLI-created worktrees get the Recent-sort grace window', async () => {
|
||||
|
|
|
|||
|
|
@ -346,6 +346,8 @@ import type { AddWorktreeResult } from '../git/worktree'
|
|||
import { isENOENT } from '../ipc/filesystem-auth'
|
||||
import {
|
||||
createSetupRunnerScript,
|
||||
getDefaultTabCommandTrustContent,
|
||||
getDefaultTabsLaunch,
|
||||
getEffectiveHooks,
|
||||
getEffectiveSetupRunPolicy,
|
||||
hasUnrecognizedOrcaYamlKeys,
|
||||
|
|
@ -682,7 +684,8 @@ type RuntimeNotifier = {
|
|||
repoId: string,
|
||||
worktreeId: string,
|
||||
setup?: CreateWorktreeResult['setup'],
|
||||
startup?: WorktreeStartupLaunch
|
||||
startup?: WorktreeStartupLaunch,
|
||||
defaultTabs?: CreateWorktreeResult['defaultTabs']
|
||||
): void
|
||||
createTerminal(worktreeId: string, opts: { command?: string; title?: string }): void
|
||||
revealTerminalSession?(
|
||||
|
|
@ -6856,9 +6859,9 @@ export class OrcaRuntimeService {
|
|||
|
||||
private getSetupHookTrustPayload(
|
||||
repo: Repo,
|
||||
setupScript: string | undefined
|
||||
scriptContentValue: string | undefined
|
||||
): { contentHash: string; scriptContent: string } | undefined {
|
||||
const scriptContent = setupScript?.trim()
|
||||
const scriptContent = scriptContentValue?.trim()
|
||||
if (!scriptContent || repo.hookSettings?.commandSourcePolicy === 'local-only') {
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -6898,7 +6901,10 @@ export class OrcaRuntimeService {
|
|||
hooks,
|
||||
setupRunPolicy: getEffectiveSetupRunPolicy(repo),
|
||||
source: hooks ? 'orca.yaml' : null,
|
||||
setupTrust: this.getSharedSetupHookTrustPayload(repo, hooks?.scripts?.setup)
|
||||
setupTrust: this.getSharedSetupHookTrustPayload(
|
||||
repo,
|
||||
getDefaultTabCommandTrustContent(hooks)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
|
|
@ -6918,7 +6924,10 @@ export class OrcaRuntimeService {
|
|||
hooks,
|
||||
setupRunPolicy,
|
||||
source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null,
|
||||
setupTrust: this.getSharedSetupHookTrustPayload(repo, sharedHooks?.scripts?.setup)
|
||||
setupTrust: this.getSharedSetupHookTrustPayload(
|
||||
repo,
|
||||
getDefaultTabCommandTrustContent(sharedHooks)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6938,9 +6947,7 @@ export class OrcaRuntimeService {
|
|||
if (result.isBinary) {
|
||||
return { hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
const { parse } = await import('yaml')
|
||||
const parsed = parse(result.content)
|
||||
return { hasHooks: true, hooks: parsed, mayNeedUpdate: false }
|
||||
return { hasHooks: true, hooks: parseOrcaYaml(result.content), mayNeedUpdate: false }
|
||||
} catch {
|
||||
return { hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
|
|
@ -7936,12 +7943,22 @@ export class OrcaRuntimeService {
|
|||
// Why: CLI-created worktrees do not have a renderer preview to mismatch
|
||||
// against. Trust is granted by the direct CLI invocation (`--run-hooks`),
|
||||
// so loading the setup hook from the created worktree is intentional here.
|
||||
const yamlHooks = loadHooks(worktreePath)
|
||||
const hooks = getEffectiveHooks(repo, worktreePath)
|
||||
// Why: setupDecision lets mobile/CLI callers control whether the setup
|
||||
// script runs. 'skip' suppresses it, 'run' forces it, 'inherit' (default)
|
||||
// defers to the repo's orca.yaml setupRunPolicy. runHooks === true maps
|
||||
// to 'run' for backwards compatibility with the desktop create flow.
|
||||
const effectiveDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit')
|
||||
let defaultTabs: CreateWorktreeResult['defaultTabs']
|
||||
try {
|
||||
defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, effectiveDecision)
|
||||
} catch (error) {
|
||||
console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error)
|
||||
defaultTabs = yamlHooks?.defaultTabs
|
||||
? { tabs: yamlHooks.defaultTabs, runCommands: false }
|
||||
: undefined
|
||||
}
|
||||
const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision)
|
||||
if (shouldRunSetup && hooks?.scripts.setup) {
|
||||
if (this.authoritativeWindowId !== null) {
|
||||
|
|
@ -8053,9 +8070,21 @@ export class OrcaRuntimeService {
|
|||
// the user can watch prompts/output in a visible pane.
|
||||
const activationSetup = didSpawnSetup ? undefined : setup
|
||||
if (effectiveStartup && !didSpawnStartup) {
|
||||
this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup, effectiveStartup)
|
||||
this.notifier?.activateWorktree(
|
||||
repo.id,
|
||||
worktree.id,
|
||||
activationSetup,
|
||||
effectiveStartup,
|
||||
defaultTabs
|
||||
)
|
||||
} else {
|
||||
this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup)
|
||||
this.notifier?.activateWorktree(
|
||||
repo.id,
|
||||
worktree.id,
|
||||
activationSetup,
|
||||
undefined,
|
||||
defaultTabs
|
||||
)
|
||||
}
|
||||
} else if (this.ptyController?.spawn) {
|
||||
try {
|
||||
|
|
@ -8090,6 +8119,7 @@ export class OrcaRuntimeService {
|
|||
},
|
||||
...(lineageInput ? { lineage, warnings: lineageWarnings } : {}),
|
||||
...(setup ? { setup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(warning ? { warning } : {}),
|
||||
...(addResult.localBaseRefRefresh
|
||||
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
|
||||
|
|
|
|||
|
|
@ -270,13 +270,15 @@ function registerRuntimeWindowLifecycle(
|
|||
repoId,
|
||||
worktreeId,
|
||||
setup?: CreateWorktreeResult['setup'],
|
||||
startup?: WorktreeStartupLaunch
|
||||
startup?: WorktreeStartupLaunch,
|
||||
defaultTabs?: CreateWorktreeResult['defaultTabs']
|
||||
) => {
|
||||
send('ui:activateWorktree', {
|
||||
repoId,
|
||||
worktreeId,
|
||||
...(setup ? { setup } : {}),
|
||||
...(startup ? { startup } : {})
|
||||
...(startup ? { startup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {})
|
||||
})
|
||||
},
|
||||
createTerminal: (worktreeId, opts) =>
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ import type {
|
|||
WorktreeMeta,
|
||||
WorktreeRemoteBranchConflictEvent,
|
||||
RemoveWorktreeResult,
|
||||
WorktreeDefaultTabsLaunch,
|
||||
WorktreeSetupLaunch,
|
||||
WorktreeStartupLaunch,
|
||||
WorkspaceSessionPatch,
|
||||
|
|
@ -1971,6 +1972,7 @@ export type PreloadApi = {
|
|||
worktreeId: string
|
||||
setup?: WorktreeSetupLaunch
|
||||
startup?: WorktreeStartupLaunch
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
}) => void
|
||||
) => () => void
|
||||
onCreateTerminal: (
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import type {
|
|||
SearchResult,
|
||||
UpdateStatus,
|
||||
WorktreeBaseStatusEvent,
|
||||
WorktreeDefaultTabsLaunch,
|
||||
WorktreeRemoteBranchConflictEvent
|
||||
} from '../shared/types'
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
|
||||
|
|
@ -2552,6 +2553,7 @@ const api = {
|
|||
worktreeId: string
|
||||
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
|
||||
startup?: { command: string; env?: Record<string, string> }
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
|
|
@ -2561,6 +2563,7 @@ const api = {
|
|||
worktreeId: string
|
||||
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
|
||||
startup?: { command: string; env?: Record<string, string> }
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
}
|
||||
) => callback(data)
|
||||
ipcRenderer.on('ui:activateWorktree', listener)
|
||||
|
|
|
|||
|
|
@ -1931,6 +1931,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
activateAndRevealWorktree(worktree.id, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
setup: result.setup,
|
||||
defaultTabs: result.defaultTabs,
|
||||
issueCommand,
|
||||
...(startupPlan
|
||||
? {
|
||||
|
|
@ -2197,6 +2198,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
activateAndRevealWorktree(worktree.id, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
setup: result.setup,
|
||||
defaultTabs: result.defaultTabs,
|
||||
...(startupPlan
|
||||
? {
|
||||
startup: {
|
||||
|
|
|
|||
|
|
@ -866,7 +866,7 @@ export function useIpcEvents(): void {
|
|||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup }) => {
|
||||
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => {
|
||||
void (async () => {
|
||||
if (isRuntimeEnvironmentActive()) {
|
||||
// Why: local CLI-created worktree events carry local repo/worktree
|
||||
|
|
@ -890,6 +890,7 @@ export function useIpcEvents(): void {
|
|||
activateAndRevealWorktree(worktreeId, {
|
||||
...(setup ? { setup } : {}),
|
||||
...(startup ? { startup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {})
|
||||
})
|
||||
})().catch((error) => {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,33 @@ describe('ensureHooksConfirmed', () => {
|
|||
await expect(promise).resolves.toBe('run')
|
||||
})
|
||||
|
||||
it('includes default tab commands in the setup trust prompt', async () => {
|
||||
const { state, pending } = createTestState()
|
||||
hooksCheckMock.mockResolvedValue({
|
||||
hasHooks: true,
|
||||
hooks: {
|
||||
scripts: { setup: 'pnpm install' },
|
||||
defaultTabs: [
|
||||
{ title: 'Server', command: 'pnpm dev' },
|
||||
{ title: 'Notes' },
|
||||
{ command: 'codex' }
|
||||
]
|
||||
},
|
||||
mayNeedUpdate: false
|
||||
})
|
||||
|
||||
const promise = ensureHooksConfirmed(state, 'repo-1', 'setup')
|
||||
|
||||
await vi.waitFor(() => expect(pending).toHaveLength(1))
|
||||
const expectedContent =
|
||||
'pnpm install\n\n# defaultTabs[1] Server\npnpm dev\n\n# defaultTabs[3]\ncodex'
|
||||
expect(pending[0].data.scriptContent).toBe(expectedContent)
|
||||
expect(pending[0].data.contentHash).toBe(await hashOrcaHookScript(expectedContent))
|
||||
|
||||
pending[0].resolve('skip')
|
||||
await expect(promise).resolves.toBe('skip')
|
||||
})
|
||||
|
||||
it('returns run without inspecting hooks when the repo is always trusted', async () => {
|
||||
const { state, pending } = createTestState()
|
||||
state.trustedOrcaHooks['repo-1'] = {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,20 @@ export function __resetTrustPromptChainForTests(): void {
|
|||
trustPromptChain = Promise.resolve()
|
||||
}
|
||||
|
||||
function getSetupTrustContent(yamlHooks: OrcaHooks | null): string {
|
||||
const defaultTabCommands = (yamlHooks?.defaultTabs ?? [])
|
||||
.map((tab, index) => {
|
||||
const command = tab.command?.trim()
|
||||
if (!command) {
|
||||
return null
|
||||
}
|
||||
const label = tab.title ? ` ${tab.title}` : ''
|
||||
return `# defaultTabs[${index + 1}]${label}\n${command}`
|
||||
})
|
||||
.filter((entry): entry is string => entry !== null)
|
||||
return [yamlHooks?.scripts?.setup?.trim(), ...defaultTabCommands].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
export async function ensureHooksConfirmed(
|
||||
state: AppState,
|
||||
repoId: string,
|
||||
|
|
@ -61,7 +75,10 @@ export async function ensureHooksConfirmed(
|
|||
return 'skip'
|
||||
}
|
||||
const yamlHooks = (result.hooks as OrcaHooks | null) ?? null
|
||||
scriptContent = (yamlHooks?.scripts?.[scriptKind] ?? '').trim()
|
||||
scriptContent =
|
||||
scriptKind === 'setup'
|
||||
? getSetupTrustContent(yamlHooks)
|
||||
: (yamlHooks?.scripts?.[scriptKind] ?? '').trim()
|
||||
}
|
||||
} catch {
|
||||
// Fail closed: if we cannot inspect the script, we cannot trust it.
|
||||
|
|
|
|||
|
|
@ -338,6 +338,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
|
|||
const activation = activateAndRevealWorktree(worktreeId, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
setup: result.setup,
|
||||
defaultTabs: result.defaultTabs,
|
||||
...buildStartupOpts(effectiveAgent, startupPlan, launchSource)
|
||||
})
|
||||
if (!activation) {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess
|
|||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
lastKnownRelayPtyIdByTabId: {},
|
||||
lastVisitedAtByWorktreeId: {}
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ function createSnapshot(
|
|||
worktreesByRepo: {},
|
||||
lastKnownRelayPtyIdByTabId: {},
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ describe('SESSION_RELEVANT_FIELDS', () => {
|
|||
repos: true,
|
||||
worktreesByRepo: true,
|
||||
lastKnownRelayPtyIdByTabId: true,
|
||||
lastVisitedAtByWorktreeId: true
|
||||
lastVisitedAtByWorktreeId: true,
|
||||
defaultTerminalTabsAppliedByWorktreeId: true
|
||||
}
|
||||
|
||||
it('contains every key of WorkspaceSessionSnapshot', () => {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,16 @@ describe('buildWorkspaceSessionPayload', () => {
|
|||
expect(payload.activeWorktreeIdsOnShutdown).toEqual(['wt-1'])
|
||||
})
|
||||
|
||||
it('persists the default-tab idempotency marker when present', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
defaultTerminalTabsAppliedByWorktreeId: { 'wt-1': true }
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.defaultTerminalTabsAppliedByWorktreeId).toEqual({ 'wt-1': true })
|
||||
})
|
||||
|
||||
it('persists floating terminal tabs for daemon reattach after restart', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export type WorkspaceSessionSnapshot = Pick<
|
|||
| 'worktreesByRepo'
|
||||
| 'lastKnownRelayPtyIdByTabId'
|
||||
| 'lastVisitedAtByWorktreeId'
|
||||
| 'defaultTerminalTabsAppliedByWorktreeId'
|
||||
>
|
||||
|
||||
// Why: the App-level Zustand subscriber that debounces session writes uses
|
||||
|
|
@ -83,7 +84,8 @@ export const SESSION_RELEVANT_FIELDS = [
|
|||
'repos',
|
||||
'worktreesByRepo',
|
||||
'lastKnownRelayPtyIdByTabId',
|
||||
'lastVisitedAtByWorktreeId'
|
||||
'lastVisitedAtByWorktreeId',
|
||||
'defaultTerminalTabsAppliedByWorktreeId'
|
||||
] as const satisfies readonly (keyof WorkspaceSessionSnapshot)[]
|
||||
|
||||
type _MissingSessionField = Exclude<
|
||||
|
|
@ -349,7 +351,12 @@ export function buildWorkspaceSessionPayload(
|
|||
// Omit when empty so sessions written by builds that never stamped
|
||||
// anything don't bloat the payload. See
|
||||
// docs/cmd-j-empty-query-ordering.md.
|
||||
lastVisitedAtByWorktreeId: buildLastVisitedAtByWorktreeId(snapshot)
|
||||
lastVisitedAtByWorktreeId: buildLastVisitedAtByWorktreeId(snapshot),
|
||||
defaultTerminalTabsAppliedByWorktreeId:
|
||||
snapshot.defaultTerminalTabsAppliedByWorktreeId &&
|
||||
Object.keys(snapshot.defaultTerminalTabsAppliedByWorktreeId).length > 0
|
||||
? snapshot.defaultTerminalTabsAppliedByWorktreeId
|
||||
: undefined
|
||||
}
|
||||
|
||||
return pruneLocalTerminalScrollbackBuffers(payload, snapshot.repos)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines -- Why: these activation cases share one mock store and assert ordering across startup, setup, issue commands, and default tabs. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SetupScriptLaunchMode } from '../../../shared/types'
|
||||
import { ensureWorktreeHasInitialTerminal } from './worktree-activation'
|
||||
|
|
@ -26,9 +27,12 @@ afterEach(() => {
|
|||
function createMockStore(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
tabsByWorktree: {} as Record<string, { id: string }[]>,
|
||||
defaultTerminalTabsAppliedByWorktreeId: {} as Record<string, true>,
|
||||
createTab: vi.fn(() => ({ id: 'tab-1' })),
|
||||
setActiveTab: vi.fn(),
|
||||
setTabCustomTitle: vi.fn(),
|
||||
setTabColor: vi.fn(),
|
||||
markDefaultTerminalTabsApplied: vi.fn(),
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })),
|
||||
queueTabStartupCommand: vi.fn(),
|
||||
queueTabSetupSplit: vi.fn(),
|
||||
|
|
@ -80,6 +84,82 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
expect(store.queueTabSetupSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates configured default tabs once with title, color, and opted-in commands', () => {
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
const result = ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
runCommands: true,
|
||||
tabs: [
|
||||
{ title: 'Claude', color: '#f97316', command: 'claude' },
|
||||
{ title: 'LocalHost', color: '#9ca3af', command: 'pnpm dev' }
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toBe('tab-1')
|
||||
expect(store.markDefaultTerminalTabsApplied).toHaveBeenCalledWith('wt-1')
|
||||
expect(createTab).toHaveBeenCalledTimes(2)
|
||||
expect(createTab).toHaveBeenNthCalledWith(1, 'wt-1', undefined, undefined, {
|
||||
pendingActivationSpawn: true,
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Claude', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'LocalHost', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(store.setTabColor).toHaveBeenCalledWith('tab-1', '#f97316')
|
||||
expect(store.setTabColor).toHaveBeenCalledWith('tab-2', '#9ca3af')
|
||||
expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-1', { command: 'claude' })
|
||||
expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', { command: 'pnpm dev' })
|
||||
expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1')
|
||||
})
|
||||
|
||||
it('does not run default tab commands when command execution is not approved', () => {
|
||||
const store = createMockStore()
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, undefined, {
|
||||
runCommands: false,
|
||||
tabs: [{ title: 'Server', command: 'pnpm dev' }]
|
||||
})
|
||||
|
||||
expect(store.queueTabStartupCommand).not.toHaveBeenCalled()
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Server', {
|
||||
recordInteraction: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not duplicate default tabs after the worktree marker is persisted', () => {
|
||||
const store = createMockStore({
|
||||
defaultTerminalTabsAppliedByWorktreeId: { 'wt-1': true }
|
||||
})
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, undefined, {
|
||||
runCommands: true,
|
||||
tabs: [
|
||||
{ title: 'Claude', command: 'claude' },
|
||||
{ title: 'Server', command: 'pnpm dev' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(store.createTab).toHaveBeenCalledTimes(1)
|
||||
expect(store.setTabCustomTitle).not.toHaveBeenCalledWith('tab-1', 'Claude', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(store.queueTabStartupCommand).not.toHaveBeenCalledWith('tab-1', {
|
||||
command: 'claude'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not create a local fallback tab in the paired web runtime client', () => {
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
useAppStore.setState((state) => ({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
/* eslint-disable max-lines -- Why: worktree activation is a single ordered flow spanning startup, setup, issue commands, and default tabs; splitting it would obscure sequencing guarantees. */
|
||||
import type {
|
||||
SetupSplitDirection,
|
||||
TuiAgent,
|
||||
Worktree,
|
||||
WorktreeDefaultTabsLaunch,
|
||||
WorktreeSetupLaunch
|
||||
} from '../../../shared/types'
|
||||
import type { EventProps } from '../../../shared/telemetry-events'
|
||||
|
|
@ -39,6 +41,7 @@ export type IssueCommandLaunch =
|
|||
|
||||
type WorktreeActivationStore = {
|
||||
tabsByWorktree: Record<string, { id: string }[]>
|
||||
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
|
||||
createTab: (
|
||||
worktreeId: string,
|
||||
targetGroupId?: string,
|
||||
|
|
@ -55,6 +58,8 @@ type WorktreeActivationStore = {
|
|||
title: string | null,
|
||||
opts?: { recordInteraction?: boolean }
|
||||
) => void
|
||||
setTabColor: (tabId: string, color: string | null) => void
|
||||
markDefaultTerminalTabsApplied: (worktreeId: string) => void
|
||||
reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number }
|
||||
queueTabStartupCommand: (
|
||||
tabId: string,
|
||||
|
|
@ -139,6 +144,7 @@ export function activateAndRevealWorktree(
|
|||
telemetry?: AgentStartedTelemetry
|
||||
}
|
||||
setup?: WorktreeSetupLaunch
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
issueCommand?: IssueCommandLaunch
|
||||
sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior']
|
||||
}
|
||||
|
|
@ -192,7 +198,8 @@ export function activateAndRevealWorktree(
|
|||
worktreeId,
|
||||
opts?.startup ?? buildCreatedAgentReopenStartup(wt),
|
||||
opts?.setup,
|
||||
opts?.issueCommand
|
||||
opts?.issueCommand,
|
||||
opts?.defaultTabs
|
||||
)
|
||||
|
||||
// 5. Clear sidebar filters that would hide the target worktree
|
||||
|
|
@ -223,7 +230,8 @@ export function ensureWorktreeHasInitialTerminal(
|
|||
telemetry?: AgentStartedTelemetry
|
||||
},
|
||||
setup?: WorktreeSetupLaunch,
|
||||
issueCommand?: IssueCommandLaunch
|
||||
issueCommand?: IssueCommandLaunch,
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
): string | null {
|
||||
const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId)
|
||||
// Why: activation can now restore editor- or browser-only worktrees from the
|
||||
|
|
@ -240,6 +248,18 @@ export function ensureWorktreeHasInitialTerminal(
|
|||
return null
|
||||
}
|
||||
|
||||
const templatedTabId = applyDefaultTerminalTabs(
|
||||
store,
|
||||
worktreeId,
|
||||
startup,
|
||||
setup,
|
||||
issueCommand,
|
||||
defaultTabs
|
||||
)
|
||||
if (templatedTabId) {
|
||||
return templatedTabId
|
||||
}
|
||||
|
||||
// Why: this tab only exists because the user clicked/activated a worktree
|
||||
// that had no focusable surface yet. Tag it so the resulting PTY spawn
|
||||
// does not count as activity and reshuffle the Recent sort. Explicit
|
||||
|
|
@ -265,7 +285,73 @@ export function ensureWorktreeHasInitialTerminal(
|
|||
if (startup) {
|
||||
store.queueTabStartupCommand(terminalTab.id, startup)
|
||||
}
|
||||
queueSetupAndIssueCommands(store, worktreeId, terminalTab.id, setup, issueCommand)
|
||||
|
||||
return terminalTab.id
|
||||
}
|
||||
|
||||
function applyDefaultTerminalTabs(
|
||||
store: WorktreeActivationStore,
|
||||
worktreeId: string,
|
||||
startup:
|
||||
| {
|
||||
command: string
|
||||
env?: Record<string, string>
|
||||
initialAgentStatus?: { agent: TuiAgent; prompt: string }
|
||||
telemetry?: AgentStartedTelemetry
|
||||
}
|
||||
| undefined,
|
||||
setup: WorktreeSetupLaunch | undefined,
|
||||
issueCommand: IssueCommandLaunch | undefined,
|
||||
defaultTabs: WorktreeDefaultTabsLaunch | undefined
|
||||
): string | null {
|
||||
if (!defaultTabs || store.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) {
|
||||
return null
|
||||
}
|
||||
store.markDefaultTerminalTabsApplied(worktreeId)
|
||||
if (defaultTabs.tabs.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let firstTabId: string | null = null
|
||||
for (const [index, template] of defaultTabs.tabs.entries()) {
|
||||
const tab = store.createTab(worktreeId, undefined, undefined, {
|
||||
pendingActivationSpawn: true,
|
||||
recordInteraction: false
|
||||
})
|
||||
if (index === 0) {
|
||||
firstTabId = tab.id
|
||||
}
|
||||
if (template.title) {
|
||||
store.setTabCustomTitle(tab.id, template.title, { recordInteraction: false })
|
||||
}
|
||||
if (template.color) {
|
||||
store.setTabColor(tab.id, template.color)
|
||||
}
|
||||
const templateCommand = template.command?.trim()
|
||||
if (templateCommand && defaultTabs.runCommands && !(index === 0 && startup)) {
|
||||
store.queueTabStartupCommand(tab.id, { command: templateCommand })
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstTabId) {
|
||||
return null
|
||||
}
|
||||
store.setActiveTab(firstTabId)
|
||||
if (startup) {
|
||||
store.queueTabStartupCommand(firstTabId, startup)
|
||||
}
|
||||
queueSetupAndIssueCommands(store, worktreeId, firstTabId, setup, issueCommand)
|
||||
return firstTabId
|
||||
}
|
||||
|
||||
function queueSetupAndIssueCommands(
|
||||
store: WorktreeActivationStore,
|
||||
worktreeId: string,
|
||||
terminalTabId: string,
|
||||
setup: WorktreeSetupLaunch | undefined,
|
||||
issueCommand: IssueCommandLaunch | undefined
|
||||
): void {
|
||||
// Why: the setup script launch location is user-configurable. The default
|
||||
// 'new-tab' creates a separate background tab titled "Setup" without
|
||||
// stealing focus from the main terminal, so setup output never crowds the
|
||||
|
|
@ -284,14 +370,14 @@ export function ensureWorktreeHasInitialTerminal(
|
|||
// Why: createTab auto-activates the new tab. Revert activation so the
|
||||
// user's focus stays on the primary terminal — per the design, the
|
||||
// Setup tab runs unattended in the background.
|
||||
store.setActiveTab(terminalTab.id)
|
||||
store.setActiveTab(terminalTabId)
|
||||
// Why: customTitle wins over the auto-generated "Terminal N" label
|
||||
// everywhere the tab is rendered (tab bar, switcher, session snapshots),
|
||||
// so labeling via customTitle is the single authoritative source.
|
||||
store.setTabCustomTitle(setupTab.id, 'Setup', { recordInteraction: false })
|
||||
store.queueTabStartupCommand(setupTab.id, setupCommand)
|
||||
} else {
|
||||
store.queueTabSetupSplit(terminalTab.id, {
|
||||
store.queueTabSetupSplit(terminalTabId, {
|
||||
...setupCommand,
|
||||
direction: mode === 'split-horizontal' ? 'horizontal' : 'vertical'
|
||||
})
|
||||
|
|
@ -314,10 +400,8 @@ export function ensureWorktreeHasInitialTerminal(
|
|||
env: issueCommand.envVars
|
||||
}
|
||||
: { command: issueCommand.command, env: issueCommand.env }
|
||||
store.queueTabIssueCommandSplit(terminalTab.id, queuedIssueCommand)
|
||||
store.queueTabIssueCommandSplit(terminalTabId, queuedIssueCommand)
|
||||
}
|
||||
|
||||
return terminalTab.id
|
||||
}
|
||||
|
||||
// Why: break the import cycle — the nav-history slice must call
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines -- Why: hydration regressions share store setup and session invariants that are easier to audit together. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
|
|
@ -201,6 +202,31 @@ describe('hydrateWorkspaceSession', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('hydrates the default-tab idempotency marker', () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo1::/wt-1'
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })]
|
||||
}
|
||||
})
|
||||
|
||||
const session: WorkspaceSessionState = {
|
||||
activeRepoId: 'repo1',
|
||||
activeWorktreeId: worktreeId,
|
||||
activeTabId: null,
|
||||
terminalLayoutsByTabId: {},
|
||||
tabsByWorktree: {},
|
||||
defaultTerminalTabsAppliedByWorktreeId: { [worktreeId]: true }
|
||||
}
|
||||
|
||||
store.getState().hydrateWorkspaceSession(session)
|
||||
|
||||
expect(store.getState().defaultTerminalTabsAppliedByWorktreeId).toEqual({
|
||||
[worktreeId]: true
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds worktree nav history with the restored active worktree', () => {
|
||||
// Why: without seeding, the first sidebar click after startup becomes the
|
||||
// only history entry, so Back stays disabled until the user clicks a
|
||||
|
|
|
|||
|
|
@ -259,6 +259,8 @@ export type TerminalSlice = {
|
|||
pendingIssueCommandSplitByTabId: Record<string, { command: string; env?: Record<string, string> }>
|
||||
tabBarOrderByWorktree: Record<string, string[]>
|
||||
workspaceSessionReady: boolean
|
||||
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
|
||||
markDefaultTerminalTabsApplied: (worktreeId: string) => void
|
||||
/** True only after hydrateWorkspaceSession ran from a real load of
|
||||
* orca-data.json. Guards the debounced session writer so that a crash
|
||||
* during early startup (fetchRepos / fetchAllWorktrees / session.get /
|
||||
|
|
@ -430,6 +432,19 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
pendingIssueCommandSplitByTabId: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
workspaceSessionReady: false,
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
markDefaultTerminalTabsApplied: (worktreeId) =>
|
||||
set((s) => {
|
||||
if (s.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
defaultTerminalTabsAppliedByWorktreeId: {
|
||||
...s.defaultTerminalTabsAppliedByWorktreeId,
|
||||
[worktreeId]: true
|
||||
}
|
||||
}
|
||||
}),
|
||||
hydrationSucceeded: false,
|
||||
setHydrationSucceeded: (value) => {
|
||||
set({ hydrationSucceeded: value })
|
||||
|
|
@ -2103,6 +2118,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
// after hydration) — not here — because SSH worktrees may still be
|
||||
// appearing in worktreesByRepo at this moment.
|
||||
lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId ?? {},
|
||||
defaultTerminalTabsAppliedByWorktreeId:
|
||||
session.defaultTerminalTabsAppliedByWorktreeId ?? {},
|
||||
pendingReconnectWorktreeIds,
|
||||
pendingReconnectTabByWorktree,
|
||||
pendingReconnectPtyIdByTabId,
|
||||
|
|
|
|||
|
|
@ -429,6 +429,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState {
|
|||
activeBrowserTabIdByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
browserUrlHistory: []
|
||||
browserUrlHistory: [],
|
||||
defaultTerminalTabsAppliedByWorktreeId: {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ describe('remote workspace session projection', () => {
|
|||
remoteSessionIdsByTabId: {
|
||||
'tab-1': 'pty-1',
|
||||
'tab-local': 'pty-local'
|
||||
},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {
|
||||
'repo-a::/srv/app': true as const,
|
||||
'repo-local::/tmp/local': true as const
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +65,7 @@ describe('remote workspace session projection', () => {
|
|||
'tab-1': { root: null, activeLeafId: null, expandedLeafId: null }
|
||||
})
|
||||
expect(projected.remoteSessionIdsByTabId).toEqual({ 'tab-1': 'pty-1' })
|
||||
expect(projected.defaultTerminalTabsAppliedByWorktreePath).toEqual({ '/srv/app': true })
|
||||
})
|
||||
|
||||
it('imports projected terminal state into this client repo id', () => {
|
||||
|
|
@ -85,7 +90,8 @@ describe('remote workspace session projection', () => {
|
|||
terminalLayoutsByTabId: {
|
||||
'tab-1': { root: null, activeLeafId: null, expandedLeafId: null }
|
||||
},
|
||||
remoteSessionIdsByTabId: { 'tab-1': 'pty-1' }
|
||||
remoteSessionIdsByTabId: { 'tab-1': 'pty-1' },
|
||||
defaultTerminalTabsAppliedByWorktreePath: { '/srv/app': true }
|
||||
},
|
||||
{ resolveWorktreeId: (path) => (path === '/srv/app' ? 'repo-b::/srv/app' : null) }
|
||||
)
|
||||
|
|
@ -97,6 +103,9 @@ describe('remote workspace session projection', () => {
|
|||
worktreeId: 'repo-b::/srv/app'
|
||||
})
|
||||
expect(session.remoteSessionIdsByTabId).toEqual({ 'tab-1': 'pty-1' })
|
||||
expect(session.defaultTerminalTabsAppliedByWorktreeId).toEqual({
|
||||
'repo-b::/srv/app': true
|
||||
})
|
||||
})
|
||||
|
||||
it('imports active worktree metadata even when the worktree has no terminal tabs', () => {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,17 @@ export function exportRemoteWorkspaceSession(
|
|||
}
|
||||
}
|
||||
|
||||
const defaultTerminalTabsAppliedByWorktreePath: Record<string, true> = {}
|
||||
for (const worktreeId of Object.keys(session.defaultTerminalTabsAppliedByWorktreeId ?? {})) {
|
||||
if (!options.isTargetWorktree(worktreeId)) {
|
||||
continue
|
||||
}
|
||||
const worktreePath = worktreePathFromId(worktreeId)
|
||||
if (worktreePath) {
|
||||
defaultTerminalTabsAppliedByWorktreePath[worktreePath] = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeWorktreePath,
|
||||
activeTabId,
|
||||
|
|
@ -100,7 +111,8 @@ export function exportRemoteWorkspaceSession(
|
|||
)
|
||||
)
|
||||
: undefined,
|
||||
lastVisitedAtByWorktreePath
|
||||
lastVisitedAtByWorktreePath,
|
||||
defaultTerminalTabsAppliedByWorktreePath
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,6 +169,14 @@ export function importRemoteWorkspaceSession(
|
|||
}
|
||||
}
|
||||
|
||||
const defaultTerminalTabsAppliedByWorktreeId: Record<string, true> = {}
|
||||
for (const worktreePath of Object.keys(remote.defaultTerminalTabsAppliedByWorktreePath ?? {})) {
|
||||
const worktreeId = resolvePath(worktreePath)
|
||||
if (worktreeId) {
|
||||
defaultTerminalTabsAppliedByWorktreeId[worktreeId] = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
activeRepoId: activeWorktreeId ? (splitWorktreeId(activeWorktreeId)?.repoId ?? null) : null,
|
||||
|
|
@ -179,6 +199,7 @@ export function importRemoteWorkspaceSession(
|
|||
)
|
||||
)
|
||||
: undefined,
|
||||
lastVisitedAtByWorktreeId
|
||||
lastVisitedAtByWorktreeId,
|
||||
defaultTerminalTabsAppliedByWorktreeId
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export type RemoteWorkspaceSession = {
|
|||
activeTabIdByWorktreePath?: Record<string, string | null>
|
||||
remoteSessionIdsByTabId?: Record<string, string>
|
||||
lastVisitedAtByWorktreePath?: Record<string, number>
|
||||
defaultTerminalTabsAppliedByWorktreePath?: Record<string, true>
|
||||
}
|
||||
|
||||
export type RemoteWorkspaceSnapshot = {
|
||||
|
|
|
|||
|
|
@ -698,6 +698,10 @@ export type WorkspaceSessionState = {
|
|||
* older builds — hydration tolerates missing/partial maps and the
|
||||
* active worktree is seeded on first restore. */
|
||||
lastVisitedAtByWorktreeId?: Record<string, number>
|
||||
/** Worktrees whose repo-defined default terminal tabs have already been
|
||||
* considered. Persisted so closing all tabs and re-opening the workspace
|
||||
* does not recreate the template. */
|
||||
defaultTerminalTabsAppliedByWorktreeId?: Record<string, true>
|
||||
}
|
||||
|
||||
export type WorkspaceSessionPatch = Partial<WorkspaceSessionState>
|
||||
|
|
@ -1463,6 +1467,13 @@ export type OrcaHooks = {
|
|||
archive?: string // Runs before worktree is archived
|
||||
}
|
||||
issueCommand?: string // Shared default command for linked GitHub issues
|
||||
defaultTabs?: OrcaDefaultTabTemplate[] // Terminal tabs to create once for a new worktree
|
||||
}
|
||||
|
||||
export type OrcaDefaultTabTemplate = {
|
||||
title?: string
|
||||
color?: string
|
||||
command?: string
|
||||
}
|
||||
|
||||
export type RepoHookSettings = {
|
||||
|
|
@ -1487,6 +1498,11 @@ export type WorktreeStartupLaunch = {
|
|||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export type WorktreeDefaultTabsLaunch = {
|
||||
tabs: OrcaDefaultTabTemplate[]
|
||||
runCommands: boolean
|
||||
}
|
||||
|
||||
export type CreateSparseCheckoutRequest = {
|
||||
directories: string[]
|
||||
/** Set when the directories came from a saved preset and the user did not
|
||||
|
|
@ -1552,6 +1568,7 @@ export type CreateWorktreeResult = {
|
|||
lineage?: WorktreeLineage | null
|
||||
warnings?: WorktreeLineageWarning[]
|
||||
setup?: WorktreeSetupLaunch
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
warning?: string
|
||||
initialBaseStatus?: WorktreeBaseStatusEvent
|
||||
localBaseRefRefresh?: LocalBaseRefRefreshResult
|
||||
|
|
|
|||
|
|
@ -222,6 +222,26 @@ describe('parseWorkspaceSession', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('accepts default-tab idempotency markers', () => {
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {
|
||||
'repo1::/path/wt1': true
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value.defaultTerminalTabsAppliedByWorktreeId).toEqual({
|
||||
'repo1::/path/wt1': true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('caps oversized browser history while parsing legacy workspace sessions', () => {
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
|
|
|
|||
|
|
@ -252,7 +252,8 @@ export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.o
|
|||
},
|
||||
z.record(z.string(), z.number().finite().nonnegative())
|
||||
)
|
||||
.optional()
|
||||
.optional(),
|
||||
defaultTerminalTabsAppliedByWorktreeId: z.record(z.string(), z.literal(true)).optional()
|
||||
})
|
||||
|
||||
export type ParsedWorkspaceSession =
|
||||
|
|
|
|||
Loading…
Reference in New Issue