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 @@