diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index a64439d..52666de 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -45,11 +45,13 @@ Rationale for splitting: 1a alone is a real, useful terminal (matches what `/ter **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 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). - **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. - - 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 2 — SSH Tunnels (NOT STARTED) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index f1c5ab7..c6ac2c4 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -398,7 +398,7 @@ function SshHostsSection() { setDrafts((prev) => ({ ...prev, [id]: { ...prev[id], [fieldKey]: value } })) } - const fieldsWithJumpHost = (excludeId?: number): FieldDef[] => [ + const fieldsWithJumpHost = (): FieldDef[] => [ ...sshFields, { key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' }, ] @@ -420,7 +420,7 @@ function SshHostsSection() { setStatusMsg((prev) => ({ ...prev, [host.id]: '' })) try { 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 }) setHosts((prev) => (prev ?? []).map((h) => (h.id === integration.id ? integration : h))) setStatusMsg((prev) => ({ ...prev, [host.id]: 'Saved' })) @@ -584,7 +584,7 @@ function SshHostsSection() {
- {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)}
) @@ -925,7 +925,7 @@ function AboutSection() { ) } -const sectionComponents: Record JSX.Element> = { +const sectionComponents: Record React.ReactElement> = { profile: ProfileSection, appearance: AppearanceSection, integrations: IntegrationsSection, diff --git a/src/pages/Terminal.tsx b/src/pages/Terminal.tsx index 386a4c0..2a5a348 100644 --- a/src/pages/Terminal.tsx +++ b/src/pages/Terminal.tsx @@ -2,19 +2,84 @@ import { useEffect, useRef, useState } from 'react' import { Terminal as XTerm } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import '@xterm/xterm/css/xterm.css' +import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash } from 'lucide-react' import { api, getToken, type Integration } from '../lib/api' const GOLD = '#C8A434' 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() { const [hosts, setHosts] = useState([]) - const [activeHostId, setActiveHostId] = useState(null) - const [connected, setConnected] = useState(false) - const containerRef = useRef(null) - const termRef = useRef(null) - const fitRef = useRef(null) - const wsRef = useRef(null) + const [tabs, setTabs] = useState(() => [newTab()]) + const [activeTabId, setActiveTabId] = useState(() => tabs[0].id) + const [activePaneId, setActivePaneId] = useState(null) + const [prefs, setPrefs] = useState(loadPrefs) + const [showPrefs, setShowPrefs] = useState(false) useEffect(() => { 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 ( +
+
+

+ SSH Hosts +

+ {hosts.length === 0 && ( +

+ No SSH integrations configured. Add one in Settings → Integrations. +

+ )} +
+ {hosts.map((h) => ( + + ))} +
+

+ Click a pane, then a host to connect it. +

+
+ +
+
+
+ {tabs.map((tab) => ( +
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} + { + e.stopPropagation() + closeTab(tab.id) + }} + /> +
+ ))} + +
+ +
+ + + + +
+
+ + {showPrefs && ( +
+ + + +
+ )} + +
+ {activeTab.panes.map((pane) => ( + setActivePaneId(pane.id)} + /> + ))} +
+
+
+ ) +} + +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(null) + const termRef = useRef(null) + const fitRef = useRef(null) + const wsRef = useRef(null) + const lastHostIdRef = useRef(null) + useEffect(() => { if (!containerRef.current) return + const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0] const term = new XTerm({ cursorBlink: true, - fontSize: 13, - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', - theme: { background: '#15161A', foreground: '#E8E6E0', cursor: GOLD }, + fontSize: prefs.fontSize, + fontFamily: prefs.fontFamily, + theme: { background: theme.background, foreground: theme.foreground, cursor: theme.cursor }, }) const fit = new FitAddon() term.loadAddon(fit) @@ -44,11 +320,22 @@ export default function Terminal() { term.dispose() 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() - setActiveHostId(hostId) setConnected(false) const term = termRef.current if (!term) return @@ -61,7 +348,7 @@ export default function Terminal() { wsRef.current = ws 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) => { const msg = JSON.parse(event.data) @@ -88,44 +375,22 @@ export default function Terminal() { }) } - return ( -
-
-

- SSH Hosts -

- {hosts.length === 0 && ( -

- No SSH integrations configured. Add one in Settings → Integrations. -

- )} -
- {hosts.map((h) => ( - - ))} -
-
+ const host = hosts.find((h) => h.id === hostId) -
-
- - {activeHostId ? (connected ? 'Connected' : 'Disconnected') : 'Select a host to connect'} -
-
+ return ( +
+
+ + {host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'}
+
) }