feat: Phase 5 complete — themes, editor, shortcuts, workspace, settings

Theme service: 7 built-in themes seeded from Rust, ThemePicker
loads from backend. Workspace service: save/load snapshots, crash
recovery detection. SettingsModal: full port with Tauri invoke.
CommandPalette, HostKeyDialog, ConnectionEditDialog all ported.

CodeMirror editor: inline above terminal, reads/writes via SFTP.
Full keyboard shortcuts: Ctrl+K/W/Tab/Shift+Tab/1-9/B/F.
Terminal search bar via Ctrl+F (SearchAddon).
Tab badges: protocol dots, ROOT warning, environment pills.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 16:36:06 -04:00
parent c75da74ecd
commit 0cd4cc0f64
17 changed files with 2489 additions and 81 deletions

View File

@ -5,3 +5,4 @@ pub mod credentials;
pub mod ssh_commands; pub mod ssh_commands;
pub mod sftp_commands; pub mod sftp_commands;
pub mod rdp_commands; pub mod rdp_commands;
pub mod theme_commands;

View File

@ -0,0 +1,18 @@
use tauri::State;
use crate::theme::Theme;
use crate::AppState;
/// Return all themes ordered by built-in first, then name.
#[tauri::command]
pub fn list_themes(state: State<'_, AppState>) -> Vec<Theme> {
state.theme.list()
}
/// Fetch a single theme by exact name.
///
/// Returns `None` if no theme with that name exists.
#[tauri::command]
pub fn get_theme(name: String, state: State<'_, AppState>) -> Option<Theme> {
state.theme.get_by_name(&name)
}

View File

