mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
xterm
This commit is contained in:
+6
-1
@@ -32,13 +32,18 @@
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"electron-updater": "^6.3.9",
|
||||
"lucide-react": "^0.577.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"restty": "github:nwparker/restty#orca/desktop-word-line-selection",
|
||||
"shadcn": "^4.0.8",
|
||||
"simple-git": "^3.33.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
Generated
+48
-23
@@ -26,6 +26,24 @@ importers:
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.23
|
||||
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
'@xterm/addon-search':
|
||||
specifier: ^0.16.0
|
||||
version: 0.16.0
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
'@xterm/addon-web-links':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0
|
||||
'@xterm/addon-webgl':
|
||||
specifier: ^0.19.0
|
||||
version: 0.19.0
|
||||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -44,9 +62,6 @@ importers:
|
||||
radix-ui:
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
restty:
|
||||
specifier: github:nwparker/restty#orca/desktop-word-line-selection
|
||||
version: https://codeload.github.com/nwparker/restty/tar.gz/fb8a604be99f570fb437b414790ef36fe85c8f17(typescript@5.9.3)
|
||||
shadcn:
|
||||
specifier: ^4.0.8
|
||||
version: 4.0.8(@types/node@25.5.0)(typescript@5.9.3)
|
||||
@@ -2064,6 +2079,24 @@ packages:
|
||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
'@xterm/addon-fit@0.11.0':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
|
||||
'@xterm/addon-search@0.16.0':
|
||||
resolution: {integrity: sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==}
|
||||
|
||||
'@xterm/addon-unicode11@0.9.0':
|
||||
resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==}
|
||||
|
||||
'@xterm/addon-web-links@0.12.0':
|
||||
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
|
||||
|
||||
'@xterm/addon-webgl@0.19.0':
|
||||
resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==}
|
||||
|
||||
'@xterm/xterm@6.0.0':
|
||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
||||
|
||||
abbrev@3.0.1:
|
||||
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
@@ -3798,11 +3831,6 @@ packages:
|
||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
restty@https://codeload.github.com/nwparker/restty/tar.gz/fb8a604be99f570fb437b414790ef36fe85c8f17:
|
||||
resolution: {tarball: https://codeload.github.com/nwparker/restty/tar.gz/fb8a604be99f570fb437b414790ef36fe85c8f17}
|
||||
version: 0.1.34
|
||||
engines: {bun: '>=1.2.0'}
|
||||
|
||||
retry@0.12.0:
|
||||
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -4071,11 +4099,6 @@ packages:
|
||||
resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
text-shaper@0.1.18:
|
||||
resolution: {integrity: sha512-ZfuqKPupphWz6OS3IhlEc5tPmJFmOJVqhp5Q4uQc32zrHwfu6uY/cIsv0i/tirUVSfVDohmVpuGr5lYOVyK13w==}
|
||||
peerDependencies:
|
||||
typescript: ^5
|
||||
|
||||
tiny-async-pool@1.3.0:
|
||||
resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==}
|
||||
|
||||
@@ -6224,6 +6247,18 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
|
||||
'@xterm/addon-fit@0.11.0': {}
|
||||
|
||||
'@xterm/addon-search@0.16.0': {}
|
||||
|
||||
'@xterm/addon-unicode11@0.9.0': {}
|
||||
|
||||
'@xterm/addon-web-links@0.12.0': {}
|
||||
|
||||
'@xterm/addon-webgl@0.19.0': {}
|
||||
|
||||
'@xterm/xterm@6.0.0': {}
|
||||
|
||||
abbrev@3.0.1: {}
|
||||
|
||||
accepts@2.0.0:
|
||||
@@ -8147,12 +8182,6 @@ snapshots:
|
||||
onetime: 7.0.0
|
||||
signal-exit: 4.1.0
|
||||
|
||||
restty@https://codeload.github.com/nwparker/restty/tar.gz/fb8a604be99f570fb437b414790ef36fe85c8f17(typescript@5.9.3):
|
||||
dependencies:
|
||||
text-shaper: 0.1.18(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
retry@0.12.0: {}
|
||||
|
||||
rettime@0.10.1: {}
|
||||
@@ -8511,10 +8540,6 @@ snapshots:
|
||||
mkdirp: 0.5.6
|
||||
rimraf: 2.6.3
|
||||
|
||||
text-shaper@0.1.18(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
tiny-async-pool@1.3.0:
|
||||
dependencies:
|
||||
semver: 5.7.2
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -397,21 +398,38 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ensure Restty pane root fills its absolutely-positioned container */
|
||||
.restty-pane-root {
|
||||
/* Ensure pane manager root fills its absolutely-positioned container */
|
||||
.pane-manager-root {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.restty-pane-root .pane-divider.is-vertical,
|
||||
.restty-pane-root .pane-divider.is-horizontal {
|
||||
/* Reset the global border/outline base rule inside terminal panes */
|
||||
.pane-manager-root *,
|
||||
.pane-manager-root *::before,
|
||||
.pane-manager-root *::after {
|
||||
border-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pane-manager-root .xterm {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pane-manager-root .xterm-viewport {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pane-divider.is-vertical,
|
||||
.pane-divider.is-horizontal {
|
||||
background: var(--orca-terminal-divider-color, transparent) !important;
|
||||
}
|
||||
|
||||
.restty-pane-root .pane-divider.is-vertical:hover,
|
||||
.restty-pane-root .pane-divider.is-vertical.is-dragging,
|
||||
.restty-pane-root .pane-divider.is-horizontal:hover,
|
||||
.restty-pane-root .pane-divider.is-horizontal.is-dragging {
|
||||
.pane-divider.is-vertical:hover,
|
||||
.pane-divider.is-vertical.is-dragging,
|
||||
.pane-divider.is-horizontal:hover,
|
||||
.pane-divider.is-horizontal.is-dragging {
|
||||
background: var(
|
||||
--orca-terminal-divider-color-strong,
|
||||
var(--orca-terminal-divider-color, transparent)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Restty, getBuiltinTheme } from 'restty'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { ITheme } from '@xterm/xterm'
|
||||
import {
|
||||
Clipboard,
|
||||
Copy,
|
||||
@@ -28,13 +28,14 @@ import type {
|
||||
import { useAppStore } from '../store'
|
||||
import {
|
||||
DEFAULT_TERMINAL_DIVIDER_DARK,
|
||||
buildTerminalFontMatchers,
|
||||
colorToCss,
|
||||
getCursorStyleSequence,
|
||||
getBuiltinTheme,
|
||||
normalizeColor,
|
||||
resolvePaneStyleOptions,
|
||||
resolveEffectiveTerminalAppearance
|
||||
} from '@/lib/terminal-theme'
|
||||
import { PaneManager, type ManagedPane } from '@/lib/pane-manager'
|
||||
import TerminalSearch from '@/components/TerminalSearch'
|
||||
|
||||
type PtyTransport = {
|
||||
connect: (options: {
|
||||
@@ -259,25 +260,15 @@ function paneLeafId(paneId: number): string {
|
||||
return `pane:${paneId}`
|
||||
}
|
||||
|
||||
function buildTerminalFontSources(fontFamily: string) {
|
||||
return [
|
||||
{
|
||||
type: 'local' as const,
|
||||
label: fontFamily || 'Preferred terminal font',
|
||||
matchers: buildTerminalFontMatchers(fontFamily),
|
||||
required: true
|
||||
},
|
||||
{
|
||||
type: 'local' as const,
|
||||
label: 'Menlo',
|
||||
matchers: ['menlo', 'menlo regular']
|
||||
},
|
||||
{
|
||||
type: 'local' as const,
|
||||
label: 'Monospace fallback',
|
||||
matchers: ['dejavu sans mono', 'liberation mono', 'ubuntu mono', 'monospace']
|
||||
}
|
||||
]
|
||||
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(', ')
|
||||
}
|
||||
|
||||
function getLayoutChildNodes(split: HTMLElement): HTMLElement[] {
|
||||
@@ -327,13 +318,13 @@ function serializeTerminalLayout(
|
||||
}
|
||||
|
||||
function replayTerminalLayout(
|
||||
restty: Restty,
|
||||
manager: PaneManager,
|
||||
snapshot: TerminalLayoutSnapshot | null | undefined,
|
||||
focusInitialPane: boolean
|
||||
): Map<string, number> {
|
||||
const paneByLeafId = new Map<string, number>()
|
||||
|
||||
const initialPane = restty.createInitialPane({ focus: focusInitialPane })
|
||||
const initialPane = manager.createInitialPane({ focus: focusInitialPane })
|
||||
if (!snapshot?.root) {
|
||||
paneByLeafId.set(paneLeafId(initialPane.id), initialPane.id)
|
||||
return paneByLeafId
|
||||
@@ -345,7 +336,7 @@ function replayTerminalLayout(
|
||||
return
|
||||
}
|
||||
|
||||
const createdPane = restty.splitPane(paneId, node.direction as TerminalPaneSplitDirection)
|
||||
const createdPane = manager.splitPane(paneId, node.direction as TerminalPaneSplitDirection)
|
||||
if (!createdPane) {
|
||||
collectLeafIds(node, paneByLeafId, paneId)
|
||||
return
|
||||
@@ -388,7 +379,7 @@ export default function TerminalPane({
|
||||
onPtyExit
|
||||
}: TerminalPaneProps): React.JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const resttyRef = useRef<Restty | null>(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())
|
||||
@@ -396,9 +387,12 @@ export default function TerminalPane({
|
||||
const expandedStyleSnapshotRef = useRef<Map<HTMLElement, { display: string; flex: string }>>(
|
||||
new Map()
|
||||
)
|
||||
// Track transports per pane for PTY communication
|
||||
const paneTransportsRef = useRef<Map<number, PtyTransport>>(new Map())
|
||||
const [terminalMenuOpen, setTerminalMenuOpen] = useState(false)
|
||||
const [terminalMenuPoint, setTerminalMenuPoint] = useState({ x: 0, y: 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)
|
||||
@@ -406,10 +400,10 @@ export default function TerminalPane({
|
||||
const initialLayoutRef = useRef(savedLayout)
|
||||
|
||||
const persistLayoutSnapshot = (): void => {
|
||||
const restty = resttyRef.current
|
||||
const manager = managerRef.current
|
||||
const container = containerRef.current
|
||||
if (!restty || !container) return
|
||||
const activePaneId = restty.getActivePane()?.id ?? restty.getPanes()[0]?.id ?? null
|
||||
if (!manager || !container) return
|
||||
const activePaneId = manager.getActivePane()?.id ?? manager.getPanes()[0]?.id ?? null
|
||||
setTabLayout(tabId, serializeTerminalLayout(container, activePaneId, expandedPaneIdRef.current))
|
||||
}
|
||||
|
||||
@@ -438,11 +432,11 @@ export default function TerminalPane({
|
||||
}
|
||||
|
||||
const applyExpandedLayout = (paneId: number): boolean => {
|
||||
const restty = resttyRef.current
|
||||
const manager = managerRef.current
|
||||
const root = containerRef.current
|
||||
if (!restty || !root) return false
|
||||
if (!manager || !root) return false
|
||||
|
||||
const panes = restty.getPanes()
|
||||
const panes = manager.getPanes()
|
||||
if (panes.length <= 1) return false
|
||||
const targetPane = panes.find((pane) => pane.id === paneId)
|
||||
if (!targetPane) return false
|
||||
@@ -470,15 +464,19 @@ export default function TerminalPane({
|
||||
|
||||
const refreshPaneSizes = (focusActive: boolean): void => {
|
||||
requestAnimationFrame(() => {
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
const panes = restty.getPanes()
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
const panes = manager.getPanes()
|
||||
for (const p of panes) {
|
||||
p.app.updateSize(true)
|
||||
try {
|
||||
p.fitAddon.fit()
|
||||
} catch {
|
||||
/* container may not have dimensions */
|
||||
}
|
||||
}
|
||||
if (focusActive) {
|
||||
const active = restty.getActivePane() ?? panes[0]
|
||||
active?.canvas.focus({ preventScroll: true })
|
||||
const active = manager.getActivePane() ?? panes[0]
|
||||
active?.terminal.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -490,9 +488,9 @@ export default function TerminalPane({
|
||||
return
|
||||
}
|
||||
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
const panes = restty.getPanes()
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
const panes = manager.getPanes()
|
||||
if (panes.length <= 1 || !panes.some((pane) => pane.id === paneId)) {
|
||||
setExpandedPane(null)
|
||||
restoreExpandedLayout()
|
||||
@@ -502,14 +500,14 @@ export default function TerminalPane({
|
||||
}
|
||||
|
||||
const syncCanExpandState = (): void => {
|
||||
const paneCount = resttyRef.current?.getPanes().length ?? 1
|
||||
const paneCount = managerRef.current?.getPanes().length ?? 1
|
||||
setTabCanExpandPane(tabId, paneCount > 1)
|
||||
}
|
||||
|
||||
const toggleExpandPane = (paneId: number): void => {
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
const panes = restty.getPanes()
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
const panes = manager.getPanes()
|
||||
if (panes.length <= 1) return
|
||||
|
||||
const isAlreadyExpanded = expandedPaneIdRef.current === paneId
|
||||
@@ -528,7 +526,7 @@ export default function TerminalPane({
|
||||
persistLayoutSnapshot()
|
||||
return
|
||||
}
|
||||
restty.setActivePane(paneId, { focus: true })
|
||||
manager.setActivePane(paneId, { focus: true })
|
||||
refreshPaneSizes(true)
|
||||
persistLayoutSnapshot()
|
||||
}
|
||||
@@ -552,7 +550,7 @@ export default function TerminalPane({
|
||||
const settingsRef = useRef(settings)
|
||||
settingsRef.current = settings
|
||||
|
||||
// Use a ref so the Restty closure always calls the latest onPtyExit
|
||||
// Use a ref so the PaneManager closure always calls the latest onPtyExit
|
||||
const onPtyExitRef = useRef(onPtyExit)
|
||||
onPtyExitRef.current = onPtyExit
|
||||
|
||||
@@ -566,7 +564,7 @@ export default function TerminalPane({
|
||||
return () => media.removeEventListener('change', handleChange)
|
||||
}, [])
|
||||
|
||||
const applyTerminalAppearance = (restty: Restty): void => {
|
||||
const applyTerminalAppearance = (manager: PaneManager): void => {
|
||||
const currentSettings = settingsRef.current
|
||||
if (!currentSettings) return
|
||||
|
||||
@@ -576,18 +574,28 @@ export default function TerminalPane({
|
||||
currentSettings.terminalCursorStyle,
|
||||
currentSettings.terminalCursorBlink
|
||||
)
|
||||
const theme = appearance.theme ?? getBuiltinTheme(appearance.themeName)
|
||||
const paneBackground = colorToCss(theme?.colors.background, '#000000')
|
||||
for (const pane of restty.getPanes()) {
|
||||
const theme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName)
|
||||
const paneBackground = theme?.background ?? '#000000'
|
||||
|
||||
for (const pane of manager.getPanes()) {
|
||||
if (theme) {
|
||||
pane.app.applyTheme(theme, appearance.themeName)
|
||||
pane.terminal.options.theme = theme
|
||||
}
|
||||
pane.terminal.options.cursorStyle = currentSettings.terminalCursorStyle
|
||||
pane.terminal.options.cursorBlink = currentSettings.terminalCursorBlink
|
||||
const paneSize = paneFontSizesRef.current.get(pane.id)
|
||||
pane.app.setFontSize(paneSize ?? currentSettings.terminalFontSize)
|
||||
pane.app.sendInput(cursorSequence, 'pty')
|
||||
pane.terminal.options.fontSize = paneSize ?? currentSettings.terminalFontSize
|
||||
try {
|
||||
pane.fitAddon.fit()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Send cursor style sequence to PTY
|
||||
const transport = paneTransportsRef.current.get(pane.id)
|
||||
transport?.sendInput(cursorSequence)
|
||||
}
|
||||
|
||||
restty.setPaneStyleOptions({
|
||||
manager.setPaneStyleOptions({
|
||||
splitBackground: paneBackground,
|
||||
paneBackground,
|
||||
inactivePaneOpacity: paneStyles.inactivePaneOpacity,
|
||||
@@ -597,27 +605,16 @@ export default function TerminalPane({
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize Restty 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 restty = resttyRef.current
|
||||
if (!restty) return
|
||||
const panes = restty.getPanes()
|
||||
for (const p of panes) {
|
||||
p.app.updateSize(true)
|
||||
}
|
||||
if (focusActive) {
|
||||
const active = restty.getActivePane() ?? panes[0]
|
||||
active?.canvas.focus({ preventScroll: true })
|
||||
}
|
||||
})
|
||||
// Connect a pane's terminal to a PTY via IPC transport
|
||||
const connectPanePty = (pane: ManagedPane, manager: PaneManager): void => {
|
||||
const onExit = (ptyId: string): void => {
|
||||
const panes = manager.getPanes()
|
||||
if (panes.length <= 1) {
|
||||
clearTabPtyId(tabId, ptyId)
|
||||
onPtyExitRef.current(ptyId)
|
||||
return
|
||||
}
|
||||
manager.closePane(pane.id)
|
||||
}
|
||||
|
||||
const onTitleChange = (title: string): void => {
|
||||
@@ -627,54 +624,80 @@ export default function TerminalPane({
|
||||
const onPtySpawn = (ptyId: string): void => updateTabPtyId(tabId, ptyId)
|
||||
const onBell = (): void => markWorktreeUnreadFromBell(worktreeId)
|
||||
|
||||
const transport = createIpcPtyTransport(cwd, onExit, onTitleChange, onPtySpawn, onBell)
|
||||
paneTransportsRef.current.set(pane.id, transport)
|
||||
|
||||
// Wire terminal → PTY
|
||||
pane.terminal.onData((data) => {
|
||||
transport.sendInput(data)
|
||||
})
|
||||
|
||||
// Wire terminal resize → PTY resize
|
||||
pane.terminal.onResize(({ cols, rows }) => {
|
||||
transport.resize(cols, rows)
|
||||
})
|
||||
|
||||
// Connect PTY and wire PTY → terminal
|
||||
const cols = pane.terminal.cols
|
||||
const rows = pane.terminal.rows
|
||||
transport.connect({
|
||||
url: '',
|
||||
cols,
|
||||
rows,
|
||||
callbacks: {
|
||||
onData: (data) => {
|
||||
pane.terminal.write(data)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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 manager = managerRef.current
|
||||
if (!manager) return
|
||||
const panes = manager.getPanes()
|
||||
for (const p of panes) {
|
||||
try {
|
||||
p.fitAddon.fit()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (focusActive) {
|
||||
const active = manager.getActivePane() ?? panes[0]
|
||||
active?.terminal.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let shouldPersistLayout = false
|
||||
|
||||
const restty = new Restty({
|
||||
root: container,
|
||||
createInitialPane: false,
|
||||
autoInit: false,
|
||||
shortcuts: { enabled: false },
|
||||
defaultContextMenu: false,
|
||||
appOptions: ({ id }) => {
|
||||
const currentSettings = settingsRef.current
|
||||
const onExit = (ptyId: string): void => {
|
||||
// Schedule close via parent
|
||||
const panes = restty.getPanes()
|
||||
if (panes.length <= 1) {
|
||||
clearTabPtyId(tabId, ptyId)
|
||||
onPtyExitRef.current(ptyId)
|
||||
return
|
||||
}
|
||||
restty.closePane(id)
|
||||
}
|
||||
return {
|
||||
renderer: 'auto',
|
||||
fontSize: currentSettings?.terminalFontSize ?? 14,
|
||||
fontSizeMode: 'em',
|
||||
fontHinting: true,
|
||||
fontHintTarget: 'normal',
|
||||
fontThicken: true,
|
||||
alphaBlending: 'linear-corrected',
|
||||
maxScrollbackBytes: currentSettings?.terminalScrollbackBytes ?? 10_000_000,
|
||||
ptyTransport: createIpcPtyTransport(
|
||||
cwd,
|
||||
onExit,
|
||||
onTitleChange,
|
||||
onPtySpawn,
|
||||
onBell
|
||||
) as never,
|
||||
fontSources: buildTerminalFontSources(currentSettings?.terminalFontFamily ?? 'SF Mono')
|
||||
}
|
||||
},
|
||||
onPaneCreated: async (pane) => {
|
||||
await pane.app.init()
|
||||
applyTerminalAppearance(restty)
|
||||
pane.app.updateSize(true)
|
||||
pane.app.connectPty('')
|
||||
pane.canvas.focus({ preventScroll: true })
|
||||
const manager = new PaneManager(container, {
|
||||
onPaneCreated: (pane) => {
|
||||
// Apply appearance before connecting PTY
|
||||
applyTerminalAppearance(manager)
|
||||
// Connect PTY
|
||||
connectPanePty(pane, manager)
|
||||
queueResizeAll(true)
|
||||
},
|
||||
onPaneClosed: () => {},
|
||||
onPaneClosed: (paneId) => {
|
||||
// Clean up transport for closed pane
|
||||
const transport = paneTransportsRef.current.get(paneId)
|
||||
if (transport) {
|
||||
transport.destroy?.()
|
||||
paneTransportsRef.current.delete(paneId)
|
||||
}
|
||||
paneFontSizesRef.current.delete(paneId)
|
||||
},
|
||||
onActivePaneChange: () => {
|
||||
if (shouldPersistLayout) persistLayoutSnapshot()
|
||||
},
|
||||
@@ -683,25 +706,41 @@ export default function TerminalPane({
|
||||
syncCanExpandState()
|
||||
queueResizeAll(false)
|
||||
if (shouldPersistLayout) persistLayoutSnapshot()
|
||||
},
|
||||
terminalOptions: () => {
|
||||
const currentSettings = settingsRef.current
|
||||
return {
|
||||
fontSize: currentSettings?.terminalFontSize ?? 14,
|
||||
fontFamily: buildFontFamily(currentSettings?.terminalFontFamily ?? 'SF Mono'),
|
||||
scrollback: Math.max(
|
||||
1000,
|
||||
Math.round((currentSettings?.terminalScrollbackBytes ?? 10_000_000) / 100)
|
||||
),
|
||||
cursorStyle: currentSettings?.terminalCursorStyle ?? 'bar',
|
||||
cursorBlink: currentSettings?.terminalCursorBlink ?? true
|
||||
}
|
||||
},
|
||||
onLinkClick: (url) => {
|
||||
window.api.shell.openExternal(url)
|
||||
}
|
||||
})
|
||||
|
||||
resttyRef.current = restty
|
||||
const restoredPaneByLeafId = replayTerminalLayout(restty, initialLayoutRef.current, isActive)
|
||||
managerRef.current = manager
|
||||
const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive)
|
||||
const restoredActivePaneId =
|
||||
(initialLayoutRef.current.activeLeafId
|
||||
? restoredPaneByLeafId.get(initialLayoutRef.current.activeLeafId)
|
||||
: null) ??
|
||||
restty.getActivePane()?.id ??
|
||||
restty.getPanes()[0]?.id ??
|
||||
manager.getActivePane()?.id ??
|
||||
manager.getPanes()[0]?.id ??
|
||||
null
|
||||
if (restoredActivePaneId !== null) {
|
||||
restty.setActivePane(restoredActivePaneId, { focus: isActive })
|
||||
manager.setActivePane(restoredActivePaneId, { focus: isActive })
|
||||
}
|
||||
const restoredExpandedPaneId = initialLayoutRef.current.expandedLeafId
|
||||
? (restoredPaneByLeafId.get(initialLayoutRef.current.expandedLeafId) ?? null)
|
||||
: null
|
||||
if (restoredExpandedPaneId !== null && restty.getPanes().length > 1) {
|
||||
if (restoredExpandedPaneId !== null && manager.getPanes().length > 1) {
|
||||
setExpandedPane(restoredExpandedPaneId)
|
||||
applyExpandedLayout(restoredExpandedPaneId)
|
||||
} else {
|
||||
@@ -709,15 +748,20 @@ export default function TerminalPane({
|
||||
}
|
||||
shouldPersistLayout = true
|
||||
syncCanExpandState()
|
||||
applyTerminalAppearance(restty)
|
||||
applyTerminalAppearance(manager)
|
||||
queueResizeAll(isActive)
|
||||
persistLayoutSnapshot()
|
||||
|
||||
return () => {
|
||||
if (resizeRaf !== null) cancelAnimationFrame(resizeRaf)
|
||||
restoreExpandedLayout()
|
||||
restty.destroy()
|
||||
resttyRef.current = null
|
||||
// Destroy all transports
|
||||
for (const transport of paneTransportsRef.current.values()) {
|
||||
transport.destroy?.()
|
||||
}
|
||||
paneTransportsRef.current.clear()
|
||||
manager.destroy()
|
||||
managerRef.current = null
|
||||
setTabPaneExpanded(tabId, false)
|
||||
setTabCanExpandPane(tabId, false)
|
||||
}
|
||||
@@ -725,16 +769,19 @@ export default function TerminalPane({
|
||||
}, [tabId, cwd])
|
||||
|
||||
useEffect(() => {
|
||||
const restty = resttyRef.current
|
||||
if (!restty || !settings) return
|
||||
applyTerminalAppearance(restty)
|
||||
void Promise.all(
|
||||
restty
|
||||
.getPanes()
|
||||
.map((pane) =>
|
||||
pane.app.setFontSources(buildTerminalFontSources(settings.terminalFontFamily))
|
||||
)
|
||||
)
|
||||
const manager = managerRef.current
|
||||
if (!manager || !settings) return
|
||||
applyTerminalAppearance(manager)
|
||||
// Update font family on all panes
|
||||
const fontFamily = buildFontFamily(settings.terminalFontFamily)
|
||||
for (const pane of manager.getPanes()) {
|
||||
pane.terminal.options.fontFamily = fontFamily
|
||||
try {
|
||||
pane.fitAddon.fit()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [settings, systemPrefersDark])
|
||||
|
||||
// Per-pane font zoom via Cmd+Plus/Minus/0
|
||||
@@ -745,9 +792,9 @@ export default function TerminalPane({
|
||||
const FONT_SIZE_STEP = 1
|
||||
|
||||
return window.api.ui.onTerminalZoom((direction) => {
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
const pane = restty.getActivePane()
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
const pane = manager.getActivePane()
|
||||
if (!pane) return
|
||||
|
||||
const globalSize = settingsRef.current?.terminalFontSize ?? 14
|
||||
@@ -765,25 +812,34 @@ export default function TerminalPane({
|
||||
paneFontSizesRef.current.set(pane.id, nextSize)
|
||||
}
|
||||
|
||||
pane.app.setFontSize(nextSize)
|
||||
pane.terminal.options.fontSize = nextSize
|
||||
try {
|
||||
pane.fitAddon.fit()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
}, [isActive])
|
||||
|
||||
// Handle focus and resize when tab becomes active
|
||||
useEffect(() => {
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
|
||||
if (isActive) {
|
||||
// Ensure size/focus is correct both on initial mount and tab activation.
|
||||
requestAnimationFrame(() => {
|
||||
const panes = restty.getPanes()
|
||||
const panes = manager.getPanes()
|
||||
for (const p of panes) {
|
||||
p.app.updateSize(true)
|
||||
try {
|
||||
p.fitAddon.fit()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const active = restty.getActivePane() ?? panes[0]
|
||||
const active = manager.getActivePane() ?? panes[0]
|
||||
if (active) {
|
||||
active.canvas.focus({ preventScroll: true })
|
||||
active.terminal.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -794,11 +850,11 @@ export default function TerminalPane({
|
||||
const onToggleExpand = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<{ tabId?: string }>).detail
|
||||
if (!detail?.tabId || detail.tabId !== tabId) return
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
const panes = restty.getPanes()
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
const panes = manager.getPanes()
|
||||
if (panes.length < 2) return
|
||||
const pane = restty.getActivePane() ?? panes[0]
|
||||
const pane = manager.getActivePane() ?? panes[0]
|
||||
if (!pane) return
|
||||
toggleExpandPane(pane.id)
|
||||
}
|
||||
@@ -815,11 +871,15 @@ export default function TerminalPane({
|
||||
if (!container) return
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
const panes = restty.getPanes()
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
const panes = manager.getPanes()
|
||||
for (const p of panes) {
|
||||
p.app.updateSize(true)
|
||||
try {
|
||||
p.fitAddon.fit()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
ro.observe(container)
|
||||
@@ -835,47 +895,52 @@ export default function TerminalPane({
|
||||
if (e.repeat) return
|
||||
if (!e.metaKey || e.altKey || e.ctrlKey) return
|
||||
|
||||
const restty = resttyRef.current
|
||||
if (!restty) 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 activePane = restty.activePane?.()
|
||||
if (activePane) {
|
||||
activePane.clearScreen()
|
||||
return
|
||||
const pane = manager.getActivePane() ?? manager.getPanes()[0]
|
||||
if (pane) {
|
||||
pane.terminal.clear()
|
||||
}
|
||||
const pane = restty.getActivePane() ?? restty.getPanes()[0]
|
||||
pane?.app.clearScreen()
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd+[ / Cmd+] cycles active split pane focus.
|
||||
if (!e.shiftKey && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
|
||||
const panes = restty.getPanes()
|
||||
const panes = manager.getPanes()
|
||||
if (panes.length < 2) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const activeId = restty.getActivePane()?.id ?? panes[0].id
|
||||
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]
|
||||
restty.setActivePane(nextPane.id, { focus: true })
|
||||
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 = restty.getPanes()
|
||||
const panes = manager.getPanes()
|
||||
if (panes.length < 2) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const pane = restty.getActivePane() ?? panes[0]
|
||||
const pane = manager.getActivePane() ?? panes[0]
|
||||
if (!pane) return
|
||||
toggleExpandPane(pane.id)
|
||||
return
|
||||
@@ -884,13 +949,13 @@ export default function TerminalPane({
|
||||
// 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 = restty.getPanes()
|
||||
const panes = manager.getPanes()
|
||||
if (panes.length < 2) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const pane = restty.getActivePane() ?? panes[0]
|
||||
const pane = manager.getActivePane() ?? panes[0]
|
||||
if (!pane) return
|
||||
restty.closePane(pane.id)
|
||||
manager.closePane(pane.id)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -898,58 +963,58 @@ export default function TerminalPane({
|
||||
if (e.key.toLowerCase() === 'd') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const pane = restty.getActivePane() ?? restty.getPanes()[0]
|
||||
const pane = manager.getActivePane() ?? manager.getPanes()[0]
|
||||
if (!pane) return
|
||||
restty.splitPane(pane.id, e.shiftKey ? 'horizontal' : 'vertical')
|
||||
manager.splitPane(pane.id, e.shiftKey ? 'horizontal' : 'vertical')
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Backspace → send \x17 (backward-kill-word) to PTY.
|
||||
// Most terminal emulators map this shortcut but xterm.js/Restty does not
|
||||
// by default, so we intercept at the capture phase and forward manually.
|
||||
const onCtrlBackspace = (e: KeyboardEvent): void => {
|
||||
if (!e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return
|
||||
if (e.key !== 'Backspace') return
|
||||
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const pane = restty.getActivePane() ?? restty.getPanes()[0]
|
||||
pane?.app.sendKeyInput('\x17')
|
||||
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.
|
||||
// Standard macOS terminal behaviour; Restty does not map this by default.
|
||||
const onAltBackspace = (e: KeyboardEvent): void => {
|
||||
if (!e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) return
|
||||
if (e.key !== 'Backspace') return
|
||||
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const pane = restty.getActivePane() ?? restty.getPanes()[0]
|
||||
pane?.app.sendKeyInput('\x1b\x7f')
|
||||
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.
|
||||
// Sends Ctrl+V (\x16, quoted-insert) followed by LF (\x0a) so that
|
||||
// both bash (readline) and zsh (zle) insert a newline character instead
|
||||
// of executing the command.
|
||||
const onShiftEnter = (e: KeyboardEvent): void => {
|
||||
if (!e.shiftKey || e.metaKey || e.altKey || e.ctrlKey) return
|
||||
if (e.key !== 'Enter') return
|
||||
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const pane = restty.getActivePane() ?? restty.getPanes()[0]
|
||||
pane?.app.sendKeyInput('\x16\x0a')
|
||||
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 })
|
||||
@@ -965,53 +1030,60 @@ export default function TerminalPane({
|
||||
}, [isActive])
|
||||
|
||||
const resolveMenuPane = () => {
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return null
|
||||
const panes = restty.getPanes()
|
||||
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 restty.getActivePane() ?? panes[0] ?? null
|
||||
return manager.getActivePane() ?? panes[0] ?? null
|
||||
}
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
const pane = resolveMenuPane()
|
||||
if (!pane) return
|
||||
await pane.app.copySelectionToClipboard()
|
||||
const selection = pane.terminal.getSelection()
|
||||
if (selection) {
|
||||
await navigator.clipboard.writeText(selection)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = async (): Promise<void> => {
|
||||
const pane = resolveMenuPane()
|
||||
if (!pane) return
|
||||
await pane.app.pasteFromClipboard()
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) {
|
||||
const transport = paneTransportsRef.current.get(pane.id)
|
||||
transport?.sendInput(text)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSplitRight = (): void => {
|
||||
const pane = resolveMenuPane()
|
||||
if (!pane) return
|
||||
resttyRef.current?.splitPane(pane.id, 'vertical')
|
||||
managerRef.current?.splitPane(pane.id, 'vertical')
|
||||
}
|
||||
|
||||
const handleSplitDown = (): void => {
|
||||
const pane = resolveMenuPane()
|
||||
if (!pane) return
|
||||
resttyRef.current?.splitPane(pane.id, 'horizontal')
|
||||
managerRef.current?.splitPane(pane.id, 'horizontal')
|
||||
}
|
||||
|
||||
const handleClosePane = (): void => {
|
||||
const pane = resolveMenuPane()
|
||||
if (!pane) return
|
||||
const panes = resttyRef.current?.getPanes() ?? []
|
||||
const panes = managerRef.current?.getPanes() ?? []
|
||||
if (panes.length <= 1) return
|
||||
resttyRef.current?.closePane(pane.id)
|
||||
managerRef.current?.closePane(pane.id)
|
||||
}
|
||||
|
||||
const handleClearScreen = (): void => {
|
||||
const pane = resolveMenuPane()
|
||||
if (!pane) return
|
||||
pane.app.clearScreen()
|
||||
pane.terminal.clear()
|
||||
}
|
||||
|
||||
const handleToggleExpand = (): void => {
|
||||
@@ -1020,7 +1092,7 @@ export default function TerminalPane({
|
||||
toggleExpandPane(pane.id)
|
||||
}
|
||||
|
||||
const paneCount = resttyRef.current?.getPanes().length ?? 1
|
||||
const paneCount = managerRef.current?.getPanes().length ?? 1
|
||||
const canClosePane = paneCount > 1
|
||||
const canExpandPane = paneCount > 1
|
||||
const menuPaneId = resolveMenuPane()?.id ?? null
|
||||
@@ -1038,6 +1110,10 @@ export default function TerminalPane({
|
||||
)
|
||||
}
|
||||
|
||||
// Get the search addon for the active pane
|
||||
const activePane = managerRef.current?.getActivePane()
|
||||
const activeSearchAddon = activePane?.searchAddon ?? null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -1048,8 +1124,8 @@ export default function TerminalPane({
|
||||
event.preventDefault()
|
||||
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
|
||||
|
||||
const restty = resttyRef.current
|
||||
if (!restty) {
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
contextPaneIdRef.current = null
|
||||
return
|
||||
}
|
||||
@@ -1060,7 +1136,7 @@ export default function TerminalPane({
|
||||
return
|
||||
}
|
||||
const clickedPane =
|
||||
restty.getPanes().find((pane) => pane.container.contains(target)) ?? null
|
||||
manager.getPanes().find((pane) => pane.container.contains(target)) ?? null
|
||||
contextPaneIdRef.current = clickedPane?.id ?? null
|
||||
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
@@ -1068,6 +1144,11 @@ export default function TerminalPane({
|
||||
setTerminalMenuOpen(true)
|
||||
}}
|
||||
/>
|
||||
<TerminalSearch
|
||||
isOpen={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
searchAddon={activeSearchAddon}
|
||||
/>
|
||||
<DropdownMenu open={terminalMenuOpen} onOpenChange={setTerminalMenuOpen} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { ChevronUp, ChevronDown, X, CaseSensitive, Regex } from 'lucide-react'
|
||||
import type { SearchAddon } from '@xterm/addon-search'
|
||||
|
||||
interface TerminalSearchProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
searchAddon: SearchAddon | null
|
||||
}
|
||||
|
||||
export default function TerminalSearch({
|
||||
isOpen,
|
||||
onClose,
|
||||
searchAddon
|
||||
}: TerminalSearchProps): React.JSX.Element | null {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [caseSensitive, setCaseSensitive] = useState(false)
|
||||
const [regex, setRegex] = useState(false)
|
||||
|
||||
const searchOptions = useCallback(
|
||||
(incremental = false) => ({ caseSensitive, regex, incremental }),
|
||||
[caseSensitive, regex]
|
||||
)
|
||||
|
||||
const findNext = useCallback(() => {
|
||||
if (searchAddon && query) {
|
||||
searchAddon.findNext(query, searchOptions())
|
||||
}
|
||||
}, [searchAddon, query, searchOptions])
|
||||
|
||||
const findPrevious = useCallback(() => {
|
||||
if (searchAddon && query) {
|
||||
searchAddon.findPrevious(query, searchOptions())
|
||||
}
|
||||
}, [searchAddon, query, searchOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
inputRef.current?.focus()
|
||||
} else {
|
||||
searchAddon?.clearDecorations()
|
||||
}
|
||||
}, [isOpen, searchAddon])
|
||||
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
searchAddon?.clearDecorations()
|
||||
return
|
||||
}
|
||||
if (searchAddon && isOpen) {
|
||||
searchAddon.findNext(query, searchOptions(true))
|
||||
}
|
||||
}, [query, searchAddon, isOpen, searchOptions])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
} else if (e.key === 'Enter' && e.shiftKey) {
|
||||
findPrevious()
|
||||
} else if (e.key === 'Enter') {
|
||||
findNext()
|
||||
}
|
||||
},
|
||||
[onClose, findNext, findPrevious]
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-2 right-2 z-50 flex items-center gap-1 rounded-lg border border-zinc-700 bg-zinc-800/95 px-2 py-1 shadow-lg backdrop-blur-sm"
|
||||
style={{ width: 300 }}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="min-w-0 flex-1 border-none bg-transparent text-sm text-white outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => setCaseSensitive((v) => !v)}
|
||||
className={`flex size-6 shrink-0 items-center justify-center rounded ${
|
||||
caseSensitive ? 'bg-zinc-700/50 text-blue-400' : 'text-zinc-400 hover:text-zinc-200'
|
||||
}`}
|
||||
title="Case sensitive"
|
||||
>
|
||||
<CaseSensitive size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setRegex((v) => !v)}
|
||||
className={`flex size-6 shrink-0 items-center justify-center rounded ${
|
||||
regex ? 'bg-zinc-700/50 text-blue-400' : 'text-zinc-400 hover:text-zinc-200'
|
||||
}`}
|
||||
title="Regex"
|
||||
>
|
||||
<Regex size={14} />
|
||||
</button>
|
||||
|
||||
<div className="mx-0.5 h-4 w-px bg-zinc-700" />
|
||||
|
||||
<button
|
||||
onClick={findPrevious}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
|
||||
title="Previous match"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={findNext}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
|
||||
title="Next match"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
|
||||
<div className="mx-0.5 h-4 w-px bg-zinc-700" />
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
|
||||
title="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
colorToCss,
|
||||
terminalPalettePreview,
|
||||
type EffectiveTerminalAppearance
|
||||
} from '@/lib/terminal-theme'
|
||||
import { terminalPalettePreview, type EffectiveTerminalAppearance } from '@/lib/terminal-theme'
|
||||
|
||||
type TerminalThemePreviewProps = {
|
||||
title: string
|
||||
@@ -22,19 +18,14 @@ export function TerminalThemePreview({
|
||||
inactivePaneOpacity = 0.9,
|
||||
activePaneOpacity = 1
|
||||
}: TerminalThemePreviewProps): React.JSX.Element {
|
||||
const background = colorToCss(
|
||||
appearance.theme?.colors.background,
|
||||
appearance.mode === 'light' ? '#f8fafc' : '#09090b'
|
||||
)
|
||||
const foreground = colorToCss(
|
||||
appearance.theme?.colors.foreground,
|
||||
appearance.mode === 'light' ? '#111827' : '#f4f4f5'
|
||||
)
|
||||
const cursor = colorToCss(appearance.theme?.colors.cursor, foreground)
|
||||
const selection = colorToCss(
|
||||
appearance.theme?.colors.selectionBackground,
|
||||
appearance.mode === 'light' ? 'rgba(59, 130, 246, 0.18)' : 'rgba(148, 163, 184, 0.22)'
|
||||
)
|
||||
const background =
|
||||
appearance.theme?.background ?? (appearance.mode === 'light' ? '#f8fafc' : '#09090b')
|
||||
const foreground =
|
||||
appearance.theme?.foreground ?? (appearance.mode === 'light' ? '#111827' : '#f4f4f5')
|
||||
const cursor = appearance.theme?.cursor ?? foreground
|
||||
const selection =
|
||||
appearance.theme?.selectionBackground ??
|
||||
(appearance.mode === 'light' ? 'rgba(59, 130, 246, 0.18)' : 'rgba(148, 163, 184, 0.22)')
|
||||
const palette = terminalPalettePreview(appearance.theme)
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
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'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface 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 interface PaneStyleOptions {
|
||||
splitBackground?: string
|
||||
paneBackground?: string
|
||||
inactivePaneOpacity?: number
|
||||
activePaneOpacity?: number
|
||||
opacityTransitionMs?: number
|
||||
dividerThicknessPx?: number
|
||||
}
|
||||
|
||||
export interface ManagedPane {
|
||||
id: number
|
||||
terminal: Terminal
|
||||
container: HTMLElement // the .pane element
|
||||
fitAddon: FitAddon
|
||||
searchAddon: SearchAddon
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ManagedPaneInternal extends ManagedPane {
|
||||
xtermContainer: HTMLElement
|
||||
webglAddon: WebglAddon | null
|
||||
unicode11Addon: Unicode11Addon
|
||||
webLinksAddon: WebLinksAddon
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PaneManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
constructor(root: HTMLElement, options: PaneManagerOptions) {
|
||||
this.root = root
|
||||
this.options = options
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
createInitialPane(opts?: { focus?: boolean }): ManagedPane {
|
||||
const pane = this.createPaneInternal()
|
||||
|
||||
// Place directly into root
|
||||
this.root.appendChild(pane.container)
|
||||
|
||||
this.openTerminal(pane)
|
||||
|
||||
this.activePaneId = pane.id
|
||||
this.applyPaneOpacity()
|
||||
|
||||
if (opts?.focus !== false) {
|
||||
pane.terminal.focus()
|
||||
}
|
||||
|
||||
void this.options.onPaneCreated?.(this.toPublic(pane))
|
||||
return this.toPublic(pane)
|
||||
}
|
||||
|
||||
splitPane(paneId: number, direction: 'vertical' | 'horizontal'): 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'
|
||||
|
||||
// Capture the flex style of the element we're replacing BEFORE modifying it,
|
||||
// so the new split wrapper inherits its position in the parent flex layout.
|
||||
const existingFlex = existing.container.style.flex || ''
|
||||
const existingMinW = existing.container.style.minWidth || ''
|
||||
const existingMinH = existing.container.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 the existing pane was inside a flex parent (another split), inherit its
|
||||
// flex properties so the split wrapper occupies the same slot. Otherwise
|
||||
// (direct child of root) use full width/height.
|
||||
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%'
|
||||
}
|
||||
|
||||
// Create divider
|
||||
const divider = this.createDivider(isVertical)
|
||||
|
||||
// Apply flex styles to existing pane container (child of the new split)
|
||||
this.applyPaneFlexStyle(existing.container)
|
||||
|
||||
// Apply flex styles to new pane container
|
||||
this.applyPaneFlexStyle(newPane.container)
|
||||
|
||||
// Replace existing pane with split in the DOM
|
||||
parent.replaceChild(split, existing.container)
|
||||
|
||||
// Build split: [existing] [divider] [new]
|
||||
split.appendChild(existing.container)
|
||||
split.appendChild(divider)
|
||||
split.appendChild(newPane.container)
|
||||
|
||||
// Open terminal for new pane
|
||||
this.openTerminal(newPane)
|
||||
|
||||
// Set new pane active
|
||||
this.activePaneId = newPane.id
|
||||
this.applyPaneOpacity()
|
||||
this.applyDividerStyles()
|
||||
|
||||
if (newPane.terminal) {
|
||||
newPane.terminal.focus()
|
||||
}
|
||||
|
||||
// Refit existing pane since it now shares space
|
||||
this.safeFit(existing)
|
||||
|
||||
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
|
||||
this.disposePane(pane)
|
||||
|
||||
if (parent.classList.contains('pane-split')) {
|
||||
// Find sibling (skip divider)
|
||||
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 !== paneContainer) ?? null
|
||||
|
||||
// Remove pane element
|
||||
paneContainer.remove()
|
||||
|
||||
// Remove divider(s)
|
||||
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()
|
||||
|
||||
if (sibling) {
|
||||
// Unwrap: replace the split container with the sibling
|
||||
const grandparent = parent.parentElement
|
||||
if (grandparent) {
|
||||
if (grandparent === this.root) {
|
||||
// Going back to root level — remove flex styles so it fills naturally
|
||||
sibling.style.flex = ''
|
||||
sibling.style.minWidth = ''
|
||||
sibling.style.minHeight = ''
|
||||
sibling.style.position = ''
|
||||
sibling.style.overflow = ''
|
||||
if (sibling.classList.contains('pane-split')) {
|
||||
sibling.style.width = '100%'
|
||||
sibling.style.height = '100%'
|
||||
}
|
||||
} else if (grandparent.classList.contains('pane-split')) {
|
||||
// Going into another split — inherit the flex slot from the
|
||||
// split container we're removing
|
||||
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 {
|
||||
// No sibling left, just remove the split
|
||||
parent.remove()
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
this.applyPaneOpacity()
|
||||
|
||||
// Refit remaining panes
|
||||
for (const p of this.panes.values()) {
|
||||
this.safeFit(p)
|
||||
}
|
||||
|
||||
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
|
||||
this.applyPaneOpacity()
|
||||
|
||||
if (opts?.focus !== false) {
|
||||
pane.terminal.focus()
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.options.onActivePaneChange?.(this.toPublic(pane))
|
||||
}
|
||||
}
|
||||
|
||||
setPaneStyleOptions(opts: PaneStyleOptions): void {
|
||||
this.styleOptions = { ...opts }
|
||||
this.applyPaneOpacity()
|
||||
this.applyDividerStyles()
|
||||
this.applyRootBackground()
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true
|
||||
for (const pane of this.panes.values()) {
|
||||
this.disposePane(pane)
|
||||
}
|
||||
this.root.innerHTML = ''
|
||||
this.activePaneId = null
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private createPaneInternal(): ManagedPaneInternal {
|
||||
const id = this.nextPaneId++
|
||||
|
||||
// Create .pane container
|
||||
const container = document.createElement('div')
|
||||
container.className = 'pane'
|
||||
container.dataset.paneId = String(id)
|
||||
|
||||
// Create .xterm-container
|
||||
const xtermContainer = document.createElement('div')
|
||||
xtermContainer.className = 'xterm-container'
|
||||
xtermContainer.style.width = '100%'
|
||||
xtermContainer.style.height = '100%'
|
||||
container.appendChild(xtermContainer)
|
||||
|
||||
// Build terminal options
|
||||
const userOpts = this.options.terminalOptions?.(id) ?? {}
|
||||
const terminalOpts: ITerminalOptions = {
|
||||
allowProposedApi: true,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
fontSize: 14,
|
||||
fontFamily: '"SF Mono", Menlo, monospace',
|
||||
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()
|
||||
const webLinksAddon = new WebLinksAddon(
|
||||
this.options.onLinkClick ? (_event, uri) => this.options.onLinkClick!(uri) : undefined
|
||||
)
|
||||
|
||||
const pane: ManagedPaneInternal = {
|
||||
id,
|
||||
terminal,
|
||||
container,
|
||||
xtermContainer,
|
||||
fitAddon,
|
||||
searchAddon,
|
||||
unicode11Addon,
|
||||
webLinksAddon,
|
||||
webglAddon: null
|
||||
}
|
||||
|
||||
// Focus handler: clicking a pane makes it active
|
||||
container.addEventListener('pointerdown', () => {
|
||||
if (!this.destroyed && this.activePaneId !== id) {
|
||||
this.setActivePane(id, { focus: false })
|
||||
}
|
||||
})
|
||||
|
||||
this.panes.set(id, pane)
|
||||
return pane
|
||||
}
|
||||
|
||||
/** Open terminal into its container and load addons. Must be called after the container is in the DOM. */
|
||||
private 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'
|
||||
|
||||
// Try WebGL, fall back to canvas
|
||||
try {
|
||||
const webglAddon = new WebglAddon()
|
||||
webglAddon.onContextLoss(() => {
|
||||
webglAddon.dispose()
|
||||
pane.webglAddon = null
|
||||
})
|
||||
terminal.loadAddon(webglAddon)
|
||||
pane.webglAddon = webglAddon
|
||||
} catch {
|
||||
// WebGL not available — default canvas renderer is fine
|
||||
pane.webglAddon = null
|
||||
}
|
||||
|
||||
// Initial fit (deferred to ensure layout has settled)
|
||||
requestAnimationFrame(() => {
|
||||
this.safeFit(pane)
|
||||
})
|
||||
}
|
||||
|
||||
private safeFit(pane: ManagedPaneInternal): void {
|
||||
try {
|
||||
pane.fitAddon.fit()
|
||||
} catch {
|
||||
// Container may not have dimensions yet
|
||||
}
|
||||
}
|
||||
|
||||
private disposePane(pane: 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 */
|
||||
}
|
||||
this.panes.delete(pane.id)
|
||||
}
|
||||
|
||||
private 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'
|
||||
}
|
||||
|
||||
private createDivider(isVertical: boolean): HTMLElement {
|
||||
const divider = document.createElement('div')
|
||||
divider.className = `pane-divider ${isVertical ? 'is-vertical' : 'is-horizontal'}`
|
||||
|
||||
const thickness = this.styleOptions.dividerThicknessPx ?? 4
|
||||
if (isVertical) {
|
||||
divider.style.width = `${thickness}px`
|
||||
divider.style.cursor = 'col-resize'
|
||||
} else {
|
||||
divider.style.height = `${thickness}px`
|
||||
divider.style.cursor = 'row-resize'
|
||||
}
|
||||
divider.style.flex = 'none'
|
||||
|
||||
if (this.styleOptions.splitBackground) {
|
||||
divider.style.background = this.styleOptions.splitBackground
|
||||
}
|
||||
|
||||
this.attachDividerDrag(divider, isVertical)
|
||||
return divider
|
||||
}
|
||||
|
||||
private attachDividerDrag(divider: HTMLElement, isVertical: boolean): void {
|
||||
const MIN_PANE_SIZE = 50
|
||||
|
||||
let dragging = 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
this.refitPanesUnder(prevEl)
|
||||
this.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
|
||||
}
|
||||
|
||||
divider.addEventListener('pointerdown', onPointerDown)
|
||||
divider.addEventListener('pointermove', onPointerMove)
|
||||
divider.addEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
private refitPanesUnder(el: HTMLElement): void {
|
||||
// If the element is a pane, refit it
|
||||
if (el.classList.contains('pane')) {
|
||||
const paneId = Number(el.dataset.paneId)
|
||||
const pane = this.panes.get(paneId)
|
||||
if (pane) this.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 = this.panes.get(paneId)
|
||||
if (pane) this.safeFit(pane)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyPaneOpacity(): void {
|
||||
const {
|
||||
activePaneOpacity = 1,
|
||||
inactivePaneOpacity = 1,
|
||||
opacityTransitionMs = 0
|
||||
} = this.styleOptions
|
||||
|
||||
const transition = opacityTransitionMs > 0 ? `opacity ${opacityTransitionMs}ms ease` : ''
|
||||
|
||||
for (const pane of this.panes.values()) {
|
||||
const isActive = pane.id === this.activePaneId
|
||||
pane.container.style.opacity = String(isActive ? activePaneOpacity : inactivePaneOpacity)
|
||||
pane.container.style.transition = transition
|
||||
}
|
||||
}
|
||||
|
||||
private applyDividerStyles(): void {
|
||||
const thickness = this.styleOptions.dividerThicknessPx ?? 4
|
||||
const bg = this.styleOptions.splitBackground ?? ''
|
||||
|
||||
const dividers = this.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 = `${thickness}px`
|
||||
} else {
|
||||
el.style.height = `${thickness}px`
|
||||
}
|
||||
el.style.background = bg
|
||||
}
|
||||
}
|
||||
|
||||
private applyRootBackground(): void {
|
||||
if (this.styleOptions.splitBackground) {
|
||||
this.root.style.background = this.styleOptions.splitBackground
|
||||
}
|
||||
}
|
||||
|
||||
private toPublic(pane: ManagedPaneInternal): ManagedPane {
|
||||
return {
|
||||
id: pane.id,
|
||||
terminal: pane.terminal,
|
||||
container: pane.container,
|
||||
fitAddon: pane.fitAddon,
|
||||
searchAddon: pane.searchAddon
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getBuiltinTheme, listBuiltinThemeNames, type GhosttyTheme } from 'restty'
|
||||
import type { ITheme } from '@xterm/xterm'
|
||||
import { getTheme, getThemeNames } from './terminal-themes-data'
|
||||
import type { GlobalSettings } from '../../../shared/types'
|
||||
|
||||
export const BUILTIN_TERMINAL_THEME_NAMES = listBuiltinThemeNames()
|
||||
export const BUILTIN_TERMINAL_THEME_NAMES = getThemeNames()
|
||||
|
||||
export const DEFAULT_TERMINAL_THEME_DARK = 'Ghostty Default Style Dark'
|
||||
export const DEFAULT_TERMINAL_THEME_LIGHT = 'Builtin Tango Light'
|
||||
@@ -13,7 +14,7 @@ export type EffectiveTerminalAppearance = {
|
||||
sourceTheme: 'system' | 'dark' | 'light'
|
||||
themeName: string
|
||||
dividerColor: string
|
||||
theme: GhosttyTheme | null
|
||||
theme: ITheme | null
|
||||
systemPrefersDark: boolean
|
||||
}
|
||||
|
||||
@@ -22,10 +23,14 @@ export function getSystemPrefersDark(): boolean {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
export function getTerminalThemePreview(name: string): GhosttyTheme | null {
|
||||
const theme = getBuiltinTheme(name)
|
||||
export function getBuiltinTheme(name: string): ITheme | null {
|
||||
return getTheme(name)
|
||||
}
|
||||
|
||||
export function getTerminalThemePreview(name: string): ITheme | null {
|
||||
const theme = getTheme(name)
|
||||
if (theme) return theme
|
||||
return getBuiltinTheme(DEFAULT_TERMINAL_THEME_DARK)
|
||||
return getTheme(DEFAULT_TERMINAL_THEME_DARK)
|
||||
}
|
||||
|
||||
export function resolveEffectiveTerminalAppearance(
|
||||
@@ -73,12 +78,10 @@ export function buildTerminalFontMatchers(fontFamily: string): string[] {
|
||||
return Array.from(
|
||||
new Set([
|
||||
...matchers,
|
||||
// macOS
|
||||
'sf mono',
|
||||
'sfmono-regular',
|
||||
'menlo',
|
||||
'menlo regular',
|
||||
// Linux
|
||||
'dejavu sans mono',
|
||||
'liberation mono',
|
||||
'ubuntu mono',
|
||||
@@ -132,19 +135,37 @@ export function colorToCss(
|
||||
color: { r: number; g: number; b: number; a?: number } | string | undefined,
|
||||
fallback: string
|
||||
): string {
|
||||
if (!color || typeof color === 'string') return fallback
|
||||
if (!color) return fallback
|
||||
if (typeof color === 'string') return color
|
||||
const alpha = typeof color.a === 'number' ? color.a / 255 : 1
|
||||
return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`
|
||||
}
|
||||
|
||||
export function terminalPalettePreview(theme: GhosttyTheme | null): string[] {
|
||||
const PALETTE_KEYS = [
|
||||
'black',
|
||||
'red',
|
||||
'green',
|
||||
'yellow',
|
||||
'blue',
|
||||
'magenta',
|
||||
'cyan',
|
||||
'white',
|
||||
'brightBlack',
|
||||
'brightRed',
|
||||
'brightGreen',
|
||||
'brightYellow',
|
||||
'brightBlue',
|
||||
'brightMagenta',
|
||||
'brightCyan',
|
||||
'brightWhite'
|
||||
] as const
|
||||
|
||||
export function terminalPalettePreview(theme: ITheme | null): string[] {
|
||||
if (!theme) return []
|
||||
const colors = theme.colors.palette
|
||||
const swatches: string[] = []
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
const color = colors[i]
|
||||
if (!color) continue
|
||||
swatches.push(colorToCss(color, '#000000'))
|
||||
for (const key of PALETTE_KEYS) {
|
||||
const color = theme[key]
|
||||
if (color) swatches.push(color)
|
||||
}
|
||||
return swatches
|
||||
}
|
||||
|
||||
@@ -0,0 +1,777 @@
|
||||
import type { ITheme } from '@xterm/xterm'
|
||||
|
||||
export const TERMINAL_THEMES: Record<string, ITheme> = {
|
||||
// ──────────────────────────────────────────────
|
||||
// Defaults
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
'Ghostty Default Style Dark': {
|
||||
background: '#282c34',
|
||||
foreground: '#c0c5ce',
|
||||
cursor: '#c0c5ce',
|
||||
cursorAccent: '#282c34',
|
||||
selectionBackground: '#3e4451',
|
||||
selectionForeground: '#c0c5ce',
|
||||
black: '#1d2021',
|
||||
red: '#cc241d',
|
||||
green: '#98971a',
|
||||
yellow: '#d79921',
|
||||
blue: '#458588',
|
||||
magenta: '#b16286',
|
||||
cyan: '#689d6a',
|
||||
white: '#a89984',
|
||||
brightBlack: '#928374',
|
||||
brightRed: '#fb4934',
|
||||
brightGreen: '#b8bb26',
|
||||
brightYellow: '#fabd2f',
|
||||
brightBlue: '#83a598',
|
||||
brightMagenta: '#d3869b',
|
||||
brightCyan: '#8ec07c',
|
||||
brightWhite: '#ebdbb2'
|
||||
},
|
||||
|
||||
'Builtin Tango Light': {
|
||||
background: '#ffffff',
|
||||
foreground: '#2e3434',
|
||||
cursor: '#2e3434',
|
||||
cursorAccent: '#ffffff',
|
||||
selectionBackground: '#accef7',
|
||||
selectionForeground: '#2e3434',
|
||||
black: '#2e3436',
|
||||
red: '#cc0000',
|
||||
green: '#4e9a06',
|
||||
yellow: '#c4a000',
|
||||
blue: '#3465a4',
|
||||
magenta: '#75507b',
|
||||
cyan: '#06989a',
|
||||
white: '#d3d7cf',
|
||||
brightBlack: '#555753',
|
||||
brightRed: '#ef2929',
|
||||
brightGreen: '#8ae234',
|
||||
brightYellow: '#fce94f',
|
||||
brightBlue: '#729fcf',
|
||||
brightMagenta: '#ad7fa8',
|
||||
brightCyan: '#34e2e2',
|
||||
brightWhite: '#eeeeec'
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Popular Dark
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
Dracula: {
|
||||
background: '#282a36',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f2',
|
||||
cursorAccent: '#282a36',
|
||||
selectionBackground: '#44475a',
|
||||
selectionForeground: '#f8f8f2',
|
||||
black: '#21222c',
|
||||
red: '#ff5555',
|
||||
green: '#50fa7b',
|
||||
yellow: '#f1fa8c',
|
||||
blue: '#bd93f9',
|
||||
magenta: '#ff79c6',
|
||||
cyan: '#8be9fd',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#6272a4',
|
||||
brightRed: '#ff6e6e',
|
||||
brightGreen: '#69ff94',
|
||||
brightYellow: '#ffffa5',
|
||||
brightBlue: '#d6acff',
|
||||
brightMagenta: '#ff92df',
|
||||
brightCyan: '#a4ffff',
|
||||
brightWhite: '#ffffff'
|
||||
},
|
||||
|
||||
'One Dark': {
|
||||
background: '#282c34',
|
||||
foreground: '#abb2bf',
|
||||
cursor: '#528bff',
|
||||
cursorAccent: '#282c34',
|
||||
selectionBackground: '#3e4451',
|
||||
selectionForeground: '#abb2bf',
|
||||
black: '#282c34',
|
||||
red: '#e06c75',
|
||||
green: '#98c379',
|
||||
yellow: '#e5c07b',
|
||||
blue: '#61afef',
|
||||
magenta: '#c678dd',
|
||||
cyan: '#56b6c2',
|
||||
white: '#abb2bf',
|
||||
brightBlack: '#5c6370',
|
||||
brightRed: '#e06c75',
|
||||
brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b',
|
||||
brightBlue: '#61afef',
|
||||
brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2',
|
||||
brightWhite: '#ffffff'
|
||||
},
|
||||
|
||||
Nord: {
|
||||
background: '#2e3440',
|
||||
foreground: '#d8dee9',
|
||||
cursor: '#d8dee9',
|
||||
cursorAccent: '#2e3440',
|
||||
selectionBackground: '#434c5e',
|
||||
selectionForeground: '#d8dee9',
|
||||
black: '#3b4252',
|
||||
red: '#bf616a',
|
||||
green: '#a3be8c',
|
||||
yellow: '#ebcb8b',
|
||||
blue: '#81a1c1',
|
||||
magenta: '#b48ead',
|
||||
cyan: '#88c0d0',
|
||||
white: '#e5e9f0',
|
||||
brightBlack: '#4c566a',
|
||||
brightRed: '#bf616a',
|
||||
brightGreen: '#a3be8c',
|
||||
brightYellow: '#ebcb8b',
|
||||
brightBlue: '#81a1c1',
|
||||
brightMagenta: '#b48ead',
|
||||
brightCyan: '#8fbcbb',
|
||||
brightWhite: '#eceff4'
|
||||
},
|
||||
|
||||
Monokai: {
|
||||
background: '#272822',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f0',
|
||||
cursorAccent: '#272822',
|
||||
selectionBackground: '#49483e',
|
||||
selectionForeground: '#f8f8f2',
|
||||
black: '#272822',
|
||||
red: '#f92672',
|
||||
green: '#a6e22e',
|
||||
yellow: '#f4bf75',
|
||||
blue: '#66d9ef',
|
||||
magenta: '#ae81ff',
|
||||
cyan: '#a1efe4',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#75715e',
|
||||
brightRed: '#f92672',
|
||||
brightGreen: '#a6e22e',
|
||||
brightYellow: '#f4bf75',
|
||||
brightBlue: '#66d9ef',
|
||||
brightMagenta: '#ae81ff',
|
||||
brightCyan: '#a1efe4',
|
||||
brightWhite: '#f9f8f5'
|
||||
},
|
||||
|
||||
'Tokyo Night': {
|
||||
background: '#1a1b26',
|
||||
foreground: '#c0caf5',
|
||||
cursor: '#c0caf5',
|
||||
cursorAccent: '#1a1b26',
|
||||
selectionBackground: '#33467c',
|
||||
selectionForeground: '#c0caf5',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5'
|
||||
},
|
||||
|
||||
'Gruvbox Dark': {
|
||||
background: '#282828',
|
||||
foreground: '#ebdbb2',
|
||||
cursor: '#ebdbb2',
|
||||
cursorAccent: '#282828',
|
||||
selectionBackground: '#504945',
|
||||
selectionForeground: '#ebdbb2',
|
||||
black: '#282828',
|
||||
red: '#cc241d',
|
||||
green: '#98971a',
|
||||
yellow: '#d79921',
|
||||
blue: '#458588',
|
||||
magenta: '#b16286',
|
||||
cyan: '#689d6a',
|
||||
white: '#a89984',
|
||||
brightBlack: '#928374',
|
||||
brightRed: '#fb4934',
|
||||
brightGreen: '#b8bb26',
|
||||
brightYellow: '#fabd2f',
|
||||
brightBlue: '#83a598',
|
||||
brightMagenta: '#d3869b',
|
||||
brightCyan: '#8ec07c',
|
||||
brightWhite: '#ebdbb2'
|
||||
},
|
||||
|
||||
'Catppuccin Mocha': {
|
||||
background: '#1e1e2e',
|
||||
foreground: '#cdd6f4',
|
||||
cursor: '#f5e0dc',
|
||||
cursorAccent: '#1e1e2e',
|
||||
selectionBackground: '#585b70',
|
||||
selectionForeground: '#cdd6f4',
|
||||
black: '#45475a',
|
||||
red: '#f38ba8',
|
||||
green: '#a6e3a1',
|
||||
yellow: '#f9e2af',
|
||||
blue: '#89b4fa',
|
||||
magenta: '#f5c2e7',
|
||||
cyan: '#94e2d5',
|
||||
white: '#bac2de',
|
||||
brightBlack: '#585b70',
|
||||
brightRed: '#f38ba8',
|
||||
brightGreen: '#a6e3a1',
|
||||
brightYellow: '#f9e2af',
|
||||
brightBlue: '#89b4fa',
|
||||
brightMagenta: '#f5c2e7',
|
||||
brightCyan: '#94e2d5',
|
||||
brightWhite: '#a6adc8'
|
||||
},
|
||||
|
||||
'Solarized Dark': {
|
||||
background: '#002b36',
|
||||
foreground: '#839496',
|
||||
cursor: '#839496',
|
||||
cursorAccent: '#002b36',
|
||||
selectionBackground: '#073642',
|
||||
selectionForeground: '#93a1a1',
|
||||
black: '#073642',
|
||||
red: '#dc322f',
|
||||
green: '#859900',
|
||||
yellow: '#b58900',
|
||||
blue: '#268bd2',
|
||||
magenta: '#d33682',
|
||||
cyan: '#2aa198',
|
||||
white: '#eee8d5',
|
||||
brightBlack: '#002b36',
|
||||
brightRed: '#cb4b16',
|
||||
brightGreen: '#586e75',
|
||||
brightYellow: '#657b83',
|
||||
brightBlue: '#839496',
|
||||
brightMagenta: '#6c71c4',
|
||||
brightCyan: '#93a1a1',
|
||||
brightWhite: '#fdf6e3'
|
||||
},
|
||||
|
||||
'Material Dark': {
|
||||
background: '#263238',
|
||||
foreground: '#eeffff',
|
||||
cursor: '#ffcc00',
|
||||
cursorAccent: '#263238',
|
||||
selectionBackground: '#546e7a',
|
||||
selectionForeground: '#eeffff',
|
||||
black: '#000000',
|
||||
red: '#f07178',
|
||||
green: '#c3e88d',
|
||||
yellow: '#ffcb6b',
|
||||
blue: '#82aaff',
|
||||
magenta: '#c792ea',
|
||||
cyan: '#89ddff',
|
||||
white: '#eeffff',
|
||||
brightBlack: '#546e7a',
|
||||
brightRed: '#f07178',
|
||||
brightGreen: '#c3e88d',
|
||||
brightYellow: '#ffcb6b',
|
||||
brightBlue: '#82aaff',
|
||||
brightMagenta: '#c792ea',
|
||||
brightCyan: '#89ddff',
|
||||
brightWhite: '#ffffff'
|
||||
},
|
||||
|
||||
'Ayu Dark': {
|
||||
background: '#0a0e14',
|
||||
foreground: '#b3b1ad',
|
||||
cursor: '#e6b450',
|
||||
cursorAccent: '#0a0e14',
|
||||
selectionBackground: '#273747',
|
||||
selectionForeground: '#b3b1ad',
|
||||
black: '#01060e',
|
||||
red: '#ea6c73',
|
||||
green: '#91b362',
|
||||
yellow: '#f9af4f',
|
||||
blue: '#53bdfa',
|
||||
magenta: '#fae994',
|
||||
cyan: '#90e1c6',
|
||||
white: '#c7c7c7',
|
||||
brightBlack: '#686868',
|
||||
brightRed: '#f07178',
|
||||
brightGreen: '#c2d94c',
|
||||
brightYellow: '#ffb454',
|
||||
brightBlue: '#59c2ff',
|
||||
brightMagenta: '#ffee99',
|
||||
brightCyan: '#95e6cb',
|
||||
brightWhite: '#ffffff'
|
||||
},
|
||||
|
||||
Nightfox: {
|
||||
background: '#192330',
|
||||
foreground: '#cdcecf',
|
||||
cursor: '#cdcecf',
|
||||
cursorAccent: '#192330',
|
||||
selectionBackground: '#2b3b51',
|
||||
selectionForeground: '#cdcecf',
|
||||
black: '#393b44',
|
||||
red: '#c94f6d',
|
||||
green: '#81b29a',
|
||||
yellow: '#dbc074',
|
||||
blue: '#719cd6',
|
||||
magenta: '#9d79d6',
|
||||
cyan: '#63cdcf',
|
||||
white: '#dfdfe0',
|
||||
brightBlack: '#575860',
|
||||
brightRed: '#d16983',
|
||||
brightGreen: '#8ebaa4',
|
||||
brightYellow: '#e0c989',
|
||||
brightBlue: '#86abdc',
|
||||
brightMagenta: '#baa1e2',
|
||||
brightCyan: '#7ad5d6',
|
||||
brightWhite: '#e4e4e5'
|
||||
},
|
||||
|
||||
Kanagawa: {
|
||||
background: '#1f1f28',
|
||||
foreground: '#dcd7ba',
|
||||
cursor: '#c8c093',
|
||||
cursorAccent: '#1f1f28',
|
||||
selectionBackground: '#2d4f67',
|
||||
selectionForeground: '#dcd7ba',
|
||||
black: '#090618',
|
||||
red: '#c34043',
|
||||
green: '#76946a',
|
||||
yellow: '#c0a36e',
|
||||
blue: '#7e9cd8',
|
||||
magenta: '#957fb8',
|
||||
cyan: '#6a9589',
|
||||
white: '#c8c093',
|
||||
brightBlack: '#727169',
|
||||
brightRed: '#e82424',
|
||||
brightGreen: '#98bb6c',
|
||||
brightYellow: '#e6c384',
|
||||
brightBlue: '#7fb4ca',
|
||||
brightMagenta: '#938aa9',
|
||||
brightCyan: '#7aa89f',
|
||||
brightWhite: '#dcd7ba'
|
||||
},
|
||||
|
||||
'Rose Pine': {
|
||||
background: '#191724',
|
||||
foreground: '#e0def4',
|
||||
cursor: '#524f67',
|
||||
cursorAccent: '#e0def4',
|
||||
selectionBackground: '#403d52',
|
||||
selectionForeground: '#e0def4',
|
||||
black: '#26233a',
|
||||
red: '#eb6f92',
|
||||
green: '#31748f',
|
||||
yellow: '#f6c177',
|
||||
blue: '#9ccfd8',
|
||||
magenta: '#c4a7e7',
|
||||
cyan: '#ebbcba',
|
||||
white: '#e0def4',
|
||||
brightBlack: '#6e6a86',
|
||||
brightRed: '#eb6f92',
|
||||
brightGreen: '#31748f',
|
||||
brightYellow: '#f6c177',
|
||||
brightBlue: '#9ccfd8',
|
||||
brightMagenta: '#c4a7e7',
|
||||
brightCyan: '#ebbcba',
|
||||
brightWhite: '#e0def4'
|
||||
},
|
||||
|
||||
'Everforest Dark': {
|
||||
background: '#2d353b',
|
||||
foreground: '#d3c6aa',
|
||||
cursor: '#d3c6aa',
|
||||
cursorAccent: '#2d353b',
|
||||
selectionBackground: '#543a48',
|
||||
selectionForeground: '#d3c6aa',
|
||||
black: '#475258',
|
||||
red: '#e67e80',
|
||||
green: '#a7c080',
|
||||
yellow: '#dbbc7f',
|
||||
blue: '#7fbbb3',
|
||||
magenta: '#d699b6',
|
||||
cyan: '#83c092',
|
||||
white: '#d3c6aa',
|
||||
brightBlack: '#697379',
|
||||
brightRed: '#e67e80',
|
||||
brightGreen: '#a7c080',
|
||||
brightYellow: '#dbbc7f',
|
||||
brightBlue: '#7fbbb3',
|
||||
brightMagenta: '#d699b6',
|
||||
brightCyan: '#83c092',
|
||||
brightWhite: '#d3c6aa'
|
||||
},
|
||||
|
||||
Palenight: {
|
||||
background: '#292d3e',
|
||||
foreground: '#a6accd',
|
||||
cursor: '#ffcc00',
|
||||
cursorAccent: '#292d3e',
|
||||
selectionBackground: '#434758',
|
||||
selectionForeground: '#a6accd',
|
||||
black: '#292d3e',
|
||||
red: '#f07178',
|
||||
green: '#c3e88d',
|
||||
yellow: '#ffcb6b',
|
||||
blue: '#82aaff',
|
||||
magenta: '#c792ea',
|
||||
cyan: '#89ddff',
|
||||
white: '#a6accd',
|
||||
brightBlack: '#676e95',
|
||||
brightRed: '#f07178',
|
||||
brightGreen: '#c3e88d',
|
||||
brightYellow: '#ffcb6b',
|
||||
brightBlue: '#82aaff',
|
||||
brightMagenta: '#c792ea',
|
||||
brightCyan: '#89ddff',
|
||||
brightWhite: '#ffffff'
|
||||
},
|
||||
|
||||
'Horizon Dark': {
|
||||
background: '#1c1e26',
|
||||
foreground: '#e0e0e0',
|
||||
cursor: '#e95678',
|
||||
cursorAccent: '#1c1e26',
|
||||
selectionBackground: '#2e303e',
|
||||
selectionForeground: '#e0e0e0',
|
||||
black: '#16161c',
|
||||
red: '#e95678',
|
||||
green: '#29d398',
|
||||
yellow: '#fab795',
|
||||
blue: '#26bbd9',
|
||||
magenta: '#ee64ac',
|
||||
cyan: '#59e1e3',
|
||||
white: '#d5d8da',
|
||||
brightBlack: '#6c6f93',
|
||||
brightRed: '#ec6a88',
|
||||
brightGreen: '#3fdaa4',
|
||||
brightYellow: '#fbc3a7',
|
||||
brightBlue: '#3fc4de',
|
||||
brightMagenta: '#f075b5',
|
||||
brightCyan: '#6be4e6',
|
||||
brightWhite: '#f0f0f0'
|
||||
},
|
||||
|
||||
'Night Owl': {
|
||||
background: '#011627',
|
||||
foreground: '#d6deeb',
|
||||
cursor: '#80a4c2',
|
||||
cursorAccent: '#011627',
|
||||
selectionBackground: '#1d3b53',
|
||||
selectionForeground: '#d6deeb',
|
||||
black: '#011627',
|
||||
red: '#ef5350',
|
||||
green: '#22da6e',
|
||||
yellow: '#addb67',
|
||||
blue: '#82aaff',
|
||||
magenta: '#c792ea',
|
||||
cyan: '#21c7a8',
|
||||
white: '#ffffff',
|
||||
brightBlack: '#575656',
|
||||
brightRed: '#ef5350',
|
||||
brightGreen: '#22da6e',
|
||||
brightYellow: '#ffeb95',
|
||||
brightBlue: '#82aaff',
|
||||
brightMagenta: '#c792ea',
|
||||
brightCyan: '#7fdbca',
|
||||
brightWhite: '#ffffff'
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Popular Light
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
'Solarized Light': {
|
||||
background: '#fdf6e3',
|
||||
foreground: '#657b83',
|
||||
cursor: '#657b83',
|
||||
cursorAccent: '#fdf6e3',
|
||||
selectionBackground: '#eee8d5',
|
||||
selectionForeground: '#586e75',
|
||||
black: '#073642',
|
||||
red: '#dc322f',
|
||||
green: '#859900',
|
||||
yellow: '#b58900',
|
||||
blue: '#268bd2',
|
||||
magenta: '#d33682',
|
||||
cyan: '#2aa198',
|
||||
white: '#eee8d5',
|
||||
brightBlack: '#002b36',
|
||||
brightRed: '#cb4b16',
|
||||
brightGreen: '#586e75',
|
||||
brightYellow: '#657b83',
|
||||
brightBlue: '#839496',
|
||||
brightMagenta: '#6c71c4',
|
||||
brightCyan: '#93a1a1',
|
||||
brightWhite: '#fdf6e3'
|
||||
},
|
||||
|
||||
'One Light': {
|
||||
background: '#fafafa',
|
||||
foreground: '#383a42',
|
||||
cursor: '#526fff',
|
||||
cursorAccent: '#fafafa',
|
||||
selectionBackground: '#e5e5e6',
|
||||
selectionForeground: '#383a42',
|
||||
black: '#383a42',
|
||||
red: '#e45649',
|
||||
green: '#50a14f',
|
||||
yellow: '#c18401',
|
||||
blue: '#4078f2',
|
||||
magenta: '#a626a4',
|
||||
cyan: '#0184bc',
|
||||
white: '#a0a1a7',
|
||||
brightBlack: '#696c77',
|
||||
brightRed: '#e45649',
|
||||
brightGreen: '#50a14f',
|
||||
brightYellow: '#c18401',
|
||||
brightBlue: '#4078f2',
|
||||
brightMagenta: '#a626a4',
|
||||
brightCyan: '#0184bc',
|
||||
brightWhite: '#fafafa'
|
||||
},
|
||||
|
||||
'Catppuccin Latte': {
|
||||
background: '#eff1f5',
|
||||
foreground: '#4c4f69',
|
||||
cursor: '#dc8a78',
|
||||
cursorAccent: '#eff1f5',
|
||||
selectionBackground: '#acb0be',
|
||||
selectionForeground: '#4c4f69',
|
||||
black: '#5c5f77',
|
||||
red: '#d20f39',
|
||||
green: '#40a02b',
|
||||
yellow: '#df8e1d',
|
||||
blue: '#1e66f5',
|
||||
magenta: '#ea76cb',
|
||||
cyan: '#179299',
|
||||
white: '#acb0be',
|
||||
brightBlack: '#6c6f85',
|
||||
brightRed: '#d20f39',
|
||||
brightGreen: '#40a02b',
|
||||
brightYellow: '#df8e1d',
|
||||
brightBlue: '#1e66f5',
|
||||
brightMagenta: '#ea76cb',
|
||||
brightCyan: '#179299',
|
||||
brightWhite: '#bcc0cc'
|
||||
},
|
||||
|
||||
'GitHub Light': {
|
||||
background: '#ffffff',
|
||||
foreground: '#24292e',
|
||||
cursor: '#044289',
|
||||
cursorAccent: '#ffffff',
|
||||
selectionBackground: '#c8c8fa',
|
||||
selectionForeground: '#24292e',
|
||||
black: '#24292e',
|
||||
red: '#d73a49',
|
||||
green: '#28a745',
|
||||
yellow: '#dbab09',
|
||||
blue: '#0366d6',
|
||||
magenta: '#5a32a3',
|
||||
cyan: '#0598bc',
|
||||
white: '#6a737d',
|
||||
brightBlack: '#959da5',
|
||||
brightRed: '#cb2431',
|
||||
brightGreen: '#22863a',
|
||||
brightYellow: '#b08800',
|
||||
brightBlue: '#005cc5',
|
||||
brightMagenta: '#5a32a3',
|
||||
brightCyan: '#3192aa',
|
||||
brightWhite: '#d1d5da'
|
||||
},
|
||||
|
||||
'Rose Pine Dawn': {
|
||||
background: '#faf4ed',
|
||||
foreground: '#575279',
|
||||
cursor: '#9893a5',
|
||||
cursorAccent: '#faf4ed',
|
||||
selectionBackground: '#dfdad9',
|
||||
selectionForeground: '#575279',
|
||||
black: '#f2e9e1',
|
||||
red: '#b4637a',
|
||||
green: '#286983',
|
||||
yellow: '#ea9d34',
|
||||
blue: '#56949f',
|
||||
magenta: '#907aa9',
|
||||
cyan: '#d7827e',
|
||||
white: '#575279',
|
||||
brightBlack: '#9893a5',
|
||||
brightRed: '#b4637a',
|
||||
brightGreen: '#286983',
|
||||
brightYellow: '#ea9d34',
|
||||
brightBlue: '#56949f',
|
||||
brightMagenta: '#907aa9',
|
||||
brightCyan: '#d7827e',
|
||||
brightWhite: '#575279'
|
||||
},
|
||||
|
||||
'Gruvbox Light': {
|
||||
background: '#fbf1c7',
|
||||
foreground: '#3c3836',
|
||||
cursor: '#3c3836',
|
||||
cursorAccent: '#fbf1c7',
|
||||
selectionBackground: '#ebdbb2',
|
||||
selectionForeground: '#3c3836',
|
||||
black: '#fbf1c7',
|
||||
red: '#cc241d',
|
||||
green: '#98971a',
|
||||
yellow: '#d79921',
|
||||
blue: '#458588',
|
||||
magenta: '#b16286',
|
||||
cyan: '#689d6a',
|
||||
white: '#7c6f64',
|
||||
brightBlack: '#928374',
|
||||
brightRed: '#9d0006',
|
||||
brightGreen: '#79740e',
|
||||
brightYellow: '#b57614',
|
||||
brightBlue: '#076678',
|
||||
brightMagenta: '#8f3f71',
|
||||
brightCyan: '#427b58',
|
||||
brightWhite: '#3c3836'
|
||||
},
|
||||
|
||||
'Tokyo Night Light': {
|
||||
background: '#d5d6db',
|
||||
foreground: '#343b58',
|
||||
cursor: '#343b58',
|
||||
cursorAccent: '#d5d6db',
|
||||
selectionBackground: '#9699a3',
|
||||
selectionForeground: '#343b58',
|
||||
black: '#0f0f14',
|
||||
red: '#8c4351',
|
||||
green: '#485e30',
|
||||
yellow: '#8f5e15',
|
||||
blue: '#34548a',
|
||||
magenta: '#5a4a78',
|
||||
cyan: '#0f4b6e',
|
||||
white: '#343b58',
|
||||
brightBlack: '#9699a3',
|
||||
brightRed: '#8c4351',
|
||||
brightGreen: '#485e30',
|
||||
brightYellow: '#8f5e15',
|
||||
brightBlue: '#34548a',
|
||||
brightMagenta: '#5a4a78',
|
||||
brightCyan: '#0f4b6e',
|
||||
brightWhite: '#343b58'
|
||||
},
|
||||
|
||||
'Everforest Light': {
|
||||
background: '#fdf6e3',
|
||||
foreground: '#5c6a72',
|
||||
cursor: '#5c6a72',
|
||||
cursorAccent: '#fdf6e3',
|
||||
selectionBackground: '#e6e2cc',
|
||||
selectionForeground: '#5c6a72',
|
||||
black: '#5c6a72',
|
||||
red: '#f85552',
|
||||
green: '#8da101',
|
||||
yellow: '#dfa000',
|
||||
blue: '#3a94c5',
|
||||
magenta: '#df69ba',
|
||||
cyan: '#35a77c',
|
||||
white: '#dfd9a8',
|
||||
brightBlack: '#939f91',
|
||||
brightRed: '#f85552',
|
||||
brightGreen: '#8da101',
|
||||
brightYellow: '#dfa000',
|
||||
brightBlue: '#3a94c5',
|
||||
brightMagenta: '#df69ba',
|
||||
brightCyan: '#35a77c',
|
||||
brightWhite: '#fdf6e3'
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Classic
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
'Tango Dark': {
|
||||
background: '#000000',
|
||||
foreground: '#d3d7cf',
|
||||
cursor: '#d3d7cf',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: '#555753',
|
||||
selectionForeground: '#eeeeec',
|
||||
black: '#2e3436',
|
||||
red: '#cc0000',
|
||||
green: '#4e9a06',
|
||||
yellow: '#c4a000',
|
||||
blue: '#3465a4',
|
||||
magenta: '#75507b',
|
||||
cyan: '#06989a',
|
||||
white: '#d3d7cf',
|
||||
brightBlack: '#555753',
|
||||
brightRed: '#ef2929',
|
||||
brightGreen: '#8ae234',
|
||||
brightYellow: '#fce94f',
|
||||
brightBlue: '#729fcf',
|
||||
brightMagenta: '#ad7fa8',
|
||||
brightCyan: '#34e2e2',
|
||||
brightWhite: '#eeeeec'
|
||||
},
|
||||
|
||||
Homebrew: {
|
||||
background: '#000000',
|
||||
foreground: '#00ff00',
|
||||
cursor: '#00ff00',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: '#005500',
|
||||
selectionForeground: '#00ff00',
|
||||
black: '#000000',
|
||||
red: '#990000',
|
||||
green: '#00a600',
|
||||
yellow: '#999900',
|
||||
blue: '#0000b2',
|
||||
magenta: '#b200b2',
|
||||
cyan: '#00a6b2',
|
||||
white: '#bfbfbf',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#e50000',
|
||||
brightGreen: '#00d900',
|
||||
brightYellow: '#e5e500',
|
||||
brightBlue: '#0000ff',
|
||||
brightMagenta: '#e500e5',
|
||||
brightCyan: '#00e5e5',
|
||||
brightWhite: '#e5e5e5'
|
||||
},
|
||||
|
||||
Snazzy: {
|
||||
background: '#282a36',
|
||||
foreground: '#eff0eb',
|
||||
cursor: '#97979b',
|
||||
cursorAccent: '#282a36',
|
||||
selectionBackground: '#3e404a',
|
||||
selectionForeground: '#eff0eb',
|
||||
black: '#282a36',
|
||||
red: '#ff5c57',
|
||||
green: '#5af78e',
|
||||
yellow: '#f3f99d',
|
||||
blue: '#57c7ff',
|
||||
magenta: '#ff6ac1',
|
||||
cyan: '#9aedfe',
|
||||
white: '#f1f1f0',
|
||||
brightBlack: '#686868',
|
||||
brightRed: '#ff5c57',
|
||||
brightGreen: '#5af78e',
|
||||
brightYellow: '#f3f99d',
|
||||
brightBlue: '#57c7ff',
|
||||
brightMagenta: '#ff6ac1',
|
||||
brightCyan: '#9aedfe',
|
||||
brightWhite: '#f1f1f0'
|
||||
}
|
||||
}
|
||||
|
||||
export function getThemeNames(): string[] {
|
||||
return Object.keys(TERMINAL_THEMES).sort()
|
||||
}
|
||||
|
||||
export function getTheme(name: string): ITheme | null {
|
||||
return TERMINAL_THEMES[name] ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user