feat: fix 6 frontend issues (F-1, F-5, F-6, F-7, F-10, F-11)

F-1 (Theme Application): Theme selection now applies to all active xterm.js
terminals at runtime via session store reactive propagation. TerminalView
watches sessionStore.activeTheme and calls terminal.options.theme = {...}.

F-5 (Tab Badges): isRootUser() now checks session.username, connection
options JSON, and connection tags — no longer hardcoded to false.

F-6 (Keyboard Shortcuts): Added Ctrl+W (close tab), Ctrl+Tab / Ctrl+Shift+Tab
(next/prev tab), Ctrl+1–9 (tab by index), Ctrl+B (toggle sidebar). Input
field guard prevents shortcuts from firing while typing.

F-7 (Status Bar Dimensions): StatusBar now reads live cols×rows from
sessionStore.activeDimensions. TerminalView hooks onResize to call
sessionStore.setTerminalDimensions(). Falls back to "120×40" until first resize.

F-10 (Multiple Sessions): Removed the "already connected" early-return guard.
Multiple SSH/RDP sessions to the same host are now allowed. Disambiguated tab
names auto-generated: "Asgard", "Asgard (2)", "Asgard (3)", etc.

F-11 (First-Run MobaConf): onMounted checks connectionStore.connections.length
after loadAll(). If empty, shows a dialog offering to import from MobaXTerm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 13:41:28 -04:00
parent 9d19147568
commit a6db3ddfa4
5 changed files with 230 additions and 16 deletions

View File

@ -53,7 +53,10 @@
Theme: {{ activeThemeName }}
</button>
<span>UTF-8</span>
<span>120&times;40</span>
<span v-if="sessionStore.activeDimensions">
{{ sessionStore.activeDimensions.cols }}&times;{{ sessionStore.activeDimensions.rows }}
</span>
<span v-else>120&times;40</span>
</div>
</div>
</template>

View File

