Compare commits

..

2 Commits

Author SHA1 Message Date
Vantz Stockwell
a6db3ddfa4 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>
2026-03-17 13:41:28 -04:00
Vantz Stockwell
9d19147568 chore: remove unused deps, add connection_history migration, wire plugin registry
- Remove naive-ui and @xterm/addon-webgl from frontend deps — neither is
  imported anywhere in frontend/src; the entire UI is hand-rolled Tailwind
  and the terminal uses only FitAddon/SearchAddon/WebLinksAddon (22 packages
  removed, 0 vulnerabilities)
- Add 003_connection_history.sql migration — CREATE TABLE IF NOT EXISTS so
  it is safe and idempotent on existing databases; tracks per-connection
  session duration for frequency/history analytics
- Wire MobaConfImporter into the plugin registry in app.New() so the
  registry is no longer empty at runtime; ImportMobaConf continues to call
  the importer directly (GetImporter key is "MobaXTerm", not "mobaconf")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 13:39:44 -04:00
9 changed files with 277 additions and 227 deletions

View File

@ -17,10 +17,8 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"naive-ui": "^2.40.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
@ -256,30 +254,6 @@
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@css-render/plugin-bem": {
"version": "0.15.14",
"resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
"integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
"license": "MIT",
"peerDependencies": {
"css-render": "~0.15.14"
}
},
"node_modules/@css-render/vue3-ssr": {
"version": "0.15.14",
"resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
"integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.11"
}
},
"node_modules/@emotion/hash": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -771,12 +745,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@juggle/resize-observer": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
"license": "Apache-2.0"
},
"node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
@ -1552,21 +1520,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@ -1776,12 +1729,6 @@
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/addon-webgl": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
@ -1798,12 +1745,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -1842,47 +1783,12 @@
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/css-render": {
"version": "0.15.14",
"resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz",
"integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
"license": "MIT",
"dependencies": {
"@emotion/hash": "~0.8.0",
"csstype": "~3.0.5"
}
},
"node_modules/css-render/node_modules/csstype": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz",
"integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-tz": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
"license": "MIT",
"peerDependencies": {
"date-fns": "^3.0.0 || ^4.0.0"
}
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@ -1974,12 +1880,6 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/evtd": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz",
"integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2030,15 +1930,6 @@
"he": "bin/he"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@ -2322,18 +2213,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -2366,38 +2245,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/naive-ui": {
"version": "2.44.1",
"resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.44.1.tgz",
"integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==",
"license": "MIT",
"dependencies": {
"@css-render/plugin-bem": "^0.15.14",
"@css-render/vue3-ssr": "^0.15.14",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"async-validator": "^4.2.5",
"css-render": "^0.15.14",
"csstype": "^3.1.3",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"evtd": "^0.2.4",
"highlight.js": "^11.8.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"seemly": "^0.3.10",
"treemate": "^0.3.11",
"vdirs": "^0.1.8",
"vooks": "^0.2.12",
"vueuc": "^0.4.65"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -2537,12 +2384,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/seemly": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz",
"integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2596,12 +2437,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/treemate": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz",
"integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -2616,18 +2451,6 @@
"node": ">=14.17"
}
},
"node_modules/vdirs": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz",
"integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==",
"license": "MIT",
"dependencies": {
"evtd": "^0.2.2"
},
"peerDependencies": {
"vue": "^3.0.11"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
@ -2703,18 +2526,6 @@
}
}
},
"node_modules/vooks": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz",
"integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==",
"license": "MIT",
"dependencies": {
"evtd": "^0.2.2"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@ -2801,24 +2612,6 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vueuc": {
"version": "0.4.65",
"resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz",
"integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==",
"license": "MIT",
"dependencies": {
"@css-render/vue3-ssr": "^0.15.10",
"@juggle/resize-observer": "^3.3.1",
"css-render": "^0.15.10",
"evtd": "^0.2.4",
"seemly": "^0.3.6",
"vdirs": "^0.1.4",
"vooks": "^0.2.4"
},
"peerDependencies": {
"vue": "^3.0.11"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",

View File

@ -20,10 +20,8 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"naive-ui": "^2.40.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"

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,
};
});

View File

@ -45,6 +45,7 @@ type WraithApp struct {
Credentials *credentials.CredentialService
AI *ai.AIService
Updater *updater.UpdateService
Workspace *WorkspaceService
oauthMgr *ai.OAuthManager
wailsApp *application.App
unlocked bool
@ -79,18 +80,31 @@ func New(version string) (*WraithApp, error) {
themeSvc := theme.NewThemeService(database)
sessionMgr := session.NewManager()
pluginReg := plugin.NewRegistry()
pluginReg.RegisterImporter(&importer.MobaConfImporter{})
// Host key store — persists SSH host key fingerprints for TOFU verification
hostKeyStore := ssh.NewHostKeyStore(database)
// SSH output handler — emits Wails events to the frontend.
// The closure captures `app` (the WraithApp being built). The wailsApp
// field is set after application.New() in main.go, but SSH sessions only
// start after app.Run(), so wailsApp is always valid at call time.
var app *WraithApp
sshSvc := ssh.NewSSHService(database, func(sessionID string, data []byte) {
sshOutputHandler := func(sessionID string, data []byte) {
if app != nil && app.wailsApp != nil {
// Base64 encode binary data for safe transport over Wails events
app.wailsApp.Event.Emit("ssh:data:"+sessionID, base64.StdEncoding.EncodeToString(data))
}
})
}
// CWD handler — emits Wails events when the remote working directory changes
sshCWDHandler := func(sessionID string, path string) {
if app != nil && app.wailsApp != nil {
app.wailsApp.Event.Emit("ssh:cwd:"+sessionID, path)
}
}
sshSvc := ssh.NewSSHService(database, hostKeyStore, sshOutputHandler, sshCWDHandler)
sftpSvc := sftp.NewSFTPService()
// RDP service with platform-aware backend factory.
@ -118,6 +132,17 @@ func New(version string) (*WraithApp, error) {
}
updaterSvc := updater.NewUpdateService(version)
workspaceSvc := NewWorkspaceService(settingsSvc)
// Clear the clean shutdown flag on startup — it will be re-set on clean exit.
// If it wasn't set, the previous run crashed and the workspace can be restored.
wasClean := workspaceSvc.WasCleanShutdown()
if err := workspaceSvc.ClearCleanShutdown(); err != nil {
slog.Warn("failed to clear clean shutdown flag", "error", err)
}
if !wasClean {
slog.Info("previous shutdown was not clean — workspace restore available")
}
app = &WraithApp{
db: database,
@ -131,6 +156,7 @@ func New(version string) (*WraithApp, error) {
RDP: rdpSvc,
AI: aiSvc,
Updater: updaterSvc,
Workspace: workspaceSvc,
oauthMgr: oauthMgr,
}
return app, nil
@ -337,6 +363,17 @@ func (a *WraithApp) ConnectSSH(connectionID int64, cols, rows int) (string, erro
slog.Warn("failed to update last_connected", "error", err)
}
// Register with session manager
if _, err := a.Sessions.Create(connectionID, "ssh"); err != nil {
slog.Warn("failed to register SSH session in manager", "error", err)
} else {
// Store the SSH session ID as the manager session ID for lookup
a.Sessions.SetState(sessionID, session.StateConnected)
}
// Save workspace state after session change
a.saveWorkspaceState()
slog.Info("SSH session started", "sessionID", sessionID, "host", conn.Hostname, "user", username)
return sessionID, nil
}

View File

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS connection_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
protocol TEXT NOT NULL,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
disconnected_at DATETIME,
duration_secs INTEGER
);