state save + settings

This commit is contained in:
Neil 2026-03-18 18:01:23 -07:00
parent 4cadfaa031
commit 8446d25440
18 changed files with 1029 additions and 300 deletions

View file

@ -1,9 +1,11 @@
import { readFileSync, existsSync } from 'fs'
import { join } from 'path'
import { exec } from 'child_process'
import type { OrcaHooks } from '../shared/types'
import { getDefaultRepoHookSettings } from '../shared/constants'
import type { OrcaHooks, Repo } from '../shared/types'
const HOOK_TIMEOUT = 120_000 // 2 minutes
type HookName = keyof OrcaHooks['scripts']
/**
* Parse a simple orca.yaml file. Handles only the `scripts:` block with
@ -39,7 +41,7 @@ function parseOrcaYaml(content: string): OrcaHooks | null {
}
// Content line (indented by 4+ spaces under a key)
if (currentKey && /^ /.test(line)) {
if (currentKey && line.startsWith(' ')) {
currentValue += line.slice(4) + '\n'
}
}
@ -75,15 +77,45 @@ export function hasHooksFile(repoPath: string): boolean {
return existsSync(join(repoPath, 'orca.yaml'))
}
export function getEffectiveHooks(repo: Repo): OrcaHooks | null {
const defaults = getDefaultRepoHookSettings()
const yamlHooks = loadHooks(repo.path)
const repoSettings = {
...defaults,
...repo.hookSettings,
scripts: {
...defaults.scripts,
...repo.hookSettings?.scripts
}
}
const hooks: OrcaHooks = { scripts: {} }
for (const hookName of ['setup', 'archive'] as HookName[]) {
const yamlScript = yamlHooks?.scripts[hookName]?.trim()
const uiScript = repoSettings.scripts[hookName].trim()
const autoScript = yamlScript || uiScript || undefined
const effectiveScript = repoSettings.mode === 'auto' ? autoScript : uiScript || undefined
if (effectiveScript) {
hooks.scripts[hookName] = effectiveScript
}
}
if (!hooks.scripts.setup && !hooks.scripts.archive) return null
return hooks
}
/**
* Run a named hook script in the given working directory.
*/
export function runHook(
hookName: 'setup' | 'archive',
cwd: string,
repoPath: string
repo: Repo
): Promise<{ success: boolean; output: string }> {
const hooks = loadHooks(repoPath)
const hooks = getEffectiveHooks(repo)
const script = hooks?.scripts[hookName]
if (!script) {
@ -99,11 +131,11 @@ export function runHook(
shell: '/bin/bash',
env: {
...process.env,
ORCA_ROOT_PATH: repoPath,
ORCA_ROOT_PATH: repo.path,
ORCA_WORKTREE_PATH: cwd,
// Compat with conductor.json users
CONDUCTOR_ROOT_PATH: repoPath,
GHOSTX_ROOT_PATH: repoPath
CONDUCTOR_ROOT_PATH: repo.path,
GHOSTX_ROOT_PATH: repo.path
}
},
(error, stdout, stderr) => {

View file

@ -11,6 +11,7 @@ import { registerPtyHandlers, killAllPty } from './ipc/pty'
import { registerGitHubHandlers } from './ipc/github'
import { registerSettingsHandlers } from './ipc/settings'
import { registerShellHandlers } from './ipc/shell'
import { registerSessionHandlers } from './ipc/session'
let mainWindow: BrowserWindow | null = null
let store: Store | null = null
@ -139,6 +140,7 @@ app.whenReady().then(() => {
registerGitHubHandlers()
registerSettingsHandlers(store)
registerShellHandlers()
registerSessionHandlers(store)
// macOS re-activate
app.on('activate', function () {

View file

@ -41,7 +41,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
'repos:update',
(
_event,
args: { repoId: string; updates: Partial<Pick<Repo, 'displayName' | 'badgeColor'>> }
args: {
repoId: string
updates: Partial<Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings'>>
}
) => {
const updated = store.updateRepo(args.repoId, args.updates)
if (updated) notifyReposChanged(mainWindow)

13
src/main/ipc/session.ts Normal file
View file

@ -0,0 +1,13 @@
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import type { WorkspaceSessionState } from '../../shared/types'
export function registerSessionHandlers(store: Store): void {
ipcMain.handle('session:get', () => {
return store.getWorkspaceSession()
})
ipcMain.handle('session:set', (_event, args: WorkspaceSessionState) => {
store.setWorkspaceSession(args)
})
}

View file

@ -4,7 +4,7 @@ import type { Store } from '../persistence'
import type { Worktree, WorktreeMeta } from '../../shared/types'
import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree'
import { getGitUsername, getDefaultBranch } from '../git/repo'
import { loadHooks, runHook, hasHooksFile } from '../hooks'
import { getEffectiveHooks, loadHooks, runHook, hasHooksFile } from '../hooks'
export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store): void {
ipcMain.handle('worktrees:listAll', async () => {
@ -76,9 +76,9 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
const worktree = mergeWorktree(repo.id, created, undefined)
// Run setup hook asynchronously (don't block the UI)
const hooks = loadHooks(repo.path)
const hooks = getEffectiveHooks(repo)
if (hooks?.scripts.setup) {
runHook('setup', worktreePath, repo.path).then((result) => {
runHook('setup', worktreePath, repo).then((result) => {
if (!result.success) {
console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output)
}
@ -98,9 +98,9 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
if (!repo) throw new Error(`Repo not found: ${repoId}`)
// Run archive hook before removal
const hooks = loadHooks(repo.path)
const hooks = getEffectiveHooks(repo)
if (hooks?.scripts.archive) {
const result = await runHook('archive', worktreePath, repo.path)
const result = await runHook('archive', worktreePath, repo)
if (!result.success) {
console.error(`[hooks] archive hook failed for ${worktreePath}:`, result.output)
}

View file

@ -3,7 +3,11 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from '
import { join, dirname } from 'path'
import { homedir } from 'os'
import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types'
import { getDefaultPersistedState } from '../shared/constants'
import {
getDefaultPersistedState,
getDefaultRepoHookSettings,
getDefaultWorkspaceSession
} from '../shared/constants'
const DATA_FILE = join(app.getPath('userData'), 'orca-data.json')
@ -26,7 +30,8 @@ export class Store {
...defaults,
...parsed,
settings: { ...defaults.settings, ...parsed.settings },
ui: { ...defaults.ui, ...parsed.ui }
ui: { ...defaults.ui, ...parsed.ui },
workspaceSession: { ...defaults.workspaceSession, ...parsed.workspaceSession }
}
}
} catch (err) {
@ -54,15 +59,17 @@ export class Store {
// ── Repos ──────────────────────────────────────────────────────────
getRepos(): Repo[] {
return this.state.repos
return this.state.repos.map((repo) => this.hydrateRepo(repo))
}
getRepo(id: string): Repo | undefined {
return this.state.repos.find((r) => r.id === id)
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) return undefined
return this.hydrateRepo(repo)
}
addRepo(repo: Repo): void {
this.state.repos.push(repo)
this.state.repos.push(this.hydrateRepo(repo))
this.scheduleSave()
}
@ -78,12 +85,29 @@ export class Store {
this.scheduleSave()
}
updateRepo(id: string, updates: Partial<Pick<Repo, 'displayName' | 'badgeColor'>>): Repo | null {
updateRepo(
id: string,
updates: Partial<Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings'>>
): Repo | null {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) return null
Object.assign(repo, updates)
this.scheduleSave()
return repo
return this.hydrateRepo(repo)
}
private hydrateRepo(repo: Repo): Repo {
return {
...repo,
hookSettings: {
...getDefaultRepoHookSettings(),
...repo.hookSettings,
scripts: {
...getDefaultRepoHookSettings().scripts,
...repo.hookSettings?.scripts
}
}
}
}
// ── Worktree Meta ──────────────────────────────────────────────────
@ -143,6 +167,17 @@ export class Store {
this.scheduleSave()
}
// ── Workspace Session ─────────────────────────────────────────────
getWorkspaceSession(): PersistedState['workspaceSession'] {
return this.state.workspaceSession ?? getDefaultWorkspaceSession()
}
setWorkspaceSession(session: PersistedState['workspaceSession']): void {
this.state.workspaceSession = session
this.scheduleSave()
}
// ── Flush (for shutdown) ───────────────────────────────────────────
flush(): void {

View file

@ -6,7 +6,8 @@ import type {
PRInfo,
IssueInfo,
GlobalSettings,
OrcaHooks
OrcaHooks,
WorkspaceSessionState
} from '../../shared/types'
interface ReposApi {
@ -15,7 +16,7 @@ interface ReposApi {
remove: (args: { repoId: string }) => Promise<void>
update: (args: {
repoId: string
updates: Partial<Pick<Repo, 'displayName' | 'badgeColor'>>
updates: Partial<Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings'>>
}) => Promise<Repo>
pickFolder: () => Promise<string | null>
onChanged: (callback: () => void) => () => void
@ -72,6 +73,11 @@ interface CacheApi {
}) => Promise<void>
}
interface SessionApi {
get: () => Promise<WorkspaceSessionState>
set: (args: WorkspaceSessionState) => Promise<void>
}
interface Api {
repos: ReposApi
worktrees: WorktreesApi
@ -81,6 +87,7 @@ interface Api {
shell: ShellApi
hooks: HooksApi
cache: CacheApi
session: SessionApi
}
declare global {

View file

@ -108,6 +108,11 @@ const api = {
cache: {
getGitHub: () => ipcRenderer.invoke('cache:getGitHub'),
setGitHub: (args: { cache: unknown }) => ipcRenderer.invoke('cache:setGitHub', args)
},
session: {
get: (): Promise<unknown> => ipcRenderer.invoke('session:get'),
set: (args: unknown): Promise<void> => ipcRenderer.invoke('session:set', args)
}
}

View file

@ -12,13 +12,18 @@ function App(): React.JSX.Element {
const toggleSidebar = useAppStore((s) => s.toggleSidebar)
const activeView = useAppStore((s) => s.activeView)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const activeTabId = useAppStore((s) => s.activeTabId)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const canExpandPaneByTabId = useAppStore((s) => s.canExpandPaneByTabId)
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const fetchRepos = useAppStore((s) => s.fetchRepos)
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const initGitHubCache = useAppStore((s) => s.initGitHubCache)
const hydrateWorkspaceSession = useAppStore((s) => s.hydrateWorkspaceSession)
const openModal = useAppStore((s) => s.openModal)
const repos = useAppStore((s) => s.repos)
@ -29,10 +34,59 @@ function App(): React.JSX.Element {
// Fetch initial data + hydrate GitHub cache from disk
useEffect(() => {
fetchRepos()
fetchSettings()
initGitHubCache()
}, [fetchRepos, fetchSettings, initGitHubCache])
let cancelled = false
void (async () => {
try {
await fetchRepos()
await fetchAllWorktrees()
const session = await window.api.session.get()
if (!cancelled) {
hydrateWorkspaceSession(session)
}
} catch (error) {
console.error('Failed to hydrate workspace session:', error)
if (!cancelled) {
hydrateWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
})
}
}
void fetchSettings()
void initGitHubCache()
})()
return () => {
cancelled = true
}
}, [fetchRepos, fetchAllWorktrees, fetchSettings, initGitHubCache, hydrateWorkspaceSession])
useEffect(() => {
if (!workspaceSessionReady) return
const timer = window.setTimeout(() => {
void window.api.session.set({
activeRepoId,
activeWorktreeId,
activeTabId,
tabsByWorktree,
terminalLayoutsByTabId
})
}, 150)
return () => window.clearTimeout(timer)
}, [
workspaceSessionReady,
activeRepoId,
activeWorktreeId,
activeTabId,
tabsByWorktree,
terminalLayoutsByTabId
])
// Apply theme to document
useEffect(() => {

View file

@ -1,6 +1,7 @@
import { useEffect, useState, useCallback } from 'react'
import type { OrcaHooks, Repo, RepoHookSettings } from '../../../shared/types'
import { REPO_COLORS, getDefaultRepoHookSettings } from '../../../shared/constants'
import { useAppStore } from '../store'
import { REPO_COLORS } from '../../../shared/constants'
import { ScrollArea } from './ui/scroll-area'
import { Button } from './ui/button'
import { Input } from './ui/input'
@ -8,6 +9,9 @@ import { Label } from './ui/label'
import { Separator } from './ui/separator'
import { ArrowLeft, FolderOpen, Minus, Plus, Trash2 } from 'lucide-react'
type HookName = keyof OrcaHooks['scripts']
const DEFAULT_REPO_HOOK_SETTINGS = getDefaultRepoHookSettings()
function Settings(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
@ -18,13 +22,16 @@ function Settings(): React.JSX.Element {
const removeRepo = useAppStore((s) => s.removeRepo)
const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null)
const [repoHooksMap, setRepoHooksMap] = useState<Record<string, boolean>>({})
const [selectedPane, setSelectedPane] = useState<'general' | 'repo'>('general')
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null)
const [repoHooksMap, setRepoHooksMap] = useState<
Record<string, { hasHooks: boolean; hooks: OrcaHooks | null }>
>({})
useEffect(() => {
fetchSettings()
}, [fetchSettings])
// Check which repos have orca.yaml hooks
useEffect(() => {
let stale = false
const checkHooks = async () => {
@ -32,23 +39,41 @@ function Settings(): React.JSX.Element {
repos.map(async (repo) => {
try {
const result = await window.api.hooks.check({ repoId: repo.id })
return [repo.id, result.hasHooks] as const
return [repo.id, result] as const
} catch {
return [repo.id, false] as const
return [repo.id, { hasHooks: false, hooks: null }] as const
}
})
)
if (!stale) {
setRepoHooksMap(Object.fromEntries(results))
}
}
if (repos.length > 0) checkHooks()
if (repos.length > 0) {
checkHooks()
} else {
setRepoHooksMap({})
}
return () => {
stale = true
}
}, [repos])
// Apply theme immediately
useEffect(() => {
if (repos.length === 0) {
setSelectedRepoId(null)
setSelectedPane('general')
return
}
if (!selectedRepoId || !repos.some((repo) => repo.id === selectedRepoId)) {
setSelectedRepoId(repos[0].id)
}
}, [repos, selectedRepoId])
const applyTheme = useCallback((theme: 'system' | 'dark' | 'light') => {
const root = document.documentElement
if (theme === 'dark') {
@ -56,7 +81,6 @@ function Settings(): React.JSX.Element {
} else if (theme === 'light') {
root.classList.remove('dark')
} else {
// system
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (prefersDark) {
root.classList.add('dark')
@ -77,304 +101,552 @@ function Settings(): React.JSX.Element {
if (confirmingRemove === repoId) {
removeRepo(repoId)
setConfirmingRemove(null)
} else {
setConfirmingRemove(repoId)
return
}
setConfirmingRemove(repoId)
}
const selectedRepo = repos.find((repo) => repo.id === selectedRepoId) ?? null
const selectedYamlHooks = selectedRepo ? (repoHooksMap[selectedRepo.id]?.hooks ?? null) : null
const showGeneralPane = selectedPane === 'general' || !selectedRepo
const updateSelectedRepoHookSettings = (
repo: Repo,
updates: Omit<Partial<RepoHookSettings>, 'scripts'> & {
scripts?: Partial<RepoHookSettings['scripts']>
}
) => {
const nextSettings: RepoHookSettings = {
...DEFAULT_REPO_HOOK_SETTINGS,
...repo.hookSettings,
...updates,
scripts: {
...DEFAULT_REPO_HOOK_SETTINGS.scripts,
...repo.hookSettings?.scripts,
...updates.scripts
}
}
updateRepo(repo.id, {
hookSettings: nextSettings
})
}
if (!settings) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="flex flex-1 items-center justify-center text-muted-foreground">
Loading settings...
</div>
)
}
return (
<div className="flex-1 flex min-h-0 flex-col overflow-hidden bg-background">
{/* Header */}
<div className="flex items-center gap-3 px-6 py-4 border-b">
<Button variant="ghost" size="icon-sm" onClick={() => setActiveView('terminal')}>
<ArrowLeft className="size-4" />
</Button>
<h1 className="text-lg font-semibold">Settings</h1>
</div>
<div className="flex min-h-0 flex-1 overflow-hidden bg-background">
<aside className="flex w-[260px] shrink-0 flex-col border-r bg-card/40">
<div className="border-b px-3 py-3">
<Button
variant="ghost"
size="sm"
onClick={() => setActiveView('terminal')}
className="w-full justify-start gap-2 text-muted-foreground"
>
<ArrowLeft className="size-4" />
Back to app
</Button>
</div>
{/* Content */}
<ScrollArea className="min-h-0 flex-1">
<div className="max-w-2xl px-8 py-6 space-y-8">
{/* ── Workspace ────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Workspace</h2>
{/* Workspace Directory */}
<div className="space-y-2">
<Label className="text-sm">Workspace Directory</Label>
<p className="text-xs text-muted-foreground">
Root directory where worktree folders are created.
</p>
<div className="flex gap-2">
<Input
value={settings.workspaceDir}
onChange={(e) => updateSettings({ workspaceDir: e.target.value })}
className="flex-1 font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={handleBrowseWorkspace}
className="gap-1.5 shrink-0"
>
<FolderOpen className="size-3.5" />
Browse
</Button>
</div>
</div>
{/* Nest Workspaces */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm">Nest Workspaces</Label>
<p className="text-xs text-muted-foreground">
Create worktrees inside a repo-named subfolder.
</p>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 px-3 py-4">
<div className="space-y-1">
<button
role="switch"
aria-checked={settings.nestWorkspaces}
onClick={() => updateSettings({ nestWorkspaces: !settings.nestWorkspaces })}
className={`
relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full
border border-transparent transition-colors
${settings.nestWorkspaces ? 'bg-foreground' : 'bg-muted-foreground/30'}
`}
onClick={() => setSelectedPane('general')}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
showGeneralPane
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<span
className={`
pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform
${settings.nestWorkspaces ? 'translate-x-4' : 'translate-x-0.5'}
`}
/>
General
</button>
</div>
</section>
<Separator />
{/* ── Branch Prefix ────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Branch Naming</h2>
<div className="space-y-2">
<Label className="text-sm">Branch Name Prefix</Label>
<p className="text-xs text-muted-foreground">
Prefix added to branch names when creating worktrees.
<p className="px-3 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
Repositories
</p>
<div className="flex gap-1 rounded-md border p-1 w-fit">
{(['git-username', 'custom', 'none'] as const).map((option) => (
<button
key={option}
onClick={() => updateSettings({ branchPrefix: option })}
className={`
px-3 py-1 text-sm rounded-sm transition-colors
${
settings.branchPrefix === option
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
}
`}
>
{option === 'git-username'
? 'Git Username'
: option === 'custom'
? 'Custom'
: 'None'}
</button>
))}
</div>
{settings.branchPrefix === 'custom' && (
<Input
value={settings.branchPrefixCustom}
onChange={(e) => updateSettings({ branchPrefixCustom: e.target.value })}
placeholder="e.g. feature/"
className="max-w-xs mt-2"
/>
{repos.length === 0 ? (
<p className="px-3 text-xs text-muted-foreground">No repositories added yet.</p>
) : (
<div className="space-y-1">
{repos.map((repo) => (
<button
key={repo.id}
onClick={() => {
setSelectedRepoId(repo.id)
setSelectedPane('repo')
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
!showGeneralPane && selectedRepoId === repo.id
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<span
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: repo.badgeColor }}
/>
<span className="truncate">{repo.displayName}</span>
</button>
))}
</div>
)}
</div>
</section>
</div>
</ScrollArea>
</aside>
<Separator />
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto max-w-5xl px-8 py-8">
{showGeneralPane ? (
<div className="space-y-8">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">General</h1>
<p className="text-sm text-muted-foreground">
Workspace, naming, appearance, and terminal defaults.
</p>
</div>
{/* ── Appearance ───────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Appearance</h2>
<div className="overflow-hidden rounded-2xl border bg-card">
<section className="space-y-4 px-6 py-5">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Workspace</h2>
<p className="text-xs text-muted-foreground">
Configure where new worktrees are created.
</p>
</div>
{/* Theme */}
<div className="space-y-2">
<Label className="text-sm">Theme</Label>
<div className="flex gap-1 rounded-md border p-1 w-fit">
{(['system', 'dark', 'light'] as const).map((option) => (
<button
key={option}
onClick={() => {
updateSettings({ theme: option })
applyTheme(option)
}}
className={`
px-3 py-1 text-sm rounded-sm transition-colors capitalize
${
settings.theme === option
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
}
`}
>
{option}
</button>
))}
<div className="space-y-2">
<Label className="text-sm">Workspace Directory</Label>
<div className="flex gap-2">
<Input
value={settings.workspaceDir}
onChange={(e) => updateSettings({ workspaceDir: e.target.value })}
className="flex-1 font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={handleBrowseWorkspace}
className="shrink-0 gap-1.5"
>
<FolderOpen className="size-3.5" />
Browse
</Button>
</div>
<p className="text-xs text-muted-foreground">
Root directory where worktree folders are created.
</p>
</div>
<div className="flex items-center justify-between gap-4 rounded-xl border bg-background/60 px-4 py-3">
<div className="space-y-0.5">
<Label className="text-sm">Nest Workspaces</Label>
<p className="text-xs text-muted-foreground">
Create worktrees inside a repo-named subfolder.
</p>
</div>
<button
role="switch"
aria-checked={settings.nestWorkspaces}
onClick={() => updateSettings({ nestWorkspaces: !settings.nestWorkspaces })}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.nestWorkspaces ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.nestWorkspaces ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</section>
<Separator />
<section className="space-y-4 px-6 py-5">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Branch Naming</h2>
<p className="text-xs text-muted-foreground">
Prefix added to branch names when creating worktrees.
</p>
</div>
<div className="flex w-fit gap-1 rounded-md border p-1">
{(['git-username', 'custom', 'none'] as const).map((option) => (
<button
key={option}
onClick={() => updateSettings({ branchPrefix: option })}
className={`rounded-sm px-3 py-1 text-sm transition-colors ${
settings.branchPrefix === option
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{option === 'git-username'
? 'Git Username'
: option === 'custom'
? 'Custom'
: 'None'}
</button>
))}
</div>
{settings.branchPrefix === 'custom' && (
<Input
value={settings.branchPrefixCustom}
onChange={(e) => updateSettings({ branchPrefixCustom: e.target.value })}
placeholder="e.g. feature"
className="max-w-xs"
/>
)}
</section>
<Separator />
<section className="space-y-4 px-6 py-5">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Appearance</h2>
<p className="text-xs text-muted-foreground">
Choose how Orca looks in the app window.
</p>
</div>
<div className="flex w-fit gap-1 rounded-md border p-1">
{(['system', 'dark', 'light'] as const).map((option) => (
<button
key={option}
onClick={() => {
updateSettings({ theme: option })
applyTheme(option)
}}
className={`rounded-sm px-3 py-1 text-sm capitalize transition-colors ${
settings.theme === option
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{option}
</button>
))}
</div>
</section>
<Separator />
<section className="space-y-4 px-6 py-5">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Terminal</h2>
<p className="text-xs text-muted-foreground">
Default terminal typography for new panes.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm">Font Size</Label>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
onClick={() => {
const next = Math.max(10, settings.terminalFontSize - 1)
updateSettings({ terminalFontSize: next })
}}
disabled={settings.terminalFontSize <= 10}
>
<Minus className="size-3" />
</Button>
<Input
type="number"
min={10}
max={24}
value={settings.terminalFontSize}
onChange={(e) => {
const value = parseInt(e.target.value, 10)
if (!Number.isNaN(value) && value >= 10 && value <= 24) {
updateSettings({ terminalFontSize: value })
}
}}
className="w-16 text-center tabular-nums"
/>
<Button
variant="outline"
size="icon-sm"
onClick={() => {
const next = Math.min(24, settings.terminalFontSize + 1)
updateSettings({ terminalFontSize: next })
}}
disabled={settings.terminalFontSize >= 24}
>
<Plus className="size-3" />
</Button>
<span className="text-xs text-muted-foreground">px</span>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm">Font Family</Label>
<Input
value={settings.terminalFontFamily}
onChange={(e) => updateSettings({ terminalFontFamily: e.target.value })}
placeholder="SF Mono"
className="max-w-xs"
/>
</div>
</section>
</div>
</div>
</section>
<Separator />
{/* ── Terminal ─────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Terminal</h2>
{/* Font Size */}
<div className="space-y-2">
<Label className="text-sm">Font Size</Label>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
onClick={() => {
const next = Math.max(10, settings.terminalFontSize - 1)
updateSettings({ terminalFontSize: next })
}}
disabled={settings.terminalFontSize <= 10}
>
<Minus className="size-3" />
</Button>
<Input
type="number"
min={10}
max={24}
value={settings.terminalFontSize}
onChange={(e) => {
const val = parseInt(e.target.value, 10)
if (!isNaN(val) && val >= 10 && val <= 24) {
updateSettings({ terminalFontSize: val })
}
}}
className="w-16 text-center tabular-nums"
/>
<Button
variant="outline"
size="icon-sm"
onClick={() => {
const next = Math.min(24, settings.terminalFontSize + 1)
updateSettings({ terminalFontSize: next })
}}
disabled={settings.terminalFontSize >= 24}
>
<Plus className="size-3" />
</Button>
<span className="text-xs text-muted-foreground">px</span>
) : selectedRepo ? (
<div className="space-y-8">
<div className="space-y-1">
<div className="flex items-center gap-3">
<span
className="size-3 rounded-full"
style={{ backgroundColor: selectedRepo.badgeColor }}
/>
<h1 className="text-2xl font-semibold">{selectedRepo.displayName}</h1>
</div>
<p className="font-mono text-xs text-muted-foreground">{selectedRepo.path}</p>
</div>
</div>
{/* Font Family */}
<div className="space-y-2">
<Label className="text-sm">Font Family</Label>
<Input
value={settings.terminalFontFamily}
onChange={(e) => updateSettings({ terminalFontFamily: e.target.value })}
placeholder="SF Mono"
className="max-w-xs"
/>
</div>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="space-y-6">
<section className="space-y-4 rounded-2xl border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Identity</h2>
<p className="text-xs text-muted-foreground">
Repo-specific display details for the sidebar and tabs.
</p>
</div>
<Separator />
<Button
variant={confirmingRemove === selectedRepo.id ? 'destructive' : 'outline'}
size="sm"
onClick={() => handleRemoveRepo(selectedRepo.id)}
onBlur={() => setConfirmingRemove(null)}
className="gap-2"
>
<Trash2 className="size-3.5" />
{confirmingRemove === selectedRepo.id ? 'Confirm Remove' : 'Remove Repo'}
</Button>
</div>
{/* ── Repos ────────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Repositories</h2>
<p className="text-xs text-muted-foreground">
Manage display names and badge colors for your repositories.
</p>
<div className="space-y-2">
<Label className="text-sm">Display Name</Label>
<Input
value={selectedRepo.displayName}
onChange={(e) =>
updateRepo(selectedRepo.id, { displayName: e.target.value })
}
className="h-9 text-sm"
/>
</div>
{repos.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">No repositories added yet.</p>
) : (
<div className="space-y-3">
{repos.map((repo) => (
<div key={repo.id} className="flex items-center gap-4 rounded-lg border p-3">
{/* Color picker */}
<div className="flex gap-1.5 shrink-0">
{REPO_COLORS.map((color) => (
<div className="space-y-2">
<Label className="text-sm">Badge Color</Label>
<div className="flex flex-wrap gap-2">
{REPO_COLORS.map((color) => (
<button
key={color}
onClick={() => updateRepo(selectedRepo.id, { badgeColor: color })}
className={`size-7 rounded-full transition-all ${
selectedRepo.badgeColor === color
? 'ring-2 ring-foreground ring-offset-2 ring-offset-card'
: 'hover:ring-1 hover:ring-muted-foreground hover:ring-offset-2 hover:ring-offset-card'
}`}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
</div>
</section>
<section className="space-y-4 rounded-2xl border bg-card p-5">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Hook Source</h2>
<p className="text-xs text-muted-foreground">
Auto prefers `orca.yaml` when present, then falls back to the UI script.
Override ignores YAML and only uses the UI script.
</p>
</div>
<div className="flex w-fit gap-1 rounded-xl border p-1">
{(['auto', 'override'] as const).map((mode) => (
<button
key={color}
onClick={() => updateRepo(repo.id, { badgeColor: color })}
className={`
size-5 rounded-full transition-all
${
repo.badgeColor === color
? 'ring-2 ring-foreground ring-offset-2 ring-offset-background'
: 'hover:ring-1 hover:ring-muted-foreground hover:ring-offset-1 hover:ring-offset-background'
}
`}
style={{ backgroundColor: color }}
title={color}
/>
key={mode}
onClick={() => updateSelectedRepoHookSettings(selectedRepo, { mode })}
className={`rounded-lg px-3 py-1.5 text-sm transition-colors ${
selectedRepo.hookSettings?.mode === mode
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{mode === 'auto' ? 'Use YAML First' : 'Override in UI'}
</button>
))}
</div>
{/* Display name */}
<Input
value={repo.displayName}
onChange={(e) => updateRepo(repo.id, { displayName: e.target.value })}
className="flex-1 h-8 text-sm"
/>
<div className="rounded-xl border border-dashed bg-muted/30 p-3 text-xs text-muted-foreground">
{selectedYamlHooks ? (
<div className="space-y-2">
<p className="font-medium text-foreground">
YAML hooks detected in `orca.yaml`
</p>
<div className="flex flex-wrap gap-2">
{(['setup', 'archive'] as HookName[]).map((hookName) =>
selectedYamlHooks.scripts[hookName] ? (
<span
key={hookName}
className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-1 text-[10px] font-medium uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-300"
>
{hookName}
</span>
) : null
)}
</div>
</div>
) : (
<p>No YAML hooks detected for this repo.</p>
)}
</div>
</section>
</div>
{/* Hooks indicator */}
{repoHooksMap[repo.id] && (
<span
className="shrink-0 rounded-md bg-accent px-2 py-0.5 text-[10px] font-medium text-accent-foreground"
title="This repo has an orca.yaml with lifecycle hooks"
>
hooks
</span>
)}
{/* Remove */}
<Button
variant={confirmingRemove === repo.id ? 'destructive' : 'ghost'}
size="icon-sm"
onClick={() => handleRemoveRepo(repo.id)}
onBlur={() => setConfirmingRemove(null)}
title={
confirmingRemove === repo.id
? 'Click again to confirm'
: 'Remove repository'
}
>
<Trash2 className="size-3.5" />
</Button>
<section className="space-y-4 rounded-2xl border bg-card p-5">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Lifecycle Hooks</h2>
<p className="text-xs text-muted-foreground">
Write scripts directly in the UI. Each repo stores its own setup and archive
hook script.
</p>
</div>
))}
</div>
)}
</section>
{/* Bottom spacing */}
<div className="h-8" />
<div className="space-y-4">
{(['setup', 'archive'] as HookName[]).map((hookName) => (
<HookEditor
key={hookName}
hookName={hookName}
repo={selectedRepo}
yamlHooks={selectedYamlHooks}
onScriptChange={(script) =>
updateSelectedRepoHookSettings(selectedRepo, {
scripts: hookName === 'setup' ? { setup: script } : { archive: script }
})
}
/>
))}
</div>
</section>
</div>
</div>
) : (
<div className="flex min-h-[24rem] items-center justify-center text-sm text-muted-foreground">
Select a repository to edit its settings.
</div>
)}
</div>
</ScrollArea>
</div>
)
}
function HookEditor({
hookName,
repo,
yamlHooks,
onScriptChange
}: {
hookName: HookName
repo: Repo
yamlHooks: OrcaHooks | null
onScriptChange: (script: string) => void
}): React.JSX.Element {
const uiScript = repo.hookSettings?.scripts[hookName] ?? ''
const yamlScript = yamlHooks?.scripts[hookName]
const effectiveSource =
repo.hookSettings?.mode === 'auto' && yamlScript ? 'yaml' : uiScript.trim() ? 'ui' : 'none'
return (
<div className="space-y-3 rounded-2xl border bg-background/80 p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h5 className="text-sm font-semibold capitalize">{hookName}</h5>
<p className="text-xs text-muted-foreground">
{hookName === 'setup'
? 'Runs after a worktree is created.'
: 'Runs before a worktree is archived.'}
</p>
</div>
<span
className={`rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${
effectiveSource === 'yaml'
? 'border border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
: effectiveSource === 'ui'
? 'border border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300'
: 'border bg-muted text-muted-foreground'
}`}
>
{effectiveSource === 'yaml'
? 'Honoring YAML'
: effectiveSource === 'ui'
? 'Using UI'
: 'Inactive'}
</span>
</div>
{yamlScript && (
<div className="space-y-2 rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs font-medium uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-300">
YAML Script
</Label>
<span className="text-[10px] text-muted-foreground">Read-only from `orca.yaml`</span>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-background/70 p-3 font-mono text-[11px] leading-5 text-foreground">
{yamlScript}
</pre>
</div>
)}
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
UI Script
</Label>
<span className="text-[10px] text-muted-foreground">
{repo.hookSettings?.mode === 'auto' && yamlScript
? 'Stored as fallback until you switch to override.'
: 'Editable script stored with this repo.'}
</span>
</div>
<textarea
value={uiScript}
onChange={(e) => onScriptChange(e.target.value)}
placeholder={
hookName === 'setup'
? 'pnpm install\npnpm generate'
: 'echo "Cleaning up before archive"'
}
spellCheck={false}
className="min-h-[12rem] w-full resize-y rounded-xl border bg-background px-3 py-3 font-mono text-[12px] leading-5 outline-none transition-colors placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30"
/>
</div>
</div>
)
}
export default Settings

View file

@ -17,6 +17,7 @@ export default function Terminal(): React.JSX.Element | null {
const setTabCustomTitle = useAppStore((s) => s.setTabCustomTitle)
const setTabColor = useAppStore((s) => s.setTabColor)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
const prevTabCountRef = useRef(tabs.length)
@ -34,6 +35,7 @@ export default function Terminal(): React.JSX.Element | null {
// Auto-create first tab when worktree activates
useEffect(() => {
if (!workspaceSessionReady) return
if (!activeWorktreeId) {
initialTabCreationGuardRef.current = null
return
@ -51,7 +53,7 @@ export default function Terminal(): React.JSX.Element | null {
if (initialTabCreationGuardRef.current === activeWorktreeId) return
initialTabCreationGuardRef.current = activeWorktreeId
createTab(activeWorktreeId)
}, [activeWorktreeId, tabs.length, createTab])
}, [workspaceSessionReady, activeWorktreeId, tabs.length, createTab])
// Ensure activeTabId is valid
useEffect(() => {

View file

@ -19,6 +19,11 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
TerminalPaneSplitDirection
} from '../../../shared/types'
import { useAppStore } from '../store'
type PtyTransport = {
@ -47,6 +52,11 @@ type PtyTransport = {
}
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
const EMPTY_LAYOUT: TerminalLayoutSnapshot = {
root: null,
activeLeafId: null,
expandedLeafId: null
}
const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g
@ -155,6 +165,102 @@ function createIpcPtyTransport(
}
}
function paneLeafId(paneId: number): string {
return `pane:${paneId}`
}
function getLayoutChildNodes(split: HTMLElement): HTMLElement[] {
return Array.from(split.children).filter(
(child): child is HTMLElement =>
child instanceof HTMLElement &&
(child.classList.contains('pane') || child.classList.contains('pane-split'))
)
}
function serializePaneTree(node: HTMLElement | null): TerminalPaneLayoutNode | null {
if (!node) return null
if (node.classList.contains('pane')) {
const paneId = Number(node.dataset.paneId ?? '')
if (!Number.isFinite(paneId)) return null
return { type: 'leaf', leafId: paneLeafId(paneId) }
}
if (!node.classList.contains('pane-split')) return null
const [first, second] = getLayoutChildNodes(node)
const firstNode = serializePaneTree(first ?? null)
const secondNode = serializePaneTree(second ?? null)
if (!firstNode || !secondNode) return null
return {
type: 'split',
direction: node.classList.contains('is-horizontal') ? 'horizontal' : 'vertical',
first: firstNode,
second: secondNode
}
}
function serializeTerminalLayout(
root: HTMLDivElement | null,
activePaneId: number | null,
expandedPaneId: number | null
): TerminalLayoutSnapshot {
const rootNode = serializePaneTree(
root?.firstElementChild instanceof HTMLElement ? root.firstElementChild : null
)
return {
root: rootNode,
activeLeafId: activePaneId === null ? null : paneLeafId(activePaneId),
expandedLeafId: expandedPaneId === null ? null : paneLeafId(expandedPaneId)
}
}
function replayTerminalLayout(
restty: Restty,
snapshot: TerminalLayoutSnapshot | null | undefined,
focusInitialPane: boolean
): Map<string, number> {
const paneByLeafId = new Map<string, number>()
const initialPane = restty.createInitialPane({ focus: focusInitialPane })
if (!snapshot?.root) {
paneByLeafId.set(paneLeafId(initialPane.id), initialPane.id)
return paneByLeafId
}
const restoreNode = (node: TerminalPaneLayoutNode, paneId: number): void => {
if (node.type === 'leaf') {
paneByLeafId.set(node.leafId, paneId)
return
}
const createdPane = restty.splitPane(paneId, node.direction as TerminalPaneSplitDirection)
if (!createdPane) {
collectLeafIds(node, paneByLeafId, paneId)
return
}
restoreNode(node.first, paneId)
restoreNode(node.second, createdPane.id)
}
restoreNode(snapshot.root, initialPane.id)
return paneByLeafId
}
function collectLeafIds(
node: TerminalPaneLayoutNode,
paneByLeafId: Map<string, number>,
paneId: number
): void {
if (node.type === 'leaf') {
paneByLeafId.set(node.leafId, paneId)
return
}
collectLeafIds(node.first, paneByLeafId, paneId)
collectLeafIds(node.second, paneByLeafId, paneId)
}
interface TerminalPaneProps {
tabId: string
cwd?: string
@ -181,11 +287,23 @@ export default function TerminalPane({
const [expandedPaneId, setExpandedPaneId] = useState<number | null>(null)
const setTabPaneExpanded = useAppStore((s) => s.setTabPaneExpanded)
const setTabCanExpandPane = useAppStore((s) => s.setTabCanExpandPane)
const savedLayout = useAppStore((s) => s.terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT)
const setTabLayout = useAppStore((s) => s.setTabLayout)
const initialLayoutRef = useRef(savedLayout)
const persistLayoutSnapshot = (): void => {
const restty = resttyRef.current
const container = containerRef.current
if (!restty || !container) return
const activePaneId = restty.getActivePane()?.id ?? restty.getPanes()[0]?.id ?? null
setTabLayout(tabId, serializeTerminalLayout(container, activePaneId, expandedPaneIdRef.current))
}
const setExpandedPane = (paneId: number | null): void => {
expandedPaneIdRef.current = paneId
setExpandedPaneId(paneId)
setTabPaneExpanded(tabId, paneId !== null)
persistLayoutSnapshot()
}
const rememberPaneStyle = (
@ -285,6 +403,7 @@ export default function TerminalPane({
setExpandedPane(null)
restoreExpandedLayout()
refreshPaneSizes(true)
persistLayoutSnapshot()
return
}
@ -292,10 +411,12 @@ export default function TerminalPane({
if (!applyExpandedLayout(paneId)) {
setExpandedPane(null)
restoreExpandedLayout()
persistLayoutSnapshot()
return
}
restty.setActivePane(paneId, { focus: true })
refreshPaneSizes(true)
persistLayoutSnapshot()
}
useEffect(() => {
@ -342,6 +463,8 @@ export default function TerminalPane({
updateTabPtyId(tabId, ptyId)
}
let shouldPersistLayout = false
const restty = new Restty({
root: container,
createInitialPane: false,
@ -389,18 +512,42 @@ export default function TerminalPane({
queueResizeAll(true)
},
onPaneClosed: () => {},
onActivePaneChange: () => {},
onActivePaneChange: () => {
if (shouldPersistLayout) persistLayoutSnapshot()
},
onLayoutChanged: () => {
syncExpandedLayout()
syncCanExpandState()
queueResizeAll(false)
if (shouldPersistLayout) persistLayoutSnapshot()
}
})
restty.createInitialPane({ focus: isActive })
resttyRef.current = restty
const restoredPaneByLeafId = replayTerminalLayout(restty, initialLayoutRef.current, isActive)
const restoredActivePaneId =
(initialLayoutRef.current.activeLeafId
? restoredPaneByLeafId.get(initialLayoutRef.current.activeLeafId)
: null) ??
restty.getActivePane()?.id ??
restty.getPanes()[0]?.id ??
null
if (restoredActivePaneId !== null) {
restty.setActivePane(restoredActivePaneId, { focus: isActive })
}
const restoredExpandedPaneId = initialLayoutRef.current.expandedLeafId
? (restoredPaneByLeafId.get(initialLayoutRef.current.expandedLeafId) ?? null)
: null
if (restoredExpandedPaneId !== null && restty.getPanes().length > 1) {
setExpandedPane(restoredExpandedPaneId)
applyExpandedLayout(restoredExpandedPaneId)
} else {
setExpandedPane(null)
}
shouldPersistLayout = true
syncCanExpandState()
queueResizeAll(isActive)
persistLayoutSnapshot()
return () => {
if (resizeRaf !== null) cancelAnimationFrame(resizeRaf)

View file

@ -181,7 +181,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
aria-label={worktree.isUnread ? 'Mark as read' : 'Mark as unread'}
>
{worktree.isUnread ? (
<FilledBellIcon className="size-3 text-yellow-400 drop-shadow-[0_0_4px_rgba(250,204,21,0.55)]" />
<FilledBellIcon className="size-3 text-yellow-400" />
) : (
<Bell className="size-3 text-muted-foreground/80 opacity-0 group-hover:opacity-100 group-hover/unread:opacity-100 transition-opacity" />
)}

View file

@ -10,7 +10,7 @@ export interface RepoSlice {
removeRepo: (repoId: string) => Promise<void>
updateRepo: (
repoId: string,
updates: Partial<Pick<Repo, 'displayName' | 'badgeColor'>>
updates: Partial<Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings'>>
) => Promise<void>
setActiveRepo: (repoId: string | null) => void
}
@ -60,14 +60,19 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
const nextWorktrees = { ...s.worktreesByRepo }
delete nextWorktrees[repoId]
const nextTabs = { ...s.tabsByWorktree }
const nextLayouts = { ...s.terminalLayoutsByTabId }
for (const wId of worktreeIds) {
delete nextTabs[wId]
}
for (const tabId of killedTabIds) {
delete nextLayouts[tabId]
}
return {
repos: s.repos.filter((r) => r.id !== repoId),
activeRepoId: s.activeRepoId === repoId ? null : s.activeRepoId,
worktreesByRepo: nextWorktrees,
tabsByWorktree: nextTabs,
terminalLayoutsByTabId: nextLayouts,
activeTabId: s.activeTabId && killedTabIds.has(s.activeTabId) ? null : s.activeTabId
}
})

View file

@ -1,12 +1,18 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { TerminalTab } from '../../../../shared/types'
import type {
TerminalLayoutSnapshot,
TerminalTab,
WorkspaceSessionState
} from '../../../../shared/types'
export interface TerminalSlice {
tabsByWorktree: Record<string, TerminalTab[]>
activeTabId: string | null
expandedPaneByTabId: Record<string, boolean>
canExpandPaneByTabId: Record<string, boolean>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
workspaceSessionReady: boolean
createTab: (worktreeId: string) => TerminalTab
closeTab: (tabId: string) => void
reorderTabs: (worktreeId: string, tabIds: string[]) => void
@ -17,6 +23,8 @@ export interface TerminalSlice {
updateTabPtyId: (tabId: string, ptyId: string) => void
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
setTabCanExpandPane: (tabId: string, canExpand: boolean) => void
setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void
hydrateWorkspaceSession: (session: WorkspaceSessionState) => void
}
export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice> = (set) => ({
@ -24,6 +32,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
activeTabId: null,
expandedPaneByTabId: {},
canExpandPaneByTabId: {},
terminalLayoutsByTabId: {},
workspaceSessionReady: false,
createTab: (worktreeId) => {
const id = globalThis.crypto.randomUUID()
@ -45,7 +55,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
...s.tabsByWorktree,
[worktreeId]: [...existing, tab]
},
activeTabId: tab.id
activeTabId: tab.id,
terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tab.id]: emptyLayoutSnapshot() }
}
})
return tab
@ -65,11 +76,14 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
delete nextExpanded[tabId]
const nextCanExpand = { ...s.canExpandPaneByTabId }
delete nextCanExpand[tabId]
const nextLayouts = { ...s.terminalLayoutsByTabId }
delete nextLayouts[tabId]
return {
tabsByWorktree: next,
activeTabId: s.activeTabId === tabId ? null : s.activeTabId,
expandedPaneByTabId: nextExpanded,
canExpandPaneByTabId: nextCanExpand
canExpandPaneByTabId: nextCanExpand,
terminalLayoutsByTabId: nextLayouts
}
})
},
@ -142,5 +156,74 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
set((s) => ({
canExpandPaneByTabId: { ...s.canExpandPaneByTabId, [tabId]: canExpand }
}))
},
setTabLayout: (tabId, layout) => {
set((s) => {
const next = { ...s.terminalLayoutsByTabId }
if (layout) next[tabId] = layout
else delete next[tabId]
return { terminalLayoutsByTabId: next }
})
},
hydrateWorkspaceSession: (session) => {
set((s) => {
const validWorktreeIds = new Set(
Object.values(s.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
)
const tabsByWorktree: Record<string, TerminalTab[]> = Object.fromEntries(
Object.entries(session.tabsByWorktree)
.filter(([worktreeId]) => validWorktreeIds.has(worktreeId))
.map(([worktreeId, tabs]) => [
worktreeId,
[...tabs]
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
.map((tab, index) => ({
...tab,
ptyId: null,
sortOrder: index
}))
])
.filter(([, tabs]) => tabs.length > 0)
)
const validTabIds = new Set(
Object.values(tabsByWorktree)
.flat()
.map((tab) => tab.id)
)
const activeWorktreeId =
session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)
? session.activeWorktreeId
: null
const activeTabId =
session.activeTabId && validTabIds.has(session.activeTabId) ? session.activeTabId : null
const activeRepoId =
session.activeRepoId && s.repos.some((repo) => repo.id === session.activeRepoId)
? session.activeRepoId
: null
return {
activeRepoId,
activeWorktreeId,
activeTabId,
tabsByWorktree,
terminalLayoutsByTabId: Object.fromEntries(
Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => validTabIds.has(tabId))
),
workspaceSessionReady: true
}
})
}
})
function emptyLayoutSnapshot(): TerminalLayoutSnapshot {
return {
root: null,
activeLeafId: null,
expandedLeafId: null
}
}

View file

@ -67,9 +67,14 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
const nextTabs = { ...s.tabsByWorktree }
delete nextTabs[worktreeId]
const nextLayouts = { ...s.terminalLayoutsByTabId }
for (const tabId of tabIds) {
delete nextLayouts[tabId]
}
return {
worktreesByRepo: next,
tabsByWorktree: nextTabs,
terminalLayoutsByTabId: nextLayouts,
activeWorktreeId: s.activeWorktreeId === worktreeId ? null : s.activeWorktreeId,
activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId
}

View file

@ -1,4 +1,9 @@
import type { GlobalSettings, PersistedState } from './types'
import type {
GlobalSettings,
PersistedState,
RepoHookSettings,
WorkspaceSessionState
} from './types'
export const SCHEMA_VERSION = 1
@ -25,6 +30,16 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
}
}
export function getDefaultRepoHookSettings(): RepoHookSettings {
return {
mode: 'auto',
scripts: {
setup: '',
archive: ''
}
}
}
export function getDefaultPersistedState(homedir: string): PersistedState {
return {
schemaVersion: SCHEMA_VERSION,
@ -36,6 +51,17 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
lastActiveWorktreeId: null,
sidebarWidth: 280
},
githubCache: { pr: {}, issue: {} }
githubCache: { pr: {}, issue: {} },
workspaceSession: getDefaultWorkspaceSession()
}
}
export function getDefaultWorkspaceSession(): WorkspaceSessionState {
return {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
}
}

View file

@ -5,6 +5,7 @@ export interface Repo {
displayName: string
badgeColor: string
addedAt: number
hookSettings?: RepoHookSettings
}
// ─── Worktree (git-level) ────────────────────────────────────────────
@ -51,6 +52,34 @@ export interface TerminalTab {
createdAt: number
}
export type TerminalPaneSplitDirection = 'vertical' | 'horizontal'
export type TerminalPaneLayoutNode =
| {
type: 'leaf'
leafId: string
}
| {
type: 'split'
direction: TerminalPaneSplitDirection
first: TerminalPaneLayoutNode
second: TerminalPaneLayoutNode
}
export interface TerminalLayoutSnapshot {
root: TerminalPaneLayoutNode | null
activeLeafId: string | null
expandedLeafId: string | null
}
export interface WorkspaceSessionState {
activeRepoId: string | null
activeWorktreeId: string | null
activeTabId: string | null
tabsByWorktree: Record<string, TerminalTab[]>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
}
// ─── GitHub ──────────────────────────────────────────────────────────
export type PRState = 'open' | 'closed' | 'merged' | 'draft'
export type IssueState = 'open' | 'closed'
@ -81,6 +110,14 @@ export interface OrcaHooks {
}
}
export interface RepoHookSettings {
mode: 'auto' | 'override'
scripts: {
setup: string
archive: string
}
}
// ─── Settings ────────────────────────────────────────────────────────
export interface GlobalSettings {
workspaceDir: string
@ -107,4 +144,5 @@ export interface PersistedState {
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
}
workspaceSession: WorkspaceSessionState
}