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:
parent
c75da74ecd
commit
0cd4cc0f64
@ -5,3 +5,4 @@ pub mod credentials;
|
||||
pub mod ssh_commands;
|
||||
pub mod sftp_commands;
|
||||
pub mod rdp_commands;
|
||||
pub mod theme_commands;
|
||||
|
||||
18
src-tauri/src/commands/theme_commands.rs
Normal file
18
src-tauri/src/commands/theme_commands.rs
Normal 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)
|
||||
}
|
||||
@ -6,6 +6,8 @@ pub mod credentials;
|
||||
pub mod ssh;
|
||||
pub mod sftp;
|
||||
pub mod rdp;
|
||||
pub mod theme;
|
||||
pub mod workspace;
|
||||
pub mod commands;
|
||||
|
||||
use std::path::PathBuf;
|
||||
@ -19,6 +21,8 @@ use connections::ConnectionService;
|
||||
use sftp::SftpService;
|
||||
use ssh::session::SshService;
|
||||
use rdp::RdpService;
|
||||
use theme::ThemeService;
|
||||
use workspace::WorkspaceService;
|
||||
|
||||
/// Application state shared across all Tauri commands via State<AppState>.
|
||||
pub struct AppState {
|
||||
@ -30,6 +34,8 @@ pub struct AppState {
|
||||
pub ssh: SshService,
|
||||
pub sftp: SftpService,
|
||||
pub rdp: RdpService,
|
||||
pub theme: ThemeService,
|
||||
pub workspace: WorkspaceService,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@ -45,6 +51,12 @@ impl AppState {
|
||||
let ssh = SshService::new(database.clone());
|
||||
let sftp = SftpService::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 {
|
||||
db: database,
|
||||
@ -55,6 +67,8 @@ impl AppState {
|
||||
ssh,
|
||||
sftp,
|
||||
rdp,
|
||||
theme,
|
||||
workspace,
|
||||
})
|
||||
}
|
||||
|
||||
@ -94,6 +108,23 @@ pub fn run() {
|
||||
let app_state = AppState::new(data_dir)
|
||||
.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()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(app_state)
|
||||
@ -136,6 +167,8 @@ pub fn run() {
|
||||
commands::rdp_commands::rdp_send_key,
|
||||
commands::rdp_commands::disconnect_rdp,
|
||||
commands::rdp_commands::list_rdp_sessions,
|
||||
commands::theme_commands::list_themes,
|
||||
commands::theme_commands::get_theme,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
339
src-tauri/src/theme/mod.rs
Normal file
339
src-tauri/src/theme/mod.rs
Normal 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)?,
|
||||
})
|
||||
}
|
||||
82
src-tauri/src/workspace/mod.rs
Normal file
82
src-tauri/src/workspace/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
269
src/components/common/CommandPalette.vue
Normal file
269
src/components/common/CommandPalette.vue
Normal 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>
|
||||
105
src/components/common/HostKeyDialog.vue
Normal file
105
src/components/common/HostKeyDialog.vue
Normal 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>
|
||||
372
src/components/common/SettingsModal.vue
Normal file
372
src/components/common/SettingsModal.vue
Normal 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>
|
||||
176
src/components/common/ThemePicker.vue
Normal file
176
src/components/common/ThemePicker.vue
Normal 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>
|
||||
591
src/components/connections/ConnectionEditDialog.vue
Normal file
591
src/components/connections/ConnectionEditDialog.vue
Normal 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----- ... -----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>
|
||||
229
src/components/editor/EditorWindow.vue
Normal file
229
src/components/editor/EditorWindow.vue
Normal 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>
|
||||
@ -8,6 +8,7 @@
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<TerminalView
|
||||
:ref="(el) => setTerminalRef(session.id, el)"
|
||||
:session-id="session.id"
|
||||
: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 { 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 sshSessions = computed(() =>
|
||||
@ -151,4 +163,17 @@ function toggleFullscreen(wrapperRef: { value: HTMLElement | null } | undefined)
|
||||
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>
|
||||
|
||||
91
src/components/session/TabBadge.vue
Normal file
91
src/components/session/TabBadge.vue
Normal 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>
|
||||
@ -5,7 +5,7 @@
|
||||
<button
|
||||
v-for="session in sessionStore.sessions"
|
||||
: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="[
|
||||
session.id === sessionStore.activeSessionId
|
||||
? '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)"
|
||||
>
|
||||
<!-- Protocol icon -->
|
||||
<span class="shrink-0">
|
||||
<!-- SSH terminal icon -->
|
||||
<svg
|
||||
v-if="session.protocol === 'ssh'"
|
||||
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>
|
||||
<!-- Badge: protocol dot + root dot + env pills -->
|
||||
<TabBadge
|
||||
:protocol="session.protocol"
|
||||
:username="session.username"
|
||||
:tags="getSessionTags(session)"
|
||||
/>
|
||||
|
||||
<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 -->
|
||||
<span
|
||||
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">
|
||||
import { useSessionStore, type Session } from "@/stores/session.store";
|
||||
import { useConnectionStore } from "@/stores/connection.store";
|
||||
import TabBadge from "@/components/session/TabBadge.vue";
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
@ -83,44 +57,22 @@ function getSessionTags(session: Session): string[] {
|
||||
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 {
|
||||
// Check username stored on the session object (set during connect)
|
||||
if (session.username === "root") return true;
|
||||
if (session.username === "root" || session.username === "Administrator") return true;
|
||||
|
||||
// Fall back to checking the connection's options JSON for a stored username
|
||||
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
|
||||
if (!conn) return false;
|
||||
|
||||
if (conn.options) {
|
||||
try {
|
||||
const opts = JSON.parse(conn.options);
|
||||
if (opts?.username === "root") return true;
|
||||
if (opts?.username === "root" || opts?.username === "Administrator") return true;
|
||||
} catch {
|
||||
// ignore malformed options
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if "root" appears in the connection tags
|
||||
return conn.tags?.includes("root") ?? false;
|
||||
}
|
||||
|
||||
/** Return Tailwind classes for environment tag badges. */
|
||||
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)]";
|
||||
return conn.tags?.some((t) => t === "root" || t === "Administrator") ?? false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,13 +1,61 @@
|
||||
<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
|
||||
ref="containerRef"
|
||||
class="terminal-container"
|
||||
class="terminal-container flex-1"
|
||||
@focus="handleFocus"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ref, nextTick, onMounted, watch } from "vue";
|
||||
import { useTerminal } from "@/composables/useTerminal";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
import "@/assets/css/terminal.css";
|
||||
@ -19,7 +67,60 @@ const props = defineProps<{
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
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(() => {
|
||||
if (containerRef.value) {
|
||||
@ -42,7 +143,6 @@ watch(
|
||||
() => props.isActive,
|
||||
(active) => {
|
||||
if (active) {
|
||||
// nextTick is not needed — fit and focus happen after the DOM update
|
||||
setTimeout(() => {
|
||||
fit();
|
||||
terminal.focus();
|
||||
|
||||
@ -36,6 +36,7 @@ const defaultTheme = {
|
||||
export interface UseTerminalReturn {
|
||||
terminal: Terminal;
|
||||
fitAddon: FitAddon;
|
||||
searchAddon: SearchAddon;
|
||||
mount: (container: HTMLElement) => void;
|
||||
destroy: () => void;
|
||||
write: (data: string) => void;
|
||||
@ -232,5 +233,5 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
|
||||
destroy();
|
||||
});
|
||||
|
||||
return { terminal, fitAddon, mount, destroy, write, fit };
|
||||
return { terminal, fitAddon, searchAddon, mount, destroy, write, fit };
|
||||
}
|
||||
|
||||
@ -149,8 +149,17 @@
|
||||
<!-- Tab bar -->
|
||||
<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 -->
|
||||
<SessionContainer />
|
||||
<SessionContainer ref="sessionContainer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -228,6 +237,7 @@ import SettingsModal from "@/components/common/SettingsModal.vue";
|
||||
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
|
||||
import FileTree from "@/components/sftp/FileTree.vue";
|
||||
import TransferProgress from "@/components/sftp/TransferProgress.vue";
|
||||
import EditorWindow from "@/components/editor/EditorWindow.vue";
|
||||
import type { FileEntry } from "@/composables/useSftp";
|
||||
|
||||
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 connectionEditDialog = ref<InstanceType<typeof ConnectionEditDialog> | 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. */
|
||||
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.
|
||||
* Reads the file content via Tauri and opens it in a new editor tab (future).
|
||||
* For now, triggers a download as a browser blob.
|
||||
* Reads the file content via Tauri SFTP and opens it in the inline editor.
|
||||
*/
|
||||
async function handleOpenFile(entry: FileEntry): Promise<void> {
|
||||
if (!activeSessionId.value) return;
|
||||
@ -305,15 +323,11 @@ async function handleOpenFile(entry: FileEntry): Promise<void> {
|
||||
sessionId: activeSessionId.value,
|
||||
path: entry.path,
|
||||
});
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = entry.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
editorFile.value = {
|
||||
path: entry.path,
|
||||
content,
|
||||
sessionId: activeSessionId.value,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Failed to open SFTP file:", err);
|
||||
}
|
||||
@ -464,6 +478,16 @@ function handleKeydown(event: KeyboardEvent): void {
|
||||
sidebarVisible.value = !sidebarVisible.value;
|
||||
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 () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user