Break up large files into well-organized modules (#29)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-03-22 15:22:55 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 8d95548c44
commit b1a5b0fc0d
31 changed files with 4971 additions and 4285 deletions
+24 -11
View File
@@ -8,7 +8,7 @@ import { useIpcEvents } from './hooks/useIpcEvents'
import Sidebar from './components/Sidebar'
import Terminal from './components/Terminal'
import Landing from './components/Landing'
import Settings from './components/Settings'
import Settings from './components/settings/Settings'
import RightSidebar from './components/right-sidebar'
function App(): React.JSX.Element {
@@ -100,7 +100,9 @@ function App(): React.JSX.Element {
])
useEffect(() => {
if (!workspaceSessionReady) return
if (!workspaceSessionReady) {
return
}
const timer = window.setTimeout(() => {
void window.api.session.set({
@@ -123,7 +125,9 @@ function App(): React.JSX.Element {
])
useEffect(() => {
if (!persistedUIReady) return
if (!persistedUIReady) {
return
}
const timer = window.setTimeout(() => {
void window.api.ui.set({
@@ -139,7 +143,9 @@ function App(): React.JSX.Element {
// Apply theme to document
useEffect(() => {
if (!settings) return
if (!settings) {
return
}
const applyTheme = (dark: boolean): void => {
document.documentElement.classList.toggle('dark', dark)
@@ -147,10 +153,10 @@ function App(): React.JSX.Element {
if (settings.theme === 'dark') {
applyTheme(true)
return
return undefined
} else if (settings.theme === 'light') {
applyTheme(false)
return
return undefined
} else {
// system
const mq = window.matchMedia('(prefers-color-scheme: dark)')
@@ -178,7 +184,9 @@ function App(): React.JSX.Element {
const showSidebar = activeView !== 'settings'
const handleToggleExpand = (): void => {
if (!effectiveActiveTabId) return
if (!effectiveActiveTabId) {
return
}
window.dispatchEvent(
new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, {
detail: { tabId: effectiveActiveTabId }
@@ -188,12 +196,18 @@ function App(): React.JSX.Element {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
if (e.repeat) return
if (!e.metaKey) return
if (e.repeat) {
return
}
if (!e.metaKey) {
return
}
// Cmd+N — create worktree
if (!e.ctrlKey && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'n') {
if (repos.length === 0) return
if (repos.length === 0) {
return
}
e.preventDefault()
openModal('create-worktree')
return
@@ -212,7 +226,6 @@ function App(): React.JSX.Element {
e.preventDefault()
setRightSidebarTab('source-control')
setRightSidebarOpen(true)
return
}
}
File diff suppressed because it is too large Load Diff
+47 -18
View File
@@ -10,8 +10,8 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import TabBar from './TabBar'
import TerminalPane from './TerminalPane'
import TabBar from './tab-bar/TabBar'
import TerminalPane from './terminal-pane/TerminalPane'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
@@ -61,9 +61,13 @@ export default function Terminal(): React.JSX.Element | null {
)
const handleSaveDialogSave = useCallback(async () => {
if (!saveDialogFileId) return
if (!saveDialogFileId) {
return
}
const file = useAppStore.getState().openFiles.find((f) => f.id === saveDialogFileId)
if (!file) return
if (!file) {
return
}
// EditorPanel stores edit buffers internally — we need to read the current content from the editor.
// The simplest approach: dispatch a custom event that the MonacoEditor listens for to trigger save,
// then close. But that's complex. Instead, just save via the editor ref approach.
@@ -77,7 +81,9 @@ export default function Terminal(): React.JSX.Element | null {
}, [saveDialogFileId])
const handleSaveDialogDiscard = useCallback(() => {
if (!saveDialogFileId) return
if (!saveDialogFileId) {
return
}
markFileDirty(saveDialogFileId, false)
closeFile(saveDialogFileId)
setSaveDialogFileId(null)
@@ -104,7 +110,9 @@ export default function Terminal(): React.JSX.Element | null {
// Auto-create first tab when worktree activates
useEffect(() => {
if (!workspaceSessionReady) return
if (!workspaceSessionReady) {
return
}
if (!activeWorktreeId) {
initialTabCreationGuardRef.current = null
return
@@ -119,7 +127,9 @@ export default function Terminal(): React.JSX.Element | null {
// In React StrictMode (dev), mount effects are intentionally invoked twice.
// Track the worktree we already initialized so we only create one first tab.
if (initialTabCreationGuardRef.current === activeWorktreeId) return
if (initialTabCreationGuardRef.current === activeWorktreeId) {
return
}
initialTabCreationGuardRef.current = activeWorktreeId
createTab(activeWorktreeId)
}, [workspaceSessionReady, activeWorktreeId, tabs.length, createTab])
@@ -127,13 +137,17 @@ export default function Terminal(): React.JSX.Element | null {
const totalTabs = tabs.length + openFiles.length
const handleNewTab = useCallback(() => {
if (!activeWorktreeId) return
if (!activeWorktreeId) {
return
}
createTab(activeWorktreeId)
}, [activeWorktreeId, createTab])
const handleCloseTab = useCallback(
(tabId: string) => {
if (!activeWorktreeId) return
if (!activeWorktreeId) {
return
}
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
if (currentTabs.length <= 1) {
// Last tab - deactivate worktree
@@ -146,7 +160,9 @@ export default function Terminal(): React.JSX.Element | null {
if (tabId === useAppStore.getState().activeTabId) {
const idx = currentTabs.findIndex((t) => t.id === tabId)
const nextTab = currentTabs[idx + 1] ?? currentTabs[idx - 1]
if (nextTab) setActiveTab(nextTab.id)
if (nextTab) {
setActiveTab(nextTab.id)
}
}
closeTab(tabId)
},
@@ -155,7 +171,9 @@ export default function Terminal(): React.JSX.Element | null {
const handlePtyExit = useCallback(
(tabId: string, ptyId: string) => {
if (consumeSuppressedPtyExit(ptyId)) return
if (consumeSuppressedPtyExit(ptyId)) {
return
}
handleCloseTab(tabId)
},
[consumeSuppressedPtyExit, handleCloseTab]
@@ -163,7 +181,9 @@ export default function Terminal(): React.JSX.Element | null {
const handleCloseOthers = useCallback(
(tabId: string) => {
if (!activeWorktreeId) return
if (!activeWorktreeId) {
return
}
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
setActiveTab(tabId)
for (const tab of currentTabs) {
@@ -177,10 +197,14 @@ export default function Terminal(): React.JSX.Element | null {
const handleCloseTabsToRight = useCallback(
(tabId: string) => {
if (!activeWorktreeId) return
if (!activeWorktreeId) {
return
}
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
const index = currentTabs.findIndex((t) => t.id === tabId)
if (index === -1) return
if (index === -1) {
return
}
const rightTabs = currentTabs.slice(index + 1)
for (const tab of rightTabs) {
closeTab(tab.id)
@@ -213,7 +237,9 @@ export default function Terminal(): React.JSX.Element | null {
// Keyboard shortcuts
useEffect(() => {
if (!activeWorktreeId) return
if (!activeWorktreeId) {
return
}
const onKeyDown = (e: KeyboardEvent): void => {
// Cmd+T - new tab
@@ -262,7 +288,6 @@ export default function Terminal(): React.JSX.Element | null {
state.setActiveTabType('editor')
}
}
return
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
@@ -281,7 +306,9 @@ export default function Terminal(): React.JSX.Element | null {
return () => window.removeEventListener('beforeunload', handler)
}, [])
if (!activeWorktreeId) return null
if (!activeWorktreeId) {
return null
}
return (
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
@@ -367,7 +394,9 @@ export default function Terminal(): React.JSX.Element | null {
<Dialog
open={saveDialogFileId !== null}
onOpenChange={(open) => {
if (!open) handleSaveDialogCancel()
if (!open) {
handleSaveDialogCancel()
}
}}
>
<DialogContent className="max-w-sm">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
import type { GlobalSettings } from '../../../../shared/types'
import { Separator } from '../ui/separator'
import { UIZoomControl } from './UIZoomControl'
type AppearancePaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
applyTheme: (theme: 'system' | 'dark' | 'light') => void
}
export function AppearancePane({
settings,
updateSettings,
applyTheme
}: AppearancePaneProps): React.JSX.Element {
return (
<div className="space-y-8">
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Theme</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">
<div className="space-y-1">
<h2 className="text-sm font-semibold">UI Zoom</h2>
<p className="text-xs text-muted-foreground">
Scale the entire application interface. Use{' '}
<kbd className="rounded border px-1 py-0.5 text-[10px]">⌘+</kbd> /{' '}
<kbd className="rounded border px-1 py-0.5 text-[10px]">⌘-</kbd> when not in a terminal
pane.
</p>
</div>
<UIZoomControl />
</section>
</div>
)
}
@@ -0,0 +1,185 @@
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { Download, FolderOpen, Loader2, RefreshCw } from 'lucide-react'
import { useAppStore } from '../../store'
type GeneralPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
displayedGitUsername: string
}
export function GeneralPane({
settings,
updateSettings,
displayedGitUsername
}: GeneralPaneProps): React.JSX.Element {
const updateStatus = useAppStore((s) => s.updateStatus)
const handleBrowseWorkspace = async () => {
const path = await window.api.repos.pickFolder()
if (path) {
updateSettings({ workspaceDir: path })
}
}
return (
<div className="space-y-8">
<section className="space-y-4">
<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>
<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 px-1 py-2">
<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">
<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' || settings.branchPrefix === 'git-username') && (
<Input
value={
settings.branchPrefix === 'git-username'
? displayedGitUsername
: settings.branchPrefixCustom
}
onChange={(e) => updateSettings({ branchPrefixCustom: e.target.value })}
placeholder={
settings.branchPrefix === 'git-username'
? 'No git username configured'
: 'e.g. feature'
}
className="max-w-xs"
readOnly={settings.branchPrefix === 'git-username'}
/>
)}
</section>
<Separator />
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Updates</h2>
<p className="text-xs text-muted-foreground">Check for new versions of Orca.</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => window.api.updater.check()}
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
className="gap-2"
>
{updateStatus.state === 'checking' ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
Check for Updates
</Button>
{updateStatus.state === 'downloaded' ? (
<Button
variant="default"
size="sm"
onClick={() => window.api.updater.quitAndInstall()}
className="gap-2"
>
<Download className="size-3.5" />
Restart to Update ({updateStatus.version})
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{updateStatus.state === 'idle' && 'Updates are checked automatically on launch.'}
{updateStatus.state === 'checking' && 'Checking for updates...'}
{updateStatus.state === 'available' &&
`Version ${updateStatus.version} is available. Downloading...`}
{updateStatus.state === 'not-available' && 'You\u2019re on the latest version.'}
{updateStatus.state === 'downloading' && `Downloading update... ${updateStatus.percent}%`}
{updateStatus.state === 'downloaded' &&
`Version ${updateStatus.version} is ready to install.`}
{updateStatus.state === 'error' && `Update error: ${updateStatus.message}`}
</p>
</section>
</div>
)
}
@@ -0,0 +1,89 @@
import type { OrcaHooks, Repo } from '../../../../shared/types'
import { Label } from '../ui/label'
import type { HookName } from './SettingsConstants'
export 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>
)
}
@@ -0,0 +1,349 @@
import { useEffect, useState } from 'react'
import type { OrcaHooks, Repo, RepoHookSettings } from '../../../../shared/types'
import { REPO_COLORS } from '../../../../shared/constants'
import { ScrollArea } from '../ui/scroll-area'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { Trash2 } from 'lucide-react'
import { HookEditor } from './HookEditor'
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
import type { HookName } from './SettingsConstants'
type RepositoryPaneProps = {
repo: Repo
yamlHooks: OrcaHooks | null
updateRepo: (repoId: string, updates: Partial<Repo>) => void
removeRepo: (repoId: string) => void
}
export function RepositoryPane({
repo,
yamlHooks,
updateRepo,
removeRepo
}: RepositoryPaneProps): React.JSX.Element {
const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null)
const [defaultBaseRef, setDefaultBaseRef] = useState('origin/main')
const [baseRefQuery, setBaseRefQuery] = useState('')
const [baseRefResults, setBaseRefResults] = useState<string[]>([])
const [isSearchingBaseRefs, setIsSearchingBaseRefs] = useState(false)
useEffect(() => {
let stale = false
const loadDefaultBaseRef = async (repoId: string) => {
try {
const result = await window.api.repos.getBaseRefDefault({ repoId })
if (stale) {
return
}
setDefaultBaseRef(result)
} catch {
if (stale) {
return
}
setDefaultBaseRef('origin/main')
}
}
setBaseRefQuery('')
setBaseRefResults([])
void loadDefaultBaseRef(repo.id)
return () => {
stale = true
}
}, [repo.id])
useEffect(() => {
const trimmedQuery = baseRefQuery.trim()
if (trimmedQuery.length < 2) {
setBaseRefResults([])
setIsSearchingBaseRefs(false)
return
}
let stale = false
setIsSearchingBaseRefs(true)
const timer = window.setTimeout(() => {
void window.api.repos
.searchBaseRefs({
repoId: repo.id,
query: trimmedQuery,
limit: 20
})
.then((results) => {
if (!stale) {
setBaseRefResults(results)
}
})
.catch(() => {
if (!stale) {
setBaseRefResults([])
}
})
.finally(() => {
if (!stale) {
setIsSearchingBaseRefs(false)
}
})
}, 200)
return () => {
stale = true
window.clearTimeout(timer)
}
}, [repo.id, baseRefQuery])
const effectiveBaseRef = repo.worktreeBaseRef ?? defaultBaseRef
const handleRemoveRepo = (repoId: string) => {
if (confirmingRemove === repoId) {
removeRepo(repoId)
setConfirmingRemove(null)
return
}
setConfirmingRemove(repoId)
}
const updateSelectedRepoHookSettings = (
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
})
}
return (
<div className="space-y-8">
<section className="space-y-4">
<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>
<Button
variant={confirmingRemove === repo.id ? 'destructive' : 'outline'}
size="sm"
onClick={() => handleRemoveRepo(repo.id)}
onBlur={() => setConfirmingRemove(null)}
className="gap-2"
>
<Trash2 className="size-3.5" />
{confirmingRemove === repo.id ? 'Confirm Remove' : 'Remove Repo'}
</Button>
</div>
<div className="space-y-2">
<Label className="text-sm">Display Name</Label>
<Input
value={repo.displayName}
onChange={(e) =>
updateRepo(repo.id, {
displayName: e.target.value
})
}
className="h-9 text-sm"
/>
</div>
<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(repo.id, { badgeColor: color })}
className={`size-7 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-2 hover:ring-offset-background'
}`}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
</div>
<div className="space-y-2">
<Label className="text-sm">Default Worktree Base</Label>
<div className="rounded-xl border bg-background/80 p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-medium text-foreground">{effectiveBaseRef}</div>
<p className="text-xs text-muted-foreground">
{repo.worktreeBaseRef
? 'Pinned for this repo'
: `Following primary branch (${defaultBaseRef})`}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setBaseRefQuery('')
setBaseRefResults([])
updateRepo(repo.id, {
worktreeBaseRef: undefined
})
}}
disabled={!repo.worktreeBaseRef}
>
Use Primary
</Button>
</div>
<div className="mt-4 space-y-2">
<Input
value={baseRefQuery}
onChange={(e) => setBaseRefQuery(e.target.value)}
placeholder="Search branches by name..."
className="max-w-md"
/>
<p className="text-xs text-muted-foreground">Type at least 2 characters.</p>
</div>
{isSearchingBaseRefs ? (
<p className="mt-3 text-xs text-muted-foreground">Searching branches...</p>
) : null}
{!isSearchingBaseRefs && baseRefQuery.trim().length >= 2 ? (
baseRefResults.length > 0 ? (
<ScrollArea className="mt-3 h-48 rounded-md border">
<div className="p-1">
{baseRefResults.map((ref) => (
<button
key={ref}
onClick={() => {
setBaseRefQuery(ref)
setBaseRefResults([])
updateRepo(repo.id, {
worktreeBaseRef: ref
})
}}
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors hover:bg-muted/60 ${
repo.worktreeBaseRef === ref
? 'bg-accent text-accent-foreground'
: 'text-foreground'
}`}
>
<span className="truncate">{ref}</span>
{repo.worktreeBaseRef === ref ? (
<span className="text-[10px] uppercase tracking-[0.18em]">Current</span>
) : null}
</button>
))}
</div>
</ScrollArea>
) : (
<p className="mt-3 text-xs text-muted-foreground">No matching branches found.</p>
)
) : null}
</div>
<p className="text-xs text-muted-foreground">
New worktrees default to the repo primary branch unless you pin a different base here.
</p>
</div>
</section>
<Separator />
<section className="space-y-4">
<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={mode}
onClick={() => updateSelectedRepoHookSettings({ mode })}
className={`rounded-lg px-3 py-1.5 text-sm transition-colors ${
repo.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>
<div className="rounded-xl border border-dashed bg-muted/30 p-3 text-xs text-muted-foreground">
{yamlHooks ? (
<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) =>
yamlHooks.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>
<Separator />
<section className="space-y-4">
<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 className="space-y-4">
{(['setup', 'archive'] as HookName[]).map((hookName) => (
<HookEditor
key={hookName}
hookName={hookName}
repo={repo}
yamlHooks={yamlHooks}
onScriptChange={(script) =>
updateSelectedRepoHookSettings({
scripts: hookName === 'setup' ? { setup: script } : { archive: script }
})
}
/>
))}
</div>
</section>
</div>
)
}
@@ -0,0 +1,337 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import type { OrcaHooks } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { ScrollArea } from '../ui/scroll-area'
import { Button } from '../ui/button'
import { ArrowLeft, Palette, SlidersHorizontal, SquareTerminal } from 'lucide-react'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants'
import { GeneralPane } from './GeneralPane'
import { AppearancePane } from './AppearancePane'
import { TerminalPane } from './TerminalPane'
import { RepositoryPane } from './RepositoryPane'
function Settings(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const setActiveView = useAppStore((s) => s.setActiveView)
const repos = useAppStore((s) => s.repos)
const updateRepo = useAppStore((s) => s.updateRepo)
const removeRepo = useAppStore((s) => s.removeRepo)
const [selectedPane, setSelectedPane] = useState<'general' | 'appearance' | 'terminal' | 'repo'>(
'general'
)
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null)
const [repoHooksMap, setRepoHooksMap] = useState<
Record<string, { hasHooks: boolean; hooks: OrcaHooks | null }>
>({})
const [systemPrefersDark, setSystemPrefersDark] = useState(getSystemPrefersDark())
const [scrollbackMode, setScrollbackMode] = useState<'preset' | 'custom'>('preset')
const [prevSettings, setPrevSettings] = useState(settings)
const [terminalFontSuggestions, setTerminalFontSuggestions] = useState<string[]>(
getFallbackTerminalFonts()
)
const terminalFontsLoadedRef = useRef(false)
useEffect(() => {
fetchSettings()
}, [fetchSettings])
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (event: MediaQueryListEvent): void => {
setSystemPrefersDark(event.matches)
}
setSystemPrefersDark(media.matches)
media.addEventListener('change', handleChange)
return () => media.removeEventListener('change', handleChange)
}, [])
useEffect(() => {
if (selectedPane !== 'terminal' || terminalFontsLoadedRef.current) {
return
}
let stale = false
const loadFontSuggestions = async (): Promise<void> => {
try {
const fonts = await window.api.settings.listFonts()
if (stale || fonts.length === 0) {
return
}
terminalFontsLoadedRef.current = true
setTerminalFontSuggestions((prev) => Array.from(new Set([...fonts, ...prev])).slice(0, 320))
} catch {
// Fall back to curated cross-platform suggestions.
}
}
void loadFontSuggestions()
return () => {
stale = true
}
}, [selectedPane])
if (settings !== prevSettings) {
setPrevSettings(settings)
if (settings) {
const scrollbackMb = Math.max(1, Math.round(settings.terminalScrollbackBytes / 1_000_000))
setScrollbackMode(
SCROLLBACK_PRESETS_MB.includes(scrollbackMb as (typeof SCROLLBACK_PRESETS_MB)[number])
? 'preset'
: 'custom'
)
}
}
useEffect(() => {
let stale = false
const checkHooks = async () => {
const results = await Promise.all(
repos.map(async (repo) => {
try {
const result = await window.api.hooks.check({ repoId: repo.id })
return [repo.id, result] as const
} catch {
return [repo.id, { hasHooks: false, hooks: null }] as const
}
})
)
if (!stale) {
setRepoHooksMap(Object.fromEntries(results))
}
}
if (repos.length > 0) {
checkHooks()
} else {
setRepoHooksMap({})
}
return () => {
stale = true
}
}, [repos])
// Validate selectedRepoId against current repos (adjusting state during render)
if (repos.length === 0) {
if (selectedRepoId !== null) {
setSelectedRepoId(null)
if (selectedPane === 'repo') {
setSelectedPane('general')
}
}
} else if (!selectedRepoId || !repos.some((repo) => repo.id === selectedRepoId)) {
setSelectedRepoId(repos[0].id)
}
const applyTheme = useCallback((theme: 'system' | 'dark' | 'light') => {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else if (theme === 'light') {
root.classList.remove('dark')
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (prefersDark) {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
}, [])
const selectedRepo = repos.find((repo) => repo.id === selectedRepoId) ?? null
const selectedYamlHooks = selectedRepo ? (repoHooksMap[selectedRepo.id]?.hooks ?? null) : null
const showGeneralPane = selectedPane === 'general'
const showAppearancePane = selectedPane === 'appearance'
const showTerminalPane = selectedPane === 'terminal'
const showRepoPane = selectedPane === 'repo' && !!selectedRepo
const displayedGitUsername = (selectedRepo ?? repos[0])?.gitUsername ?? ''
if (!settings) {
return (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
Loading settings...
</div>
)
}
const contentClassName = 'w-full max-w-5xl px-8'
const pageHeader = showGeneralPane ? (
<div className="space-y-1">
<h1 className="text-2xl font-semibold">General</h1>
<p className="text-sm text-muted-foreground">Workspace, naming, and updates.</p>
</div>
) : showAppearancePane ? (
<div className="space-y-1">
<h1 className="text-2xl font-semibold">Appearance</h1>
<p className="text-sm text-muted-foreground">Theme and UI scaling.</p>
</div>
) : showTerminalPane ? (
<div className="space-y-1">
<h1 className="text-2xl font-semibold">Terminal</h1>
<p className="text-sm text-muted-foreground">
Terminal appearance, previews, and defaults for new panes.
</p>
</div>
) : selectedRepo ? (
<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 className="space-y-1">
<h1 className="text-2xl font-semibold">Repository Settings</h1>
<p className="text-sm text-muted-foreground">Select a repository to edit its settings.</p>
</div>
)
return (
<div className="settings-view-shell flex min-h-0 flex-1 overflow-hidden bg-background">
<aside className="flex w-[260px] shrink-0 flex-col border-r border-border/50 bg-card/40">
<div className="border-b border-border/50 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>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 px-3 py-4">
<div className="space-y-1">
<button
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'
}`}
>
<SlidersHorizontal className="mr-2 size-4" />
General
</button>
<button
onClick={() => setSelectedPane('appearance')}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
showAppearancePane
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<Palette className="mr-2 size-4" />
Appearance
</button>
<button
onClick={() => setSelectedPane('terminal')}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
showTerminalPane
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<SquareTerminal className="mr-2 size-4" />
Terminal
</button>
</div>
<div className="space-y-2">
<p className="px-3 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
Repositories
</p>
{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 ${
showRepoPane && 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>
</div>
</ScrollArea>
</aside>
<div className="flex min-h-0 flex-1 flex-col">
<div className="sticky top-0 z-10 border-b border-border/50 bg-background/95 py-6 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className={contentClassName}>{pageHeader}</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className={`${contentClassName} py-8`}>
{showGeneralPane ? (
<GeneralPane
settings={settings}
updateSettings={updateSettings}
displayedGitUsername={displayedGitUsername}
/>
) : showAppearancePane ? (
<AppearancePane
settings={settings}
updateSettings={updateSettings}
applyTheme={applyTheme}
/>
) : showTerminalPane ? (
<TerminalPane
settings={settings}
updateSettings={updateSettings}
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
scrollbackMode={scrollbackMode}
setScrollbackMode={setScrollbackMode}
/>
) : selectedRepo ? (
<RepositoryPane
repo={selectedRepo}
yamlHooks={selectedYamlHooks}
updateRepo={updateRepo}
removeRepo={removeRepo}
/>
) : (
<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>
</div>
)
}
export default Settings
@@ -0,0 +1,41 @@
import type { OrcaHooks } from '../../../../shared/types'
import { getDefaultRepoHookSettings } from '../../../../shared/constants'
export type HookName = keyof OrcaHooks['scripts']
export const DEFAULT_REPO_HOOK_SETTINGS = getDefaultRepoHookSettings()
export const MAX_THEME_RESULTS = 80
export const MAX_FONT_RESULTS = 12
export const SCROLLBACK_PRESETS_MB = [10, 25, 50, 100, 250] as const
export const ZOOM_STEP = 0.5
export const ZOOM_MIN = -3
export const ZOOM_MAX = 5
export function zoomLevelToPercent(level: number): number {
return Math.round(100 * Math.pow(1.2, level))
}
export function getFallbackTerminalFonts(): string[] {
const nav =
typeof navigator !== 'undefined'
? (navigator as Navigator & { userAgentData?: { platform?: string } })
: null
const platform = nav ? (nav.userAgentData?.platform ?? nav.platform ?? '') : ''
const normalizedPlatform = platform.toLowerCase()
if (normalizedPlatform.includes('mac')) {
return ['SF Mono', 'Menlo', 'Monaco', 'JetBrains Mono', 'Fira Code']
}
if (normalizedPlatform.includes('win')) {
return ['Cascadia Mono', 'Consolas', 'Lucida Console', 'JetBrains Mono', 'Fira Code']
}
return [
'JetBrains Mono',
'Fira Code',
'DejaVu Sans Mono',
'Liberation Mono',
'Ubuntu Mono',
'Noto Sans Mono'
]
}
@@ -0,0 +1,303 @@
import { useEffect, useState, useMemo, useRef } from 'react'
import { ScrollArea } from '../ui/scroll-area'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Check, ChevronsUpDown, CircleX } from 'lucide-react'
import { BUILTIN_TERMINAL_THEME_NAMES, normalizeColor } from '@/lib/terminal-theme'
import { MAX_THEME_RESULTS, MAX_FONT_RESULTS } from './SettingsConstants'
type ThemePickerProps = {
label: string
description: string
selectedTheme: string
query: string
onQueryChange: (value: string) => void
onSelectTheme: (theme: string) => void
}
type ColorFieldProps = {
label: string
description: string
value: string
fallback: string
onChange: (value: string) => void
}
type NumberFieldProps = {
label: string
description: string
value: number
defaultValue?: number
min: number
max: number
step?: number
onChange: (value: number) => void
suffix?: string
}
type FontAutocompleteProps = {
value: string
suggestions: string[]
onChange: (value: string) => void
}
export function ThemePicker({
label,
description,
selectedTheme,
query,
onQueryChange,
onSelectTheme
}: ThemePickerProps): React.JSX.Element {
const normalizedQuery = query.trim().toLowerCase()
const filteredThemes = BUILTIN_TERMINAL_THEME_NAMES.filter((theme) =>
theme.toLowerCase().includes(normalizedQuery)
).slice(0, MAX_THEME_RESULTS)
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-sm">{label}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<Input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search builtin themes"
/>
<div className="rounded-lg border">
<div className="flex items-center justify-between border-b px-3 py-2 text-xs text-muted-foreground">
<span>Selected: {selectedTheme}</span>
<span>
Showing {filteredThemes.length}
{normalizedQuery
? ` matching "${query.trim()}"`
: ` of ${BUILTIN_TERMINAL_THEME_NAMES.length}`}
</span>
</div>
<ScrollArea className="h-64">
<div className="space-y-1 p-2">
{filteredThemes.map((theme) => (
<button
key={theme}
onClick={() => onSelectTheme(theme)}
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors ${
selectedTheme === theme
? 'bg-accent font-medium text-accent-foreground'
: 'hover:bg-muted/60'
}`}
>
<span className="truncate">{theme}</span>
{selectedTheme === theme ? (
<span className="ml-3 shrink-0 text-[11px] uppercase tracking-[0.16em]">
Current
</span>
) : null}
</button>
))}
{filteredThemes.length === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">No themes found.</div>
) : null}
</div>
</ScrollArea>
</div>
</div>
)
}
export function ColorField({
label,
description,
value,
fallback,
onChange
}: ColorFieldProps): React.JSX.Element {
const normalized = normalizeColor(value, fallback)
return (
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-sm">{label}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<div className="flex items-center gap-3">
<input
type="color"
value={normalized}
onChange={(e) => onChange(e.target.value)}
className="h-9 w-12 rounded-md border border-input bg-transparent p-1"
/>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={fallback}
className="max-w-xs font-mono text-xs"
/>
</div>
</div>
)
}
export function NumberField({
label,
description,
value,
defaultValue,
min,
max,
step = 1,
onChange,
suffix
}: NumberFieldProps): React.JSX.Element {
return (
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-sm">{label}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<div className="flex items-center gap-3">
<Input
type="number"
min={min}
max={max}
step={step}
value={Number.isFinite(value) ? String(value) : ''}
onChange={(e) => {
const next = Number(e.target.value)
if (!Number.isFinite(next)) {
return
}
onChange(next)
}}
className="number-input-clean w-28 tabular-nums"
/>
{suffix ? <span className="text-xs text-muted-foreground">{suffix}</span> : null}
</div>
<p className="text-[11px] text-muted-foreground">
Current: {value}
{defaultValue !== undefined ? ` · Default: ${defaultValue}` : ''}
</p>
</div>
)
}
export function FontAutocomplete({
value,
suggestions,
onChange
}: FontAutocompleteProps): React.JSX.Element {
const [query, setQuery] = useState(value)
const [prevValue, setPrevValue] = useState(value)
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
if (value !== prevValue) {
setPrevValue(value)
setQuery(value)
}
useEffect(() => {
if (!open) {
return
}
const handlePointerDown = (event: MouseEvent): void => {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [open])
const normalizedQuery = query.trim().toLowerCase()
const filteredSuggestions = useMemo(() => {
const startsWith = suggestions.filter((font) => font.toLowerCase().startsWith(normalizedQuery))
const includes = suggestions.filter(
(font) =>
!font.toLowerCase().startsWith(normalizedQuery) &&
font.toLowerCase().includes(normalizedQuery)
)
const ordered = normalizedQuery ? [...startsWith, ...includes] : suggestions
return ordered.slice(0, MAX_FONT_RESULTS)
}, [suggestions, normalizedQuery])
const commitValue = (nextValue: string): void => {
setQuery(nextValue)
onChange(nextValue)
setOpen(false)
}
return (
<div ref={rootRef} className="relative max-w-sm">
<div className="relative">
<Input
value={query}
onChange={(e) => {
const next = e.target.value
setQuery(next)
onChange(next)
setOpen(true)
}}
onFocus={() => setOpen(true)}
placeholder="SF Mono"
className="pr-18"
/>
<div className="absolute inset-y-0 right-2 flex items-center gap-1">
{query ? (
<button
type="button"
onClick={() => {
setQuery('')
onChange('')
setOpen(true)
}}
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Clear font selection"
title="Clear"
>
<CircleX className="size-3.5" />
</button>
) : null}
<button
type="button"
onClick={() => setOpen((current) => !current)}
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Toggle font suggestions"
title="Fonts"
>
<ChevronsUpDown className="size-3.5" />
</button>
</div>
</div>
{open ? (
<div className="absolute top-full z-20 mt-2 w-full overflow-hidden rounded-md border bg-popover shadow-md">
<ScrollArea className="max-h-64">
<div className="p-1">
{filteredSuggestions.length > 0 ? (
filteredSuggestions.map((font) => (
<button
key={font}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => commitValue(font)}
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors ${
font === value ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60'
}`}
>
<span className="truncate">{font}</span>
{font === value ? <Check className="ml-3 size-4 shrink-0" /> : null}
</button>
))
) : (
<div className="px-3 py-3 text-sm text-muted-foreground">No matching fonts.</div>
)}
</div>
</ScrollArea>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,395 @@
import { useState } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'
import { TerminalThemePreview } from './TerminalThemePreview'
import { Minus, Plus } from 'lucide-react'
import {
clampNumber,
resolveEffectiveTerminalAppearance,
resolvePaneStyleOptions
} from '@/lib/terminal-theme'
import { ThemePicker, ColorField, NumberField, FontAutocomplete } from './SettingsFormControls'
import { SCROLLBACK_PRESETS_MB } from './SettingsConstants'
type TerminalPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
systemPrefersDark: boolean
terminalFontSuggestions: string[]
scrollbackMode: 'preset' | 'custom'
setScrollbackMode: (mode: 'preset' | 'custom') => void
}
export function TerminalPane({
settings,
updateSettings,
systemPrefersDark,
terminalFontSuggestions,
scrollbackMode,
setScrollbackMode
}: TerminalPaneProps): React.JSX.Element {
const [themeSearchDark, setThemeSearchDark] = useState('')
const [themeSearchLight, setThemeSearchLight] = useState('')
const darkPreviewAppearance = resolveEffectiveTerminalAppearance(
{ ...settings, theme: 'dark' },
systemPrefersDark
)
const lightPreviewAppearance = resolveEffectiveTerminalAppearance(
{ ...settings, theme: 'light' },
systemPrefersDark
)
const paneStyleOptions = resolvePaneStyleOptions(settings)
const scrollbackMb = Math.max(1, Math.round(settings.terminalScrollbackBytes / 1_000_000))
const isPreset = SCROLLBACK_PRESETS_MB.includes(
scrollbackMb as (typeof SCROLLBACK_PRESETS_MB)[number]
)
const scrollbackToggleValue =
scrollbackMode === 'custom' ? 'custom' : isPreset ? `${scrollbackMb}` : 'custom'
return (
<div className="space-y-8">
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Typography</h2>
<p className="text-xs text-muted-foreground">
Default terminal typography for new panes and live updates.
</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>
<FontAutocomplete
value={settings.terminalFontFamily}
suggestions={terminalFontSuggestions}
onChange={(value) => updateSettings({ terminalFontFamily: value })}
/>
</div>
</section>
<Separator />
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Cursor</h2>
<p className="text-xs text-muted-foreground">
Default cursor appearance for Orca terminal panes.
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm">Cursor Shape</Label>
<div className="flex w-fit gap-1 rounded-md border p-1">
{(['bar', 'block', 'underline'] as const).map((option) => (
<button
key={option}
onClick={() => updateSettings({ terminalCursorStyle: option })}
className={`rounded-sm px-3 py-1 text-sm capitalize transition-colors ${
settings.terminalCursorStyle === option
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{option}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between gap-4 px-1 py-2">
<div className="space-y-0.5">
<Label className="text-sm">Blinking Cursor</Label>
<p className="text-xs text-muted-foreground">
Uses the blinking variant of the selected cursor shape.
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalCursorBlink}
onClick={() =>
updateSettings({
terminalCursorBlink: !settings.terminalCursorBlink
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.terminalCursorBlink ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.terminalCursorBlink ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</div>
</section>
<Separator />
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Pane Styling</h2>
<p className="text-xs text-muted-foreground">
Control inactive pane dimming, divider thickness, and transition timing.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<NumberField
label="Inactive Pane Opacity"
description="Opacity applied to panes that are not currently active."
value={paneStyleOptions.inactivePaneOpacity}
defaultValue={0.8}
min={0}
max={1}
step={0.05}
suffix="0 to 1"
onChange={(value) =>
updateSettings({
terminalInactivePaneOpacity: clampNumber(value, 0, 1)
})
}
/>
<NumberField
label="Divider Thickness"
description="Thickness of the pane divider line."
value={paneStyleOptions.dividerThicknessPx}
defaultValue={1}
min={1}
max={32}
step={1}
suffix="px"
onChange={(value) =>
updateSettings({
terminalDividerThicknessPx: clampNumber(value, 1, 32)
})
}
/>
</div>
</section>
<Separator />
<section className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<ThemePicker
label="Dark Theme"
description="Choose the terminal theme used in dark mode."
selectedTheme={settings.terminalThemeDark}
query={themeSearchDark}
onQueryChange={setThemeSearchDark}
onSelectTheme={(theme) => updateSettings({ terminalThemeDark: theme })}
/>
<ColorField
label="Dark Divider Color"
description="Controls the split divider line between panes in dark mode."
value={settings.terminalDividerColorDark}
fallback="#3f3f46"
onChange={(value) => updateSettings({ terminalDividerColorDark: value })}
/>
</div>
<TerminalThemePreview
title="Dark Mode Preview"
description={
settings.theme === 'system'
? `System mode is currently ${systemPrefersDark ? 'Dark' : 'Light'}.`
: `Orca is currently in ${settings.theme} mode.`
}
appearance={darkPreviewAppearance}
dividerThicknessPx={paneStyleOptions.dividerThicknessPx}
inactivePaneOpacity={paneStyleOptions.inactivePaneOpacity}
activePaneOpacity={paneStyleOptions.activePaneOpacity}
/>
</section>
<Separator />
<section className="space-y-4">
<div className="flex items-center justify-between gap-4 px-1 py-2">
<div className="space-y-0.5">
<Label className="text-sm">Use Separate Theme In Light Mode</Label>
<p className="text-xs text-muted-foreground">
When disabled, light mode reuses the dark terminal theme.
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalUseSeparateLightTheme}
onClick={() =>
updateSettings({
terminalUseSeparateLightTheme: !settings.terminalUseSeparateLightTheme
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.terminalUseSeparateLightTheme ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.terminalUseSeparateLightTheme ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<div
className={`grid overflow-hidden transition-all duration-300 ease-out ${
settings.terminalUseSeparateLightTheme
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
}`}
>
<div className="min-h-0">
<div className="grid gap-6 pt-2 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<ThemePicker
label="Light Theme"
description="Choose the theme used when Orca is in light mode."
selectedTheme={settings.terminalThemeLight}
query={themeSearchLight}
onQueryChange={setThemeSearchLight}
onSelectTheme={(theme) => updateSettings({ terminalThemeLight: theme })}
/>
<ColorField
label="Light Divider Color"
description="Controls the split divider line between panes in light mode."
value={settings.terminalDividerColorLight}
fallback="#d4d4d8"
onChange={(value) => updateSettings({ terminalDividerColorLight: value })}
/>
</div>
<TerminalThemePreview
title="Light Mode Preview"
description="Updates live as you change the light theme or divider color."
appearance={lightPreviewAppearance}
dividerThicknessPx={paneStyleOptions.dividerThicknessPx}
inactivePaneOpacity={paneStyleOptions.inactivePaneOpacity}
activePaneOpacity={paneStyleOptions.activePaneOpacity}
/>
</div>
</div>
</div>
</section>
<Separator />
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Advanced</h2>
<p className="text-xs text-muted-foreground">
Scrollback is bounded for stability. This setting applies to new terminal panes.
</p>
</div>
<div className="space-y-3">
<Label className="text-sm">Scrollback Size</Label>
<ToggleGroup
type="single"
value={scrollbackToggleValue}
onValueChange={(value) => {
if (!value) {
return
}
if (value === 'custom') {
setScrollbackMode('custom')
return
}
setScrollbackMode('preset')
updateSettings({
terminalScrollbackBytes: Number(value) * 1_000_000
})
}}
variant="outline"
size="sm"
className="h-8 flex-wrap"
>
{SCROLLBACK_PRESETS_MB.map((preset) => (
<ToggleGroupItem
key={preset}
value={`${preset}`}
className="h-8 px-3 text-xs"
aria-label={`${preset} megabytes`}
>
{preset} MB
</ToggleGroupItem>
))}
<ToggleGroupItem value="custom" className="h-8 px-3 text-xs" aria-label="Custom">
Custom
</ToggleGroupItem>
</ToggleGroup>
{scrollbackMode === 'custom' ? (
<NumberField
label="Custom Scrollback"
description="Maximum terminal scrollback buffer size."
value={scrollbackMb}
defaultValue={10}
min={1}
max={256}
step={1}
suffix="MB"
onChange={(value) =>
updateSettings({
terminalScrollbackBytes: clampNumber(value, 1, 256) * 1_000_000
})
}
/>
) : null}
</div>
</section>
</div>
)
}
@@ -0,0 +1,56 @@
import { useEffect, useState, useCallback } from 'react'
import { Button } from '../ui/button'
import { Minus, Plus, RotateCcw } from 'lucide-react'
import { applyUIZoom } from '@/lib/ui-zoom'
import { ZOOM_STEP, ZOOM_MIN, ZOOM_MAX, zoomLevelToPercent } from './SettingsConstants'
export function UIZoomControl(): React.JSX.Element {
const [zoomLevel, setZoomLevel] = useState(() => window.api.ui.getZoomLevel())
useEffect(() => {
return window.api.ui.onTerminalZoom(() => {
setZoomLevel(window.api.ui.getZoomLevel())
})
}, [])
const applyZoom = useCallback((level: number) => {
const clamped = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, level))
applyUIZoom(clamped)
setZoomLevel(clamped)
window.api.ui.set({ uiZoomLevel: clamped })
}, [])
const percent = zoomLevelToPercent(zoomLevel)
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
onClick={() => applyZoom(zoomLevel - ZOOM_STEP)}
disabled={zoomLevel <= ZOOM_MIN}
>
<Minus className="size-3" />
</Button>
<span className="w-14 text-center text-sm tabular-nums text-foreground">{percent}%</span>
<Button
variant="outline"
size="icon-sm"
onClick={() => applyZoom(zoomLevel + ZOOM_STEP)}
disabled={zoomLevel >= ZOOM_MAX}
>
<Plus className="size-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => applyZoom(0)}
disabled={zoomLevel === 0}
className="ml-1 gap-1.5"
>
<RotateCcw className="size-3" />
Reset
</Button>
</div>
)
}
@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react'
import { X, FileCode, GitCompareArrows, Copy } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import type { OpenFile } from '../../store/slices/editor'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab'
export default function EditorFileTab({
file,
isActive,
editorFileCount,
onActivate,
onClose,
onCloseOthers,
onCloseAll
}: {
file: OpenFile
isActive: boolean
editorFileCount: number
onActivate: () => void
onClose: () => void
onCloseOthers: () => void
onCloseAll: () => void
}): React.JSX.Element {
const fileName = file.relativePath.split('/').pop() ?? file.relativePath
const isDiff = file.mode === 'diff'
const [menuOpen, setMenuOpen] = useState(false)
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
useEffect(() => {
const closeMenu = (): void => setMenuOpen(false)
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
return (
<>
<div
onContextMenuCapture={(event) => {
event.preventDefault()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
setMenuPoint({ x: event.clientX, y: event.clientY })
setMenuOpen(true)
}}
>
<div
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
isActive
? 'bg-background text-foreground border-b-transparent'
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
onClick={onActivate}
onMouseDown={(e) => {
if (e.button === 1) {
e.preventDefault()
e.stopPropagation()
onClose()
}
}}
>
{isDiff ? (
<GitCompareArrows className="w-3.5 h-3.5 mr-1.5 shrink-0 text-muted-foreground" />
) : (
<FileCode className="w-3.5 h-3.5 mr-1.5 shrink-0 text-muted-foreground" />
)}
{file.isDirty && (
<span className="mr-1 size-1.5 rounded-full bg-foreground/60 shrink-0" />
)}
<span className="truncate max-w-[130px] mr-1.5">
{isDiff
? file.relativePath === 'All Changes'
? 'All Changes'
: `${fileName} (diff${file.diffStaged ? ' staged' : ''})`
: fileName}
</span>
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
isActive
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
>
<X className="w-3 h-3" />
</button>
</div>
</div>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: menuPoint.x, top: menuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" sideOffset={0} align="start">
<DropdownMenuItem onSelect={onClose}>Close</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseOthers} disabled={editorFileCount <= 1}>
Close Others
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseAll}>Close All Editor Tabs</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
navigator.clipboard.writeText(file.filePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Path
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { X, Minimize2, Terminal as TerminalIcon } from 'lucide-react'
import { X, Terminal as TerminalIcon, Minimize2 } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,9 +19,9 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { TerminalTab } from '../../../shared/types'
import type { TerminalTab } from '../../../../shared/types'
export type SortableTabProps = {
type SortableTabProps = {
tab: TerminalTab
tabCount: number
hasTabsToRight: boolean
@@ -36,7 +36,7 @@ export type SortableTabProps = {
onToggleExpand: (tabId: string) => void
}
const TAB_COLORS = [
export const TAB_COLORS = [
{ label: 'None', value: null },
{ label: 'Blue', value: '#3b82f6' },
{ label: 'Purple', value: '#a855f7' },
@@ -49,9 +49,9 @@ const TAB_COLORS = [
{ label: 'Gray', value: '#9ca3af' }
]
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
export function SortableTab({
export default function SortableTab({
tab,
tabCount,
hasTabsToRight,
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
DndContext,
closestCenter,
@@ -8,134 +8,11 @@ import {
type DragEndEvent
} from '@dnd-kit/core'
import { SortableContext, horizontalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
import { X, Plus, FileCode, GitCompareArrows, Copy } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import type { TerminalTab } from '../../../shared/types'
import type { OpenFile } from '../store/slices/editor'
import { SortableTab } from './SortableTab'
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
function EditorFileTab({
file,
isActive,
editorFileCount,
onActivate,
onClose,
onCloseOthers,
onCloseAll
}: {
file: OpenFile
isActive: boolean
editorFileCount: number
onActivate: () => void
onClose: () => void
onCloseOthers: () => void
onCloseAll: () => void
}): React.JSX.Element {
const fileName = file.relativePath.split('/').pop() ?? file.relativePath
const isDiff = file.mode === 'diff'
const [menuOpen, setMenuOpen] = useState(false)
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
useEffect(() => {
const closeMenu = (): void => setMenuOpen(false)
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
return (
<>
<div
onContextMenuCapture={(event) => {
event.preventDefault()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
setMenuPoint({ x: event.clientX, y: event.clientY })
setMenuOpen(true)
}}
>
<div
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
isActive
? 'bg-background text-foreground border-b-transparent'
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
onClick={onActivate}
onMouseDown={(e) => {
if (e.button === 1) {
e.preventDefault()
e.stopPropagation()
onClose()
}
}}
>
{isDiff ? (
<GitCompareArrows className="w-3.5 h-3.5 mr-1.5 shrink-0 text-muted-foreground" />
) : (
<FileCode className="w-3.5 h-3.5 mr-1.5 shrink-0 text-muted-foreground" />
)}
{file.isDirty && (
<span className="mr-1 size-1.5 rounded-full bg-foreground/60 shrink-0" />
)}
<span className="truncate max-w-[130px] mr-1.5">
{isDiff
? file.relativePath === 'All Changes'
? 'All Changes'
: `${fileName} (diff${file.diffStaged ? ' staged' : ''})`
: fileName}
</span>
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
isActive
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
>
<X className="w-3 h-3" />
</button>
</div>
</div>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: menuPoint.x, top: menuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" sideOffset={0} align="start">
<DropdownMenuItem onSelect={onClose}>Close</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseOthers} disabled={editorFileCount <= 1}>
Close Others
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseAll}>Close All Editor Tabs</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
navigator.clipboard.writeText(file.filePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Path
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
import { Plus } from 'lucide-react'
import type { TerminalTab } from '../../../../shared/types'
import type { OpenFile } from '../../store/slices/editor'
import SortableTab from './SortableTab'
import EditorFileTab from './EditorFileTab'
type TabBarProps = {
tabs: TerminalTab[]
@@ -178,9 +55,8 @@ export default function TabBar({
activeTabType,
onActivateFile,
onCloseFile,
onCloseAllFiles: _onCloseAllFiles
onCloseAllFiles
}: TabBarProps): React.JSX.Element {
void _onCloseAllFiles
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 }
@@ -274,7 +150,7 @@ export default function TabBar({
onActivate={() => onActivateFile?.(file.id)}
onClose={() => onCloseFile?.(file.id)}
onCloseOthers={() => handleCloseOtherEditorFiles(file.id)}
onCloseAll={() => _onCloseAllFiles?.()}
onCloseAll={() => onCloseAllFiles?.()}
/>
))}
</div>
@@ -0,0 +1,129 @@
import {
Clipboard,
Copy,
Eraser,
Maximize2,
Minimize2,
PanelBottomOpen,
PanelRightOpen,
X
} from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
type TerminalContextMenuProps = {
open: boolean
onOpenChange: (open: boolean) => void
menuPoint: { x: number; y: number }
menuOpenedAtRef: React.RefObject<number>
canClosePane: boolean
canExpandPane: boolean
menuPaneIsExpanded: boolean
onCopy: () => void
onPaste: () => void
onSplitRight: () => void
onSplitDown: () => void
onClosePane: () => void
onClearScreen: () => void
onToggleExpand: () => void
}
export default function TerminalContextMenu({
open,
onOpenChange,
menuPoint,
menuOpenedAtRef,
canClosePane,
canExpandPane,
menuPaneIsExpanded,
onCopy,
onPaste,
onSplitRight,
onSplitDown,
onClosePane,
onClearScreen,
onToggleExpand
}: TerminalContextMenuProps): React.JSX.Element {
return (
<DropdownMenu
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && Date.now() - menuOpenedAtRef.current < 100) {
return
}
onOpenChange(nextOpen)
}}
modal={false}
>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none absolute size-px opacity-0"
style={{ left: menuPoint.x, top: menuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
sideOffset={0}
align="start"
onCloseAutoFocus={(e) => {
// Prevent Radix from moving focus back to the hidden trigger;
// let xterm keep focus naturally.
e.preventDefault()
}}
onFocusOutside={(e) => {
// xterm reclaims focus after the contextmenu event; don't let
// Radix treat that as a dismiss signal.
e.preventDefault()
}}
>
<DropdownMenuItem onSelect={onCopy}>
<Copy />
Copy
<DropdownMenuShortcut>⌘C</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onPaste}>
<Clipboard />
Paste
<DropdownMenuShortcut>⌘V</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onSplitRight}>
<PanelRightOpen />
Split Right
<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onSplitDown}>
<PanelBottomOpen />
Split Down
<DropdownMenuShortcut>⌘⇧D</DropdownMenuShortcut>
</DropdownMenuItem>
{canExpandPane && (
<DropdownMenuItem onSelect={onToggleExpand}>
{menuPaneIsExpanded ? <Minimize2 /> : <Maximize2 />}
{menuPaneIsExpanded ? 'Collapse Pane' : 'Expand Pane'}
<DropdownMenuShortcut>⌘⇧↩</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{canClosePane && (
<DropdownMenuItem variant="destructive" onSelect={onClosePane}>
<X />
Close Pane
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onClearScreen}>
<Eraser />
Clear Screen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,578 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { CSSProperties } from 'react'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { useAppStore } from '../../store'
import {
DEFAULT_TERMINAL_DIVIDER_DARK,
normalizeColor,
resolveEffectiveTerminalAppearance
} from '@/lib/terminal-theme'
import { PaneManager } from '@/lib/pane-manager/pane-manager'
import TerminalSearch from '@/components/TerminalSearch'
import type { PtyTransport } from './pty-transport'
import {
EMPTY_LAYOUT,
buildFontFamily,
serializeTerminalLayout,
replayTerminalLayout
} from './layout-serialization'
import {
createExpandCollapseActions,
restoreExpandedLayoutFrom,
applyExpandedLayoutTo
} from './expand-collapse'
import { useTerminalKeyboardShortcuts, useTerminalFontZoom } from './keyboard-handlers'
import { applyTerminalAppearance } from './terminal-appearance'
import { connectPanePty } from './pty-connection'
import TerminalContextMenu from './TerminalContextMenu'
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
type TerminalPaneProps = {
tabId: string
worktreeId: string
cwd?: string
isActive: boolean
onPtyExit: (ptyId: string) => void
}
export default function TerminalPane({
tabId,
worktreeId,
cwd,
isActive,
onPtyExit
}: TerminalPaneProps): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const managerRef = useRef<PaneManager | null>(null)
const contextPaneIdRef = useRef<number | null>(null)
const wasActiveRef = useRef(false)
const paneFontSizesRef = useRef<Map<number, number>>(new Map())
const expandedPaneIdRef = useRef<number | null>(null)
const expandedStyleSnapshotRef = useRef<Map<HTMLElement, { display: string; flex: string }>>(
new Map()
)
const paneTransportsRef = useRef<Map<number, PtyTransport>>(new Map())
const pendingWritesRef = useRef<Map<number, string>>(new Map())
const isActiveRef = useRef(isActive)
isActiveRef.current = isActive
const [terminalMenuOpen, setTerminalMenuOpen] = useState(false)
const [terminalMenuPoint, setTerminalMenuPoint] = useState({ x: 0, y: 0 })
const menuOpenedAtRef = useRef(0)
const [expandedPaneId, setExpandedPaneId] = useState<number | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
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 updateTabTitle = useAppStore((s) => s.updateTabTitle)
const updateTabPtyId = useAppStore((s) => s.updateTabPtyId)
const clearTabPtyId = useAppStore((s) => s.clearTabPtyId)
const markWorktreeUnreadFromBell = useAppStore((s) => s.markWorktreeUnreadFromBell)
const settings = useAppStore((s) => s.settings)
const settingsRef = useRef(settings)
settingsRef.current = settings
const onPtyExitRef = useRef(onPtyExit)
onPtyExitRef.current = onPtyExit
const [systemPrefersDark, setSystemPrefersDark] = useState(() =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: true
)
const persistLayoutSnapshot = (): void => {
const manager = managerRef.current
const container = containerRef.current
if (!manager || !container) {
return
}
const activePaneId = manager.getActivePane()?.id ?? manager.getPanes()[0]?.id ?? null
setTabLayout(tabId, serializeTerminalLayout(container, activePaneId, expandedPaneIdRef.current))
}
const {
setExpandedPane,
restoreExpandedLayout,
refreshPaneSizes,
syncExpandedLayout,
toggleExpandPane
} = createExpandCollapseActions({
expandedPaneIdRef,
expandedStyleSnapshotRef,
containerRef,
managerRef,
setExpandedPaneId,
setTabPaneExpanded,
tabId,
persistLayoutSnapshot
})
const syncCanExpandState = (): void => {
const paneCount = managerRef.current?.getPanes().length ?? 1
setTabCanExpandPane(tabId, paneCount > 1)
}
const doApplyAppearance = (manager: PaneManager): void => {
const s = settingsRef.current
if (!s) {
return
}
applyTerminalAppearance(
manager,
s,
systemPrefersDark,
paneFontSizesRef.current,
paneTransportsRef.current
)
}
useEffect(() => {
const closeMenu = (): void => {
if (Date.now() - menuOpenedAtRef.current < 100) {
return
}
setTerminalMenuOpen(false)
}
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (event: MediaQueryListEvent): void => setSystemPrefersDark(event.matches)
setSystemPrefersDark(media.matches)
media.addEventListener('change', handleChange)
return () => media.removeEventListener('change', handleChange)
}, [])
// Initialize PaneManager instance once
useEffect(() => {
const container = containerRef.current
if (!container) {
return
}
let resizeRaf: number | null = null
const queueResizeAll = (focusActive: boolean): void => {
if (resizeRaf !== null) {
cancelAnimationFrame(resizeRaf)
}
resizeRaf = requestAnimationFrame(() => {
resizeRaf = null
const m = managerRef.current
if (!m) {
return
}
const panes = m.getPanes()
for (const p of panes) {
try {
p.fitAddon.fit()
} catch {
/* ignore */
}
}
if (focusActive) {
const active = m.getActivePane() ?? panes[0]
active?.terminal.focus()
}
})
}
let shouldPersistLayout = false
const ptyDeps = {
tabId,
worktreeId,
cwd,
paneTransportsRef,
pendingWritesRef,
isActiveRef,
onPtyExitRef,
clearTabPtyId,
updateTabTitle,
updateTabPtyId,
markWorktreeUnreadFromBell
}
const manager = new PaneManager(container, {
onPaneCreated: (pane) => {
doApplyAppearance(manager)
connectPanePty(pane, manager, ptyDeps)
queueResizeAll(true)
},
onPaneClosed: (paneId) => {
const transport = paneTransportsRef.current.get(paneId)
if (transport) {
transport.destroy?.()
paneTransportsRef.current.delete(paneId)
}
paneFontSizesRef.current.delete(paneId)
pendingWritesRef.current.delete(paneId)
},
onActivePaneChange: () => {
if (shouldPersistLayout) {
persistLayoutSnapshot()
}
},
onLayoutChanged: () => {
syncExpandedLayout()
syncCanExpandState()
queueResizeAll(false)
if (shouldPersistLayout) {
persistLayoutSnapshot()
}
},
terminalOptions: () => {
const cs = settingsRef.current
return {
fontSize: cs?.terminalFontSize ?? 14,
fontFamily: buildFontFamily(cs?.terminalFontFamily ?? 'SF Mono'),
scrollback: Math.min(
50_000,
Math.max(1000, Math.round((cs?.terminalScrollbackBytes ?? 10_000_000) / 200))
),
cursorStyle: cs?.terminalCursorStyle ?? 'bar',
cursorBlink: cs?.terminalCursorBlink ?? true
}
},
onLinkClick: (url) => {
window.api.shell.openExternal(url)
}
})
managerRef.current = manager
const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive)
const restoredActivePaneId =
(initialLayoutRef.current.activeLeafId
? restoredPaneByLeafId.get(initialLayoutRef.current.activeLeafId)
: null) ??
manager.getActivePane()?.id ??
manager.getPanes()[0]?.id ??
null
if (restoredActivePaneId !== null) {
manager.setActivePane(restoredActivePaneId, { focus: isActive })
}
const restoredExpandedPaneId = initialLayoutRef.current.expandedLeafId
? (restoredPaneByLeafId.get(initialLayoutRef.current.expandedLeafId) ?? null)
: null
if (restoredExpandedPaneId !== null && manager.getPanes().length > 1) {
setExpandedPane(restoredExpandedPaneId)
applyExpandedLayoutTo(restoredExpandedPaneId, {
managerRef,
containerRef,
expandedStyleSnapshotRef
})
} else {
setExpandedPane(null)
}
shouldPersistLayout = true
syncCanExpandState()
doApplyAppearance(manager)
queueResizeAll(isActive)
persistLayoutSnapshot()
return () => {
if (resizeRaf !== null) {
cancelAnimationFrame(resizeRaf)
}
restoreExpandedLayoutFrom(expandedStyleSnapshotRef.current)
for (const transport of paneTransportsRef.current.values()) {
transport.destroy?.()
}
paneTransportsRef.current.clear()
pendingWritesRef.current.clear()
manager.destroy()
managerRef.current = null
setTabPaneExpanded(tabId, false)
setTabCanExpandPane(tabId, false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabId, cwd])
useEffect(() => {
const manager = managerRef.current
if (!manager || !settings) {
return
}
doApplyAppearance(manager)
const fontFamily = buildFontFamily(settings.terminalFontFamily)
for (const pane of manager.getPanes()) {
pane.terminal.options.fontFamily = fontFamily
try {
pane.fitAddon.fit()
} catch {
/* ignore */
}
}
}, [settings, systemPrefersDark])
useTerminalFontZoom({ isActive, managerRef, paneFontSizesRef, settingsRef })
useEffect(() => {
const manager = managerRef.current
if (!manager) {
return
}
if (isActive) {
manager.resumeRendering()
for (const [paneId, buf] of pendingWritesRef.current.entries()) {
if (buf.length > 0) {
const pane = manager.getPanes().find((p) => p.id === paneId)
if (pane) {
pane.terminal.write(buf)
}
pendingWritesRef.current.set(paneId, '')
}
}
requestAnimationFrame(() => {
const panes = manager.getPanes()
for (const p of panes) {
try {
p.fitAddon.fit()
} catch {
/* ignore */
}
}
const active = manager.getActivePane() ?? panes[0]
if (active) {
active.terminal.focus()
}
})
} else if (wasActiveRef.current) {
manager.suspendRendering()
}
wasActiveRef.current = isActive
}, [isActive])
useEffect(() => {
const onToggleExpand = (event: Event): void => {
const detail = (event as CustomEvent<{ tabId?: string }>).detail
if (!detail?.tabId || detail.tabId !== tabId) {
return
}
const manager = managerRef.current
if (!manager) {
return
}
const panes = manager.getPanes()
if (panes.length < 2) {
return
}
const pane = manager.getActivePane() ?? panes[0]
if (!pane) {
return
}
toggleExpandPane(pane.id)
}
window.addEventListener(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, onToggleExpand)
return () => window.removeEventListener(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, onToggleExpand)
}, [tabId])
useEffect(() => {
if (!isActive) {
return
}
const container = containerRef.current
if (!container) {
return
}
const ro = new ResizeObserver(() => {
const manager = managerRef.current
if (!manager) {
return
}
for (const p of manager.getPanes()) {
try {
p.fitAddon.fit()
} catch {
/* ignore */
}
}
})
ro.observe(container)
return () => ro.disconnect()
}, [isActive])
useTerminalKeyboardShortcuts({
isActive,
managerRef,
paneTransportsRef,
expandedPaneIdRef,
setExpandedPane,
restoreExpandedLayout,
refreshPaneSizes,
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen
})
useEffect(() => {
if (!isActive) {
return
}
const shellEscape = (p: string): string => {
if (/^[a-zA-Z0-9_./@:-]+$/.test(p)) {
return p
}
return `'${p.replace(/'/g, "'\\''")}'`
}
return window.api.ui.onFileDrop(({ path: filePath }) => {
const manager = managerRef.current
if (!manager) {
return
}
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) {
return
}
const transport = paneTransportsRef.current.get(pane.id)
if (!transport) {
return
}
transport.sendInput(shellEscape(filePath))
})
}, [isActive])
const resolveMenuPane = () => {
const manager = managerRef.current
if (!manager) {
return null
}
const panes = manager.getPanes()
if (contextPaneIdRef.current !== null) {
const clickedPane = panes.find((p) => p.id === contextPaneIdRef.current) ?? null
if (clickedPane) {
return clickedPane
}
}
return manager.getActivePane() ?? panes[0] ?? null
}
const handleCopy = async (): Promise<void> => {
const pane = resolveMenuPane()
if (!pane) {
return
}
const selection = pane.terminal.getSelection()
if (selection) {
await navigator.clipboard.writeText(selection)
}
}
const handlePaste = async (): Promise<void> => {
const pane = resolveMenuPane()
if (!pane) {
return
}
const text = await navigator.clipboard.readText()
if (text) {
paneTransportsRef.current.get(pane.id)?.sendInput(text)
}
}
const handleSplitRight = (): void => {
const p = resolveMenuPane()
if (p) {
managerRef.current?.splitPane(p.id, 'vertical')
}
}
const handleSplitDown = (): void => {
const p = resolveMenuPane()
if (p) {
managerRef.current?.splitPane(p.id, 'horizontal')
}
}
const handleClosePane = (): void => {
const p = resolveMenuPane()
if (p && (managerRef.current?.getPanes().length ?? 0) > 1) {
managerRef.current?.closePane(p.id)
}
}
const handleClearScreen = (): void => {
const p = resolveMenuPane()
if (p) {
p.terminal.clear()
}
}
const handleToggleExpand = (): void => {
const p = resolveMenuPane()
if (p) {
toggleExpandPane(p.id)
}
}
const paneCount = managerRef.current?.getPanes().length ?? 1
const menuPaneId = resolveMenuPane()?.id ?? null
const effectiveAppearance = settings
? resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
: null
const terminalContainerStyle: CSSProperties = {
display: isActive ? 'flex' : 'none',
['--orca-terminal-divider-color' as string]:
effectiveAppearance?.dividerColor ?? DEFAULT_TERMINAL_DIVIDER_DARK,
['--orca-terminal-divider-color-strong' as string]: normalizeColor(
effectiveAppearance?.dividerColor,
DEFAULT_TERMINAL_DIVIDER_DARK
)
}
const activePane = managerRef.current?.getActivePane()
return (
<>
<div
ref={containerRef}
className="absolute inset-0 min-h-0 min-w-0"
style={terminalContainerStyle}
onContextMenuCapture={(event) => {
event.preventDefault()
menuOpenedAtRef.current = Date.now()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
const manager = managerRef.current
if (!manager) {
contextPaneIdRef.current = null
return
}
const target = event.target
if (!(target instanceof Node)) {
contextPaneIdRef.current = null
return
}
const clickedPane =
manager.getPanes().find((pane) => pane.container.contains(target)) ?? null
contextPaneIdRef.current = clickedPane?.id ?? null
const bounds = event.currentTarget.getBoundingClientRect()
setTerminalMenuPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top })
setTerminalMenuOpen(true)
}}
/>
{activePane?.container &&
createPortal(
<TerminalSearch
isOpen={searchOpen}
onClose={() => setSearchOpen(false)}
searchAddon={activePane.searchAddon ?? null}
/>,
activePane.container
)}
<TerminalContextMenu
open={terminalMenuOpen}
onOpenChange={setTerminalMenuOpen}
menuPoint={terminalMenuPoint}
menuOpenedAtRef={menuOpenedAtRef}
canClosePane={paneCount > 1}
canExpandPane={paneCount > 1}
menuPaneIsExpanded={menuPaneId !== null && menuPaneId === expandedPaneId}
onCopy={() => void handleCopy()}
onPaste={() => void handlePaste()}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
onClosePane={handleClosePane}
onClearScreen={handleClearScreen}
onToggleExpand={handleToggleExpand}
/>
</>
)
}
@@ -0,0 +1,171 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
type ExpandCollapseState = {
expandedPaneIdRef: React.MutableRefObject<number | null>
expandedStyleSnapshotRef: React.MutableRefObject<
Map<HTMLElement, { display: string; flex: string }>
>
containerRef: React.RefObject<HTMLDivElement | null>
managerRef: React.RefObject<PaneManager | null>
setExpandedPaneId: (paneId: number | null) => void
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
tabId: string
persistLayoutSnapshot: () => void
}
function rememberPaneStyle(
snapshots: Map<HTMLElement, { display: string; flex: string }>,
el: HTMLElement
): void {
if (snapshots.has(el)) {
return
}
snapshots.set(el, { display: el.style.display, flex: el.style.flex })
}
export function restoreExpandedLayoutFrom(
snapshots: Map<HTMLElement, { display: string; flex: string }>
): void {
for (const [el, prev] of snapshots.entries()) {
el.style.display = prev.display
el.style.flex = prev.flex
}
snapshots.clear()
}
export function applyExpandedLayoutTo(
paneId: number,
state: Pick<ExpandCollapseState, 'managerRef' | 'containerRef' | 'expandedStyleSnapshotRef'>
): boolean {
const manager = state.managerRef.current
const root = state.containerRef.current
if (!manager || !root) {
return false
}
const panes = manager.getPanes()
if (panes.length <= 1) {
return false
}
const targetPane = panes.find((pane) => pane.id === paneId)
if (!targetPane) {
return false
}
restoreExpandedLayoutFrom(state.expandedStyleSnapshotRef.current)
const snapshots = state.expandedStyleSnapshotRef.current
let current: HTMLElement | null = targetPane.container
while (current && current !== root) {
const parent = current.parentElement
if (!parent) {
break
}
for (const child of Array.from(parent.children)) {
if (!(child instanceof HTMLElement)) {
continue
}
rememberPaneStyle(snapshots, child)
if (child === current) {
child.style.display = ''
child.style.flex = '1 1 auto'
} else {
child.style.display = 'none'
}
}
current = parent
}
return true
}
export function createExpandCollapseActions(state: ExpandCollapseState) {
const setExpandedPane = (paneId: number | null): void => {
state.expandedPaneIdRef.current = paneId
state.setExpandedPaneId(paneId)
state.setTabPaneExpanded(state.tabId, paneId !== null)
state.persistLayoutSnapshot()
}
const restoreExpandedLayout = (): void => {
restoreExpandedLayoutFrom(state.expandedStyleSnapshotRef.current)
}
const refreshPaneSizes = (focusActive: boolean): void => {
requestAnimationFrame(() => {
const manager = state.managerRef.current
if (!manager) {
return
}
const panes = manager.getPanes()
for (const p of panes) {
try {
p.fitAddon.fit()
} catch {
/* container may not have dimensions */
}
}
if (focusActive) {
const active = manager.getActivePane() ?? panes[0]
active?.terminal.focus()
}
})
}
const syncExpandedLayout = (): void => {
const paneId = state.expandedPaneIdRef.current
if (paneId === null) {
restoreExpandedLayout()
return
}
const manager = state.managerRef.current
if (!manager) {
return
}
const panes = manager.getPanes()
if (panes.length <= 1 || !panes.some((pane) => pane.id === paneId)) {
setExpandedPane(null)
restoreExpandedLayout()
return
}
applyExpandedLayoutTo(paneId, state)
}
const toggleExpandPane = (paneId: number): void => {
const manager = state.managerRef.current
if (!manager) {
return
}
const panes = manager.getPanes()
if (panes.length <= 1) {
return
}
const isAlreadyExpanded = state.expandedPaneIdRef.current === paneId
if (isAlreadyExpanded) {
setExpandedPane(null)
restoreExpandedLayout()
refreshPaneSizes(true)
state.persistLayoutSnapshot()
return
}
setExpandedPane(paneId)
if (!applyExpandedLayoutTo(paneId, state)) {
setExpandedPane(null)
restoreExpandedLayout()
state.persistLayoutSnapshot()
return
}
manager.setActivePane(paneId, { focus: true })
refreshPaneSizes(true)
state.persistLayoutSnapshot()
}
return {
setExpandedPane,
restoreExpandedLayout,
refreshPaneSizes,
syncExpandedLayout,
toggleExpandPane
}
}
@@ -0,0 +1,280 @@
import { useEffect } from 'react'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
type KeyboardHandlersDeps = {
isActive: boolean
managerRef: React.RefObject<PaneManager | null>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
expandedPaneIdRef: React.RefObject<number | null>
setExpandedPane: (paneId: number | null) => void
restoreExpandedLayout: () => void
refreshPaneSizes: (focusActive: boolean) => void
persistLayoutSnapshot: () => void
toggleExpandPane: (paneId: number) => void
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>
}
export function useTerminalKeyboardShortcuts({
isActive,
managerRef,
paneTransportsRef,
expandedPaneIdRef,
setExpandedPane,
restoreExpandedLayout,
refreshPaneSizes,
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen
}: KeyboardHandlersDeps): void {
useEffect(() => {
if (!isActive) {
return
}
const onKeyDown = (e: KeyboardEvent): void => {
if (e.repeat) {
return
}
if (!e.metaKey || e.altKey || e.ctrlKey) {
return
}
const manager = managerRef.current
if (!manager) {
return
}
// Cmd+F opens search
if (!e.shiftKey && e.key.toLowerCase() === 'f') {
e.preventDefault()
e.stopPropagation()
setSearchOpen((prev) => !prev)
return
}
// Cmd+K clears active pane screen + scrollback.
if (!e.shiftKey && e.key.toLowerCase() === 'k') {
e.preventDefault()
e.stopPropagation()
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (pane) {
pane.terminal.clear()
}
return
}
// Cmd+[ / Cmd+] cycles active split pane focus.
if (!e.shiftKey && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
const panes = manager.getPanes()
if (panes.length < 2) {
return
}
e.preventDefault()
e.stopPropagation()
// Collapse expanded pane before switching
if (expandedPaneIdRef.current !== null) {
setExpandedPane(null)
restoreExpandedLayout()
refreshPaneSizes(true)
persistLayoutSnapshot()
}
const activeId = manager.getActivePane()?.id ?? panes[0].id
const currentIdx = panes.findIndex((p) => p.id === activeId)
if (currentIdx === -1) {
return
}
const dir = e.code === 'BracketRight' ? 1 : -1
const nextPane = panes[(currentIdx + dir + panes.length) % panes.length]
manager.setActivePane(nextPane.id, { focus: true })
return
}
// Cmd+Shift+Enter expands/collapses the active pane to full terminal area.
if (e.shiftKey && e.key === 'Enter' && (e.code === 'Enter' || e.code === 'NumpadEnter')) {
const panes = manager.getPanes()
if (panes.length < 2) {
return
}
e.preventDefault()
e.stopPropagation()
const pane = manager.getActivePane() ?? panes[0]
if (!pane) {
return
}
toggleExpandPane(pane.id)
return
}
// Cmd+W closes only the active split pane and prevents the tab-level
// handler from closing the entire terminal tab.
if (!e.shiftKey && e.key.toLowerCase() === 'w') {
const panes = manager.getPanes()
if (panes.length < 2) {
return
}
e.preventDefault()
e.stopPropagation()
const pane = manager.getActivePane() ?? panes[0]
if (!pane) {
return
}
manager.closePane(pane.id)
return
}
// Cmd+D / Cmd+Shift+D split the active pane in the focused tab only.
if (e.key.toLowerCase() === 'd') {
e.preventDefault()
e.stopPropagation()
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) {
return
}
manager.splitPane(pane.id, e.shiftKey ? 'horizontal' : 'vertical')
}
}
// Ctrl+Backspace → send \x17 (backward-kill-word) to PTY.
const onCtrlBackspace = (e: KeyboardEvent): void => {
if (!e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
return
}
if (e.key !== 'Backspace') {
return
}
const manager = managerRef.current
if (!manager) {
return
}
e.preventDefault()
e.stopPropagation()
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) {
return
}
const transport = paneTransportsRef.current.get(pane.id)
transport?.sendInput('\x17')
}
// Alt+Backspace → send ESC + DEL (\x1b\x7f, backward-kill-word) to PTY.
const onAltBackspace = (e: KeyboardEvent): void => {
if (!e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) {
return
}
if (e.key !== 'Backspace') {
return
}
const manager = managerRef.current
if (!manager) {
return
}
e.preventDefault()
e.stopPropagation()
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) {
return
}
const transport = paneTransportsRef.current.get(pane.id)
transport?.sendInput('\x1b\x7f')
}
// Shift+Enter → insert a literal newline into the shell command line.
const onShiftEnter = (e: KeyboardEvent): void => {
if (!e.shiftKey || e.metaKey || e.altKey || e.ctrlKey) {
return
}
if (e.key !== 'Enter') {
return
}
const manager = managerRef.current
if (!manager) {
return
}
e.preventDefault()
e.stopPropagation()
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) {
return
}
const transport = paneTransportsRef.current.get(pane.id)
transport?.sendInput('\x16\x0a')
}
window.addEventListener('keydown', onKeyDown, { capture: true })
window.addEventListener('keydown', onCtrlBackspace, { capture: true })
window.addEventListener('keydown', onAltBackspace, { capture: true })
window.addEventListener('keydown', onShiftEnter, { capture: true })
return () => {
window.removeEventListener('keydown', onKeyDown, { capture: true })
window.removeEventListener('keydown', onCtrlBackspace, { capture: true })
window.removeEventListener('keydown', onAltBackspace, { capture: true })
window.removeEventListener('keydown', onShiftEnter, { capture: true })
}
}, [isActive])
}
type FontZoomDeps = {
isActive: boolean
managerRef: React.RefObject<PaneManager | null>
paneFontSizesRef: React.RefObject<Map<number, number>>
settingsRef: React.RefObject<{ terminalFontSize?: number } | null>
}
export function useTerminalFontZoom({
isActive,
managerRef,
paneFontSizesRef,
settingsRef
}: FontZoomDeps): void {
useEffect(() => {
if (!isActive) {
return
}
const MIN_FONT_SIZE = 8
const MAX_FONT_SIZE = 32
const FONT_SIZE_STEP = 1
return window.api.ui.onTerminalZoom((direction) => {
const manager = managerRef.current
if (!manager) {
return
}
const pane = manager.getActivePane()
if (!pane) {
return
}
const globalSize = settingsRef.current?.terminalFontSize ?? 14
const currentSize = paneFontSizesRef.current.get(pane.id) ?? globalSize
let nextSize: number
if (direction === 'reset') {
nextSize = globalSize
paneFontSizesRef.current.delete(pane.id)
} else if (direction === 'in') {
nextSize = Math.min(MAX_FONT_SIZE, currentSize + FONT_SIZE_STEP)
paneFontSizesRef.current.set(pane.id, nextSize)
} else {
nextSize = Math.max(MIN_FONT_SIZE, currentSize - FONT_SIZE_STEP)
paneFontSizesRef.current.set(pane.id, nextSize)
}
pane.terminal.options.fontSize = nextSize
try {
pane.fitAddon.fit()
} catch {
/* ignore */
}
})
}, [isActive])
}
@@ -0,0 +1,146 @@
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
TerminalPaneSplitDirection
} from '../../../../shared/types'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
export const EMPTY_LAYOUT: TerminalLayoutSnapshot = {
root: null,
activeLeafId: null,
expandedLeafId: null
}
export function paneLeafId(paneId: number): string {
return `pane:${paneId}`
}
export function buildFontFamily(fontFamily: string): string {
const trimmed = fontFamily.trim()
const parts = trimmed ? [`"${trimmed}"`] : []
// Always include fallbacks
if (!parts.some((p) => p.toLowerCase().includes('sf mono'))) {
parts.push('"SF Mono"')
}
parts.push('Menlo', 'monospace')
return parts.join(', ')
}
export 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'))
)
}
export 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
}
// Capture the flex ratio so resized panes survive serialization round-trips.
// We read the computed flex-grow values to derive the first-child proportion.
let ratio: number | undefined
if (first && second) {
const firstGrow = parseFloat(first.style.flex) || 1
const secondGrow = parseFloat(second.style.flex) || 1
const total = firstGrow + secondGrow
if (total > 0) {
const r = firstGrow / total
// Only store if meaningfully different from 0.5 (default equal split)
if (Math.abs(r - 0.5) > 0.005) {
ratio = Math.round(r * 1000) / 1000
}
}
}
return {
type: 'split',
direction: node.classList.contains('is-horizontal') ? 'horizontal' : 'vertical',
first: firstNode,
second: secondNode,
...(ratio !== undefined && { ratio })
}
}
export 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 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)
}
export function replayTerminalLayout(
manager: PaneManager,
snapshot: TerminalLayoutSnapshot | null | undefined,
focusInitialPane: boolean
): Map<string, number> {
const paneByLeafId = new Map<string, number>()
const initialPane = manager.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 = manager.splitPane(paneId, node.direction as TerminalPaneSplitDirection, {
ratio: node.ratio
})
if (!createdPane) {
collectLeafIds(node, paneByLeafId, paneId)
return
}
restoreNode(node.first, paneId)
restoreNode(node.second, createdPane.id)
}
restoreNode(snapshot.root, initialPane.id)
return paneByLeafId
}
@@ -0,0 +1,79 @@
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import { createIpcPtyTransport } from './pty-transport'
type PtyConnectionDeps = {
tabId: string
worktreeId: string
cwd?: string
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
pendingWritesRef: React.RefObject<Map<number, string>>
isActiveRef: React.RefObject<boolean>
onPtyExitRef: React.RefObject<(ptyId: string) => void>
clearTabPtyId: (tabId: string, ptyId: string) => void
updateTabTitle: (tabId: string, title: string) => void
updateTabPtyId: (tabId: string, ptyId: string) => void
markWorktreeUnreadFromBell: (worktreeId: string) => void
}
export function connectPanePty(
pane: ManagedPane,
manager: PaneManager,
deps: PtyConnectionDeps
): void {
const onExit = (ptyId: string): void => {
deps.clearTabPtyId(deps.tabId, ptyId)
const panes = manager.getPanes()
if (panes.length <= 1) {
deps.onPtyExitRef.current(ptyId)
return
}
manager.closePane(pane.id)
}
const onTitleChange = (title: string): void => {
deps.updateTabTitle(deps.tabId, title)
}
const onPtySpawn = (ptyId: string): void => deps.updateTabPtyId(deps.tabId, ptyId)
const onBell = (): void => deps.markWorktreeUnreadFromBell(deps.worktreeId)
const transport = createIpcPtyTransport(deps.cwd, onExit, onTitleChange, onPtySpawn, onBell)
deps.paneTransportsRef.current.set(pane.id, transport)
pane.terminal.onData((data) => {
transport.sendInput(data)
})
pane.terminal.onResize(({ cols, rows }) => {
transport.resize(cols, rows)
})
// Defer PTY spawn to next frame so FitAddon has time to calculate
// the correct terminal dimensions from the laid-out container.
deps.pendingWritesRef.current.set(pane.id, '')
requestAnimationFrame(() => {
try {
pane.fitAddon.fit()
} catch {
/* ignore */
}
const cols = pane.terminal.cols
const rows = pane.terminal.rows
transport.connect({
url: '',
cols,
rows,
callbacks: {
onData: (data) => {
if (deps.isActiveRef.current) {
pane.terminal.write(data)
} else {
const pending = deps.pendingWritesRef.current
pending.set(pane.id, (pending.get(pane.id) ?? '') + data)
}
}
}
})
})
}
@@ -0,0 +1,222 @@
export type PtyTransport = {
connect: (options: {
url: string
cols?: number
rows?: number
callbacks: {
onConnect?: () => void
onDisconnect?: () => void
onData?: (data: string) => void
onStatus?: (shell: string) => void
onError?: (message: string, errors?: string[]) => void
onExit?: (code: number) => void
}
}) => void | Promise<void>
disconnect: () => void
sendInput: (data: string) => boolean
resize: (
cols: number,
rows: number,
meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number }
) => boolean
isConnected: () => boolean
destroy?: () => void | Promise<void>
}
// Singleton PTY event dispatcher — one global IPC listener per channel,
// routes events to transports by PTY ID. Eliminates the N-listener problem
// that triggers MaxListenersExceededWarning with many panes/tabs.
const ptyDataHandlers = new Map<string, (data: string) => void>()
const ptyExitHandlers = new Map<string, (code: number) => void>()
let ptyDispatcherAttached = false
function ensurePtyDispatcher(): void {
if (ptyDispatcherAttached) {
return
}
ptyDispatcherAttached = true
window.api.pty.onData((payload) => {
ptyDataHandlers.get(payload.id)?.(payload.data)
})
window.api.pty.onExit((payload) => {
ptyExitHandlers.get(payload.id)?.(payload.code)
})
}
// eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching
const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g
export function extractLastOscTitle(data: string): string | null {
let last: string | null = null
let m: RegExpExecArray | null
OSC_TITLE_RE.lastIndex = 0
while ((m = OSC_TITLE_RE.exec(data)) !== null) {
last = m[2]
}
return last
}
export function createIpcPtyTransport(
cwd?: string,
onPtyExit?: (ptyId: string) => void,
onTitleChange?: (title: string) => void,
onPtySpawn?: (ptyId: string) => void,
onBell?: () => void
): PtyTransport {
let connected = false
let destroyed = false
let ptyId: string | null = null
let pendingEscape = false
let inOsc = false
let pendingOscEscape = false
let storedCallbacks: {
onConnect?: () => void
onDisconnect?: () => void
onData?: (data: string) => void
onStatus?: (shell: string) => void
onError?: (message: string, errors?: string[]) => void
onExit?: (code: number) => void
} = {}
function unregisterPtyHandlers(id: string): void {
ptyDataHandlers.delete(id)
ptyExitHandlers.delete(id)
}
return {
async connect(options) {
storedCallbacks = options.callbacks
ensurePtyDispatcher()
try {
const result = await window.api.pty.spawn({
cols: options.cols ?? 80,
rows: options.rows ?? 24,
cwd
})
// If destroyed while spawn was in flight, kill the new pty and bail
if (destroyed) {
window.api.pty.kill(result.id)
return
}
ptyId = result.id
connected = true
onPtySpawn?.(result.id)
ptyDataHandlers.set(result.id, (data) => {
storedCallbacks.onData?.(data)
if (onTitleChange) {
const title = extractLastOscTitle(data)
if (title !== null) {
onTitleChange(title)
}
}
if (onBell && chunkContainsBell(data)) {
onBell()
}
})
const spawnedId = result.id
ptyExitHandlers.set(spawnedId, (code) => {
connected = false
ptyId = null
unregisterPtyHandlers(spawnedId)
storedCallbacks.onExit?.(code)
storedCallbacks.onDisconnect?.()
onPtyExit?.(spawnedId)
})
storedCallbacks.onConnect?.()
storedCallbacks.onStatus?.('shell')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
storedCallbacks.onError?.(msg)
}
},
disconnect() {
if (ptyId) {
const id = ptyId
window.api.pty.kill(id)
connected = false
ptyId = null
unregisterPtyHandlers(id)
storedCallbacks.onDisconnect?.()
}
},
sendInput(data: string): boolean {
if (!connected || !ptyId) {
return false
}
window.api.pty.write(ptyId, data)
return true
},
resize(cols: number, rows: number): boolean {
if (!connected || !ptyId) {
return false
}
window.api.pty.resize(ptyId, cols, rows)
return true
},
isConnected() {
return connected
},
destroy() {
destroyed = true
this.disconnect()
}
}
function chunkContainsBell(data: string): boolean {
for (let i = 0; i < data.length; i += 1) {
const char = data[i]
if (inOsc) {
if (pendingOscEscape) {
pendingOscEscape = char === '\x1b'
if (char === '\\') {
inOsc = false
pendingOscEscape = false
}
continue
}
if (char === '\x07') {
inOsc = false
continue
}
pendingOscEscape = char === '\x1b'
continue
}
if (pendingEscape) {
pendingEscape = false
if (char === ']') {
inOsc = true
pendingOscEscape = false
} else if (char === '\x1b') {
pendingEscape = true
}
continue
}
if (char === '\x1b') {
pendingEscape = true
continue
}
if (char === '\x07') {
return true
}
}
return false
}
}
@@ -0,0 +1,53 @@
import type { ITheme } from '@xterm/xterm'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { GlobalSettings } from '../../../../shared/types'
import {
getCursorStyleSequence,
getBuiltinTheme,
resolvePaneStyleOptions,
resolveEffectiveTerminalAppearance
} from '@/lib/terminal-theme'
import type { PtyTransport } from './pty-transport'
export function applyTerminalAppearance(
manager: PaneManager,
settings: GlobalSettings,
systemPrefersDark: boolean,
paneFontSizes: Map<number, number>,
paneTransports: Map<number, PtyTransport>
): void {
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
const paneStyles = resolvePaneStyleOptions(settings)
const cursorSequence = getCursorStyleSequence(
settings.terminalCursorStyle,
settings.terminalCursorBlink
)
const theme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName)
const paneBackground = theme?.background ?? '#000000'
for (const pane of manager.getPanes()) {
if (theme) {
pane.terminal.options.theme = theme
}
pane.terminal.options.cursorStyle = settings.terminalCursorStyle
pane.terminal.options.cursorBlink = settings.terminalCursorBlink
const paneSize = paneFontSizes.get(pane.id)
pane.terminal.options.fontSize = paneSize ?? settings.terminalFontSize
try {
pane.fitAddon.fit()
} catch {
/* ignore */
}
const transport = paneTransports.get(pane.id)
transport?.sendInput(cursorSequence)
}
manager.setPaneStyleOptions({
splitBackground: paneBackground,
paneBackground,
inactivePaneOpacity: paneStyles.inactivePaneOpacity,
activePaneOpacity: paneStyles.activePaneOpacity,
opacityTransitionMs: paneStyles.opacityTransitionMs,
dividerThicknessPx: paneStyles.dividerThicknessPx
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
import type { PaneStyleOptions, ManagedPaneInternal } from './pane-manager-types'
// ---------------------------------------------------------------------------
// Divider creation & drag-to-resize
// ---------------------------------------------------------------------------
/** Total hit area size = visible thickness + invisible padding on each side */
export function getDividerHitSize(styleOptions: PaneStyleOptions): number {
const thickness = styleOptions.dividerThicknessPx ?? 4
const HIT_PADDING = 3
return thickness + HIT_PADDING * 2
}
export function createDivider(
isVertical: boolean,
styleOptions: PaneStyleOptions,
callbacks: {
refitPanesUnder: (el: HTMLElement) => void
onLayoutChanged?: () => void
}
): HTMLElement {
const divider = document.createElement('div')
divider.className = `pane-divider ${isVertical ? 'is-vertical' : 'is-horizontal'}`
// Ghostty-style: the element itself is a wide transparent hit area for easy
// grabbing. The visible line is drawn by a CSS ::after pseudo-element
// (see main.css), so `background` on the element stays transparent.
const hitSize = getDividerHitSize(styleOptions)
if (isVertical) {
divider.style.width = `${hitSize}px`
divider.style.cursor = 'col-resize'
} else {
divider.style.height = `${hitSize}px`
divider.style.cursor = 'row-resize'
}
divider.style.flex = 'none'
divider.style.position = 'relative'
attachDividerDrag(divider, isVertical, callbacks)
return divider
}
function attachDividerDrag(
divider: HTMLElement,
isVertical: boolean,
callbacks: {
refitPanesUnder: (el: HTMLElement) => void
onLayoutChanged?: () => void
}
): void {
const MIN_PANE_SIZE = 50
let dragging = false
let didMove = false
let startPos = 0
let prevFlex = 0
let nextFlex = 0
let totalSize = 0
let prevEl: HTMLElement | null = null
let nextEl: HTMLElement | null = null
const onPointerDown = (e: PointerEvent): void => {
e.preventDefault()
divider.setPointerCapture(e.pointerId)
divider.classList.add('is-dragging')
dragging = true
didMove = false
startPos = isVertical ? e.clientX : e.clientY
// Find previous and next pane/split siblings
prevEl = divider.previousElementSibling as HTMLElement | null
nextEl = divider.nextElementSibling as HTMLElement | null
if (!prevEl || !nextEl) {
return
}
const prevRect = prevEl.getBoundingClientRect()
const nextRect = nextEl.getBoundingClientRect()
const prevSize = isVertical ? prevRect.width : prevRect.height
const nextSize = isVertical ? nextRect.width : nextRect.height
totalSize = prevSize + nextSize
// Store current proportions as flex-basis values
prevFlex = prevSize
nextFlex = nextSize
}
const onPointerMove = (e: PointerEvent): void => {
if (!dragging || !prevEl || !nextEl) {
return
}
didMove = true
const currentPos = isVertical ? e.clientX : e.clientY
const delta = currentPos - startPos
let newPrev = prevFlex + delta
let newNext = nextFlex - delta
// Enforce minimum pane size
if (newPrev < MIN_PANE_SIZE) {
newPrev = MIN_PANE_SIZE
newNext = totalSize - MIN_PANE_SIZE
}
if (newNext < MIN_PANE_SIZE) {
newNext = MIN_PANE_SIZE
newPrev = totalSize - MIN_PANE_SIZE
}
// Use flex-grow proportionally
prevEl.style.flex = `${newPrev} 1 0%`
nextEl.style.flex = `${newNext} 1 0%`
// Refit terminals in affected panes
callbacks.refitPanesUnder(prevEl)
callbacks.refitPanesUnder(nextEl)
}
const onPointerUp = (e: PointerEvent): void => {
if (!dragging) {
return
}
dragging = false
divider.releasePointerCapture(e.pointerId)
divider.classList.remove('is-dragging')
prevEl = null
nextEl = null
// Persist updated ratios after a real drag
if (didMove) {
callbacks.onLayoutChanged?.()
}
}
// Ghostty-style: double-click divider to equalize sibling panes
const onDoubleClick = (): void => {
const prev = divider.previousElementSibling as HTMLElement | null
const next = divider.nextElementSibling as HTMLElement | null
if (!prev || !next) {
return
}
prev.style.flex = '1 1 0%'
next.style.flex = '1 1 0%'
callbacks.refitPanesUnder(prev)
callbacks.refitPanesUnder(next)
callbacks.onLayoutChanged?.()
}
divider.addEventListener('pointerdown', onPointerDown)
divider.addEventListener('pointermove', onPointerMove)
divider.addEventListener('pointerup', onPointerUp)
divider.addEventListener('dblclick', onDoubleClick)
}
export function applyDividerStyles(root: HTMLElement, styleOptions: PaneStyleOptions): void {
const thickness = styleOptions.dividerThicknessPx ?? 4
const hitSize = getDividerHitSize(styleOptions)
const dividers = root.querySelectorAll('.pane-divider')
for (const div of dividers) {
const el = div as HTMLElement
const isVertical = el.classList.contains('is-vertical')
if (isVertical) {
el.style.width = `${hitSize}px`
} else {
el.style.height = `${hitSize}px`
}
// Store the visual thickness for the CSS ::after pseudo-element
el.style.setProperty('--divider-thickness', `${thickness}px`)
}
}
export function applyPaneOpacity(
panes: Iterable<ManagedPaneInternal>,
activePaneId: number | null,
styleOptions: PaneStyleOptions
): void {
const { activePaneOpacity = 1, inactivePaneOpacity = 1, opacityTransitionMs = 0 } = styleOptions
const transition = opacityTransitionMs > 0 ? `opacity ${opacityTransitionMs}ms ease` : ''
for (const pane of panes) {
const isActive = pane.id === activePaneId
pane.container.style.opacity = String(isActive ? activePaneOpacity : inactivePaneOpacity)
pane.container.style.transition = transition
}
}
export function applyRootBackground(root: HTMLElement, styleOptions: PaneStyleOptions): void {
if (styleOptions.splitBackground) {
root.style.background = styleOptions.splitBackground
}
}
@@ -0,0 +1,264 @@
import type { DropZone, ManagedPaneInternal } from './pane-manager-types'
import type { PaneStyleOptions } from './pane-manager-types'
import { detachPaneFromTree, insertPaneNextTo } from './pane-tree-ops'
// ---------------------------------------------------------------------------
// Drag-to-reorder panes
// ---------------------------------------------------------------------------
export type DragReorderState = {
dragSourcePaneId: number | null
dropOverlay: HTMLElement | null
currentDropTarget: { paneId: number; zone: DropZone } | null
}
export type DragReorderCallbacks = {
getPanes: () => Map<number, ManagedPaneInternal>
getRoot: () => HTMLElement
getStyleOptions: () => PaneStyleOptions
isDestroyed: () => boolean
safeFit: (pane: ManagedPaneInternal) => void
applyPaneOpacity: () => void
applyDividerStyles: () => void
refitPanesUnder: (el: HTMLElement) => void
onLayoutChanged?: () => void
}
export function createDragReorderState(): DragReorderState {
return {
dragSourcePaneId: null,
dropOverlay: null,
currentDropTarget: null
}
}
/** Attach drag-to-reorder handlers to a pane's drag handle. */
export function attachPaneDrag(
handle: HTMLElement,
paneId: number,
state: DragReorderState,
callbacks: DragReorderCallbacks
): void {
let dragging = false
let startX = 0
let startY = 0
const DRAG_THRESHOLD = 5
const onPointerDown = (e: PointerEvent): void => {
// Only start drag if there are 2+ panes
if (callbacks.getPanes().size < 2) {
return
}
e.preventDefault()
e.stopPropagation()
handle.setPointerCapture(e.pointerId)
startX = e.clientX
startY = e.clientY
dragging = false
const onPointerMoveOuter = (ev: PointerEvent): void => {
const dx = ev.clientX - startX
const dy = ev.clientY - startY
if (!dragging && Math.hypot(dx, dy) >= DRAG_THRESHOLD) {
dragging = true
state.dragSourcePaneId = paneId
callbacks.getRoot().classList.add('is-pane-dragging')
const sourcePane = callbacks.getPanes().get(paneId)
if (sourcePane) {
sourcePane.container.classList.add('is-drag-source')
}
showDropOverlay(state)
}
if (dragging) {
updateDropTarget(ev.clientX, ev.clientY, state, callbacks)
}
}
const onPointerUpOuter = (ev: PointerEvent): void => {
handle.releasePointerCapture(ev.pointerId)
handle.removeEventListener('pointermove', onPointerMoveOuter)
handle.removeEventListener('pointerup', onPointerUpOuter)
if (dragging) {
callbacks.getRoot().classList.remove('is-pane-dragging')
const sourcePane = callbacks.getPanes().get(paneId)
if (sourcePane) {
sourcePane.container.classList.remove('is-drag-source')
}
// Execute the drop
if (state.currentDropTarget && state.dragSourcePaneId !== null) {
handlePaneDrop(
state.dragSourcePaneId,
state.currentDropTarget.paneId,
state.currentDropTarget.zone,
state,
callbacks
)
}
hideDropOverlay(state)
state.dragSourcePaneId = null
state.currentDropTarget = null
}
}
handle.addEventListener('pointermove', onPointerMoveOuter)
handle.addEventListener('pointerup', onPointerUpOuter)
}
handle.addEventListener('pointerdown', onPointerDown)
}
/** Move a pane from its current position to a new position relative to a target pane. */
export function handlePaneDrop(
sourcePaneId: number,
targetPaneId: number,
zone: DropZone,
_state: DragReorderState,
callbacks: DragReorderCallbacks
): void {
if (sourcePaneId === targetPaneId) {
return
}
const panes = callbacks.getPanes()
const source = panes.get(sourcePaneId)
const target = panes.get(targetPaneId)
if (!source || !target) {
return
}
// 1. Detach source pane from the tree (without disposing its terminal)
detachPaneFromTree(source, callbacks)
// 2. Insert source next to target in the requested zone
insertPaneNextTo(source, target, zone, callbacks)
// 3. Refit all panes and persist
for (const p of panes.values()) {
callbacks.safeFit(p)
}
callbacks.applyPaneOpacity()
callbacks.applyDividerStyles()
updateMultiPaneState(callbacks)
callbacks.onLayoutChanged?.()
}
export function showDropOverlay(state: DragReorderState): void {
if (!state.dropOverlay) {
const overlay = document.createElement('div')
overlay.className = 'pane-drop-overlay'
document.body.appendChild(overlay)
state.dropOverlay = overlay
}
state.dropOverlay.style.display = 'none'
}
export function hideDropOverlay(state: DragReorderState): void {
if (state.dropOverlay) {
state.dropOverlay.remove()
state.dropOverlay = null
}
}
/** Add/remove .has-multiple-panes on root to control drag handle visibility. */
export function updateMultiPaneState(callbacks: DragReorderCallbacks): void {
if (callbacks.getPanes().size >= 2) {
callbacks.getRoot().classList.add('has-multiple-panes')
} else {
callbacks.getRoot().classList.remove('has-multiple-panes')
}
}
/** Determine which pane and zone the cursor is over, and position the overlay. */
function updateDropTarget(
clientX: number,
clientY: number,
state: DragReorderState,
callbacks: DragReorderCallbacks
): void {
const overlay = state.dropOverlay
if (!overlay) {
return
}
// Find which pane the cursor is over (excluding the source)
let targetPane: ManagedPaneInternal | null = null
for (const pane of callbacks.getPanes().values()) {
if (pane.id === state.dragSourcePaneId) {
continue
}
const rect = pane.container.getBoundingClientRect()
if (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
) {
targetPane = pane
break
}
}
if (!targetPane) {
overlay.style.display = 'none'
state.currentDropTarget = null
return
}
const rect = targetPane.container.getBoundingClientRect()
const relX = (clientX - rect.left) / rect.width
const relY = (clientY - rect.top) / rect.height
// Determine zone: which edge is the cursor closest to?
const distTop = relY
const distBottom = 1 - relY
const distLeft = relX
const distRight = 1 - relX
const minDist = Math.min(distTop, distBottom, distLeft, distRight)
let zone: DropZone
if (minDist === distTop) {
zone = 'top'
} else if (minDist === distBottom) {
zone = 'bottom'
} else if (minDist === distLeft) {
zone = 'left'
} else {
zone = 'right'
}
state.currentDropTarget = { paneId: targetPane.id, zone }
// Position overlay to cover the target half
overlay.style.display = ''
const scrollX = window.scrollX
const scrollY = window.scrollY
switch (zone) {
case 'top':
overlay.style.left = `${rect.left + scrollX}px`
overlay.style.top = `${rect.top + scrollY}px`
overlay.style.width = `${rect.width}px`
overlay.style.height = `${rect.height / 2}px`
break
case 'bottom':
overlay.style.left = `${rect.left + scrollX}px`
overlay.style.top = `${rect.top + scrollY + rect.height / 2}px`
overlay.style.width = `${rect.width}px`
overlay.style.height = `${rect.height / 2}px`
break
case 'left':
overlay.style.left = `${rect.left + scrollX}px`
overlay.style.top = `${rect.top + scrollY}px`
overlay.style.width = `${rect.width / 2}px`
overlay.style.height = `${rect.height}px`
break
case 'right':
overlay.style.left = `${rect.left + scrollX + rect.width / 2}px`
overlay.style.top = `${rect.top + scrollY}px`
overlay.style.width = `${rect.width / 2}px`
overlay.style.height = `${rect.height}px`
break
}
}
@@ -0,0 +1,193 @@
import { Terminal } from '@xterm/xterm'
import type { ITerminalOptions } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import type { PaneManagerOptions, ManagedPaneInternal } from './pane-manager-types'
import type { DragReorderState } from './pane-drag-reorder'
import type { DragReorderCallbacks } from './pane-drag-reorder'
import { attachPaneDrag } from './pane-drag-reorder'
import { safeFit } from './pane-tree-ops'
// ---------------------------------------------------------------------------
// Pane creation, terminal open/close, addon management
// ---------------------------------------------------------------------------
const TERMINAL_PADDING = 4
export function createPaneDOM(
id: number,
options: PaneManagerOptions,
dragState: DragReorderState,
dragCallbacks: DragReorderCallbacks,
onPointerDown: (id: number) => void
): ManagedPaneInternal {
// Create .pane container
const container = document.createElement('div')
container.className = 'pane'
container.dataset.paneId = String(id)
// Create .xterm-container with small inset padding
const xtermContainer = document.createElement('div')
xtermContainer.className = 'xterm-container'
xtermContainer.style.width = `calc(100% - ${TERMINAL_PADDING}px)`
xtermContainer.style.height = `calc(100% - ${TERMINAL_PADDING}px)`
xtermContainer.style.marginTop = `${TERMINAL_PADDING}px`
xtermContainer.style.marginLeft = `${TERMINAL_PADDING}px`
container.appendChild(xtermContainer)
// Build terminal options
const userOpts = options.terminalOptions?.(id) ?? {}
const terminalOpts: ITerminalOptions = {
allowProposedApi: true,
cursorBlink: true,
cursorStyle: 'bar',
fontSize: 14,
fontFamily: '"SF Mono", Menlo, monospace',
fontWeight: '300',
fontWeightBold: '500',
scrollback: 10000,
allowTransparency: false,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
drawBoldTextInBrightColors: true,
...userOpts
}
const terminal = new Terminal(terminalOpts)
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
const unicode11Addon = new Unicode11Addon()
// URL tooltip element — Ghostty-style bottom-left hint on hover
const linkTooltip = document.createElement('div')
linkTooltip.className = 'pane-link-tooltip'
linkTooltip.style.cssText =
'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' +
'padding:2px 8px;border-radius:4px;font-size:11px;font-family:inherit;' +
'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' +
'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
container.appendChild(linkTooltip)
// Ghostty-style drag handle — appears at top of pane on hover when 2+ panes
const dragHandle = document.createElement('div')
dragHandle.className = 'pane-drag-handle'
container.appendChild(dragHandle)
attachPaneDrag(dragHandle, id, dragState, dragCallbacks)
const webLinksAddon = new WebLinksAddon(
options.onLinkClick ? (_event, uri) => options.onLinkClick!(uri) : undefined,
{
hover: (event, uri) => {
if (event.type === 'mouseover' && uri) {
linkTooltip.textContent = uri
linkTooltip.style.display = ''
} else {
linkTooltip.style.display = 'none'
}
}
}
)
const pane: ManagedPaneInternal = {
id,
terminal,
container,
xtermContainer,
fitAddon,
searchAddon,
unicode11Addon,
webLinksAddon,
webglAddon: null
}
// Focus handler: clicking a pane makes it active and explicitly focuses
// the terminal. We must call focus: true here because after DOM reparenting
// (e.g. splitPane moves the original pane into a flex container), xterm.js's
// native click-to-focus on its internal textarea may not fire reliably.
container.addEventListener('pointerdown', () => {
onPointerDown(id)
})
return pane
}
/** Open terminal into its container and load addons. Must be called after the container is in the DOM. */
export function openTerminal(pane: ManagedPaneInternal): void {
const { terminal, xtermContainer, fitAddon, searchAddon, unicode11Addon, webLinksAddon } = pane
// Open terminal into DOM
terminal.open(xtermContainer)
// Load addons (order matters: WebGL must be after open())
terminal.loadAddon(fitAddon)
terminal.loadAddon(searchAddon)
terminal.loadAddon(unicode11Addon)
terminal.loadAddon(webLinksAddon)
// Activate unicode 11
terminal.unicode.activeVersion = '11'
// Attach GPU renderer
attachWebgl(pane)
// Initial fit (deferred to ensure layout has settled)
requestAnimationFrame(() => {
safeFit(pane)
})
}
export function attachWebgl(pane: ManagedPaneInternal): void {
try {
const webglAddon = new WebglAddon()
webglAddon.onContextLoss(() => {
webglAddon.dispose()
pane.webglAddon = null
})
pane.terminal.loadAddon(webglAddon)
pane.webglAddon = webglAddon
} catch {
// WebGL not available — default DOM renderer is fine
pane.webglAddon = null
}
}
export function disposePane(
pane: ManagedPaneInternal,
panes: Map<number, ManagedPaneInternal>
): void {
try {
pane.webglAddon?.dispose()
} catch {
/* ignore */
}
try {
pane.searchAddon.dispose()
} catch {
/* ignore */
}
try {
pane.unicode11Addon.dispose()
} catch {
/* ignore */
}
try {
pane.webLinksAddon.dispose()
} catch {
/* ignore */
}
try {
pane.fitAddon.dispose()
} catch {
/* ignore */
}
try {
pane.terminal.dispose()
} catch {
/* ignore */
}
panes.delete(pane.id)
}
@@ -0,0 +1,50 @@
import type { Terminal } from '@xterm/xterm'
import type { ITerminalOptions } from '@xterm/xterm'
import type { FitAddon } from '@xterm/addon-fit'
import type { SearchAddon } from '@xterm/addon-search'
import type { Unicode11Addon } from '@xterm/addon-unicode11'
import type { WebLinksAddon } from '@xterm/addon-web-links'
import type { WebglAddon } from '@xterm/addon-webgl'
// ---------------------------------------------------------------------------
// Public interfaces
// ---------------------------------------------------------------------------
export type PaneManagerOptions = {
onPaneCreated?: (pane: ManagedPane) => void | Promise<void>
onPaneClosed?: (paneId: number) => void
onActivePaneChange?: (pane: ManagedPane) => void
onLayoutChanged?: () => void
terminalOptions?: (paneId: number) => Partial<ITerminalOptions>
onLinkClick?: (url: string) => void
}
export type PaneStyleOptions = {
splitBackground?: string
paneBackground?: string
inactivePaneOpacity?: number
activePaneOpacity?: number
opacityTransitionMs?: number
dividerThicknessPx?: number
}
export type ManagedPane = {
id: number
terminal: Terminal
container: HTMLElement // the .pane element
fitAddon: FitAddon
searchAddon: SearchAddon
}
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
export type ManagedPaneInternal = {
xtermContainer: HTMLElement
webglAddon: WebglAddon | null
unicode11Addon: Unicode11Addon
webLinksAddon: WebLinksAddon
} & ManagedPane
export type DropZone = 'top' | 'bottom' | 'left' | 'right'
@@ -0,0 +1,314 @@
import type {
PaneManagerOptions,
PaneStyleOptions,
ManagedPane,
ManagedPaneInternal,
DropZone
} from './pane-manager-types'
import {
createDivider,
applyDividerStyles,
applyPaneOpacity,
applyRootBackground
} from './pane-divider'
import {
createDragReorderState,
hideDropOverlay,
handlePaneDrop,
updateMultiPaneState
} from './pane-drag-reorder'
import { createPaneDOM, openTerminal, attachWebgl, disposePane } from './pane-lifecycle'
import {
findPaneChildren,
removeDividers,
promoteSibling,
wrapInSplit,
safeFit,
refitPanesUnder
} from './pane-tree-ops'
export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone }
export class PaneManager {
private root: HTMLElement
private panes: Map<number, ManagedPaneInternal> = new Map()
private activePaneId: number | null = null
private nextPaneId = 1
private options: PaneManagerOptions
private styleOptions: PaneStyleOptions = {}
private destroyed = false
// Drag-to-reorder state
private dragState = createDragReorderState()
constructor(root: HTMLElement, options: PaneManagerOptions) {
this.root = root
this.options = options
}
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
createInitialPane(opts?: { focus?: boolean }): ManagedPane {
const pane = this.createPaneInternal()
// When the pane is the sole child of root (no splits), it must
// fill the root container so FitAddon calculates correct dimensions.
pane.container.style.width = '100%'
pane.container.style.height = '100%'
pane.container.style.position = 'relative'
pane.container.style.overflow = 'hidden'
// Place directly into root
this.root.appendChild(pane.container)
openTerminal(pane)
this.activePaneId = pane.id
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
if (opts?.focus !== false) {
pane.terminal.focus()
}
void this.options.onPaneCreated?.(this.toPublic(pane))
return this.toPublic(pane)
}
splitPane(
paneId: number,
direction: 'vertical' | 'horizontal',
opts?: { ratio?: number }
): ManagedPane | null {
const existing = this.panes.get(paneId)
if (!existing) {
return null
}
const newPane = this.createPaneInternal()
const parent = existing.container.parentElement
if (!parent) {
return null
}
const isVertical = direction === 'vertical'
const divider = this.createDividerWrapped(isVertical)
wrapInSplit(existing.container, newPane.container, isVertical, divider, opts)
// Open terminal for new pane
openTerminal(newPane)
// Set new pane active
this.activePaneId = newPane.id
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
this.applyDividerStylesWrapped()
if (newPane.terminal) {
newPane.terminal.focus()
}
// Refit existing pane since it now shares space
safeFit(existing)
updateMultiPaneState(this.getDragCallbacks())
void this.options.onPaneCreated?.(this.toPublic(newPane))
this.options.onLayoutChanged?.()
return this.toPublic(newPane)
}
closePane(paneId: number): void {
const pane = this.panes.get(paneId)
if (!pane) {
return
}
const paneContainer = pane.container
const parent = paneContainer.parentElement
if (!parent) {
return
}
// Dispose terminal and addons
disposePane(pane, this.panes)
if (parent.classList.contains('pane-split')) {
const siblings = findPaneChildren(parent)
const sibling = siblings.find((c) => c !== paneContainer) ?? null
paneContainer.remove()
removeDividers(parent)
promoteSibling(sibling, parent, this.root)
} else {
// Direct child of root (only pane) — just remove
paneContainer.remove()
}
// Activate next pane if needed
if (this.activePaneId === paneId) {
const remaining = Array.from(this.panes.values())
if (remaining.length > 0) {
this.activePaneId = remaining[0].id
remaining[0].terminal.focus()
} else {
this.activePaneId = null
}
}
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
// Refit remaining panes
for (const p of this.panes.values()) {
safeFit(p)
}
updateMultiPaneState(this.getDragCallbacks())
this.options.onPaneClosed?.(paneId)
this.options.onLayoutChanged?.()
}
getPanes(): ManagedPane[] {
return Array.from(this.panes.values()).map((p) => this.toPublic(p))
}
getActivePane(): ManagedPane | null {
if (this.activePaneId === null) {
return null
}
const pane = this.panes.get(this.activePaneId)
return pane ? this.toPublic(pane) : null
}
setActivePane(paneId: number, opts?: { focus?: boolean }): void {
const pane = this.panes.get(paneId)
if (!pane) {
return
}
const changed = this.activePaneId !== paneId
this.activePaneId = paneId
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
if (opts?.focus !== false) {
pane.terminal.focus()
}
if (changed) {
this.options.onActivePaneChange?.(this.toPublic(pane))
}
}
setPaneStyleOptions(opts: PaneStyleOptions): void {
this.styleOptions = { ...opts }
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
this.applyDividerStylesWrapped()
applyRootBackground(this.root, this.styleOptions)
}
/**
* Suspend GPU rendering for all panes. Disposes WebGL addons to free
* GPU contexts while keeping Terminal instances alive (scrollback, cursor,
* screen buffer all preserved). Call when this tab/worktree becomes hidden.
*/
suspendRendering(): void {
for (const pane of this.panes.values()) {
if (pane.webglAddon) {
try {
pane.webglAddon.dispose()
} catch {
/* ignore */
}
pane.webglAddon = null
}
}
}
/**
* Resume GPU rendering for all panes. Recreates WebGL addons. Call when
* this tab/worktree becomes visible again. Must be followed by a fit() pass.
*/
resumeRendering(): void {
for (const pane of this.panes.values()) {
if (!pane.webglAddon) {
attachWebgl(pane)
}
}
}
/** Move a pane from its current position to a new position relative to a target pane. */
movePane(sourcePaneId: number, targetPaneId: number, zone: DropZone): void {
handlePaneDrop(sourcePaneId, targetPaneId, zone, this.dragState, this.getDragCallbacks())
}
destroy(): void {
this.destroyed = true
hideDropOverlay(this.dragState)
for (const pane of this.panes.values()) {
disposePane(pane, this.panes)
}
this.root.innerHTML = ''
this.activePaneId = null
}
// -----------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------
private createPaneInternal(): ManagedPaneInternal {
const id = this.nextPaneId++
const pane = createPaneDOM(
id,
this.options,
this.dragState,
this.getDragCallbacks(),
(paneId) => {
if (!this.destroyed && this.activePaneId !== paneId) {
this.setActivePane(paneId, { focus: true })
}
}
)
this.panes.set(id, pane)
return pane
}
private createDividerWrapped(isVertical: boolean): HTMLElement {
return createDivider(isVertical, this.styleOptions, {
refitPanesUnder: (el) => refitPanesUnder(el, this.panes),
onLayoutChanged: this.options.onLayoutChanged
})
}
private applyDividerStylesWrapped(): void {
applyDividerStyles(this.root, this.styleOptions)
}
private toPublic(pane: ManagedPaneInternal): ManagedPane {
return {
id: pane.id,
terminal: pane.terminal,
container: pane.container,
fitAddon: pane.fitAddon,
searchAddon: pane.searchAddon
}
}
/** Build the callbacks object for drag-reorder functions. */
private getDragCallbacks() {
return {
getPanes: () => this.panes,
getRoot: () => this.root,
getStyleOptions: () => this.styleOptions,
isDestroyed: () => this.destroyed,
safeFit: (pane: ManagedPaneInternal) => safeFit(pane),
applyPaneOpacity: () =>
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions),
applyDividerStyles: () => this.applyDividerStylesWrapped(),
refitPanesUnder: (el: HTMLElement) => refitPanesUnder(el, this.panes),
onLayoutChanged: this.options.onLayoutChanged
}
}
}
@@ -0,0 +1,268 @@
import type { DropZone, ManagedPaneInternal, PaneStyleOptions } from './pane-manager-types'
import { createDivider } from './pane-divider'
// ---------------------------------------------------------------------------
// Split-tree manipulation: detach, insert, promote sibling
// ---------------------------------------------------------------------------
type TreeOpsCallbacks = {
getRoot: () => HTMLElement
getStyleOptions: () => PaneStyleOptions
safeFit: (pane: ManagedPaneInternal) => void
refitPanesUnder: (el: HTMLElement) => void
onLayoutChanged?: () => void
}
export function safeFit(pane: ManagedPaneInternal): void {
try {
pane.fitAddon.fit()
} catch {
// Container may not have dimensions yet
}
}
export function refitPanesUnder(el: HTMLElement, panes: Map<number, ManagedPaneInternal>): void {
// If the element is a pane, refit it
if (el.classList.contains('pane')) {
const paneId = Number(el.dataset.paneId)
const pane = panes.get(paneId)
if (pane) {
safeFit(pane)
}
return
}
// If it's a split, refit all panes inside it
if (el.classList.contains('pane-split')) {
const paneEls = el.querySelectorAll('.pane[data-pane-id]')
for (const paneEl of paneEls) {
const paneId = Number((paneEl as HTMLElement).dataset.paneId)
const pane = panes.get(paneId)
if (pane) {
safeFit(pane)
}
}
}
}
/**
* Detach a pane's container from the split tree without disposing the terminal.
* The sibling is promoted to take the split container's slot.
*/
export function detachPaneFromTree(pane: ManagedPaneInternal, callbacks: TreeOpsCallbacks): void {
const container = pane.container
const parent = container.parentElement
if (!parent) {
return
}
if (!parent.classList.contains('pane-split')) {
// Direct child of root — just remove it
container.remove()
return
}
// Find sibling (skip dividers)
const children = Array.from(parent.children).filter(
(child): child is HTMLElement =>
child instanceof HTMLElement &&
(child.classList.contains('pane') || child.classList.contains('pane-split'))
)
const sibling = children.find((c) => c !== container) ?? null
// Remove pane and dividers from the split
container.remove()
removeDividers(parent)
// Promote sibling to replace the split container
promoteSibling(sibling, parent, callbacks.getRoot())
}
/** Insert source pane next to target pane by wrapping target in a new split. */
export function insertPaneNextTo(
source: ManagedPaneInternal,
target: ManagedPaneInternal,
zone: DropZone,
callbacks: TreeOpsCallbacks
): void {
const targetContainer = target.container
const parent = targetContainer.parentElement
if (!parent) {
return
}
const isVertical = zone === 'left' || zone === 'right'
const sourceFirst = zone === 'left' || zone === 'top'
// Capture target's flex slot
const targetFlex = targetContainer.style.flex || ''
const targetMinW = targetContainer.style.minWidth || ''
const targetMinH = targetContainer.style.minHeight || ''
// Create split wrapper
const split = document.createElement('div')
split.className = `pane-split ${isVertical ? 'is-vertical' : 'is-horizontal'}`
split.style.display = 'flex'
split.style.flexDirection = isVertical ? 'row' : 'column'
if (parent.classList.contains('pane-split')) {
split.style.flex = targetFlex || '1 1 0%'
split.style.minWidth = targetMinW || '0'
split.style.minHeight = targetMinH || '0'
split.style.overflow = 'hidden'
} else {
split.style.width = '100%'
split.style.height = '100%'
}
// Create divider
const divider = createDivider(isVertical, callbacks.getStyleOptions(), {
refitPanesUnder: callbacks.refitPanesUnder,
onLayoutChanged: callbacks.onLayoutChanged
})
// Apply flex styles to both panes
applyPaneFlexStyle(source.container)
applyPaneFlexStyle(targetContainer)
// Replace target with the split in the DOM
parent.replaceChild(split, targetContainer)
// Build split: [first] [divider] [second]
if (sourceFirst) {
split.appendChild(source.container)
split.appendChild(divider)
split.appendChild(targetContainer)
} else {
split.appendChild(targetContainer)
split.appendChild(divider)
split.appendChild(source.container)
}
// Refit both
requestAnimationFrame(() => {
callbacks.safeFit(source)
callbacks.safeFit(target)
})
}
/**
* Promote a sibling element to replace its parent split container.
* Used when a pane is removed and the split wrapper becomes unnecessary.
*/
export function promoteSibling(
sibling: HTMLElement | null,
parent: HTMLElement,
root: HTMLElement
): void {
if (sibling) {
const grandparent = parent.parentElement
if (grandparent) {
if (grandparent === root) {
sibling.style.flex = ''
sibling.style.minWidth = ''
sibling.style.minHeight = ''
sibling.style.width = '100%'
sibling.style.height = '100%'
sibling.style.position = 'relative'
sibling.style.overflow = 'hidden'
} else if (grandparent.classList.contains('pane-split')) {
sibling.style.flex = parent.style.flex || '1 1 0%'
sibling.style.minWidth = parent.style.minWidth || '0'
sibling.style.minHeight = parent.style.minHeight || '0'
sibling.style.overflow = 'hidden'
}
grandparent.replaceChild(sibling, parent)
}
} else {
parent.remove()
}
}
/** Apply standard flex styles to a pane container inside a split. */
export function applyPaneFlexStyle(el: HTMLElement): void {
el.style.flex = '1 1 0%'
el.style.minWidth = '0'
el.style.minHeight = '0'
el.style.position = 'relative'
el.style.overflow = 'hidden'
// Clear any fixed width/height from createInitialPane so flex sizing
// controls the layout instead of the leftover 100% values.
el.style.width = ''
el.style.height = ''
}
/** Remove all divider elements from a parent element. */
export function removeDividers(parent: HTMLElement): void {
const dividers = Array.from(parent.children).filter(
(child): child is HTMLElement =>
child instanceof HTMLElement && child.classList.contains('pane-divider')
)
for (const d of dividers) {
d.remove()
}
}
/** Find non-divider children (panes and splits) of an element. */
export function findPaneChildren(parent: HTMLElement): HTMLElement[] {
return Array.from(parent.children).filter(
(child): child is HTMLElement =>
child instanceof HTMLElement &&
(child.classList.contains('pane') || child.classList.contains('pane-split'))
)
}
/**
* Create a flex split wrapper that replaces `existingContainer` in the DOM,
* then places [existing] [divider] [new] inside it.
*/
export function wrapInSplit(
existingContainer: HTMLElement,
newContainer: HTMLElement,
isVertical: boolean,
divider: HTMLElement,
opts?: { ratio?: number }
): void {
const parent = existingContainer.parentElement
if (!parent) {
return
}
// Capture the flex style BEFORE modifying
const existingFlex = existingContainer.style.flex || ''
const existingMinW = existingContainer.style.minWidth || ''
const existingMinH = existingContainer.style.minHeight || ''
// Create split container
const split = document.createElement('div')
split.className = `pane-split ${isVertical ? 'is-vertical' : 'is-horizontal'}`
split.style.display = 'flex'
split.style.flexDirection = isVertical ? 'row' : 'column'
if (parent.classList.contains('pane-split')) {
split.style.flex = existingFlex || '1 1 0%'
split.style.minWidth = existingMinW || '0'
split.style.minHeight = existingMinH || '0'
split.style.overflow = 'hidden'
} else {
split.style.width = '100%'
split.style.height = '100%'
}
// Apply flex styles to both pane containers
applyPaneFlexStyle(existingContainer)
applyPaneFlexStyle(newContainer)
// Apply custom ratio if provided
const ratio = opts?.ratio
if (ratio !== undefined && ratio > 0 && ratio < 1) {
existingContainer.style.flex = `${ratio} 1 0%`
newContainer.style.flex = `${1 - ratio} 1 0%`
}
// Replace existing with split in the DOM, then build children
parent.replaceChild(split, existingContainer)
split.appendChild(existingContainer)
split.appendChild(divider)
split.appendChild(newContainer)
}