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 ssh_commands;
|
||||||
pub mod sftp_commands;
|
pub mod sftp_commands;
|
||||||
pub mod rdp_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 ssh;
|
||||||
pub mod sftp;
|
pub mod sftp;
|
||||||
pub mod rdp;
|
pub mod rdp;
|
||||||
|
pub mod theme;
|
||||||
|
pub mod workspace;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@ -19,6 +21,8 @@ use connections::ConnectionService;
|
|||||||
use sftp::SftpService;
|
use sftp::SftpService;
|
||||||
use ssh::session::SshService;
|
use ssh::session::SshService;
|
||||||
use rdp::RdpService;
|
use rdp::RdpService;
|
||||||
|
use theme::ThemeService;
|
||||||
|
use workspace::WorkspaceService;
|
||||||
|
|
||||||
/// Application state shared across all Tauri commands via State<AppState>.
|
/// Application state shared across all Tauri commands via State<AppState>.
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@ -30,6 +34,8 @@ pub struct AppState {
|
|||||||
pub ssh: SshService,
|
pub ssh: SshService,
|
||||||
pub sftp: SftpService,
|
pub sftp: SftpService,
|
||||||
pub rdp: RdpService,
|
pub rdp: RdpService,
|
||||||
|
pub theme: ThemeService,
|
||||||
|
pub workspace: WorkspaceService,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@ -45,6 +51,12 @@ impl AppState {
|
|||||||
let ssh = SshService::new(database.clone());
|
let ssh = SshService::new(database.clone());
|
||||||
let sftp = SftpService::new();
|
let sftp = SftpService::new();
|
||||||
let rdp = RdpService::new();
|
let rdp = RdpService::new();
|
||||||
|
let theme = ThemeService::new(database.clone());
|
||||||
|
// WorkspaceService shares the same SettingsService interface; we clone
|
||||||
|
// the Database to construct a second SettingsService for the workspace
|
||||||
|
// module so it can remain self-contained.
|
||||||
|
let workspace_settings = SettingsService::new(database.clone());
|
||||||
|
let workspace = WorkspaceService::new(workspace_settings);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
db: database,
|
db: database,
|
||||||
@ -55,6 +67,8 @@ impl AppState {
|
|||||||
ssh,
|
ssh,
|
||||||
sftp,
|
sftp,
|
||||||
rdp,
|
rdp,
|
||||||
|
theme,
|
||||||
|
workspace,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,6 +108,23 @@ pub fn run() {
|
|||||||
let app_state = AppState::new(data_dir)
|
let app_state = AppState::new(data_dir)
|
||||||
.expect("Failed to initialize application state");
|
.expect("Failed to initialize application state");
|
||||||
|
|
||||||
|
// Seed built-in themes (INSERT OR IGNORE — safe to call on every boot).
|
||||||
|
app_state.theme.seed_builtins();
|
||||||
|
|
||||||
|
// Crash recovery detection: log dirty shutdowns so they can be acted on.
|
||||||
|
if app_state.workspace.was_clean_shutdown() {
|
||||||
|
app_state
|
||||||
|
.workspace
|
||||||
|
.clear_clean_shutdown()
|
||||||
|
.unwrap_or_else(|e| eprintln!("workspace: failed to clear clean-shutdown flag: {e}"));
|
||||||
|
} else {
|
||||||
|
// No clean-shutdown flag found — either first run or a crash/kill.
|
||||||
|
// Only log if a snapshot exists (i.e. there were open tabs last time).
|
||||||
|
if app_state.workspace.load().is_some() {
|
||||||
|
eprintln!("workspace: dirty shutdown detected — a previous session may not have exited cleanly");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.manage(app_state)
|
.manage(app_state)
|
||||||
@ -136,6 +167,8 @@ pub fn run() {
|
|||||||
commands::rdp_commands::rdp_send_key,
|
commands::rdp_commands::rdp_send_key,
|
||||||
commands::rdp_commands::disconnect_rdp,
|
commands::rdp_commands::disconnect_rdp,
|
||||||
commands::rdp_commands::list_rdp_sessions,
|
commands::rdp_commands::list_rdp_sessions,
|
||||||
|
commands::theme_commands::list_themes,
|
||||||
|
commands::theme_commands::get_theme,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
339
src-tauri/src/theme/mod.rs
Normal file
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"
|
class="absolute inset-0"
|
||||||
>
|
>
|
||||||
<TerminalView
|
<TerminalView
|
||||||
|
:ref="(el) => setTerminalRef(session.id, el)"
|
||||||
:session-id="session.id"
|
:session-id="session.id"
|
||||||
:is-active="session.id === sessionStore.activeSessionId"
|
:is-active="session.id === sessionStore.activeSessionId"
|
||||||
/>
|
/>
|
||||||
@ -63,6 +64,17 @@ import RdpView from "@/components/rdp/RdpView.vue";
|
|||||||
import RdpToolbar from "@/components/rdp/RdpToolbar.vue";
|
import RdpToolbar from "@/components/rdp/RdpToolbar.vue";
|
||||||
import { ScancodeMap } from "@/composables/useRdp";
|
import { ScancodeMap } from "@/composables/useRdp";
|
||||||
|
|
||||||
|
/** Map from session ID → TerminalView instance ref (for search access). */
|
||||||
|
const terminalViewRefs: Record<string, { openSearch: () => void } | null> = {};
|
||||||
|
|
||||||
|
function setTerminalRef(sessionId: string, el: unknown): void {
|
||||||
|
if (el) {
|
||||||
|
terminalViewRefs[sessionId] = el as { openSearch: () => void };
|
||||||
|
} else {
|
||||||
|
delete terminalViewRefs[sessionId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
|
|
||||||
const sshSessions = computed(() =>
|
const sshSessions = computed(() =>
|
||||||
@ -151,4 +163,17 @@ function toggleFullscreen(wrapperRef: { value: HTMLElement | null } | undefined)
|
|||||||
wrapper.requestFullscreen();
|
wrapper.requestFullscreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the inline search bar on the currently active SSH terminal.
|
||||||
|
* No-op if the active session is RDP (no terminal to search).
|
||||||
|
*/
|
||||||
|
function openActiveSearch(): void {
|
||||||
|
const activeId = sessionStore.activeSessionId;
|
||||||
|
if (!activeId) return;
|
||||||
|
const termRef = terminalViewRefs[activeId];
|
||||||
|
termRef?.openSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ openActiveSearch });
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
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
|
<button
|
||||||
v-for="session in sessionStore.sessions"
|
v-for="session in sessionStore.sessions"
|
||||||
:key="session.id"
|
:key="session.id"
|
||||||
class="group flex items-center gap-2 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0"
|
class="group flex items-center gap-1.5 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0"
|
||||||
:class="[
|
:class="[
|
||||||
session.id === sessionStore.activeSessionId
|
session.id === sessionStore.activeSessionId
|
||||||
? 'bg-[var(--wraith-bg-primary)] text-[var(--wraith-text-primary)] border-b-2 border-b-[var(--wraith-accent-blue)]'
|
? 'bg-[var(--wraith-bg-primary)] text-[var(--wraith-text-primary)] border-b-2 border-b-[var(--wraith-accent-blue)]'
|
||||||
@ -14,42 +14,15 @@
|
|||||||
]"
|
]"
|
||||||
@click="sessionStore.activateSession(session.id)"
|
@click="sessionStore.activateSession(session.id)"
|
||||||
>
|
>
|
||||||
<!-- Protocol icon -->
|
<!-- Badge: protocol dot + root dot + env pills -->
|
||||||
<span class="shrink-0">
|
<TabBadge
|
||||||
<!-- SSH terminal icon -->
|
:protocol="session.protocol"
|
||||||
<svg
|
:username="session.username"
|
||||||
v-if="session.protocol === 'ssh'"
|
:tags="getSessionTags(session)"
|
||||||
class="w-3.5 h-3.5 text-[#3fb950]"
|
/>
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 1 1-1.06-1.06L5.44 8 3.72 6.28a.749.749 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z" />
|
|
||||||
</svg>
|
|
||||||
<!-- RDP monitor icon -->
|
|
||||||
<svg
|
|
||||||
v-else
|
|
||||||
class="w-3.5 h-3.5 text-[#1f6feb]"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M1.75 2.5h12.5a.25.25 0 0 1 .25.25v7.5a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25v-7.5a.25.25 0 0 1 .25-.25ZM14.25 1H1.75A1.75 1.75 0 0 0 0 2.75v7.5C0 11.216.784 12 1.75 12h4.388l-.533 1.5H4a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5h-1.605l-.533-1.5h4.388A1.75 1.75 0 0 0 16 10.25v-7.5A1.75 1.75 0 0 0 14.25 1ZM9.112 13.5H6.888l.533-1.5h1.158l.533 1.5Z" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span>{{ session.name }}</span>
|
<span>{{ session.name }}</span>
|
||||||
|
|
||||||
<!-- Environment tag badges -->
|
|
||||||
<template v-if="getSessionTags(session).length > 0">
|
|
||||||
<span
|
|
||||||
v-for="tag in getSessionTags(session)"
|
|
||||||
:key="tag"
|
|
||||||
class="px-1 py-0.5 text-[9px] font-semibold rounded leading-none"
|
|
||||||
:class="tagClass(tag)"
|
|
||||||
>
|
|
||||||
{{ tag }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Close button -->
|
<!-- Close button -->
|
||||||
<span
|
<span
|
||||||
class="ml-1 opacity-0 group-hover:opacity-100 hover:text-[var(--wraith-accent-red)] transition-opacity"
|
class="ml-1 opacity-0 group-hover:opacity-100 hover:text-[var(--wraith-accent-red)] transition-opacity"
|
||||||
@ -73,6 +46,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useSessionStore, type Session } from "@/stores/session.store";
|
import { useSessionStore, type Session } from "@/stores/session.store";
|
||||||
import { useConnectionStore } from "@/stores/connection.store";
|
import { useConnectionStore } from "@/stores/connection.store";
|
||||||
|
import TabBadge from "@/components/session/TabBadge.vue";
|
||||||
|
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
const connectionStore = useConnectionStore();
|
const connectionStore = useConnectionStore();
|
||||||
@ -83,44 +57,22 @@ function getSessionTags(session: Session): string[] {
|
|||||||
return conn?.tags ?? [];
|
return conn?.tags ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if the connection for this session uses the root user. */
|
/** Check if the connection for this session uses the root user (drives the orange top border). */
|
||||||
function isRootUser(session: Session): boolean {
|
function isRootUser(session: Session): boolean {
|
||||||
// Check username stored on the session object (set during connect)
|
if (session.username === "root" || session.username === "Administrator") return true;
|
||||||
if (session.username === "root") return true;
|
|
||||||
|
|
||||||
// Fall back to checking the connection's options JSON for a stored username
|
|
||||||
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
|
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
|
||||||
if (!conn) return false;
|
if (!conn) return false;
|
||||||
|
|
||||||
if (conn.options) {
|
if (conn.options) {
|
||||||
try {
|
try {
|
||||||
const opts = JSON.parse(conn.options);
|
const opts = JSON.parse(conn.options);
|
||||||
if (opts?.username === "root") return true;
|
if (opts?.username === "root" || opts?.username === "Administrator") return true;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed options
|
// ignore malformed options
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check if "root" appears in the connection tags
|
return conn.tags?.some((t) => t === "root" || t === "Administrator") ?? false;
|
||||||
return conn.tags?.includes("root") ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return Tailwind classes for environment tag badges. */
|
|
||||||
function tagClass(tag: string): string {
|
|
||||||
const t = tag.toUpperCase();
|
|
||||||
if (t === "PROD" || t === "PRODUCTION") {
|
|
||||||
return "bg-[#da3633]/20 text-[#f85149]";
|
|
||||||
}
|
|
||||||
if (t === "DEV" || t === "DEVELOPMENT") {
|
|
||||||
return "bg-[#238636]/20 text-[#3fb950]";
|
|
||||||
}
|
|
||||||
if (t === "STAGING" || t === "STG") {
|
|
||||||
return "bg-[#9e6a03]/20 text-[#d29922]";
|
|
||||||
}
|
|
||||||
if (t === "TEST" || t === "QA") {
|
|
||||||
return "bg-[#1f6feb]/20 text-[#58a6ff]";
|
|
||||||
}
|
|
||||||
// Default for other tags
|
|
||||||
return "bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]";
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,13 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="terminal-wrapper flex flex-col h-full relative">
|
||||||
ref="containerRef"
|
<!-- Inline terminal search bar — shown when Ctrl+F is pressed -->
|
||||||
class="terminal-container"
|
<div
|
||||||
@focus="handleFocus"
|
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 flex-1"
|
||||||
|
@focus="handleFocus"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from "vue";
|
import { ref, nextTick, onMounted, watch } from "vue";
|
||||||
import { useTerminal } from "@/composables/useTerminal";
|
import { useTerminal } from "@/composables/useTerminal";
|
||||||
import { useSessionStore } from "@/stores/session.store";
|
import { useSessionStore } from "@/stores/session.store";
|
||||||
import "@/assets/css/terminal.css";
|
import "@/assets/css/terminal.css";
|
||||||
@ -19,7 +67,60 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
const { terminal, mount, fit } = useTerminal(props.sessionId);
|
const { terminal, searchAddon, mount, fit } = useTerminal(props.sessionId);
|
||||||
|
|
||||||
|
// --- Search state ---
|
||||||
|
const searchVisible = ref(false);
|
||||||
|
const searchQuery = ref("");
|
||||||
|
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
/** Open the inline search bar and focus it. */
|
||||||
|
function openSearch(): void {
|
||||||
|
searchVisible.value = true;
|
||||||
|
nextTick(() => {
|
||||||
|
searchInputRef.value?.focus();
|
||||||
|
searchInputRef.value?.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the search bar and refocus the terminal. */
|
||||||
|
function closeSearch(): void {
|
||||||
|
searchVisible.value = false;
|
||||||
|
searchQuery.value = "";
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decoration options for search highlights — xterm SearchAddon spec. */
|
||||||
|
const searchDecorations = {
|
||||||
|
matchBackground: "#f0883e40",
|
||||||
|
matchBorder: "#f0883e",
|
||||||
|
matchOverviewRuler: "#f0883e",
|
||||||
|
activeMatchBackground: "#f0883e80",
|
||||||
|
activeMatchBorder: "#f0883e",
|
||||||
|
activeMatchColorOverviewRuler: "#f0883e",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Find the next match. */
|
||||||
|
function findNext(): void {
|
||||||
|
if (!searchQuery.value) return;
|
||||||
|
searchAddon.findNext(searchQuery.value, { caseSensitive: false, decorations: searchDecorations });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the previous match. */
|
||||||
|
function findPrevious(): void {
|
||||||
|
if (!searchQuery.value) return;
|
||||||
|
searchAddon.findPrevious(searchQuery.value, { caseSensitive: false, decorations: searchDecorations });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-search as the user types. */
|
||||||
|
function onSearchInput(): void {
|
||||||
|
if (searchQuery.value) {
|
||||||
|
findNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose openSearch so SessionContainer / MainLayout can trigger Ctrl+F
|
||||||
|
defineExpose({ openSearch });
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
@ -42,7 +143,6 @@ watch(
|
|||||||
() => props.isActive,
|
() => props.isActive,
|
||||||
(active) => {
|
(active) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
// nextTick is not needed — fit and focus happen after the DOM update
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fit();
|
fit();
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
|
|||||||
@ -36,6 +36,7 @@ const defaultTheme = {
|
|||||||
export interface UseTerminalReturn {
|
export interface UseTerminalReturn {
|
||||||
terminal: Terminal;
|
terminal: Terminal;
|
||||||
fitAddon: FitAddon;
|
fitAddon: FitAddon;
|
||||||
|
searchAddon: SearchAddon;
|
||||||
mount: (container: HTMLElement) => void;
|
mount: (container: HTMLElement) => void;
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
write: (data: string) => void;
|
write: (data: string) => void;
|
||||||
@ -232,5 +233,5 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
|
|||||||
destroy();
|
destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
return { terminal, fitAddon, mount, destroy, write, fit };
|
return { terminal, fitAddon, searchAddon, mount, destroy, write, fit };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -149,8 +149,17 @@
|
|||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
<TabBar />
|
<TabBar />
|
||||||
|
|
||||||
|
<!-- Inline file editor — shown above the terminal when a file is open -->
|
||||||
|
<EditorWindow
|
||||||
|
v-if="editorFile"
|
||||||
|
:content="editorFile.content"
|
||||||
|
:file-path="editorFile.path"
|
||||||
|
:session-id="editorFile.sessionId"
|
||||||
|
@close="editorFile = null"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Session area -->
|
<!-- Session area -->
|
||||||
<SessionContainer />
|
<SessionContainer ref="sessionContainer" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -228,6 +237,7 @@ import SettingsModal from "@/components/common/SettingsModal.vue";
|
|||||||
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
|
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
|
||||||
import FileTree from "@/components/sftp/FileTree.vue";
|
import FileTree from "@/components/sftp/FileTree.vue";
|
||||||
import TransferProgress from "@/components/sftp/TransferProgress.vue";
|
import TransferProgress from "@/components/sftp/TransferProgress.vue";
|
||||||
|
import EditorWindow from "@/components/editor/EditorWindow.vue";
|
||||||
import type { FileEntry } from "@/composables/useSftp";
|
import type { FileEntry } from "@/composables/useSftp";
|
||||||
|
|
||||||
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
||||||
@ -254,6 +264,15 @@ const importDialog = ref<InstanceType<typeof ImportDialog> | null>(null);
|
|||||||
const settingsModal = ref<InstanceType<typeof SettingsModal> | null>(null);
|
const settingsModal = ref<InstanceType<typeof SettingsModal> | null>(null);
|
||||||
const connectionEditDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
|
const connectionEditDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
|
||||||
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
|
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
|
||||||
|
const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null);
|
||||||
|
|
||||||
|
/** Currently open file in the inline editor. Null when the editor is closed. */
|
||||||
|
interface EditorFile {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
const editorFile = ref<EditorFile | null>(null);
|
||||||
|
|
||||||
/** File menu dropdown state. */
|
/** File menu dropdown state. */
|
||||||
const showFileMenu = ref(false);
|
const showFileMenu = ref(false);
|
||||||
@ -295,8 +314,7 @@ function handleThemeSelect(theme: ThemeDefinition): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the user double-clicks a file in the SFTP FileTree.
|
* Called when the user double-clicks a file in the SFTP FileTree.
|
||||||
* Reads the file content via Tauri and opens it in a new editor tab (future).
|
* Reads the file content via Tauri SFTP and opens it in the inline editor.
|
||||||
* For now, triggers a download as a browser blob.
|
|
||||||
*/
|
*/
|
||||||
async function handleOpenFile(entry: FileEntry): Promise<void> {
|
async function handleOpenFile(entry: FileEntry): Promise<void> {
|
||||||
if (!activeSessionId.value) return;
|
if (!activeSessionId.value) return;
|
||||||
@ -305,15 +323,11 @@ async function handleOpenFile(entry: FileEntry): Promise<void> {
|
|||||||
sessionId: activeSessionId.value,
|
sessionId: activeSessionId.value,
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
});
|
});
|
||||||
const blob = new Blob([content], { type: "text/plain" });
|
editorFile.value = {
|
||||||
const url = URL.createObjectURL(blob);
|
path: entry.path,
|
||||||
const a = document.createElement("a");
|
content,
|
||||||
a.href = url;
|
sessionId: activeSessionId.value,
|
||||||
a.download = entry.name;
|
};
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to open SFTP file:", err);
|
console.error("Failed to open SFTP file:", err);
|
||||||
}
|
}
|
||||||
@ -464,6 +478,16 @@ function handleKeydown(event: KeyboardEvent): void {
|
|||||||
sidebarVisible.value = !sidebarVisible.value;
|
sidebarVisible.value = !sidebarVisible.value;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+F — open terminal scrollback search (SSH sessions only)
|
||||||
|
if (ctrl && event.key === "f") {
|
||||||
|
const active = sessionStore.activeSession;
|
||||||
|
if (active?.protocol === "ssh") {
|
||||||
|
event.preventDefault();
|
||||||
|
sessionContainer.value?.openActiveSearch();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user