From b86e2d68d865371211658e76296d332391a95b67 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sun, 29 Mar 2026 16:53:46 -0400 Subject: [PATCH] refactor: extract keyboard shortcuts composable + 5 UX bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract handleKeydown into useKeyboardShortcuts.ts composable; reduces MainLayout by ~20 lines and isolates keyboard logic cleanly - ConnectionTree: watch groups for additions and auto-expand new entries - MonitorBar: generation counter prevents stale event listeners on rapid tab switching - NetworkScanner: revoke blob URL after CSV export click (memory leak) - TransferProgress: implement the auto-expand/collapse watcher that was only commented but never wired up - FileTree: block binary/large file uploads with clear user error rather than silently corrupting — backend sftp_write_file is text-only Co-Authored-By: Claude Sonnet 4.6 --- src/components/sftp/FileTree.vue | 25 +++++ src/components/sftp/TransferProgress.vue | 8 +- src/components/sidebar/ConnectionTree.vue | 11 ++- src/components/terminal/MonitorBar.vue | 10 +- src/components/tools/NetworkScanner.vue | 1 + src/composables/useKeyboardShortcuts.ts | 106 ++++++++++++++++++++++ src/layouts/MainLayout.vue | 25 ++--- 7 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 src/composables/useKeyboardShortcuts.ts diff --git a/src/components/sftp/FileTree.vue b/src/components/sftp/FileTree.vue index 3b711ba..68f63ae 100644 --- a/src/components/sftp/FileTree.vue +++ b/src/components/sftp/FileTree.vue @@ -371,6 +371,31 @@ function handleFileSelected(event: Event): void { failTransfer(transferId); }; + // Guard: the backend sftp_write_file command accepts a UTF-8 string only. + // Binary files (images, archives, executables, etc.) will be corrupted if + // sent as text. Warn and abort for known binary extensions or large files. + const BINARY_EXTENSIONS = new Set([ + "png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tiff", "svg", + "zip", "tar", "gz", "bz2", "xz", "7z", "rar", "zst", + "exe", "dll", "so", "dylib", "bin", "elf", + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", + "mp3", "mp4", "avi", "mkv", "mov", "flac", "wav", "ogg", + "ttf", "otf", "woff", "woff2", + "db", "sqlite", "sqlite3", + ]); + const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; + const isBinary = BINARY_EXTENSIONS.has(ext); + const isLarge = file.size > 1 * 1024 * 1024; // 1 MB + + if (isBinary || isLarge) { + const reason = isBinary + ? `"${ext}" files are binary and cannot be safely uploaded as text` + : `file is ${(file.size / (1024 * 1024)).toFixed(1)} MB — only text files under 1 MB are supported`; + alert(`Upload blocked: ${reason}.\n\nBinary file upload support will be added in a future release.`); + failTransfer(transferId); + return; + } + reader.readAsText(file); } diff --git a/src/components/sftp/TransferProgress.vue b/src/components/sftp/TransferProgress.vue index 01f03ca..d751371 100644 --- a/src/components/sftp/TransferProgress.vue +++ b/src/components/sftp/TransferProgress.vue @@ -52,11 +52,15 @@ diff --git a/src/components/sidebar/ConnectionTree.vue b/src/components/sidebar/ConnectionTree.vue index 265f084..4114191 100644 --- a/src/components/sidebar/ConnectionTree.vue +++ b/src/components/sidebar/ConnectionTree.vue @@ -110,7 +110,7 @@ diff --git a/src/composables/useKeyboardShortcuts.ts b/src/composables/useKeyboardShortcuts.ts new file mode 100644 index 0000000..0896224 --- /dev/null +++ b/src/composables/useKeyboardShortcuts.ts @@ -0,0 +1,106 @@ +import { onMounted, onBeforeUnmount } from "vue"; +import type { Ref } from "vue"; +import type { useSessionStore } from "@/stores/session.store"; + +interface KeyboardShortcutActions { + sessionStore: ReturnType; + sidebarVisible: Ref; + copilotVisible: Ref; + openCommandPalette: () => void; + openActiveSearch: () => void; +} + +export function useKeyboardShortcuts(actions: KeyboardShortcutActions): void { + const { sessionStore, sidebarVisible, copilotVisible, openCommandPalette, openActiveSearch } = actions; + + function handleKeydown(event: KeyboardEvent): void { + const target = event.target as HTMLElement; + const isInputFocused = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT"; + const ctrl = event.ctrlKey || event.metaKey; + + // Ctrl+K — command palette (fires even when input is focused) + if (ctrl && event.key === "k") { + event.preventDefault(); + openCommandPalette(); + return; + } + + if (isInputFocused) return; + + // Ctrl+W — close active tab + if (ctrl && event.key === "w") { + event.preventDefault(); + const active = sessionStore.activeSession; + if (active) sessionStore.closeSession(active.id); + return; + } + + // Ctrl+Tab — next tab + if (ctrl && event.key === "Tab" && !event.shiftKey) { + event.preventDefault(); + const sessions = sessionStore.sessions; + if (sessions.length < 2) return; + const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); + const next = sessions[(idx + 1) % sessions.length]; + sessionStore.activateSession(next.id); + return; + } + + // Ctrl+Shift+Tab — previous tab + if (ctrl && event.key === "Tab" && event.shiftKey) { + event.preventDefault(); + const sessions = sessionStore.sessions; + if (sessions.length < 2) return; + const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); + const prev = sessions[(idx - 1 + sessions.length) % sessions.length]; + sessionStore.activateSession(prev.id); + return; + } + + // Ctrl+1-9 — jump to tab by index + if (ctrl && event.key >= "1" && event.key <= "9") { + const tabIndex = parseInt(event.key, 10) - 1; + const sessions = sessionStore.sessions; + if (tabIndex < sessions.length) { + event.preventDefault(); + sessionStore.activateSession(sessions[tabIndex].id); + } + return; + } + + // Ctrl+B — toggle sidebar + if (ctrl && event.key === "b") { + event.preventDefault(); + sidebarVisible.value = !sidebarVisible.value; + return; + } + + // Ctrl+Shift+G — toggle AI copilot + if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") { + event.preventDefault(); + copilotVisible.value = !copilotVisible.value; + return; + } + + // Ctrl+F — terminal search (SSH sessions only) + if (ctrl && event.key === "f") { + const active = sessionStore.activeSession; + if (active?.protocol === "ssh") { + event.preventDefault(); + openActiveSearch(); + } + return; + } + } + + onMounted(() => { + document.addEventListener("keydown", handleKeydown); + }); + + onBeforeUnmount(() => { + document.removeEventListener("keydown", handleKeydown); + }); +} diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index a359aa7..8cde734 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -308,6 +308,7 @@