@ -6,6 +6,8 @@ pub mod credentials;
pub mod ssh; pub mod ssh;
pub mod sftp; pub mod sftp;
pub mod rdp; pub mod rdp;
pub mod theme;
pub mod workspace;
pub mod commands; pub mod commands;
use std::path::PathBuf; use std::path::PathBuf;
@ -19,6 +21,8 @@ use connections::ConnectionService;
use sftp::SftpService; use sftp::SftpService;
use ssh::session::SshService; use ssh::session::SshService;
use rdp::RdpService; use rdp::RdpService;
use theme::ThemeService;
use workspace::WorkspaceService;
/// Application state shared across all Tauri commands via State<AppState>. /// Application state shared across all Tauri commands via State<AppState>.
pub struct AppState { pub struct AppState {
@ -30,6 +34,8 @@ pub struct AppState {
pub ssh: SshService, pub ssh: SshService,
pub sftp: SftpService, pub sftp: SftpService,
pub rdp: RdpService, pub rdp: RdpService,
pub theme: ThemeService,
pub workspace: WorkspaceService,
} }
impl AppState { impl AppState {
@ -45,6 +51,12 @@ impl AppState {
let ssh = SshService::new(database.clone()); let ssh = SshService::new(database.clone());
let sftp = SftpService::new(); let sftp = SftpService::new();
let rdp = RdpService::new(); let rdp = RdpService::new();
let theme = ThemeService::new(database.clone());
// WorkspaceService shares the same SettingsService interface; we clone
// the Database to construct a second SettingsService for the workspace
// module so it can remain self-contained.
let workspace_settings = SettingsService::new(database.clone());
let workspace = WorkspaceService::new(workspace_settings);
Ok(Self { Ok(Self {
db: database, db: database,
@ -55,6 +67,8 @@ impl AppState {
ssh, ssh,
sftp, sftp,
rdp, rdp,
theme,
workspace,
}) })
} }
@ -94,6 +108,23 @@ pub fn run() {
let app_state = AppState::new(data_dir) let app_state = AppState::new(data_dir)
.expect("Failed to initialize application state"); .expect("Failed to initialize application state");
// Seed built-in themes (INSERT OR IGNORE — safe to call on every boot).
app_state.theme.seed_builtins();
// Crash recovery detection: log dirty shutdowns so they can be acted on.
if app_state.workspace.was_clean_shutdown() {
app_state
.workspace
.clear_clean_shutdown()
.unwrap_or_else(|e| eprintln!("workspace: failed to clear clean-shutdown flag: {e}"));
} else {
// No clean-shutdown flag found — either first run or a crash/kill.
// Only log if a snapshot exists (i.e. there were open tabs last time).
if app_state.workspace.load().is_some() {
eprintln!("workspace: dirty shutdown detected — a previous session may not have exited cleanly");
}
}
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.manage(app_state) .manage(app_state)
@ -136,6 +167,8 @@ pub fn run() {
commands::rdp_commands::rdp_send_key, commands::rdp_commands::rdp_send_key,
commands::rdp_commands::disconnect_rdp, commands::rdp_commands::disconnect_rdp,
commands::rdp_commands::list_rdp_sessions, commands::rdp_commands::list_rdp_sessions,
commands::theme_commands::list_themes,
commands::theme_commands::get_theme,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

339
src-tauri/src/theme/mod.rs Normal file
View File

@ -0,0 +1,339 @@
use rusqlite::params;
use serde::{Deserialize, Serialize};
use crate::db::Database;
// ── domain types ─────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Theme {
pub id: i64,
pub name: String,
pub foreground: String,
pub background: String,
pub cursor: String,
pub black: String,
pub red: String,
pub green: String,
pub yellow: String,
pub blue: String,
pub magenta: String,
pub cyan: String,
pub white: String,
pub bright_black: String,
pub bright_red: String,
pub bright_green: String,
pub bright_yellow: String,
pub bright_blue: String,
pub bright_magenta: String,
pub bright_cyan: String,
pub bright_white: String,
pub is_builtin: bool,
}
// ── helper struct for seed data ───────────────────────────────────────────────
struct BuiltinTheme {
name: &'static str,
foreground: &'static str,
background: &'static str,
cursor: &'static str,
black: &'static str,
red: &'static str,
green: &'static str,
yellow: &'static str,
blue: &'static str,
magenta: &'static str,
cyan: &'static str,
white: &'static str,
bright_black: &'static str,
bright_red: &'static str,
bright_green: &'static str,
bright_yellow: &'static str,
bright_blue: &'static str,
bright_magenta: &'static str,
bright_cyan: &'static str,
bright_white: &'static str,
}
// ── service ───────────────────────────────────────────────────────────────────
pub struct ThemeService {
db: Database,
}
impl ThemeService {
pub fn new(db: Database) -> Self {
Self { db }
}
/// Insert the 7 built-in themes if they don't exist yet.
///
/// Uses INSERT OR IGNORE so re-running on an existing database is safe.
pub fn seed_builtins(&self) {
let themes: &[BuiltinTheme] = &[
BuiltinTheme {
name: "Dracula",
foreground: "#f8f8f2",
background: "#282a36",
cursor: "#f8f8f2",
black: "#21222c",
red: "#ff5555",
green: "#50fa7b",
yellow: "#f1fa8c",
blue: "#bd93f9",
magenta: "#ff79c6",
cyan: "#8be9fd",
white: "#f8f8f2",
bright_black: "#6272a4",
bright_red: "#ff6e6e",
bright_green: "#69ff94",
bright_yellow: "#ffffa5",
bright_blue: "#d6acff",
bright_magenta: "#ff92df",
bright_cyan: "#a4ffff",
bright_white: "#ffffff",
},
BuiltinTheme {
name: "Nord",
foreground: "#d8dee9",
background: "#2e3440",
cursor: "#d8dee9",
black: "#3b4252",
red: "#bf616a",
green: "#a3be8c",
yellow: "#ebcb8b",
blue: "#81a1c1",
magenta: "#b48ead",
cyan: "#88c0d0",
white: "#e5e9f0",
bright_black: "#4c566a",
bright_red: "#bf616a",
bright_green: "#a3be8c",
bright_yellow: "#ebcb8b",
bright_blue: "#81a1c1",
bright_magenta: "#b48ead",
bright_cyan: "#8fbcbb",
bright_white: "#eceff4",
},
BuiltinTheme {
name: "Monokai",
foreground: "#f8f8f2",
background: "#272822",
cursor: "#f8f8f0",
black: "#272822",
red: "#f92672",
green: "#a6e22e",
yellow: "#f4bf75",
blue: "#66d9ef",
magenta: "#ae81ff",
cyan: "#a1efe4",
white: "#f8f8f2",
bright_black: "#75715e",
bright_red: "#f92672",
bright_green: "#a6e22e",
bright_yellow: "#f4bf75",
bright_blue: "#66d9ef",
bright_magenta: "#ae81ff",
bright_cyan: "#a1efe4",
bright_white: "#f9f8f5",
},
BuiltinTheme {
name: "One Dark",
foreground: "#abb2bf",
background: "#282c34",
cursor: "#528bff",
black: "#282c34",
red: "#e06c75",
green: "#98c379",
yellow: "#e5c07b",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
bright_black: "#545862",
bright_red: "#e06c75",
bright_green: "#98c379",
bright_yellow: "#e5c07b",
bright_blue: "#61afef",
bright_magenta: "#c678dd",
bright_cyan: "#56b6c2",
bright_white: "#c8ccd4",
},
BuiltinTheme {
name: "Solarized Dark",
foreground: "#839496",
background: "#002b36",
cursor: "#839496",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
bright_black: "#002b36",
bright_red: "#cb4b16",
bright_green: "#586e75",
bright_yellow: "#657b83",
bright_blue: "#839496",
bright_magenta: "#6c71c4",
bright_cyan: "#93a1a1",
bright_white: "#fdf6e3",
},
BuiltinTheme {
name: "Gruvbox Dark",
foreground: "#ebdbb2",
background: "#282828",
cursor: "#ebdbb2",
black: "#282828",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#a89984",
bright_black: "#928374",
bright_red: "#fb4934",
bright_green: "#b8bb26",
bright_yellow: "#fabd2f",
bright_blue: "#83a598",
bright_magenta: "#d3869b",
bright_cyan: "#8ec07c",
bright_white: "#ebdbb2",
},
BuiltinTheme {
name: "MobaXTerm Classic",
foreground: "#ececec",
background: "#242424",
cursor: "#b4b4c0",
black: "#000000",
red: "#aa4244",
green: "#7e8d53",
yellow: "#e4b46d",
blue: "#6e9aba",
magenta: "#9e5085",
cyan: "#80d5cf",
white: "#cccccc",
bright_black: "#808080",
bright_red: "#cc7b7d",
bright_green: "#a5b17c",
bright_yellow: "#ecc995",
bright_blue: "#96b6cd",
bright_magenta: "#c083ac",
bright_cyan: "#a9e2de",
bright_white: "#cccccc",
},
];
let conn = self.db.conn();
for t in themes {
if let Err(e) = conn.execute(
"INSERT OR IGNORE INTO themes (
name, foreground, background, cursor,
black, red, green, yellow, blue, magenta, cyan, white,
bright_black, bright_red, bright_green, bright_yellow,
bright_blue, bright_magenta, bright_cyan, bright_white,
is_builtin
) VALUES (
?1, ?2, ?3, ?4,
?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
?13, ?14, ?15, ?16,
?17, ?18, ?19, ?20,
1
)",
params![
t.name, t.foreground, t.background, t.cursor,
t.black, t.red, t.green, t.yellow,
t.blue, t.magenta, t.cyan, t.white,
t.bright_black, t.bright_red, t.bright_green, t.bright_yellow,
t.bright_blue, t.bright_magenta, t.bright_cyan, t.bright_white,
],
) {
eprintln!("theme::seed_builtins: failed to seed '{}': {}", t.name, e);
}
}
}
/// Return all themes ordered by built-in first, then name.
pub fn list(&self) -> Vec<Theme> {
let conn = self.db.conn();
let mut stmt = match conn.prepare(
"SELECT id, name, foreground, background, cursor,
black, red, green, yellow, blue, magenta, cyan, white,
bright_black, bright_red, bright_green, bright_yellow,
bright_blue, bright_magenta, bright_cyan, bright_white,
is_builtin
FROM themes
ORDER BY is_builtin DESC, name COLLATE NOCASE ASC",
) {
Ok(s) => s,
Err(e) => {
eprintln!("theme::list: failed to prepare query: {}", e);
return vec![];
}
};
match stmt.query_map([], map_theme_row) {
Ok(rows) => rows
.filter_map(|r| {
r.map_err(|e| eprintln!("theme::list: row error: {}", e))
.ok()
})
.collect(),
Err(e) => {
eprintln!("theme::list: query failed: {}", e);
vec![]
}
}
}
/// Look up a theme by exact name (case-sensitive).
pub fn get_by_name(&self, name: &str) -> Option<Theme> {
let conn = self.db.conn();
conn.query_row(
"SELECT id, name, foreground, background, cursor,
black, red, green, yellow, blue, magenta, cyan, white,
bright_black, bright_red, bright_green, bright_yellow,
bright_blue, bright_magenta, bright_cyan, bright_white,
is_builtin
FROM themes
WHERE name = ?1",
params![name],
map_theme_row,
)
.ok()
}
}
// ── private helpers ───────────────────────────────────────────────────────────
fn map_theme_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Theme> {
Ok(Theme {
id: row.get(0)?,
name: row.get(1)?,
foreground: row.get(2)?,
background: row.get(3)?,
cursor: row.get(4)?,
black: row.get(5)?,
red: row.get(6)?,
green: row.get(7)?,
yellow: row.get(8)?,
blue: row.get(9)?,
magenta: row.get(10)?,
cyan: row.get(11)?,
white: row.get(12)?,
bright_black: row.get(13)?,
bright_red: row.get(14)?,
bright_green: row.get(15)?,
bright_yellow: row.get(16)?,
bright_blue: row.get(17)?,
bright_magenta: row.get(18)?,
bright_cyan: row.get(19)?,
bright_white: row.get(20)?,
is_builtin: row.get(21)?,
})
}

View File

@ -0,0 +1,82 @@
use serde::{Deserialize, Serialize};
use crate::settings::SettingsService;
// ── domain types ─────────────────────────────────────────────────────────────
/// A single open tab that should be restored on next launch.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceTab {
pub connection_id: i64,
pub protocol: String,
pub position: i32,
}
/// The full workspace state persisted between sessions.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WorkspaceSnapshot {
pub tabs: Vec<WorkspaceTab>,
}
// ── service ───────────────────────────────────────────────────────────────────
const SNAPSHOT_KEY: &str = "workspace_snapshot";
const CLEAN_SHUTDOWN_KEY: &str = "clean_shutdown";
pub struct WorkspaceService {
settings: SettingsService,
}
impl WorkspaceService {
pub fn new(settings: SettingsService) -> Self {
Self { settings }
}
/// Serialize `snapshot` to JSON and persist it via the settings store.
pub fn save(&self, snapshot: &WorkspaceSnapshot) -> Result<(), String> {
let json = serde_json::to_string(snapshot)
.map_err(|e| format!("workspace::save: failed to serialize snapshot: {e}"))?;
self.settings.set(SNAPSHOT_KEY, &json)
}
/// Load and deserialize the last saved workspace snapshot.
///
/// Returns `None` if no snapshot has been saved yet or if deserialization
/// fails (treated as a fresh start rather than a hard error).
pub fn load(&self) -> Option<WorkspaceSnapshot> {
let json = self.settings.get(SNAPSHOT_KEY)?;
serde_json::from_str(&json)
.map_err(|e| eprintln!("workspace::load: failed to deserialize snapshot: {e}"))
.ok()
}
/// Record that the application is shutting down cleanly.
///
/// Call this just before the Tauri window closes normally. On the next
/// startup, `was_clean_shutdown()` will return `true` and the crash-
/// recovery path can be skipped.
pub fn mark_clean_shutdown(&self) -> Result<(), String> {
self.settings.set(CLEAN_SHUTDOWN_KEY, "true")
}
/// Returns `true` if the last session ended with a clean shutdown.
///
/// Returns `false` (crash / dirty shutdown) when the key is absent, empty,
/// or set to any value other than `"true"`.
pub fn was_clean_shutdown(&self) -> bool {
self.settings
.get(CLEAN_SHUTDOWN_KEY)
.map(|v| v == "true")
.unwrap_or(false)
}
/// Remove the clean-shutdown flag.
///
/// Call this at startup — immediately after reading `was_clean_shutdown()`.
/// The flag is only valid for the single launch following the shutdown that
/// wrote it; clearing it ensures a subsequent crash is detected correctly.
pub fn clear_clean_shutdown(&self) -> Result<(), String> {
self.settings.delete(CLEAN_SHUTDOWN_KEY)
}
}

View File

@ -0,0 +1,269 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
@click.self="close"
@keydown.esc="close"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="close" />
<!-- Palette -->
<div
ref="paletteRef"
class="relative w-full max-w-lg bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden animate-fade-in"
>
<!-- Search input -->
<div class="flex items-center px-4 py-3 border-b border-[#30363d]">
<svg class="w-4 h-4 text-[var(--wraith-text-muted)] mr-3 shrink-0" 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>
<input
ref="inputRef"
v-model="query"
type="text"
placeholder="Search connections, actions..."
class="flex-1 bg-transparent text-sm text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none"
@keydown.down.prevent="moveSelection(1)"
@keydown.up.prevent="moveSelection(-1)"
@keydown.enter.prevent="executeSelected"
/>
<kbd class="ml-2 px-1.5 py-0.5 text-[10px] text-[var(--wraith-text-muted)] bg-[#30363d] rounded border border-[#484f58]">ESC</kbd>
</div>
<!-- Results -->
<div class="max-h-80 overflow-y-auto py-1">
<!-- Connections group -->
<template v-if="filteredConnections.length > 0">
<div class="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--wraith-text-muted)]">
Connections
</div>
<button
v-for="(conn, idx) in filteredConnections"
:key="`conn-${conn.id}`"
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors cursor-pointer"
:class="selectedIndex === idx ? 'bg-[#1f6feb]/20 text-[var(--wraith-text-primary)]' : 'text-[var(--wraith-text-secondary)] hover:bg-[#30363d]'"
@click="connectTo(conn)"
@mouseenter="selectedIndex = idx"
>
<span
class="w-2 h-2 rounded-full shrink-0"
:class="conn.protocol === 'ssh' ? 'bg-[#3fb950]' : 'bg-[#1f6feb]'"
/>
<span class="flex-1 truncate">{{ conn.name }}</span>
<span class="text-xs text-[var(--wraith-text-muted)]">{{ conn.hostname }}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-[#30363d] text-[var(--wraith-text-muted)] uppercase">
{{ conn.protocol }}
</span>
</button>
</template>
<!-- Actions group -->
<template v-if="filteredActions.length > 0">
<div class="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--wraith-text-muted)]">
Actions
</div>
<button
v-for="(action, idx) in filteredActions"
:key="`action-${action.id}`"
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors cursor-pointer"
:class="selectedIndex === filteredConnections.length + idx ? 'bg-[#1f6feb]/20 text-[var(--wraith-text-primary)]' : 'text-[var(--wraith-text-secondary)] hover:bg-[#30363d]'"
@click="executeAction(action)"
@mouseenter="selectedIndex = filteredConnections.length + idx"
>
<span class="w-4 h-4 flex items-center justify-center text-[var(--wraith-text-muted)] shrink-0" v-html="action.icon" />
<span class="flex-1 truncate">{{ action.label }}</span>
<span v-if="action.shortcut" class="text-[10px] px-1.5 py-0.5 rounded bg-[#30363d] text-[var(--wraith-text-muted)]">
{{ action.shortcut }}
</span>
</button>
</template>
<!-- No results -->
<div
v-if="filteredConnections.length === 0 && filteredActions.length === 0"
class="px-4 py-8 text-center text-sm text-[var(--wraith-text-muted)]"
>
No results found
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { useConnectionStore, type Connection } from "@/stores/connection.store";
import { useSessionStore } from "@/stores/session.store";
interface PaletteAction {
id: string;
label: string;
icon: string;
shortcut?: string;
handler: () => void;
}
const visible = ref(false);
const query = ref("");
const selectedIndex = ref(0);
const inputRef = ref<HTMLInputElement | null>(null);
const paletteRef = ref<HTMLDivElement | null>(null);
const connectionStore = useConnectionStore();
const sessionStore = useSessionStore();
const emit = defineEmits<{
(e: "open-import"): void;
(e: "open-settings"): void;
(e: "open-new-connection", protocol?: "ssh" | "rdp"): void;
}>();
const actions: PaletteAction[] = [
{
id: "new-ssh",
label: "New SSH Connection",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4"><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>`,
handler: () => {
emit("open-new-connection", "ssh");
close();
},
},
{
id: "new-rdp",
label: "New RDP Connection",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4"><path d="M1.75 2.5h12.5a.25.25 0 0 1 .25.25v7.5a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25v-7.5a.25.25 0 0 1 .25-.25ZM14.25 1H1.75A1.75 1.75 0 0 0 0 2.75v7.5C0 11.216.784 12 1.75 12h4.388l-.533 1.5H4a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5h-1.605l-.533-1.5h4.388A1.75 1.75 0 0 0 16 10.25v-7.5A1.75 1.75 0 0 0 14.25 1ZM9.112 13.5H6.888l.533-1.5h1.158l.533 1.5Z"/></svg>`,
handler: () => {
emit("open-new-connection", "rdp");
close();
},
},
{
id: "open-vault",
label: "Open Vault",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4"><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>`,
handler: () => {
// TODO: Navigate to vault
close();
},
},
{
id: "settings",
label: "Settings",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4"><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>`,
handler: () => {
emit("open-settings");
close();
},
},
{
id: "import-moba",
label: "Import MobaXTerm Config",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4"><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>`,
handler: () => {
emit("open-import");
close();
},
},
];
/** Fuzzy match: checks if all characters of needle appear in order in haystack. */
function fuzzyMatch(needle: string, haystack: string): boolean {
const n = needle.toLowerCase();
const h = haystack.toLowerCase();
let ni = 0;
for (let hi = 0; hi < h.length && ni < n.length; hi++) {
if (h[hi] === n[ni]) ni++;
}
return ni === n.length;
}
const filteredConnections = computed(() => {
const q = query.value.trim();
if (!q) return connectionStore.connections.slice(0, 10);
return connectionStore.connections.filter(
(c) =>
fuzzyMatch(q, c.name) ||
fuzzyMatch(q, c.hostname) ||
c.tags?.some((t) => fuzzyMatch(q, t)),
);
});
const filteredActions = computed(() => {
const q = query.value.trim();
if (!q) return actions;
return actions.filter((a) => fuzzyMatch(q, a.label));
});
const totalItems = computed(() => filteredConnections.value.length + filteredActions.value.length);
function moveSelection(delta: number): void {
if (totalItems.value === 0) return;
selectedIndex.value = (selectedIndex.value + delta + totalItems.value) % totalItems.value;
}
function executeSelected(): void {
if (totalItems.value === 0) return;
const idx = selectedIndex.value;
if (idx < filteredConnections.value.length) {
connectTo(filteredConnections.value[idx]);
} else {
const actionIdx = idx - filteredConnections.value.length;
executeAction(filteredActions.value[actionIdx]);
}
}
function connectTo(conn: Connection): void {
sessionStore.connect(conn.id);
close();
}
function executeAction(action: PaletteAction): void {
action.handler();
}
function open(): void {
visible.value = true;
query.value = "";
selectedIndex.value = 0;
nextTick(() => inputRef.value?.focus());
}
function close(): void {
visible.value = false;
}
function toggle(): void {
if (visible.value) {
close();
} else {
open();
}
}
// Reset selection when query changes
watch(query, () => {
selectedIndex.value = 0;
});
defineExpose({ open, close, toggle, visible });
</script>
<style scoped>
.animate-fade-in {
animation: palette-fade-in 0.1s ease-out;
}
@keyframes palette-fade-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -0,0 +1,105 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/60"
@click="emit('reject')"
/>
<!-- Dialog -->
<div class="relative bg-[var(--wraith-bg-secondary)] border border-[var(--wraith-border)] rounded-lg shadow-2xl w-[480px] max-w-[90vw]">
<!-- Header -->
<div
class="flex items-center gap-3 px-5 py-4 border-b border-[var(--wraith-border)]"
:class="isChanged ? 'bg-red-500/10' : ''"
>
<!-- Warning icon for changed keys -->
<svg
v-if="isChanged"
class="w-6 h-6 text-[var(--wraith-accent-red)] shrink-0"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575L6.457 1.047zM8 5a.75.75 0 0 0-.75.75v2.5a.75.75 0 0 0 1.5 0v-2.5A.75.75 0 0 0 8 5zm1 6a1 1 0 1 0-2 0 1 1 0 0 0 2 0z" />
</svg>
<!-- Lock icon for new keys -->
<svg
v-else
class="w-6 h-6 text-[var(--wraith-accent-blue)] shrink-0"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M4 4a4 4 0 0 1 8 0v2h.25c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4V4zm8.25 3.5h-8.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25zM10.5 4a2.5 2.5 0 1 0-5 0v2h5V4z" />
</svg>
<div>
<h2 class="text-sm font-semibold text-[var(--wraith-text-primary)]">
{{ isChanged ? 'HOST KEY HAS CHANGED' : 'Unknown Host Key' }}
</h2>
<p class="text-xs text-[var(--wraith-text-muted)] mt-0.5">
{{ isChanged ? 'The server key does not match the stored key!' : 'This host has not been seen before.' }}
</p>
</div>
</div>
<!-- Body -->
<div class="px-5 py-4 space-y-3">
<p v-if="isChanged" class="text-xs text-[var(--wraith-accent-red)]">
WARNING: This could indicate a man-in-the-middle attack. Only accept if you know the host key was recently changed.
</p>
<div class="space-y-2">
<div class="flex items-center gap-2 text-xs">
<span class="text-[var(--wraith-text-muted)] w-16 shrink-0">Host:</span>
<span class="text-[var(--wraith-text-primary)] font-mono">{{ hostname }}</span>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-[var(--wraith-text-muted)] w-16 shrink-0">Key type:</span>
<span class="text-[var(--wraith-text-primary)] font-mono">{{ keyType }}</span>
</div>
<div class="flex items-start gap-2 text-xs">
<span class="text-[var(--wraith-text-muted)] w-16 shrink-0">Fingerprint:</span>
<span class="text-[var(--wraith-accent-blue)] font-mono break-all select-text">{{ fingerprint }}</span>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-5 py-3 border-t border-[var(--wraith-border)]">
<button
class="px-4 py-1.5 text-xs rounded bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-border)] transition-colors cursor-pointer"
@click="emit('reject')"
>
Reject
</button>
<button
class="px-4 py-1.5 text-xs rounded transition-colors cursor-pointer"
:class="
isChanged
? 'bg-[var(--wraith-accent-red)] text-white hover:bg-red-600'
: 'bg-[var(--wraith-accent-blue)] text-white hover:bg-blue-600'
"
@click="emit('accept')"
>
{{ isChanged ? 'Accept Anyway' : 'Accept' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
hostname: string;
keyType: string;
fingerprint: string;
isChanged: boolean;
}>();
const emit = defineEmits<{
accept: [];
reject: [];
}>();
</script>

View File

@ -0,0 +1,372 @@
<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>
<!-- 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>
<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 { open as shellOpen } from "@tauri-apps/plugin-shell";
type Section = "general" | "terminal" | "vault" | "about";
const visible = ref(false);
const activeSection = ref<Section>("general");
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: "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 () => {
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),
);
watch(
() => settings.value.sidebarWidth,
(val) => invoke("set_setting", { key: "sidebar_width", value: String(val) }).catch(console.error),
);
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";
}
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>

