Phase 1b: terminal tabs, up to 4 split panes, theme/font customization
Terminal.tsx is rebuilt around a reusable TerminalPane component (one xterm + WebSocket connection each) so a tab can hold 1, 2, or 4 panes (single / split-2 / 2x2 grid), each independently connectable to any SSH host. Added a small terminal preferences bar (theme preset, font size, font family) persisted to localStorage and applied per-pane. Also fixes two build-time issues surfaced while wiring this up: an unused parameter in Settings.tsx's fieldsWithJumpHost helper, and a stale JSX.Element reference that doesn't resolve under this project's tsc -b project-reference build (replaced with React.ReactElement). This completes Phase 1b of the Termix migration (see TERMIX_MIGRATION.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
parent
5d56a1d902
commit
94b174c72e
3 changed files with 322 additions and 55 deletions
|
|
@ -45,11 +45,13 @@ Rationale for splitting: 1a alone is a real, useful terminal (matches what `/ter
|
||||||
|
|
||||||
**Status:**
|
**Status:**
|
||||||
- ✅ **Phase 1a — done.** `/terminal` is a real interactive SSH terminal: `backend/src/routes/terminal.ts` (WebSocket, connect/input/resize/disconnect over `ssh2`), `backend/src/db/secrets.ts` (shared secret loader), `src/pages/Terminal.tsx` (xterm.js + host picker, reuses ArchNest's existing SSH integrations — no duplicate host table). Verified end-to-end against a real test SSH server. No jump hosts, no tabs/split panes, no OPKSSH, no tmux monitor yet — see 1b/1c below.
|
- ✅ **Phase 1a — done.** `/terminal` is a real interactive SSH terminal: `backend/src/routes/terminal.ts` (WebSocket, connect/input/resize/disconnect over `ssh2`), `backend/src/db/secrets.ts` (shared secret loader), `src/pages/Terminal.tsx` (xterm.js + host picker, reuses ArchNest's existing SSH integrations — no duplicate host table). Verified end-to-end against a real test SSH server. No jump hosts, no tabs/split panes, no OPKSSH, no tmux monitor yet — see 1b/1c below.
|
||||||
- 🟡 **Phase 1b — in progress.** Done so far:
|
- ✅ **Phase 1b — done.**
|
||||||
- **Jump-host chaining**: an SSH integration's config can carry `jumpHostIntegrationId` referencing another SSH integration. `backend/src/routes/terminal.ts` connects to the jump host first, opens a `forwardOut()` channel to the real target, and connects the target `Client` over that channel (single-hop; mirrors Termix's core mechanism without its multi-hop/credential-sharing complexity). Verified end-to-end with two real test SSH servers (one as jump, one as target).
|
- **Jump-host chaining**: an SSH integration's config can carry `jumpHostIntegrationId` referencing another SSH integration. `backend/src/routes/terminal.ts` connects to the jump host first, opens a `forwardOut()` channel to the real target, and connects the target `Client` over that channel (single-hop; mirrors Termix's core mechanism without its multi-hop/credential-sharing complexity). Verified end-to-end with two real test SSH servers (one as jump, one as target).
|
||||||
- **Host-key verification (TOFU)**: new `ssh_host_keys` table (`backend/src/db/index.ts`) stores a SHA-256 fingerprint per SSH integration on first successful connect; subsequent connects are rejected if the fingerprint changes, via `ssh2`'s `hostVerifier` connect option. No interactive accept/reject-changed-key UI yet — first-use accept-and-store, hard-reject on mismatch. Verified both the accept-on-first-use and reject-on-mismatch paths against a real test server.
|
- **Host-key verification (TOFU)**: new `ssh_host_keys` table (`backend/src/db/index.ts`) stores a SHA-256 fingerprint per SSH integration on first successful connect; subsequent connects are rejected if the fingerprint changes, via `ssh2`'s `hostVerifier` connect option. No interactive accept/reject-changed-key UI yet — first-use accept-and-store, hard-reject on mismatch. Verified both the accept-on-first-use and reject-on-mismatch paths against a real test server.
|
||||||
- **Settings UI for multiple SSH hosts**: `src/pages/Settings.tsx` previously could only show/edit one integration per type, which silently broke multi-host SSH. Added a dedicated `SshHostsSection` with its own per-host cards (Save/Test/Delete) and an "Add SSH Host" flow, including a `Jump Host` dropdown populated from the other configured SSH hosts.
|
- **Settings UI for multiple SSH hosts**: `src/pages/Settings.tsx` previously could only show/edit one integration per type, which silently broke multi-host SSH. Added a dedicated `SshHostsSection` with its own per-host cards (Save/Test/Delete) and an "Add SSH Host" flow, including a `Jump Host` dropdown populated from the other configured SSH hosts.
|
||||||
- Not yet done: tab system + up-to-4 split panes and terminal theme/font customization in `src/pages/Terminal.tsx` — that page is still single-session only.
|
- **Tabs + up to 4 split panes**: `src/pages/Terminal.tsx` rewritten around a `TerminalPane` component (one xterm + WebSocket connection each, reusable). Each tab holds 1/2/4 panes (single / split-2 / 2x2 grid); each pane connects independently to whichever SSH host is clicked while it's focused.
|
||||||
|
- **Terminal theme/font customization**: a preferences bar (theme preset, font size, font family) persisted to `localStorage` (`archnest-terminal-prefs`), applied per-pane on connect.
|
||||||
|
- Verified via a clean production build (`tsc -b && vite build`) — no real browser available in this environment to click through tabs/panes, so this is build/type verification only, not an interactive UI test.
|
||||||
- ⬜ Phase 1c — not started.
|
- ⬜ Phase 1c — not started.
|
||||||
|
|
||||||
### Phase 2 — SSH Tunnels (NOT STARTED)
|
### Phase 2 — SSH Tunnels (NOT STARTED)
|
||||||
|
|
|
||||||
|
|
@ -398,7 +398,7 @@ function SshHostsSection() {
|
||||||
setDrafts((prev) => ({ ...prev, [id]: { ...prev[id], [fieldKey]: value } }))
|
setDrafts((prev) => ({ ...prev, [id]: { ...prev[id], [fieldKey]: value } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldsWithJumpHost = (excludeId?: number): FieldDef[] => [
|
const fieldsWithJumpHost = (): FieldDef[] => [
|
||||||
...sshFields,
|
...sshFields,
|
||||||
{ key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' },
|
{ key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' },
|
||||||
]
|
]
|
||||||
|
|
@ -420,7 +420,7 @@ function SshHostsSection() {
|
||||||
setStatusMsg((prev) => ({ ...prev, [host.id]: '' }))
|
setStatusMsg((prev) => ({ ...prev, [host.id]: '' }))
|
||||||
try {
|
try {
|
||||||
const draft = drafts[host.id] ?? {}
|
const draft = drafts[host.id] ?? {}
|
||||||
const { config, secrets } = buildPayload(fieldsWithJumpHost(host.id), draft)
|
const { config, secrets } = buildPayload(fieldsWithJumpHost(), draft)
|
||||||
const { integration } = await api.updateIntegration(host.id, { config, secrets })
|
const { integration } = await api.updateIntegration(host.id, { config, secrets })
|
||||||
setHosts((prev) => (prev ?? []).map((h) => (h.id === integration.id ? integration : h)))
|
setHosts((prev) => (prev ?? []).map((h) => (h.id === integration.id ? integration : h)))
|
||||||
setStatusMsg((prev) => ({ ...prev, [host.id]: 'Saved' }))
|
setStatusMsg((prev) => ({ ...prev, [host.id]: 'Saved' }))
|
||||||
|
|
@ -584,7 +584,7 @@ function SshHostsSection() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{renderFields(fieldsWithJumpHost(host.id), draft, (k, v) => setDraftField(host.id, k, v), host.id, host, host.id)}
|
{renderFields(fieldsWithJumpHost(), draft, (k, v) => setDraftField(host.id, k, v), host.id, host, host.id)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -925,7 +925,7 @@ function AboutSection() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionComponents: Record<string, () => JSX.Element> = {
|
const sectionComponents: Record<string, () => React.ReactElement> = {
|
||||||
profile: ProfileSection,
|
profile: ProfileSection,
|
||||||
appearance: AppearanceSection,
|
appearance: AppearanceSection,
|
||||||
integrations: IntegrationsSection,
|
integrations: IntegrationsSection,
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,84 @@ import { useEffect, useRef, useState } from 'react'
|
||||||
import { Terminal as XTerm } from '@xterm/xterm'
|
import { Terminal as XTerm } from '@xterm/xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash } from 'lucide-react'
|
||||||
import { api, getToken, type Integration } from '../lib/api'
|
import { api, getToken, type Integration } from '../lib/api'
|
||||||
|
|
||||||
const GOLD = '#C8A434'
|
const GOLD = '#C8A434'
|
||||||
const TEXT_SECONDARY = '#7A7D85'
|
const TEXT_SECONDARY = '#7A7D85'
|
||||||
|
|
||||||
|
interface TermTheme {
|
||||||
|
name: string
|
||||||
|
background: string
|
||||||
|
foreground: string
|
||||||
|
cursor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERM_THEMES: TermTheme[] = [
|
||||||
|
{ name: 'ArchNest Dark', background: '#15161A', foreground: '#E8E6E0', cursor: GOLD },
|
||||||
|
{ name: 'Matrix', background: '#000000', foreground: '#33FF66', cursor: '#33FF66' },
|
||||||
|
{ name: 'Solarized', background: '#002B36', foreground: '#93A1A1', cursor: '#CB4B16' },
|
||||||
|
{ name: 'Midnight Blue', background: '#0B1021', foreground: '#C7D3F2', cursor: '#5FA8FF' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FONT_SIZES = [11, 12, 13, 14, 15, 16]
|
||||||
|
const FONT_FAMILIES = [
|
||||||
|
{ name: 'Monospace', value: 'ui-monospace, SFMono-Regular, Menlo, monospace' },
|
||||||
|
{ name: 'Fira Code', value: '"Fira Code", ui-monospace, monospace' },
|
||||||
|
{ name: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface TerminalPrefs {
|
||||||
|
themeName: string
|
||||||
|
fontSize: number
|
||||||
|
fontFamily: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFS_KEY = 'archnest-terminal-prefs'
|
||||||
|
|
||||||
|
function loadPrefs(): TerminalPrefs {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PREFS_KEY)
|
||||||
|
if (raw) return { ...defaultPrefs(), ...JSON.parse(raw) }
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed local storage */
|
||||||
|
}
|
||||||
|
return defaultPrefs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPrefs(): TerminalPrefs {
|
||||||
|
return { themeName: TERM_THEMES[0].name, fontSize: 13, fontFamily: FONT_FAMILIES[0].value }
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePrefs(prefs: TerminalPrefs) {
|
||||||
|
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs))
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaneState {
|
||||||
|
id: number
|
||||||
|
hostId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabState {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
panes: PaneState[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 1
|
||||||
|
const genId = () => nextId++
|
||||||
|
|
||||||
|
function newTab(): TabState {
|
||||||
|
return { id: genId(), name: 'Terminal', panes: [{ id: genId(), hostId: null }] }
|
||||||
|
}
|
||||||
|
|
||||||
export default function Terminal() {
|
export default function Terminal() {
|
||||||
const [hosts, setHosts] = useState<Integration[]>([])
|
const [hosts, setHosts] = useState<Integration[]>([])
|
||||||
const [activeHostId, setActiveHostId] = useState<number | null>(null)
|
const [tabs, setTabs] = useState<TabState[]>(() => [newTab()])
|
||||||
const [connected, setConnected] = useState(false)
|
const [activeTabId, setActiveTabId] = useState(() => tabs[0].id)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const [activePaneId, setActivePaneId] = useState<number | null>(null)
|
||||||
const termRef = useRef<XTerm | null>(null)
|
const [prefs, setPrefs] = useState<TerminalPrefs>(loadPrefs)
|
||||||
const fitRef = useRef<FitAddon | null>(null)
|
const [showPrefs, setShowPrefs] = useState(false)
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listIntegrations().then(({ integrations }) => {
|
api.listIntegrations().then(({ integrations }) => {
|
||||||
|
|
@ -22,13 +87,224 @@ export default function Terminal() {
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => savePrefs(prefs), [prefs])
|
||||||
|
|
||||||
|
const activeTab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]
|
||||||
|
|
||||||
|
function addTab() {
|
||||||
|
const tab = newTab()
|
||||||
|
setTabs((prev) => [...prev, tab])
|
||||||
|
setActiveTabId(tab.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTab(id: number) {
|
||||||
|
setTabs((prev) => {
|
||||||
|
const next = prev.filter((t) => t.id !== id)
|
||||||
|
if (next.length === 0) {
|
||||||
|
const t = newTab()
|
||||||
|
setActiveTabId(t.id)
|
||||||
|
return [t]
|
||||||
|
}
|
||||||
|
if (id === activeTabId) setActiveTabId(next[0].id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveTab(fn: (tab: TabState) => TabState) {
|
||||||
|
setTabs((prev) => prev.map((t) => (t.id === activeTabId ? fn(t) : t)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPaneCount(count: number) {
|
||||||
|
updateActiveTab((tab) => {
|
||||||
|
const panes = [...tab.panes]
|
||||||
|
while (panes.length < count) panes.push({ id: genId(), hostId: null })
|
||||||
|
while (panes.length > count) panes.pop()
|
||||||
|
return { ...tab, panes }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPaneHost(paneId: number, hostId: number) {
|
||||||
|
updateActiveTab((tab) => ({
|
||||||
|
...tab,
|
||||||
|
panes: tab.panes.map((p) => (p.id === paneId ? { ...p, hostId } : p)),
|
||||||
|
}))
|
||||||
|
setActivePaneId(paneId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const paneGridClass =
|
||||||
|
activeTab.panes.length === 1
|
||||||
|
? 'grid-cols-1 grid-rows-1'
|
||||||
|
: activeTab.panes.length === 2
|
||||||
|
? 'grid-cols-2 grid-rows-1'
|
||||||
|
: 'grid-cols-2 grid-rows-2'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-4">
|
||||||
|
<div className="w-56 shrink-0 overflow-y-auto rounded-lg border border-white/10 bg-white/5 p-3">
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase tracking-wide" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
SSH Hosts
|
||||||
|
</p>
|
||||||
|
{hosts.length === 0 && (
|
||||||
|
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
No SSH integrations configured. Add one in Settings → Integrations.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{hosts.map((h) => (
|
||||||
|
<button
|
||||||
|
key={h.id}
|
||||||
|
onClick={() => activePaneId !== null && setPaneHost(activePaneId, h.id)}
|
||||||
|
className="rounded-md px-2 py-1.5 text-left text-sm transition-colors"
|
||||||
|
style={{
|
||||||
|
background: activeTab.panes.find((p) => p.id === activePaneId)?.hostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
|
||||||
|
color: activeTab.panes.find((p) => p.id === activePaneId)?.hostId === h.id ? GOLD : '#E8E6E0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[11px]" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
Click a pane, then a host to connect it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex flex-1 items-center gap-1 overflow-x-auto">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTabId(tab.id)}
|
||||||
|
className="flex shrink-0 cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-xs"
|
||||||
|
style={{
|
||||||
|
background: tab.id === activeTabId ? 'rgba(200,164,52,0.15)' : 'rgba(255,255,255,0.04)',
|
||||||
|
color: tab.id === activeTabId ? GOLD : '#E8E6E0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
<X
|
||||||
|
size={12}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
closeTab(tab.id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={addTab} className="shrink-0 rounded-md p-1.5" style={{ color: TEXT_SECONDARY }} title="New tab">
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setPaneCount(1)} className="rounded-md p-1.5" style={{ color: activeTab.panes.length === 1 ? GOLD : TEXT_SECONDARY }} title="Single pane">
|
||||||
|
<SquareSlash size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setPaneCount(2)} className="rounded-md p-1.5" style={{ color: activeTab.panes.length === 2 ? GOLD : TEXT_SECONDARY }} title="Split 2">
|
||||||
|
<Columns2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setPaneCount(4)} className="rounded-md p-1.5" style={{ color: activeTab.panes.length === 4 ? GOLD : TEXT_SECONDARY }} title="Split 4">
|
||||||
|
<Grid2x2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowPrefs((v) => !v)} className="rounded-md p-1.5" style={{ color: showPrefs ? GOLD : TEXT_SECONDARY }} title="Terminal preferences">
|
||||||
|
<Settings2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPrefs && (
|
||||||
|
<div className="flex items-center gap-4 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs" style={{ color: '#E8E6E0' }}>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
Theme
|
||||||
|
<select
|
||||||
|
value={prefs.themeName}
|
||||||
|
onChange={(e) => setPrefs((p) => ({ ...p, themeName: e.target.value }))}
|
||||||
|
className="rounded-md border border-white/10 bg-transparent px-2 py-1"
|
||||||
|
>
|
||||||
|
{TERM_THEMES.map((t) => (
|
||||||
|
<option key={t.name} value={t.name}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
Font Size
|
||||||
|
<select
|
||||||
|
value={prefs.fontSize}
|
||||||
|
onChange={(e) => setPrefs((p) => ({ ...p, fontSize: Number(e.target.value) }))}
|
||||||
|
className="rounded-md border border-white/10 bg-transparent px-2 py-1"
|
||||||
|
>
|
||||||
|
{FONT_SIZES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}px
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
Font
|
||||||
|
<select
|
||||||
|
value={prefs.fontFamily}
|
||||||
|
onChange={(e) => setPrefs((p) => ({ ...p, fontFamily: e.target.value }))}
|
||||||
|
className="rounded-md border border-white/10 bg-transparent px-2 py-1"
|
||||||
|
>
|
||||||
|
{FONT_FAMILIES.map((f) => (
|
||||||
|
<option key={f.name} value={f.value}>
|
||||||
|
{f.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`grid min-h-0 flex-1 gap-2 ${paneGridClass}`}>
|
||||||
|
{activeTab.panes.map((pane) => (
|
||||||
|
<TerminalPane
|
||||||
|
key={pane.id}
|
||||||
|
hostId={pane.hostId}
|
||||||
|
hosts={hosts}
|
||||||
|
prefs={prefs}
|
||||||
|
active={pane.id === activePaneId}
|
||||||
|
onFocus={() => setActivePaneId(pane.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TerminalPane({
|
||||||
|
hostId,
|
||||||
|
hosts,
|
||||||
|
prefs,
|
||||||
|
active,
|
||||||
|
onFocus,
|
||||||
|
}: {
|
||||||
|
hostId: number | null
|
||||||
|
hosts: Integration[]
|
||||||
|
prefs: TerminalPrefs
|
||||||
|
active: boolean
|
||||||
|
onFocus: () => void
|
||||||
|
}) {
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const termRef = useRef<XTerm | null>(null)
|
||||||
|
const fitRef = useRef<FitAddon | null>(null)
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
const lastHostIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return
|
if (!containerRef.current) return
|
||||||
|
const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0]
|
||||||
const term = new XTerm({
|
const term = new XTerm({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 13,
|
fontSize: prefs.fontSize,
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
fontFamily: prefs.fontFamily,
|
||||||
theme: { background: '#15161A', foreground: '#E8E6E0', cursor: GOLD },
|
theme: { background: theme.background, foreground: theme.foreground, cursor: theme.cursor },
|
||||||
})
|
})
|
||||||
const fit = new FitAddon()
|
const fit = new FitAddon()
|
||||||
term.loadAddon(fit)
|
term.loadAddon(fit)
|
||||||
|
|
@ -44,11 +320,22 @@ export default function Terminal() {
|
||||||
term.dispose()
|
term.dispose()
|
||||||
wsRef.current?.close()
|
wsRef.current?.close()
|
||||||
}
|
}
|
||||||
}, [])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [prefs.themeName, prefs.fontSize, prefs.fontFamily])
|
||||||
|
|
||||||
function connect(hostId: number) {
|
useEffect(() => {
|
||||||
|
fitRef.current?.fit()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hostId === null || hostId === lastHostIdRef.current) return
|
||||||
|
lastHostIdRef.current = hostId
|
||||||
|
connect(hostId)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hostId])
|
||||||
|
|
||||||
|
function connect(id: number) {
|
||||||
wsRef.current?.close()
|
wsRef.current?.close()
|
||||||
setActiveHostId(hostId)
|
|
||||||
setConnected(false)
|
setConnected(false)
|
||||||
const term = termRef.current
|
const term = termRef.current
|
||||||
if (!term) return
|
if (!term) return
|
||||||
|
|
@ -61,7 +348,7 @@ export default function Terminal() {
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
ws.send(JSON.stringify({ type: 'connect', integrationId: hostId, cols: term.cols, rows: term.rows }))
|
ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows }))
|
||||||
}
|
}
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
|
|
@ -88,44 +375,22 @@ export default function Terminal() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const host = hosts.find((h) => h.id === hostId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-4">
|
<div
|
||||||
<div className="w-56 shrink-0 overflow-y-auto rounded-lg border border-white/10 bg-white/5 p-3">
|
onClick={onFocus}
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide" style={{ color: TEXT_SECONDARY }}>
|
className="flex min-w-0 min-h-0 flex-col rounded-lg p-2"
|
||||||
SSH Hosts
|
|
||||||
</p>
|
|
||||||
{hosts.length === 0 && (
|
|
||||||
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
|
|
||||||
No SSH integrations configured. Add one in Settings → Integrations.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{hosts.map((h) => (
|
|
||||||
<button
|
|
||||||
key={h.id}
|
|
||||||
onClick={() => connect(h.id)}
|
|
||||||
className="rounded-md px-2 py-1.5 text-left text-sm transition-colors"
|
|
||||||
style={{
|
style={{
|
||||||
background: activeHostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
|
backgroundColor: '#15161A',
|
||||||
color: activeHostId === h.id ? GOLD : '#E8E6E0',
|
border: active ? `1px solid ${GOLD}` : '1px solid rgba(255,255,255,0.1)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{h.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col rounded-lg border border-white/10 bg-[#15161A] p-2">
|
|
||||||
<div className="mb-2 flex items-center gap-2 px-1 text-xs" style={{ color: TEXT_SECONDARY }}>
|
<div className="mb-2 flex items-center gap-2 px-1 text-xs" style={{ color: TEXT_SECONDARY }}>
|
||||||
<span
|
<span className="inline-block h-2 w-2 rounded-full" style={{ background: connected ? '#2ECC71' : '#7A7D85' }} />
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
{host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'}
|
||||||
style={{ background: connected ? '#2ECC71' : '#7A7D85' }}
|
|
||||||
/>
|
|
||||||
{activeHostId ? (connected ? 'Connected' : 'Disconnected') : 'Select a host to connect'}
|
|
||||||
</div>
|
</div>
|
||||||
<div ref={containerRef} className="min-h-0 flex-1" />
|
<div ref={containerRef} className="min-h-0 flex-1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue