wraith/src/layouts/MainLayout.vue
Vantz Stockwell 3a260f6a2c feat: remove MobaXTerm import entirely
Removed ImportDialog, MobaXTerm first-run prompt, import menu
item, and all related refs. 6 connections — entered by hand.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 02:53:44 -04:00

459 lines
17 KiB
Vue

<template>
<div class="h-screen w-screen flex flex-col overflow-hidden">
<!-- Toolbar -->
<div
class="h-10 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] shrink-0"
data-tauri-drag-region
>
<div class="flex items-center gap-3">
<span class="text-sm font-bold tracking-widest text-[var(--wraith-accent-blue)]">
WRAITH
</span>
<!-- File menu -->
<div class="relative">
<button
class="text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer px-2 py-1 rounded hover:bg-[var(--wraith-bg-tertiary)]"
@click="showFileMenu = !showFileMenu"
@blur="closeFileMenuDeferred"
>
File
</button>
<div
v-if="showFileMenu"
class="absolute top-full left-0 mt-0.5 w-56 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden z-50 py-1"
>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleFileMenuAction('new-connection')"
>
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
<span class="flex-1">New Connection</span>
<kbd class="text-[10px] text-[var(--wraith-text-muted)]">Ctrl+N</kbd>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleFileMenuAction('settings')"
>
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8.2 8.2 0 0 1 .701.031C8.955.017 9.209 0 9.466 0a1.934 1.934 0 0 1 1.466.665c.33.367.51.831.54 1.316a7.96 7.96 0 0 1 .82.4c.463-.207.97-.29 1.476-.19.504.1.963.37 1.3.77.339.404.516.91.5 1.423a1.94 1.94 0 0 1-.405 1.168 8.02 8.02 0 0 1 .356.9 1.939 1.939 0 0 1 1.48.803 1.941 1.941 0 0 1 0 2.29 1.939 1.939 0 0 1-1.48.803c-.095.316-.215.622-.357.9a1.94 1.94 0 0 1-.094 2.59 1.94 1.94 0 0 1-2.776.22 7.96 7.96 0 0 1-.82.4 1.94 1.94 0 0 1-2.006 1.98A8.2 8.2 0 0 1 8 16a8.2 8.2 0 0 1-.701-.031 1.938 1.938 0 0 1-2.005-1.98 7.96 7.96 0 0 1-.82-.4 1.94 1.94 0 0 1-2.776-.22 1.94 1.94 0 0 1-.094-2.59 8.02 8.02 0 0 1-.357-.9A1.939 1.939 0 0 1 0 8.945a1.941 1.941 0 0 1 0-2.29 1.939 1.939 0 0 1 1.247-.803c.095-.316.215-.622.357-.9a1.94 1.94 0 0 1 .094-2.59 1.94 1.94 0 0 1 2.776-.22c.258-.157.532-.293.82-.4A1.934 1.934 0 0 1 6.834.665 1.934 1.934 0 0 1 8.3.03 8.2 8.2 0 0 1 8 0ZM8 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>
<span class="flex-1">Settings</span>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleFileMenuAction('exit')"
>
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2.75C2 1.784 2.784 1 3.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h3.5a.75.75 0 0 1 0 1.5h-3.5A1.75 1.75 0 0 1 2 13.25Zm10.44 4.5-1.97-1.97a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.97-1.97H6.75a.75.75 0 0 1 0-1.5Z"/></svg>
<span class="flex-1">Exit</span>
<kbd class="text-[10px] text-[var(--wraith-text-muted)]">Alt+F4</kbd>
</button>
</div>
</div>
</div>
<!-- Quick Connect -->
<div class="flex-1 max-w-xs mx-4">
<input
v-model="quickConnectInput"
type="text"
placeholder="Quick connect: user@host:port"
class="w-full px-2.5 py-1 text-xs rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
@keydown.enter="handleQuickConnect"
/>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--wraith-text-secondary)]">
<span>{{ sessionStore.sessionCount }} session{{ sessionStore.sessionCount !== 1 ? "s" : "" }}</span>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Command palette (Ctrl+K)"
@click="commandPalette?.toggle()"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04z" />
</svg>
</button>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Lock vault"
@click="appStore.lock()"
>
&#x1f512;
</button>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Settings"
@click="settingsModal?.open()"
>
&#x2699;
</button>
</div>
</div>
<!-- Main content area -->
<div class="flex flex-1 min-h-0">
<!-- Sidebar -->
<div
v-if="sidebarVisible"
class="flex flex-col bg-[var(--wraith-bg-secondary)] border-r border-[var(--wraith-border)] shrink-0"
:style="{ width: sidebarWidth + 'px' }"
>
<SidebarToggle v-model="sidebarTab" />
<!-- Search (connections mode only) -->
<div v-if="sidebarTab === 'connections'" class="px-3 py-2">
<input
v-model="connectionStore.searchQuery"
type="text"
placeholder="Search connections..."
class="w-full px-2.5 py-1.5 text-xs rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<!-- Sidebar content -->
<div class="flex-1 overflow-y-auto">
<!-- Connection tree -->
<ConnectionTree v-if="sidebarTab === 'connections'" />
<!-- SFTP browser -->
<template v-else-if="sidebarTab === 'sftp'">
<template v-if="activeSessionId">
<FileTree
:session-id="activeSessionId"
class="flex-1 min-h-0"
@open-file="handleOpenFile"
/>
<TransferProgress />
</template>
<div v-else class="flex items-center justify-center py-8 px-3">
<p class="text-[var(--wraith-text-muted)] text-xs text-center">
No active session
</p>
</div>
</template>
</div>
</div>
<!-- Content area -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Tab bar -->
<TabBar />
<!-- Inline file editor — shown above the terminal when a file is open -->
<EditorWindow
v-if="editorFile"
:content="editorFile.content"
:file-path="editorFile.path"
:session-id="editorFile.sessionId"
@close="editorFile = null"
/>
<!-- Session area -->
<SessionContainer ref="sessionContainer" />
</div>
</div>
<!-- Status bar -->
<StatusBar ref="statusBar" @open-theme-picker="themePicker?.open()" />
<!-- Command Palette (Ctrl+K) — stub, full implementation Phase N -->
<CommandPalette
ref="commandPalette"
@open-settings="settingsModal?.open()"
@open-new-connection="connectionEditDialog?.openNew()"
/>
<!-- Theme Picker -->
<ThemePicker ref="themePicker" @select="handleThemeSelect" />
<!-- Settings Modal -->
<SettingsModal ref="settingsModal" />
<!-- Connection Edit Dialog (for File menu / Command Palette new connection) -->
<ConnectionEditDialog ref="connectionEditDialog" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useAppStore } from "@/stores/app.store";
import { useConnectionStore } from "@/stores/connection.store";
import { useSessionStore } from "@/stores/session.store";
import SidebarToggle from "@/components/sidebar/SidebarToggle.vue";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import TabBar from "@/components/session/TabBar.vue";
import SessionContainer from "@/components/session/SessionContainer.vue";
import StatusBar from "@/components/common/StatusBar.vue";
import CommandPalette from "@/components/common/CommandPalette.vue";
import ThemePicker from "@/components/common/ThemePicker.vue";
import SettingsModal from "@/components/common/SettingsModal.vue";
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
import FileTree from "@/components/sftp/FileTree.vue";
import TransferProgress from "@/components/sftp/TransferProgress.vue";
import EditorWindow from "@/components/editor/EditorWindow.vue";
import type { FileEntry } from "@/composables/useSftp";
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
const appStore = useAppStore();
const connectionStore = useConnectionStore();
const sessionStore = useSessionStore();
/** Active SSH session ID, exposed to the SFTP sidebar. */
const activeSessionId = computed(() => sessionStore.activeSessionId);
const sidebarWidth = ref(240);
const sidebarVisible = ref(true);
const sidebarTab = ref<SidebarTab>("connections");
const quickConnectInput = ref("");
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null);
const themePicker = ref<InstanceType<typeof ThemePicker> | null>(null);
const settingsModal = ref<InstanceType<typeof SettingsModal> | null>(null);
const connectionEditDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null);
/** Currently open file in the inline editor. Null when the editor is closed. */
interface EditorFile {
path: string;
content: string;
sessionId: string;
}
const editorFile = ref<EditorFile | null>(null);
/** File menu dropdown state. */
const showFileMenu = ref(false);
/** Close the file menu after a short delay (allows click events to fire first). */
function closeFileMenuDeferred(): void {
setTimeout(() => { showFileMenu.value = false; }, 150);
}
/** Handle file menu item clicks. */
async function handleFileMenuAction(action: string): Promise<void> {
showFileMenu.value = false;
switch (action) {
case "new-connection":
connectionEditDialog.value?.openNew();
break;
case "settings":
settingsModal.value?.open();
break;
case "exit":
try {
await getCurrentWindow().close();
} catch {
window.close();
}
break;
}
}
/** Handle theme selection from the ThemePicker. */
function handleThemeSelect(theme: ThemeDefinition): void {
statusBar.value?.setThemeName(theme.name);
// Propagate theme to all active terminal instances via the session store
sessionStore.setTheme(theme);
}
/**
* Called when the user double-clicks a file in the SFTP FileTree.
* Reads the file content via Tauri SFTP and opens it in the inline editor.
*/
async function handleOpenFile(entry: FileEntry): Promise<void> {
if (!activeSessionId.value) return;
try {
const content = await invoke<string>("sftp_read_file", {
sessionId: activeSessionId.value,
path: entry.path,
});
editorFile.value = {
path: entry.path,
content,
sessionId: activeSessionId.value,
};
} catch (err) {
console.error("Failed to open SFTP file:", err);
}
}
/**
* Quick Connect: parse user@host:port and open a session.
* Default protocol: SSH, default port: 22.
* If port is 3389, use RDP.
*/
async function handleQuickConnect(): Promise<void> {
const raw = quickConnectInput.value.trim();
if (!raw) return;
let username = "";
let hostname = "";
let port = 22;
let protocol: "ssh" | "rdp" = "ssh";
let hostPart = raw;
// Extract username if present (user@...)
const atIdx = raw.indexOf("@");
if (atIdx > 0) {
username = raw.substring(0, atIdx);
hostPart = raw.substring(atIdx + 1);
}
// Extract port if present (...:port)
const colonIdx = hostPart.lastIndexOf(":");
if (colonIdx > 0) {
const portStr = hostPart.substring(colonIdx + 1);
const parsedPort = parseInt(portStr, 10);
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
port = parsedPort;
hostPart = hostPart.substring(0, colonIdx);
}
}
hostname = hostPart;
if (!hostname) return;
// Auto-detect RDP by port
if (port === 3389) {
protocol = "rdp";
}
const name = username ? `${username}@${hostname}` : hostname;
try {
// Create a persistent connection record then connect to it
const conn = await invoke<{ id: number }>("create_connection", {
name,
hostname,
port,
protocol,
groupId: null,
credentialId: null,
color: "",
tags: username ? [username] : [],
notes: "",
options: username ? JSON.stringify({ username }) : "{}",
});
// Add to local store so sessionStore.connect can find it
connectionStore.connections.push({
id: conn.id,
name,
hostname,
port,
protocol,
groupId: null,
tags: username ? [username] : [],
options: username ? JSON.stringify({ username }) : "{}",
});
await sessionStore.connect(conn.id);
quickConnectInput.value = "";
} catch (err) {
console.error("Quick connect failed:", err);
}
}
/** Global keyboard shortcut handler. */
function handleKeydown(event: KeyboardEvent): void {
// Skip shortcuts when the user is typing in an input, textarea, or select
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 — open command palette (fires even in inputs to match VS Code behavior)
if (ctrl && event.key === "k") {
event.preventDefault();
commandPalette.value?.toggle();
return;
}
// All remaining shortcuts skip when typing in input fields
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 through Ctrl+9 — switch 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+F — open terminal scrollback search (SSH sessions only)
if (ctrl && event.key === "f") {
const active = sessionStore.activeSession;
if (active?.protocol === "ssh") {
event.preventDefault();
sessionContainer.value?.openActiveSearch();
}
return;
}
}
onMounted(async () => {
document.addEventListener("keydown", handleKeydown);
// Load connections and groups from the Rust backend after vault unlock
await connectionStore.loadAll();
});
onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown);
});
</script>
<style scoped>
</style>