View File

@ -0,0 +1,176 @@
<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" />
<!-- Picker panel -->
<div class="relative w-full max-w-md 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)]">Terminal Theme</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>
<!-- Loading state -->
<div v-if="loading" class="py-8 text-center text-sm text-[var(--wraith-text-muted)]">
Loading themes...
</div>
<!-- Theme list -->
<div v-else class="max-h-96 overflow-y-auto py-2">
<button
v-for="theme in themes"
:key="theme.name"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors cursor-pointer"
:class="activeTheme === theme.name ? 'bg-[#1f6feb]/20' : 'hover:bg-[#30363d]'"
@click="selectTheme(theme)"
>
<!-- Active indicator -->
<span class="w-4 flex items-center justify-center shrink-0">
<svg
v-if="activeTheme === theme.name"
class="w-4 h-4 text-[#3fb950]"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
</svg>
</span>
<!-- Theme info -->
<div class="flex-1 min-w-0">
<div class="text-sm text-[var(--wraith-text-primary)]">{{ theme.name }}</div>
<!-- Color swatch: 16 ANSI colors as small blocks -->
<div class="flex gap-0.5 mt-1.5">
<span
v-for="(color, i) in themeColors(theme)"
:key="i"
class="w-4 h-3 rounded-sm"
:style="{ backgroundColor: color }"
/>
</div>
</div>
<!-- Preview: fg on bg -->
<div
class="px-2 py-1 rounded text-[10px] font-mono shrink-0"
:style="{ backgroundColor: theme.background, color: theme.foreground }"
>
~/wraith $
</div>
</button>
<!-- Empty state (no themes loaded) -->
<div
v-if="themes.length === 0"
class="px-4 py-8 text-center text-sm text-[var(--wraith-text-muted)]"
>
No themes available
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
export interface ThemeDefinition {
id?: number;
name: string;
foreground: string;
background: string;
cursor: string;
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
isBuiltin?: boolean;
}
const visible = ref(false);
const activeTheme = ref("Dracula");
const themes = ref<ThemeDefinition[]>([]);
const loading = ref(false);
/** Load themes from the Rust backend. */
async function loadThemes(): Promise<void> {
loading.value = true;
try {
const result = await invoke<ThemeDefinition[]>("list_themes");
themes.value = result || [];
} catch (err) {
console.error("ThemePicker: failed to load themes from backend:", err);
themes.value = [];
} finally {
loading.value = false;
}
}
/** Load saved active theme name from settings on mount. */
onMounted(async () => {
await loadThemes();
try {
const saved = await invoke<string | null>("get_setting", { key: "active_theme" });
if (saved) activeTheme.value = saved;
} catch {
// No saved theme keep default
}
});
function themeColors(theme: ThemeDefinition): string[] {
return [
theme.black, theme.red, theme.green, theme.yellow,
theme.blue, theme.magenta, theme.cyan, theme.white,
theme.brightBlack, theme.brightRed, theme.brightGreen, theme.brightYellow,
theme.brightBlue, theme.brightMagenta, theme.brightCyan, theme.brightWhite,
];
}
const emit = defineEmits<{
(e: "select", theme: ThemeDefinition): void;
}>();
function selectTheme(theme: ThemeDefinition): void {
activeTheme.value = theme.name;
emit("select", theme);
invoke("set_setting", { key: "active_theme", value: theme.name }).catch(console.error);
}
function open(): void {
visible.value = true;
}
function close(): void {
visible.value = false;
}
defineExpose({ open, close, visible, activeTheme });
</script>

