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:
parent
28619bba3f
commit
b86e2d68d8
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
106
src/composables/useKeyboardShortcuts.ts
Normal file
106
src/composables/useKeyboardShortcuts.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user