@ -85,11 +85,24 @@ function getSessionTags(session: Session): string[] {
/** Check if the connection for this session uses the root user. */
function isRootUser(session: Session): boolean {
// Check username stored on the session object (set during connect)
if (session.username === "root") return true;
// Fall back to checking the connection's options JSON for a stored username
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
if (!conn) return false;
// TODO: Get actual username from the credential or session
// For now, check mock data root user detection will come from the session/credential store
return false;
if (conn.options) {
try {
const opts = JSON.parse(conn.options);
if (opts?.username === "root") return true;
} catch {
// ignore malformed options
}
}
// Also check if "root" appears in the connection tags
return conn.tags?.includes("root") ?? false;
}
/** Return Tailwind classes for environment tag badges. */

View File

@ -9,6 +9,7 @@
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { useTerminal } from "@/composables/useTerminal";
import { useSessionStore } from "@/stores/session.store";
import "@/assets/css/terminal.css";
const props = defineProps<{
@ -16,6 +17,7 @@ const props = defineProps<{
isActive: boolean;
}>();
const sessionStore = useSessionStore();
const containerRef = ref<HTMLElement | null>(null);
const { terminal, mount, fit } = useTerminal(props.sessionId);
@ -23,6 +25,16 @@ onMounted(() => {
if (containerRef.value) {
mount(containerRef.value);
}
// Apply the current theme immediately if one is already active
if (sessionStore.activeTheme) {
applyTheme();
}
// Track terminal dimensions in the session store
terminal.onResize(({ cols, rows }) => {
sessionStore.setTerminalDimensions(props.sessionId, cols, rows);
});
});
// Re-fit and focus terminal when this tab becomes active
@ -39,6 +51,38 @@ watch(
},
);
/** Apply the session store's active theme to this terminal instance. */
function applyTheme(): void {
const theme = sessionStore.activeTheme;
if (!theme) return;
terminal.options.theme = {
background: theme.background,
foreground: theme.foreground,
cursor: theme.cursor,
black: theme.black,
red: theme.red,
green: theme.green,
yellow: theme.yellow,
blue: theme.blue,
magenta: theme.magenta,
cyan: theme.cyan,
white: theme.white,
brightBlack: theme.brightBlack,
brightRed: theme.brightRed,
brightGreen: theme.brightGreen,
brightYellow: theme.brightYellow,
brightBlue: theme.brightBlue,
brightMagenta: theme.brightMagenta,
brightCyan: theme.brightCyan,
brightWhite: theme.brightWhite,
};
}
// Watch for theme changes in the session store and apply to this terminal
watch(() => sessionStore.activeTheme, (newTheme) => {
if (newTheme) applyTheme();
});
function handleFocus(): void {
terminal.focus();
}

View File

@ -105,6 +105,7 @@
<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' }"
>
@ -187,6 +188,36 @@
<!-- Connection Edit Dialog (for File menu / Command Palette new connection) -->
<ConnectionEditDialog ref="connectionEditDialog" />
<!-- First-run: MobaXTerm import prompt -->
<Teleport to="body">
<div
v-if="showMobaPrompt"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div class="absolute inset-0 bg-black/50" @click="showMobaPrompt = false" />
<div class="relative w-full max-w-sm bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl p-6 space-y-4">
<h3 class="text-sm font-semibold text-[var(--wraith-text-primary)]">No connections found</h3>
<p class="text-xs text-[var(--wraith-text-secondary)]">
It looks like this is your first time running Wraith. Would you like to import connections from MobaXTerm?
</p>
<div class="flex gap-2 justify-end">
<button
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@click="showMobaPrompt = false"
>
Skip
</button>
<button
class="px-3 py-1.5 text-xs rounded bg-[#1f6feb] text-white hover:bg-[#388bfd] transition-colors cursor-pointer"
@click="() => { showMobaPrompt = false; importDialog?.open(); }"
>
Import from MobaXTerm
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
@ -224,9 +255,13 @@ const sessionStore = useSessionStore();
// copilotStore removed
const sidebarWidth = ref(240);
const sidebarVisible = ref(true);
const sidebarTab = ref<SidebarTab>("connections");
const quickConnectInput = ref("");
/** Whether to show the MobaXTerm import prompt (first run, no connections). */
const showMobaPrompt = ref(false);
// Auto-switch to SFTP tab when an SSH session becomes active
watch(() => sessionStore.activeSession, (session) => {
if (session && session.protocol === "ssh") {
@ -294,6 +329,8 @@ async function handleOpenFile(entry: FileEntry): Promise<void> {
/** 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);
}
/**
@ -375,10 +412,70 @@ async function handleQuickConnect(): Promise<void> {
/** Global keyboard shortcut handler. */
function handleKeydown(event: KeyboardEvent): void {
// Ctrl+K or Cmd+K open command palette
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
// 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;
}
}
@ -386,6 +483,11 @@ onMounted(async () => {
document.addEventListener("keydown", handleKeydown);
// Load connections and groups from the Go backend after vault unlock
await connectionStore.loadAll();
// First-run: if no connections found, offer to import from MobaXTerm
if (connectionStore.connections.length === 0) {
showMobaPrompt.value = true;
}
});
onUnmounted(() => {

View File

@ -2,6 +2,7 @@ import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { Call } from "@wailsio/runtime";
import { useConnectionStore } from "@/stores/connection.store";
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
@ -11,6 +12,12 @@ export interface Session {
name: string;
protocol: "ssh" | "rdp";
active: boolean;
username?: string;
}
export interface TerminalDimensions {
cols: number;
rows: number;
}
export const useSessionStore = defineStore("session", () => {
@ -19,6 +26,12 @@ export const useSessionStore = defineStore("session", () => {
const connecting = ref(false);
const lastError = ref<string | null>(null);
/** Active terminal theme — applied to all terminal instances. */
const activeTheme = ref<ThemeDefinition | null>(null);
/** Per-session terminal dimensions (cols x rows). */
const terminalDimensions = ref<Record<string, TerminalDimensions>>({});
const activeSession = computed(() =>
sessions.value.find((s) => s.id === activeSessionId.value) ?? null,
);
@ -54,26 +67,32 @@ export const useSessionStore = defineStore("session", () => {
}
}
/** Count how many sessions already exist for this connection (for tab name disambiguation). */
function sessionCountForConnection(connId: number): number {
return sessions.value.filter((s) => s.connectionId === connId).length;
}
/** Generate a disambiguated tab name like "Asgard", "Asgard (2)", "Asgard (3)". */
function disambiguatedName(baseName: string, connId: number): string {
const count = sessionCountForConnection(connId);
return count === 0 ? baseName : `${baseName} (${count + 1})`;
}
/**
* Connect to a server by connection ID.
* Calls the real Go backend to establish an SSH or RDP session.
* Multiple sessions to the same host are allowed (MobaXTerm-style).
* Each gets its own tab with a disambiguated name like "Asgard (2)".
*/
async function connect(connectionId: number): Promise<void> {
const connectionStore = useConnectionStore();
const conn = connectionStore.connections.find((c) => c.id === connectionId);
if (!conn) return;
// Check if there's already an active session for this connection
const existing = sessions.value.find((s) => s.connectionId === connectionId);
if (existing) {
activeSessionId.value = existing.id;
return;
}
connecting.value = true;
try {
if (conn.protocol === "ssh") {
let sessionId: string;
let resolvedUsername: string | undefined;
try {
// Try with stored credentials first
@ -93,6 +112,7 @@ export const useSessionStore = defineStore("session", () => {
const password = prompt(`Password for ${username}@${conn.hostname}:`);
if (password === null) throw new Error("Connection cancelled");
resolvedUsername = username;
sessionId = await Call.ByName(
`${APP}.ConnectSSHWithPassword`,
connectionId,
@ -106,12 +126,23 @@ export const useSessionStore = defineStore("session", () => {
}
}
// Try to get username from connection options if not already resolved
if (!resolvedUsername && conn.options) {
try {
const opts = JSON.parse(conn.options);
if (opts?.username) resolvedUsername = opts.username;
} catch {
// ignore malformed options
}
}
sessions.value.push({
id: sessionId,
connectionId,
name: conn.name,
name: disambiguatedName(conn.name, connectionId),
protocol: "ssh",
active: true,
username: resolvedUsername,
});
activeSessionId.value = sessionId;
} else if (conn.protocol === "rdp") {
@ -126,7 +157,7 @@ export const useSessionStore = defineStore("session", () => {
sessions.value.push({
id: sessionId,
connectionId,
name: conn.name,
name: disambiguatedName(conn.name, connectionId),
protocol: "rdp",
active: true,
});
@ -143,6 +174,22 @@ export const useSessionStore = defineStore("session", () => {
}
}
/** Apply a theme to all active terminal instances. */
function setTheme(theme: ThemeDefinition): void {
activeTheme.value = theme;
}
/** Update the recorded dimensions for a terminal session. */
function setTerminalDimensions(sessionId: string, cols: number, rows: number): void {
terminalDimensions.value[sessionId] = { cols, rows };
}
/** Get the dimensions for the active session, or null if not tracked yet. */
const activeDimensions = computed<TerminalDimensions | null>(() => {
if (!activeSessionId.value) return null;
return terminalDimensions.value[activeSessionId.value] ?? null;
});
return {
sessions,
activeSessionId,
@ -150,8 +197,13 @@ export const useSessionStore = defineStore("session", () => {
sessionCount,
connecting,
lastError,
activeTheme,
terminalDimensions,
activeDimensions,
activateSession,
closeSession,
connect,
setTheme,
setTerminalDimensions,
};
});