Replace all TODO stubs in frontend stores with real Wails Call.ByName bindings. The app store now calls WraithApp.IsFirstRun/CreateVault/Unlock so vault state persists across launches. The connection store loads from ConnectionService.ListConnections/ListGroups instead of hardcoded mock data. The import dialog calls a new WraithApp.ImportMobaConf method that parses the file, creates groups and connections in SQLite, and stores host keys. ConnectionEditDialog also uses real Go CRUD calls. MainLayout loads connections on mount after vault unlock. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
410 lines
17 KiB
Vue
410 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"
|
|
style="--wails-draggable: drag"
|
|
>
|
|
<div class="flex items-center gap-3" style="--wails-draggable: no-drag">
|
|
<span class="text-sm font-bold tracking-widest text-[var(--wraith-accent-blue)]" style="--wails-draggable: drag">
|
|
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('import')"
|
|
>
|
|
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14ZM11.78 4.72a.749.749 0 1 1-1.06 1.06L8.75 3.81V9.5a.75.75 0 0 1-1.5 0V3.81L5.28 5.78a.749.749 0 1 1-1.06-1.06l3.25-3.25a.749.749 0 0 1 1.06 0l3.25 3.25Z"/></svg>
|
|
<span class="flex-1">Import from MobaXTerm</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('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" style="--wails-draggable: no-drag">
|
|
<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)]" style="--wails-draggable: no-drag">
|
|
<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>
|
|
<!-- XO Copilot toggle -->
|
|
<button
|
|
class="relative hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
|
:class="copilotStore.isPanelOpen ? 'text-[var(--wraith-accent-blue)]' : ''"
|
|
title="Toggle XO Copilot (Ctrl+Shift+K)"
|
|
@click="copilotStore.togglePanel()"
|
|
>
|
|
<span class="text-sm">👻</span>
|
|
<!-- Streaming indicator dot -->
|
|
<span
|
|
v-if="copilotStore.isStreaming"
|
|
class="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[var(--wraith-accent-blue)] animate-pulse"
|
|
/>
|
|
<!-- Subtle glow when open -->
|
|
<span
|
|
v-if="copilotStore.isPanelOpen"
|
|
class="absolute inset-0 rounded-full bg-[var(--wraith-accent-blue)] opacity-15 blur-sm"
|
|
/>
|
|
</button>
|
|
<button
|
|
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
|
title="Lock vault"
|
|
@click="appStore.lock()"
|
|
>
|
|
🔒
|
|
</button>
|
|
<button
|
|
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
|
title="Settings"
|
|
@click="settingsModal?.open()"
|
|
>
|
|
⚙
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main content area -->
|
|
<div class="flex flex-1 min-h-0">
|
|
<!-- Sidebar -->
|
|
<div
|
|
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 file tree -->
|
|
<template v-else-if="sidebarTab === 'sftp'">
|
|
<FileTree
|
|
v-if="sessionStore.activeSession && sessionStore.activeSession.protocol === 'ssh'"
|
|
:session-id="sessionStore.activeSession.id"
|
|
@open-file="handleOpenFile"
|
|
/>
|
|
<div v-else class="flex items-center justify-center py-8 px-3">
|
|
<p class="text-[var(--wraith-text-muted)] text-xs text-center">
|
|
Connect to an SSH session to browse files
|
|
</p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Transfer progress (SFTP mode only) -->
|
|
<TransferProgress v-if="sidebarTab === 'sftp'" />
|
|
</div>
|
|
|
|
<!-- Content area -->
|
|
<div class="flex-1 flex flex-col min-w-0">
|
|
<!-- Tab bar -->
|
|
<TabBar />
|
|
|
|
<!-- Editor panel (if 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 />
|
|
</div>
|
|
|
|
<!-- Copilot Panel (slides in from right) -->
|
|
<transition name="copilot-slide">
|
|
<CopilotPanel v-if="copilotStore.isPanelOpen" />
|
|
</transition>
|
|
</div>
|
|
|
|
<!-- Status bar -->
|
|
<StatusBar ref="statusBar" @open-theme-picker="themePicker?.open()" />
|
|
|
|
<!-- Command Palette (Ctrl+K) -->
|
|
<CommandPalette
|
|
ref="commandPalette"
|
|
@open-import="importDialog?.open()"
|
|
@open-settings="settingsModal?.open()"
|
|
@open-new-connection="connectionEditDialog?.openNew()"
|
|
/>
|
|
|
|
<!-- Theme Picker -->
|
|
<ThemePicker ref="themePicker" @select="handleThemeSelect" />
|
|
|
|
<!-- Import Dialog -->
|
|
<ImportDialog ref="importDialog" />
|
|
|
|
<!-- 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, onMounted, onUnmounted } from "vue";
|
|
import { useAppStore } from "@/stores/app.store";
|
|
import { useConnectionStore } from "@/stores/connection.store";
|
|
import { useSessionStore } from "@/stores/session.store";
|
|
import { useCopilotStore } from "@/stores/copilot.store";
|
|
import SidebarToggle from "@/components/sidebar/SidebarToggle.vue";
|
|
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
|
|
import FileTree from "@/components/sftp/FileTree.vue";
|
|
import TransferProgress from "@/components/sftp/TransferProgress.vue";
|
|
import TabBar from "@/components/session/TabBar.vue";
|
|
import SessionContainer from "@/components/session/SessionContainer.vue";
|
|
import StatusBar from "@/components/common/StatusBar.vue";
|
|
import EditorWindow from "@/components/editor/EditorWindow.vue";
|
|
import CommandPalette from "@/components/common/CommandPalette.vue";
|
|
import ThemePicker from "@/components/common/ThemePicker.vue";
|
|
import ImportDialog from "@/components/common/ImportDialog.vue";
|
|
import SettingsModal from "@/components/common/SettingsModal.vue";
|
|
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
|
|
import CopilotPanel from "@/components/copilot/CopilotPanel.vue";
|
|
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
|
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
|
|
import type { FileEntry } from "@/composables/useSftp";
|
|
|
|
const appStore = useAppStore();
|
|
const connectionStore = useConnectionStore();
|
|
const sessionStore = useSessionStore();
|
|
const copilotStore = useCopilotStore();
|
|
|
|
const sidebarWidth = ref(240);
|
|
const sidebarTab = ref<SidebarTab>("connections");
|
|
const quickConnectInput = ref("");
|
|
|
|
/** Currently open file in the editor panel (null = no file open). */
|
|
const editorFile = ref<{ content: string; path: string; sessionId: string } | null>(null);
|
|
|
|
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null);
|
|
const themePicker = ref<InstanceType<typeof ThemePicker> | null>(null);
|
|
const importDialog = ref<InstanceType<typeof ImportDialog> | 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);
|
|
|
|
/** 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. */
|
|
function handleFileMenuAction(action: string): void {
|
|
showFileMenu.value = false;
|
|
switch (action) {
|
|
case "new-connection":
|
|
connectionEditDialog.value?.openNew();
|
|
break;
|
|
case "import":
|
|
importDialog.value?.open();
|
|
break;
|
|
case "settings":
|
|
settingsModal.value?.open();
|
|
break;
|
|
case "exit":
|
|
// TODO: Replace with Wails runtime.Quit()
|
|
window.close();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/** Handle file open from SFTP sidebar -- loads mock content for now. */
|
|
function handleOpenFile(entry: FileEntry): void {
|
|
if (!sessionStore.activeSession) return;
|
|
|
|
// TODO: Replace with Wails binding call -- SFTPService.ReadFile(sessionId, entry.path)
|
|
// Mock file content for development
|
|
const mockContent = `# ${entry.name}\n\n` +
|
|
`# File: ${entry.path}\n` +
|
|
`# Size: ${entry.size} bytes\n` +
|
|
`# Permissions: ${entry.permissions}\n` +
|
|
`# Modified: ${entry.modTime}\n\n` +
|
|
`# TODO: Content will be loaded from SFTPService.ReadFile()\n`;
|
|
|
|
editorFile.value = {
|
|
content: mockContent,
|
|
path: entry.path,
|
|
sessionId: sessionStore.activeSession.id,
|
|
};
|
|
}
|
|
|
|
/** Handle theme selection from the ThemePicker. */
|
|
function handleThemeSelect(theme: ThemeDefinition): void {
|
|
statusBar.value?.setThemeName(theme.name);
|
|
}
|
|
|
|
/**
|
|
* Quick Connect: parse user@host:port and open a session.
|
|
* Default protocol: SSH, default port: 22.
|
|
* If port is 3389, use RDP.
|
|
*/
|
|
function handleQuickConnect(): 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";
|
|
}
|
|
|
|
// Create a temporary connection and session
|
|
// TODO: Replace with Wails binding — create ephemeral session via SSHService.Connect / RDPService.Connect
|
|
const tempId = Math.max(...connectionStore.connections.map((c) => c.id), 0) + 1;
|
|
const name = username ? `${username}@${hostname}` : hostname;
|
|
|
|
connectionStore.connections.push({
|
|
id: tempId,
|
|
name,
|
|
hostname,
|
|
port,
|
|
protocol,
|
|
groupId: 1,
|
|
tags: [],
|
|
});
|
|
|
|
sessionStore.connect(tempId);
|
|
quickConnectInput.value = "";
|
|
}
|
|
|
|
/** Global keyboard shortcut handler. */
|
|
function handleKeydown(event: KeyboardEvent): void {
|
|
// Ctrl+Shift+K or Cmd+Shift+K — toggle copilot panel
|
|
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "K") {
|
|
event.preventDefault();
|
|
copilotStore.togglePanel();
|
|
return;
|
|
}
|
|
|
|
// Ctrl+K or Cmd+K — open command palette
|
|
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
|
event.preventDefault();
|
|
commandPalette.value?.toggle();
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
document.addEventListener("keydown", handleKeydown);
|
|
// Load connections and groups from the Go backend after vault unlock
|
|
await connectionStore.loadAll();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener("keydown", handleKeydown);
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.copilot-slide-enter-active,
|
|
.copilot-slide-leave-active {
|
|
transition: width 0.3s ease, opacity 0.3s ease;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.copilot-slide-enter-from,
|
|
.copilot-slide-leave-to {
|
|
width: 0px !important;
|
|
opacity: 0;
|
|
}
|
|
</style>
|