mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
state save + settings
This commit is contained in:
parent
4cadfaa031
commit
8446d25440
18 changed files with 1029 additions and 300 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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
13
src/main/ipc/session.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
11
src/preload/index.d.ts
vendored
11
src/preload/index.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue