feat(core): model shortcuts connections and transfers
test / workspace (push) Successful in 12m48s

This commit is contained in:
Tom You
2026-07-08 10:43:56 -05:00
parent dafde13572
commit 1c4dd973c1
4 changed files with 426 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForwardRule {
pub local_port: u16,
pub remote_host: String,
pub remote_port: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshOptions {
pub host: String,
pub user: String,
pub port: u16,
pub jump_hosts: Vec<String>,
pub agent_forwarding: bool,
pub x11_forwarding: bool,
pub port_forwards: Vec<ForwardRule>,
pub login_script: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TelnetOptions {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SerialNewlineMode {
Lf,
CrLf,
Cr,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SerialOptions {
pub path: String,
pub baud_rate: u32,
pub newline_mode: SerialNewlineMode,
pub hex_input: bool,
pub hexdump_output: bool,
pub auto_reconnect: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConnectionOptions {
Local { command: String },
Ssh(SshOptions),
Telnet(TelnetOptions),
Serial(SerialOptions),
}
impl ConnectionOptions {
pub fn validate(&self) -> Result<(), String> {
match self {
Self::Local { command } if command.trim().is_empty() => {
Err("local profile command is required".to_string())
}
Self::Local { .. } => Ok(()),
Self::Ssh(options) => options.validate(),
Self::Telnet(options) => options.validate(),
Self::Serial(options) => options.validate(),
}
}
}
impl SshOptions {
pub fn validate(&self) -> Result<(), String> {
if self.host.trim().is_empty() {
return Err("SSH host is required".to_string());
}
if self.user.trim().is_empty() {
return Err("SSH user is required".to_string());
}
if self.port == 0 {
return Err("SSH port is required".to_string());
}
for forward in &self.port_forwards {
if forward.local_port == 0
|| forward.remote_port == 0
|| forward.remote_host.trim().is_empty()
{
return Err(
"SSH forwarding rule requires local port, remote host, and remote port"
.to_string(),
);
}
}
Ok(())
}
}
impl TelnetOptions {
pub fn validate(&self) -> Result<(), String> {
if self.host.trim().is_empty() {
return Err("Telnet host is required".to_string());
}
if self.port == 0 {
return Err("Telnet port is required".to_string());
}
Ok(())
}
}
impl SerialOptions {
pub fn validate(&self) -> Result<(), String> {
if self.path.trim().is_empty() {
return Err("serial path is required".to_string());
}
if self.baud_rate == 0 {
return Err("serial baud rate is required".to_string());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ssh() -> SshOptions {
SshOptions {
host: "example.com".to_string(),
user: "tom".to_string(),
port: 22,
jump_hosts: vec!["bastion".to_string()],
agent_forwarding: true,
x11_forwarding: true,
port_forwards: vec![ForwardRule {
local_port: 8080,
remote_host: "127.0.0.1".to_string(),
remote_port: 80,
}],
login_script: Some("echo ready".to_string()),
}
}
#[test]
fn validates_advanced_ssh_options() {
assert!(ConnectionOptions::Ssh(ssh()).validate().is_ok());
}
#[test]
fn rejects_incomplete_ssh_forwarding_rule() {
let mut options = ssh();
options.port_forwards[0].remote_host = " ".to_string();
let err = ConnectionOptions::Ssh(options).validate().unwrap_err();
assert!(err.contains("forwarding"));
}
#[test]
fn validates_telnet_host_and_port() {
assert!(ConnectionOptions::Telnet(TelnetOptions {
host: "router.local".to_string(),
port: 23,
})
.validate()
.is_ok());
}
#[test]
fn validates_serial_terminal_options() {
assert!(ConnectionOptions::Serial(SerialOptions {
path: "/dev/ttyUSB0".to_string(),
baud_rate: 115_200,
newline_mode: SerialNewlineMode::CrLf,
hex_input: true,
hexdump_output: true,
auto_reconnect: true,
})
.validate()
.is_ok());
}
}
+8
View File
@@ -4,14 +4,22 @@
//! unit-tested without starting the desktop shell. //! unit-tested without starting the desktop shell.
pub mod config; pub mod config;
pub mod connection;
pub mod feature; pub mod feature;
pub mod shortcut;
pub mod terminal_model; pub mod terminal_model;
pub mod transfer;
pub mod workspace; pub mod workspace;
pub use config::{AppConfig, ShortcutBinding}; pub use config::{AppConfig, ShortcutBinding};
pub use connection::{
ConnectionOptions, ForwardRule, SerialNewlineMode, SerialOptions, SshOptions, TelnetOptions,
};
pub use feature::{ pub use feature::{
all_features, feature_by_key, feature_matrix, FeatureArea, FeatureSpec, FeatureStatus, all_features, feature_by_key, feature_matrix, FeatureArea, FeatureSpec, FeatureStatus,
}; };
pub use shortcut::{validate_unique_bindings, CommandBinding, KeyChord, ShortcutSequence};
pub use transfer::{TransferDirection, TransferEvent, TransferProtocol, TransferRequest};
pub use workspace::{ConnectionKind, PaneNode, Profile, SplitAxis, Tab, Workspace}; pub use workspace::{ConnectionKind, PaneNode, Profile, SplitAxis, Tab, Workspace};
impl Workspace { impl Workspace {
+157
View File
@@ -0,0 +1,157 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeyChord {
pub modifiers: Vec<String>,
pub key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShortcutSequence {
pub chords: Vec<KeyChord>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandBinding {
pub command: String,
pub sequence: ShortcutSequence,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShortcutError {
Empty,
MissingKey,
DuplicateBinding(String),
}
impl fmt::Display for ShortcutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "shortcut cannot be empty"),
Self::MissingKey => write!(f, "shortcut chord must include a key"),
Self::DuplicateBinding(binding) => write!(f, "duplicate shortcut binding: {binding}"),
}
}
}
impl std::error::Error for ShortcutError {}
impl ShortcutSequence {
pub fn parse(input: &str) -> Result<Self, ShortcutError> {
let input = input.trim();
if input.is_empty() {
return Err(ShortcutError::Empty);
}
let chords = input
.split_whitespace()
.map(KeyChord::parse)
.collect::<Result<Vec<_>, _>>()?;
Ok(Self { chords })
}
pub fn canonical(&self) -> String {
self.chords
.iter()
.map(KeyChord::canonical)
.collect::<Vec<_>>()
.join(" ")
}
}
impl KeyChord {
pub fn parse(input: &str) -> Result<Self, ShortcutError> {
let mut parts = input
.split('+')
.map(str::trim)
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if parts.is_empty() {
return Err(ShortcutError::MissingKey);
}
let key = parts.pop().unwrap().to_ascii_uppercase();
if key.is_empty() {
return Err(ShortcutError::MissingKey);
}
let mut modifiers = parts
.into_iter()
.map(normalize_modifier)
.collect::<Vec<_>>();
modifiers.sort();
modifiers.dedup();
Ok(Self { modifiers, key })
}
pub fn canonical(&self) -> String {
let mut parts = self.modifiers.clone();
parts.push(self.key.clone());
parts.join("+")
}
}
pub fn validate_unique_bindings(bindings: &[CommandBinding]) -> Result<(), ShortcutError> {
let mut seen = HashSet::new();
for binding in bindings {
let canonical = binding.sequence.canonical();
if !seen.insert(canonical.clone()) {
return Err(ShortcutError::DuplicateBinding(canonical));
}
}
Ok(())
}
fn normalize_modifier(input: &str) -> String {
match input.to_ascii_lowercase().as_str() {
"cmd" | "command" | "meta" | "super" => "Meta".to_string(),
"ctrl" | "control" => "Ctrl".to_string(),
"alt" | "option" => "Alt".to_string(),
"shift" => "Shift".to_string(),
other => {
let mut chars = other.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
None => String::new(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_single_chord_shortcut() {
let shortcut = ShortcutSequence::parse("Ctrl+Shift+P").unwrap();
assert_eq!(shortcut.canonical(), "Ctrl+Shift+P");
}
#[test]
fn parses_multi_chord_shortcut() {
let shortcut = ShortcutSequence::parse("Ctrl+K Ctrl+S").unwrap();
assert_eq!(shortcut.chords.len(), 2);
assert_eq!(shortcut.canonical(), "Ctrl+K Ctrl+S");
}
#[test]
fn rejects_duplicate_shortcut_bindings() {
let bindings = vec![
CommandBinding {
command: "new-tab".to_string(),
sequence: ShortcutSequence::parse("Ctrl+T").unwrap(),
},
CommandBinding {
command: "another".to_string(),
sequence: ShortcutSequence::parse("Control+T").unwrap(),
},
];
assert!(matches!(
validate_unique_bindings(&bindings),
Err(ShortcutError::DuplicateBinding(binding)) if binding == "Ctrl+T"
));
}
}
+85
View File
@@ -0,0 +1,85 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransferProtocol {
Sftp,
Zmodem,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransferDirection {
Upload,
Download,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransferRequest {
pub protocol: TransferProtocol,
pub direction: TransferDirection,
pub local_path: String,
pub remote_path: String,
}
impl TransferRequest {
pub fn validate(&self) -> Result<(), String> {
if self.local_path.trim().is_empty() {
return Err("local path is required".to_string());
}
if self.remote_path.trim().is_empty() {
return Err("remote path is required".to_string());
}
if self.local_path.contains("..") {
return Err("local path traversal is not allowed".to_string());
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransferEvent {
Queued,
Progress { bytes_done: u64, bytes_total: u64 },
Completed,
Failed(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_sftp_download_request() {
let request = TransferRequest {
protocol: TransferProtocol::Sftp,
direction: TransferDirection::Download,
local_path: "downloads/file.txt".to_string(),
remote_path: "/home/tom/file.txt".to_string(),
};
assert!(request.validate().is_ok());
}
#[test]
fn validates_zmodem_upload_request() {
let request = TransferRequest {
protocol: TransferProtocol::Zmodem,
direction: TransferDirection::Upload,
local_path: "uploads/firmware.bin".to_string(),
remote_path: "firmware.bin".to_string(),
};
assert!(request.validate().is_ok());
}
#[test]
fn rejects_local_path_traversal() {
let request = TransferRequest {
protocol: TransferProtocol::Sftp,
direction: TransferDirection::Download,
local_path: "../secret".to_string(),
remote_path: "/tmp/secret".to_string(),
};
assert!(request.validate().unwrap_err().contains("traversal"));
}
}