View File

@ -0,0 +1,591 @@
<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" />
<!-- Dialog -->
<div class="relative w-full max-w-md 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)]">
{{ isEditing ? "Edit Connection" : "New Connection" }}
</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="px-4 py-4 space-y-3 max-h-[80vh] overflow-y-auto">
<!-- Name -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Name</label>
<input
v-model="form.name"
type="text"
placeholder="My Server"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<!-- Hostname & Port -->
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Hostname</label>
<input
v-model="form.hostname"
type="text"
placeholder="192.168.1.1"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div class="w-24">
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Port</label>
<input
v-model.number="form.port"
type="number"
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>
</div>
<!-- Protocol -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Protocol</label>
<div class="flex gap-2">
<button
class="flex-1 py-2 text-sm rounded border transition-colors cursor-pointer"
:class="form.protocol === 'ssh'
? 'bg-[#3fb950]/10 border-[#3fb950] text-[#3fb950]'
: 'bg-[#0d1117] border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="setProtocol('ssh')"
>
SSH
</button>
<button
class="flex-1 py-2 text-sm rounded border transition-colors cursor-pointer"
:class="form.protocol === 'rdp'
? 'bg-[#1f6feb]/10 border-[#1f6feb] text-[#1f6feb]'
: 'bg-[#0d1117] border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="setProtocol('rdp')"
>
RDP
</button>
</div>
</div>
<!-- Group -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Group</label>
<select
v-model="form.groupId"
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="null">No Group</option>
<option v-for="group in connectionStore.groups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
</div>
<!-- Tags -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Tags (comma-separated)</label>
<input
v-model="tagsInput"
type="text"
placeholder="Prod, Linux, Web"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<!-- Color -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Color Label</label>
<div class="flex gap-2">
<button
v-for="color in colorOptions"
:key="color.value"
class="w-6 h-6 rounded-full border-2 transition-transform cursor-pointer hover:scale-110"
:class="form.color === color.value ? 'border-white scale-110' : 'border-transparent'"
:style="{ backgroundColor: color.hex }"
:title="color.label"
@click="form.color = color.value"
/>
</div>
</div>
<!-- Notes -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Notes</label>
<textarea
v-model="form.notes"
rows="3"
placeholder="Optional notes about this connection..."
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors resize-none"
/>
</div>
<!-- Divider -->
<div class="border-t border-[#30363d]" />
<!-- Credentials -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Credential</label>
<div class="flex gap-2">
<select
v-model="form.credentialId"
class="flex-1 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="null">None</option>
<option
v-for="cred in credentials"
:key="cred.id"
:value="cred.id"
>
{{ cred.name }}
<template v-if="cred.credentialType === 'ssh_key'"> (SSH Key)</template>
<template v-else> (Password{{ cred.username ? ` ${cred.username}` : '' }})</template>
</option>
</select>
<button
v-if="form.credentialId"
type="button"
class="px-2.5 py-2 text-xs rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
title="Delete selected credential"
@click="deleteSelectedCredential"
>
Delete
</button>
</div>
</div>
<!-- Add New Credential (collapsible) -->
<div class="rounded border border-[#30363d] overflow-hidden">
<!-- Toggle header -->
<button
class="w-full flex items-center justify-between px-3 py-2 text-xs text-[var(--wraith-text-secondary)] hover:bg-[#1c2128] transition-colors cursor-pointer"
@click="showNewCred = !showNewCred"
>
<span class="font-medium">Add New Credential</span>
<svg
class="w-3.5 h-3.5 transition-transform"
:class="showNewCred ? 'rotate-180' : ''"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M4.427 7.427l3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z" />
</svg>
</button>
<!-- Collapsed content -->
<div v-if="showNewCred" class="px-3 pb-3 pt-2 space-y-3 bg-[#0d1117]">
<!-- Credential type selector -->
<div class="flex gap-2">
<button
class="flex-1 py-1.5 text-xs rounded border transition-colors cursor-pointer"
:class="newCredType === 'password'
? 'bg-[#3fb950]/10 border-[#3fb950] text-[#3fb950]'
: 'bg-transparent border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="newCredType = 'password'"
>
Password
</button>
<button
class="flex-1 py-1.5 text-xs rounded border transition-colors cursor-pointer"
:class="newCredType === 'ssh_key'
? 'bg-[#58a6ff]/10 border-[#58a6ff] text-[#58a6ff]'
: 'bg-transparent border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="newCredType = 'ssh_key'"
>
SSH Key
</button>
</div>
<!-- Password fields -->
<template v-if="newCredType === 'password'">
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Credential Name</label>
<input
v-model="newCred.name"
type="text"
placeholder="e.g. prod-admin"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Username</label>
<input
v-model="newCred.username"
type="text"
placeholder="admin"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Password</label>
<input
v-model="newCred.password"
type="password"
placeholder="••••••••"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
</template>
<!-- SSH Key fields -->
<template v-if="newCredType === 'ssh_key'">
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Credential Name</label>
<input
v-model="newCred.name"
type="text"
placeholder="e.g. my-ssh-key"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Username</label>
<input
v-model="newCred.username"
type="text"
placeholder="e.g. vstockwell"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Private Key (PEM)</label>
<textarea
v-model="newCred.privateKeyPEM"
rows="5"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors resize-none font-mono"
spellcheck="false"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Passphrase <span class="text-[var(--wraith-text-muted)]">(optional)</span></label>
<input
v-model="newCred.passphrase"
type="password"
placeholder="Leave blank if key is unencrypted"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
</template>
<!-- Error message -->
<p v-if="newCredError" class="text-xs text-[#f85149]">{{ newCredError }}</p>
<!-- Save credential button -->
<button
class="w-full py-1.5 text-xs rounded border transition-colors cursor-pointer"
:class="isNewCredValid
? 'bg-[#238636] border-[#238636] text-white hover:bg-[#2ea043] hover:border-[#2ea043]'
: 'bg-transparent border-[#30363d] text-[var(--wraith-text-muted)] cursor-not-allowed opacity-50'
"
:disabled="!isNewCredValid || savingCred"
@click="saveNewCredential"
>
{{ savingCred ? 'Saving...' : 'Save Credential' }}
</button>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-[#30363d]">
<button
class="px-3 py-1.5 text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
@click="close"
>
Cancel
</button>
<button
class="px-3 py-1.5 text-xs text-white bg-[#238636] hover:bg-[#2ea043] rounded transition-colors cursor-pointer"
:class="{ 'opacity-50 cursor-not-allowed': !isValid }"
:disabled="!isValid"
@click="save"
>
{{ isEditing ? "Save Changes" : "Create" }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useConnectionStore, type Connection } from "@/stores/connection.store";
interface Credential {
id: number;
name: string;
username: string | null;
domain: string | null;
credentialType: "password" | "ssh_key";
sshKeyId: number | null;
}
interface ConnectionForm {
name: string;
hostname: string;
port: number;
protocol: "ssh" | "rdp";
groupId: number | null;
credentialId: number | null;
color: string;
notes: string;
}
interface NewCredForm {
name: string;
username: string;
password: string;
privateKeyPEM: string;
passphrase: string;
}
const connectionStore = useConnectionStore();
const visible = ref(false);
const isEditing = ref(false);
const editingId = ref<number | null>(null);
const tagsInput = ref("");
const credentials = ref<Credential[]>([]);
// New credential panel state
const showNewCred = ref(false);
const newCredType = ref<"password" | "ssh_key">("password");
const savingCred = ref(false);
const newCredError = ref("");
const newCred = ref<NewCredForm>({
name: "",
username: "",
password: "",
privateKeyPEM: "",
passphrase: "",
});
const form = ref<ConnectionForm>({
name: "",
hostname: "",
port: 22,
protocol: "ssh",
groupId: null,
credentialId: null,
color: "",
notes: "",
});
const colorOptions = [
{ value: "", label: "None", hex: "#30363d" },
{ value: "red", label: "Red", hex: "#f85149" },
{ value: "orange", label: "Orange", hex: "#d29922" },
{ value: "green", label: "Green", hex: "#3fb950" },
{ value: "blue", label: "Blue", hex: "#58a6ff" },
{ value: "purple", label: "Purple", hex: "#bc8cff" },
{ value: "pink", label: "Pink", hex: "#f778ba" },
];
const isValid = computed(() => {
return form.value.name.trim() !== "" && form.value.hostname.trim() !== "" && form.value.port > 0;
});
const isNewCredValid = computed(() => {
if (!newCred.value.name.trim()) return false;
if (newCredType.value === "password") {
return newCred.value.password.length > 0;
}
// ssh_key: must have PEM content
return newCred.value.privateKeyPEM.trim().length > 0;
});
function setProtocol(protocol: "ssh" | "rdp"): void {
form.value.protocol = protocol;
if (protocol === "ssh" && form.value.port === 3389) {
form.value.port = 22;
} else if (protocol === "rdp" && form.value.port === 22) {
form.value.port = 3389;
}
}
function resetNewCredForm(): void {
newCred.value = { name: "", username: "", password: "", privateKeyPEM: "", passphrase: "" };
newCredError.value = "";
}
async function deleteSelectedCredential(): Promise<void> {
if (!form.value.credentialId) return;
const cred = credentials.value.find((c) => c.id === form.value.credentialId);
const name = cred?.name ?? `ID ${form.value.credentialId}`;
if (!confirm(`Delete credential "${name}"? This cannot be undone.`)) return;
try {
await invoke("delete_credential", { id: form.value.credentialId });
form.value.credentialId = null;
await loadCredentials();
} catch (err) {
alert(`Failed to delete credential: ${err}`);
}
}
async function loadCredentials(): Promise<void> {
try {
const result = await invoke<Credential[]>("list_credentials");
credentials.value = result || [];
} catch {
credentials.value = [];
}
}
async function saveNewCredential(): Promise<void> {
if (!isNewCredValid.value || savingCred.value) return;
savingCred.value = true;
newCredError.value = "";
try {
let created: Credential | null = null;
if (newCredType.value === "password") {
created = await invoke<Credential>("create_password", {
name: newCred.value.name.trim(),
username: newCred.value.username.trim(),
password: newCred.value.password,
domain: null,
});
} else {
// SSH Key
created = await invoke<Credential>("create_ssh_key", {
name: newCred.value.name.trim(),
username: newCred.value.username.trim(),
privateKeyPem: newCred.value.privateKeyPEM.trim(),
passphrase: newCred.value.passphrase || null,
});
}
// Refresh list and auto-select the new credential
await loadCredentials();
if (created) {
form.value.credentialId = created.id;
}
resetNewCredForm();
showNewCred.value = false;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
newCredError.value = msg || "Failed to save credential.";
} finally {
savingCred.value = false;
}
}
function openNew(groupId?: number): void {
isEditing.value = false;
editingId.value = null;
form.value = {
name: "",
hostname: "",
port: 22,
protocol: "ssh",
groupId: groupId ?? null,
credentialId: null,
color: "",
notes: "",
};
tagsInput.value = "";
resetNewCredForm();
showNewCred.value = false;
void loadCredentials();
visible.value = true;
}
function openEdit(conn: Connection): void {
isEditing.value = true;
editingId.value = conn.id;
form.value = {
name: conn.name,
hostname: conn.hostname,
port: conn.port,
protocol: conn.protocol,
groupId: conn.groupId,
credentialId: conn.credentialId ?? null,
color: conn.color ?? "",
notes: conn.notes ?? "",
};
tagsInput.value = conn.tags?.join(", ") ?? "";
resetNewCredForm();
showNewCred.value = false;
void loadCredentials();
visible.value = true;
}
function close(): void {
visible.value = false;
}
async function save(): Promise<void> {
if (!isValid.value) return;
const tags = tagsInput.value
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
try {
if (isEditing.value && editingId.value !== null) {
await invoke("update_connection", {
id: editingId.value,
input: {
name: form.value.name,
hostname: form.value.hostname,
port: form.value.port,
groupId: form.value.groupId,
credentialId: form.value.credentialId,
color: form.value.color || null,
tags,
notes: form.value.notes || null,
},
});
} else {
await invoke("create_connection", {
input: {
name: form.value.name,
hostname: form.value.hostname,
port: form.value.port,
protocol: form.value.protocol,
groupId: form.value.groupId,
credentialId: form.value.credentialId,
color: form.value.color || null,
tags,
notes: form.value.notes || null,
},
});
}
// Refresh connections from backend
await connectionStore.loadAll();
close();
} catch (err) {
console.error("Failed to save connection:", err);
}
}
defineExpose({ openNew, openEdit, close, visible });
</script>

View File

@ -0,0 +1,229 @@
<template>
<div
class="flex flex-col border-b border-[var(--wraith-border)] bg-[var(--wraith-bg-secondary)]"
:style="{ height: editorHeight + 'px' }"
>
<!-- Editor toolbar -->
<div class="flex items-center justify-between px-3 py-1.5 border-b border-[var(--wraith-border)] shrink-0">
<div class="flex items-center gap-2 text-xs min-w-0">
<!-- File icon -->
<svg
class="w-3.5 h-3.5 text-[var(--wraith-text-muted)] shrink-0"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M3.75 1.5a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V6H9.75A1.75 1.75 0 0 1 8 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25V1.75z" />
</svg>
<span class="text-[var(--wraith-text-primary)] truncate font-mono text-[11px]">
{{ filePath }}
</span>
<!-- Unsaved indicator -->
<span
v-if="hasUnsavedChanges"
class="w-2 h-2 rounded-full bg-[var(--wraith-accent-yellow)] shrink-0"
title="Unsaved changes"
/>
</div>
<div class="flex items-center gap-2 shrink-0">
<!-- Save button -->
<button
class="px-3 py-1 text-xs rounded transition-colors cursor-pointer"
:class="
hasUnsavedChanges
? 'bg-[var(--wraith-accent-blue)] text-white hover:bg-blue-600'
: 'bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)] cursor-not-allowed'
"
:disabled="!hasUnsavedChanges"
@click="handleSave"
>
Save
</button>
<!-- Close button -->
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)] transition-colors cursor-pointer"
title="Close editor"
@click="emit('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 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z" />
</svg>
</button>
</div>
</div>
<!-- CodeMirror container -->
<div ref="editorRef" class="flex-1 min-h-0 overflow-hidden" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import {
EditorView,
keymap,
lineNumbers,
highlightActiveLine,
highlightSpecialChars,
} from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import {
syntaxHighlighting,
defaultHighlightStyle,
bracketMatching,
} from "@codemirror/language";
import { oneDark } from "@codemirror/theme-one-dark";
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
const props = defineProps<{
/** Initial file content to display. */
content: string;
/** Remote path of the file being edited. */
filePath: string;
/** Active SSH session ID — used for the SFTP write call. */
sessionId: string;
}>();
const emit = defineEmits<{
/** Emitted when the user clicks the close button. */
close: [];
}>();
const editorRef = ref<HTMLElement | null>(null);
const hasUnsavedChanges = ref(false);
/** Fixed height for the inline editor panel — sits above the terminal. */
const editorHeight = ref(300);
let view: EditorView | null = null;
/** Lazily load a language extension based on the file extension. */
async function getLanguageExtension(path: string) {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
switch (ext) {
case "js":
case "jsx":
case "ts":
case "tsx": {
const { javascript } = await import("@codemirror/lang-javascript");
return javascript({ jsx: ext.includes("x"), typescript: ext.startsWith("t") });
}
case "json": {
const { json } = await import("@codemirror/lang-json");
return json();
}
case "py": {
const { python } = await import("@codemirror/lang-python");
return python();
}
case "md":
case "markdown": {
const { markdown } = await import("@codemirror/lang-markdown");
return markdown();
}
default:
return null;
}
}
/** Build the full extension list for the editor (used on mount and on file change). */
async function buildExtensions() {
const langExt = await getLanguageExtension(props.filePath);
const extensions = [
lineNumbers(),
highlightActiveLine(),
highlightSpecialChars(),
history(),
bracketMatching(),
closeBrackets(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
oneDark,
keymap.of([
...defaultKeymap,
...historyKeymap,
...closeBracketsKeymap,
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
hasUnsavedChanges.value = true;
}
}),
EditorView.theme({
"&": {
height: "100%",
fontSize: "13px",
},
".cm-scroller": {
overflow: "auto",
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, monospace",
},
}),
];
if (langExt) {
extensions.push(langExt);
}
return extensions;
}
onMounted(async () => {
if (!editorRef.value) return;
const extensions = await buildExtensions();
const state = EditorState.create({ doc: props.content, extensions });
view = new EditorView({ state, parent: editorRef.value });
});
onBeforeUnmount(() => {
if (view) {
view.destroy();
view = null;
}
});
/**
* Re-create the editor when the file changes (new file opened in same panel).
* Reset unsaved-changes indicator on each new file load.
*/
watch(
() => props.filePath,
async () => {
if (!editorRef.value) return;
// Destroy the old view before re-creating
if (view) {
view.destroy();
view = null;
}
hasUnsavedChanges.value = false;
const extensions = await buildExtensions();
const state = EditorState.create({ doc: props.content, extensions });
view = new EditorView({ state, parent: editorRef.value });
},
);
/** Save the current editor content to the remote file via Tauri SFTP. */
async function handleSave(): Promise<void> {
if (!view || !hasUnsavedChanges.value) return;
const currentContent = view.state.doc.toString();
try {
await invoke("sftp_write_file", {
sessionId: props.sessionId,
path: props.filePath,
content: currentContent,
});
hasUnsavedChanges.value = false;
} catch (err) {
console.error("Failed to save file:", err);
}
}
</script>

