diff --git a/App.tsx b/App.tsx index 46afa0ed..f0498dea 100755 --- a/App.tsx +++ b/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import SettingsDialog from './components/SettingsDialog'; import HostDetailsPanel from './components/HostDetailsPanel'; import { SftpView } from './components/SftpView'; @@ -6,388 +6,75 @@ import { TopTabs } from './components/TopTabs'; import { QuickSwitcher } from './components/QuickSwitcher'; import { VaultView } from './components/VaultView'; import { TerminalLayer } from './components/TerminalLayer'; -import { normalizeDistroId } from './components/DistroAvatar'; -import { INITIAL_HOSTS, INITIAL_SNIPPETS } from './lib/defaultData'; -import { - STORAGE_KEY_COLOR, - STORAGE_KEY_GROUPS, - STORAGE_KEY_HOSTS, - STORAGE_KEY_KEYS, - STORAGE_KEY_SNIPPET_PACKAGES, - STORAGE_KEY_SNIPPETS, - STORAGE_KEY_SYNC, - STORAGE_KEY_TERM_THEME, - STORAGE_KEY_THEME, -} from './lib/storageKeys'; -import { Host, SSHKey, Snippet, SyncConfig, TerminalSession, Workspace, WorkspaceNode } from './types'; -import { TERMINAL_THEMES } from './lib/terminalThemes'; +import { Host } from './types'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './components/ui/dialog'; import { Button } from './components/ui/button'; import { Input } from './components/ui/input'; import { Label } from './components/ui/label'; - +import { useSettingsState } from './application/state/useSettingsState'; +import { useVaultState } from './application/state/useVaultState'; +import { useSessionState } from './application/state/useSessionState'; function App() { - const sanitizeHost = (host: Host): Host => { - const cleanHostname = (host.hostname || '').split(/\s+/)[0]; - const cleanDistro = normalizeDistroId(host.distro); - return { ...host, hostname: cleanHostname, distro: cleanDistro }; - }; - - const [theme, setTheme] = useState<'dark' | 'light'>(() => (localStorage.getItem(STORAGE_KEY_THEME) as any) || 'light'); - const [primaryColor, setPrimaryColor] = useState(() => localStorage.getItem(STORAGE_KEY_COLOR) || '221.2 83.2% 53.3%'); - const [syncConfig, setSyncConfig] = useState(() => { - const saved = localStorage.getItem(STORAGE_KEY_SYNC); - return saved ? JSON.parse(saved) : null; - }); - const [terminalThemeId, setTerminalThemeId] = useState(() => localStorage.getItem(STORAGE_KEY_TERM_THEME) || 'termius-dark'); - - // Data - const [hosts, setHosts] = useState([]); - const [keys, setKeys] = useState([]); - const [snippets, setSnippets] = useState([]); - const [customGroups, setCustomGroups] = useState([]); - const [workspaceRenameTarget, setWorkspaceRenameTarget] = useState(null); - const [workspaceRenameValue, setWorkspaceRenameValue] = useState(''); - - // Navigation & Sessions - const [sessions, setSessions] = useState([]); - const [activeTabId, setActiveTabId] = useState('vault'); // 'vault', session.id, or workspace.id - const [workspaces, setWorkspaces] = useState([]); - const [draggingSessionId, setDraggingSessionId] = useState(null); - - // Modals - const [editingHost, setEditingHost] = useState(null); const [isFormOpen, setIsFormOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false); const [quickSearch, setQuickSearch] = useState(''); - - // Vault View State + const [editingHost, setEditingHost] = useState(null); const [showAssistant, setShowAssistant] = useState(false); - const [snippetPackages, setSnippetPackages] = useState([]); - const createLocalTerminal = () => { - const sessionId = crypto.randomUUID(); - const localHostId = `local-${sessionId}`; - const newSession: TerminalSession = { - id: sessionId, - hostId: localHostId, - hostLabel: 'Local Terminal', - hostname: 'localhost', - username: 'local', - status: 'connecting', - }; - setSessions(prev => [...prev, newSession]); - setActiveTabId(sessionId); - }; + const { + theme, + setTheme, + primaryColor, + setPrimaryColor, + syncConfig, + updateSyncConfig, + terminalThemeId, + setTerminalThemeId, + currentTerminalTheme, + } = useSettingsState(); - // --- Effects --- - useEffect(() => { - const root = window.document.documentElement; - root.classList.remove('light', 'dark'); - root.classList.add(theme); - root.style.setProperty('--primary', primaryColor); - root.style.setProperty('--accent', primaryColor); - root.style.setProperty('--ring', primaryColor); - const lightness = parseFloat(primaryColor.split(/\s+/)[2]?.replace('%', '') || ''); - const accentForeground = theme === 'dark' - ? '220 40% 96%' - : (!Number.isNaN(lightness) && lightness < 55 ? '0 0% 98%' : '222 47% 12%'); - root.style.setProperty('--accent-foreground', accentForeground); - localStorage.setItem(STORAGE_KEY_THEME, theme); - localStorage.setItem(STORAGE_KEY_COLOR, primaryColor); - }, [theme, primaryColor]); + const { + hosts, + keys, + snippets, + customGroups, + snippetPackages, + updateHosts, + updateKeys, + updateSnippets, + updateSnippetPackages, + updateCustomGroups, + updateHostDistro, + exportData, + importDataFromString, + } = useVaultState(); - useEffect(() => { - localStorage.setItem(STORAGE_KEY_TERM_THEME, terminalThemeId); - }, [terminalThemeId]); + const { + sessions, + workspaces, + activeTabId, + setActiveTabId, + draggingSessionId, + setDraggingSessionId, + workspaceRenameTarget, + workspaceRenameValue, + setWorkspaceRenameValue, + startWorkspaceRename, + submitWorkspaceRename, + resetWorkspaceRename, + createLocalTerminal, + connectToHost, + closeSession, + closeWorkspace, + updateSessionStatus, + createWorkspaceFromSessions, + addSessionToWorkspace, + updateSplitSizes, + orphanSessions, + } = useSessionState(); - useEffect(() => { - const savedHosts = localStorage.getItem(STORAGE_KEY_HOSTS); - const savedKeys = localStorage.getItem(STORAGE_KEY_KEYS); - const savedGroups = localStorage.getItem(STORAGE_KEY_GROUPS); - const savedSnippets = localStorage.getItem(STORAGE_KEY_SNIPPETS); - const savedSnippetPackages = localStorage.getItem(STORAGE_KEY_SNIPPET_PACKAGES); - - if (savedHosts) { - const sanitized = JSON.parse(savedHosts).map((h: Host) => sanitizeHost(h)); - setHosts(sanitized); - localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify(sanitized)); - } else updateHosts(INITIAL_HOSTS); - - if (savedKeys) setKeys(JSON.parse(savedKeys)); - if (savedSnippets) setSnippets(JSON.parse(savedSnippets)); - else updateSnippets(INITIAL_SNIPPETS); - if (savedSnippetPackages) setSnippetPackages(JSON.parse(savedSnippetPackages)); - - if (savedGroups) setCustomGroups(JSON.parse(savedGroups)); - }, []); - - const updateHosts = (d: Host[]) => { - const cleaned = d.map(sanitizeHost); - setHosts(cleaned); - localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify(cleaned)); - }; - const updateKeys = (d: SSHKey[]) => { setKeys(d); localStorage.setItem(STORAGE_KEY_KEYS, JSON.stringify(d)); }; - const updateSnippets = (d: Snippet[]) => { setSnippets(d); localStorage.setItem(STORAGE_KEY_SNIPPETS, JSON.stringify(d)); }; - const updateSnippetPackages = (d: string[]) => { setSnippetPackages(d); localStorage.setItem(STORAGE_KEY_SNIPPET_PACKAGES, JSON.stringify(d)); }; - const updateCustomGroups = (d: string[]) => { setCustomGroups(d); localStorage.setItem(STORAGE_KEY_GROUPS, JSON.stringify(d)); }; - const updateSyncConfig = (d: SyncConfig | null) => { setSyncConfig(d); localStorage.setItem(STORAGE_KEY_SYNC, JSON.stringify(d)); }; - - // --- Session Management --- - const handleConnect = (host: Host) => { - const newSession: TerminalSession = { - id: crypto.randomUUID(), - hostId: host.id, - hostLabel: host.label, - hostname: host.hostname, - username: host.username, - status: 'connecting' - }; - setSessions(prev => [...prev, newSession]); - setActiveTabId(newSession.id); - }; - - const handleEditHost = (host: Host) => { - setEditingHost(host); - setIsFormOpen(true); - }; - - const handleDeleteHost = (hostId: string) => { - const target = hosts.find(h => h.id === hostId); - const confirmed = window.confirm(`Delete host "${target?.label || hostId}"?`); - if (!confirmed) return; - updateHosts(hosts.filter(h => h.id !== hostId)); - }; - - const updateSessionStatus = (sessionId: string, status: TerminalSession['status']) => { - setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s)); - }; - - const updateHostDistro = (hostId: string, distro: string) => { - const normalized = normalizeDistroId(distro); - setHosts(prev => { - const next = prev.map(h => h.id === hostId ? { ...h, distro: normalized } : h); - localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify(next)); - return next; - }); - }; - - const pruneWorkspaceNode = (node: WorkspaceNode, targetSessionId: string): WorkspaceNode | null => { - if (node.type === 'pane') { - return node.sessionId === targetSessionId ? null : node; - } - const nextChildren: WorkspaceNode[] = []; - const nextSizes: number[] = []; - const sizeList = node.sizes && node.sizes.length === node.children.length ? node.sizes : node.children.map(() => 1); - - node.children.forEach((child, idx) => { - const pruned = pruneWorkspaceNode(child, targetSessionId); - if (pruned) { - nextChildren.push(pruned); - nextSizes.push(sizeList[idx] ?? 1); - } - }); - - if (nextChildren.length === 0) return null; - if (nextChildren.length === 1) return nextChildren[0]; - - const total = nextSizes.reduce((acc, n) => acc + n, 0) || 1; - const normalized = nextSizes.map(n => n / total); - return { ...node, children: nextChildren, sizes: normalized }; - }; - - const closeSession = (sessionId: string, e?: React.MouseEvent) => { - e?.stopPropagation(); - const targetSession = sessions.find(s => s.id === sessionId); - const workspaceId = targetSession?.workspaceId; - let removedWorkspaceId: string | null = null; - - let nextWorkspaces = workspaces; - if (workspaceId) { - nextWorkspaces = workspaces - .map(ws => { - if (ws.id !== workspaceId) return ws; - const pruned = pruneWorkspaceNode(ws.root, sessionId); - if (!pruned) { - removedWorkspaceId = ws.id; - return null; - } - return { ...ws, root: pruned }; - }) - .filter((ws): ws is Workspace => Boolean(ws)); - } - - const remainingSessions = sessions.filter(s => s.id !== sessionId); - setWorkspaces(nextWorkspaces); - setSessions(remainingSessions); - - const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1]; - const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0]; - - const setFallback = () => { - if (fallbackWorkspace) setActiveTabId(fallbackWorkspace.id); - else if (fallbackSolo) setActiveTabId(fallbackSolo.id); - else setActiveTabId('vault'); - }; - - if (activeTabId === sessionId) { - if (fallbackSolo) setActiveTabId(fallbackSolo.id); - else setFallback(); - } else if (removedWorkspaceId && activeTabId === removedWorkspaceId) { - setFallback(); - } else if (workspaceId && activeTabId === workspaceId && !nextWorkspaces.find(w => w.id === workspaceId)) { - setFallback(); - } - }; - - const closeWorkspace = (workspaceId: string) => { - const remainingWorkspaces = workspaces.filter(w => w.id !== workspaceId); - const remainingSessions = sessions.filter(s => s.workspaceId !== workspaceId); - setWorkspaces(remainingWorkspaces); - setSessions(remainingSessions); - - if (activeTabId === workspaceId) { - const remainingOrphans = remainingSessions.filter(s => !s.workspaceId); - if (remainingWorkspaces.length > 0) { - setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id); - } else if (remainingOrphans.length > 0) { - setActiveTabId(remainingOrphans[remainingOrphans.length - 1].id); - } else { - setActiveTabId('vault'); - } - } - }; - - const renameWorkspace = (workspaceId: string) => { - const target = workspaces.find(w => w.id === workspaceId); - if (!target) return; - setWorkspaceRenameTarget(target); - setWorkspaceRenameValue(target.title); - }; - - const submitWorkspaceRename = () => { - const name = workspaceRenameValue.trim(); - if (!name || !workspaceRenameTarget) return; - setWorkspaces(prev => prev.map(w => w.id === workspaceRenameTarget.id ? { ...w, title: name } : w)); - setWorkspaceRenameTarget(null); - setWorkspaceRenameValue(''); - }; - - const createWorkspaceFromSessions = ( - baseSessionId: string, - joiningSessionId: string, - hint: { direction: 'horizontal' | 'vertical'; position: 'left' | 'right' | 'top' | 'bottom'; targetSessionId?: string } - ) => { - if (!hint || baseSessionId === joiningSessionId) return; - const base = sessions.find(s => s.id === baseSessionId); - const joining = sessions.find(s => s.id === joiningSessionId); - if (!base || !joining || base.workspaceId || joining.workspaceId) return; - - const basePane: WorkspaceNode = { id: crypto.randomUUID(), type: 'pane', sessionId: baseSessionId }; - const newPane: WorkspaceNode = { id: crypto.randomUUID(), type: 'pane', sessionId: joiningSessionId }; - const children = (hint.position === 'left' || hint.position === 'top') ? [newPane, basePane] : [basePane, newPane]; - - const newWorkspace: Workspace = { - id: `ws-${crypto.randomUUID()}`, - title: 'Workspace', - root: { - id: crypto.randomUUID(), - type: 'split', - direction: hint.direction, - children, - sizes: [1, 1], - }, - }; - - setWorkspaces(prev => [...prev, newWorkspace]); - setSessions(prev => prev.map(s => { - if (s.id === baseSessionId || s.id === joiningSessionId) { - return { ...s, workspaceId: newWorkspace.id }; - } - return s; - })); - setActiveTabId(newWorkspace.id); - }; - - const addSessionToWorkspace = ( - workspaceId: string, - sessionId: string, - hint: { direction: 'horizontal' | 'vertical'; position: 'left' | 'right' | 'top' | 'bottom'; targetSessionId?: string } | null - ) => { - const targetWorkspace = workspaces.find(w => w.id === workspaceId); - if (!targetWorkspace || !hint) return; - const session = sessions.find(s => s.id === sessionId); - if (!session || session.workspaceId) return; - - const targetSessionId = hint.targetSessionId; - const insertPane = (node: WorkspaceNode): WorkspaceNode => { - if (node.type === 'pane' && node.sessionId === targetSessionId) { - const pane: WorkspaceNode = { id: crypto.randomUUID(), type: 'pane', sessionId }; - const children = (hint.position === 'left' || hint.position === 'top') ? [pane, node] : [node, pane]; - return { - id: crypto.randomUUID(), - type: 'split', - direction: hint.direction, - children, - sizes: [1, 1], - }; - } - if (node.type === 'split') { - return { - ...node, - children: node.children.map(child => insertPane(child)), - }; - } - return node; - }; - - setWorkspaces(prev => prev.map(ws => { - if (ws.id !== workspaceId) return ws; - let newRoot = ws.root; - if (targetSessionId) { - newRoot = insertPane(ws.root); - } else { - const pane: WorkspaceNode = { id: crypto.randomUUID(), type: 'pane', sessionId }; - newRoot = { - id: crypto.randomUUID(), - type: 'split', - direction: hint.direction, - children: (hint.position === 'left' || hint.position === 'top') ? [pane, ws.root] : [ws.root, pane], - sizes: [1, 1], - }; - } - return { ...ws, root: newRoot }; - })); - - setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, workspaceId } : s)); - setActiveTabId(workspaceId); - }; - - // --- Data Logic --- - const getExportData = () => ({ hosts, keys, snippets, customGroups }); - const handleImportData = (jsonString: string) => { - const data = JSON.parse(jsonString); - if(data.hosts) updateHosts(data.hosts); - if(data.keys) updateKeys(data.keys); - if(data.snippets) updateSnippets(data.snippets); - if(data.customGroups) updateCustomGroups(data.customGroups); - }; - - const quickResults = useMemo(() => { - const term = quickSearch.trim().toLowerCase(); - const filtered = term - ? hosts.filter(h => - h.label.toLowerCase().includes(term) || - h.hostname.toLowerCase().includes(term) || - (h.group || '').toLowerCase().includes(term) - ) - : hosts; - return filtered.slice(0, 8); - }, [hosts, quickSearch]); - - const currentTerminalTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0]; const isVaultActive = activeTabId === 'vault'; const isSftpActive = activeTabId === 'sftp'; const isTerminalLayerActive = !isVaultActive && !isSftpActive; @@ -404,26 +91,29 @@ function App() { return () => window.removeEventListener('keydown', onKeyDown); }, [isQuickSwitcherOpen]); - const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId), [sessions]); - const updateSplitSizes = (workspaceId: string, splitId: string, sizes: number[]) => { - setWorkspaces(prev => prev.map(ws => { - if (ws.id !== workspaceId) return ws; - const patch = (node: WorkspaceNode): WorkspaceNode => { - if (node.type === 'split') { - if (node.id === splitId) { - return { ...node, sizes }; - } - return { ...node, children: node.children.map(child => patch(child)) }; - } - return node; - }; - return { ...ws, root: patch(ws.root) }; - })); + const quickResults = useMemo(() => { + const term = quickSearch.trim().toLowerCase(); + const filtered = term + ? hosts.filter(h => + h.label.toLowerCase().includes(term) || + h.hostname.toLowerCase().includes(term) || + (h.group || '').toLowerCase().includes(term) + ) + : hosts; + return filtered.slice(0, 8); + }, [hosts, quickSearch]); + + const handleEditHost = (host: Host) => { + setEditingHost(host); + setIsFormOpen(true); }; - const handleSessionDragStart = (sessionId: string) => setDraggingSessionId(sessionId); - const handleSessionDragEnd = () => setDraggingSessionId(null); - + const handleDeleteHost = (hostId: string) => { + const target = hosts.find(h => h.id === hostId); + const confirmed = window.confirm(`Delete host "${target?.label || hostId}"?`); + if (!confirmed) return; + updateHosts(hosts.filter(h => h.id !== hostId)); + }; return (
e.preventDefault()}> @@ -439,12 +129,12 @@ function App() { isMacClient={isMacClient} onSelectTab={setActiveTabId} onCloseSession={closeSession} - onRenameWorkspace={renameWorkspace} + onRenameWorkspace={startWorkspaceRename} onCloseWorkspace={closeWorkspace} onOpenQuickSwitcher={() => setIsQuickSwitcherOpen(true)} onToggleTheme={() => setTheme(prev => prev === 'dark' ? 'light' : 'dark')} - onStartSessionDrag={handleSessionDragStart} - onEndSessionDrag={handleSessionDragEnd} + onStartSessionDrag={setDraggingSessionId} + onEndSessionDrag={() => setDraggingSessionId(null)} />
@@ -464,7 +154,7 @@ function App() { onNewHost={() => { setEditingHost(null); setIsFormOpen(true); }} onEditHost={handleEditHost} onDeleteHost={handleDeleteHost} - onConnect={handleConnect} + onConnect={connectToHost} onUpdateHosts={updateHosts} onUpdateKeys={updateKeys} onUpdateSnippets={updateSnippets} @@ -494,13 +184,14 @@ function App() { onSetDraggingSessionId={setDraggingSessionId} />
+ { - handleConnect(host); + connectToHost(host); setIsQuickSwitcherOpen(false); setQuickSearch(''); }} @@ -510,8 +201,7 @@ function App() { { if (!open) { - setWorkspaceRenameTarget(null); - setWorkspaceRenameValue(''); + resetWorkspaceRename(); } }}> @@ -530,13 +220,12 @@ function App() { />
- + - {/* Host Panel */} {isFormOpen && ( setIsSettingsOpen(false)} - onImport={handleImportData} - exportData={getExportData} + onImport={importDataFromString} + exportData={exportData} theme={theme} onThemeChange={setTheme} primaryColor={primaryColor} diff --git a/agents.md b/agents.md new file mode 100644 index 00000000..1dcaf4cc --- /dev/null +++ b/agents.md @@ -0,0 +1,44 @@ +# Agents Overview + +This project is wired around three layers: domain (pure logic), application state (React hooks orchestrating the domain), and UI (components). Use this document as a quick guide for extending or reusing the codebase. + +## Current Agents (Roles) +- **Domain** (`domain/`): Models and pure helpers. Examples: + - `models.ts` defines Host/SSHKey/Snippet/Workspace entities. + - `host.ts` handles distro normalization and host sanitization. + - `workspace.ts` contains workspace tree operations (split/insert/prune/sizing). +- **Application State** (`application/state/`): Hooks that own state and persistence boundaries. + - `useSettingsState` handles theme, accent color, terminal themes, sync config (localStorage). + - `useVaultState` owns hosts/keys/snippets/custom groups and import/export, persisting to storage. + - `useSessionState` owns terminal sessions, workspace lifecycle, drag/split logic. +- **Infrastructure** (`infrastructure/`): External edges and configuration. + - `config/` holds defaults, storage keys, terminal themes. + - `persistence/localStorageAdapter.ts` abstracts localStorage read/write. + - `services/` contains networked services (Gemini AI, GitHub Gist sync). +- **UI** (`components/`, `App.tsx`): Presentation; depends on hooks and domain helpers only. + +## How Things Talk +- UI calls application hooks → hooks call domain helpers → persistence/config via infrastructure adapters. +- `App.tsx` wires hooks to components; no business logic should live in components beyond view glue. +- Local storage keys are centralized in `infrastructure/config/storageKeys.ts`; avoid ad-hoc `localStorage` calls elsewhere. + +## Extending the System +1) **New domain logic**: Add pure functions/types under `domain/`; avoid side effects. +2) **New stateful behavior**: Wrap it in a hook under `application/state/`; keep external I/O behind adapters. +3) **New integrations**: Create adapters under `infrastructure/services/` (or `persistence/`); expose typed functions. +4) **UI changes**: Consume hook outputs/handlers; do not bypass state hooks for persistence or domain logic. + +## Data & Storage +- Persisted keys: see `storageKeys.ts`. Use `localStorageAdapter` for all reads/writes. +- Seed data: `config/defaultData.ts`; terminal themes: `config/terminalThemes.ts`. + +## Testing & Safety +- Favor unit tests for domain helpers (e.g., `workspace.ts`, `host.ts`) and hook-level tests for application state. +- When changing storage keys or schema, provide migration or backward-compat handling. +- Keep components dumb: if a prop list grows large, consider deriving a smaller view model in the hook. + +## Coding Conventions +- Keep logic pure in domain; side effects belong to application/infrastructure layers. +- Prefer composition over deep prop drilling; lift shared state into hooks. +- Avoid direct network/fetch in components; add a service/adaptor first. +- Maintain ASCII-only unless required by existing file content. diff --git a/application/state/useSessionState.ts b/application/state/useSessionState.ts new file mode 100644 index 00000000..89ed1fd9 --- /dev/null +++ b/application/state/useSessionState.ts @@ -0,0 +1,205 @@ +import { MouseEvent, useMemo, useState } from 'react'; +import { Host, TerminalSession, Workspace } from '../../domain/models'; +import { + createWorkspaceFromSessions as createWorkspaceEntity, + insertPaneIntoWorkspace, + pruneWorkspaceNode, + SplitHint, + updateWorkspaceSplitSizes, +} from '../../domain/workspace'; + +export const useSessionState = () => { + const [sessions, setSessions] = useState([]); + const [workspaces, setWorkspaces] = useState([]); + const [activeTabId, setActiveTabId] = useState('vault'); + const [draggingSessionId, setDraggingSessionId] = useState(null); + const [workspaceRenameTarget, setWorkspaceRenameTarget] = useState(null); + const [workspaceRenameValue, setWorkspaceRenameValue] = useState(''); + + const createLocalTerminal = () => { + const sessionId = crypto.randomUUID(); + const localHostId = `local-${sessionId}`; + const newSession: TerminalSession = { + id: sessionId, + hostId: localHostId, + hostLabel: 'Local Terminal', + hostname: 'localhost', + username: 'local', + status: 'connecting', + }; + setSessions(prev => [...prev, newSession]); + setActiveTabId(sessionId); + }; + + const connectToHost = (host: Host) => { + const newSession: TerminalSession = { + id: crypto.randomUUID(), + hostId: host.id, + hostLabel: host.label, + hostname: host.hostname, + username: host.username, + status: 'connecting', + }; + setSessions(prev => [...prev, newSession]); + setActiveTabId(newSession.id); + }; + + const updateSessionStatus = (sessionId: string, status: TerminalSession['status']) => { + setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s)); + }; + + const closeSession = (sessionId: string, e?: MouseEvent) => { + e?.stopPropagation(); + const targetSession = sessions.find(s => s.id === sessionId); + const workspaceId = targetSession?.workspaceId; + let removedWorkspaceId: string | null = null; + + let nextWorkspaces = workspaces; + if (workspaceId) { + nextWorkspaces = workspaces + .map(ws => { + if (ws.id !== workspaceId) return ws; + const pruned = pruneWorkspaceNode(ws.root, sessionId); + if (!pruned) { + removedWorkspaceId = ws.id; + return null; + } + return { ...ws, root: pruned }; + }) + .filter((ws): ws is Workspace => Boolean(ws)); + } + + const remainingSessions = sessions.filter(s => s.id !== sessionId); + setWorkspaces(nextWorkspaces); + setSessions(remainingSessions); + + const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1]; + const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0]; + + const setFallback = () => { + if (fallbackWorkspace) setActiveTabId(fallbackWorkspace.id); + else if (fallbackSolo) setActiveTabId(fallbackSolo.id); + else setActiveTabId('vault'); + }; + + if (activeTabId === sessionId) { + if (fallbackSolo) setActiveTabId(fallbackSolo.id); + else setFallback(); + } else if (removedWorkspaceId && activeTabId === removedWorkspaceId) { + setFallback(); + } else if (workspaceId && activeTabId === workspaceId && !nextWorkspaces.find(w => w.id === workspaceId)) { + setFallback(); + } + }; + + const closeWorkspace = (workspaceId: string) => { + const remainingWorkspaces = workspaces.filter(w => w.id !== workspaceId); + const remainingSessions = sessions.filter(s => s.workspaceId !== workspaceId); + setWorkspaces(remainingWorkspaces); + setSessions(remainingSessions); + + if (activeTabId === workspaceId) { + const remainingOrphans = remainingSessions.filter(s => !s.workspaceId); + if (remainingWorkspaces.length > 0) { + setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id); + } else if (remainingOrphans.length > 0) { + setActiveTabId(remainingOrphans[remainingOrphans.length - 1].id); + } else { + setActiveTabId('vault'); + } + } + }; + + const startWorkspaceRename = (workspaceId: string) => { + const target = workspaces.find(w => w.id === workspaceId); + if (!target) return; + setWorkspaceRenameTarget(target); + setWorkspaceRenameValue(target.title); + }; + + const submitWorkspaceRename = () => { + const name = workspaceRenameValue.trim(); + if (!name || !workspaceRenameTarget) return; + setWorkspaces(prev => prev.map(w => w.id === workspaceRenameTarget.id ? { ...w, title: name } : w)); + setWorkspaceRenameTarget(null); + setWorkspaceRenameValue(''); + }; + + const resetWorkspaceRename = () => { + setWorkspaceRenameTarget(null); + setWorkspaceRenameValue(''); + }; + + const createWorkspaceFromSessions = ( + baseSessionId: string, + joiningSessionId: string, + hint: SplitHint + ) => { + if (!hint || baseSessionId === joiningSessionId) return; + const base = sessions.find(s => s.id === baseSessionId); + const joining = sessions.find(s => s.id === joiningSessionId); + if (!base || !joining || base.workspaceId || joining.workspaceId) return; + + const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint); + setWorkspaces(prev => [...prev, newWorkspace]); + setSessions(prev => prev.map(s => { + if (s.id === baseSessionId || s.id === joiningSessionId) { + return { ...s, workspaceId: newWorkspace.id }; + } + return s; + })); + setActiveTabId(newWorkspace.id); + }; + + const addSessionToWorkspace = ( + workspaceId: string, + sessionId: string, + hint: SplitHint + ) => { + const targetWorkspace = workspaces.find(w => w.id === workspaceId); + if (!targetWorkspace || !hint) return; + const session = sessions.find(s => s.id === sessionId); + if (!session || session.workspaceId) return; + + setWorkspaces(prev => prev.map(ws => { + if (ws.id !== workspaceId) return ws; + return { ...ws, root: insertPaneIntoWorkspace(ws.root, sessionId, hint) }; + })); + + setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, workspaceId } : s)); + setActiveTabId(workspaceId); + }; + + const updateSplitSizes = (workspaceId: string, splitId: string, sizes: number[]) => { + setWorkspaces(prev => prev.map(ws => { + if (ws.id !== workspaceId) return ws; + return { ...ws, root: updateWorkspaceSplitSizes(ws.root, splitId, sizes) }; + })); + }; + + const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId), [sessions]); + + return { + sessions, + workspaces, + activeTabId, + setActiveTabId, + draggingSessionId, + setDraggingSessionId, + workspaceRenameTarget, + workspaceRenameValue, + setWorkspaceRenameValue, + startWorkspaceRename, + submitWorkspaceRename, + resetWorkspaceRename, + createLocalTerminal, + connectToHost, + closeSession, + closeWorkspace, + updateSessionStatus, + createWorkspaceFromSessions, + addSessionToWorkspace, + updateSplitSizes, + orphanSessions, + }; +}; diff --git a/application/state/useSettingsState.ts b/application/state/useSettingsState.ts new file mode 100644 index 00000000..92280e04 --- /dev/null +++ b/application/state/useSettingsState.ts @@ -0,0 +1,67 @@ +import { useEffect, useMemo, useState } from 'react'; +import { SyncConfig } from '../../domain/models'; +import { + STORAGE_KEY_COLOR, + STORAGE_KEY_SYNC, + STORAGE_KEY_TERM_THEME, + STORAGE_KEY_THEME, +} from '../../infrastructure/config/storageKeys'; +import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes'; +import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; + +const DEFAULT_COLOR = '221.2 83.2% 53.3%'; +const DEFAULT_THEME: 'light' | 'dark' = 'light'; +const DEFAULT_TERMINAL_THEME = 'termius-dark'; + +const applyThemeTokens = (theme: 'light' | 'dark', primaryColor: string) => { + const root = window.document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(theme); + root.style.setProperty('--primary', primaryColor); + root.style.setProperty('--accent', primaryColor); + root.style.setProperty('--ring', primaryColor); + const lightness = parseFloat(primaryColor.split(/\s+/)[2]?.replace('%', '') || ''); + const accentForeground = theme === 'dark' + ? '220 40% 96%' + : (!Number.isNaN(lightness) && lightness < 55 ? '0 0% 98%' : '222 47% 12%'); + root.style.setProperty('--accent-foreground', accentForeground); +}; + +export const useSettingsState = () => { + const [theme, setTheme] = useState<'dark' | 'light'>(() => (localStorageAdapter.readString(STORAGE_KEY_THEME) as 'dark' | 'light') || DEFAULT_THEME); + const [primaryColor, setPrimaryColor] = useState(() => localStorageAdapter.readString(STORAGE_KEY_COLOR) || DEFAULT_COLOR); + const [syncConfig, setSyncConfig] = useState(() => localStorageAdapter.read(STORAGE_KEY_SYNC)); + const [terminalThemeId, setTerminalThemeId] = useState(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME); + + useEffect(() => { + applyThemeTokens(theme, primaryColor); + localStorageAdapter.writeString(STORAGE_KEY_THEME, theme); + localStorageAdapter.writeString(STORAGE_KEY_COLOR, primaryColor); + }, [theme, primaryColor]); + + useEffect(() => { + localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId); + }, [terminalThemeId]); + + const updateSyncConfig = (config: SyncConfig | null) => { + setSyncConfig(config); + localStorageAdapter.write(STORAGE_KEY_SYNC, config); + }; + + const currentTerminalTheme = useMemo( + () => TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0], + [terminalThemeId] + ); + + return { + theme, + setTheme, + primaryColor, + setPrimaryColor, + syncConfig, + updateSyncConfig, + terminalThemeId, + setTerminalThemeId, + currentTerminalTheme, + }; +}; diff --git a/application/state/useVaultState.ts b/application/state/useVaultState.ts new file mode 100644 index 00000000..4c0f5d62 --- /dev/null +++ b/application/state/useVaultState.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Host, SSHKey, Snippet } from '../../domain/models'; +import { normalizeDistroId, sanitizeHost } from '../../domain/host'; +import { INITIAL_HOSTS, INITIAL_SNIPPETS } from '../../infrastructure/config/defaultData'; +import { + STORAGE_KEY_GROUPS, + STORAGE_KEY_HOSTS, + STORAGE_KEY_KEYS, + STORAGE_KEY_SNIPPET_PACKAGES, + STORAGE_KEY_SNIPPETS, +} from '../../infrastructure/config/storageKeys'; +import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; + +type ExportableVaultData = { + hosts: Host[]; + keys: SSHKey[]; + snippets: Snippet[]; + customGroups: string[]; +}; + +export const useVaultState = () => { + const [hosts, setHosts] = useState([]); + const [keys, setKeys] = useState([]); + const [snippets, setSnippets] = useState([]); + const [customGroups, setCustomGroups] = useState([]); + const [snippetPackages, setSnippetPackages] = useState([]); + + useEffect(() => { + const savedHosts = localStorageAdapter.read(STORAGE_KEY_HOSTS); + const savedKeys = localStorageAdapter.read(STORAGE_KEY_KEYS); + const savedGroups = localStorageAdapter.read(STORAGE_KEY_GROUPS); + const savedSnippets = localStorageAdapter.read(STORAGE_KEY_SNIPPETS); + const savedSnippetPackages = localStorageAdapter.read(STORAGE_KEY_SNIPPET_PACKAGES); + + if (savedHosts?.length) { + const sanitized = savedHosts.map(sanitizeHost); + setHosts(sanitized); + localStorageAdapter.write(STORAGE_KEY_HOSTS, sanitized); + } else { + updateHosts(INITIAL_HOSTS); + } + + if (savedKeys) setKeys(savedKeys); + if (savedSnippets) setSnippets(savedSnippets); + else updateSnippets(INITIAL_SNIPPETS); + + if (savedGroups) setCustomGroups(savedGroups); + if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages); + }, []); + + const updateHosts = (data: Host[]) => { + const cleaned = data.map(sanitizeHost); + setHosts(cleaned); + localStorageAdapter.write(STORAGE_KEY_HOSTS, cleaned); + }; + + const updateKeys = (data: SSHKey[]) => { + setKeys(data); + localStorageAdapter.write(STORAGE_KEY_KEYS, data); + }; + + const updateSnippets = (data: Snippet[]) => { + setSnippets(data); + localStorageAdapter.write(STORAGE_KEY_SNIPPETS, data); + }; + + const updateSnippetPackages = (data: string[]) => { + setSnippetPackages(data); + localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data); + }; + + const updateCustomGroups = (data: string[]) => { + setCustomGroups(data); + localStorageAdapter.write(STORAGE_KEY_GROUPS, data); + }; + + const updateHostDistro = (hostId: string, distro: string) => { + const normalized = normalizeDistroId(distro); + setHosts(prev => { + const next = prev.map(h => h.id === hostId ? { ...h, distro: normalized } : h); + localStorageAdapter.write(STORAGE_KEY_HOSTS, next); + return next; + }); + }; + + const exportData = useCallback((): ExportableVaultData => ({ + hosts, + keys, + snippets, + customGroups, + }), [hosts, keys, snippets, customGroups]); + + const importData = (payload: Partial) => { + if (payload.hosts) updateHosts(payload.hosts); + if (payload.keys) updateKeys(payload.keys); + if (payload.snippets) updateSnippets(payload.snippets); + if (payload.customGroups) updateCustomGroups(payload.customGroups); + }; + + const importDataFromString = (jsonString: string) => { + const data = JSON.parse(jsonString); + importData(data); + }; + + return { + hosts, + keys, + snippets, + customGroups, + snippetPackages, + updateHosts, + updateKeys, + updateSnippets, + updateSnippetPackages, + updateCustomGroups, + updateHostDistro, + exportData, + importDataFromString, + }; +}; diff --git a/components/AssistantPanel.tsx b/components/AssistantPanel.tsx index bbbf27fe..487998bf 100755 --- a/components/AssistantPanel.tsx +++ b/components/AssistantPanel.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { generateSSHCommand, explainLog } from '../services/geminiService'; +import { generateSSHCommand, explainLog } from '../infrastructure/services/geminiService'; import { Sparkles, MessageSquare, Copy, Terminal, Check } from 'lucide-react'; import { Button } from './ui/button'; import { Textarea } from './ui/textarea'; diff --git a/components/DistroAvatar.tsx b/components/DistroAvatar.tsx index 59335974..1ba9166c 100644 --- a/components/DistroAvatar.tsx +++ b/components/DistroAvatar.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Host } from '../types'; +import { normalizeDistroId } from '../domain/host'; import { cn } from '../lib/utils'; export const DISTRO_LOGOS: Record = { @@ -33,28 +34,10 @@ export const DISTRO_COLORS: Record = { default: "bg-slate-600", }; -export const normalizeDistroId = (value?: string) => { - const v = (value || '').toLowerCase().trim(); - if (!v) return ''; - if (v.includes('ubuntu')) return 'ubuntu'; - if (v.includes('debian')) return 'debian'; - if (v.includes('centos')) return 'centos'; - if (v.includes('rocky')) return 'rocky'; - if (v.includes('fedora')) return 'fedora'; - if (v.includes('arch') || v.includes('manjaro')) return 'arch'; - if (v.includes('alpine')) return 'alpine'; - if (v.includes('amzn') || v.includes('amazon') || v.includes('aws')) return 'amazon'; - if (v.includes('opensuse') || v.includes('suse') || v.includes('sles')) return 'opensuse'; - if (v.includes('red hat') || v.includes('rhel')) return 'redhat'; - if (v.includes('oracle')) return 'oracle'; - if (v.includes('kali')) return 'kali'; - return ''; -}; - type DistroAvatarProps = { host: Host; fallback: string; className?: string }; export const DistroAvatar: React.FC = ({ host, fallback, className }) => { - const distro = (host.distro || '').toLowerCase(); + const distro = normalizeDistroId(host.distro) || (host.distro || '').toLowerCase(); const logo = DISTRO_LOGOS[distro]; const [errored, setErrored] = React.useState(false); const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default; diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx index ac35ae4e..f0eea89c 100755 --- a/components/SettingsDialog.tsx +++ b/components/SettingsDialog.tsx @@ -9,8 +9,8 @@ import { ScrollArea } from './ui/scroll-area'; import { Sun, Moon, Cloud, Download, Upload, Palette, Github, Loader2, Check, TerminalSquare } from 'lucide-react'; import { cn } from '../lib/utils'; import { SyncConfig } from '../types'; -import { syncToGist, loadFromGist } from '../services/syncService'; -import { TERMINAL_THEMES } from '../lib/terminalThemes'; +import { syncToGist, loadFromGist } from '../infrastructure/services/syncService'; +import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes'; interface SettingsDialogProps { isOpen: boolean; diff --git a/components/VaultView.tsx b/components/VaultView.tsx index 75718b99..7f1d7a83 100644 --- a/components/VaultView.tsx +++ b/components/VaultView.tsx @@ -20,7 +20,7 @@ import { TerminalSquare, } from 'lucide-react'; import { Host, SSHKey, Snippet, GroupNode, TerminalSession } from '../types'; -import { normalizeDistroId, DistroAvatar } from './DistroAvatar'; +import { DistroAvatar } from './DistroAvatar'; import SnippetsManager from './SnippetsManager'; import KeyManager from './KeyManager'; import PortForwarding from './PortForwarding'; @@ -31,6 +31,7 @@ import { cn } from '../lib/utils'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'; import { Label } from './ui/label'; +import { sanitizeHost } from '../domain/host'; type VaultSection = 'hosts' | 'keys' | 'snippets' | 'port'; @@ -58,12 +59,6 @@ interface VaultViewProps { onUpdateCustomGroups: (groups: string[]) => void; } -const sanitizeHost = (host: Host): Host => { - const cleanHostname = (host.hostname || '').split(/\s+/)[0]; - const cleanDistro = normalizeDistroId(host.distro); - return { ...host, hostname: cleanHostname, distro: cleanDistro }; -}; - export const VaultView: React.FC = ({ isActive, hosts, diff --git a/domain/host.ts b/domain/host.ts new file mode 100644 index 00000000..dbb3f877 --- /dev/null +++ b/domain/host.ts @@ -0,0 +1,25 @@ +import { Host } from './models'; + +export const normalizeDistroId = (value?: string) => { + const v = (value || '').toLowerCase().trim(); + if (!v) return ''; + if (v.includes('ubuntu')) return 'ubuntu'; + if (v.includes('debian')) return 'debian'; + if (v.includes('centos')) return 'centos'; + if (v.includes('rocky')) return 'rocky'; + if (v.includes('fedora')) return 'fedora'; + if (v.includes('arch') || v.includes('manjaro')) return 'arch'; + if (v.includes('alpine')) return 'alpine'; + if (v.includes('amzn') || v.includes('amazon') || v.includes('aws')) return 'amazon'; + if (v.includes('opensuse') || v.includes('suse') || v.includes('sles')) return 'opensuse'; + if (v.includes('red hat') || v.includes('rhel')) return 'redhat'; + if (v.includes('oracle')) return 'oracle'; + if (v.includes('kali')) return 'kali'; + return ''; +}; + +export const sanitizeHost = (host: Host): Host => { + const cleanHostname = (host.hostname || '').split(/\s+/)[0]; + const cleanDistro = normalizeDistroId(host.distro); + return { ...host, hostname: cleanHostname, distro: cleanDistro }; +}; diff --git a/domain/models.ts b/domain/models.ts new file mode 100755 index 00000000..3654b4ef --- /dev/null +++ b/domain/models.ts @@ -0,0 +1,132 @@ + +export interface Host { + id: string; + label: string; + hostname: string; + port: number; + username: string; + group?: string; + tags: string[]; + os: 'linux' | 'windows' | 'macos'; + identityFileId?: string; // Reference to SSHKey + protocol?: 'ssh' | 'telnet' | 'local'; + password?: string; + authMethod?: 'password' | 'key' | 'certificate' | 'fido2'; + agentForwarding?: boolean; + startupCommand?: string; + hostChaining?: string; + proxy?: string; + envVars?: string; + charset?: string; + moshEnabled?: boolean; + theme?: string; + distro?: string; // detected distro id (e.g., ubuntu, debian) +} + +export interface SSHKey { + id: string; + label: string; + type: 'RSA' | 'ECDSA' | 'ED25519'; + privateKey: string; + publicKey?: string; + created: number; +} + +export interface Snippet { + id: string; + label: string; + command: string; // Multi-line script + tags?: string[]; + package?: string; // package path + targets?: string[]; // host ids +} + +export interface TerminalLine { + type: 'input' | 'output' | 'error' | 'system'; + content: string; + directory?: string; + timestamp: number; +} + +export interface ChatMessage { + role: 'user' | 'model'; + text: string; +} + +export interface GroupNode { + name: string; + path: string; + children: Record; + hosts: Host[]; +} + +export interface SyncConfig { + gistId: string; + githubToken: string; + lastSync?: number; +} + +export interface TerminalTheme { + id: string; + name: string; + type: 'dark' | 'light'; + colors: { + background: string; + foreground: string; + cursor: string; + selection: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; + } +} + +export interface TerminalSession { + id: string; + hostId: string; + hostLabel: string; + username: string; + hostname: string; + status: 'connecting' | 'connected' | 'disconnected'; + workspaceId?: string; +} + +export interface RemoteFile { + name: string; + type: 'file' | 'directory'; + size: string; + lastModified: string; +} + +export type WorkspaceNode = + | { + id: string; + type: 'pane'; + sessionId: string; + } + | { + id: string; + type: 'split'; + direction: 'horizontal' | 'vertical'; + children: WorkspaceNode[]; + sizes?: number[]; // relative sizes for children + }; + +export interface Workspace { + id: string; + title: string; + root: WorkspaceNode; +} diff --git a/domain/workspace.ts b/domain/workspace.ts new file mode 100644 index 00000000..a1db52a6 --- /dev/null +++ b/domain/workspace.ts @@ -0,0 +1,120 @@ +import { Workspace, WorkspaceNode } from './models'; + +export type SplitDirection = 'horizontal' | 'vertical'; +export type SplitPosition = 'left' | 'right' | 'top' | 'bottom'; + +export type SplitHint = { + direction: SplitDirection; + position: SplitPosition; + targetSessionId?: string; +}; + +export const pruneWorkspaceNode = (node: WorkspaceNode, targetSessionId: string): WorkspaceNode | null => { + if (node.type === 'pane') { + return node.sessionId === targetSessionId ? null : node; + } + + const nextChildren: WorkspaceNode[] = []; + const nextSizes: number[] = []; + const sizeList = node.sizes && node.sizes.length === node.children.length ? node.sizes : node.children.map(() => 1); + + node.children.forEach((child, idx) => { + const pruned = pruneWorkspaceNode(child, targetSessionId); + if (pruned) { + nextChildren.push(pruned); + nextSizes.push(sizeList[idx] ?? 1); + } + }); + + if (nextChildren.length === 0) return null; + if (nextChildren.length === 1) return nextChildren[0]; + + const total = nextSizes.reduce((acc, n) => acc + n, 0) || 1; + const normalized = nextSizes.map(n => n / total); + return { ...node, children: nextChildren, sizes: normalized }; +}; + +const createSplitFromPane = ( + existingPane: WorkspaceNode, + newPane: WorkspaceNode, + hint: SplitHint +): WorkspaceNode => { + const children = (hint.position === 'left' || hint.position === 'top') ? [newPane, existingPane] : [existingPane, newPane]; + return { + id: crypto.randomUUID(), + type: 'split', + direction: hint.direction, + children, + sizes: [1, 1], + }; +}; + +export const insertPaneIntoWorkspace = ( + root: WorkspaceNode, + sessionId: string, + hint: SplitHint +): WorkspaceNode => { + const pane: WorkspaceNode = { id: crypto.randomUUID(), type: 'pane', sessionId }; + + if (!hint.targetSessionId) { + const children = (hint.position === 'left' || hint.position === 'top') ? [pane, root] : [root, pane]; + return { + id: crypto.randomUUID(), + type: 'split', + direction: hint.direction, + children, + sizes: [1, 1], + }; + } + + const insertPane = (node: WorkspaceNode): WorkspaceNode => { + if (node.type === 'pane' && node.sessionId === hint.targetSessionId) { + return createSplitFromPane(node, pane, hint); + } + if (node.type === 'split') { + return { ...node, children: node.children.map(child => insertPane(child)) }; + } + return node; + }; + + return insertPane(root); +}; + +export const createWorkspaceFromSessions = ( + baseSessionId: string, + joiningSessionId: string, + hint: SplitHint +): Workspace => { + const basePane: WorkspaceNode = { id: crypto.randomUUID(), type: 'pane', sessionId: baseSessionId }; + const newPane: WorkspaceNode = { id: crypto.randomUUID(), type: 'pane', sessionId: joiningSessionId }; + const children = (hint.position === 'left' || hint.position === 'top') ? [newPane, basePane] : [basePane, newPane]; + + return { + id: `ws-${crypto.randomUUID()}`, + title: 'Workspace', + root: { + id: crypto.randomUUID(), + type: 'split', + direction: hint.direction, + children, + sizes: [1, 1], + }, + }; +}; + +export const updateWorkspaceSplitSizes = ( + root: WorkspaceNode, + splitId: string, + sizes: number[] +): WorkspaceNode => { + const patch = (node: WorkspaceNode): WorkspaceNode => { + if (node.type === 'split') { + if (node.id === splitId) { + return { ...node, sizes }; + } + return { ...node, children: node.children.map(child => patch(child)) }; + } + return node; + }; + return patch(root); +}; diff --git a/lib/defaultData.ts b/infrastructure/config/defaultData.ts similarity index 92% rename from lib/defaultData.ts rename to infrastructure/config/defaultData.ts index 6a425c43..6afede1b 100644 --- a/lib/defaultData.ts +++ b/infrastructure/config/defaultData.ts @@ -1,4 +1,4 @@ -import { Host, Snippet } from '../types'; +import { Host, Snippet } from '../../domain/models'; export const INITIAL_HOSTS: Host[] = [ { id: '1', label: 'Production Web', hostname: '10.0.0.12', port: 22, username: 'ubuntu', group: 'AWS/Production', tags: ['prod', 'web'], os: 'linux' }, diff --git a/lib/storageKeys.ts b/infrastructure/config/storageKeys.ts similarity index 89% rename from lib/storageKeys.ts rename to infrastructure/config/storageKeys.ts index 8c4828a1..c45d9a1c 100644 --- a/lib/storageKeys.ts +++ b/infrastructure/config/storageKeys.ts @@ -1,6 +1,7 @@ export const STORAGE_KEY_HOSTS = 'nebula_hosts_v1'; export const STORAGE_KEY_KEYS = 'nebula_keys_v1'; export const STORAGE_KEY_GROUPS = 'nebula_groups_v1'; +export const STORAGE_KEY_CUSTOM_GROUPS = STORAGE_KEY_GROUPS; export const STORAGE_KEY_SNIPPETS = 'nebula_snippets_v1'; export const STORAGE_KEY_SNIPPET_PACKAGES = 'nebula_snippet_packages_v1'; export const STORAGE_KEY_THEME = 'nebula_theme_v1'; diff --git a/lib/terminalThemes.ts b/infrastructure/config/terminalThemes.ts similarity index 98% rename from lib/terminalThemes.ts rename to infrastructure/config/terminalThemes.ts index 3422350e..fdf707f2 100755 --- a/lib/terminalThemes.ts +++ b/infrastructure/config/terminalThemes.ts @@ -1,4 +1,4 @@ -import { TerminalTheme } from '../types'; +import { TerminalTheme } from '../../domain/models'; export const TERMINAL_THEMES: TerminalTheme[] = [ { @@ -217,4 +217,4 @@ export const TERMINAL_THEMES: TerminalTheme[] = [ brightWhite: '#ffffff' } } -]; \ No newline at end of file +]; diff --git a/infrastructure/persistence/localStorageAdapter.ts b/infrastructure/persistence/localStorageAdapter.ts new file mode 100644 index 00000000..6d81964b --- /dev/null +++ b/infrastructure/persistence/localStorageAdapter.ts @@ -0,0 +1,26 @@ +const safeParse = (value: string | null): T | null => { + if (!value) return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } +}; + +export const localStorageAdapter = { + read(key: string): T | null { + return safeParse(localStorage.getItem(key)); + }, + write(key: string, value: T) { + localStorage.setItem(key, JSON.stringify(value)); + }, + readString(key: string): string | null { + return localStorage.getItem(key); + }, + writeString(key: string, value: string) { + localStorage.setItem(key, value); + }, + remove(key: string) { + localStorage.removeItem(key); + }, +}; diff --git a/services/geminiService.ts b/infrastructure/services/geminiService.ts similarity index 98% rename from services/geminiService.ts rename to infrastructure/services/geminiService.ts index 7eab3cf0..b16755c2 100755 --- a/services/geminiService.ts +++ b/infrastructure/services/geminiService.ts @@ -1,5 +1,5 @@ import { GoogleGenAI, Chat } from "@google/genai"; -import { RemoteFile } from "../types"; +import { RemoteFile } from "../../domain/models"; const getClient = () => { const apiKey = process.env.API_KEY; @@ -108,4 +108,4 @@ export const explainLog = async (log: string): Promise => { } catch (error) { return "Could not analyze logs."; } -}; \ No newline at end of file +}; diff --git a/services/syncService.ts b/infrastructure/services/syncService.ts similarity index 96% rename from services/syncService.ts rename to infrastructure/services/syncService.ts index bbf0dbfb..55da9556 100755 --- a/services/syncService.ts +++ b/infrastructure/services/syncService.ts @@ -1,5 +1,4 @@ - -import { Host, SSHKey, Snippet } from '../types'; +import { Host, SSHKey, Snippet } from '../../domain/models'; interface BackupData { hosts: Host[]; diff --git a/types.ts b/types.ts old mode 100755 new mode 100644 index 3654b4ef..08b53c41 --- a/types.ts +++ b/types.ts @@ -1,132 +1 @@ - -export interface Host { - id: string; - label: string; - hostname: string; - port: number; - username: string; - group?: string; - tags: string[]; - os: 'linux' | 'windows' | 'macos'; - identityFileId?: string; // Reference to SSHKey - protocol?: 'ssh' | 'telnet' | 'local'; - password?: string; - authMethod?: 'password' | 'key' | 'certificate' | 'fido2'; - agentForwarding?: boolean; - startupCommand?: string; - hostChaining?: string; - proxy?: string; - envVars?: string; - charset?: string; - moshEnabled?: boolean; - theme?: string; - distro?: string; // detected distro id (e.g., ubuntu, debian) -} - -export interface SSHKey { - id: string; - label: string; - type: 'RSA' | 'ECDSA' | 'ED25519'; - privateKey: string; - publicKey?: string; - created: number; -} - -export interface Snippet { - id: string; - label: string; - command: string; // Multi-line script - tags?: string[]; - package?: string; // package path - targets?: string[]; // host ids -} - -export interface TerminalLine { - type: 'input' | 'output' | 'error' | 'system'; - content: string; - directory?: string; - timestamp: number; -} - -export interface ChatMessage { - role: 'user' | 'model'; - text: string; -} - -export interface GroupNode { - name: string; - path: string; - children: Record; - hosts: Host[]; -} - -export interface SyncConfig { - gistId: string; - githubToken: string; - lastSync?: number; -} - -export interface TerminalTheme { - id: string; - name: string; - type: 'dark' | 'light'; - colors: { - background: string; - foreground: string; - cursor: string; - selection: string; - black: string; - red: string; - green: string; - yellow: string; - blue: string; - magenta: string; - cyan: string; - white: string; - brightBlack: string; - brightRed: string; - brightGreen: string; - brightYellow: string; - brightBlue: string; - brightMagenta: string; - brightCyan: string; - brightWhite: string; - } -} - -export interface TerminalSession { - id: string; - hostId: string; - hostLabel: string; - username: string; - hostname: string; - status: 'connecting' | 'connected' | 'disconnected'; - workspaceId?: string; -} - -export interface RemoteFile { - name: string; - type: 'file' | 'directory'; - size: string; - lastModified: string; -} - -export type WorkspaceNode = - | { - id: string; - type: 'pane'; - sessionId: string; - } - | { - id: string; - type: 'split'; - direction: 'horizontal' | 'vertical'; - children: WorkspaceNode[]; - sizes?: number[]; // relative sizes for children - }; - -export interface Workspace { - id: string; - title: string; - root: WorkspaceNode; -} +export * from './domain/models';