feat: configurable setup script launch location (#745)
Adds a Terminal settings control for where the repo setup script runs on workspace create: vertical split (default, preserves prior behavior), horizontal split, or a separate "Setup" tab that does not steal focus from the primary terminal.
This commit is contained in:
parent
ad472f89d4
commit
eec2f9137e
|
|
@ -2,7 +2,7 @@
|
|||
splitting individual settings into separate files would scatter related controls without a
|
||||
meaningful abstraction boundary. Mirrors the same decision made for GeneralPane.tsx. */
|
||||
import { useState } from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import type { GlobalSettings, SetupScriptLaunchMode } from '../../../../shared/types'
|
||||
import {
|
||||
DEFAULT_TERMINAL_FONT_WEIGHT,
|
||||
TERMINAL_FONT_WEIGHT_MAX,
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
TERMINAL_LIGHT_THEME_SEARCH_ENTRIES,
|
||||
TERMINAL_PANE_STYLE_SEARCH_ENTRIES,
|
||||
TERMINAL_RIGHT_CLICK_TO_PASTE_SEARCH_ENTRY,
|
||||
TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES,
|
||||
TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES
|
||||
} from './terminal-search'
|
||||
import { DarkTerminalThemeSection, LightTerminalThemeSection } from './TerminalThemeSections'
|
||||
|
|
@ -390,6 +391,77 @@ export function TerminalPane({
|
|||
lightPreviewAppearance={lightPreviewAppearance}
|
||||
/>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES) ? (
|
||||
<section key="setup-script" className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Workspace Setup Script</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Where the repository setup script runs when a new workspace is created.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title="Setup Script Location"
|
||||
description="Where the repository setup script runs when a new workspace is created."
|
||||
keywords={[
|
||||
'setup',
|
||||
'script',
|
||||
'workspace',
|
||||
'split',
|
||||
'horizontal',
|
||||
'vertical',
|
||||
'tab',
|
||||
'new',
|
||||
'location',
|
||||
'launch'
|
||||
]}
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label>Setup Script Location</Label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={settings.setupScriptLaunchMode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
updateSettings({
|
||||
setupScriptLaunchMode: value as SetupScriptLaunchMode
|
||||
})
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 flex-wrap"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="split-vertical"
|
||||
className="h-8 px-3 text-xs"
|
||||
aria-label="Split vertically"
|
||||
>
|
||||
Split Vertically
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="split-horizontal"
|
||||
className="h-8 px-3 text-xs"
|
||||
aria-label="Split horizontally"
|
||||
>
|
||||
Split Horizontally
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="new-tab"
|
||||
className="h-8 px-3 text-xs"
|
||||
aria-label="Run in a new tab"
|
||||
>
|
||||
New Tab
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
"New Tab" opens the setup command in a background tab titled "Setup"
|
||||
without stealing focus from your main terminal.
|
||||
</p>
|
||||
</SearchableSetting>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, TERMINAL_ADVANCED_SEARCH_ENTRIES) ? (
|
||||
<section key="advanced" className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
|
|
|
|||
|
|
@ -89,6 +89,26 @@ export const TERMINAL_ADVANCED_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
|||
}
|
||||
]
|
||||
|
||||
export const TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Setup Script Location',
|
||||
description:
|
||||
"Where the repository setup script runs when a new workspace is created: a vertical split (default), a horizontal split, or a background tab titled 'Setup'.",
|
||||
keywords: [
|
||||
'setup',
|
||||
'script',
|
||||
'workspace',
|
||||
'split',
|
||||
'horizontal',
|
||||
'vertical',
|
||||
'tab',
|
||||
'new',
|
||||
'location',
|
||||
'launch'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export const TERMINAL_WINDOWS_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Right-click to paste',
|
||||
|
|
@ -111,6 +131,7 @@ export function getTerminalPaneSearchEntries(isWindows: boolean): SettingsSearch
|
|||
...(isWindows ? TERMINAL_WINDOWS_SEARCH_ENTRIES : []),
|
||||
...TERMINAL_DARK_THEME_SEARCH_ENTRIES,
|
||||
...TERMINAL_LIGHT_THEME_SEARCH_ENTRIES,
|
||||
...TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES,
|
||||
...TERMINAL_ADVANCED_SEARCH_ENTRIES
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import {
|
|||
handleOscLink
|
||||
} from './terminal-link-handlers'
|
||||
import type { LinkHandlerDeps } from './terminal-link-handlers'
|
||||
import type { GlobalSettings, TerminalLayoutSnapshot } from '../../../../shared/types'
|
||||
import type {
|
||||
GlobalSettings,
|
||||
SetupSplitDirection,
|
||||
TerminalLayoutSnapshot
|
||||
} from '../../../../shared/types'
|
||||
import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts'
|
||||
import {
|
||||
buildFontFamily,
|
||||
|
|
@ -30,9 +34,14 @@ type UseTerminalPaneLifecycleDeps = {
|
|||
worktreeId: string
|
||||
cwd?: string
|
||||
startup?: { command: string; env?: Record<string, string> } | null
|
||||
/** When present, the initial pane boots clean and a right-side split pane is
|
||||
* created to run the setup command — keeping the main terminal interactive. */
|
||||
setupSplit?: { command: string; env?: Record<string, string> } | null
|
||||
/** When present, the initial pane boots clean and a split pane is created
|
||||
* (vertical or horizontal per the user setting) to run the setup command —
|
||||
* keeping the main terminal interactive. */
|
||||
setupSplit?: {
|
||||
command: string
|
||||
env?: Record<string, string>
|
||||
direction: SetupSplitDirection
|
||||
} | null
|
||||
/** When present, a split pane is created to run the repo's configured
|
||||
* issue-automation command with the linked issue number interpolated. */
|
||||
issueCommandSplit?: { command: string; env?: Record<string, string> } | null
|
||||
|
|
@ -466,11 +475,11 @@ export function useTerminalPaneLifecycle({
|
|||
const setupPane = splitPaneWithOneShotStartup(
|
||||
ptyDeps,
|
||||
{ command: setupSplit.command, env: setupSplit.env },
|
||||
() => manager.splitPane(initialPane.id, 'vertical')
|
||||
() => manager.splitPane(initialPane.id, setupSplit.direction)
|
||||
)
|
||||
issueAutomationAnchorPaneId = setupPane?.id ?? null
|
||||
// Restore focus to the main (left) pane so the user's terminal
|
||||
// receives keyboard input — the setup pane runs unattended.
|
||||
// Restore focus to the main pane so the user's terminal receives
|
||||
// keyboard input — the setup pane runs unattended.
|
||||
manager.setActivePane(initialPane.id, { focus: isActive })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,28 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SetupScriptLaunchMode } from '../../../shared/types'
|
||||
import { ensureWorktreeHasInitialTerminal } from './worktree-activation'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void {
|
||||
useAppStore.setState((state) => ({
|
||||
settings: state.settings
|
||||
? { ...state.settings, setupScriptLaunchMode: mode ?? 'split-vertical' }
|
||||
: mode !== null
|
||||
? ({ setupScriptLaunchMode: mode } as unknown as typeof state.settings)
|
||||
: state.settings
|
||||
}))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setSetupScriptLaunchMode('split-vertical')
|
||||
})
|
||||
|
||||
function createMockStore(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
tabsByWorktree: {} as Record<string, { id: string }[]>,
|
||||
createTab: vi.fn(() => ({ id: 'tab-1' })),
|
||||
setActiveTab: vi.fn(),
|
||||
setTabCustomTitle: vi.fn(),
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })),
|
||||
queueTabStartupCommand: vi.fn(),
|
||||
queueTabSetupSplit: vi.fn(),
|
||||
|
|
@ -34,7 +51,8 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
env: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1'
|
||||
}
|
||||
},
|
||||
direction: 'vertical'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -141,7 +159,8 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
expect(store.queueTabStartupCommand).not.toHaveBeenCalled()
|
||||
expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', {
|
||||
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
|
||||
env: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
env: { ORCA_ROOT_PATH: '/tmp/repo' },
|
||||
direction: 'vertical'
|
||||
})
|
||||
expect(store.queueTabIssueCommandSplit).toHaveBeenCalledWith('tab-1', {
|
||||
command: 'bash /tmp/repo/.git/orca/issue-command-runner.sh',
|
||||
|
|
@ -157,4 +176,44 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
expect(store.queueTabStartupCommand).not.toHaveBeenCalled()
|
||||
expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues a horizontal setup split when setupScriptLaunchMode is split-horizontal', () => {
|
||||
setSetupScriptLaunchMode('split-horizontal')
|
||||
const store = createMockStore()
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
})
|
||||
|
||||
expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', {
|
||||
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
|
||||
env: { ORCA_ROOT_PATH: '/tmp/repo' },
|
||||
direction: 'horizontal'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a background Setup tab when setupScriptLaunchMode is new-tab', () => {
|
||||
setSetupScriptLaunchMode('new-tab')
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
})
|
||||
|
||||
expect(createTab).toHaveBeenCalledTimes(2)
|
||||
// Main tab is activated first (new terminal), then setup tab is created,
|
||||
// and the helper re-activates the main tab so focus stays on tab-1.
|
||||
expect(store.setActiveTab).toHaveBeenNthCalledWith(1, 'tab-1')
|
||||
expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1')
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup')
|
||||
expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', {
|
||||
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
|
||||
env: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
})
|
||||
expect(store.queueTabSetupSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { WorktreeSetupLaunch } from '../../../shared/types'
|
||||
import type { SetupSplitDirection, WorktreeSetupLaunch } from '../../../shared/types'
|
||||
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
|
||||
import { buildSetupRunnerCommand } from './setup-runner'
|
||||
import { useAppStore } from '@/store'
|
||||
|
|
@ -17,6 +17,7 @@ type WorktreeActivationStore = {
|
|||
tabsByWorktree: Record<string, { id: string }[]>
|
||||
createTab: (worktreeId: string) => { id: string }
|
||||
setActiveTab: (tabId: string) => void
|
||||
setTabCustomTitle: (tabId: string, title: string | null) => void
|
||||
reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number }
|
||||
queueTabStartupCommand: (
|
||||
tabId: string,
|
||||
|
|
@ -24,7 +25,7 @@ type WorktreeActivationStore = {
|
|||
) => void
|
||||
queueTabSetupSplit: (
|
||||
tabId: string,
|
||||
startup: { command: string; env?: Record<string, string> }
|
||||
startup: { command: string; env?: Record<string, string>; direction: SetupSplitDirection }
|
||||
) => void
|
||||
queueTabIssueCommandSplit: (
|
||||
tabId: string,
|
||||
|
|
@ -122,15 +123,34 @@ export function ensureWorktreeHasInitialTerminal(
|
|||
store.queueTabStartupCommand(terminalTab.id, startup)
|
||||
}
|
||||
|
||||
// Why: run the setup script in a split pane to the right so the main
|
||||
// terminal stays immediately interactive. The TerminalPane reads this
|
||||
// signal on mount, creates the initial pane clean, then splits right
|
||||
// and injects the setup command into the new pane's PTY.
|
||||
// Why: the setup script launch location is user-configurable. The default
|
||||
// 'split-vertical' preserves the historical behavior (right-side split so
|
||||
// the main terminal stays immediately interactive); 'split-horizontal'
|
||||
// swaps the split orientation; 'new-tab' creates a separate background
|
||||
// tab titled "Setup" without stealing focus from the main terminal.
|
||||
if (setup) {
|
||||
store.queueTabSetupSplit(terminalTab.id, {
|
||||
const mode = useAppStore.getState().settings?.setupScriptLaunchMode ?? 'split-vertical'
|
||||
const setupCommand = {
|
||||
command: buildSetupRunnerCommand(setup.runnerScriptPath),
|
||||
env: setup.envVars
|
||||
})
|
||||
}
|
||||
if (mode === 'new-tab') {
|
||||
const setupTab = store.createTab(worktreeId)
|
||||
// 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)
|
||||
// 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')
|
||||
store.queueTabStartupCommand(setupTab.id, setupCommand)
|
||||
} else {
|
||||
store.queueTabSetupSplit(terminalTab.id, {
|
||||
...setupCommand,
|
||||
direction: mode === 'split-horizontal' ? 'horizontal' : 'vertical'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: when the user links a GitHub issue and opts into that repo's
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type {
|
||||
SetupSplitDirection,
|
||||
TerminalLayoutSnapshot,
|
||||
TerminalTab,
|
||||
WorkspaceSessionState
|
||||
|
|
@ -63,9 +64,13 @@ export type TerminalSlice = {
|
|||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
|
||||
pendingStartupByTabId: Record<string, { command: string; env?: Record<string, string> }>
|
||||
/** Queued setup-split requests — when present, TerminalPane creates the
|
||||
* initial pane clean, then splits right and runs the command in the new pane
|
||||
* so the main terminal stays immediately interactive. */
|
||||
pendingSetupSplitByTabId: Record<string, { command: string; env?: Record<string, string> }>
|
||||
* initial pane clean, then splits (vertical or horizontal per user setting)
|
||||
* and runs the command in the new pane so the main terminal stays
|
||||
* immediately interactive. */
|
||||
pendingSetupSplitByTabId: Record<
|
||||
string,
|
||||
{ command: string; env?: Record<string, string>; direction: SetupSplitDirection }
|
||||
>
|
||||
/** Queued issue-command-split requests — similar to setup splits but triggered
|
||||
* when an issue is linked during worktree creation and the repo's issue
|
||||
* automation command is enabled. */
|
||||
|
|
@ -107,9 +112,11 @@ export type TerminalSlice = {
|
|||
) => { command: string; env?: Record<string, string> } | null
|
||||
queueTabSetupSplit: (
|
||||
tabId: string,
|
||||
startup: { command: string; env?: Record<string, string> }
|
||||
startup: { command: string; env?: Record<string, string>; direction: SetupSplitDirection }
|
||||
) => void
|
||||
consumeTabSetupSplit: (tabId: string) => { command: string; env?: Record<string, string> } | null
|
||||
consumeTabSetupSplit: (
|
||||
tabId: string
|
||||
) => { command: string; env?: Record<string, string>; direction: SetupSplitDirection } | null
|
||||
queueTabIssueCommandSplit: (
|
||||
tabId: string,
|
||||
issueCommand: { command: string; env?: Record<string, string> }
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
// { ...defaults.settings, ...parsed.settings } merge, so enabling
|
||||
// focus-follows-mouse never happens unexpectedly.
|
||||
terminalFocusFollowsMouse: false,
|
||||
setupScriptLaunchMode: 'split-vertical',
|
||||
terminalScrollbackBytes: 10_000_000,
|
||||
openLinksInApp: true,
|
||||
rightSidebarOpenByDefault: true,
|
||||
|
|
|
|||
|
|
@ -546,6 +546,15 @@ export type TuiAgent =
|
|||
|
||||
export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs'
|
||||
|
||||
/** Where the repo setup script runs when a worktree is created.
|
||||
* - 'split-vertical': split the initial terminal pane with a vertical divider (default).
|
||||
* - 'split-horizontal': split the initial terminal pane with a horizontal divider.
|
||||
* - 'new-tab': open a background tab titled "Setup" and leave focus on the first tab. */
|
||||
export type SetupScriptLaunchMode = 'split-vertical' | 'split-horizontal' | 'new-tab'
|
||||
|
||||
/** Direction used when the setup script launch mode is a split. */
|
||||
export type SetupSplitDirection = 'vertical' | 'horizontal'
|
||||
|
||||
export type GlobalSettings = {
|
||||
workspaceDir: string
|
||||
nestWorkspaces: boolean
|
||||
|
|
@ -574,6 +583,9 @@ export type GlobalSettings = {
|
|||
* menu behavior and users can still reach the menu with Ctrl+right-click. */
|
||||
terminalRightClickToPaste: boolean
|
||||
terminalFocusFollowsMouse: boolean
|
||||
/** Where the repo setup script runs on workspace create. Defaults to a
|
||||
* vertical split so the user's main terminal stays immediately usable. */
|
||||
setupScriptLaunchMode: SetupScriptLaunchMode
|
||||
terminalScrollbackBytes: number
|
||||
/** Why: opening arbitrary links inside Orca uses an isolated guest browser surface.
|
||||
* The setting stays opt-in so existing workflows continue to use the system browser
|
||||
|
|
|
|||
Loading…
Reference in New Issue