feat: Phase 2 complete — SSH terminal + frontend UI
Rust SSH service: russh async client, DashMap session registry, TOFU host key verification, CWD tracking via separate exec channel (never touches terminal stream), base64 event emission for terminal I/O. 52/52 tests passing. Vue 3 frontend: ported from Wails v3 to Tauri v2 — useTerminal composable with streaming TextDecoder + rAF batching, session store with multi-connection support, connection store/tree, sidebar, tab bar, status bar, keyboard shortcuts. All Wails imports replaced with Tauri API equivalents. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0802ae0753
commit
737491d3f0
972
src-tauri/Cargo.lock
generated
972
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -24,6 +24,9 @@ uuid = { version = "1", features = ["v4"] }
|
||||
base64 = "0.22"
|
||||
dashmap = "6"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
thiserror = "2"
|
||||
russh = "0.48"
|
||||
ssh-key = { version = "0.6", features = ["ed25519", "rsa"] }
|
||||
|
||||
@ -2,3 +2,4 @@ pub mod vault;
|
||||
pub mod settings;
|
||||
pub mod connections;
|
||||
pub mod credentials;
|
||||
pub mod ssh_commands;
|
||||
|
||||
115
src-tauri/src/commands/ssh_commands.rs
Normal file
115
src-tauri/src/commands/ssh_commands.rs
Normal file
@ -0,0 +1,115 @@
|
||||
//! Tauri commands for SSH session management.
|
||||
//!
|
||||
//! All commands are async because russh operations are inherently asynchronous.
|
||||
//! The SSH service is accessed via `State<AppState>` and the `AppHandle` is
|
||||
//! used for event emission.
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::ssh::session::{AuthMethod, SessionInfo};
|
||||
use crate::AppState;
|
||||
|
||||
/// Connect to an SSH server with password authentication.
|
||||
///
|
||||
/// Opens a PTY, starts a shell, and begins streaming output via
|
||||
/// `ssh:data:{session_id}` events. Returns the session UUID.
|
||||
#[tauri::command]
|
||||
pub async fn connect_ssh(
|
||||
hostname: String,
|
||||
port: u16,
|
||||
username: String,
|
||||
password: String,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.ssh
|
||||
.connect(
|
||||
app_handle,
|
||||
&hostname,
|
||||
port,
|
||||
&username,
|
||||
AuthMethod::Password(password),
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Connect to an SSH server with private key authentication.
|
||||
///
|
||||
/// The `private_key_pem` should be the PEM-encoded private key content.
|
||||
/// `passphrase` is `None` if the key is not encrypted.
|
||||
///
|
||||
/// Opens a PTY, starts a shell, and begins streaming output via
|
||||
/// `ssh:data:{session_id}` events. Returns the session UUID.
|
||||
#[tauri::command]
|
||||
pub async fn connect_ssh_with_key(
|
||||
hostname: String,
|
||||
port: u16,
|
||||
username: String,
|
||||
private_key_pem: String,
|
||||
passphrase: Option<String>,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.ssh
|
||||
.connect(
|
||||
app_handle,
|
||||
&hostname,
|
||||
port,
|
||||
&username,
|
||||
AuthMethod::Key {
|
||||
private_key_pem,
|
||||
passphrase,
|
||||
},
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Write data to a session's PTY stdin.
|
||||
///
|
||||
/// The `data` parameter is a string that will be sent as UTF-8 bytes.
|
||||
#[tauri::command]
|
||||
pub async fn ssh_write(
|
||||
session_id: String,
|
||||
data: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.ssh.write(&session_id, data.as_bytes()).await
|
||||
}
|
||||
|
||||
/// Resize the PTY window for a session.
|
||||
#[tauri::command]
|
||||
pub async fn ssh_resize(
|
||||
session_id: String,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.ssh.resize(&session_id, cols, rows).await
|
||||
}
|
||||
|
||||
/// Disconnect an SSH session — closes the channel and removes it.
|
||||
#[tauri::command]
|
||||
pub async fn disconnect_ssh(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.ssh.disconnect(&session_id).await
|
||||
}
|
||||
|
||||
/// List all active SSH sessions (metadata only).
|
||||
#[tauri::command]
|
||||
pub async fn list_ssh_sessions(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<SessionInfo>, String> {
|
||||
Ok(state.ssh.list_sessions())
|
||||
}
|
||||
@ -3,6 +3,7 @@ pub mod vault;
|
||||
pub mod settings;
|
||||
pub mod connections;
|
||||
pub mod credentials;
|
||||
pub mod ssh;
|
||||
pub mod commands;
|
||||
|
||||
use std::path::PathBuf;
|
||||
@ -13,6 +14,7 @@ use vault::VaultService;
|
||||
use credentials::CredentialService;
|
||||
use settings::SettingsService;
|
||||
use connections::ConnectionService;
|
||||
use ssh::session::SshService;
|
||||
|
||||
/// Application state shared across all Tauri commands via State<AppState>.
|
||||
pub struct AppState {
|
||||
@ -21,6 +23,7 @@ pub struct AppState {
|
||||
pub settings: SettingsService,
|
||||
pub connections: ConnectionService,
|
||||
pub credentials: Mutex<Option<CredentialService>>,
|
||||
pub ssh: SshService,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@ -33,6 +36,7 @@ impl AppState {
|
||||
|
||||
let settings = SettingsService::new(database.clone());
|
||||
let connections = ConnectionService::new(database.clone());
|
||||
let ssh = SshService::new(database.clone());
|
||||
|
||||
Ok(Self {
|
||||
db: database,
|
||||
@ -40,6 +44,7 @@ impl AppState {
|
||||
settings,
|
||||
connections,
|
||||
credentials: Mutex::new(None),
|
||||
ssh,
|
||||
})
|
||||
}
|
||||
|
||||
@ -103,6 +108,12 @@ pub fn run() {
|
||||
commands::credentials::create_password,
|
||||
commands::credentials::create_ssh_key,
|
||||
commands::credentials::delete_credential,
|
||||
commands::ssh_commands::connect_ssh,
|
||||
commands::ssh_commands::connect_ssh_with_key,
|
||||
commands::ssh_commands::ssh_write,
|
||||
commands::ssh_commands::ssh_resize,
|
||||
commands::ssh_commands::disconnect_ssh,
|
||||
commands::ssh_commands::list_ssh_sessions,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
129
src-tauri/src/ssh/cwd.rs
Normal file
129
src-tauri/src/ssh/cwd.rs
Normal file
@ -0,0 +1,129 @@
|
||||
//! CWD tracking via a SEPARATE SSH exec channel.
|
||||
//!
|
||||
//! This module opens an independent exec channel on the same SSH connection and
|
||||
//! periodically runs `pwd` to determine the remote shell's current working
|
||||
//! directory. The terminal data stream is NEVER touched — CWD is tracked
|
||||
//! entirely out of band.
|
||||
//!
|
||||
//! When the CWD changes, an `ssh:cwd:{session_id}` event is emitted to the
|
||||
//! frontend.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{debug, error, warn};
|
||||
use russh::client::Handle;
|
||||
use russh::ChannelMsg;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::watch;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use crate::ssh::session::SshClient;
|
||||
|
||||
/// Tracks the current working directory of a remote shell by periodically
|
||||
/// running `pwd` over a separate exec channel.
|
||||
pub struct CwdTracker {
|
||||
_sender: watch::Sender<String>,
|
||||
pub receiver: watch::Receiver<String>,
|
||||
}
|
||||
|
||||
impl CwdTracker {
|
||||
/// Create a new CWD tracker with an empty initial directory.
|
||||
pub fn new() -> Self {
|
||||
let (sender, receiver) = watch::channel(String::new());
|
||||
Self {
|
||||
_sender: sender,
|
||||
receiver,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a background tokio task that polls `pwd` every 2 seconds on a
|
||||
/// separate exec channel.
|
||||
///
|
||||
/// The task runs until the SSH connection is closed or the channel cannot
|
||||
/// be opened. CWD changes are emitted as `ssh:cwd:{session_id}` events.
|
||||
pub fn start(
|
||||
&self,
|
||||
handle: Arc<TokioMutex<Handle<SshClient>>>,
|
||||
app_handle: AppHandle,
|
||||
session_id: String,
|
||||
) {
|
||||
let sender = self._sender.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Brief initial delay to let the shell start up.
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
let mut previous_cwd = String::new();
|
||||
|
||||
loop {
|
||||
// Open a fresh exec channel for each `pwd` invocation.
|
||||
// Some SSH servers do not allow multiple exec requests on a
|
||||
// single channel, so we open a new one each time.
|
||||
let result = {
|
||||
let handle_guard = handle.lock().await;
|
||||
handle_guard.channel_open_session().await
|
||||
};
|
||||
|
||||
let mut exec_channel = match result {
|
||||
Ok(ch) => ch,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"CWD tracker for session {} could not open exec channel: {} — stopping",
|
||||
session_id, e
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Execute `pwd` on the exec channel.
|
||||
if let Err(e) = exec_channel.exec(true, "pwd").await {
|
||||
warn!(
|
||||
"CWD tracker for session {}: failed to exec pwd: {}",
|
||||
session_id, e
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Read the output.
|
||||
let mut output = String::new();
|
||||
loop {
|
||||
match exec_channel.wait().await {
|
||||
Some(ChannelMsg::Data { ref data }) => {
|
||||
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
|
||||
output.push_str(text);
|
||||
}
|
||||
}
|
||||
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,
|
||||
Some(ChannelMsg::ExitStatus { .. }) => {
|
||||
// pwd exited — we may still get data, keep reading
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let cwd = output.trim().to_string();
|
||||
|
||||
if !cwd.is_empty() && cwd != previous_cwd {
|
||||
previous_cwd = cwd.clone();
|
||||
|
||||
// Update the watch channel.
|
||||
let _ = sender.send(cwd.clone());
|
||||
|
||||
// Emit event to frontend.
|
||||
let event_name = format!("ssh:cwd:{}", session_id);
|
||||
if let Err(e) = app_handle.emit(&event_name, &cwd) {
|
||||
error!(
|
||||
"CWD tracker for session {}: failed to emit event: {}",
|
||||
session_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait 2 seconds before the next poll.
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
debug!("CWD tracker for session {} stopped", session_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
203
src-tauri/src/ssh/host_key.rs
Normal file
203
src-tauri/src/ssh/host_key.rs
Normal file
@ -0,0 +1,203 @@
|
||||
//! TOFU (Trust On First Use) host key verification backed by SQLite.
|
||||
//!
|
||||
//! On first connection to a host, the server's public key fingerprint is stored.
|
||||
//! On subsequent connections, the stored fingerprint is compared against the
|
||||
//! presented key. If the fingerprint has changed, the connection is flagged as
|
||||
//! potentially compromised (MITM warning).
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use crate::db::Database;
|
||||
|
||||
/// Result of verifying a host key against the local store.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum HostKeyResult {
|
||||
/// No key on file for this host — first connection.
|
||||
New,
|
||||
/// Stored fingerprint matches the presented key.
|
||||
Match,
|
||||
/// Stored fingerprint differs from the presented key (possible MITM).
|
||||
Changed,
|
||||
}
|
||||
|
||||
/// Persistent host key store using the `host_keys` SQLite table.
|
||||
pub struct HostKeyStore {
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl HostKeyStore {
|
||||
pub fn new(db: Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Check whether a host key fingerprint is known, matches, or has changed.
|
||||
///
|
||||
/// - Returns `New` if no row exists for `(hostname, port, key_type)`.
|
||||
/// - Returns `Match` if the stored fingerprint equals `fingerprint`.
|
||||
/// - Returns `Changed` if the stored fingerprint differs.
|
||||
pub fn verify(
|
||||
&self,
|
||||
hostname: &str,
|
||||
port: u16,
|
||||
key_type: &str,
|
||||
fingerprint: &str,
|
||||
) -> Result<HostKeyResult, String> {
|
||||
let conn = self.db.conn();
|
||||
|
||||
let stored: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT fingerprint FROM host_keys
|
||||
WHERE hostname = ?1 AND port = ?2 AND key_type = ?3",
|
||||
params![hostname, port as i64, key_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
match stored {
|
||||
None => Ok(HostKeyResult::New),
|
||||
Some(ref stored_fp) if stored_fp == fingerprint => Ok(HostKeyResult::Match),
|
||||
Some(_) => Ok(HostKeyResult::Changed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store (or update) a host key fingerprint.
|
||||
///
|
||||
/// Uses `INSERT OR REPLACE` so that calling `store` after a `Changed`
|
||||
/// result will update the key (i.e. the user accepted the new key).
|
||||
pub fn store(
|
||||
&self,
|
||||
hostname: &str,
|
||||
port: u16,
|
||||
key_type: &str,
|
||||
fingerprint: &str,
|
||||
raw_key: &str,
|
||||
) -> Result<(), String> {
|
||||
let conn = self.db.conn();
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO host_keys (hostname, port, key_type, fingerprint, raw_key, first_seen)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP)",
|
||||
params![hostname, port as i64, key_type, fingerprint, raw_key],
|
||||
)
|
||||
.map_err(|e| format!("Failed to store host key for {hostname}:{port}: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a stored host key for a specific host/port.
|
||||
pub fn delete(&self, hostname: &str, port: u16) -> Result<(), String> {
|
||||
let conn = self.db.conn();
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM host_keys WHERE hostname = ?1 AND port = ?2",
|
||||
params![hostname, port as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to delete host key for {hostname}:{port}: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
fn make_store() -> HostKeyStore {
|
||||
let db = Database::open(std::path::Path::new(":memory:")).unwrap();
|
||||
db.migrate().unwrap();
|
||||
HostKeyStore::new(db)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_host_returns_new() {
|
||||
let store = make_store();
|
||||
let result = store
|
||||
.verify("example.com", 22, "ssh-ed25519", "SHA256:abc123")
|
||||
.unwrap();
|
||||
assert_eq!(result, HostKeyResult::New);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_host_returns_match() {
|
||||
let store = make_store();
|
||||
store
|
||||
.store("example.com", 22, "ssh-ed25519", "SHA256:abc123", "AAAA")
|
||||
.unwrap();
|
||||
|
||||
let result = store
|
||||
.verify("example.com", 22, "ssh-ed25519", "SHA256:abc123")
|
||||
.unwrap();
|
||||
assert_eq!(result, HostKeyResult::Match);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_fingerprint_returns_changed() {
|
||||
let store = make_store();
|
||||
store
|
||||
.store("example.com", 22, "ssh-ed25519", "SHA256:abc123", "AAAA")
|
||||
.unwrap();
|
||||
|
||||
let result = store
|
||||
.verify("example.com", 22, "ssh-ed25519", "SHA256:DIFFERENT")
|
||||
.unwrap();
|
||||
assert_eq!(result, HostKeyResult::Changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_port_is_separate_host() {
|
||||
let store = make_store();
|
||||
store
|
||||
.store("example.com", 22, "ssh-ed25519", "SHA256:abc123", "AAAA")
|
||||
.unwrap();
|
||||
|
||||
let result = store
|
||||
.verify("example.com", 2222, "ssh-ed25519", "SHA256:abc123")
|
||||
.unwrap();
|
||||
assert_eq!(result, HostKeyResult::New);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_overwrites_on_replace() {
|
||||
let store = make_store();
|
||||
store
|
||||
.store("example.com", 22, "ssh-ed25519", "SHA256:old", "AAAA")
|
||||
.unwrap();
|
||||
store
|
||||
.store("example.com", 22, "ssh-ed25519", "SHA256:new", "BBBB")
|
||||
.unwrap();
|
||||
|
||||
let result = store
|
||||
.verify("example.com", 22, "ssh-ed25519", "SHA256:new")
|
||||
.unwrap();
|
||||
assert_eq!(result, HostKeyResult::Match);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_all_key_types_for_host() {
|
||||
let store = make_store();
|
||||
store
|
||||
.store("example.com", 22, "ssh-ed25519", "SHA256:abc", "AAAA")
|
||||
.unwrap();
|
||||
store
|
||||
.store("example.com", 22, "ssh-rsa", "SHA256:def", "BBBB")
|
||||
.unwrap();
|
||||
|
||||
store.delete("example.com", 22).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.verify("example.com", 22, "ssh-ed25519", "SHA256:abc")
|
||||
.unwrap(),
|
||||
HostKeyResult::New
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.verify("example.com", 22, "ssh-rsa", "SHA256:def")
|
||||
.unwrap(),
|
||||
HostKeyResult::New
|
||||
);
|
||||
}
|
||||
}
|
||||
3
src-tauri/src/ssh/mod.rs
Normal file
3
src-tauri/src/ssh/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod session;
|
||||
pub mod host_key;
|
||||
pub mod cwd;
|
||||
419
src-tauri/src/ssh/session.rs
Normal file
419
src-tauri/src/ssh/session.rs
Normal file
@ -0,0 +1,419 @@
|
||||
//! SSH session manager — connects, authenticates, manages PTY channels.
|
||||
//!
|
||||
//! Each SSH session runs asynchronously via tokio. Terminal stdout is read in a
|
||||
//! loop and emitted to the frontend via Tauri events (`ssh:data:{session_id}`,
|
||||
//! base64 encoded). Terminal stdin receives data from the frontend via Tauri
|
||||
//! commands.
|
||||
//!
|
||||
//! Sessions are stored in a `DashMap<String, Arc<SshSession>>`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use dashmap::DashMap;
|
||||
use log::{debug, error, info, warn};
|
||||
use russh::client::{self, Handle, Msg};
|
||||
use russh::{Channel, ChannelMsg, Disconnect};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::ssh::cwd::CwdTracker;
|
||||
use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
|
||||
|
||||
// ── auth method ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Authentication method for SSH connections.
|
||||
pub enum AuthMethod {
|
||||
Password(String),
|
||||
Key {
|
||||
private_key_pem: String,
|
||||
passphrase: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ── session info (serializable for frontend) ─────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub hostname: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
// ── SSH session ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Represents a single active SSH session with a PTY channel.
|
||||
pub struct SshSession {
|
||||
pub id: String,
|
||||
pub hostname: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
/// The PTY channel used for interactive shell I/O.
|
||||
pub channel: Arc<TokioMutex<Channel<Msg>>>,
|
||||
/// Handle to the underlying SSH connection (used for opening new channels).
|
||||
pub handle: Arc<TokioMutex<Handle<SshClient>>>,
|
||||
/// CWD tracker that polls via a separate exec channel.
|
||||
pub cwd_tracker: Option<CwdTracker>,
|
||||
}
|
||||
|
||||
// ── SSH client handler ───────────────────────────────────────────────────────
|
||||
|
||||
/// Minimal `russh::client::Handler` implementation.
|
||||
///
|
||||
/// Host key verification is done via TOFU in the `HostKeyStore`. The handler
|
||||
/// stores the verification result so the connect flow can check it after
|
||||
/// `client::connect` returns.
|
||||
pub struct SshClient {
|
||||
host_key_store: HostKeyStore,
|
||||
hostname: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl client::Handler for SshClient {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
server_public_key: &ssh_key::PublicKey,
|
||||
) -> Result<bool, Self::Error> {
|
||||
let key_type = server_public_key.algorithm().to_string();
|
||||
let fingerprint = server_public_key
|
||||
.fingerprint(ssh_key::HashAlg::Sha256)
|
||||
.to_string();
|
||||
|
||||
let raw_key = server_public_key
|
||||
.to_openssh()
|
||||
.unwrap_or_default();
|
||||
|
||||
match self
|
||||
.host_key_store
|
||||
.verify(&self.hostname, self.port, &key_type, &fingerprint)
|
||||
{
|
||||
Ok(HostKeyResult::New) => {
|
||||
info!(
|
||||
"New host key for {}:{} ({}): {}",
|
||||
self.hostname, self.port, key_type, fingerprint
|
||||
);
|
||||
// TOFU: store the key on first contact.
|
||||
if let Err(e) = self.host_key_store.store(
|
||||
&self.hostname,
|
||||
self.port,
|
||||
&key_type,
|
||||
&fingerprint,
|
||||
&raw_key,
|
||||
) {
|
||||
warn!("Failed to store host key: {}", e);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Ok(HostKeyResult::Match) => {
|
||||
debug!(
|
||||
"Host key match for {}:{} ({})",
|
||||
self.hostname, self.port, key_type
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Ok(HostKeyResult::Changed) => {
|
||||
error!(
|
||||
"HOST KEY CHANGED for {}:{} ({})! Expected stored fingerprint, got {}. \
|
||||
Possible man-in-the-middle attack.",
|
||||
self.hostname, self.port, key_type, fingerprint
|
||||
);
|
||||
// Reject the connection — the frontend should prompt the user
|
||||
// to accept the new key and call delete + reconnect.
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Host key verification error: {}", e);
|
||||
// On DB error, reject to be safe.
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSH service ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Manages all active SSH sessions.
|
||||
pub struct SshService {
|
||||
sessions: DashMap<String, Arc<SshSession>>,
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl SshService {
|
||||
pub fn new(db: Database) -> Self {
|
||||
Self {
|
||||
sessions: DashMap::new(),
|
||||
db,
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish an SSH connection, authenticate, open a PTY, start a shell,
|
||||
/// and begin streaming output to the frontend.
|
||||
///
|
||||
/// Returns the session UUID on success.
|
||||
pub async fn connect(
|
||||
&self,
|
||||
app_handle: AppHandle,
|
||||
hostname: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
auth: AuthMethod,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
) -> Result<String, String> {
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// Build russh client config.
|
||||
let config = russh::client::Config::default();
|
||||
let config = Arc::new(config);
|
||||
|
||||
// Build our handler with TOFU host key verification.
|
||||
let handler = SshClient {
|
||||
host_key_store: HostKeyStore::new(self.db.clone()),
|
||||
hostname: hostname.to_string(),
|
||||
port,
|
||||
};
|
||||
|
||||
// Connect to the SSH server.
|
||||
let mut handle = client::connect(config, (hostname, port), handler)
|
||||
.await
|
||||
.map_err(|e| format!("SSH connection to {}:{} failed: {}", hostname, port, e))?;
|
||||
|
||||
// Authenticate.
|
||||
let auth_success = match auth {
|
||||
AuthMethod::Password(password) => {
|
||||
handle
|
||||
.authenticate_password(username, &password)
|
||||
.await
|
||||
.map_err(|e| format!("Password authentication failed: {}", e))?
|
||||
}
|
||||
AuthMethod::Key {
|
||||
private_key_pem,
|
||||
passphrase,
|
||||
} => {
|
||||
let key = russh::keys::decode_secret_key(
|
||||
&private_key_pem,
|
||||
passphrase.as_deref(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to decode private key: {}", e))?;
|
||||
|
||||
handle
|
||||
.authenticate_publickey(username, Arc::new(key))
|
||||
.await
|
||||
.map_err(|e| format!("Public key authentication failed: {}", e))?
|
||||
}
|
||||
};
|
||||
|
||||
if !auth_success {
|
||||
return Err("Authentication failed: server rejected credentials".to_string());
|
||||
}
|
||||
|
||||
// Open a session channel.
|
||||
let channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to open session channel: {}", e))?;
|
||||
|
||||
// Request a PTY.
|
||||
channel
|
||||
.request_pty(
|
||||
true,
|
||||
"xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
0, // pix_width
|
||||
0, // pix_height
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to request PTY: {}", e))?;
|
||||
|
||||
// Start a shell.
|
||||
channel
|
||||
.request_shell(true)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start shell: {}", e))?;
|
||||
|
||||
let handle = Arc::new(TokioMutex::new(handle));
|
||||
let channel = Arc::new(TokioMutex::new(channel));
|
||||
|
||||
// Start CWD tracker.
|
||||
let cwd_tracker = CwdTracker::new();
|
||||
cwd_tracker.start(
|
||||
handle.clone(),
|
||||
app_handle.clone(),
|
||||
session_id.clone(),
|
||||
);
|
||||
|
||||
// Build session object.
|
||||
let session = Arc::new(SshSession {
|
||||
id: session_id.clone(),
|
||||
hostname: hostname.to_string(),
|
||||
port,
|
||||
username: username.to_string(),
|
||||
channel: channel.clone(),
|
||||
handle: handle.clone(),
|
||||
cwd_tracker: Some(cwd_tracker),
|
||||
});
|
||||
|
||||
self.sessions.insert(session_id.clone(), session);
|
||||
|
||||
// Spawn the stdout read loop.
|
||||
let sid = session_id.clone();
|
||||
let chan = channel.clone();
|
||||
let app = app_handle.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let msg = {
|
||||
let mut ch = chan.lock().await;
|
||||
ch.wait().await
|
||||
};
|
||||
|
||||
match msg {
|
||||
Some(ChannelMsg::Data { ref data }) => {
|
||||
let encoded = base64::engine::general_purpose::STANDARD
|
||||
.encode(data.as_ref());
|
||||
let event_name = format!("ssh:data:{}", sid);
|
||||
if let Err(e) = app.emit(&event_name, encoded) {
|
||||
error!("Failed to emit SSH data event: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(ChannelMsg::ExtendedData { ref data, .. }) => {
|
||||
// stderr — emit on the same event channel so the
|
||||
// terminal renders it inline (same as a real terminal).
|
||||
let encoded = base64::engine::general_purpose::STANDARD
|
||||
.encode(data.as_ref());
|
||||
let event_name = format!("ssh:data:{}", sid);
|
||||
if let Err(e) = app.emit(&event_name, encoded) {
|
||||
error!("Failed to emit SSH stderr event: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(ChannelMsg::ExitStatus { exit_status }) => {
|
||||
info!("SSH session {} exited with status {}", sid, exit_status);
|
||||
let event_name = format!("ssh:exit:{}", sid);
|
||||
let _ = app.emit(&event_name, exit_status);
|
||||
break;
|
||||
}
|
||||
Some(ChannelMsg::Eof) => {
|
||||
debug!("SSH session {} received EOF", sid);
|
||||
}
|
||||
Some(ChannelMsg::Close) => {
|
||||
info!("SSH session {} channel closed", sid);
|
||||
let event_name = format!("ssh:close:{}", sid);
|
||||
let _ = app.emit(&event_name, ());
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
info!("SSH session {} channel stream ended", sid);
|
||||
let event_name = format!("ssh:close:{}", sid);
|
||||
let _ = app.emit(&event_name, ());
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
// Ignore other channel messages (WindowAdjust, etc.)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
info!(
|
||||
"SSH session {} connected to {}@{}:{}",
|
||||
session_id, username, hostname, port
|
||||
);
|
||||
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Write data to a session's PTY stdin.
|
||||
pub async fn write(&self, session_id: &str, data: &[u8]) -> Result<(), String> {
|
||||
let session = self
|
||||
.sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
|
||||
let channel: tokio::sync::MutexGuard<'_, Channel<Msg>> =
|
||||
session.channel.lock().await;
|
||||
channel
|
||||
.data(&data[..])
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write to session {}: {}", session_id, e))
|
||||
}
|
||||
|
||||
/// Resize the PTY window for a session.
|
||||
pub async fn resize(
|
||||
&self,
|
||||
session_id: &str,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
) -> Result<(), String> {
|
||||
let session = self
|
||||
.sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
|
||||
let channel: tokio::sync::MutexGuard<'_, Channel<Msg>> =
|
||||
session.channel.lock().await;
|
||||
channel
|
||||
.window_change(cols, rows, 0, 0)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to resize session {}: {}", session_id, e))
|
||||
}
|
||||
|
||||
/// Disconnect a session — close the channel and remove it from the map.
|
||||
pub async fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
||||
let (_, session) = self
|
||||
.sessions
|
||||
.remove(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
|
||||
// Close the channel gracefully.
|
||||
{
|
||||
let channel: tokio::sync::MutexGuard<'_, Channel<Msg>> =
|
||||
session.channel.lock().await;
|
||||
let _ = channel.eof().await;
|
||||
let _ = channel.close().await;
|
||||
}
|
||||
|
||||
// Disconnect the SSH connection.
|
||||
{
|
||||
let handle = session.handle.lock().await;
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "", "en")
|
||||
.await;
|
||||
}
|
||||
|
||||
info!("SSH session {} disconnected", session_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a reference to a session by ID.
|
||||
pub fn get_session(&self, session_id: &str) -> Option<Arc<SshSession>> {
|
||||
self.sessions.get(session_id).map(|entry| entry.clone())
|
||||
}
|
||||
|
||||
/// List all active sessions (metadata only).
|
||||
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||
self.sessions
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let s = entry.value();
|
||||
SessionInfo {
|
||||
id: s.id.clone(),
|
||||
hostname: s.hostname.clone(),
|
||||
port: s.port,
|
||||
username: s.username.clone(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
49
src/assets/css/terminal.css
Normal file
49
src/assets/css/terminal.css
Normal file
@ -0,0 +1,49 @@
|
||||
/* xterm.js terminal container styling */
|
||||
|
||||
.terminal-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--wraith-bg-primary);
|
||||
}
|
||||
|
||||
.terminal-container .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-screen {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Selection styling */
|
||||
.terminal-container .xterm-selection div {
|
||||
background-color: rgba(88, 166, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Cursor styling */
|
||||
.terminal-container .xterm-cursor-layer {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* Scrollbar inside terminal */
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-track {
|
||||
background: var(--wraith-bg-primary);
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: var(--wraith-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--wraith-text-muted);
|
||||
}
|
||||
69
src/components/common/StatusBar.vue
Normal file
69
src/components/common/StatusBar.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="h-6 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-t border-[var(--wraith-border)] text-[10px] text-[var(--wraith-text-muted)] shrink-0">
|
||||
<!-- Left: connection info -->
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="sessionStore.activeSession">
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="sessionStore.activeSession.protocol === 'ssh' ? 'bg-[#3fb950]' : 'bg-[#1f6feb]'"
|
||||
/>
|
||||
{{ sessionStore.activeSession.protocol.toUpperCase() }}
|
||||
</span>
|
||||
<span class="text-[var(--wraith-text-secondary)]">·</span>
|
||||
<span>{{ connectionInfo }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>Ready</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Right: terminal info -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
title="Change terminal theme"
|
||||
@click="emit('open-theme-picker')"
|
||||
>
|
||||
Theme: {{ activeThemeName }}
|
||||
</button>
|
||||
<span>UTF-8</span>
|
||||
<span v-if="sessionStore.activeDimensions">
|
||||
{{ sessionStore.activeDimensions.cols }}×{{ sessionStore.activeDimensions.rows }}
|
||||
</span>
|
||||
<span v-else>120×40</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
import { useConnectionStore } from "@/stores/connection.store";
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const activeThemeName = ref("Default");
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "open-theme-picker"): void;
|
||||
}>();
|
||||
|
||||
const connectionInfo = computed(() => {
|
||||
const session = sessionStore.activeSession;
|
||||
if (!session) return "";
|
||||
|
||||
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
|
||||
if (!conn) return session.name;
|
||||
|
||||
const user = session.username ? `${session.username}@` : "";
|
||||
return `${user}${conn.hostname}:${conn.port}`;
|
||||
});
|
||||
|
||||
function setThemeName(name: string): void {
|
||||
activeThemeName.value = name;
|
||||
}
|
||||
|
||||
defineExpose({ setThemeName, activeThemeName });
|
||||
</script>
|
||||
43
src/components/session/SessionContainer.vue
Normal file
43
src/components/session/SessionContainer.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col bg-[var(--wraith-bg-primary)] min-h-0 relative">
|
||||
<!-- Terminal views — v-show keeps them alive across tab switches -->
|
||||
<div
|
||||
v-for="session in sshSessions"
|
||||
:key="session.id"
|
||||
v-show="session.id === sessionStore.activeSessionId"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<TerminalView
|
||||
:session-id="session.id"
|
||||
:is-active="session.id === sessionStore.activeSessionId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- No session placeholder -->
|
||||
<div
|
||||
v-if="!sessionStore.activeSession"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="text-[var(--wraith-text-muted)] text-sm">
|
||||
No active session
|
||||
</p>
|
||||
<p class="text-[var(--wraith-text-muted)] text-xs mt-1">
|
||||
Double-click a connection to start a session
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
import TerminalView from "@/components/terminal/TerminalView.vue";
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
|
||||
const sshSessions = computed(() =>
|
||||
sessionStore.sessions.filter((s) => s.protocol === "ssh"),
|
||||
);
|
||||
</script>
|
||||
126
src/components/session/TabBar.vue
Normal file
126
src/components/session/TabBar.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="flex items-center bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] h-9 shrink-0">
|
||||
<!-- Tabs -->
|
||||
<div class="flex items-center overflow-x-auto min-w-0">
|
||||
<button
|
||||
v-for="session in sessionStore.sessions"
|
||||
:key="session.id"
|
||||
class="group flex items-center gap-2 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0"
|
||||
:class="[
|
||||
session.id === sessionStore.activeSessionId
|
||||
? 'bg-[var(--wraith-bg-primary)] text-[var(--wraith-text-primary)] border-b-2 border-b-[var(--wraith-accent-blue)]'
|
||||
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] hover:bg-[var(--wraith-bg-tertiary)]',
|
||||
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
|
||||
]"
|
||||
@click="sessionStore.activateSession(session.id)"
|
||||
>
|
||||
<!-- Protocol icon -->
|
||||
<span class="shrink-0">
|
||||
<!-- SSH terminal icon -->
|
||||
<svg
|
||||
v-if="session.protocol === 'ssh'"
|
||||
class="w-3.5 h-3.5 text-[#3fb950]"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 1 1-1.06-1.06L5.44 8 3.72 6.28a.749.749 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z" />
|
||||
</svg>
|
||||
<!-- RDP monitor icon -->
|
||||
<svg
|
||||
v-else
|
||||
class="w-3.5 h-3.5 text-[#1f6feb]"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M1.75 2.5h12.5a.25.25 0 0 1 .25.25v7.5a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25v-7.5a.25.25 0 0 1 .25-.25ZM14.25 1H1.75A1.75 1.75 0 0 0 0 2.75v7.5C0 11.216.784 12 1.75 12h4.388l-.533 1.5H4a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5h-1.605l-.533-1.5h4.388A1.75 1.75 0 0 0 16 10.25v-7.5A1.75 1.75 0 0 0 14.25 1ZM9.112 13.5H6.888l.533-1.5h1.158l.533 1.5Z" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<span>{{ session.name }}</span>
|
||||
|
||||
<!-- Environment tag badges -->
|
||||
<template v-if="getSessionTags(session).length > 0">
|
||||
<span
|
||||
v-for="tag in getSessionTags(session)"
|
||||
:key="tag"
|
||||
class="px-1 py-0.5 text-[9px] font-semibold rounded leading-none"
|
||||
:class="tagClass(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Close button -->
|
||||
<span
|
||||
class="ml-1 opacity-0 group-hover:opacity-100 hover:text-[var(--wraith-accent-red)] transition-opacity"
|
||||
@click.stop="sessionStore.closeSession(session.id)"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New tab button -->
|
||||
<button
|
||||
class="flex items-center justify-center w-9 h-9 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer shrink-0"
|
||||
title="New session"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSessionStore, type Session } from "@/stores/session.store";
|
||||
import { useConnectionStore } from "@/stores/connection.store";
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
/** Get tags for a session's underlying connection. */
|
||||
function getSessionTags(session: Session): string[] {
|
||||
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
|
||||
return conn?.tags ?? [];
|
||||
}
|
||||
|
||||
/** Check if the connection for this session uses the root user. */
|
||||
function isRootUser(session: Session): boolean {
|
||||
// Check username stored on the session object (set during connect)
|
||||
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);
|
||||
if (!conn) return false;
|
||||
|
||||
if (conn.options) {
|
||||
try {
|
||||
const opts = JSON.parse(conn.options);
|
||||
if (opts?.username === "root") return true;
|
||||
} catch {
|
||||
// ignore malformed options
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if "root" appears in the connection tags
|
||||
return conn.tags?.includes("root") ?? false;
|
||||
}
|
||||
|
||||
/** Return Tailwind classes for environment tag badges. */
|
||||
function tagClass(tag: string): string {
|
||||
const t = tag.toUpperCase();
|
||||
if (t === "PROD" || t === "PRODUCTION") {
|
||||
return "bg-[#da3633]/20 text-[#f85149]";
|
||||
}
|
||||
if (t === "DEV" || t === "DEVELOPMENT") {
|
||||
return "bg-[#238636]/20 text-[#3fb950]";
|
||||
}
|
||||
if (t === "STAGING" || t === "STG") {
|
||||
return "bg-[#9e6a03]/20 text-[#d29922]";
|
||||
}
|
||||
if (t === "TEST" || t === "QA") {
|
||||
return "bg-[#1f6feb]/20 text-[#58a6ff]";
|
||||
}
|
||||
// Default for other tags
|
||||
return "bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]";
|
||||
}
|
||||
</script>
|
||||
251
src/components/sidebar/ConnectionTree.vue
Normal file
251
src/components/sidebar/ConnectionTree.vue
Normal file
@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="py-1">
|
||||
<!-- Add Group / Add Host buttons -->
|
||||
<div class="flex gap-1 px-3 py-1.5 border-b border-[var(--wraith-border)]">
|
||||
<button
|
||||
class="flex-1 flex items-center justify-center gap-1 px-2 py-1 text-[10px] font-medium rounded
|
||||
bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]
|
||||
hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-secondary)] transition-colors"
|
||||
title="New Group"
|
||||
@click="addGroup"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
|
||||
Group
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 flex items-center justify-center gap-1 px-2 py-1 text-[10px] font-medium rounded
|
||||
bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]
|
||||
hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-secondary)] transition-colors"
|
||||
title="New Connection"
|
||||
@click="editDialog?.openNew()"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
|
||||
Host
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-for="group in connectionStore.groups" :key="group.id">
|
||||
<!-- Only show groups that have matching connections during search -->
|
||||
<div v-if="!connectionStore.searchQuery || connectionStore.groupHasResults(group.id)">
|
||||
<!-- Group header -->
|
||||
<button
|
||||
class="w-full flex items-center gap-1.5 px-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
|
||||
@click="toggleGroup(group.id)"
|
||||
@contextmenu.prevent="showGroupMenu($event, group)"
|
||||
>
|
||||
<!-- Chevron -->
|
||||
<svg
|
||||
class="w-3 h-3 text-[var(--wraith-text-muted)] transition-transform shrink-0"
|
||||
:class="{ 'rotate-90': expandedGroups.has(group.id) }"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M6 4l4 4-4 4z" />
|
||||
</svg>
|
||||
|
||||
<!-- Folder icon -->
|
||||
<svg
|
||||
class="w-3.5 h-3.5 text-[var(--wraith-accent-yellow)] shrink-0"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z" />
|
||||
</svg>
|
||||
|
||||
<span class="text-[var(--wraith-text-primary)] truncate">{{ group.name }}</span>
|
||||
|
||||
<!-- Connection count -->
|
||||
<span class="ml-auto text-[var(--wraith-text-muted)] text-[10px]">
|
||||
{{ connectionStore.connectionsByGroup(group.id).length }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Connections in group -->
|
||||
<div v-if="expandedGroups.has(group.id)">
|
||||
<button
|
||||
v-for="conn in connectionStore.connectionsByGroup(group.id)"
|
||||
:key="conn.id"
|
||||
class="w-full flex items-center gap-2 pl-8 pr-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
|
||||
@dblclick="handleConnect(conn)"
|
||||
@contextmenu.prevent="showConnectionMenu($event, conn)"
|
||||
>
|
||||
<!-- Protocol dot -->
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="conn.protocol === 'ssh' ? 'bg-[#3fb950]' : 'bg-[#1f6feb]'"
|
||||
/>
|
||||
<span class="text-[var(--wraith-text-primary)] truncate">{{ conn.name }}</span>
|
||||
<span
|
||||
v-for="tag in conn.tags"
|
||||
:key="tag"
|
||||
class="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Context menu -->
|
||||
<ContextMenu ref="contextMenu" />
|
||||
|
||||
<!-- Edit dialog -->
|
||||
<ConnectionEditDialog ref="editDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useConnectionStore, type Connection, type Group } from "@/stores/connection.store";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
import ContextMenu from "@/components/common/ContextMenu.vue";
|
||||
import type { ContextMenuItem } from "@/components/common/ContextMenu.vue";
|
||||
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
|
||||
|
||||
const connectionStore = useConnectionStore();
|
||||
const sessionStore = useSessionStore();
|
||||
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu> | null>(null);
|
||||
const editDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
|
||||
|
||||
// All groups expanded by default
|
||||
const expandedGroups = ref<Set<number>>(
|
||||
new Set(connectionStore.groups.map((g) => g.id)),
|
||||
);
|
||||
|
||||
function toggleGroup(groupId: number): void {
|
||||
if (expandedGroups.value.has(groupId)) {
|
||||
expandedGroups.value.delete(groupId);
|
||||
} else {
|
||||
expandedGroups.value.add(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a new group. */
|
||||
async function addGroup(): Promise<void> {
|
||||
const name = prompt("New group name:");
|
||||
if (!name) return;
|
||||
try {
|
||||
await invoke("create_group", { name, parentId: null });
|
||||
await connectionStore.loadGroups();
|
||||
} catch (err) {
|
||||
console.error("Failed to create group:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Double-click a connection to open a new session. */
|
||||
function handleConnect(conn: Connection): void {
|
||||
sessionStore.connect(conn.id);
|
||||
}
|
||||
|
||||
/** Show context menu for a connection. */
|
||||
function showConnectionMenu(event: MouseEvent, conn: Connection): void {
|
||||
const items: ContextMenuItem[] = [
|
||||
{
|
||||
label: "Connect",
|
||||
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25Zm5 1.22v8.06l4.97-4.03L6 3.97Z"/></svg>`,
|
||||
action: () => handleConnect(conn),
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: "Edit",
|
||||
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.25.25 0 0 0-.064.108l-.558 1.953 1.953-.558a.249.249 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/></svg>`,
|
||||
action: () => editDialog.value?.openEdit(conn),
|
||||
},
|
||||
{
|
||||
label: "Duplicate",
|
||||
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25ZM5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>`,
|
||||
action: () => duplicateConnection(conn),
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: "Delete",
|
||||
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"/></svg>`,
|
||||
danger: true,
|
||||
action: () => deleteConnection(conn),
|
||||
},
|
||||
];
|
||||
|
||||
contextMenu.value?.open(event, items);
|
||||
}
|
||||
|
||||
/** Show context menu for a group. */
|
||||
function showGroupMenu(event: MouseEvent, group: Group): void {
|
||||
const items: ContextMenuItem[] = [
|
||||
{
|
||||
label: "New Connection",
|
||||
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>`,
|
||||
action: () => editDialog.value?.openNew(group.id),
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: "Rename",
|
||||
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.25.25 0 0 0-.064.108l-.558 1.953 1.953-.558a.249.249 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/></svg>`,
|
||||
action: async () => {
|
||||
const newName = prompt("Rename group:", group.name);
|
||||
if (newName && newName !== group.name) {
|
||||
try {
|
||||
await invoke("rename_group", { groupId: group.id, name: newName });
|
||||
await connectionStore.loadGroups();
|
||||
} catch (err) {
|
||||
console.error("Failed to rename group:", err);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"/></svg>`,
|
||||
danger: true,
|
||||
action: () => deleteGroup(group),
|
||||
},
|
||||
];
|
||||
|
||||
contextMenu.value?.open(event, items);
|
||||
}
|
||||
|
||||
/** Duplicate a connection via the Rust backend. */
|
||||
async function duplicateConnection(conn: Connection): Promise<void> {
|
||||
try {
|
||||
await invoke("create_connection", {
|
||||
name: `${conn.name} (copy)`,
|
||||
hostname: conn.hostname,
|
||||
port: conn.port,
|
||||
protocol: conn.protocol,
|
||||
groupId: conn.groupId,
|
||||
credentialId: conn.credentialId ?? null,
|
||||
color: conn.color ?? "",
|
||||
tags: conn.tags ?? [],
|
||||
notes: conn.notes ?? "",
|
||||
options: conn.options ?? "{}",
|
||||
});
|
||||
await connectionStore.loadConnections();
|
||||
} catch (err) {
|
||||
console.error("Failed to duplicate connection:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a connection via the Rust backend after confirmation. */
|
||||
async function deleteConnection(conn: Connection): Promise<void> {
|
||||
if (!confirm(`Delete "${conn.name}"?`)) return;
|
||||
try {
|
||||
await invoke("delete_connection", { connectionId: conn.id });
|
||||
await connectionStore.loadConnections();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete connection:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a group via the Rust backend after confirmation. */
|
||||
async function deleteGroup(group: Group): Promise<void> {
|
||||
if (!confirm(`Delete group "${group.name}" and all its connections?`)) return;
|
||||
try {
|
||||
await invoke("delete_group", { groupId: group.id });
|
||||
await connectionStore.loadAll();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete group:", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
34
src/components/sidebar/SidebarToggle.vue
Normal file
34
src/components/sidebar/SidebarToggle.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex border-b border-[var(--wraith-border)]">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="flex-1 py-2 text-xs font-medium text-center transition-colors cursor-pointer"
|
||||
:class="
|
||||
modelValue === tab.id
|
||||
? 'text-[var(--wraith-accent-blue)] border-b-2 border-[var(--wraith-accent-blue)]'
|
||||
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)]'
|
||||
"
|
||||
@click="emit('update:modelValue', tab.id)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export type SidebarTab = "connections" | "sftp";
|
||||
|
||||
const tabs = [
|
||||
{ id: "connections" as const, label: "Connections" },
|
||||
{ id: "sftp" as const, label: "SFTP" },
|
||||
];
|
||||
|
||||
defineProps<{
|
||||
modelValue: SidebarTab;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [tab: SidebarTab];
|
||||
}>();
|
||||
</script>
|
||||
89
src/components/terminal/TerminalView.vue
Normal file
89
src/components/terminal/TerminalView.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="terminal-container"
|
||||
@focus="handleFocus"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { useTerminal } from "@/composables/useTerminal";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
import "@/assets/css/terminal.css";
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string;
|
||||
isActive: boolean;
|
||||
}>();
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const { terminal, mount, fit } = useTerminal(props.sessionId);
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
mount(containerRef.value);
|
||||
}
|
||||
|
||||
// Apply the current theme immediately if one is already active
|
||||
if (sessionStore.activeTheme) {
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
// Track terminal dimensions in the session store
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
sessionStore.setTerminalDimensions(props.sessionId, cols, rows);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-fit and focus terminal when this tab becomes active
|
||||
watch(
|
||||
() => props.isActive,
|
||||
(active) => {
|
||||
if (active) {
|
||||
// nextTick is not needed — fit and focus happen after the DOM update
|
||||
setTimeout(() => {
|
||||
fit();
|
||||
terminal.focus();
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** Apply the session store's active theme to this terminal instance. */
|
||||
function applyTheme(): void {
|
||||
const theme = sessionStore.activeTheme;
|
||||
if (!theme) return;
|
||||
terminal.options.theme = {
|
||||
background: theme.background,
|
||||
foreground: theme.foreground,
|
||||
cursor: theme.cursor,
|
||||
black: theme.black,
|
||||
red: theme.red,
|
||||
green: theme.green,
|
||||
yellow: theme.yellow,
|
||||
blue: theme.blue,
|
||||
magenta: theme.magenta,
|
||||
cyan: theme.cyan,
|
||||
white: theme.white,
|
||||
brightBlack: theme.brightBlack,
|
||||
brightRed: theme.brightRed,
|
||||
brightGreen: theme.brightGreen,
|
||||
brightYellow: theme.brightYellow,
|
||||
brightBlue: theme.brightBlue,
|
||||
brightMagenta: theme.brightMagenta,
|
||||
brightCyan: theme.brightCyan,
|
||||
brightWhite: theme.brightWhite,
|
||||
};
|
||||
}
|
||||
|
||||
// Watch for theme changes in the session store and apply to this terminal
|
||||
watch(() => sessionStore.activeTheme, (newTheme) => {
|
||||
if (newTheme) applyTheme();
|
||||
});
|
||||
|
||||
function handleFocus(): void {
|
||||
terminal.focus();
|
||||
}
|
||||
</script>
|
||||
236
src/composables/useTerminal.ts
Normal file
236
src/composables/useTerminal.ts
Normal file
@ -0,0 +1,236 @@
|
||||
import { onBeforeUnmount } from "vue";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
/** MobaXTerm Classic–inspired terminal theme colors. */
|
||||
const defaultTheme = {
|
||||
background: "#0d1117",
|
||||
foreground: "#e0e0e0",
|
||||
cursor: "#58a6ff",
|
||||
cursorAccent: "#0d1117",
|
||||
selectionBackground: "rgba(88, 166, 255, 0.3)",
|
||||
selectionForeground: "#ffffff",
|
||||
black: "#0d1117",
|
||||
red: "#f85149",
|
||||
green: "#3fb950",
|
||||
yellow: "#e3b341",
|
||||
blue: "#58a6ff",
|
||||
magenta: "#bc8cff",
|
||||
cyan: "#39c5cf",
|
||||
white: "#e0e0e0",
|
||||
brightBlack: "#484f58",
|
||||
brightRed: "#ff7b72",
|
||||
brightGreen: "#56d364",
|
||||
brightYellow: "#e3b341",
|
||||
brightBlue: "#79c0ff",
|
||||
brightMagenta: "#d2a8ff",
|
||||
brightCyan: "#56d4dd",
|
||||
brightWhite: "#f0f6fc",
|
||||
};
|
||||
|
||||
export interface UseTerminalReturn {
|
||||
terminal: Terminal;
|
||||
fitAddon: FitAddon;
|
||||
mount: (container: HTMLElement) => void;
|
||||
destroy: () => void;
|
||||
write: (data: string) => void;
|
||||
fit: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that manages an xterm.js Terminal lifecycle.
|
||||
*
|
||||
* Wires bidirectional I/O:
|
||||
* - User keystrokes → ssh_write (via Tauri invoke)
|
||||
* - SSH stdout → xterm.js (via Tauri listen, base64 encoded)
|
||||
* - Terminal resize → ssh_resize (via Tauri invoke)
|
||||
*/
|
||||
export function useTerminal(sessionId: string): UseTerminalReturn {
|
||||
const fitAddon = new FitAddon();
|
||||
const searchAddon = new SearchAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
const terminal = new Terminal({
|
||||
theme: defaultTheme,
|
||||
fontFamily: "'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', 'Fira Code', Menlo, Monaco, 'Courier New', monospace",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "block",
|
||||
scrollback: 10000,
|
||||
allowProposedApi: true,
|
||||
convertEol: true,
|
||||
rightClickSelectsWord: false,
|
||||
});
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(searchAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
// Forward typed data to the SSH backend
|
||||
terminal.onData((data: string) => {
|
||||
invoke("ssh_write", { sessionId, data }).catch((err: unknown) => {
|
||||
console.error("SSH write error:", err);
|
||||
});
|
||||
});
|
||||
|
||||
// Forward resize events to the SSH backend
|
||||
terminal.onResize((size: { cols: number; rows: number }) => {
|
||||
invoke("ssh_resize", { sessionId, cols: size.cols, rows: size.rows }).catch((err: unknown) => {
|
||||
console.error("SSH resize error:", err);
|
||||
});
|
||||
});
|
||||
|
||||
// MobaXTerm-style clipboard: highlight to copy, right-click to paste
|
||||
const selectionDisposable = terminal.onSelectionChange(() => {
|
||||
const sel = terminal.getSelection();
|
||||
if (sel) {
|
||||
navigator.clipboard.writeText(sel).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
function handleRightClickPaste(e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (text) {
|
||||
invoke("ssh_write", { sessionId, data: text }).catch(() => {});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Listen for SSH output events from the Rust backend (base64 encoded)
|
||||
// Tauri listen() returns a Promise<UnlistenFn> — store both the promise and
|
||||
// the resolved unlisten function so we can clean up properly.
|
||||
let unlistenFn: UnlistenFn | null = null;
|
||||
let unlistenPromise: Promise<UnlistenFn> | null = null;
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// Streaming TextDecoder persists across events so split multi-byte UTF-8
|
||||
// sequences at chunk boundaries are decoded correctly (e.g. a 3-byte em-dash
|
||||
// split across two Rust read() calls).
|
||||
const utf8Decoder = new TextDecoder("utf-8", { fatal: false });
|
||||
|
||||
// Write batching — accumulate chunks and flush once per animation frame.
|
||||
// Without this, every tiny SSH read (sometimes single characters) triggers
|
||||
// a separate terminal.write(), producing a laggy typewriter effect.
|
||||
let pendingData = "";
|
||||
let rafId: number | null = null;
|
||||
|
||||
function flushPendingData(): void {
|
||||
rafId = null;
|
||||
if (pendingData) {
|
||||
terminal.write(pendingData);
|
||||
pendingData = "";
|
||||
}
|
||||
}
|
||||
|
||||
function queueWrite(data: string): void {
|
||||
pendingData += data;
|
||||
if (rafId === null) {
|
||||
rafId = requestAnimationFrame(flushPendingData);
|
||||
}
|
||||
}
|
||||
|
||||
function mount(container: HTMLElement): void {
|
||||
terminal.open(container);
|
||||
|
||||
// Wait for fonts to load before measuring cell dimensions.
|
||||
// If the font (Cascadia Mono etc.) isn't loaded when fitAddon.fit()
|
||||
// runs, canvas.measureText() uses a fallback font and gets wrong
|
||||
// cell widths — producing tiny dashes and 200+ column terminals.
|
||||
document.fonts.ready.then(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
|
||||
// Right-click paste on the terminal's DOM element
|
||||
terminal.element?.addEventListener("contextmenu", handleRightClickPaste);
|
||||
|
||||
// Subscribe to SSH output events for this session.
|
||||
// Tauri v2 listen() callback receives { payload: T } — the base64 string
|
||||
// is in event.payload (not event.data as in Wails).
|
||||
unlistenPromise = listen<string>(`ssh:data:${sessionId}`, (event) => {
|
||||
const b64data = event.payload;
|
||||
|
||||
try {
|
||||
// atob() returns Latin-1 — each byte becomes a char code 0x00–0xFF.
|
||||
// Reconstruct raw bytes, then decode with the streaming TextDecoder
|
||||
// which buffers incomplete multi-byte sequences between calls.
|
||||
const binaryStr = atob(b64data);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
bytes[i] = binaryStr.charCodeAt(i);
|
||||
}
|
||||
const decoded = utf8Decoder.decode(bytes, { stream: true });
|
||||
if (decoded) {
|
||||
queueWrite(decoded);
|
||||
}
|
||||
} catch {
|
||||
// Fallback: write raw if not valid base64
|
||||
queueWrite(b64data);
|
||||
}
|
||||
});
|
||||
|
||||
// Capture the resolved unlisten function for synchronous cleanup
|
||||
unlistenPromise.then((fn) => {
|
||||
unlistenFn = fn;
|
||||
});
|
||||
|
||||
// Auto-fit when the container resizes
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
// Flush any remaining buffered data before teardown
|
||||
if (pendingData) {
|
||||
terminal.write(pendingData);
|
||||
pendingData = "";
|
||||
}
|
||||
terminal.element?.removeEventListener("contextmenu", handleRightClickPaste);
|
||||
selectionDisposable.dispose();
|
||||
|
||||
// Clean up the Tauri event listener.
|
||||
// If the promise already resolved, call unlisten directly.
|
||||
// If it's still pending, wait for resolution then call it.
|
||||
if (unlistenFn) {
|
||||
unlistenFn();
|
||||
unlistenFn = null;
|
||||
} else if (unlistenPromise) {
|
||||
unlistenPromise.then((fn) => fn());
|
||||
}
|
||||
unlistenPromise = null;
|
||||
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
terminal.dispose();
|
||||
}
|
||||
|
||||
function write(data: string): void {
|
||||
terminal.write(data);
|
||||
}
|
||||
|
||||
function fit(): void {
|
||||
fitAddon.fit();
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroy();
|
||||
});
|
||||
|
||||
return { terminal, fitAddon, mount, destroy, write, fit };
|
||||
}
|
||||
@ -1,87 +1,446 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from "@/stores/app.store";
|
||||
|
||||
const app = useAppStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen w-screen flex flex-col overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div
|
||||
style="
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--wraith-bg-primary);
|
||||
color: var(--wraith-text-primary);
|
||||
"
|
||||
>
|
||||
<!-- Titlebar / nav placeholder — swap in real nav component when built -->
|
||||
<header
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.25rem;
|
||||
height: 48px;
|
||||
background-color: var(--wraith-bg-secondary);
|
||||
border-bottom: 1px solid var(--wraith-border);
|
||||
flex-shrink: 0;
|
||||
"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.25em;
|
||||
color: var(--wraith-accent-blue);
|
||||
text-transform: uppercase;
|
||||
"
|
||||
class="h-10 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] shrink-0"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-bold tracking-widest text-[var(--wraith-accent-blue)]">
|
||||
WRAITH
|
||||
</span>
|
||||
|
||||
<!-- File menu -->
|
||||
<div class="relative">
|
||||
<button
|
||||
style="
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
background: transparent;
|
||||
border: 1px solid var(--wraith-border);
|
||||
border-radius: 4px;
|
||||
color: var(--wraith-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
"
|
||||
@click="app.lock()"
|
||||
@mouseenter="
|
||||
($event.target as HTMLButtonElement).style.color = 'var(--wraith-accent-red)';
|
||||
($event.target as HTMLButtonElement).style.borderColor = 'var(--wraith-accent-red)';
|
||||
"
|
||||
@mouseleave="
|
||||
($event.target as HTMLButtonElement).style.color = 'var(--wraith-text-secondary)';
|
||||
($event.target as HTMLButtonElement).style.borderColor = 'var(--wraith-border)';
|
||||
"
|
||||
class="text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer px-2 py-1 rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||
@click="showFileMenu = !showFileMenu"
|
||||
@blur="closeFileMenuDeferred"
|
||||
>
|
||||
Lock
|
||||
File
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Main content area — modules mount here -->
|
||||
<main style="flex: 1; overflow: hidden; display: flex">
|
||||
<div
|
||||
style="
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--wraith-text-muted);
|
||||
font-size: 0.9rem;
|
||||
"
|
||||
v-if="showFileMenu"
|
||||
class="absolute top-full left-0 mt-0.5 w-56 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden z-50 py-1"
|
||||
>
|
||||
<!-- Placeholder — replace with router-view or tab shell -->
|
||||
Vault unlocked. Main UI shell goes here.
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
@mousedown.prevent="handleFileMenuAction('new-connection')"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
|
||||
<span class="flex-1">New Connection</span>
|
||||
<kbd class="text-[10px] text-[var(--wraith-text-muted)]">Ctrl+N</kbd>
|
||||
</button>
|
||||
<div class="border-t border-[#30363d] my-1" />
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
@mousedown.prevent="handleFileMenuAction('import')"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><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>
|
||||
<span class="flex-1">Import from MobaXTerm</span>
|
||||
</button>
|
||||
<div class="border-t border-[#30363d] my-1" />
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
@mousedown.prevent="handleFileMenuAction('settings')"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><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>
|
||||
<span class="flex-1">Settings</span>
|
||||
</button>
|
||||
<div class="border-t border-[#30363d] my-1" />
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
@mousedown.prevent="handleFileMenuAction('exit')"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2.75C2 1.784 2.784 1 3.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h3.5a.75.75 0 0 1 0 1.5h-3.5A1.75 1.75 0 0 1 2 13.25Zm10.44 4.5-1.97-1.97a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.97-1.97H6.75a.75.75 0 0 1 0-1.5Z"/></svg>
|
||||
<span class="flex-1">Exit</span>
|
||||
<kbd class="text-[10px] text-[var(--wraith-text-muted)]">Alt+F4</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Connect -->
|
||||
<div class="flex-1 max-w-xs mx-4">
|
||||
<input
|
||||
v-model="quickConnectInput"
|
||||
type="text"
|
||||
placeholder="Quick connect: user@host:port"
|
||||
class="w-full px-2.5 py-1 text-xs rounded 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)] transition-colors"
|
||||
@keydown.enter="handleQuickConnect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--wraith-text-secondary)]">
|
||||
<span>{{ sessionStore.sessionCount }} session{{ sessionStore.sessionCount !== 1 ? "s" : "" }}</span>
|
||||
<button
|
||||
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
title="Command palette (Ctrl+K)"
|
||||
@click="commandPalette?.toggle()"
|
||||
>
|
||||
<svg class="w-4 h-4" 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>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
title="Lock vault"
|
||||
@click="appStore.lock()"
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
<button
|
||||
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
title="Settings"
|
||||
@click="settingsModal?.open()"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<!-- Sidebar -->
|
||||
<div
|
||||
v-if="sidebarVisible"
|
||||
class="flex flex-col bg-[var(--wraith-bg-secondary)] border-r border-[var(--wraith-border)] shrink-0"
|
||||
:style="{ width: sidebarWidth + 'px' }"
|
||||
>
|
||||
<SidebarToggle v-model="sidebarTab" />
|
||||
|
||||
<!-- Search (connections mode only) -->
|
||||
<div v-if="sidebarTab === 'connections'" class="px-3 py-2">
|
||||
<input
|
||||
v-model="connectionStore.searchQuery"
|
||||
type="text"
|
||||
placeholder="Search connections..."
|
||||
class="w-full px-2.5 py-1.5 text-xs rounded 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)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar content -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Connection tree -->
|
||||
<ConnectionTree v-if="sidebarTab === 'connections'" />
|
||||
|
||||
<!-- SFTP placeholder (Phase N) -->
|
||||
<template v-else-if="sidebarTab === 'sftp'">
|
||||
<div class="flex items-center justify-center py-8 px-3">
|
||||
<p class="text-[var(--wraith-text-muted)] text-xs text-center">
|
||||
SFTP browser coming soon
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Tab bar -->
|
||||
<TabBar />
|
||||
|
||||
<!-- Session area -->
|
||||
<SessionContainer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<StatusBar ref="statusBar" @open-theme-picker="themePicker?.open()" />
|
||||
|
||||
<!-- Command Palette (Ctrl+K) — stub, full implementation Phase N -->
|
||||
<CommandPalette
|
||||
ref="commandPalette"
|
||||
@open-import="importDialog?.open()"
|
||||
@open-settings="settingsModal?.open()"
|
||||
@open-new-connection="connectionEditDialog?.openNew()"
|
||||
/>
|
||||
|
||||
<!-- Theme Picker -->
|
||||
<ThemePicker ref="themePicker" @select="handleThemeSelect" />
|
||||
|
||||
<!-- Import Dialog -->
|
||||
<ImportDialog ref="importDialog" />
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<SettingsModal ref="settingsModal" />
|
||||
|
||||
<!-- Connection Edit Dialog (for File menu / Command Palette new connection) -->
|
||||
<ConnectionEditDialog ref="connectionEditDialog" />
|
||||
|
||||
<!-- First-run: MobaXTerm import prompt -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showMobaPrompt"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/50" @click="showMobaPrompt = false" />
|
||||
<div class="relative w-full max-w-sm bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl p-6 space-y-4">
|
||||
<h3 class="text-sm font-semibold text-[var(--wraith-text-primary)]">No connections found</h3>
|
||||
<p class="text-xs text-[var(--wraith-text-secondary)]">
|
||||
It looks like this is your first time running Wraith. Would you like to import connections from MobaXTerm?
|
||||
</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
@click="showMobaPrompt = false"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs rounded bg-[#1f6feb] text-white hover:bg-[#388bfd] transition-colors cursor-pointer"
|
||||
@click="() => { showMobaPrompt = false; importDialog?.open(); }"
|
||||
>
|
||||
Import from MobaXTerm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useAppStore } from "@/stores/app.store";
|
||||
import { useConnectionStore } from "@/stores/connection.store";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
import SidebarToggle from "@/components/sidebar/SidebarToggle.vue";
|
||||
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
|
||||
import TabBar from "@/components/session/TabBar.vue";
|
||||
import SessionContainer from "@/components/session/SessionContainer.vue";
|
||||
import StatusBar from "@/components/common/StatusBar.vue";
|
||||
import CommandPalette from "@/components/common/CommandPalette.vue";
|
||||
import ThemePicker from "@/components/common/ThemePicker.vue";
|
||||
import ImportDialog from "@/components/common/ImportDialog.vue";
|
||||
import SettingsModal from "@/components/common/SettingsModal.vue";
|
||||
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
|
||||
|
||||
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
||||
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
const sessionStore = useSessionStore();
|
||||
|
||||
const sidebarWidth = ref(240);
|
||||
const sidebarVisible = ref(true);
|
||||
const sidebarTab = ref<SidebarTab>("connections");
|
||||
const quickConnectInput = ref("");
|
||||
|
||||
/** Whether to show the MobaXTerm import prompt (first run, no connections). */
|
||||
const showMobaPrompt = ref(false);
|
||||
|
||||
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null);
|
||||
const themePicker = ref<InstanceType<typeof ThemePicker> | null>(null);
|
||||
const importDialog = ref<InstanceType<typeof ImportDialog> | null>(null);
|
||||
const settingsModal = ref<InstanceType<typeof SettingsModal> | null>(null);
|
||||
const connectionEditDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
|
||||
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
|
||||
|
||||
/** File menu dropdown state. */
|
||||
const showFileMenu = ref(false);
|
||||
|
||||
/** Close the file menu after a short delay (allows click events to fire first). */
|
||||
function closeFileMenuDeferred(): void {
|
||||
setTimeout(() => { showFileMenu.value = false; }, 150);
|
||||
}
|
||||
|
||||
/** Handle file menu item clicks. */
|
||||
async function handleFileMenuAction(action: string): Promise<void> {
|
||||
showFileMenu.value = false;
|
||||
switch (action) {
|
||||
case "new-connection":
|
||||
connectionEditDialog.value?.openNew();
|
||||
break;
|
||||
case "import":
|
||||
importDialog.value?.open();
|
||||
break;
|
||||
case "settings":
|
||||
settingsModal.value?.open();
|
||||
break;
|
||||
case "exit":
|
||||
try {
|
||||
await getCurrentWindow().close();
|
||||
} catch {
|
||||
window.close();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle theme selection from the ThemePicker. */
|
||||
function handleThemeSelect(theme: ThemeDefinition): void {
|
||||
statusBar.value?.setThemeName(theme.name);
|
||||
// Propagate theme to all active terminal instances via the session store
|
||||
sessionStore.setTheme(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick Connect: parse user@host:port and open a session.
|
||||
* Default protocol: SSH, default port: 22.
|
||||
* If port is 3389, use RDP.
|
||||
*/
|
||||
async function handleQuickConnect(): Promise<void> {
|
||||
const raw = quickConnectInput.value.trim();
|
||||
if (!raw) return;
|
||||
|
||||
let username = "";
|
||||
let hostname = "";
|
||||
let port = 22;
|
||||
let protocol: "ssh" | "rdp" = "ssh";
|
||||
|
||||
let hostPart = raw;
|
||||
|
||||
// Extract username if present (user@...)
|
||||
const atIdx = raw.indexOf("@");
|
||||
if (atIdx > 0) {
|
||||
username = raw.substring(0, atIdx);
|
||||
hostPart = raw.substring(atIdx + 1);
|
||||
}
|
||||
|
||||
// Extract port if present (...:port)
|
||||
const colonIdx = hostPart.lastIndexOf(":");
|
||||
if (colonIdx > 0) {
|
||||
const portStr = hostPart.substring(colonIdx + 1);
|
||||
const parsedPort = parseInt(portStr, 10);
|
||||
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
|
||||
port = parsedPort;
|
||||
hostPart = hostPart.substring(0, colonIdx);
|
||||
}
|
||||
}
|
||||
|
||||
hostname = hostPart;
|
||||
if (!hostname) return;
|
||||
|
||||
// Auto-detect RDP by port
|
||||
if (port === 3389) {
|
||||
protocol = "rdp";
|
||||
}
|
||||
|
||||
const name = username ? `${username}@${hostname}` : hostname;
|
||||
|
||||
try {
|
||||
// Create a persistent connection record then connect to it
|
||||
const conn = await invoke<{ id: number }>("create_connection", {
|
||||
name,
|
||||
hostname,
|
||||
port,
|
||||
protocol,
|
||||
groupId: null,
|
||||
credentialId: null,
|
||||
color: "",
|
||||
tags: username ? [username] : [],
|
||||
notes: "",
|
||||
options: username ? JSON.stringify({ username }) : "{}",
|
||||
});
|
||||
|
||||
// Add to local store so sessionStore.connect can find it
|
||||
connectionStore.connections.push({
|
||||
id: conn.id,
|
||||
name,
|
||||
hostname,
|
||||
port,
|
||||
protocol,
|
||||
groupId: null,
|
||||
tags: username ? [username] : [],
|
||||
options: username ? JSON.stringify({ username }) : "{}",
|
||||
});
|
||||
|
||||
await sessionStore.connect(conn.id);
|
||||
quickConnectInput.value = "";
|
||||
} catch (err) {
|
||||
console.error("Quick connect failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Global keyboard shortcut handler. */
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
// Skip shortcuts when the user is typing in an input, textarea, or select
|
||||
const target = event.target as HTMLElement;
|
||||
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
|
||||
|
||||
const ctrl = event.ctrlKey || event.metaKey;
|
||||
|
||||
// Ctrl+K — open command palette (fires even in inputs to match VS Code behavior)
|
||||
if (ctrl && event.key === "k") {
|
||||
event.preventDefault();
|
||||
commandPalette.value?.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
// All remaining shortcuts skip when typing in input fields
|
||||
if (isInputFocused) return;
|
||||
|
||||
// Ctrl+W — close active tab
|
||||
if (ctrl && event.key === "w") {
|
||||
event.preventDefault();
|
||||
const active = sessionStore.activeSession;
|
||||
if (active) {
|
||||
sessionStore.closeSession(active.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Tab — next tab
|
||||
if (ctrl && event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const sessions = sessionStore.sessions;
|
||||
if (sessions.length < 2) return;
|
||||
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
|
||||
const next = sessions[(idx + 1) % sessions.length];
|
||||
sessionStore.activateSession(next.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+Tab — previous tab
|
||||
if (ctrl && event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const sessions = sessionStore.sessions;
|
||||
if (sessions.length < 2) return;
|
||||
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
|
||||
const prev = sessions[(idx - 1 + sessions.length) % sessions.length];
|
||||
sessionStore.activateSession(prev.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+1 through Ctrl+9 — switch to tab by index
|
||||
if (ctrl && event.key >= "1" && event.key <= "9") {
|
||||
const tabIndex = parseInt(event.key, 10) - 1;
|
||||
const sessions = sessionStore.sessions;
|
||||
if (tabIndex < sessions.length) {
|
||||
event.preventDefault();
|
||||
sessionStore.activateSession(sessions[tabIndex].id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+B — toggle sidebar
|
||||
if (ctrl && event.key === "b") {
|
||||
event.preventDefault();
|
||||
sidebarVisible.value = !sidebarVisible.value;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
// Load connections and groups from the Rust backend after vault unlock
|
||||
await connectionStore.loadAll();
|
||||
|
||||
// First-run: if no connections found, offer to import from MobaXTerm
|
||||
if (connectionStore.connections.length === 0) {
|
||||
showMobaPrompt.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
110
src/stores/connection.store.ts
Normal file
110
src/stores/connection.store.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface Connection {
|
||||
id: number;
|
||||
name: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
protocol: "ssh" | "rdp";
|
||||
groupId: number | null;
|
||||
credentialId?: number | null;
|
||||
color?: string;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
options?: string;
|
||||
sortOrder?: number;
|
||||
lastConnected?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
sortOrder?: number;
|
||||
icon?: string;
|
||||
children?: Group[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection store.
|
||||
* Manages connections, groups, and search state.
|
||||
* Loads data from the Rust backend via Tauri invoke.
|
||||
*/
|
||||
export const useConnectionStore = defineStore("connection", () => {
|
||||
const connections = ref<Connection[]>([]);
|
||||
const groups = ref<Group[]>([]);
|
||||
const searchQuery = ref("");
|
||||
|
||||
/** Filter connections by search query. */
|
||||
const filteredConnections = computed(() => {
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
if (!q) return connections.value;
|
||||
return connections.value.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.hostname.toLowerCase().includes(q) ||
|
||||
c.tags?.some((t) => t.toLowerCase().includes(q)),
|
||||
);
|
||||
});
|
||||
|
||||
/** Get connections belonging to a specific group. */
|
||||
function connectionsByGroup(groupId: number): Connection[] {
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
const groupConns = connections.value.filter((c) => c.groupId === groupId);
|
||||
if (!q) return groupConns;
|
||||
return groupConns.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.hostname.toLowerCase().includes(q) ||
|
||||
c.tags?.some((t) => t.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if a group has any matching connections (for search filtering). */
|
||||
function groupHasResults(groupId: number): boolean {
|
||||
return connectionsByGroup(groupId).length > 0;
|
||||
}
|
||||
|
||||
/** Load connections from the Rust backend. */
|
||||
async function loadConnections(): Promise<void> {
|
||||
try {
|
||||
const conns = await invoke<Connection[]>("list_connections");
|
||||
connections.value = conns || [];
|
||||
} catch (err) {
|
||||
console.error("Failed to load connections:", err);
|
||||
connections.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Load groups from the Rust backend. */
|
||||
async function loadGroups(): Promise<void> {
|
||||
try {
|
||||
const grps = await invoke<Group[]>("list_groups");
|
||||
groups.value = grps || [];
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups:", err);
|
||||
groups.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Load both connections and groups from the Rust backend. */
|
||||
async function loadAll(): Promise<void> {
|
||||
await Promise.all([loadConnections(), loadGroups()]);
|
||||
}
|
||||
|
||||
return {
|
||||
connections,
|
||||
groups,
|
||||
searchQuery,
|
||||
filteredConnections,
|
||||
connectionsByGroup,
|
||||
groupHasResults,
|
||||
loadConnections,
|
||||
loadGroups,
|
||||
loadAll,
|
||||
};
|
||||
});
|
||||
202
src/stores/session.store.ts
Normal file
202
src/stores/session.store.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useConnectionStore } from "@/stores/connection.store";
|
||||
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
connectionId: number;
|
||||
name: string;
|
||||
protocol: "ssh" | "rdp";
|
||||
active: boolean;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface TerminalDimensions {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export const useSessionStore = defineStore("session", () => {
|
||||
const sessions = ref<Session[]>([]);
|
||||
const activeSessionId = ref<string | null>(null);
|
||||
const connecting = ref(false);
|
||||
const lastError = ref<string | null>(null);
|
||||
|
||||
/** Active terminal theme — applied to all terminal instances. */
|
||||
const activeTheme = ref<ThemeDefinition | null>(null);
|
||||
|
||||
/** Per-session terminal dimensions (cols x rows). */
|
||||
const terminalDimensions = ref<Record<string, TerminalDimensions>>({});
|
||||
|
||||
const activeSession = computed(() =>
|
||||
sessions.value.find((s) => s.id === activeSessionId.value) ?? null,
|
||||
);
|
||||
|
||||
const sessionCount = computed(() => sessions.value.length);
|
||||
|
||||
function activateSession(id: string): void {
|
||||
activeSessionId.value = id;
|
||||
}
|
||||
|
||||
async function closeSession(id: string): Promise<void> {
|
||||
const idx = sessions.value.findIndex((s) => s.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const session = sessions.value[idx];
|
||||
|
||||
// Disconnect the backend session
|
||||
try {
|
||||
await invoke("disconnect_session", { sessionId: session.id });
|
||||
} catch (err) {
|
||||
console.error("Failed to disconnect session:", err);
|
||||
}
|
||||
|
||||
sessions.value.splice(idx, 1);
|
||||
|
||||
if (activeSessionId.value === id) {
|
||||
if (sessions.value.length === 0) {
|
||||
activeSessionId.value = null;
|
||||
} else {
|
||||
const nextIdx = Math.min(idx, sessions.value.length - 1);
|
||||
activeSessionId.value = sessions.value[nextIdx].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Count how many sessions already exist for this connection (for tab name disambiguation). */
|
||||
function sessionCountForConnection(connId: number): number {
|
||||
return sessions.value.filter((s) => s.connectionId === connId).length;
|
||||
}
|
||||
|
||||
/** Generate a disambiguated tab name like "Asgard", "Asgard (2)", "Asgard (3)". */
|
||||
function disambiguatedName(baseName: string, connId: number): string {
|
||||
const count = sessionCountForConnection(connId);
|
||||
return count === 0 ? baseName : `${baseName} (${count + 1})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a server by connection ID.
|
||||
* Multiple sessions to the same host are allowed (MobaXTerm-style).
|
||||
* Each gets its own tab with a disambiguated name like "Asgard (2)".
|
||||
*
|
||||
* For Tauri: we must resolve the connection details ourselves and pass
|
||||
* hostname/port/username/password directly to connect_ssh, because the
|
||||
* Rust side has no knowledge of connection IDs — the vault owns credentials.
|
||||
*/
|
||||
async function connect(connectionId: number): Promise<void> {
|
||||
const connectionStore = useConnectionStore();
|
||||
const conn = connectionStore.connections.find((c) => c.id === connectionId);
|
||||
if (!conn) return;
|
||||
|
||||
connecting.value = true;
|
||||
try {
|
||||
if (conn.protocol === "ssh") {
|
||||
let sessionId: string;
|
||||
let resolvedUsername: string | undefined;
|
||||
let resolvedPassword = "";
|
||||
|
||||
// Extract stored username from connection options JSON if present
|
||||
if (conn.options) {
|
||||
try {
|
||||
const opts = JSON.parse(conn.options);
|
||||
if (opts?.username) resolvedUsername = opts.username;
|
||||
if (opts?.password) resolvedPassword = opts.password;
|
||||
} catch {
|
||||
// ignore malformed options
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sessionId = await invoke<string>("connect_ssh", {
|
||||
hostname: conn.hostname,
|
||||
port: conn.port,
|
||||
username: resolvedUsername ?? "",
|
||||
password: resolvedPassword,
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
});
|
||||
} catch (sshErr: unknown) {
|
||||
const errMsg = sshErr instanceof Error
|
||||
? sshErr.message
|
||||
: typeof sshErr === "string"
|
||||
? sshErr
|
||||
: String(sshErr);
|
||||
|
||||
// If no credentials or auth failed, prompt for username/password
|
||||
if (errMsg.includes("NO_CREDENTIALS") || errMsg.includes("unable to authenticate") || errMsg.includes("authentication")) {
|
||||
const username = prompt(`Username for ${conn.hostname}:`, "root");
|
||||
if (!username) throw new Error("Connection cancelled");
|
||||
const password = prompt(`Password for ${username}@${conn.hostname}:`);
|
||||
if (password === null) throw new Error("Connection cancelled");
|
||||
|
||||
resolvedUsername = username;
|
||||
sessionId = await invoke<string>("connect_ssh", {
|
||||
hostname: conn.hostname,
|
||||
port: conn.port,
|
||||
username,
|
||||
password,
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
});
|
||||
} else {
|
||||
throw sshErr;
|
||||
}
|
||||
}
|
||||
|
||||
sessions.value.push({
|
||||
id: sessionId,
|
||||
connectionId,
|
||||
name: disambiguatedName(conn.name, connectionId),
|
||||
protocol: "ssh",
|
||||
active: true,
|
||||
username: resolvedUsername,
|
||||
});
|
||||
activeSessionId.value = sessionId;
|
||||
}
|
||||
// RDP support will be added in a future phase
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
|
||||
console.error("Connection failed:", msg);
|
||||
lastError.value = msg;
|
||||
// Show error as native alert so it's visible without DevTools
|
||||
alert(`Connection failed: ${msg}`);
|
||||
} finally {
|
||||
connecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a theme to all active terminal instances. */
|
||||
function setTheme(theme: ThemeDefinition): void {
|
||||
activeTheme.value = theme;
|
||||
}
|
||||
|
||||
/** Update the recorded dimensions for a terminal session. */
|
||||
function setTerminalDimensions(sessionId: string, cols: number, rows: number): void {
|
||||
terminalDimensions.value[sessionId] = { cols, rows };
|
||||
}
|
||||
|
||||
/** Get the dimensions for the active session, or null if not tracked yet. */
|
||||
const activeDimensions = computed<TerminalDimensions | null>(() => {
|
||||
if (!activeSessionId.value) return null;
|
||||
return terminalDimensions.value[activeSessionId.value] ?? null;
|
||||
});
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeSession,
|
||||
sessionCount,
|
||||
connecting,
|
||||
lastError,
|
||||
activeTheme,
|
||||
terminalDimensions,
|
||||
activeDimensions,
|
||||
activateSession,
|
||||
closeSession,
|
||||
connect,
|
||||
setTheme,
|
||||
setTerminalDimensions,
|
||||
};
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user