wraith/src/components/common/SettingsModal.vue
Vantz Stockwell 28619bba3f
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m49s
refactor: Vue 3 state, TypeScript, and lifecycle improvements
- connectionsByGroup memoized as computed map — eliminates redundant filter on every render
- DockerPanel: replace any[] with typed DockerContainer/Image/Volume interfaces
- useRdp: replace ReturnType<typeof ref<boolean>> with Ref<boolean>
- SettingsModal: debounce sidebarWidth slider watch (300ms) to prevent rapid IPC calls
- useSftp: export cleanupSession() to prevent sessionPaths memory leak
- StatusBar, CommandPalette: migrate defineEmits to Vue 3.3+ tuple syntax
- SidebarToggle: replace manual v-model (defineProps + defineEmits) with defineModel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:53:17 -04:00

523 lines
23 KiB
Vue

<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center"
@click.self="close"
@keydown.esc="close"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="close" />
<!-- Modal -->
<div class="relative w-full max-w-lg bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[#30363d]">
<h3 class="text-sm font-semibold text-[var(--wraith-text-primary)]">Settings</h3>
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@click="close"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.749.749 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.749.749 0 1 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
</div>
<!-- Body -->
<div class="flex min-h-[400px] max-h-[70vh]">
<!-- Section tabs (left sidebar) -->
<div class="w-36 border-r border-[#30363d] py-2 shrink-0">
<button
v-for="section in sections"
:key="section.id"
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left transition-colors cursor-pointer"
:class="activeSection === section.id
? 'bg-[#1f6feb]/20 text-[var(--wraith-text-primary)] border-r-2 border-[#1f6feb]'
: 'text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)]'
"
@click="activeSection = section.id"
>
<span v-html="section.icon" />
{{ section.label }}
</button>
</div>
<!-- Section content (right panel) -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<!-- General -->
<template v-if="activeSection === 'general'">
<h4 class="text-xs font-semibold text-[var(--wraith-text-muted)] uppercase tracking-wider mb-3">General</h4>
<!-- Default protocol -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Default Protocol</label>
<select
v-model="settings.defaultProtocol"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
>
<option value="ssh">SSH</option>
<option value="rdp">RDP</option>
</select>
</div>
<!-- Sidebar width -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">
Sidebar Width: {{ settings.sidebarWidth }}px
</label>
<input
v-model.number="settings.sidebarWidth"
type="range"
min="180"
max="400"
step="10"
class="w-full accent-[var(--wraith-accent-blue)]"
/>
</div>
</template>
<!-- Terminal -->
<template v-if="activeSection === 'terminal'">
<h4 class="text-xs font-semibold text-[var(--wraith-text-muted)] uppercase tracking-wider mb-3">Terminal</h4>
<!-- Default theme -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Default Theme</label>
<select
v-model="settings.terminalTheme"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
>
<option v-for="theme in themeNames" :key="theme" :value="theme">{{ theme }}</option>
</select>
</div>
<!-- Font size -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Font Size</label>
<input
v-model.number="settings.fontSize"
type="number"
min="8"
max="32"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<!-- Scrollback buffer -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Scrollback Buffer (lines)</label>
<input
v-model.number="settings.scrollbackBuffer"
type="number"
min="100"
max="100000"
step="100"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
</template>
<!-- Vault -->
<template v-if="activeSection === 'vault'">
<h4 class="text-xs font-semibold text-[var(--wraith-text-muted)] uppercase tracking-wider mb-3">Vault</h4>
<!-- Change master password -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-2">Master Password</label>
<button
class="px-3 py-2 text-xs text-[var(--wraith-text-primary)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
@click="changeMasterPassword"
>
Change Master Password
</button>
</div>
<!-- Export vault -->
<div class="pt-2">
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-2">Backup</label>
<div class="flex gap-2">
<button
class="px-3 py-2 text-xs text-[var(--wraith-text-primary)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
@click="exportVault"
>
Export Vault
</button>
<button
class="px-3 py-2 text-xs text-[var(--wraith-text-primary)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
@click="importVault"
>
Import Vault
</button>
</div>
</div>
</template>
<!-- AI Copilot -->
<template v-if="activeSection === 'copilot'">
<h4 class="text-xs font-semibold text-[var(--wraith-text-muted)] uppercase tracking-wider mb-3">Launch Presets</h4>
<p class="text-[10px] text-[var(--wraith-text-muted)] mb-3">
Configure quick-launch buttons for the AI copilot panel. Each preset spawns a shell and runs the command.
</p>
<div class="space-y-2">
<div
v-for="(preset, idx) in copilotPresets"
:key="idx"
class="flex items-center gap-2"
>
<input
v-model="preset.name"
type="text"
placeholder="Name"
class="w-24 px-2 py-1 text-xs rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)]"
/>
<input
v-model="preset.command"
type="text"
placeholder="Command (e.g. claude --dangerously-skip-permissions)"
class="flex-1 px-2 py-1 text-xs rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] font-mono"
/>
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)] transition-colors cursor-pointer text-sm"
@click="copilotPresets.splice(idx, 1)"
>
&times;
</button>
</div>
</div>
<div class="flex gap-2 mt-3">
<button
class="px-3 py-1.5 text-xs rounded border border-[#30363d] text-[var(--wraith-text-secondary)] hover:bg-[#30363d] transition-colors cursor-pointer"
@click="copilotPresets.push({ name: '', shell: '', command: '' })"
>
+ Add Preset
</button>
<button
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-accent-blue)] text-black font-bold transition-colors cursor-pointer"
@click="saveCopilotPresets"
>
Save
</button>
</div>
</template>
<!-- About -->
<template v-if="activeSection === 'about'">
<h4 class="text-xs font-semibold text-[var(--wraith-text-muted)] uppercase tracking-wider mb-3">About</h4>
<div class="space-y-3">
<div class="bg-[#0d1117] rounded-lg p-4 text-center">
<div class="text-lg font-bold tracking-widest text-[var(--wraith-accent-blue)] mb-1">WRAITH</div>
<div class="text-xs text-[var(--wraith-text-secondary)]">Connection Manager</div>
</div>
<div class="space-y-2 text-xs">
<div class="flex justify-between py-1.5 border-b border-[#30363d]">
<span class="text-[var(--wraith-text-secondary)]">Version</span>
<span class="text-[var(--wraith-text-primary)]">{{ currentVersion }}</span>
</div>
<div class="flex justify-between py-1.5 border-b border-[#30363d]">
<span class="text-[var(--wraith-text-secondary)]">License</span>
<span class="text-[var(--wraith-text-primary)]">Proprietary</span>
</div>
<div class="flex justify-between py-1.5 border-b border-[#30363d]">
<span class="text-[var(--wraith-text-secondary)]">Runtime</span>
<span class="text-[var(--wraith-text-primary)]">Tauri v2</span>
</div>
<div class="flex justify-between py-1.5">
<span class="text-[var(--wraith-text-secondary)]">Frontend</span>
<span class="text-[var(--wraith-text-primary)]">Vue 3 + TypeScript</span>
</div>
</div>
<!-- Update check -->
<div class="pt-2">
<button
class="w-full px-3 py-2 text-xs font-bold rounded bg-[var(--wraith-accent-blue)] text-black cursor-pointer disabled:opacity-40"
:disabled="updateChecking"
@click="checkUpdates"
>
{{ updateChecking ? "Checking..." : "Check for Updates" }}
</button>
<div v-if="updateInfo" class="mt-2 p-3 rounded bg-[#0d1117] border border-[#30363d]">
<template v-if="updateInfo.updateAvailable">
<p class="text-xs text-[#3fb950] mb-1">Update available: v{{ updateInfo.latestVersion }}</p>
<p v-if="updateInfo.releaseNotes" class="text-[10px] text-[var(--wraith-text-muted)] mb-2 max-h-20 overflow-auto">{{ updateInfo.releaseNotes }}</p>
<button
class="w-full px-3 py-1.5 text-xs font-bold rounded bg-[#238636] text-white cursor-pointer"
@click="downloadUpdate"
>
Download v{{ updateInfo.latestVersion }}
</button>
</template>
<p v-else class="text-xs text-[var(--wraith-text-muted)]">You're on the latest version.</p>
</div>
</div>
<div class="flex gap-2 pt-2">
<a
href="#"
class="flex-1 px-3 py-2 text-xs text-center text-[var(--wraith-accent-blue)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
@click.prevent="openLink('docs')"
>
Documentation
</a>
<a
href="#"
class="flex-1 px-3 py-2 text-xs text-center text-[var(--wraith-accent-blue)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
@click.prevent="openLink('repo')"
>
Source Code
</a>
</div>
</div>
</template>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end px-4 py-3 border-t border-[#30363d]">
<button
class="px-3 py-1.5 text-xs text-white bg-[#1f6feb] hover:bg-[#388bfd] rounded transition-colors cursor-pointer"
@click="close"
>
Done
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { open as shellOpen } from "@tauri-apps/plugin-shell";
type Section = "general" | "terminal" | "vault" | "copilot" | "about";
interface CopilotPreset { name: string; shell: string; command: string; }
const visible = ref(false);
const activeSection = ref<Section>("general");
const copilotPresets = ref<CopilotPreset[]>([]);
interface UpdateCheckInfo {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
downloadUrl: string;
releaseNotes: string;
}
const updateChecking = ref(false);
const updateInfo = ref<UpdateCheckInfo | null>(null);
async function checkUpdates(): Promise<void> {
updateChecking.value = true;
updateInfo.value = null;
try {
updateInfo.value = await invoke<UpdateCheckInfo>("check_for_updates");
} catch (err) {
alert(`Update check failed: ${err}`);
}
updateChecking.value = false;
}
async function downloadUpdate(): Promise<void> {
if (!updateInfo.value?.downloadUrl) return;
try {
await shellOpen(updateInfo.value.downloadUrl);
} catch {
window.open(updateInfo.value.downloadUrl, "_blank");
}
}
const currentVersion = ref("loading...");
const sections = [
{
id: "general" as const,
label: "General",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><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>`,
},
{
id: "terminal" as const,
label: "Terminal",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 1 1-1.06-1.06L5.44 8 3.72 6.28a.749.749 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z"/></svg>`,
},
{
id: "vault" as const,
label: "Vault",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M4 4v2h-.25A1.75 1.75 0 0 0 2 7.75v5.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0 0 14 13.25v-5.5A1.75 1.75 0 0 0 12.25 6H12V4a4 4 0 1 0-8 0Zm6.5 2V4a2.5 2.5 0 0 0-5 0v2ZM8 9.5a1.5 1.5 0 0 1 .5 2.915V13.5a.5.5 0 0 1-1 0v-1.085A1.5 1.5 0 0 1 8 9.5Z"/></svg>`,
},
{
id: "copilot" as const,
label: "AI Copilot",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M5.5 8.5 9 5l-2-.5L4 7.5l1.5 1ZM1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25Z"/></svg>`,
},
{
id: "about" as const,
label: "About",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/></svg>`,
},
];
// Theme names loaded from backend (populated in loadThemeNames)
const themeNames = ref<string[]>([
"Dracula",
"Nord",
"Monokai",
"One Dark",
"Solarized Dark",
"Gruvbox Dark",
"MobaXTerm Classic",
]);
const settings = ref({
defaultProtocol: "ssh" as "ssh" | "rdp",
sidebarWidth: 240,
terminalTheme: "Dracula",
fontSize: 14,
scrollbackBuffer: 5000,
});
/** Load saved settings from Rust backend on mount. */
onMounted(async () => {
// Populate version from Tauri app config
try { currentVersion.value = await getVersion(); } catch { currentVersion.value = "unknown"; }
try {
const [protocol, sidebarW, theme, fontSize, scrollback] = await Promise.all([
invoke<string | null>("get_setting", { key: "default_protocol" }),
invoke<string | null>("get_setting", { key: "sidebar_width" }),
invoke<string | null>("get_setting", { key: "terminal_theme" }),
invoke<string | null>("get_setting", { key: "font_size" }),
invoke<string | null>("get_setting", { key: "scrollback_buffer" }),
]);
if (protocol) settings.value.defaultProtocol = protocol as "ssh" | "rdp";
if (sidebarW) settings.value.sidebarWidth = Number(sidebarW);
if (theme) settings.value.terminalTheme = theme;
if (fontSize) settings.value.fontSize = Number(fontSize);
if (scrollback) settings.value.scrollbackBuffer = Number(scrollback);
} catch (err) {
console.error("SettingsModal: failed to load settings:", err);
}
// Load theme names from backend for the terminal theme dropdown
try {
const themes = await invoke<Array<{ name: string }>>("list_themes");
if (themes && themes.length > 0) {
themeNames.value = themes.map((t) => t.name);
}
} catch {
// Keep the hardcoded fallback list
}
});
/** Persist settings changes to Rust backend as they change. */
watch(
() => settings.value.defaultProtocol,
(val) => invoke("set_setting", { key: "default_protocol", value: val }).catch(console.error),
);
let sidebarWidthDebounce: ReturnType<typeof setTimeout>;
watch(
() => settings.value.sidebarWidth,
(val) => {
clearTimeout(sidebarWidthDebounce);
sidebarWidthDebounce = setTimeout(
() => invoke("set_setting", { key: "sidebar_width", value: String(val) }).catch(console.error),
300,
);
},
);
watch(
() => settings.value.terminalTheme,
(val) => invoke("set_setting", { key: "terminal_theme", value: val }).catch(console.error),
);
watch(
() => settings.value.fontSize,
(val) => invoke("set_setting", { key: "font_size", value: String(val) }).catch(console.error),
);
watch(
() => settings.value.scrollbackBuffer,
(val) => invoke("set_setting", { key: "scrollback_buffer", value: String(val) }).catch(console.error),
);
function open(): void {
visible.value = true;
activeSection.value = "general";
loadCopilotPresets();
}
async function loadCopilotPresets(): Promise<void> {
try {
const raw = await invoke<string | null>("get_setting", { key: "copilot_presets" });
if (raw) {
copilotPresets.value = JSON.parse(raw);
} else {
copilotPresets.value = [
{ name: "Claude Code", shell: "", command: "claude" },
{ name: "Gemini CLI", shell: "", command: "gemini" },
{ name: "Codex CLI", shell: "", command: "codex" },
];
}
} catch {
copilotPresets.value = [];
}
}
async function saveCopilotPresets(): Promise<void> {
try {
const json = JSON.stringify(copilotPresets.value.filter(p => p.name && p.command));
await invoke("set_setting", { key: "copilot_presets", value: json });
} catch (err) {
console.error("Failed to save copilot presets:", err);
}
}
function close(): void {
visible.value = false;
}
async function changeMasterPassword(): Promise<void> {
const oldPw = prompt("Current master password:");
if (!oldPw) return;
const newPw = prompt("New master password:");
if (!newPw) return;
const confirmPw = prompt("Confirm new master password:");
if (newPw !== confirmPw) {
alert("Passwords do not match.");
return;
}
try {
await invoke("unlock", { password: oldPw });
await invoke("create_vault", { password: newPw });
alert("Master password changed successfully.");
} catch (err) {
alert(`Failed to change password: ${err}`);
}
}
function exportVault(): void {
alert("Export vault is not yet available. Your data is stored in %APPDATA%\\Wraith\\wraith.db");
}
function importVault(): void {
alert("Import vault is not yet available. Copy wraith.db to %APPDATA%\\Wraith\\ to restore.");
}
function openLink(target: string): void {
const urls: Record<string, string> = {
docs: "https://github.com/wraith/docs",
repo: "https://github.com/wraith",
};
const url = urls[target] ?? target;
shellOpen(url).catch(console.error);
}
defineExpose({ open, close, visible });
</script>