mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +00:00
Break up large files into well-organized modules (#29)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+24
-11
@@ -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
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+6
-6
@@ -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,
|
||||
+8
-132
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user