diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b2582ee..d25394a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,3 +5,4 @@ pub mod credentials; pub mod ssh_commands; pub mod sftp_commands; pub mod rdp_commands; +pub mod theme_commands; diff --git a/src-tauri/src/commands/theme_commands.rs b/src-tauri/src/commands/theme_commands.rs new file mode 100644 index 0000000..c9df594 --- /dev/null +++ b/src-tauri/src/commands/theme_commands.rs @@ -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 { + 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 { + state.theme.get_by_name(&name) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9370621..da434db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,8 @@ pub mod credentials; pub mod ssh; pub mod sftp; pub mod rdp; +pub mod theme; +pub mod workspace; pub mod commands; use std::path::PathBuf; @@ -19,6 +21,8 @@ use connections::ConnectionService; use sftp::SftpService; use ssh::session::SshService; use rdp::RdpService; +use theme::ThemeService; +use workspace::WorkspaceService; /// Application state shared across all Tauri commands via State. pub struct AppState { @@ -30,6 +34,8 @@ pub struct AppState { pub ssh: SshService, pub sftp: SftpService, pub rdp: RdpService, + pub theme: ThemeService, + pub workspace: WorkspaceService, } impl AppState { @@ -45,6 +51,12 @@ impl AppState { let ssh = SshService::new(database.clone()); let sftp = SftpService::new(); let rdp = RdpService::new(); + let theme = ThemeService::new(database.clone()); + // WorkspaceService shares the same SettingsService interface; we clone + // the Database to construct a second SettingsService for the workspace + // module so it can remain self-contained. + let workspace_settings = SettingsService::new(database.clone()); + let workspace = WorkspaceService::new(workspace_settings); Ok(Self { db: database, @@ -55,6 +67,8 @@ impl AppState { ssh, sftp, rdp, + theme, + workspace, }) } @@ -94,6 +108,23 @@ pub fn run() { let app_state = AppState::new(data_dir) .expect("Failed to initialize application state"); + // Seed built-in themes (INSERT OR IGNORE — safe to call on every boot). + app_state.theme.seed_builtins(); + + // Crash recovery detection: log dirty shutdowns so they can be acted on. + if app_state.workspace.was_clean_shutdown() { + app_state + .workspace + .clear_clean_shutdown() + .unwrap_or_else(|e| eprintln!("workspace: failed to clear clean-shutdown flag: {e}")); + } else { + // No clean-shutdown flag found — either first run or a crash/kill. + // Only log if a snapshot exists (i.e. there were open tabs last time). + if app_state.workspace.load().is_some() { + eprintln!("workspace: dirty shutdown detected — a previous session may not have exited cleanly"); + } + } + tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(app_state) @@ -136,6 +167,8 @@ pub fn run() { commands::rdp_commands::rdp_send_key, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions, + commands::theme_commands::list_themes, + commands::theme_commands::get_theme, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/theme/mod.rs b/src-tauri/src/theme/mod.rs new file mode 100644 index 0000000..614aae8 --- /dev/null +++ b/src-tauri/src/theme/mod.rs @@ -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 { + 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 { + 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 { + 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)?, + }) +} diff --git a/src-tauri/src/workspace/mod.rs b/src-tauri/src/workspace/mod.rs new file mode 100644 index 0000000..900d03a --- /dev/null +++ b/src-tauri/src/workspace/mod.rs @@ -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, +} + +// ── 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 { + 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) + } +} diff --git a/src/components/common/CommandPalette.vue b/src/components/common/CommandPalette.vue new file mode 100644 index 0000000..11f051e --- /dev/null +++ b/src/components/common/CommandPalette.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/src/components/common/HostKeyDialog.vue b/src/components/common/HostKeyDialog.vue new file mode 100644 index 0000000..862d7b8 --- /dev/null +++ b/src/components/common/HostKeyDialog.vue @@ -0,0 +1,105 @@ + + + diff --git a/src/components/common/SettingsModal.vue b/src/components/common/SettingsModal.vue new file mode 100644 index 0000000..d78490b --- /dev/null +++ b/src/components/common/SettingsModal.vue @@ -0,0 +1,372 @@ + + + diff --git a/src/components/common/ThemePicker.vue b/src/components/common/ThemePicker.vue new file mode 100644 index 0000000..1e5d137 --- /dev/null +++ b/src/components/common/ThemePicker.vue @@ -0,0 +1,176 @@ + + + diff --git a/src/components/connections/ConnectionEditDialog.vue b/src/components/connections/ConnectionEditDialog.vue new file mode 100644 index 0000000..88a4046 --- /dev/null +++ b/src/components/connections/ConnectionEditDialog.vue @@ -0,0 +1,591 @@ +