use crate::agent::AgentState;
use crate::layout::{Border, PaneId};
pub enum Mode {
Normal,
Leader {
typed: Vec<crate::input::Key>,
miss: Option<String>,
},
Resize,
Scroll,
Search {
query: String,
found: bool,
},
Picker(Picker),
Prompt(Prompt),
Help {
scroll: u16,
},
Messages {
scroll: u16,
},
Confirm(Question),
Hints(HintsMode),
}
pub struct Question {
pub message: String,
pub action: ConfirmAction,
pub yes: bool,
pub scroll: u16,
}
pub struct HintsMode {
pub pane: PaneId,
pub hints: Vec<crate::hints::Hint>,
pub typed: String,
pub act: bool,
pub snapshot: Option<Box<ratatui::buffer::Buffer>>,
}
impl Mode {
pub fn label(&self) -> &'static str {
match self {
Mode::Normal => "",
Mode::Leader { .. } => "MENU",
Mode::Resize => "RESIZE",
Mode::Scroll => "COPY",
Mode::Search { .. } => "SEARCH",
Mode::Picker(_) => "CHOOSE",
Mode::Prompt(_) => "INPUT",
Mode::Help { .. } => "HELP",
Mode::Messages { .. } => "MESSAGES",
Mode::Confirm(_) => "CONFIRM",
Mode::Hints(_) => "HINTS",
}
}
pub fn leader() -> Mode {
Mode::Leader { typed: Vec::new(), miss: None }
}
pub fn confirm(message: impl Into<String>, action: ConfirmAction) -> Mode {
Mode::Confirm(Question { message: message.into(), action, yes: false, scroll: 0 })
}
}
#[allow(clippy::enum_variant_names)]
pub enum ConfirmAction {
ClosePane(PaneId),
CloseTab {
workspace: u32,
tab: u32,
},
CloseWorkspace(u32),
Job {
label: String,
args: Vec<String>,
},
SwitchAccount {
pane: PaneId,
agent: String,
account: String,
},
GitJob {
label: String,
script: String,
},
RemoveWorktrees(Vec<(crate::git::Worktree, crate::git::WorktreeState)>),
TrustWorktreeSetup(Box<crate::server::worktrees::NewWorktree>),
RunTool {
pane: Option<PaneId>,
name: String,
selection: Option<String>,
reason: crate::server::contexts::Production,
place: Option<String>,
},
Edit(Box<super::edits::EditTarget>),
TrustRunbook,
RunRunbook,
RunBlock {
index: usize,
production: Option<crate::server::contexts::Production>,
},
Production {
workspace: u32,
approve: Vec<(PaneId, crate::server::contexts::Production)>,
pending: PendingInput,
},
}
impl ConfirmAction {
pub fn verb(&self) -> String {
match self {
ConfirmAction::ClosePane(_) => "Close pane".into(),
ConfirmAction::CloseTab { .. } => "Close tab".into(),
ConfirmAction::CloseWorkspace(_) => "Close workspace".into(),
ConfirmAction::Job { label, .. } | ConfirmAction::GitJob { label, .. } => {
let mut l = label.clone();
if let Some(first) = l.get_mut(..1) {
first.make_ascii_uppercase();
}
l
}
ConfirmAction::SwitchAccount { .. } => "Switch account".into(),
ConfirmAction::RemoveWorktrees(list) if list.len() == 1 => "Remove worktree".into(),
ConfirmAction::RemoveWorktrees(_) => "Remove worktrees".into(),
ConfirmAction::TrustWorktreeSetup(_) => "Trust this version".into(),
ConfirmAction::RunTool { .. } => "Run tool".into(),
ConfirmAction::Edit(_) => "Edit".into(),
ConfirmAction::TrustRunbook => "Trust this version".into(),
ConfirmAction::RunRunbook => "Run".into(),
ConfirmAction::RunBlock { .. } => "Send to the pane".into(),
ConfirmAction::Production { .. } => "Send".into(),
}
}
pub fn danger(&self, message: &str) -> bool {
matches!(self, ConfirmAction::Production { .. } | ConfirmAction::RemoveWorktrees(_))
|| matches!(self, ConfirmAction::Job { label, .. } if label == "delete" || label.contains("replace"))
|| message.contains("PRODUCTION")
|| message.contains(" is production (")
}
}
pub struct PendingInput {
pub items: Vec<InputItem>,
pub targets: Vec<(PaneId, Option<crate::server::contexts::Production>)>,
}
pub enum InputItem {
Key(crate::input::Key),
Paste(Vec<u8>),
Piece(Vec<u8>, bool),
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PickerKind {
Commands,
Workspace,
Ssh,
Pane,
Agent,
Tool,
Runbook,
RunbookTarget,
RunbookParam,
RunbookSteps,
EditConflict,
}
#[derive(Clone)]
pub enum PickAction {
SwitchWorkspace(u32),
OpenDefinition(String),
NewWorkspace,
Ssh(String),
SshGroup(String),
FocusPane(PaneId),
RemoteAgent {
server: String,
pane: u32,
},
AgentLaunch {
agent: String,
account: String,
},
AgentSwitch {
pane: PaneId,
agent: String,
account: String,
},
AgentAdd {
agent: String,
},
AgentWorktree {
agent: String,
},
Worktree {
pane: PaneId,
op: String,
},
Runbook(std::path::PathBuf),
RunbookTarget(String),
RunbookValue(String),
RunBlock(usize),
RunbookList,
Tool {
pane: Option<PaneId>,
name: String,
},
Command(crate::keymap::Command),
EditAnswer {
edit: u32,
answer: super::edits::Answer,
},
FilesSet {
pane: PaneId,
side: crate::files::Side,
target: String,
},
None,
}
#[derive(Clone)]
pub struct PickItem {
pub label: String,
pub detail: String,
pub action: PickAction,
pub state: Option<AgentState>,
pub dim: bool,
}
impl PickItem {
pub fn new(label: impl Into<String>, detail: impl Into<String>, action: PickAction) -> PickItem {
PickItem { label: label.into(), detail: detail.into(), action, state: None, dim: false }
}
pub fn dim(self) -> PickItem {
PickItem { dim: true, ..self }
}
pub fn with_state(self, state: Option<AgentState>) -> PickItem {
PickItem { state, ..self }
}
}
pub struct Picker {
pub kind: PickerKind,
pub title: String,
pub query: String,
pub cursor: usize,
pub items: Vec<PickItem>,
pub filtered: Vec<usize>,
pub selected: usize,
pub actions: Option<usize>,
pub loading: Option<u32>,
}
impl Picker {
pub fn new(kind: PickerKind, title: &str, items: Vec<PickItem>) -> Picker {
let mut p = Picker {
kind,
title: title.into(),
query: String::new(),
cursor: 0,
items,
filtered: Vec::new(),
selected: 0,
actions: None,
loading: None,
};
p.refilter();
p
}
pub fn action_keys(&self) -> &'static [(&'static str, &'static str)] {
match self.kind {
PickerKind::Ssh => &[
("enter", "Connect"),
("ctrl+t", "Connect in a new tab"),
("ctrl+f", "Browse its files"),
("ctrl+r", "Run a command on it"),
("ctrl+o", "Forward one of its ports"),
("ctrl+e", "Edit saved servers"),
("ctrl+x", "Remove saved server"),
],
PickerKind::Agent => &[
("enter", "Start"),
("ctrl+t", "Start in a new tab"),
("ctrl+l", "Log in to this account"),
("ctrl+x", "Remove account (its files are kept)"),
],
PickerKind::Workspace => &[("enter", "Open"), ("ctrl+x", "Close workspace")],
PickerKind::Runbook => &[("enter", "Run"), ("ctrl+e", "Edit")],
PickerKind::RunbookSteps => &[("enter", "Run step"), ("ctrl+y", "Copy step")],
PickerKind::Commands => &[("enter", "Run")],
PickerKind::Pane => &[("enter", "Go to pane")],
PickerKind::Tool => &[("enter", "Start tool")],
_ => &[("enter", "Choose")],
}
}
pub fn refilter(&mut self) {
let mut scored: Vec<(i32, usize)> = self
.items
.iter()
.enumerate()
.filter(|(_, it)| !matches!(it.action, PickAction::NewWorkspace))
.filter_map(|(i, it)| {
fuzzy_score(&self.query, &it.label)
.or_else(|| fuzzy_score(&self.query, &it.detail).map(|s| s - 1000))
.map(|s| (s, i))
})
.collect();
if !self.query.is_empty() {
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
}
self.filtered = scored.into_iter().map(|(_, i)| i).collect();
if self.kind == PickerKind::Workspace
&& !self.query.trim().is_empty()
&& !self.items.iter().any(|it| it.label == self.query.trim())
&& let Some(i) = self.items.iter().position(|it| matches!(it.action, PickAction::NewWorkspace))
{
self.items[i].label = format!("+ new workspace \"{}\"", self.query.trim());
self.filtered.push(i);
}
if self.kind == PickerKind::Ssh && !self.query.trim().is_empty() && !self.query.contains(' ') {
let host = self.query.trim().to_string();
if !self.items.iter().any(|it| it.label == host) {
self.items.retain(|it| it.detail != "typed");
self.items.push(PickItem {
label: host.clone(),
detail: "typed".into(),
action: PickAction::Ssh(host),
state: None,
dim: false,
});
self.filtered.push(self.items.len() - 1);
}
}
self.selected = self.selected.min(self.filtered.len().saturating_sub(1));
}
pub fn current(&self) -> Option<&PickItem> {
self.filtered.get(self.selected).map(|&i| &self.items[i])
}
pub fn move_by(&mut self, delta: i32) {
if self.filtered.is_empty() {
return;
}
let n = self.filtered.len() as i32;
self.selected = (self.selected as i32 + delta).rem_euclid(n) as usize;
}
}
pub enum PromptAction {
RenameTab {
workspace: u32,
tab: u32,
},
RenamePane(PaneId),
RenameWorkspace(u32),
NewWorkspace,
OpenPreview,
SaveWorkspace(u32),
Upload {
paths: Vec<std::path::PathBuf>,
raw: Vec<u8>,
pane: PaneId,
},
UploadPick {
server: String,
},
Download,
AddAccount {
agent: String,
},
NewWorktree {
agent: String,
},
Commit {
pane: PaneId,
},
RunOn {
targets: Vec<String>,
},
Tunnel {
server: String,
},
RunbookValue,
}
impl PromptAction {
pub fn verb(&self) -> &'static str {
match self {
PromptAction::RenameTab { .. } | PromptAction::RenamePane(_) | PromptAction::RenameWorkspace(_) => "rename",
PromptAction::NewWorkspace | PromptAction::NewWorktree { .. } => "create",
PromptAction::OpenPreview => "open",
PromptAction::SaveWorkspace(_) => "save",
PromptAction::Upload { .. } | PromptAction::UploadPick { .. } => "upload",
PromptAction::Download => "download",
PromptAction::AddAccount { .. } => "add",
PromptAction::Commit { .. } => "commit",
PromptAction::RunOn { .. } => "run",
PromptAction::Tunnel { .. } => "forward",
PromptAction::RunbookValue => "next",
}
}
}
pub struct Prompt {
pub title: String,
pub value: String,
pub cursor: usize,
pub action: PromptAction,
pub error: Option<String>,
pub masked: bool,
}
impl Prompt {
pub fn new(title: &str, value: &str, action: PromptAction) -> Prompt {
Prompt {
title: title.into(),
value: value.into(),
cursor: value.chars().count(),
action,
error: None,
masked: false,
}
}
}
pub fn edit_line(text: &mut String, cursor: &mut usize, k: &crate::input::Key) -> bool {
use crate::input::KeyCode as K;
let chars: Vec<char> = text.chars().collect();
let mut at = (*cursor).min(chars.len());
let word_left = |at: usize| {
let mut i = at;
while i > 0 && !chars[i - 1].is_alphanumeric() {
i -= 1;
}
while i > 0 && chars[i - 1].is_alphanumeric() {
i -= 1;
}
i
};
let word_right = |at: usize| {
let mut i = at;
while i < chars.len() && !chars[i].is_alphanumeric() {
i += 1;
}
while i < chars.len() && chars[i].is_alphanumeric() {
i += 1;
}
i
};
let (ctrl, alt) = (k.mods.ctrl, k.mods.alt);
let mut next: Vec<char> = chars.clone();
match k.code {
K::Left if alt || ctrl => at = word_left(at),
K::Right if alt || ctrl => at = word_right(at),
K::Left => at = at.saturating_sub(1),
K::Right => at = (at + 1).min(chars.len()),
K::Home => at = 0,
K::End => at = chars.len(),
K::Backspace if alt || ctrl => {
let from = word_left(at);
next.drain(from..at);
at = from;
}
K::Backspace if at > 0 => {
next.remove(at - 1);
at -= 1;
}
K::Backspace => {}
K::Delete if at < chars.len() => {
next.remove(at);
}
K::Delete => {}
K::Char(c) if ctrl => match c {
'a' => at = 0,
'e' => at = chars.len(),
'b' => at = at.saturating_sub(1),
'f' => at = (at + 1).min(chars.len()),
'd' if at < chars.len() => {
next.remove(at);
}
'h' if at > 0 => {
next.remove(at - 1);
at -= 1;
}
'w' => {
let mut from = at;
while from > 0 && matches!(chars[from - 1], ' ' | '/') {
from -= 1;
}
while from > 0 && !matches!(chars[from - 1], ' ' | '/') {
from -= 1;
}
next.drain(from..at);
at = from;
}
'u' => {
next.drain(..at);
at = 0;
}
'k' => {
next.truncate(at);
}
_ => return false,
},
K::Char('b') if alt => at = word_left(at),
K::Char('f') if alt => at = word_right(at),
K::Char('d') if alt => {
let to = word_right(at);
next.drain(at..to);
}
K::Char(_) if alt => return false,
K::Char(_) if chars.len() >= MAX_FIELD => {}
K::Char(c) => {
next.insert(at, c);
at += 1;
}
_ => return false,
}
*text = next.into_iter().collect();
*cursor = at;
true
}
pub const MAX_FIELD: usize = 4096;
pub fn paste_line(text: &mut String, cursor: &mut usize, pasted: &str) -> Result<(), String> {
let line = pasted.lines().next().unwrap_or("");
if text.chars().count() + line.chars().count() > MAX_FIELD {
return Err(format!("Too long to paste: a field holds at most {MAX_FIELD} characters."));
}
let at: usize = text.char_indices().nth(*cursor).map(|(i, _)| i).unwrap_or(text.len());
text.insert_str(at, line);
*cursor += line.chars().count();
Ok(())
}
pub enum Drag {
Border(Border, u16),
Select {
pane: PaneId,
from: (u16, u16),
moved: bool,
},
MoveFloat {
pane: PaneId,
grab_x: u16,
grab_y: u16,
},
ResizeFloat {
pane: PaneId,
},
Forward {
pane: PaneId,
button: crate::input::MouseButton,
},
}
pub fn fuzzy_score(query: &str, text: &str) -> Option<i32> {
let q: Vec<char> = query.to_lowercase().chars().filter(|c| !c.is_whitespace()).collect();
if q.is_empty() {
return Some(0);
}
let t: Vec<char> = text.to_lowercase().chars().collect();
let mut score = 0;
let mut qi = 0;
let mut prev_match: Option<usize> = None;
for (ti, ch) in t.iter().enumerate() {
if qi < q.len() && *ch == q[qi] {
score += 1;
if prev_match == Some(ti.wrapping_sub(1)) {
score += 5;
}
if ti == 0 || matches!(t[ti - 1], ' ' | '-' | '_' | '.' | '/' | '@' | '›') {
score += 8;
}
prev_match = Some(ti);
qi += 1;
}
}
if qi < q.len() {
return None;
}
Some(score * 4 - t.len() as i32 / 8)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fuzzy_prefers_word_starts_and_runs() {
assert!(fuzzy_score("tp", "team-prod-nginx").unwrap() > fuzzy_score("tp", "settings-path").unwrap_or(-100));
assert!(fuzzy_score("xyz", "tig").is_none());
assert_eq!(fuzzy_score("", "anything"), Some(0));
assert!(fuzzy_score("worker1", "admin@worker10.lan").is_some());
}
#[test]
fn line_editing() {
use crate::input::{Key, KeyCode, Mods};
let key = |code: KeyCode, mods: Mods| Key { code, mods, raw: Vec::new() };
let ch = |c: char| key(KeyCode::Char(c), Mods::NONE);
let ctrl = |c: char| key(KeyCode::Char(c), Mods::CTRL);
let (mut t, mut c) = (String::from("kraal web"), 9);
assert!(edit_line(&mut t, &mut c, &key(KeyCode::Left, Mods::ALT)));
assert_eq!(c, 6);
edit_line(&mut t, &mut c, &ch('X'));
assert_eq!((t.as_str(), c), ("kraal Xweb", 7));
edit_line(&mut t, &mut c, &ctrl('a'));
edit_line(&mut t, &mut c, &key(KeyCode::Delete, Mods::NONE));
assert_eq!(t, "raal Xweb");
edit_line(&mut t, &mut c, &ctrl('e'));
edit_line(&mut t, &mut c, &ctrl('w'));
assert_eq!(t, "raal ");
edit_line(&mut t, &mut c, &key(KeyCode::Home, Mods::NONE));
edit_line(&mut t, &mut c, &ctrl('k'));
assert_eq!((t.as_str(), c), ("", 0));
assert!(!edit_line(&mut t, &mut c, &key(KeyCode::Enter, Mods::NONE)));
let (mut t, mut c) = (String::from("é日"), 1);
paste_line(&mut t, &mut c, "ab\ncd").unwrap();
assert!(paste_line(&mut t, &mut c, &"x".repeat(MAX_FIELD)).is_err(), "refused whole");
assert_eq!((t.as_str(), c), ("éab日", 3));
}
#[test]
fn picker_offers_new_workspace() {
let items = vec![
PickItem {
label: "perso".into(),
detail: String::new(),
action: PickAction::SwitchWorkspace(1),
state: None,
dim: false,
},
PickItem {
label: String::new(),
detail: String::new(),
action: PickAction::NewWorkspace,
state: None,
dim: false,
},
];
let mut p = Picker::new(PickerKind::Workspace, "ws", items);
assert_eq!(p.filtered.len(), 1);
p.query = "blog".into();
p.refilter();
assert_eq!(p.filtered.len(), 1);
assert!(p.current().unwrap().label.contains("blog"));
}
}