use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::{cursor, queue, style, terminal};
use crate::commands::clean::{
Candidate, NAME_HEADER as CLEAN_NAME_HEADER, NOTE_HEADER, VERDICT_HEADER,
};
use crate::commands::remove::{removal_blocker, remove_worktree, RemoveOptions};
use crate::git::{self, Worktree};
use crate::repo::Repo;
pub enum Outcome {
Selected(PathBuf),
Cancelled,
}
pub fn is_available() -> bool {
open_tty().is_ok()
}
fn open_tty() -> Result<File> {
File::options()
.write(true)
.open("/dev/tty")
.context("no terminal available")
}
pub fn should_pick(repo: &Repo) -> Result<bool> {
if !is_available() {
return Ok(false);
}
Ok(repo.worktrees()?.len() > 1)
}
struct Item {
name: String,
path: PathBuf,
head: String,
note: String,
is_current: bool,
is_main: bool,
worktree: Worktree,
}
#[derive(Debug, PartialEq, Eq)]
enum Backspace {
ErasedFilter,
Absorbed,
Delete,
}
struct Picker {
items: Vec<Item>,
filter: String,
cursor: usize,
offset: usize,
erasing: bool,
}
impl Picker {
fn new(items: Vec<Item>) -> Self {
Self {
items,
filter: String::new(),
cursor: 0,
offset: 0,
erasing: false,
}
}
fn backspace(&mut self) -> Backspace {
if !self.filter.is_empty() {
self.pop_filter();
self.erasing = true;
return Backspace::ErasedFilter;
}
if self.erasing {
self.erasing = false;
return Backspace::Absorbed;
}
Backspace::Delete
}
fn note_other_key(&mut self) {
self.erasing = false;
}
fn matches(&self) -> Vec<usize> {
if self.filter.is_empty() {
return (0..self.items.len()).collect();
}
let needle = self.filter.to_lowercase();
self.items
.iter()
.enumerate()
.filter(|(_, item)| {
item.name.to_lowercase().contains(&needle)
|| item.path.to_string_lossy().to_lowercase().contains(&needle)
})
.map(|(i, _)| i)
.collect()
}
fn selected(&self) -> Option<&Item> {
self.matches().get(self.cursor).map(|&i| &self.items[i])
}
fn move_down(&mut self) {
let len = self.matches().len();
if len > 0 {
self.cursor = (self.cursor + 1) % len;
}
}
fn move_up(&mut self) {
let len = self.matches().len();
if len > 0 {
self.cursor = (self.cursor + len - 1) % len;
}
}
fn push_filter(&mut self, c: char) {
self.filter.push(c);
self.clamp();
}
fn pop_filter(&mut self) {
self.filter.pop();
self.clamp();
}
fn clamp(&mut self) {
let len = self.matches().len();
if len == 0 {
self.cursor = 0;
} else if self.cursor >= len {
self.cursor = len - 1;
}
}
fn scroll_into_view(&mut self, height: usize) {
if height == 0 {
return;
}
if self.cursor < self.offset {
self.offset = self.cursor;
} else if self.cursor >= self.offset + height {
self.offset = self.cursor + 1 - height;
}
}
}
struct Screen {
tty: File,
}
impl Screen {
fn open() -> Result<Self> {
let mut tty = open_tty()?;
terminal::enable_raw_mode().context("failed to switch the terminal to raw mode")?;
queue!(tty, terminal::EnterAlternateScreen, cursor::Hide)?;
tty.flush()?;
Ok(Self { tty })
}
}
impl Drop for Screen {
fn drop(&mut self) {
let _ = queue!(self.tty, cursor::Show, terminal::LeaveAlternateScreen);
let _ = self.tty.flush();
let _ = terminal::disable_raw_mode();
}
}
const STATUS_POLL: std::time::Duration = std::time::Duration::from_millis(60);
pub fn pick(repo: &Repo) -> Result<Outcome> {
let mut screen = Screen::open()?;
let mut picker = Picker::new(load(repo)?);
let mut status = StatusFeed::spawn(repo, &picker.items);
let mut message: Option<String> = None;
loop {
draw(&mut screen.tty, &mut picker, message.as_deref())?;
let Some(key) = next_key(&mut picker, &mut status)? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
message = None;
let action = key_action(&key);
if action != Action::Backspace {
picker.note_other_key();
}
match action {
Action::Cancel => return Ok(Outcome::Cancelled),
Action::Confirm => {
if let Some(item) = picker.selected() {
return Ok(Outcome::Selected(item.path.clone()));
}
}
Action::Down => picker.move_down(),
Action::Up => picker.move_up(),
Action::Backspace => {
if picker.backspace() == Backspace::Delete {
message = delete_selected(repo, &mut screen.tty, &mut picker)?;
status = StatusFeed::spawn(repo, &picker.items);
}
}
Action::Insert(c) => picker.push_filter(c),
Action::Delete => {
message = delete_selected(repo, &mut screen.tty, &mut picker)?;
status = StatusFeed::spawn(repo, &picker.items);
}
Action::None => {}
}
}
}
fn next_key(picker: &mut Picker, status: &mut StatusFeed) -> Result<Option<KeyEvent>> {
loop {
if status.is_done() {
return Ok(match event::read()? {
Event::Key(key) => Some(key),
_ => None,
});
}
if event::poll(STATUS_POLL)? {
return Ok(match event::read()? {
Event::Key(key) => Some(key),
_ => None,
});
}
if status.drain(&mut picker.items) {
return Ok(None);
}
}
}
fn delete_selected(repo: &Repo, tty: &mut File, picker: &mut Picker) -> Result<Option<String>> {
let Some(item) = picker.selected() else {
return Ok(None);
};
let worktree = item.worktree.clone();
let label = item.name.clone();
if let Some(reason) = removal_blocker(repo, &worktree, true) {
return Ok(Some(format!("cannot remove `{label}`: {reason}")));
}
let dirty = crate::git::is_dirty(&worktree.path).unwrap_or(false);
let merged = worktree
.branch
.as_deref()
.map(|b| crate::git::is_merged(&repo.main, b).unwrap_or(false))
.unwrap_or(false);
let Some(with_branch) = confirm(tty, &worktree, dirty, merged)? else {
return Ok(None);
};
working(tty, &format!("Removing {}...", worktree.path.display()))?;
let opts = RemoveOptions {
force: true,
with_branch,
quiet: true,
no_hooks: false,
};
let outcome = match remove_worktree(repo, &worktree, opts) {
Ok(()) => {
picker.items = load(repo)?;
picker.clamp();
let extra = if with_branch { " and its branch" } else { "" };
Some(format!("removed `{label}`{extra}"))
}
Err(e) => Some(format!("failed to remove `{label}`: {e:#}")),
};
discard_pending_input()?;
Ok(outcome)
}
fn working(tty: &mut File, what: &str) -> Result<()> {
let (_, rows) = terminal::size()?;
queue!(
tty,
terminal::Clear(terminal::ClearType::All),
cursor::MoveTo(0, 0),
style::Print(what),
cursor::MoveTo(0, rows.saturating_sub(1)),
)?;
tty.flush()?;
Ok(())
}
fn discard_pending_input() -> Result<()> {
while event::poll(std::time::Duration::from_millis(0))? {
let _ = event::read()?;
}
Ok(())
}
fn confirm(tty: &mut File, worktree: &Worktree, dirty: bool, merged: bool) -> Result<Option<bool>> {
let branch = worktree.branch.clone();
loop {
let (_, rows) = terminal::size()?;
queue!(
tty,
terminal::Clear(terminal::ClearType::All),
cursor::MoveTo(0, 0),
style::Print("Remove this worktree?"),
cursor::MoveTo(0, 2),
style::Print(format!(" {}", worktree.path.display())),
)?;
let mut row = 4;
if dirty {
queue!(
tty,
cursor::MoveTo(0, row),
style::Print(" ! uncommitted changes will be lost"),
)?;
row += 1;
}
if let Some(b) = &branch {
let state = if merged { "merged" } else { "NOT merged" };
queue!(
tty,
cursor::MoveTo(0, row),
style::Print(format!(" branch `{b}` ({state})")),
)?;
}
let keys = match &branch {
Some(_) => "[y] remove worktree [b] remove worktree and branch [n] cancel",
None => "[y] remove worktree [n] cancel",
};
queue!(
tty,
cursor::MoveTo(0, rows.saturating_sub(1)),
style::Print(keys)
)?;
tty.flush()?;
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => return Ok(Some(false)),
KeyCode::Char('b') | KeyCode::Char('B') if branch.is_some() => return Ok(Some(true)),
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => return Ok(None),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return Ok(None),
_ => {}
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum Action {
Confirm,
Cancel,
Up,
Down,
Delete,
Backspace,
Insert(char),
None,
}
fn key_action(key: &KeyEvent) -> Action {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Enter => Action::Confirm,
KeyCode::Esc => Action::Cancel,
KeyCode::Char('c') | KeyCode::Char('g') if ctrl => Action::Cancel,
KeyCode::Down => Action::Down,
KeyCode::Up => Action::Up,
KeyCode::Char('n') if ctrl => Action::Down,
KeyCode::Char('p') if ctrl => Action::Up,
KeyCode::Char('d') if ctrl => Action::Delete,
KeyCode::Delete => Action::Delete,
KeyCode::Backspace => Action::Backspace,
KeyCode::Char('h') if ctrl => Action::Backspace,
KeyCode::Char(c) if !ctrl => Action::Insert(c),
_ => Action::None,
}
}
fn load(repo: &Repo) -> Result<Vec<Item>> {
let worktrees = repo.worktrees()?;
let Some(main) = worktrees.first().cloned() else {
return Ok(Vec::new());
};
Ok(worktrees
.into_iter()
.map(|wt| Item {
name: repo.display_name(&wt, &main),
head: wt.short_head(),
note: String::new(),
is_main: wt.path == main.path,
is_current: repo.cwd.starts_with(&wt.path),
path: wt.path.clone(),
worktree: wt,
})
.collect())
}
struct StatusFeed {
rx: Option<std::sync::mpsc::Receiver<Vec<(usize, String)>>>,
}
struct Subject {
index: usize,
path: PathBuf,
branch: Option<String>,
is_main: bool,
flags: Vec<&'static str>,
}
impl StatusFeed {
fn spawn(repo: &Repo, items: &[Item]) -> Self {
let (tx, rx) = std::sync::mpsc::channel();
let main = repo.main.clone();
let subjects: Vec<Subject> = items
.iter()
.enumerate()
.map(|(index, item)| Subject {
index,
path: item.path.clone(),
branch: item.worktree.branch.clone(),
is_main: item.is_main,
flags: flags(&item.worktree),
})
.collect();
std::thread::spawn(move || {
let merged = git::merged_branches(&main).unwrap_or_default();
let mut workers = Vec::new();
for subject in subjects {
let merged = merged.clone();
workers.push(std::thread::spawn(move || {
let note = note(
&subject.path,
subject.branch.as_deref(),
&merged,
subject.is_main,
&subject.flags,
);
(subject.index, note)
}));
}
let notes = workers.into_iter().filter_map(|w| w.join().ok()).collect();
let _ = tx.send(notes);
});
Self { rx: Some(rx) }
}
fn drain(&mut self, items: &mut [Item]) -> bool {
use std::sync::mpsc::TryRecvError;
let Some(rx) = &self.rx else {
return false;
};
match rx.try_recv() {
Ok(notes) => {
for (index, note) in notes {
if let Some(item) = items.get_mut(index) {
item.note = note;
}
}
self.rx = None;
true
}
Err(TryRecvError::Empty) => false,
Err(TryRecvError::Disconnected) => {
self.rx = None;
false
}
}
}
fn is_done(&self) -> bool {
self.rx.is_none()
}
}
fn note(
path: &std::path::Path,
branch: Option<&str>,
merged: &[String],
is_main: bool,
flags: &[&str],
) -> String {
let mut notes: Vec<String> = Vec::new();
if git::is_dirty(path).unwrap_or(false) {
notes.push("dirty".to_string());
}
if !is_main {
if let Some(branch) = branch {
if merged.iter().any(|b| b == branch) {
notes.push("merged".to_string());
}
}
}
notes.extend(flags.iter().map(|f| f.to_string()));
notes.join(", ")
}
fn flags(wt: &Worktree) -> Vec<&'static str> {
let mut notes = Vec::new();
if wt.bare {
notes.push("bare");
}
if wt.detached {
notes.push("detached");
}
if wt.locked {
notes.push("locked");
}
notes
}
struct Hint {
keys: &'static [&'static str],
label: &'static str,
optional: bool,
}
const HINTS_IDLE: &[Hint] = &[
Hint {
keys: &["up/down"],
label: "move",
optional: true,
},
Hint {
keys: &["enter"],
label: "cd",
optional: false,
},
Hint {
keys: &["ctrl-d", "backspace"],
label: "delete",
optional: false,
},
Hint {
keys: &["esc"],
label: "cancel",
optional: false,
},
];
const HINTS_FILTERING: &[Hint] = &[
Hint {
keys: &["up/down"],
label: "move",
optional: true,
},
Hint {
keys: &["enter"],
label: "cd",
optional: false,
},
Hint {
keys: &["ctrl-d"],
label: "delete",
optional: false,
},
Hint {
keys: &["backspace"],
label: "erase",
optional: false,
},
Hint {
keys: &["esc"],
label: "cancel",
optional: false,
},
];
fn hints_for(picker: &Picker) -> &'static [Hint] {
if picker.filter.is_empty() {
HINTS_IDLE
} else {
HINTS_FILTERING
}
}
fn hints_width(hints: &[&Hint]) -> usize {
hints
.iter()
.map(|h| {
let keys: usize = h.keys.iter().map(|k| k.chars().count() + 2).sum();
let between_keys = h.keys.len() - 1;
keys + between_keys + 1 + h.label.chars().count()
})
.sum::<usize>()
+ hints.len().saturating_sub(1) * 2
}
fn hints_that_fit(hints: &'static [Hint], width: usize) -> Vec<&'static Hint> {
let mut kept: Vec<&Hint> = hints.iter().collect();
while hints_width(&kept) > width && kept.iter().any(|h| h.optional) {
let index = kept.iter().position(|h| h.optional).unwrap();
kept.remove(index);
}
kept
}
fn fit(line: &str, width: usize) -> String {
let mut out: String = line.chars().take(width).collect();
let len = out.chars().count();
if len < width {
out.extend(std::iter::repeat_n(' ', width - len));
}
out
}
const PLACEHOLDER: &str = "type to filter";
const NAME_HEADER: &str = "WORKTREE";
const HEAD_HEADER: &str = "HEAD ";
const STATUS_HEADER: &str = "STATUS";
fn count_label(filter: &str, matched: usize, total: usize) -> String {
if !filter.is_empty() {
return format!("{matched} of {total}");
}
match total {
1 => "1 worktree".to_string(),
n => format!("{n} worktrees"),
}
}
fn draw_prompt(tty: &mut File, picker: &Picker, matched: usize, cols: usize) -> Result<()> {
let placeholder = if picker.filter.is_empty() {
PLACEHOLDER
} else {
""
};
let count = count_label(&picker.filter, matched, picker.items.len());
let left = 2 + picker.filter.chars().count() + 1 + placeholder.chars().count();
queue!(tty, style::Print("> "), style::Print(&picker.filter))?;
queue!(
tty,
style::SetAttribute(style::Attribute::Reverse),
style::Print(" "),
style::SetAttribute(style::Attribute::Reset),
)?;
if !placeholder.is_empty() {
queue!(
tty,
style::SetAttribute(style::Attribute::Dim),
style::Print(placeholder),
style::SetAttribute(style::Attribute::Reset),
)?;
}
let gap = cols.saturating_sub(left + count.chars().count());
if gap > 0 {
queue!(
tty,
style::Print(" ".repeat(gap)),
style::SetAttribute(style::Attribute::Dim),
style::Print(count),
style::SetAttribute(style::Attribute::Reset),
)?;
}
Ok(())
}
fn draw(tty: &mut File, picker: &mut Picker, message: Option<&str>) -> Result<()> {
let (cols, rows) = terminal::size()?;
let cols = cols as usize;
let reserved = if message.is_some() { 4 } else { 3 };
let height = (rows as usize).saturating_sub(reserved);
picker.scroll_into_view(height);
let matches = picker.matches();
let name_width = matches
.iter()
.map(|&i| picker.items[i].name.chars().count())
.max()
.unwrap_or(0)
.max(NAME_HEADER.len());
let header = fit(
&format!(" {NAME_HEADER:<name_width$} {HEAD_HEADER} {STATUS_HEADER}"),
cols,
);
queue!(
tty,
terminal::Clear(terminal::ClearType::All),
cursor::MoveTo(0, 0),
style::SetAttribute(style::Attribute::Bold),
style::SetAttribute(style::Attribute::Underlined),
style::Print(header),
style::SetAttribute(style::Attribute::Reset),
)?;
for (row, &index) in matches.iter().skip(picker.offset).take(height).enumerate() {
let item = &picker.items[index];
let is_cursor = picker.offset + row == picker.cursor;
let marker = if item.is_current { "*" } else { " " };
let line = format!(
"{marker} {:<name_width$} {} {}",
item.name, item.head, item.note
);
let line = fit(&line, cols);
queue!(tty, cursor::MoveTo(0, row as u16 + 1))?;
if is_cursor {
queue!(
tty,
style::SetAttribute(style::Attribute::Reverse),
style::Print(line),
style::SetAttribute(style::Attribute::Reset),
)?;
} else {
queue!(tty, style::Print(line))?;
}
}
if let Some(message) = message {
queue!(
tty,
cursor::MoveTo(0, rows.saturating_sub(3)),
style::Print(message.chars().take(cols).collect::<String>()),
)?;
}
queue!(tty, cursor::MoveTo(0, rows.saturating_sub(2)))?;
draw_prompt(tty, picker, matches.len(), cols)?;
queue!(tty, cursor::MoveTo(0, rows.saturating_sub(1)))?;
draw_hints(tty, &hints_that_fit(hints_for(picker), cols))?;
tty.flush()?;
Ok(())
}
fn draw_hints(tty: &mut File, hints: &[&Hint]) -> Result<()> {
for (i, hint) in hints.iter().enumerate() {
if i > 0 {
queue!(tty, style::Print(" "))?;
}
for (k, key) in hint.keys.iter().enumerate() {
if k > 0 {
queue!(tty, style::Print(" "))?;
}
queue!(
tty,
style::SetAttribute(style::Attribute::Reverse),
style::Print(format!(" {key} ")),
style::SetAttribute(style::Attribute::Reset),
)?;
}
queue!(tty, style::Print(format!(" {}", hint.label)))?;
}
Ok(())
}
pub fn choose_to_clean(candidates: &[Candidate], with_branch: bool) -> Result<Option<Vec<usize>>> {
let mut screen = Screen::open()?;
let mut ticked: Vec<bool> = candidates.iter().map(|c| c.state.preselected()).collect();
let mut cursor_at = 0usize;
let last = candidates.len() - 1;
loop {
draw_clean(&mut screen.tty, candidates, &ticked, cursor_at, with_branch)?;
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => return Ok(None),
KeyCode::Char('c') | KeyCode::Char('g') if ctrl => return Ok(None),
KeyCode::Enter => {
return Ok(Some(
ticked
.iter()
.enumerate()
.filter(|(_, on)| **on)
.map(|(i, _)| i)
.collect(),
))
}
KeyCode::Down => cursor_at = if cursor_at == last { 0 } else { cursor_at + 1 },
KeyCode::Char('n') if ctrl => {
cursor_at = if cursor_at == last { 0 } else { cursor_at + 1 }
}
KeyCode::Up => cursor_at = cursor_at.checked_sub(1).unwrap_or(last),
KeyCode::Char('p') if ctrl => cursor_at = cursor_at.checked_sub(1).unwrap_or(last),
KeyCode::Char(' ') => ticked[cursor_at] = !ticked[cursor_at],
_ => {}
}
}
}
fn draw_clean(
tty: &mut File,
candidates: &[Candidate],
ticked: &[bool],
cursor_at: usize,
with_branch: bool,
) -> Result<()> {
let (cols, rows) = terminal::size()?;
let cols = cols as usize;
let verdicts: Vec<String> = candidates
.iter()
.map(|c| c.state.verdict(with_branch))
.collect();
let width = candidates
.iter()
.map(|c| c.name.chars().count())
.max()
.unwrap_or(0)
.max(CLEAN_NAME_HEADER.len());
let verdict_width = verdicts
.iter()
.map(|v| v.chars().count())
.max()
.unwrap_or(0)
.max(VERDICT_HEADER.len());
let header = format!(
" {CLEAN_NAME_HEADER:<width$} {VERDICT_HEADER:<verdict_width$} {NOTE_HEADER}"
);
queue!(
tty,
terminal::Clear(terminal::ClearType::All),
cursor::MoveTo(0, 0),
style::Print("Select worktrees to remove"),
cursor::MoveTo(0, 2),
style::SetAttribute(style::Attribute::Bold),
style::SetAttribute(style::Attribute::Underlined),
style::Print(fit(&header, cols)),
style::SetAttribute(style::Attribute::Reset),
)?;
for (i, candidate) in candidates.iter().enumerate() {
let row = 3 + i as u16;
if row >= rows.saturating_sub(2) {
break;
}
let line = format!(
"{} [{}] {:<width$} {:<verdict_width$} {}",
if i == cursor_at { ">" } else { " " },
if ticked[i] { "x" } else { " " },
candidate.name,
verdicts[i],
candidate.note,
);
queue!(tty, cursor::MoveTo(0, row))?;
if i == cursor_at {
queue!(
tty,
style::SetAttribute(style::Attribute::Reverse),
style::Print(fit(&format!("{line:<cols$}"), cols)),
style::SetAttribute(style::Attribute::Reset),
)?;
} else {
queue!(tty, style::Print(fit(&line, cols)))?;
}
}
let count = ticked.iter().filter(|on| **on).count();
queue!(
tty,
cursor::MoveTo(0, rows.saturating_sub(2)),
style::SetAttribute(style::Attribute::Dim),
style::Print(fit(
&format!("{count} of {} selected", candidates.len()),
cols
)),
style::SetAttribute(style::Attribute::Reset),
cursor::MoveTo(0, rows.saturating_sub(1)),
)?;
draw_hints(tty, &hints_that_fit(CLEAN_HINTS, cols))?;
tty.flush()?;
Ok(())
}
const CLEAN_HINTS: &[Hint] = &[
Hint {
keys: &["up/down"],
label: "move",
optional: false,
},
Hint {
keys: &["space"],
label: "toggle",
optional: false,
},
Hint {
keys: &["enter"],
label: "remove",
optional: false,
},
Hint {
keys: &["esc"],
label: "cancel",
optional: false,
},
];
#[cfg(test)]
mod tests {
use super::*;
fn item(name: &str, path: &str) -> Item {
Item {
name: name.to_string(),
path: PathBuf::from(path),
head: "abc1234".to_string(),
note: String::new(),
is_current: false,
is_main: false,
worktree: Worktree {
path: PathBuf::from(path),
head: None,
branch: Some(name.to_string()),
bare: false,
detached: false,
locked: false,
},
}
}
fn picker() -> Picker {
Picker::new(vec![
item("@", "/repo"),
item("feature/auth", "/wt/feature/auth"),
item("feature/billing", "/wt/feature/billing"),
item("hotfix", "/wt/hotfix"),
])
}
#[test]
fn the_interface_is_ascii_only() {
for hint in HINTS_IDLE.iter().chain(HINTS_FILTERING) {
for key in hint.keys {
assert!(key.is_ascii(), "{key}");
}
assert!(hint.label.is_ascii(), "{}", hint.label);
}
for label in [
"Remove this worktree?",
" ! uncommitted changes will be lost",
"[y] remove worktree [b] remove worktree and branch [n] cancel",
] {
assert!(label.is_ascii(), "{label}");
}
}
#[test]
fn keys_are_spelled_out() {
let keys: Vec<&str> = HINTS_IDLE
.iter()
.chain(HINTS_FILTERING)
.flat_map(|h| h.keys.iter().copied())
.collect();
assert!(keys.contains(&"ctrl-d"), "{keys:?}");
assert!(keys.contains(&"backspace"), "{keys:?}");
assert!(!keys.iter().any(|k| k.contains("bksp") || k.contains('^')));
}
#[test]
fn the_help_says_what_backspace_will_do() {
let mut p = picker();
let idle = hints_for(&p);
let delete = idle.iter().find(|h| h.label == "delete").unwrap();
assert!(delete.keys.contains(&"backspace"), "{:?}", delete.keys);
p.push_filter('a');
let filtering = hints_for(&p);
let erase = filtering.iter().find(|h| h.label == "erase").unwrap();
assert_eq!(erase.keys, &["backspace"]);
let delete = filtering.iter().find(|h| h.label == "delete").unwrap();
assert_eq!(delete.keys, &["ctrl-d"]);
}
#[test]
fn a_narrow_terminal_drops_optional_hints_instead_of_cutting_words() {
let full = hints_width(&HINTS_IDLE.iter().collect::<Vec<_>>());
assert_eq!(hints_that_fit(HINTS_IDLE, full).len(), HINTS_IDLE.len());
let narrowed = hints_that_fit(HINTS_IDLE, full - 1);
assert_eq!(narrowed.len(), HINTS_IDLE.len() - 1);
assert!(!narrowed.iter().any(|h| h.label == "move"));
assert!(narrowed.iter().any(|h| h.label == "cancel"));
assert!(!hints_that_fit(HINTS_IDLE, 1).is_empty());
}
#[test]
fn the_count_explains_an_empty_list() {
assert_eq!(count_label("", 4, 4), "4 worktrees");
assert_eq!(count_label("", 1, 1), "1 worktree");
assert_eq!(count_label("bill", 1, 4), "1 of 4");
assert_eq!(count_label("zzz", 0, 4), "0 of 4");
}
#[test]
fn the_column_headers_are_ascii() {
for header in [NAME_HEADER, HEAD_HEADER, STATUS_HEADER] {
assert!(header.is_ascii(), "{header}");
}
}
#[test]
fn rows_start_without_a_status() {
let p = picker();
assert!(p.items.iter().all(|i| i.note.is_empty()));
}
#[test]
fn the_placeholder_is_ascii_and_says_what_typing_does() {
assert!(PLACEHOLDER.is_ascii(), "{PLACEHOLDER}");
assert!(PLACEHOLDER.contains("filter"), "{PLACEHOLDER}");
}
#[test]
fn backspace_removes_a_worktree_when_nothing_is_typed() {
let mut p = picker();
assert_eq!(p.backspace(), Backspace::Delete);
}
#[test]
fn backspace_edits_the_filter_while_there_is_one() {
let mut p = picker();
p.push_filter('a');
p.push_filter('u');
assert_eq!(p.backspace(), Backspace::ErasedFilter);
assert_eq!(p.backspace(), Backspace::ErasedFilter);
assert_eq!(p.filter, "");
}
#[test]
fn holding_backspace_to_clear_cannot_run_into_a_deletion() {
let mut p = picker();
for c in "auth".chars() {
p.push_filter(c);
}
for _ in 0..4 {
assert_eq!(p.backspace(), Backspace::ErasedFilter);
}
assert_eq!(p.backspace(), Backspace::Absorbed);
assert_eq!(p.backspace(), Backspace::Delete);
}
#[test]
fn any_other_key_ends_the_erasing_streak() {
let mut p = picker();
p.push_filter('a');
assert_eq!(p.backspace(), Backspace::ErasedFilter);
p.note_other_key();
assert_eq!(p.backspace(), Backspace::Delete);
}
#[test]
fn rows_are_padded_so_a_highlight_covers_the_line() {
assert_eq!(fit("abc", 6), "abc ");
assert_eq!(fit("abcdefgh", 4), "abcd");
assert_eq!(fit("", 3), " ");
}
#[test]
fn filter_matches_name_and_path_case_insensitively() {
let mut p = picker();
p.filter = "AUTH".to_string();
assert_eq!(p.matches().len(), 1);
assert_eq!(p.selected().unwrap().name, "feature/auth");
p.filter = "feature".to_string();
assert_eq!(p.matches().len(), 2);
p.filter = "/wt/hot".to_string();
assert_eq!(p.selected().unwrap().name, "hotfix");
}
#[test]
fn cursor_wraps_around() {
let mut p = picker();
p.move_up();
assert_eq!(p.selected().unwrap().name, "hotfix");
p.move_down();
assert_eq!(p.selected().unwrap().name, "@");
}
#[test]
fn cursor_stays_inside_a_shrinking_list() {
let mut p = picker();
p.cursor = 3;
for c in "feature".chars() {
p.push_filter(c);
}
assert_eq!(p.matches().len(), 2);
assert_eq!(p.cursor, 1);
assert_eq!(p.selected().unwrap().name, "feature/billing");
}
#[test]
fn no_match_leaves_nothing_selected() {
let mut p = picker();
p.filter = "zzz".to_string();
p.clamp();
assert!(p.selected().is_none());
}
#[test]
fn backspace_restores_matches() {
let mut p = picker();
p.push_filter('z');
assert!(p.selected().is_none());
p.pop_filter();
assert_eq!(p.matches().len(), 4);
}
#[test]
fn scrolling_follows_the_cursor() {
let mut p = picker();
p.cursor = 3;
p.scroll_into_view(2);
assert_eq!(p.offset, 2);
p.cursor = 0;
p.scroll_into_view(2);
assert_eq!(p.offset, 0);
}
#[test]
fn delete_is_bound_to_both_delete_and_ctrl_d() {
let del = KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE);
let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
assert!(matches!(key_action(&del), Action::Delete));
assert!(matches!(key_action(&ctrl_d), Action::Delete));
let backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
assert!(matches!(key_action(&backspace), Action::Backspace));
}
#[test]
fn plain_characters_type_into_the_filter() {
let a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
assert!(matches!(key_action(&a), Action::Insert('a')));
let ctrl_n = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL);
assert!(matches!(key_action(&ctrl_n), Action::Down));
}
}