refactor: extract keyboard shortcuts composable + 5 UX bug fixes

- 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 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-29 16:53:46 -04:00
parent 28619bba3f
commit b86e2d68d8
7 changed files with 165 additions and 21 deletions

View File

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

View File

@ -52,11 +52,15 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ref, watch } from "vue";
import { useTransfers } from "@/composables/useTransfers";
const expanded = ref(false);
const { transfers } = useTransfers();
// Auto-expand when transfers become active, collapse when all are gone
const { transfers } = useTransfers();
watch(() => transfers.value.length, (newLen, oldLen) => {
if (newLen > 0 && oldLen === 0) expanded.value = true;
if (newLen === 0) expanded.value = false;
});
</script>

View File

@ -110,7 +110,7 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ref, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useConnectionStore, type Connection, type Group } from "@/stores/connection.store";
import { useSessionStore } from "@/stores/session.store";
@ -224,6 +224,15 @@ const expandedGroups = ref<Set<number>>(
new Set(connectionStore.groups.map((g) => g.id)),
);
// Auto-expand groups added after initial load
watch(() => connectionStore.groups, (newGroups) => {
for (const group of newGroups) {
if (!expandedGroups.value.has(group.id)) {
expandedGroups.value.add(group.id);
}
}
}, { deep: true });
function toggleGroup(groupId: number): void {
if (expandedGroups.value.has(groupId)) {
expandedGroups.value.delete(groupId);

View File

@ -55,6 +55,7 @@ interface SystemStats {
const stats = ref<SystemStats | null>(null);
let unlistenFn: UnlistenFn | null = null;
let subscribeGeneration = 0;
function colorClass(value: number, warnThreshold: number, critThreshold: number): string {
if (value >= critThreshold) return "text-[#f85149]"; // red
@ -70,10 +71,17 @@ function formatBytes(bytes: number): string {
}
async function subscribe(): Promise<void> {
const gen = ++subscribeGeneration;
if (unlistenFn) unlistenFn();
unlistenFn = await listen<SystemStats>(`ssh:monitor:${props.sessionId}`, (event) => {
const fn = await listen<SystemStats>(`ssh:monitor:${props.sessionId}`, (event) => {
stats.value = event.payload;
});
if (gen !== subscribeGeneration) {
// A newer subscribe() call has already taken over discard this listener
fn();
return;
}
unlistenFn = fn;
}
onMounted(subscribe);

View File

@ -85,5 +85,6 @@ function exportCsv(): void {
a.href = URL.createObjectURL(blob);
a.download = `wraith-scan-${subnet.value}-${Date.now()}.csv`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
</script>

View File

@ -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<typeof useSessionStore>;
sidebarVisible: Ref<boolean>;
copilotVisible: Ref<boolean>;
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);
});
}

View File

@ -308,6 +308,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useKeyboardShortcuts } from "@/composables/useKeyboardShortcuts";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useAppStore } from "@/stores/app.store";
@ -479,20 +480,13 @@ async function handleQuickConnect(): Promise<void> {
} catch (err) { console.error("Quick connect failed:", err); }
}
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;
if (ctrl && event.key === "k") { event.preventDefault(); commandPalette.value?.toggle(); return; }
if (isInputFocused) return;
if (ctrl && event.key === "w") { event.preventDefault(); const active = sessionStore.activeSession; if (active) sessionStore.closeSession(active.id); return; }
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; }
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; }
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; }
if (ctrl && event.key === "b") { event.preventDefault(); sidebarVisible.value = !sidebarVisible.value; return; }
if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") { event.preventDefault(); copilotVisible.value = !copilotVisible.value; return; }
if (ctrl && event.key === "f") { const active = sessionStore.activeSession; if (active?.protocol === "ssh") { event.preventDefault(); sessionContainer.value?.openActiveSearch(); } return; }
}
useKeyboardShortcuts({
sessionStore,
sidebarVisible,
copilotVisible,
openCommandPalette: () => commandPalette.value?.toggle(),
openActiveSearch: () => sessionContainer.value?.openActiveSearch(),
});
let workspaceSaveInterval: ReturnType<typeof setInterval> | null = null;
@ -503,8 +497,6 @@ function handleBeforeUnload(e: BeforeUnloadEvent): void {
}
onMounted(async () => {
document.addEventListener("keydown", handleKeydown);
// Confirm before closing if sessions are active (synchronous won't hang)
window.addEventListener("beforeunload", handleBeforeUnload);
@ -546,7 +538,6 @@ onMounted(async () => {
});
onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown);
window.removeEventListener("beforeunload", handleBeforeUnload);
if (workspaceSaveInterval !== null) {
clearInterval(workspaceSaveInterval);