feat: Implement agents architecture and core functionality

- Added agents overview documentation outlining the project structure and roles.
- Implemented `useSessionState` for managing terminal sessions and workspaces.
- Developed `useSettingsState` for handling user settings and theme management.
- Created `useVaultState` for managing hosts, SSH keys, snippets, and custom groups.
- Introduced domain logic for host normalization and sanitization.
- Defined models for Host, SSHKey, Snippet, TerminalSession, and Workspace.
- Implemented workspace management functions including creation, insertion, and pruning.
- Established local storage adapter for persistent data management.
- Integrated Gemini AI service for terminal simulation and command generation.
- Developed sync service for backing up and restoring configuration to/from GitHub Gists.
This commit is contained in:
bincxz
2025-12-08 01:29:49 +08:00
parent 39d926065b
commit 885d837fda
19 changed files with 840 additions and 565 deletions

483
App.tsx
View File

@@ -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<string>(() => localStorage.getItem(STORAGE_KEY_COLOR) || '221.2 83.2% 53.3%');
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => {
const saved = localStorage.getItem(STORAGE_KEY_SYNC);
return saved ? JSON.parse(saved) : null;
});
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorage.getItem(STORAGE_KEY_TERM_THEME) || 'termius-dark');
// Data
const [hosts, setHosts] = useState<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [customGroups, setCustomGroups] = useState<string[]>([]);
const [workspaceRenameTarget, setWorkspaceRenameTarget] = useState<Workspace | null>(null);
const [workspaceRenameValue, setWorkspaceRenameValue] = useState('');
// Navigation & Sessions
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeTabId, setActiveTabId] = useState<string>('vault'); // 'vault', session.id, or workspace.id
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [draggingSessionId, setDraggingSessionId] = useState<string | null>(null);
// Modals
const [editingHost, setEditingHost] = useState<Host | null>(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<Host | null>(null);
const [showAssistant, setShowAssistant] = useState(false);
const [snippetPackages, setSnippetPackages] = useState<string[]>([]);
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 (
<div className="flex flex-col h-screen text-foreground font-sans nebula-shell" onContextMenu={(e) => 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)}
/>
<div className="flex-1 relative min-h-0">
@@ -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}
/>
</div>
<QuickSwitcher
isOpen={isQuickSwitcherOpen}
query={quickSearch}
results={quickResults}
onQueryChange={setQuickSearch}
onSelect={(host) => {
handleConnect(host);
connectToHost(host);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
@@ -510,8 +201,7 @@ function App() {
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
if (!open) {
setWorkspaceRenameTarget(null);
setWorkspaceRenameValue('');
resetWorkspaceRename();
}
}}>
<DialogContent className="max-w-sm">
@@ -530,13 +220,12 @@ function App() {
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => { setWorkspaceRenameTarget(null); setWorkspaceRenameValue(''); }}>Cancel</Button>
<Button variant="ghost" onClick={resetWorkspaceRename}>Cancel</Button>
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Host Panel */}
{isFormOpen && (
<HostDetailsPanel
initialData={editingHost}
@@ -554,8 +243,8 @@ function App() {
<SettingsDialog
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
onImport={handleImportData}
exportData={getExportData}
onImport={importDataFromString}
exportData={exportData}
theme={theme}
onThemeChange={setTheme}
primaryColor={primaryColor}

44
agents.md Normal file
View File

@@ -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.

View File

@@ -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<TerminalSession[]>([]);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [activeTabId, setActiveTabId] = useState<string>('vault');
const [draggingSessionId, setDraggingSessionId] = useState<string | null>(null);
const [workspaceRenameTarget, setWorkspaceRenameTarget] = useState<Workspace | null>(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,
};
};

View File

@@ -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<string>(() => localStorageAdapter.readString(STORAGE_KEY_COLOR) || DEFAULT_COLOR);
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => 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,
};
};

View File

@@ -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<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [customGroups, setCustomGroups] = useState<string[]>([]);
const [snippetPackages, setSnippetPackages] = useState<string[]>([]);
useEffect(() => {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
const savedKeys = localStorageAdapter.read<SSHKey[]>(STORAGE_KEY_KEYS);
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets = localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(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<ExportableVaultData>) => {
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,
};
};

View File

@@ -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';

View File

@@ -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<string, string> = {
@@ -33,28 +34,10 @@ export const DISTRO_COLORS: Record<string, string> = {
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<DistroAvatarProps> = ({ 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;

View File

@@ -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;

View File

@@ -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<VaultViewProps> = ({
isActive,
hosts,

25
domain/host.ts Normal file
View File

@@ -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 };
};

132
domain/models.ts Executable file
View File

@@ -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<string, GroupNode>;
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;
}

120
domain/workspace.ts Normal file
View File

@@ -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);
};

View File

@@ -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' },

View File

@@ -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';

View File

@@ -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'
}
}
];
];

View File

@@ -0,0 +1,26 @@
const safeParse = <T>(value: string | null): T | null => {
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
};
export const localStorageAdapter = {
read<T>(key: string): T | null {
return safeParse<T>(localStorage.getItem(key));
},
write<T>(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);
},
};

View File

@@ -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<string> => {
} catch (error) {
return "Could not analyze logs.";
}
};
};

View File

@@ -1,5 +1,4 @@
import { Host, SSHKey, Snippet } from '../types';
import { Host, SSHKey, Snippet } from '../../domain/models';
interface BackupData {
hosts: Host[];

133
types.ts Executable file → Normal file
View File

@@ -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<string, GroupNode>;
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';