View File

@ -8,6 +8,7 @@
class="absolute inset-0" class="absolute inset-0"
> >
<TerminalView <TerminalView
:ref="(el) => setTerminalRef(session.id, el)"
:session-id="session.id" :session-id="session.id"
:is-active="session.id === sessionStore.activeSessionId" :is-active="session.id === sessionStore.activeSessionId"
/> />
@ -63,6 +64,17 @@ import RdpView from "@/components/rdp/RdpView.vue";
import RdpToolbar from "@/components/rdp/RdpToolbar.vue"; import RdpToolbar from "@/components/rdp/RdpToolbar.vue";
import { ScancodeMap } from "@/composables/useRdp"; import { ScancodeMap } from "@/composables/useRdp";
/** Map from session ID → TerminalView instance ref (for search access). */
const terminalViewRefs: Record<string, { openSearch: () => void } | null> = {};
function setTerminalRef(sessionId: string, el: unknown): void {
if (el) {
terminalViewRefs[sessionId] = el as { openSearch: () => void };
} else {
delete terminalViewRefs[sessionId];
}
}
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const sshSessions = computed(() => const sshSessions = computed(() =>
@ -151,4 +163,17 @@ function toggleFullscreen(wrapperRef: { value: HTMLElement | null } | undefined)
wrapper.requestFullscreen(); wrapper.requestFullscreen();
} }
} }
/**
* Open the inline search bar on the currently active SSH terminal.
* No-op if the active session is RDP (no terminal to search).
*/
function openActiveSearch(): void {
const activeId = sessionStore.activeSessionId;
if (!activeId) return;
const termRef = terminalViewRefs[activeId];
termRef?.openSearch();
}
defineExpose({ openActiveSearch });
</script> </script>

