From 7d0a6fccb870cadd60cb34ca15d8b1b91a9520ef Mon Sep 17 00:00:00 2001 From: BrianXiong Date: Tue, 21 Jul 2026 04:02:54 +0000 Subject: [PATCH] feat: ssh/sftp usability and reliability improvements - SFTP/file navigator path bar commits on Enter/blur instead of every keystroke - Add delete confirmation dialog for local/remote file deletion - Surface terminal session exit as disconnected tab state with in-place reconnect - Reuse SSH connections via OpenSSH ControlMaster multiplexing (ssh/sftp/monitor) - Includes pending AI memory files feature work (scan/read agent memory files) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015R3DVRqVdvkiD6YVchgcBv --- ServerDeck/src-tauri/Cargo.lock | 2 +- ServerDeck/src-tauri/src/main.rs | 158 +++++++++ ServerDeck/src/App.tsx | 317 +++++++++++++++++- .../src/components/files/FileBrowserPane.tsx | 43 ++- .../files/TerminalFileNavigator.tsx | 42 ++- ServerDeck/src/lib/api.ts | 38 +++ ServerDeck/src/lib/i18n.ts | 72 ++++ ServerDeck/src/styles/app.css | 282 ++++++++++++++++ 8 files changed, 945 insertions(+), 9 deletions(-) diff --git a/ServerDeck/src-tauri/Cargo.lock b/ServerDeck/src-tauri/Cargo.lock index ba9d67b..e6bc253 100644 --- a/ServerDeck/src-tauri/Cargo.lock +++ b/ServerDeck/src-tauri/Cargo.lock @@ -3412,7 +3412,7 @@ dependencies = [ [[package]] name = "serverdeck" -version = "0.0.14" +version = "0.0.30" dependencies = [ "dirs", "portable-pty", diff --git a/ServerDeck/src-tauri/src/main.rs b/ServerDeck/src-tauri/src/main.rs index 62a2487..5ff3075 100644 --- a/ServerDeck/src-tauri/src/main.rs +++ b/ServerDeck/src-tauri/src/main.rs @@ -81,6 +81,19 @@ struct AiProviderImportSuggestion { note: String, } +// author: BrianXiong +// time: 2026/05/07/23:30:00 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AiMemoryFile { + id: String, + agent_name: String, + file_name: String, + file_path: String, + file_size: u64, + modified_time: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct AppPreferencesPayload { @@ -174,6 +187,15 @@ struct TerminalOutputPayload { stream: String, } +// author: BrianXiong +// time: 2026/07/21/00:00:00 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct TerminalExitPayload { + session_id: String, + exit_code: Option, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct TransferUpdatePayload { @@ -982,7 +1004,37 @@ fn list_archive_entries(path: &Path) -> Result, String> { .collect()) } +// author: BrianXiong +// time: 2026/07/21/00:00:00 +// OpenSSH connection multiplexing: reuse one authenticated connection per +// host across terminal/SFTP/monitor commands. The first connection becomes a +// background master (kept alive 60s after the last client), subsequent ssh +// and sftp invocations attach to its socket and skip TCP+auth entirely. +fn control_master_options() -> Vec { + let Some(home) = home_dir() else { + return Vec::new(); + }; + + let socket_dir = home.join(".serverdeck").join("cm"); + if fs::create_dir_all(&socket_dir).is_err() { + return Vec::new(); + } + + vec![ + "-o".to_string(), + "ControlMaster=auto".to_string(), + "-o".to_string(), + format!("ControlPath={}/%C", socket_dir.display()), + "-o".to_string(), + "ControlPersist=60".to_string(), + ] +} + fn append_ssh_options(command: &mut Command, host: &HostRecord, ssh_options: &SshConnectionOptions) { + for option in control_master_options() { + command.arg(option); + } + command .arg("-o") .arg("StrictHostKeyChecking=accept-new") @@ -1066,6 +1118,10 @@ fn build_sftp_command(host: &HostRecord, ssh_options: &SshConnectionOptions) -> Command::new(resolve_binary("sftp").unwrap_or_else(|_| PathBuf::from("sftp"))) }; + for option in control_master_options() { + command.arg(option); + } + command .arg("-o") .arg("StrictHostKeyChecking=accept-new") @@ -1654,6 +1710,17 @@ fn wire_terminal_pty_exit( ), "system", ); + // author: BrianXiong + // time: 2026/07/21/00:00:00 + // Structured exit event so the UI can flip the tab into a + // disconnected state and offer a reconnect action. + let _ = app.emit( + "terminal-exit", + TerminalExitPayload { + session_id: session_id.clone(), + exit_code: Some(exit_status.exit_code()), + }, + ); break; } Ok(None) => continue, @@ -1664,6 +1731,13 @@ fn wire_terminal_pty_exit( format!("\r\n[serverdeck] session wait failed: {}\r\n", error), "system", ); + let _ = app.emit( + "terminal-exit", + TerminalExitPayload { + session_id: session_id.clone(), + exit_code: None, + }, + ); break; } } @@ -2249,6 +2323,9 @@ fn start_terminal_session( }; cmd.arg("-tt"); + for option in control_master_options() { + cmd.arg(option); + } cmd.arg("-o"); cmd.arg("StrictHostKeyChecking=accept-new"); cmd.arg("-o"); @@ -2477,6 +2554,85 @@ fn detect_ai_provider_imports() -> Result, Strin Ok(suggestions) } +#[tauri::command] +// author: BrianXiong +// time: 2026/05/07/23:30:00 +fn scan_ai_memory_files() -> Result, String> { + let mut files = Vec::new(); + let home = home_dir().ok_or("Cannot resolve home directory")?; + + // Define AI agents and their memory file locations + let memory_locations: Vec<(&str, Vec)> = vec![ + ("Claude", vec![ + home.join("CLAUDE.md"), + home.join(".claude/CLAUDE.md"), + ]), + ("Codex", vec![ + home.join(".codex/AGENTS.md"), + ]), + ("Alma", vec![ + home.join(".config/alma/MEMORY.md"), + home.join(".config/alma/USER.md"), + ]), + ("Hermes", vec![ + home.join(".hermes/SOUL.md"), + ]), + ]; + + for (agent_name, paths) in memory_locations { + for path in paths { + if path.exists() && path.is_file() { + if let Ok(metadata) = fs::metadata(&path) { + let modified_time = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + + files.push(AiMemoryFile { + id: format!("{}:{}", agent_name.to_lowercase(), file_name), + agent_name: agent_name.to_string(), + file_name, + file_path: path.display().to_string(), + file_size: metadata.len(), + modified_time, + }); + } + } + } + } + + // Sort by agent name, then by file name + files.sort_by(|a, b| { + a.agent_name.cmp(&b.agent_name).then(a.file_name.cmp(&b.file_name)) + }); + + Ok(files) +} + +#[tauri::command] +// author: BrianXiong +// time: 2026/05/07/23:30:00 +fn read_ai_memory_file(path: String) -> Result { + let expanded = expand_path(&path); + + if !expanded.exists() { + return Err(format!("File not found: {}", expanded.display())); + } + + if !expanded.is_file() { + return Err(format!("Path is not a file: {}", expanded.display())); + } + + fs::read_to_string(&expanded).map_err(|error| error.to_string()) +} + #[tauri::command] // author: BrianXiong // time: 2026/04/05/16:20:45 @@ -2619,6 +2775,8 @@ fn main() { agent_read_file, fetch_ai_provider_models, detect_ai_provider_imports, + scan_ai_memory_files, + read_ai_memory_file, list_hosts, save_host, delete_host, diff --git a/ServerDeck/src/App.tsx b/ServerDeck/src/App.tsx index 49084e6..0d90ab3 100644 --- a/ServerDeck/src/App.tsx +++ b/ServerDeck/src/App.tsx @@ -34,7 +34,10 @@ import { TerminalSquare, Trash2, Users, - X + X, + RefreshCw, + FileText, + ExternalLink } from "lucide-react"; import { clearAppData, @@ -66,6 +69,9 @@ import { startTerminalSession, testConnection, writeTerminalInput, + scanAiMemoryFiles, + readAiMemoryFile, + type AiMemoryFile, type AiProviderConfig, type AiProviderImportSuggestion, type AiProviderFetchRequest, @@ -77,6 +83,7 @@ import { type ServerObservation, type SshConnectionOptions, type TerminalEventPayload, + type TerminalExitPayload, type TransferUpdatePayload } from "./lib/api"; import { FileBrowserPane } from "./components/files/FileBrowserPane"; @@ -143,7 +150,7 @@ const blankHost: SavedHost = { }; type DrawerMode = "new" | "edit"; -type TerminalState = "connecting" | "connected" | "error"; +type TerminalState = "connecting" | "connected" | "error" | "disconnected"; type TerminalTab = { id: string; @@ -151,10 +158,16 @@ type TerminalTab = { title: string; kind: "remote" | "local"; host: SavedHost | null; + cwd?: string; state: TerminalState; statusText: string; }; +type DeleteConfirmState = { + side: "local" | "remote"; + entry: FileEntry; +}; + type MonitorTab = { id: string; host: SavedHost; @@ -225,7 +238,7 @@ type SettingsSectionId = "general" | "projects" | "terminal" | "ai" | "about" | type ProjectEditorMode = "new" | "edit"; type LocalTerminalSource = "default" | "project" | "directory"; type AiProviderEditorMode = "new" | "edit"; -type AiSettingsSectionId = "providers" | "models" | "usage" | "skills"; +type AiSettingsSectionId = "providers" | "models" | "usage" | "skills" | "memory"; const blankAiProvider: AiProviderConfig = { id: "", @@ -792,6 +805,7 @@ export default function App() { const [busy, setBusy] = useState(false); const [contextMenu, setContextMenu] = useState(null); const [fileMenu, setFileMenu] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); const [terminalMountNode, setTerminalMountNode] = useState(null); const [transferJobs, setTransferJobs] = useState([]); const [availableUpdate, setAvailableUpdate] = useState(null); @@ -817,6 +831,12 @@ export default function App() { const [aiProviderModelsLoading, setAiProviderModelsLoading] = useState(false); const [aiProviderModelsError, setAiProviderModelsError] = useState(""); const [aiImportSuggestions, setAiImportSuggestions] = useState([]); + // author: BrianXiong + // time: 2026/05/07/23:30:00 + const [aiMemoryFiles, setAiMemoryFiles] = useState([]); + const [selectedMemoryFile, setSelectedMemoryFile] = useState(null); + const [memoryContent, setMemoryContent] = useState(""); + const [memoryLoading, setMemoryLoading] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [localTerminalMenuOpen, setLocalTerminalMenuOpen] = useState(false); const [localTerminalProjectSearch, setLocalTerminalProjectSearch] = useState(""); @@ -1905,6 +1925,61 @@ export default function App() { void detectAiProviderImports().then(setAiImportSuggestions).catch(() => {}); }, [activeSettingsSection]); + // author: BrianXiong + // time: 2026/05/07/23:30:00 + useEffect(() => { + if (activeSettingsSection !== "ai" || activeAiSettingsSection !== "memory") { + return; + } + + void loadMemoryFiles(); + }, [activeSettingsSection, activeAiSettingsSection]); + + // author: BrianXiong + // time: 2026/05/07/23:30:00 + async function loadMemoryFiles() { + try { + const files = await scanAiMemoryFiles(); + setAiMemoryFiles(files); + if (files.length > 0 && !selectedMemoryFile) { + await handleSelectMemoryFile(files[0]); + } + } catch { + setAiMemoryFiles([]); + } + } + + // author: BrianXiong + // time: 2026/05/07/23:30:00 + async function handleSelectMemoryFile(file: AiMemoryFile) { + setSelectedMemoryFile(file); + setMemoryLoading(true); + try { + const content = await readAiMemoryFile(file.filePath); + setMemoryContent(content); + } catch { + setMemoryContent(""); + } finally { + setMemoryLoading(false); + } + } + + // author: BrianXiong + // time: 2026/05/07/23:30:00 + function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + // author: BrianXiong + // time: 2026/05/07/23:30:00 + function formatModifiedTime(timestamp: number): string { + if (timestamp === 0) return "-"; + const date = new Date(timestamp * 1000); + return date.toLocaleString(); + } + useEffect(() => { terminalDecodersRef.current.clear(); }, [settings.terminalCharset]); @@ -2241,7 +2316,7 @@ export default function App() { terminalRef.current = null; fitAddonRef.current = null; }; - }, [activeTerminalTab?.id, sendActiveTerminalInput, terminalMountNode]); + }, [activeTerminalTab?.id, activeTerminalTab?.sessionId, sendActiveTerminalInput, terminalMountNode]); useEffect(() => { if (!terminalRef.current) { @@ -2337,6 +2412,43 @@ export default function App() { }; }, [decodeTerminalPayload, enqueueActiveTerminalOutput, messages.connected]); + // author: BrianXiong + // time: 2026/07/21/00:00:00 + // Track PTY exit so a dropped SSH session is surfaced in the tab state + // instead of silently keeping the "connected" dot forever. + useEffect(() => { + let cleanup: null | (() => void) = null; + let disposed = false; + + void listen("terminal-exit", (event) => { + const matchingTab = terminalTabsRef.current.find((tab) => tab.sessionId === event.payload.sessionId); + if (!matchingTab || matchingTab.state === "disconnected") { + return; + } + + setTerminalTabs((prev) => + prev.map((tab) => + tab.sessionId === event.payload.sessionId + ? { ...tab, state: "disconnected", statusText: messages.sessionDisconnected } + : tab + ) + ); + }) + .then((unlisten) => { + if (disposed) { + unlisten(); + return; + } + cleanup = unlisten; + }) + .catch(() => {}); + + return () => { + disposed = true; + cleanup?.(); + }; + }, [messages.sessionDisconnected]); + useEffect(() => { let cleanup: null | (() => void) = null; let disposed = false; @@ -3070,6 +3182,33 @@ export default function App() { setRemoteRefreshTick((current) => current + 1); } + // author: BrianXiong + // time: 2026/07/21/00:00:00 + // Deleting is destructive and has no undo, so it always goes through an + // explicit confirmation dialog instead of firing straight from the menu. + function handleRequestDeleteEntry(side: DeleteConfirmState["side"], entry: FileEntry) { + setFileMenu(null); + setDeleteConfirm({ side, entry }); + } + + async function handleConfirmDeleteEntry() { + const target = deleteConfirm; + if (!target) return; + + setDeleteConfirm(null); + + try { + if (target.side === "local") { + await handleDeleteLocalFile(target.entry); + } else { + await handleDeleteRemoteFile(target.entry); + } + } catch (error) { + setStatus(getErrorMessage(error, messages.deleteEntryFailed(target.entry.name))); + setStatusTone("error"); + } + } + function handleSelectSftpHost(hostId: string) { setSelectedId(hostId); setRemotePath("."); @@ -3289,6 +3428,7 @@ export default function App() { title, kind: "local", host: null, + cwd, state: "connecting", statusText: messages.openingLocalTerminal } @@ -3377,6 +3517,48 @@ export default function App() { setLocalBrowserSelectedPath(entry?.path ?? ""); } + // author: BrianXiong + // time: 2026/07/21/00:00:00 + // Reconnect a disconnected tab in place: start a fresh session for the same + // host (or local cwd) and rebind the existing tab to the new session id. + async function handleReconnectTerminalTab(tab: TerminalTab) { + setStatusTone("neutral"); + setStatus(messages.reconnecting); + setTerminalTabs((prev) => + prev.map((item) => (item.id === tab.id ? { ...item, state: "connecting", statusText: messages.reconnecting } : item)) + ); + + try { + const sessionId = + tab.kind === "remote" && tab.host + ? await startTerminalSession(tab.host, sshOptions) + : await startLocalTerminalSession(tab.cwd); + + terminalDecodersRef.current.delete(tab.sessionId); + terminalBuffersRef.current.delete(tab.sessionId); + void closeTerminalSession(tab.sessionId).catch(() => {}); + terminalBuffersRef.current.set(sessionId, []); + + const statusText = + tab.kind === "remote" && tab.host + ? messages.connectingToHost(tab.host.username, tab.host.address, tab.host.port) + : messages.openingLocalTerminal; + + setTerminalTabs((prev) => + prev.map((item) => (item.id === tab.id ? { ...item, sessionId, state: "connecting", statusText } : item)) + ); + setStatus(statusText); + } catch (error) { + setTerminalTabs((prev) => + prev.map((item) => + item.id === tab.id ? { ...item, state: "disconnected", statusText: messages.sessionDisconnected } : item + ) + ); + setStatus(getErrorMessage(error, messages.terminalOpenFailed)); + setStatusTone("error"); + } + } + // author: BrianXiong // time: 2026/04/05/11:21:34 async function handleCloseTerminalTab(tabId: string) { @@ -3571,6 +3753,25 @@ export default function App() { }; }, [fileMenu]); + // author: BrianXiong + // time: 2026/07/21/00:00:00 + const terminalDisconnectOverlay = + activeTerminalTab && activeTerminalTab.state === "disconnected" ? ( +
+
+ {messages.sessionDisconnected} + +
+
+ ) : null; + return (
@@ -4576,6 +4777,9 @@ export default function App() { +
{activeAiSettingsSection === "providers" ? ( @@ -4666,6 +4870,76 @@ export default function App() { ) : null} + + {activeAiSettingsSection === "memory" ? ( + <> +
+ +
+ + {aiMemoryFiles.length === 0 ? ( +
+
+
+ + {messages.aiMemoryEmpty} + {messages.aiMemoryEmptyDescription} +
+
+
+ ) : ( +
+
+ {aiMemoryFiles.map((file) => ( +
void handleSelectMemoryFile(file)} + > +
+ {file.agentName} + {formatFileSize(file.fileSize)} +
+
{file.fileName}
+
{file.filePath}
+
+ ))} +
+
+ {selectedMemoryFile ? ( + <> +
+
+ {selectedMemoryFile.fileName} + {selectedMemoryFile.agentName} +
+
+ {formatFileSize(selectedMemoryFile.fileSize)} + {formatModifiedTime(selectedMemoryFile.modifiedTime)} +
+
+
+ {memoryLoading ? ( +
Loading...
+ ) : ( +
{memoryContent}
+ )} +
+ + ) : ( +
+ + Select a memory file to preview +
+ )} +
+
+ )} + + ) : null} ) : null} @@ -4921,6 +5195,7 @@ export default function App() { onMouseDown={() => terminalRef.current?.focus()} style={{ background: activeTerminalTheme.theme.background }} /> + {terminalDisconnectOverlay} ) : ( @@ -4932,6 +5207,7 @@ export default function App() { onMouseDown={() => terminalRef.current?.focus()} style={{ background: activeTerminalTheme.theme.background }} /> + {terminalDisconnectOverlay} )} @@ -5039,7 +5315,7 @@ export default function App() { + + + + + ) : null} + {updateModalOpen && availableUpdate ? (
setUpdateModalOpen(false)}>
event.stopPropagation()}> diff --git a/ServerDeck/src/components/files/FileBrowserPane.tsx b/ServerDeck/src/components/files/FileBrowserPane.tsx index bbd3540..99f2ecc 100644 --- a/ServerDeck/src/components/files/FileBrowserPane.tsx +++ b/ServerDeck/src/components/files/FileBrowserPane.tsx @@ -101,6 +101,40 @@ export function FileBrowserPane({ const paneRef = useRef(null); const [sortField, setSortField] = useState("name"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [pathDraft, setPathDraft] = useState(path); + + useEffect(() => { + setPathDraft(path); + }, [path]); + + // author: BrianXiong + // time: 2026/07/21/00:00:00 + // Commit the path on Enter/blur instead of on every keystroke, so typing a + // path does not trigger a directory listing (and an SFTP round-trip) per key. + function commitPathDraft() { + const next = pathDraft.trim(); + if (!next || next === path) { + setPathDraft(path); + return; + } + + onPathChange(next); + } + + function handlePathKeyDown(event: KeyboardEvent) { + event.stopPropagation(); + + if (event.key === "Enter") { + event.preventDefault(); + commitPathDraft(); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + setPathDraft(path); + } + } const orderedItems = useMemo(() => { const entries = [...items]; @@ -234,7 +268,14 @@ export function FileBrowserPane({ - onPathChange(event.target.value)} disabled={disabled} /> + setPathDraft(event.target.value)} + onKeyDown={handlePathKeyDown} + onBlur={commitPathDraft} + spellCheck={false} + disabled={disabled} + />
diff --git a/ServerDeck/src/components/files/TerminalFileNavigator.tsx b/ServerDeck/src/components/files/TerminalFileNavigator.tsx index 4ce1ff7..316d3ea 100644 --- a/ServerDeck/src/components/files/TerminalFileNavigator.tsx +++ b/ServerDeck/src/components/files/TerminalFileNavigator.tsx @@ -75,6 +75,40 @@ export function TerminalFileNavigator({ const paneRef = useRef(null); const [sortField, setSortField] = useState("name"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [pathDraft, setPathDraft] = useState(path); + + useEffect(() => { + setPathDraft(path); + }, [path]); + + // author: BrianXiong + // time: 2026/07/21/00:00:00 + // Commit the path on Enter/blur instead of on every keystroke, so typing a + // path does not trigger one directory listing per key press. + function commitPathDraft() { + const next = pathDraft.trim(); + if (!next || next === path) { + setPathDraft(path); + return; + } + + onPathChange(next); + } + + function handlePathKeyDown(event: KeyboardEvent) { + event.stopPropagation(); + + if (event.key === "Enter") { + event.preventDefault(); + commitPathDraft(); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + setPathDraft(path); + } + } const orderedItems = useMemo(() => { const entries = [...items]; @@ -209,7 +243,13 @@ export function TerminalFileNavigator({
$ - onPathChange(event.target.value)} /> + setPathDraft(event.target.value)} + onKeyDown={handlePathKeyDown} + onBlur={commitPathDraft} + spellCheck={false} + />
diff --git a/ServerDeck/src/lib/api.ts b/ServerDeck/src/lib/api.ts index 4b5719a..6a45d10 100644 --- a/ServerDeck/src/lib/api.ts +++ b/ServerDeck/src/lib/api.ts @@ -27,6 +27,13 @@ export type TerminalEventPayload = { stream?: "stdout" | "stderr" | "system"; }; +// author: BrianXiong +// time: 2026/07/21/00:00:00 +export type TerminalExitPayload = { + sessionId: string; + exitCode?: number | null; +}; + export type TransferUpdatePayload = { jobId: string; direction: "upload" | "download"; @@ -87,6 +94,17 @@ export type AiProviderImportSuggestion = { note: string; }; +// author: BrianXiong +// time: 2026/05/07/23:30:00 +export type AiMemoryFile = { + id: string; + agentName: string; + fileName: string; + filePath: string; + fileSize: number; + modifiedTime: number; +}; + export type AppPreferences = { settings: { appTheme: "light" | "dark"; @@ -360,6 +378,26 @@ export async function detectAiProviderImports() { return []; } +// author: BrianXiong +// time: 2026/05/07/23:30:00 +export async function scanAiMemoryFiles() { + if (hasTauri()) { + return tauriInvoke("scan_ai_memory_files"); + } + + return []; +} + +// author: BrianXiong +// time: 2026/05/07/23:30:00 +export async function readAiMemoryFile(path: string) { + if (hasTauri()) { + return tauriInvoke("read_ai_memory_file", { path }); + } + + return ""; +} + export async function startTerminalSession(host: SavedHost, sshOptions: SshConnectionOptions) { if (hasTauri()) { return tauriInvoke("start_terminal_session", { host, sshOptions }); diff --git a/ServerDeck/src/lib/i18n.ts b/ServerDeck/src/lib/i18n.ts index eb64d12..cfe8a71 100644 --- a/ServerDeck/src/lib/i18n.ts +++ b/ServerDeck/src/lib/i18n.ts @@ -171,6 +171,14 @@ type Messages = { testSsh: string; save: string; delete: string; + confirmDeleteTitle: string; + confirmDeleteLocalMessage: (path: string) => string; + confirmDeleteRemoteMessage: (path: string) => string; + deleteEntryFailed: (name: string) => string; + disconnected: string; + reconnect: string; + sessionDisconnected: string; + reconnecting: string; sftpDescription: string; selectHost: string; selectHostPlaceholder: string; @@ -256,6 +264,16 @@ type Messages = { aiModels: string; aiUsage: string; aiSkills: string; + aiMemory: string; + aiMemoryDescription: string; + aiMemoryEmpty: string; + aiMemoryEmptyDescription: string; + aiMemoryAgent: string; + aiMemoryFile: string; + aiMemorySize: string; + aiMemoryModified: string; + aiMemoryOpenInEditor: string; + aiMemoryRefresh: string; addAiProvider: string; importClaudeCode: string; importCodexCli: string; @@ -498,6 +516,14 @@ export const messagesByLanguage: Record = { testSsh: "Test SSH", save: "Save", delete: "Delete", + confirmDeleteTitle: "Confirm Delete", + confirmDeleteLocalMessage: (path: string) => `Delete local file "${path}"? This cannot be undone.`, + confirmDeleteRemoteMessage: (path: string) => `Delete remote file "${path}" on the server? This cannot be undone.`, + deleteEntryFailed: (name: string) => `Failed to delete ${name}`, + disconnected: "Disconnected", + reconnect: "Reconnect", + sessionDisconnected: "Session disconnected", + reconnecting: "Reconnecting...", sftpDescription: "Choose a host before browsing remote files.", selectHost: "Select Host", selectHostPlaceholder: "Select host", @@ -583,6 +609,16 @@ export const messagesByLanguage: Record = { aiModels: "Models", aiUsage: "Usage", aiSkills: "Skills", + aiMemory: "Memory", + aiMemoryDescription: "View and manage memory files from different AI agents on your system.", + aiMemoryEmpty: "No memory files found", + aiMemoryEmptyDescription: "No AI agent memory files detected in your home directory.", + aiMemoryAgent: "Agent", + aiMemoryFile: "File", + aiMemorySize: "Size", + aiMemoryModified: "Modified", + aiMemoryOpenInEditor: "Open in Editor", + aiMemoryRefresh: "Refresh", addAiProvider: "Add Provider", importClaudeCode: "Import Claude Code", importCodexCli: "Import Codex CLI", @@ -823,6 +859,14 @@ export const messagesByLanguage: Record = { testSsh: "测试 SSH", save: "保存", delete: "删除", + confirmDeleteTitle: "删除确认", + confirmDeleteLocalMessage: (path: string) => `确定要删除本地文件「${path}」吗?此操作不可恢复。`, + confirmDeleteRemoteMessage: (path: string) => `确定要删除服务器上的「${path}」吗?此操作不可恢复。`, + deleteEntryFailed: (name: string) => `删除 ${name} 失败`, + disconnected: "已断开", + reconnect: "重新连接", + sessionDisconnected: "会话已断开", + reconnecting: "正在重新连接...", sftpDescription: "浏览远程文件前请先选择一台主机。", selectHost: "选择主机", selectHostPlaceholder: "请选择主机", @@ -908,6 +952,16 @@ export const messagesByLanguage: Record = { aiModels: "模型", aiUsage: "用量", aiSkills: "技能", + aiMemory: "记忆", + aiMemoryDescription: "查看和管理系统中各 AI Agent 的记忆文件。", + aiMemoryEmpty: "未找到记忆文件", + aiMemoryEmptyDescription: "在主目录中未检测到 AI Agent 记忆文件。", + aiMemoryAgent: "Agent", + aiMemoryFile: "文件", + aiMemorySize: "大小", + aiMemoryModified: "修改时间", + aiMemoryOpenInEditor: "在编辑器中打开", + aiMemoryRefresh: "刷新", addAiProvider: "添加 Provider", importClaudeCode: "导入 Claude Code", importCodexCli: "导入 Codex CLI", @@ -1148,6 +1202,14 @@ export const messagesByLanguage: Record = { testSsh: "SSH をテスト", save: "保存", delete: "削除", + confirmDeleteTitle: "削除の確認", + confirmDeleteLocalMessage: (path: string) => `ローカルファイル「${path}」を削除しますか?この操作は元に戻せません。`, + confirmDeleteRemoteMessage: (path: string) => `サーバー上の「${path}」を削除しますか?この操作は元に戻せません。`, + deleteEntryFailed: (name: string) => `${name} の削除に失敗しました`, + disconnected: "切断されました", + reconnect: "再接続", + sessionDisconnected: "セッションが切断されました", + reconnecting: "再接続しています...", sftpDescription: "リモートファイルを閲覧する前にホストを選択してください。", selectHost: "ホストを選択", selectHostPlaceholder: "ホストを選択", @@ -1233,6 +1295,16 @@ export const messagesByLanguage: Record = { aiModels: "モデル", aiUsage: "利用量", aiSkills: "スキル", + aiMemory: "メモリ", + aiMemoryDescription: "システム上の各 AI エージェントのメモリファイルを表示・管理します。", + aiMemoryEmpty: "メモリファイルが見つかりません", + aiMemoryEmptyDescription: "ホームディレクトリに AI エージェントのメモリファイルが検出されませんでした。", + aiMemoryAgent: "エージェント", + aiMemoryFile: "ファイル", + aiMemorySize: "サイズ", + aiMemoryModified: "更新日時", + aiMemoryOpenInEditor: "エディタで開く", + aiMemoryRefresh: "更新", addAiProvider: "Provider を追加", importClaudeCode: "Claude Code を導入", importCodexCli: "Codex CLI を導入", diff --git a/ServerDeck/src/styles/app.css b/ServerDeck/src/styles/app.css index 3e84f8b..9d8b7ea 100644 --- a/ServerDeck/src/styles/app.css +++ b/ServerDeck/src/styles/app.css @@ -4509,10 +4509,180 @@ select { } } +/* AI Memory Styles */ +/* author: BrianXiong */ +/* time: 2026/05/07/23:30:00 */ + +.ai-memory-container { + display: grid; + grid-template-columns: 280px 1fr; + gap: 16px; + min-height: 400px; +} + +.ai-memory-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + background: var(--content-subtle-bg); + border-radius: 12px; + overflow-y: auto; + max-height: 500px; +} + +.ai-memory-item { + padding: 12px; + border-radius: 10px; + background: var(--card-background); + cursor: pointer; + transition: background 120ms ease, box-shadow 120ms ease; + border: 1px solid transparent; +} + +.ai-memory-item:hover { + background: var(--hover-background); +} + +.ai-memory-item--active { + background: var(--primary-button-bg); + color: #fff; + border-color: var(--primary-button-bg); +} + +.ai-memory-item--active:hover { + background: var(--primary-button-bg); +} + +.ai-memory-item__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} + +.ai-memory-item__agent { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.8; +} + +.ai-memory-item__size { + font-size: 11px; + opacity: 0.6; +} + +.ai-memory-item__name { + font-size: 14px; + font-weight: 600; + margin-bottom: 2px; +} + +.ai-memory-item__path { + font-size: 11px; + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-memory-preview { + display: flex; + flex-direction: column; + background: var(--card-background); + border-radius: 12px; + border: 1px solid var(--divider-color); + overflow: hidden; +} + +.ai-memory-preview__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--divider-color); + background: var(--content-subtle-bg); +} + +.ai-memory-preview__title { + display: flex; + align-items: center; + gap: 10px; +} + +.ai-memory-preview__title strong { + font-size: 14px; + font-weight: 600; +} + +.ai-memory-preview__title span { + font-size: 12px; + padding: 2px 8px; + background: var(--primary-button-bg); + color: #fff; + border-radius: 4px; +} + +.ai-memory-preview__meta { + display: flex; + align-items: center; + gap: 16px; + font-size: 12px; + color: var(--text-secondary); +} + +.ai-memory-preview__content { + flex: 1; + overflow: auto; + padding: 16px; + max-height: 450px; +} + +.ai-memory-preview__content pre { + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; + font-family: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", Consolas, monospace; + font-size: 13px; + line-height: 1.6; +} + +.ai-memory-preview__loading { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--text-secondary); +} + +.ai-memory-preview__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 300px; + gap: 12px; + color: var(--text-secondary); +} + +.ai-memory-preview__empty svg { + opacity: 0.3; +} + @media (max-width: 1280px) { .hosts-layout--drawer { grid-template-columns: 1fr; } + + .ai-memory-container { + grid-template-columns: 1fr; + } + + .ai-memory-list { + max-height: 200px; + } } @media (max-width: 900px) { @@ -4556,3 +4726,115 @@ select { min-width: auto; } } + +/* author: BrianXiong */ +/* time: 2026/07/21/00:00:00 */ +/* Disconnected terminal state + reconnect overlay */ +.top-tab__dot--disconnected { + background: #9aa4b5; +} + +.terminal-frame-shell { + position: relative; +} + +.terminal-disconnect { + position: absolute; + inset: 0; + z-index: 5; + display: grid; + place-items: center; + pointer-events: none; + background: rgba(8, 12, 22, 0.35); + backdrop-filter: blur(1.5px); +} + +.terminal-disconnect__card { + pointer-events: auto; + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(15, 20, 32, 0.92); + color: #e6ebf5; + box-shadow: 0 16px 44px rgba(0, 0, 0, 0.35); +} + +.terminal-disconnect__title { + font-size: 13px; + font-weight: 600; +} + +/* Delete confirmation modal */ +.confirm-modal { + width: min(440px, 100%); + display: grid; + gap: 16px; + padding: 20px; + border: 1px solid var(--white-line); + border-radius: 18px; + background: var(--white-panel); + color: var(--white-text); + box-shadow: 0 24px 60px rgba(17, 34, 68, 0.22); +} + +.confirm-modal__header { + display: flex; + align-items: center; + gap: 10px; +} + +.confirm-modal__header h3 { + margin: 0; + font-size: 16px; +} + +.confirm-modal__icon { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 10px; + color: #d64545; + background: rgba(214, 69, 69, 0.12); +} + +.confirm-modal__message { + margin: 0; + font-size: 13px; + line-height: 1.6; + word-break: break-all; + opacity: 0.85; +} + +.confirm-modal__actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.confirm-modal__danger { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: 1px solid transparent; + border-radius: 10px; + background: #d64545; + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.confirm-modal__danger:hover { + background: #c23a3a; +} + +:root[data-app-theme="dark"] .confirm-modal { + border-color: var(--settings-border); + background: var(--settings-card-bg); + color: var(--settings-text); +}