This commit is contained in:
@@ -8,7 +8,9 @@ Keep the same broad product shape as Tabby—terminal tabs, nested split panes,
|
|||||||
|
|
||||||
## Current implementation
|
## Current implementation
|
||||||
|
|
||||||
- `rabby-core`: dependency-light Rust domain model for profiles, tabs, split panes, session restore primitives, and feature coverage tests.
|
- `rabby-core`: dependency-light Rust domain model for profiles, tabs, split panes, session restore primitives, and Tabby feature coverage tests.
|
||||||
|
- `rabby-core/src/feature`: structured Tabby-vs-Rabby feature catalog with honest status (`Modeled`, `UiPrototype`, `Planned`).
|
||||||
|
- `rabby-core/src/workspace`: refactored workspace/profile/pane domain state.
|
||||||
- `src-tauri`: Tauri v2 desktop backend exposing the Rust feature matrix to the UI.
|
- `src-tauri`: Tauri v2 desktop backend exposing the Rust feature matrix to the UI.
|
||||||
- `ui`: static responsive UI mock/prototype matching Tabby-style terminal UX without a JavaScript build chain.
|
- `ui`: static responsive UI mock/prototype matching Tabby-style terminal UX without a JavaScript build chain.
|
||||||
- `docs/rabby-screenshot.svg`: screenshot artifact of the UI/UX direction.
|
- `docs/rabby-screenshot.svg`: screenshot artifact of the UI/UX direction.
|
||||||
@@ -16,21 +18,38 @@ Keep the same broad product shape as Tabby—terminal tabs, nested split panes,
|
|||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
cargo test -p rabby-core
|
cargo test -p rabby-core
|
||||||
|
cargo check -p rabby
|
||||||
|
cargo build --release -p rabby
|
||||||
```
|
```
|
||||||
|
|
||||||
The core crate is intentionally testable without launching the desktop shell.
|
The core crate is intentionally testable without launching the desktop shell.
|
||||||
|
|
||||||
## Tabby feature mapping
|
## Tabby feature comparison
|
||||||
|
|
||||||
| Tabby feature | Rabby implementation direction |
|
This table is based on Tabby's README and top-level modules (`tabby-terminal`, `tabby-local`, `tabby-ssh`, `tabby-telnet`, `tabby-serial`, `tabby-plugin-manager`, `tabby-settings`, `tabby-web`, etc.). The scaffold does **not** claim every production implementation is finished; it verifies that each class is represented in the Rust catalog and tests.
|
||||||
| --- | --- |
|
|
||||||
| Terminal emulator | Rust core models terminal sessions; Tauri UI presents panes/tabs. |
|
| Tabby feature class | Rabby coverage now | Status |
|
||||||
| SSH/Telnet/Serial | Typed connection profiles with validation and command boundaries. |
|
| --- | --- | --- |
|
||||||
| Split panes/tabs | Recursive pane tree with tested split bounds and leaf counting. |
|
| VT terminal emulation | Terminal session/pane surface requirement in Rust catalog | Planned |
|
||||||
| Theming/shortcuts | Static UI theme now; config surface reserved in core. |
|
| Nested split panes | Recursive `PaneNode` with bounds and leaf-count tests | Modeled |
|
||||||
| Session restore | Workspace struct stores profiles, tabs, active tab, and quake mode. |
|
| Remembered tabs | `Workspace` stores tabs and active tab | Modeled |
|
||||||
| Smaller footprint | No Electron/Angular runtime in this scaffold; static assets + Tauri. |
|
| Quake console/global hotkey | `quake_mode` state plus UI control | Modeled |
|
||||||
|
| Progress/process notifications | `Tab.progress` state with validation | Modeled |
|
||||||
|
| Bracketed/multiline paste, RMB paste, copy-on-select | Terminal preference requirement in catalog | Planned |
|
||||||
|
| Unicode/double-width/ligatures | Renderer requirement in catalog | Planned |
|
||||||
|
| Local shell profiles | Typed `LocalShell` profile command | Modeled |
|
||||||
|
| SSH connection manager | Typed `Ssh` profile command boundary | Modeled |
|
||||||
|
| SSH forwarding/jump hosts/agent forwarding/login scripts | Advanced SSH backlog entry in catalog | Planned |
|
||||||
|
| Telnet | Typed `Telnet` profile | Modeled |
|
||||||
|
| Serial terminal | Typed `Serial` profile plus serial option backlog | Modeled |
|
||||||
|
| Zmodem/SFTP transfers | Transfer surface reserved for SSH sessions | Planned |
|
||||||
|
| Encrypted SSH secrets/config | Tauri/Rust secret-store boundary in catalog | Planned |
|
||||||
|
| Themes/color schemes | `Profile.color_scheme` plus UI theme prototype | Modeled |
|
||||||
|
| Configurable multi-chord shortcuts | Command palette/shortcut UX prototype entry | UI prototype |
|
||||||
|
| Plugin manager/ecosystem | Extension boundary reserved outside core | Planned |
|
||||||
|
| Portable/web surfaces | Static UI + Tauri shell; web surface tracked as platform backlog | UI prototype |
|
||||||
|
|
||||||
## Screenshot
|
## Screenshot
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum FeatureStatus {
|
||||||
|
Modeled,
|
||||||
|
UiPrototype,
|
||||||
|
Planned,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum FeatureArea {
|
||||||
|
Terminal,
|
||||||
|
Connection,
|
||||||
|
UiUx,
|
||||||
|
Transfer,
|
||||||
|
Security,
|
||||||
|
Extensibility,
|
||||||
|
Platform,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct FeatureSpec {
|
||||||
|
pub key: &'static str,
|
||||||
|
pub title: &'static str,
|
||||||
|
pub area: FeatureArea,
|
||||||
|
pub tabby_reference: &'static str,
|
||||||
|
pub rabby_design: &'static str,
|
||||||
|
pub status: FeatureStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const TABBY_FEATURES: &[FeatureSpec] = &[
|
||||||
|
FeatureSpec {
|
||||||
|
key: "vt-terminal",
|
||||||
|
title: "VT terminal emulation",
|
||||||
|
area: FeatureArea::Terminal,
|
||||||
|
tabby_reference: "VT220 terminal + extensions",
|
||||||
|
rabby_design: "Terminal session model plus Tauri pane surface",
|
||||||
|
status: FeatureStatus::Planned,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "nested-splits",
|
||||||
|
title: "Nested split panes",
|
||||||
|
area: FeatureArea::Terminal,
|
||||||
|
tabby_reference: "Multiple nested split panes",
|
||||||
|
rabby_design: "Recursive PaneNode tree with ratio bounds",
|
||||||
|
status: FeatureStatus::Modeled,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "tabs-any-side",
|
||||||
|
title: "Remembered tabs",
|
||||||
|
area: FeatureArea::Terminal,
|
||||||
|
tabby_reference: "Tabs on any side and remembered tabs",
|
||||||
|
rabby_design: "Workspace stores tabs and active tab",
|
||||||
|
status: FeatureStatus::Modeled,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "quake-hotkey",
|
||||||
|
title: "Quake console",
|
||||||
|
area: FeatureArea::UiUx,
|
||||||
|
tabby_reference: "Dockable window with global spawn hotkey",
|
||||||
|
rabby_design: "Workspace quake_mode flag and UI control",
|
||||||
|
status: FeatureStatus::Modeled,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "progress-notifications",
|
||||||
|
title: "Progress and process notifications",
|
||||||
|
area: FeatureArea::UiUx,
|
||||||
|
tabby_reference: "Progress detection and notification on process completion",
|
||||||
|
rabby_design: "Tab progress field and future notification boundary",
|
||||||
|
status: FeatureStatus::Modeled,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "paste-safety",
|
||||||
|
title: "Paste ergonomics",
|
||||||
|
area: FeatureArea::Terminal,
|
||||||
|
tabby_reference: "Bracketed paste, multiline paste warnings, RMB paste, copy-on-select",
|
||||||
|
rabby_design: "Terminal preference catalog entry",
|
||||||
|
status: FeatureStatus::Planned,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "font-ligatures-unicode",
|
||||||
|
title: "Unicode and font ligatures",
|
||||||
|
area: FeatureArea::Terminal,
|
||||||
|
tabby_reference: "Full Unicode including double-width characters and font ligatures",
|
||||||
|
rabby_design: "Renderer requirement in feature catalog",
|
||||||
|
status: FeatureStatus::Planned,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "local-shell-profiles",
|
||||||
|
title: "Local shell profiles",
|
||||||
|
area: FeatureArea::Connection,
|
||||||
|
tabby_reference: "Custom shell profiles and PowerShell/WSL/Git-Bash/Cygwin/MSYS2/Cmder/CMD support",
|
||||||
|
rabby_design: "Typed LocalShell profile command",
|
||||||
|
status: FeatureStatus::Modeled,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "ssh-client",
|
||||||
|
title: "SSH client and connection manager",
|
||||||
|
area: FeatureArea::Connection,
|
||||||
|
tabby_reference: "SSH2 client with connection manager",
|
||||||
|
rabby_design: "Typed SSH profile and command boundary",
|
||||||
|
status: FeatureStatus::Modeled,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "ssh-forwarding",
|
||||||
|
title: "SSH forwarding and jump hosts",
|
||||||
|
area: FeatureArea::Connection,
|
||||||
|
tabby_reference: "X11/port forwarding, automatic jump host management, agent forwarding, login scripts",
|
||||||
|
rabby_design: "Advanced SSH options reserved in catalog",
|
||||||
|
status: FeatureStatus::Planned,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "telnet-client",
|
||||||
|
title: "Telnet client",
|
||||||
|
area: FeatureArea::Connection,
|
||||||
|
tabby_reference: "Integrated Telnet client",
|
||||||
|
rabby_design: "Typed Telnet profile",
|
||||||
|
status: FeatureStatus::Modeled,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "serial-terminal",
|
||||||
|
title: "Serial terminal",
|
||||||
|
area: FeatureArea::Connection,
|
||||||
|
tabby_reference: "Saved serial connections, readline input, hex/hexdump, newline conversion, auto reconnect",
|
||||||
|
rabby_design: "Typed Serial profile plus serial option backlog",
|
||||||
|
status: FeatureStatus::Modeled,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "zmodem-sftp",
|
||||||
|
title: "File transfer",
|
||||||
|
area: FeatureArea::Transfer,
|
||||||
|
tabby_reference: "Zmodem transfer plus SFTP/web SFTP plugins",
|
||||||
|
rabby_design: "Zmodem/SFTP transfer surface reserved for SSH sessions",
|
||||||
|
status: FeatureStatus::Planned,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "encrypted-secrets",
|
||||||
|
title: "Encrypted secret container",
|
||||||
|
area: FeatureArea::Security,
|
||||||
|
tabby_reference: "Integrated encrypted container for SSH secrets and configuration",
|
||||||
|
rabby_design: "Tauri/Rust secret-store boundary",
|
||||||
|
status: FeatureStatus::Planned,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "themes-color-schemes",
|
||||||
|
title: "Themes and color schemes",
|
||||||
|
area: FeatureArea::UiUx,
|
||||||
|
tabby_reference: "Theming and installable color schemes",
|
||||||
|
rabby_design: "Profile color_scheme and UI theme prototype",
|
||||||
|
status: FeatureStatus::Modeled,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "shortcuts",
|
||||||
|
title: "Configurable shortcuts",
|
||||||
|
area: FeatureArea::UiUx,
|
||||||
|
tabby_reference: "Fully configurable shortcuts and multi-chord shortcuts",
|
||||||
|
rabby_design: "Shortcut feature catalog and command palette UI",
|
||||||
|
status: FeatureStatus::UiPrototype,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "plugins",
|
||||||
|
title: "Plugins and themes installable from settings",
|
||||||
|
area: FeatureArea::Extensibility,
|
||||||
|
tabby_reference: "Plugin manager and plugin ecosystem",
|
||||||
|
rabby_design: "Extension boundary reserved outside core runtime",
|
||||||
|
status: FeatureStatus::Planned,
|
||||||
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
key: "portable-web",
|
||||||
|
title: "Portable and web surfaces",
|
||||||
|
area: FeatureArea::Platform,
|
||||||
|
tabby_reference: "Portable app mode and SSH/SFTP/Telnet web app",
|
||||||
|
rabby_design: "Static UI + Tauri shell; web surface tracked as platform backlog",
|
||||||
|
status: FeatureStatus::UiPrototype,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn all_features() -> &'static [FeatureSpec] {
|
||||||
|
TABBY_FEATURES
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn feature_by_key(key: &str) -> Option<&'static FeatureSpec> {
|
||||||
|
TABBY_FEATURES.iter().find(|feature| feature.key == key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn feature_matrix() -> Vec<(&'static str, &'static str)> {
|
||||||
|
TABBY_FEATURES
|
||||||
|
.iter()
|
||||||
|
.map(|feature| (feature.title, feature.rabby_design))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn missing_required_features<'a>(required_keys: &'a [&'a str]) -> Vec<&'a str> {
|
||||||
|
required_keys
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|key| feature_by_key(key).is_none())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
const TABBY_README_REQUIREMENTS: &[&str] = &[
|
||||||
|
"vt-terminal",
|
||||||
|
"nested-splits",
|
||||||
|
"tabs-any-side",
|
||||||
|
"quake-hotkey",
|
||||||
|
"progress-notifications",
|
||||||
|
"paste-safety",
|
||||||
|
"font-ligatures-unicode",
|
||||||
|
"local-shell-profiles",
|
||||||
|
"ssh-client",
|
||||||
|
"ssh-forwarding",
|
||||||
|
"telnet-client",
|
||||||
|
"serial-terminal",
|
||||||
|
"zmodem-sftp",
|
||||||
|
"encrypted-secrets",
|
||||||
|
"themes-color-schemes",
|
||||||
|
"shortcuts",
|
||||||
|
"plugins",
|
||||||
|
"portable-web",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_covers_tabby_readme_feature_classes() {
|
||||||
|
assert_eq!(
|
||||||
|
missing_required_features(TABBY_README_REQUIREMENTS),
|
||||||
|
Vec::<&str>::new()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_keys_are_unique() {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for feature in TABBY_FEATURES {
|
||||||
|
assert!(
|
||||||
|
seen.insert(feature.key),
|
||||||
|
"duplicate feature key {}",
|
||||||
|
feature.key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_has_no_empty_user_facing_fields() {
|
||||||
|
for feature in TABBY_FEATURES {
|
||||||
|
assert!(!feature.title.trim().is_empty());
|
||||||
|
assert!(!feature.tabby_reference.trim().is_empty());
|
||||||
|
assert!(!feature.rabby_design.trim().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_is_honest_about_planned_work() {
|
||||||
|
let planned = TABBY_FEATURES
|
||||||
|
.iter()
|
||||||
|
.filter(|feature| feature.status == FeatureStatus::Planned)
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
planned >= 5,
|
||||||
|
"scaffold should not imply every Tabby feature is complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-197
@@ -3,169 +3,17 @@
|
|||||||
//! This crate is intentionally dependency-light so terminal/profile/session behavior can be
|
//! This crate is intentionally dependency-light so terminal/profile/session behavior can be
|
||||||
//! unit-tested without starting the desktop shell.
|
//! unit-tested without starting the desktop shell.
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
pub mod feature;
|
||||||
pub enum ConnectionKind {
|
pub mod workspace;
|
||||||
LocalShell,
|
|
||||||
Ssh,
|
|
||||||
Telnet,
|
|
||||||
Serial,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
pub use feature::{
|
||||||
pub struct Profile {
|
all_features, feature_by_key, feature_matrix, FeatureArea, FeatureSpec, FeatureStatus,
|
||||||
pub id: String,
|
};
|
||||||
pub name: String,
|
pub use workspace::{ConnectionKind, PaneNode, Profile, SplitAxis, Tab, Workspace};
|
||||||
pub kind: ConnectionKind,
|
|
||||||
pub command: String,
|
|
||||||
pub color_scheme: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Profile {
|
|
||||||
pub fn new(id: &str, name: &str, kind: ConnectionKind, command: &str) -> Result<Self, String> {
|
|
||||||
if id.trim().is_empty() {
|
|
||||||
return Err("profile id is required".into());
|
|
||||||
}
|
|
||||||
if name.trim().is_empty() {
|
|
||||||
return Err("profile name is required".into());
|
|
||||||
}
|
|
||||||
if command.trim().is_empty() {
|
|
||||||
return Err("profile command is required".into());
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
id: slug(id),
|
|
||||||
name: name.trim().to_string(),
|
|
||||||
kind,
|
|
||||||
command: command.trim().to_string(),
|
|
||||||
color_scheme: "Rabby Dark".to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum PaneNode {
|
|
||||||
Leaf {
|
|
||||||
profile_id: String,
|
|
||||||
},
|
|
||||||
Split {
|
|
||||||
axis: SplitAxis,
|
|
||||||
ratio_percent: u8,
|
|
||||||
first: Box<PaneNode>,
|
|
||||||
second: Box<PaneNode>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum SplitAxis {
|
|
||||||
Horizontal,
|
|
||||||
Vertical,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PaneNode {
|
|
||||||
pub fn leaf(profile_id: impl Into<String>) -> Self {
|
|
||||||
Self::Leaf {
|
|
||||||
profile_id: profile_id.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn split(
|
|
||||||
self,
|
|
||||||
axis: SplitAxis,
|
|
||||||
other: PaneNode,
|
|
||||||
ratio_percent: u8,
|
|
||||||
) -> Result<Self, String> {
|
|
||||||
if !(10..=90).contains(&ratio_percent) {
|
|
||||||
return Err("split ratio must be between 10 and 90 percent".into());
|
|
||||||
}
|
|
||||||
Ok(Self::Split {
|
|
||||||
axis,
|
|
||||||
ratio_percent,
|
|
||||||
first: Box::new(self),
|
|
||||||
second: Box::new(other),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn leaf_count(&self) -> usize {
|
|
||||||
match self {
|
|
||||||
PaneNode::Leaf { .. } => 1,
|
|
||||||
PaneNode::Split { first, second, .. } => first.leaf_count() + second.leaf_count(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Tab {
|
|
||||||
pub title: String,
|
|
||||||
pub root: PaneNode,
|
|
||||||
pub progress: Option<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Workspace {
|
|
||||||
pub profiles: Vec<Profile>,
|
|
||||||
pub tabs: Vec<Tab>,
|
|
||||||
pub active_tab: usize,
|
|
||||||
pub quake_mode: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
pub fn default_linux() -> Self {
|
|
||||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
|
|
||||||
let profile = Profile::new("local", "Local Shell", ConnectionKind::LocalShell, &shell)
|
|
||||||
.expect("static default profile is valid");
|
|
||||||
Self {
|
|
||||||
profiles: vec![profile.clone()],
|
|
||||||
tabs: vec![Tab {
|
|
||||||
title: profile.name.clone(),
|
|
||||||
root: PaneNode::leaf(profile.id),
|
|
||||||
progress: None,
|
|
||||||
}],
|
|
||||||
active_tab: 0,
|
|
||||||
quake_mode: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_tab(&mut self, profile_id: &str) -> Result<(), String> {
|
|
||||||
let profile = self
|
|
||||||
.profiles
|
|
||||||
.iter()
|
|
||||||
.find(|p| p.id == profile_id)
|
|
||||||
.ok_or_else(|| format!("unknown profile: {profile_id}"))?;
|
|
||||||
self.tabs.push(Tab {
|
|
||||||
title: profile.name.clone(),
|
|
||||||
root: PaneNode::leaf(profile.id.clone()),
|
|
||||||
progress: None,
|
|
||||||
});
|
|
||||||
self.active_tab = self.tabs.len() - 1;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn feature_matrix() -> Vec<(&'static str, &'static str)> {
|
pub fn feature_matrix() -> Vec<(&'static str, &'static str)> {
|
||||||
vec![
|
feature_matrix()
|
||||||
(
|
|
||||||
"Terminal",
|
|
||||||
"VT-style terminal surface, tabs, nested split panes, Unicode-first rendering",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Connections",
|
|
||||||
"Local shell, SSH, Telnet, and Serial profile model",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"UX",
|
|
||||||
"Command palette, configurable shortcuts, quake-mode window, remembered sessions",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Transfers",
|
|
||||||
"Reserved Zmodem/SFTP transfer surface for SSH sessions",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Security",
|
|
||||||
"Encrypted secret-store boundary in the Tauri/Rust backend",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Footprint",
|
|
||||||
"Rust core + Tauri shell instead of Electron/Angular runtime",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,43 +42,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_validation_rejects_empty_command() {
|
fn exported_feature_matrix_mentions_tabby_capabilities() {
|
||||||
let err = Profile::new("p", "Prod", ConnectionKind::Ssh, " ").unwrap_err();
|
|
||||||
assert!(err.contains("command"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_panes_count_nested_leaves() {
|
|
||||||
let root = PaneNode::leaf("local")
|
|
||||||
.split(SplitAxis::Horizontal, PaneNode::leaf("ssh"), 50)
|
|
||||||
.unwrap()
|
|
||||||
.split(SplitAxis::Vertical, PaneNode::leaf("serial"), 65)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(root.leaf_count(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_ratio_has_sane_bounds() {
|
|
||||||
assert!(PaneNode::leaf("a")
|
|
||||||
.split(SplitAxis::Horizontal, PaneNode::leaf("b"), 5)
|
|
||||||
.is_err());
|
|
||||||
assert!(PaneNode::leaf("a")
|
|
||||||
.split(SplitAxis::Horizontal, PaneNode::leaf("b"), 50)
|
|
||||||
.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_adds_tabs_for_known_profiles() {
|
|
||||||
let mut ws = Workspace::default_linux();
|
|
||||||
ws.profiles
|
|
||||||
.push(Profile::new("ssh-prod", "Prod SSH", ConnectionKind::Ssh, "ssh prod").unwrap());
|
|
||||||
ws.add_tab("ssh-prod").unwrap();
|
|
||||||
assert_eq!(ws.active_tab, 1);
|
|
||||||
assert_eq!(ws.tabs[1].title, "Prod SSH");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feature_matrix_covers_tabby_readme_capabilities() {
|
|
||||||
let text = Workspace::feature_matrix()
|
let text = Workspace::feature_matrix()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(a, b)| format!("{a} {b}"))
|
.map(|(a, b)| format!("{a} {b}"))
|
||||||
@@ -238,7 +50,8 @@ mod tests {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
for needle in [
|
for needle in [
|
||||||
"terminal", "ssh", "telnet", "serial", "split", "unicode", "quake", "zmodem",
|
"terminal", "ssh", "telnet", "serial", "split", "unicode", "quake", "zmodem", "plugin",
|
||||||
|
"portable", "shortcut", "theme", "sftp", "secret",
|
||||||
] {
|
] {
|
||||||
assert!(text.contains(needle), "missing {needle}");
|
assert!(text.contains(needle), "missing {needle}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
use crate::slug;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ConnectionKind {
|
||||||
|
LocalShell,
|
||||||
|
Ssh,
|
||||||
|
Telnet,
|
||||||
|
Serial,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Profile {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: ConnectionKind,
|
||||||
|
pub command: String,
|
||||||
|
pub color_scheme: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Profile {
|
||||||
|
pub fn new(id: &str, name: &str, kind: ConnectionKind, command: &str) -> Result<Self, String> {
|
||||||
|
if id.trim().is_empty() {
|
||||||
|
return Err("profile id is required".into());
|
||||||
|
}
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err("profile name is required".into());
|
||||||
|
}
|
||||||
|
if command.trim().is_empty() {
|
||||||
|
return Err("profile command is required".into());
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
id: slug(id),
|
||||||
|
name: name.trim().to_string(),
|
||||||
|
kind,
|
||||||
|
command: command.trim().to_string(),
|
||||||
|
color_scheme: "Rabby Dark".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PaneNode {
|
||||||
|
Leaf {
|
||||||
|
profile_id: String,
|
||||||
|
},
|
||||||
|
Split {
|
||||||
|
axis: SplitAxis,
|
||||||
|
ratio_percent: u8,
|
||||||
|
first: Box<PaneNode>,
|
||||||
|
second: Box<PaneNode>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SplitAxis {
|
||||||
|
Horizontal,
|
||||||
|
Vertical,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaneNode {
|
||||||
|
pub fn leaf(profile_id: impl Into<String>) -> Self {
|
||||||
|
Self::Leaf {
|
||||||
|
profile_id: profile_id.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn split(
|
||||||
|
self,
|
||||||
|
axis: SplitAxis,
|
||||||
|
other: PaneNode,
|
||||||
|
ratio_percent: u8,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
if !(10..=90).contains(&ratio_percent) {
|
||||||
|
return Err("split ratio must be between 10 and 90 percent".into());
|
||||||
|
}
|
||||||
|
Ok(Self::Split {
|
||||||
|
axis,
|
||||||
|
ratio_percent,
|
||||||
|
first: Box::new(self),
|
||||||
|
second: Box::new(other),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leaf_count(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
PaneNode::Leaf { .. } => 1,
|
||||||
|
PaneNode::Split { first, second, .. } => first.leaf_count() + second.leaf_count(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Tab {
|
||||||
|
pub title: String,
|
||||||
|
pub root: PaneNode,
|
||||||
|
pub progress: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Workspace {
|
||||||
|
pub profiles: Vec<Profile>,
|
||||||
|
pub tabs: Vec<Tab>,
|
||||||
|
pub active_tab: usize,
|
||||||
|
pub quake_mode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Workspace {
|
||||||
|
pub fn default_linux() -> Self {
|
||||||
|
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
|
||||||
|
let profile = Profile::new("local", "Local Shell", ConnectionKind::LocalShell, &shell)
|
||||||
|
.expect("static default profile is valid");
|
||||||
|
Self {
|
||||||
|
profiles: vec![profile.clone()],
|
||||||
|
tabs: vec![Tab {
|
||||||
|
title: profile.name.clone(),
|
||||||
|
root: PaneNode::leaf(profile.id),
|
||||||
|
progress: None,
|
||||||
|
}],
|
||||||
|
active_tab: 0,
|
||||||
|
quake_mode: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_profile(&mut self, profile: Profile) -> Result<(), String> {
|
||||||
|
if self
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.any(|existing| existing.id == profile.id)
|
||||||
|
{
|
||||||
|
return Err(format!("duplicate profile: {}", profile.id));
|
||||||
|
}
|
||||||
|
self.profiles.push(profile);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_tab(&mut self, profile_id: &str) -> Result<(), String> {
|
||||||
|
let profile = self
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.id == profile_id)
|
||||||
|
.ok_or_else(|| format!("unknown profile: {profile_id}"))?;
|
||||||
|
self.tabs.push(Tab {
|
||||||
|
title: profile.name.clone(),
|
||||||
|
root: PaneNode::leaf(profile.id.clone()),
|
||||||
|
progress: None,
|
||||||
|
});
|
||||||
|
self.active_tab = self.tabs.len() - 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_tab_progress(
|
||||||
|
&mut self,
|
||||||
|
tab_index: usize,
|
||||||
|
progress: Option<u8>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if let Some(value) = progress {
|
||||||
|
if value > 100 {
|
||||||
|
return Err("progress must be between 0 and 100".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tab = self
|
||||||
|
.tabs
|
||||||
|
.get_mut(tab_index)
|
||||||
|
.ok_or_else(|| format!("unknown tab index: {tab_index}"))?;
|
||||||
|
tab.progress = progress;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_quake_mode(&mut self, enabled: bool) {
|
||||||
|
self.quake_mode = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_validation_rejects_empty_command() {
|
||||||
|
let err = Profile::new("p", "Prod", ConnectionKind::Ssh, " ").unwrap_err();
|
||||||
|
assert!(err.contains("command"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_panes_count_nested_leaves() {
|
||||||
|
let root = PaneNode::leaf("local")
|
||||||
|
.split(SplitAxis::Horizontal, PaneNode::leaf("ssh"), 50)
|
||||||
|
.unwrap()
|
||||||
|
.split(SplitAxis::Vertical, PaneNode::leaf("serial"), 65)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(root.leaf_count(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_ratio_has_sane_bounds() {
|
||||||
|
assert!(PaneNode::leaf("a")
|
||||||
|
.split(SplitAxis::Horizontal, PaneNode::leaf("b"), 5)
|
||||||
|
.is_err());
|
||||||
|
assert!(PaneNode::leaf("a")
|
||||||
|
.split(SplitAxis::Horizontal, PaneNode::leaf("b"), 50)
|
||||||
|
.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_adds_tabs_for_known_profiles() {
|
||||||
|
let mut ws = Workspace::default_linux();
|
||||||
|
ws.add_profile(
|
||||||
|
Profile::new("ssh-prod", "Prod SSH", ConnectionKind::Ssh, "ssh prod").unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
ws.add_tab("ssh-prod").unwrap();
|
||||||
|
assert_eq!(ws.active_tab, 1);
|
||||||
|
assert_eq!(ws.tabs[1].title, "Prod SSH");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_rejects_duplicate_profiles() {
|
||||||
|
let mut ws = Workspace::default_linux();
|
||||||
|
let err = ws
|
||||||
|
.add_profile(
|
||||||
|
Profile::new(
|
||||||
|
"local",
|
||||||
|
"Another Local",
|
||||||
|
ConnectionKind::LocalShell,
|
||||||
|
"/bin/sh",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("duplicate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_tracks_quake_mode_and_progress() {
|
||||||
|
let mut ws = Workspace::default_linux();
|
||||||
|
ws.set_quake_mode(true);
|
||||||
|
ws.set_tab_progress(0, Some(42)).unwrap();
|
||||||
|
assert!(ws.quake_mode);
|
||||||
|
assert_eq!(ws.tabs[0].progress, Some(42));
|
||||||
|
assert!(ws.set_tab_progress(0, Some(101)).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user