View File

@ -0,0 +1,91 @@
<template>
<span class="flex items-center gap-1">
<!-- Protocol dot -->
<span
class="w-1.5 h-1.5 rounded-full shrink-0"
:class="protocolDotClass"
:title="protocol === 'ssh' ? 'SSH' : 'RDP'"
/>
<!-- ROOT warning dot -->
<span
v-if="isRoot"
class="w-1.5 h-1.5 rounded-full bg-[#f0883e] shrink-0"
title="Running as root / Administrator"
/>
<!-- Environment pills -->
<span
v-for="tag in envTags"
:key="tag"
class="px-1 py-0.5 text-[9px] font-semibold rounded leading-none shrink-0"
:class="envTagClass(tag)"
>
{{ tag }}
</span>
</span>
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
/** Connection protocol — drives the protocol-dot colour. */
protocol: "ssh" | "rdp";
/** Username from the active session (if known). */
username?: string;
/** Raw tags from the connection record. */
tags?: string[];
}>();
/** Green for SSH, blue for RDP. */
const protocolDotClass = computed(() =>
props.protocol === "ssh" ? "bg-[#3fb950]" : "bg-[#1f6feb]",
);
/** True when the session is running as root or Administrator. */
const isRoot = computed(() => {
if (props.username === "root" || props.username === "Administrator") return true;
// Also flag if a tag explicitly marks the session as root
return props.tags?.some((t) => t === "root" || t === "Administrator") ?? false;
});
/**
* Environment tags derived from the connection's tag list.
* Only surfaces recognised env labels ignore noise like username tags.
*/
const envTags = computed<string[]>(() => {
if (!props.tags?.length) return [];
return props.tags.filter((t) => {
const u = t.toUpperCase();
return (
u === "PROD" ||
u === "PRODUCTION" ||
u === "DEV" ||
u === "DEVELOPMENT" ||
u === "STAGING" ||
u === "STG" ||
u === "TEST" ||
u === "QA"
);
});
});
/** Tailwind classes for an individual environment pill. */
function envTagClass(tag: string): string {
const u = tag.toUpperCase();
if (u === "PROD" || u === "PRODUCTION") {
return "bg-[#da3633]/20 text-[#f85149]";
}
if (u === "DEV" || u === "DEVELOPMENT") {
return "bg-[#238636]/20 text-[#3fb950]";
}
if (u === "STAGING" || u === "STG") {
return "bg-[#9e6a03]/20 text-[#d29922]";
}
if (u === "TEST" || u === "QA") {
return "bg-[#1f6feb]/20 text-[#58a6ff]";
}
return "bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]";
}
</script>

