diff --git a/.gitignore b/.gitignore index a368894..9683cb4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target .data/*.log /.env +tmp/* +!.gitkeep diff --git a/Cargo.lock b/Cargo.lock index 031bf44..46acbc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,7 +184,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fa9e1d11a268684cbd90ed36370d7577afb6c62d912ddff5c15fc34343e5036" dependencies = [ "backtrace", - "console", + "console 0.15.11", ] [[package]] @@ -464,6 +464,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "const-random" version = "0.1.18" @@ -1142,6 +1153,7 @@ dependencies = [ "futures", "git2", "human-panic", + "insta", "json5", "libc", "pretty_assertions", @@ -2047,6 +2059,21 @@ dependencies = [ "rustversion", ] +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console 0.16.4", + "once_cell", + "pest", + "pest_derive", + "serde", + "similar", + "tempfile", +] + [[package]] name = "instability" version = "0.3.12" @@ -3214,6 +3241,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index dcfd04b..cd7b515 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,3 +60,6 @@ codegen-units = 1 lto = true opt-level = "s" strip = true + +[dev-dependencies] +insta = { version = "1.48.0", features = ["redactions"] } diff --git a/src/action.rs b/src/action.rs index c2792cf..9dbb46a 100644 --- a/src/action.rs +++ b/src/action.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use serde::{Deserialize, Serialize}; use strum::Display; @@ -12,7 +14,7 @@ pub enum Action { ClearScreen, Error(String), Help, - WorktreeUpdate, + WorktreeUpdate(Option), WorktreeOpenEditor(String), WorktreeNext, WorktreePrevious, diff --git a/src/app.rs b/src/app.rs index 03dce9e..3176413 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,7 @@ use crossterm::event::KeyEvent; -use ratatui::prelude::Rect; +use ratatui::{Frame, prelude::Rect}; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use tokio::sync::mpsc; use tracing::{debug, info}; @@ -12,6 +13,7 @@ use crate::{ }; pub struct App { + path: Option, config: Config, tick_rate: f64, frame_rate: f64, @@ -31,12 +33,13 @@ pub enum Mode { } impl App { - pub fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result { + pub fn new(tick_rate: f64, frame_rate: f64, path: Option) -> color_eyre::Result { let (action_tx, action_rx) = mpsc::unbounded_channel(); Ok(Self { + path: path.clone(), tick_rate, frame_rate, - components: vec![Box::new(WorktreeList::new())], + components: vec![Box::new(WorktreeList::new(path)?)], should_quit: false, should_suspend: false, config: Config::new()?, @@ -66,7 +69,7 @@ impl App { let action_tx = self.action_tx.clone(); - action_tx.send(Action::WorktreeUpdate)?; + action_tx.send(Action::WorktreeUpdate(self.path.clone()))?; loop { self.handle_events(&mut tui).await?; self.handle_actions(&mut tui)?; @@ -163,15 +166,21 @@ impl App { Ok(()) } - fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> { - tui.draw(|frame| { - for component in self.components.iter_mut() { - if let Err(err) = component.draw(frame, frame.area()) { - let _ = self - .action_tx - .send(Action::Error(format!("Failed to draw: {:?}", err))); - } + pub fn draw(&mut self, frame: &mut Frame, _area: Rect) -> color_eyre::Result<()> { + for component in self.components.iter_mut() { + if let Err(err) = component.draw(frame, frame.area()) { + let _ = self + .action_tx + .send(Action::Error(format!("Failed to draw: {:?}", err))); } + } + Ok(()) + } + + pub fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> { + tui.draw(|frame| { + self.draw(frame, frame.area()) + .expect("failed to draw to terminal") })?; Ok(()) } diff --git a/src/components/worktree_list.rs b/src/components/worktree_list.rs index 207273b..22ec546 100644 --- a/src/components/worktree_list.rs +++ b/src/components/worktree_list.rs @@ -11,11 +11,13 @@ use ratatui::layout::{Constraint, Rect}; use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Padding, Row, Table, TableState}; use std::io::stdout; +use std::path::PathBuf; use std::process::Command; use tokio::sync::mpsc::UnboundedSender; #[derive(Default)] pub struct WorktreeList { + repo: GitRepo, command_tx: Option>, config: Config, table_items: Vec, @@ -23,16 +25,46 @@ pub struct WorktreeList { } impl WorktreeList { - pub fn new() -> Self { - Self::default() + pub fn new(path: Option) -> color_eyre::Result { + let repo = GitRepo::new(path); + let mut table_state = TableState::default(); + let mut table_items = vec![]; + + let worktrees = repo.get_worktrees().context("Unable to get worktrees")?; + table_state.select(Some(0)); + for worktree in worktrees { + table_items.push(worktree); + } + Ok(Self { + repo, + table_items, + table_state, + ..Default::default() + }) } fn run_editor(&self, path: &str) -> color_eyre::Result<()> { stdout().execute(LeaveAlternateScreen)?; disable_raw_mode()?; + //TODO: should call configured editor or fallback to $EDITOR Command::new("zed").arg(path).status()?; Ok(()) } + + fn update_repos(&mut self, _path: Option) -> color_eyre::Result<()> { + let worktrees = self + .repo + .get_worktrees() + .context("Unable to get worktrees")?; + if !&worktrees.is_empty() && self.table_state.selected().is_none() { + self.table_state.select(Some(0)) + } + for worktree in worktrees { + self.table_items.push(worktree); + } + + Ok(()) + } } impl Component for WorktreeList { @@ -50,8 +82,8 @@ impl Component for WorktreeList { match key.code { KeyCode::Enter if self.table_state.selected().is_some() => { if let Some(current_path_index) = self.table_state.selected() { - let path = &self.table_items[current_path_index]; - return Ok(Some(Action::WorktreeOpenEditor(path.path.to_string()))); + let workspace = &self.table_items[current_path_index]; + return Ok(Some(Action::WorktreeOpenEditor(workspace.path.to_string()))); } color_eyre::eyre::bail!("expected to have selected path"); } @@ -79,15 +111,8 @@ impl Component for WorktreeList { Action::Render => { // add any logic here that should run on every render } - Action::WorktreeUpdate => { - let repo = GitRepo::new(); - let worktrees = repo.get_worktrees().context("Unable to get worktrees")?; - if !&worktrees.is_empty() && self.table_state.selected().is_none() { - self.table_state.select(Some(0)) - } - for worktree in worktrees { - self.table_items.push(worktree); - } + Action::WorktreeUpdate(path) => { + self.update_repos(path)?; } Action::WorktreePrevious => { self.table_state.select_previous(); @@ -115,7 +140,7 @@ impl Component for WorktreeList { let rows: Vec = self .table_items .iter() - .map(|item| Row::new([item.branch.clone(), item.path.to_string()])) + .map(|item| Row::new([item.branch.clone(), item.path.get_short_path()])) .collect(); let widths = [Constraint::Percentage(20), Constraint::Percentage(80)]; @@ -135,7 +160,7 @@ impl Component for WorktreeList { .style(Color::White) .row_highlight_style(Style::new().on_black().bold()) .column_highlight_style(Color::Gray) - .highlight_symbol("🐸 "); + .highlight_symbol("> "); frame.render_stateful_widget(table, area, &mut self.table_state); Ok(()) diff --git a/src/git_path.rs b/src/git_path.rs index 769bee8..9d70d7f 100644 --- a/src/git_path.rs +++ b/src/git_path.rs @@ -7,6 +7,20 @@ impl GitPath { pub fn new(path_buf: PathBuf) -> Self { Self(path_buf) } + + pub fn get_short_path(&self) -> String { + let mut short_path = String::new(); + let mut parts = self.0.iter().rev(); + // let mut parts = self.0.ancestors(); + for _ in 0..3 { + if let Some(part) = parts.next() + && let Some(part) = part.to_str() + { + short_path = format!("{}/{}", part, short_path,); + } + } + short_path + } } impl std::fmt::Display for GitPath { diff --git a/src/git_repo.rs b/src/git_repo.rs index 309fa52..23e1b3b 100644 --- a/src/git_repo.rs +++ b/src/git_repo.rs @@ -1,16 +1,24 @@ +use std::path::PathBuf; + use git2::Repository; use crate::{git_path::GitPath, git_worktree::GitWorktree}; -#[derive(Debug)] -pub struct GitRepo {} +#[derive(Debug, Default)] +pub struct GitRepo { + path: Option, +} impl GitRepo { - pub fn new() -> Self { - Self {} + pub fn new(path: Option) -> Self { + Self { path } } fn get_repo(&self) -> Result { - Repository::open_from_env() + if let Some(path) = &self.path { + Repository::open(path) + } else { + Repository::open_from_env() + } } pub fn get_worktrees(&self) -> Result, git2::Error> { diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1d14cc9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,8 @@ +pub mod action; +pub mod app; +pub mod components; +pub mod config; +pub mod git_path; +pub mod git_repo; +pub mod git_worktree; +pub mod tui; diff --git a/src/main.rs b/src/main.rs index 3e0c22c..3ad6ab0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,8 @@ async fn main() -> color_eyre::Result<()> { crate::logging::init()?; let args = Cli::parse(); - let mut app = App::new(args.tick_rate, args.frame_rate)?; + //TODO: add path to args list + let mut app = App::new(args.tick_rate, args.frame_rate, None)?; app.run().await?; Ok(()) } diff --git a/tests/common/helpers.rs b/tests/common/helpers.rs new file mode 100644 index 0000000..ae5da64 --- /dev/null +++ b/tests/common/helpers.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; + +use git2::{Repository, RepositoryInitOptions, WorktreeAddOptions}; + +pub struct Fixture { + pub path: PathBuf, + pub repo: Repository, +} + +impl Fixture { + pub fn new(path: &PathBuf) -> Self { + let mut opts = RepositoryInitOptions::new(); + opts.initial_head("main"); + let repo = Repository::init_opts(path, &opts).unwrap(); + + { + let mut config = repo.config().unwrap(); + config.set_str("user.name", "name").unwrap(); + config.set_str("user.email", "email").unwrap(); + let mut index = repo.index().unwrap(); + let id = index.write_tree().unwrap(); + + let tree = repo.find_tree(id).unwrap(); + let sig = repo.signature().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "initial\n\nbody", &tree, &[]) + .unwrap(); + } + Self { + repo, + path: path.to_path_buf(), + } + } + + pub fn create_worktree(&mut self, name: &str) { + let opts = WorktreeAddOptions::new(); + + let _ = self + .repo + .worktree(name, &self.path.join(name), Some(&opts)) + .unwrap(); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..6da0b6d --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,2 @@ +pub mod helpers; +pub mod temp_directory; diff --git a/tests/common/temp_directory.rs b/tests/common/temp_directory.rs new file mode 100644 index 0000000..435dabe --- /dev/null +++ b/tests/common/temp_directory.rs @@ -0,0 +1,18 @@ +use std::fs::{create_dir, remove_dir_all}; +use std::path::PathBuf; + +pub struct TempDirectory { + pub path: PathBuf, +} +impl TempDirectory { + pub fn new(path: PathBuf) -> color_eyre::Result { + create_dir("tmp/repo").unwrap(); + Ok(Self { path }) + } +} + +impl Drop for TempDirectory { + fn drop(&mut self) { + remove_dir_all(self.path.clone()).unwrap(); + } +} diff --git a/tests/snapshots/worktree_list__renders_worktree_list.snap b/tests/snapshots/worktree_list__renders_worktree_list.snap new file mode 100644 index 0000000..ece43d0 --- /dev/null +++ b/tests/snapshots/worktree_list__renders_worktree_list.snap @@ -0,0 +1,24 @@ +--- +source: tests/worktree_list.rs +expression: terminal.backend() +--- +"β”ŒWorktree selector─────────────────────────────────────────────────────────────────────────────────┐" +"β”‚ Branch Path β”‚" +"β”‚ β”‚" +"β”‚ > worktree2 tmp/repo/worktree2/ β”‚" +"β”‚ worktree1 tmp/repo/worktree1/ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" diff --git a/tests/worktree_list.rs b/tests/worktree_list.rs new file mode 100644 index 0000000..5ebc323 --- /dev/null +++ b/tests/worktree_list.rs @@ -0,0 +1,22 @@ +mod common; +use crate::common::helpers::Fixture; +use crate::common::temp_directory::TempDirectory; +use git_treefrog::app::App; +use insta::assert_snapshot; +use ratatui::{Terminal, backend::TestBackend}; +use std::path::Path; + +#[tokio::test] +async fn renders_worktree_list() -> color_eyre::Result<()> { + let temp = TempDirectory::new(Path::new("tmp/repo").to_path_buf())?; + let mut fixture = Fixture::new(&temp.path); + fixture.create_worktree("worktree1"); + fixture.create_worktree("worktree2"); + let mut app = App::new(4.0, 60.0, Some(temp.path.clone()))?; + let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); + terminal.draw(|frame| { + app.draw(frame, frame.area()).unwrap(); + }); + assert_snapshot!(terminal.backend()); + Ok(()) +} diff --git a/tmp/.gitkeep b/tmp/.gitkeep new file mode 100644 index 0000000..e69de29