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:
483
App.tsx
483
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<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
44
agents.md
Normal 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.
|
||||
205
application/state/useSessionState.ts
Normal file
205
application/state/useSessionState.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
67
application/state/useSettingsState.ts
Normal file
67
application/state/useSettingsState.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
120
application/state/useVaultState.ts
Normal file
120
application/state/useVaultState.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
25
domain/host.ts
Normal 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
132
domain/models.ts
Executable 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
120
domain/workspace.ts
Normal 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);
|
||||
};
|
||||
@@ -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' },
|
||||
@@ -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';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TerminalTheme } from '../types';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
|
||||
export const TERMINAL_THEMES: TerminalTheme[] = [
|
||||
{
|
||||
26
infrastructure/persistence/localStorageAdapter.ts
Normal file
26
infrastructure/persistence/localStorageAdapter.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
133
types.ts
Executable file → Normal 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';
|
||||
|
||||
Reference in New Issue
Block a user