View File

@ -5,7 +5,7 @@
<button <button
v-for="session in sessionStore.sessions" v-for="session in sessionStore.sessions"
:key="session.id" :key="session.id"
class="group flex items-center gap-2 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0" class="group flex items-center gap-1.5 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0"
:class="[ :class="[
session.id === sessionStore.activeSessionId session.id === sessionStore.activeSessionId
? 'bg-[var(--wraith-bg-primary)] text-[var(--wraith-text-primary)] border-b-2 border-b-[var(--wraith-accent-blue)]' ? 'bg-[var(--wraith-bg-primary)] text-[var(--wraith-text-primary)] border-b-2 border-b-[var(--wraith-accent-blue)]'
@ -14,42 +14,15 @@
]" ]"
@click="sessionStore.activateSession(session.id)" @click="sessionStore.activateSession(session.id)"
> >
<!-- Protocol icon --> <!-- Badge: protocol dot + root dot + env pills -->
<span class="shrink-0"> <TabBadge
<!-- SSH terminal icon --> :protocol="session.protocol"
<svg :username="session.username"
v-if="session.protocol === 'ssh'" :tags="getSessionTags(session)"
class="w-3.5 h-3.5 text-[#3fb950]" />
viewBox="0 0 16 16"
fill="currentColor"
>
<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>
<!-- RDP monitor icon -->
<svg
v-else
class="w-3.5 h-3.5 text-[#1f6feb]"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M1.75 2.5h12.5a.25.25 0 0 1 .25.25v7.5a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25v-7.5a.25.25 0 0 1 .25-.25ZM14.25 1H1.75A1.75 1.75 0 0 0 0 2.75v7.5C0 11.216.784 12 1.75 12h4.388l-.533 1.5H4a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5h-1.605l-.533-1.5h4.388A1.75 1.75 0 0 0 16 10.25v-7.5A1.75 1.75 0 0 0 14.25 1ZM9.112 13.5H6.888l.533-1.5h1.158l.533 1.5Z" />
</svg>
</span>
<span>{{ session.name }}</span> <span>{{ session.name }}</span>
<!-- Environment tag badges -->
<template v-if="getSessionTags(session).length > 0">
<span
v-for="tag in getSessionTags(session)"
:key="tag"
class="px-1 py-0.5 text-[9px] font-semibold rounded leading-none"
:class="tagClass(tag)"
>
{{ tag }}
</span>
</template>
<!-- Close button --> <!-- Close button -->
<span <span
class="ml-1 opacity-0 group-hover:opacity-100 hover:text-[var(--wraith-accent-red)] transition-opacity" class="ml-1 opacity-0 group-hover:opacity-100 hover:text-[var(--wraith-accent-red)] transition-opacity"
@ -73,6 +46,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useSessionStore, type Session } from "@/stores/session.store"; import { useSessionStore, type Session } from "@/stores/session.store";
import { useConnectionStore } from "@/stores/connection.store"; import { useConnectionStore } from "@/stores/connection.store";
import TabBadge from "@/components/session/TabBadge.vue";
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const connectionStore = useConnectionStore(); const connectionStore = useConnectionStore();
@ -83,44 +57,22 @@ function getSessionTags(session: Session): string[] {
return conn?.tags ?? []; return conn?.tags ?? [];
} }
/** Check if the connection for this session uses the root user. */ /** Check if the connection for this session uses the root user (drives the orange top border). */
function isRootUser(session: Session): boolean { function isRootUser(session: Session): boolean {
// Check username stored on the session object (set during connect) if (session.username === "root" || session.username === "Administrator") return true;
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); const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
if (!conn) return false; if (!conn) return false;
if (conn.options) { if (conn.options) {
try { try {
const opts = JSON.parse(conn.options); const opts = JSON.parse(conn.options);
if (opts?.username === "root") return true; if (opts?.username === "root" || opts?.username === "Administrator") return true;
} catch { } catch {
// ignore malformed options // ignore malformed options
} }
} }
// Also check if "root" appears in the connection tags return conn.tags?.some((t) => t === "root" || t === "Administrator") ?? false;
return conn.tags?.includes("root") ?? false;
}
/** Return Tailwind classes for environment tag badges. */
function tagClass(tag: string): string {
const t = tag.toUpperCase();
if (t === "PROD" || t === "PRODUCTION") {
return "bg-[#da3633]/20 text-[#f85149]";
}
if (t === "DEV" || t === "DEVELOPMENT") {
return "bg-[#238636]/20 text-[#3fb950]";
}
if (t === "STAGING" || t === "STG") {
return "bg-[#9e6a03]/20 text-[#d29922]";
}
if (t === "TEST" || t === "QA") {
return "bg-[#1f6feb]/20 text-[#58a6ff]";
}
// Default for other tags
return "bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]";
} }
</script> </script>

