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);
|
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);
|
reader.readAsText(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,11 +52,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { useTransfers } from "@/composables/useTransfers";
|
import { useTransfers } from "@/composables/useTransfers";
|
||||||
|
|
||||||
const expanded = ref(false);
|
const expanded = ref(false);
|
||||||
|
const { transfers } = useTransfers();
|
||||||
|
|
||||||
// Auto-expand when transfers become active, collapse when all are gone
|
// 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>
|
</script>
|
||||||
|
|||||||
@ -110,7 +110,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useConnectionStore, type Connection, type Group } from "@/stores/connection.store";
|
import { useConnectionStore, type Connection, type Group } from "@/stores/connection.store";
|
||||||
import { useSessionStore } from "@/stores/session.store";
|
import { useSessionStore } from "@/stores/session.store";
|
||||||
@ -224,6 +224,15 @@ const expandedGroups = ref<Set<number>>(
|
|||||||
new Set(connectionStore.groups.map((g) => g.id)),
|
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 {
|
function toggleGroup(groupId: number): void {
|
||||||
if (expandedGroups.value.has(groupId)) {
|
if (expandedGroups.value.has(groupId)) {
|
||||||
expandedGroups.value.delete(groupId);
|
expandedGroups.value.delete(groupId);
|
||||||
|
|||||||
@ -55,6 +55,7 @@ interface SystemStats {
|
|||||||
|
|
||||||
const stats = ref<SystemStats | null>(null);
|
const stats = ref<SystemStats | null>(null);
|
||||||
let unlistenFn: UnlistenFn | null = null;
|
let unlistenFn: UnlistenFn | null = null;
|
||||||
|
let subscribeGeneration = 0;
|
||||||
|
|
||||||
function colorClass(value: number, warnThreshold: number, critThreshold: number): string {
|
function colorClass(value: number, warnThreshold: number, critThreshold: number): string {
|
||||||
if (value >= critThreshold) return "text-[#f85149]"; // red
|
if (value >= critThreshold) return "text-[#f85149]"; // red
|
||||||
@ -70,10 +71,17 @@ function formatBytes(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function subscribe(): Promise<void> {
|
async function subscribe(): Promise<void> {
|
||||||
|
const gen = ++subscribeGeneration;
|
||||||
if (unlistenFn) unlistenFn();
|
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;
|
stats.value = event.payload;
|
||||||
});
|
});
|
||||||
|
if (gen !== subscribeGeneration) {
|
||||||
|
// A newer subscribe() call has already taken over — discard this listener
|
||||||
|
fn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unlistenFn = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(subscribe);
|
onMounted(subscribe);
|
||||||
|
|||||||
@ -85,5 +85,6 @@ function exportCsv(): void {
|
|||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = `wraith-scan-${subnet.value}-${Date.now()}.csv`;
|
a.download = `wraith-scan-${subnet.value}-${Date.now()}.csv`;
|
||||||
a.click();
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
}
|
}
|
||||||
</script>
|
</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">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
|
import { useKeyboardShortcuts } from "@/composables/useKeyboardShortcuts";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { useAppStore } from "@/stores/app.store";
|
import { useAppStore } from "@/stores/app.store";
|
||||||
@ -479,20 +480,13 @@ async function handleQuickConnect(): Promise<void> {
|
|||||||
} catch (err) { console.error("Quick connect failed:", err); }
|
} catch (err) { console.error("Quick connect failed:", err); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent): void {
|
useKeyboardShortcuts({
|
||||||
const target = event.target as HTMLElement;
|
sessionStore,
|
||||||
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
|
sidebarVisible,
|
||||||
const ctrl = event.ctrlKey || event.metaKey;
|
copilotVisible,
|
||||||
if (ctrl && event.key === "k") { event.preventDefault(); commandPalette.value?.toggle(); return; }
|
openCommandPalette: () => commandPalette.value?.toggle(),
|
||||||
if (isInputFocused) return;
|
openActiveSearch: () => sessionContainer.value?.openActiveSearch(),
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let workspaceSaveInterval: ReturnType<typeof setInterval> | null = null;
|
let workspaceSaveInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
@ -503,8 +497,6 @@ function handleBeforeUnload(e: BeforeUnloadEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener("keydown", handleKeydown);
|
|
||||||
|
|
||||||
// Confirm before closing if sessions are active (synchronous — won't hang)
|
// Confirm before closing if sessions are active (synchronous — won't hang)
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
|
||||||
@ -546,7 +538,6 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener("keydown", handleKeydown);
|
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
if (workspaceSaveInterval !== null) {
|
if (workspaceSaveInterval !== null) {
|
||||||
clearInterval(workspaceSaveInterval);
|
clearInterval(workspaceSaveInterval);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user