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:
Vantz Stockwell 2026-03-17 15:28:18 -04:00
parent 0802ae0753
commit 737491d3f0
20 changed files with 3492 additions and 94 deletions

972
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,9 @@ uuid = { version = "1", features = ["v4"] }
base64 = "0.22" base64 = "0.22"
dashmap = "6" dashmap = "6"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
thiserror = "2" thiserror = "2"
russh = "0.48"
ssh-key = { version = "0.6", features = ["ed25519", "rsa"] }

View File

@ -2,3 +2,4 @@ pub mod vault;
pub mod settings; pub mod settings;
pub mod connections; pub mod connections;
pub mod credentials; pub mod credentials;
pub mod ssh_commands;

View 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())
}

View File

@ -3,6 +3,7 @@ pub mod vault;
pub mod settings; pub mod settings;
pub mod connections; pub mod connections;
pub mod credentials; pub mod credentials;
pub mod ssh;
pub mod commands; pub mod commands;
use std::path::PathBuf; use std::path::PathBuf;
@ -13,6 +14,7 @@ use vault::VaultService;
use credentials::CredentialService; use credentials::CredentialService;
use settings::SettingsService; use settings::SettingsService;
use connections::ConnectionService; use connections::ConnectionService;
use ssh::session::SshService;
/// Application state shared across all Tauri commands via State<AppState>. /// Application state shared across all Tauri commands via State<AppState>.
pub struct AppState { pub struct AppState {
@ -21,6 +23,7 @@ pub struct AppState {
pub settings: SettingsService, pub settings: SettingsService,
pub connections: ConnectionService, pub connections: ConnectionService,
pub credentials: Mutex<Option<CredentialService>>, pub credentials: Mutex<Option<CredentialService>>,
pub ssh: SshService,
} }
impl AppState { impl AppState {
@ -33,6 +36,7 @@ impl AppState {
let settings = SettingsService::new(database.clone()); let settings = SettingsService::new(database.clone());
let connections = ConnectionService::new(database.clone()); let connections = ConnectionService::new(database.clone());
let ssh = SshService::new(database.clone());
Ok(Self { Ok(Self {
db: database, db: database,
@ -40,6 +44,7 @@ impl AppState {
settings, settings,
connections, connections,
credentials: Mutex::new(None), credentials: Mutex::new(None),
ssh,
}) })
} }
@ -103,6 +108,12 @@ pub fn run() {
commands::credentials::create_password, commands::credentials::create_password,
commands::credentials::create_ssh_key, commands::credentials::create_ssh_key,
commands::credentials::delete_credential, 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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

129
src-tauri/src/ssh/cwd.rs Normal file
View 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);
});
}
}

View 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
View File

@ -0,0 +1,3 @@
pub mod session;
pub mod host_key;
pub mod cwd;

View 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()
}
}

View 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);
}

View 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)]">&middot;</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 }}&times;{{ sessionStore.activeDimensions.rows }}
</span>
<span v-else>120&times;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>

View 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>

View 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)"
>
&times;
</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>

View 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>

View 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>

View 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>

View 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 Classicinspired 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 0x000xFF.
// 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 };
}

View File

@ -1,87 +1,446 @@
<script setup lang="ts">
import { useAppStore } from "@/stores/app.store";
const app = useAppStore();
</script>
<template> <template>
<div <div class="h-screen w-screen flex flex-col overflow-hidden">
style=" <!-- Toolbar -->
height: 100%; <div
display: flex; class="h-10 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] shrink-0"
flex-direction: column; data-tauri-drag-region
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 <div class="flex items-center gap-3">
style=" <span class="text-sm font-bold tracking-widest text-[var(--wraith-accent-blue)]">
font-size: 0.9rem; WRAITH
font-weight: 700; </span>
letter-spacing: 0.25em;
color: var(--wraith-accent-blue);
text-transform: uppercase;
"
>
WRAITH
</span>
<button <!-- File menu -->
style=" <div class="relative">
padding: 0.3rem 0.8rem; <button
font-size: 0.75rem; 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)]"
font-weight: 600; @click="showFileMenu = !showFileMenu"
letter-spacing: 0.05em; @blur="closeFileMenuDeferred"
text-transform: uppercase; >
background: transparent; File
border: 1px solid var(--wraith-border); </button>
border-radius: 4px; <div
color: var(--wraith-text-secondary); v-if="showFileMenu"
cursor: pointer; 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"
transition: color 0.15s ease, border-color 0.15s ease; >
" <button
@click="app.lock()" 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"
@mouseenter=" @mousedown.prevent="handleFileMenuAction('new-connection')"
($event.target as HTMLButtonElement).style.color = 'var(--wraith-accent-red)'; >
($event.target as HTMLButtonElement).style.borderColor = 'var(--wraith-accent-red)'; <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>
@mouseleave=" <kbd class="text-[10px] text-[var(--wraith-text-muted)]">Ctrl+N</kbd>
($event.target as HTMLButtonElement).style.color = 'var(--wraith-text-secondary)'; </button>
($event.target as HTMLButtonElement).style.borderColor = 'var(--wraith-border)'; <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"
Lock @mousedown.prevent="handleFileMenuAction('import')"
</button> >
</header> <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>
<!-- Main content area modules mount here --> </button>
<main style="flex: 1; overflow: hidden; display: flex"> <div class="border-t border-[#30363d] my-1" />
<div <button
style=" 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"
flex: 1; @mousedown.prevent="handleFileMenuAction('settings')"
display: flex; >
align-items: center; <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>
justify-content: center; <span class="flex-1">Settings</span>
color: var(--wraith-text-muted); </button>
font-size: 0.9rem; <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"
<!-- Placeholder replace with router-view or tab shell --> @mousedown.prevent="handleFileMenuAction('exit')"
Vault unlocked. Main UI shell goes here. >
<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>
</div>
</div> </div>
</main>
<!-- 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()"
>
&#x1f512;
</button>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Settings"
@click="settingsModal?.open()"
>
&#x2699;
</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> </div>
</template> </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>

View 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
View 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,
};
});