View File

@ -1,13 +1,61 @@
<template> <template>
<div class="terminal-wrapper flex flex-col h-full relative">
<!-- Inline terminal search bar shown when Ctrl+F is pressed -->
<div
v-if="searchVisible"
class="absolute top-2 right-2 z-20 flex items-center gap-1 bg-[#161b22] border border-[#30363d] rounded-lg shadow-lg px-2 py-1"
>
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
placeholder="Find in terminal…"
class="w-48 px-1.5 py-0.5 text-xs 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)] rounded transition-colors"
@keydown.enter="findNext"
@keydown.shift.enter.prevent="findPrevious"
@keydown.escape="closeSearch"
@input="onSearchInput"
/>
<button
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Previous match (Shift+Enter)"
@click="findPrevious"
>
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.427 9.573 8 6l3.573 3.573a.75.75 0 0 0 1.06-1.06L8.53 4.409a.75.75 0 0 0-1.06 0L3.367 8.513a.75.75 0 0 0 1.06 1.06z" />
</svg>
</button>
<button
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Next match (Enter)"
@click="findNext"
>
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.427 6.427 8 10l3.573-3.573a.75.75 0 0 1 1.06 1.06L8.53 11.591a.75.75 0 0 1-1.06 0L3.367 7.487a.75.75 0 0 1 1.06-1.06z" />
</svg>
</button>
<button
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)] transition-colors cursor-pointer"
title="Close (Esc)"
@click="closeSearch"
>
<svg class="w-3 h-3" 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 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z" />
</svg>
</button>
</div>
<!-- Terminal container -->
<div <div
ref="containerRef" ref="containerRef"
class="terminal-container" class="terminal-container flex-1"
@focus="handleFocus" @focus="handleFocus"
/> />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from "vue"; import { ref, nextTick, onMounted, watch } from "vue";
import { useTerminal } from "@/composables/useTerminal"; import { useTerminal } from "@/composables/useTerminal";
import { useSessionStore } from "@/stores/session.store"; import { useSessionStore } from "@/stores/session.store";
import "@/assets/css/terminal.css"; import "@/assets/css/terminal.css";
@ -19,7 +67,60 @@ const props = defineProps<{
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const containerRef = ref<HTMLElement | null>(null); const containerRef = ref<HTMLElement | null>(null);
const { terminal, mount, fit } = useTerminal(props.sessionId); const { terminal, searchAddon, mount, fit } = useTerminal(props.sessionId);
// --- Search state ---
const searchVisible = ref(false);
const searchQuery = ref("");
const searchInputRef = ref<HTMLInputElement | null>(null);
/** Open the inline search bar and focus it. */
function openSearch(): void {
searchVisible.value = true;
nextTick(() => {
searchInputRef.value?.focus();
searchInputRef.value?.select();
});
}
/** Close the search bar and refocus the terminal. */
function closeSearch(): void {
searchVisible.value = false;
searchQuery.value = "";
terminal.focus();
}
/** Decoration options for search highlights — xterm SearchAddon spec. */
const searchDecorations = {
matchBackground: "#f0883e40",
matchBorder: "#f0883e",
matchOverviewRuler: "#f0883e",
activeMatchBackground: "#f0883e80",
activeMatchBorder: "#f0883e",
activeMatchColorOverviewRuler: "#f0883e",
} as const;
/** Find the next match. */
function findNext(): void {
if (!searchQuery.value) return;
searchAddon.findNext(searchQuery.value, { caseSensitive: false, decorations: searchDecorations });
}
/** Find the previous match. */
function findPrevious(): void {
if (!searchQuery.value) return;
searchAddon.findPrevious(searchQuery.value, { caseSensitive: false, decorations: searchDecorations });
}
/** Re-search as the user types. */
function onSearchInput(): void {
if (searchQuery.value) {
findNext();
}
}
// Expose openSearch so SessionContainer / MainLayout can trigger Ctrl+F
defineExpose({ openSearch });
onMounted(() => { onMounted(() => {
if (containerRef.value) { if (containerRef.value) {
@ -42,7 +143,6 @@ watch(
() => props.isActive, () => props.isActive,
(active) => { (active) => {
if (active) { if (active) {
// nextTick is not needed fit and focus happen after the DOM update
setTimeout(() => { setTimeout(() => {
fit(); fit();
terminal.focus(); terminal.focus();

View File

@ -36,6 +36,7 @@ const defaultTheme = {
export interface UseTerminalReturn { export interface UseTerminalReturn {
terminal: Terminal; terminal: Terminal;
fitAddon: FitAddon; fitAddon: FitAddon;
searchAddon: SearchAddon;
mount: (container: HTMLElement) => void; mount: (container: HTMLElement) => void;
destroy: () => void; destroy: () => void;
write: (data: string) => void; write: (data: string) => void;
@ -232,5 +233,5 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
destroy(); destroy();
}); });
return { terminal, fitAddon, mount, destroy, write, fit }; return { terminal, fitAddon, searchAddon, mount, destroy, write, fit };
} }

View File

@ -149,8 +149,17 @@
<!-- Tab bar --> <!-- Tab bar -->
<TabBar /> <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 --> <!-- Session area -->
<SessionContainer /> <SessionContainer ref="sessionContainer" />
</div> </div>
</div> </div>
@ -228,6 +237,7 @@ import SettingsModal from "@/components/common/SettingsModal.vue";
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue"; import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
import FileTree from "@/components/sftp/FileTree.vue"; import FileTree from "@/components/sftp/FileTree.vue";
import TransferProgress from "@/components/sftp/TransferProgress.vue"; import TransferProgress from "@/components/sftp/TransferProgress.vue";
import EditorWindow from "@/components/editor/EditorWindow.vue";
import type { FileEntry } from "@/composables/useSftp"; import type { FileEntry } from "@/composables/useSftp";
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue"; import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
@ -254,6 +264,15 @@ const importDialog = ref<InstanceType<typeof ImportDialog> | null>(null);
const settingsModal = ref<InstanceType<typeof SettingsModal> | null>(null); const settingsModal = ref<InstanceType<typeof SettingsModal> | null>(null);
const connectionEditDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null); const connectionEditDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
const statusBar = ref<InstanceType<typeof StatusBar> | 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. */ /** File menu dropdown state. */
const showFileMenu = ref(false); const showFileMenu = ref(false);
@ -295,8 +314,7 @@ function handleThemeSelect(theme: ThemeDefinition): void {
/** /**
* Called when the user double-clicks a file in the SFTP FileTree. * Called when the user double-clicks a file in the SFTP FileTree.
* Reads the file content via Tauri and opens it in a new editor tab (future). * Reads the file content via Tauri SFTP and opens it in the inline editor.
* For now, triggers a download as a browser blob.
*/ */
async function handleOpenFile(entry: FileEntry): Promise<void> { async function handleOpenFile(entry: FileEntry): Promise<void> {
if (!activeSessionId.value) return; if (!activeSessionId.value) return;
@ -305,15 +323,11 @@ async function handleOpenFile(entry: FileEntry): Promise<void> {
sessionId: activeSessionId.value, sessionId: activeSessionId.value,
path: entry.path, path: entry.path,
}); });
const blob = new Blob([content], { type: "text/plain" }); editorFile.value = {
const url = URL.createObjectURL(blob); path: entry.path,
const a = document.createElement("a"); content,
a.href = url; sessionId: activeSessionId.value,
a.download = entry.name; };
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) { } catch (err) {
console.error("Failed to open SFTP file:", err); console.error("Failed to open SFTP file:", err);
} }
@ -464,6 +478,16 @@ function handleKeydown(event: KeyboardEvent): void {
sidebarVisible.value = !sidebarVisible.value; sidebarVisible.value = !sidebarVisible.value;
return; 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 () => { onMounted(async () => {