diff --git a/crates/gitnow/src/commands.rs b/crates/gitnow/src/commands.rs index 9a5de09..a7b4082 100644 --- a/crates/gitnow/src/commands.rs +++ b/crates/gitnow/src/commands.rs @@ -1,3 +1,4 @@ +pub mod project; pub mod root; pub mod shell; pub mod update; diff --git a/crates/gitnow/src/commands/project.rs b/crates/gitnow/src/commands/project.rs new file mode 100644 index 0000000..4892090 --- /dev/null +++ b/crates/gitnow/src/commands/project.rs @@ -0,0 +1,358 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::{ + app::App, + cache::CacheApp, + custom_command::CustomCommandApp, + interactive::{InteractiveApp, Searchable}, + projects_list::ProjectsListApp, + shell::ShellApp, + template_command, +}; + +#[derive(clap::Parser)] +pub struct ProjectCommand { + #[command(subcommand)] + command: Option, + + /// Search string to filter existing projects + #[arg()] + search: Option, + + /// Skip spawning a shell in the project directory + #[arg(long = "no-shell", default_value = "false")] + no_shell: bool, +} + +#[derive(clap::Subcommand)] +enum ProjectSubcommand { + /// Create a new project with selected repositories + Create(ProjectCreateCommand), + /// Delete an existing project + Delete(ProjectDeleteCommand), +} + +#[derive(clap::Parser)] +pub struct ProjectCreateCommand { + /// Project name (will be used as directory name) + #[arg()] + name: Option, + + /// Skip cache when fetching repositories + #[arg(long = "no-cache", default_value = "false")] + no_cache: bool, + + /// Skip spawning a shell in the project directory + #[arg(long = "no-shell", default_value = "false")] + no_shell: bool, +} + +#[derive(clap::Parser)] +pub struct ProjectDeleteCommand { + /// Project name to delete + #[arg()] + name: Option, + + /// Skip confirmation prompt + #[arg(long = "force", short = 'f', default_value = "false")] + force: bool, +} + +fn get_projects_dir(app: &'static App) -> PathBuf { + if let Some(ref project_settings) = app.config.settings.project { + if let Some(ref dir) = project_settings.directory { + let path = PathBuf::from(dir); + if let Ok(stripped) = path.strip_prefix("~") { + let home = dirs::home_dir().unwrap_or_default(); + return home.join(stripped); + } + return path; + } + } + + let home = dirs::home_dir().unwrap_or_default(); + home.join(".gitnow").join("projects") +} + +#[derive(Clone)] +struct ProjectEntry { + name: String, + path: PathBuf, +} + +impl Searchable for ProjectEntry { + fn display_label(&self) -> String { + self.name.clone() + } +} + +fn list_existing_projects(projects_dir: &PathBuf) -> anyhow::Result> { + if !projects_dir.exists() { + return Ok(Vec::new()); + } + + let mut projects = Vec::new(); + for entry in std::fs::read_dir(projects_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + let name = entry.file_name().to_string_lossy().to_string(); + projects.push(ProjectEntry { + name, + path: entry.path(), + }); + } + } + projects.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(projects) +} + +impl ProjectCommand { + pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { + match self.command.take() { + Some(ProjectSubcommand::Create(mut create)) => create.execute(app).await, + Some(ProjectSubcommand::Delete(mut delete)) => delete.execute(app).await, + None => self.open_existing(app).await, + } + } + + async fn open_existing(&self, app: &'static App) -> anyhow::Result<()> { + let projects_dir = get_projects_dir(app); + let projects = list_existing_projects(&projects_dir)?; + + if projects.is_empty() { + anyhow::bail!( + "no projects found in {}. Use 'gitnow project create' to create one.", + projects_dir.display() + ); + } + + let project = match &self.search { + Some(needle) => { + let matched = projects + .iter() + .find(|p| p.name.contains(needle.as_str())) + .or_else(|| { + // fuzzy fallback + projects.iter().find(|p| { + p.name + .to_lowercase() + .contains(&needle.to_lowercase()) + }) + }) + .ok_or(anyhow::anyhow!( + "no project matching '{}' found", + needle + ))? + .clone(); + matched + } + None => app + .interactive() + .interactive_search_items(&projects)? + .ok_or(anyhow::anyhow!("no project selected"))?, + }; + + if !self.no_shell { + app.shell().spawn_shell_at(&project.path).await?; + } else { + println!("{}", project.path.display()); + } + + Ok(()) + } +} + +impl ProjectCreateCommand { + async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { + // Step 1: Get project name + let name = match self.name.take() { + Some(n) => n, + None => { + eprint!("Project name: "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + anyhow::bail!("project name cannot be empty"); + } + trimmed + } + }; + + // Sanitize project name for use as directory + let dir_name = name + .replace(' ', "-") + .replace('/', "-") + .to_lowercase(); + + let projects_dir = get_projects_dir(app); + let project_path = projects_dir.join(&dir_name); + + if project_path.exists() { + anyhow::bail!( + "project '{}' already exists at {}", + dir_name, + project_path.display() + ); + } + + // Step 2: Load repositories + let repositories = if !self.no_cache { + match app.cache().get().await? { + Some(repos) => repos, + None => { + eprintln!("fetching repositories..."); + let repositories = app.projects_list().get_projects().await?; + app.cache().update(&repositories).await?; + repositories + } + } + } else { + app.projects_list().get_projects().await? + }; + + // Step 3: Multi-select repositories + eprintln!("Select repositories (Tab to toggle, Enter to confirm):"); + let selected_repos = app + .interactive() + .interactive_multi_search(&repositories)?; + + if selected_repos.is_empty() { + anyhow::bail!("no repositories selected"); + } + + // Step 4: Create project directory + tokio::fs::create_dir_all(&project_path).await?; + + // Step 5: Clone each selected repository into the project directory + let clone_template = app + .config + .settings + .clone_command + .as_deref() + .unwrap_or(template_command::DEFAULT_CLONE_COMMAND); + + let concurrency_limit = Arc::new(tokio::sync::Semaphore::new(5)); + let mut handles = Vec::new(); + + for repo in &selected_repos { + let repo = repo.clone(); + let project_path = project_path.clone(); + let clone_template = clone_template.to_string(); + let concurrency = Arc::clone(&concurrency_limit); + let custom_command = app.custom_command(); + + let handle = tokio::spawn(async move { + let permit = concurrency.acquire().await?; + + let clone_path = project_path.join(&repo.repo_name); + + if clone_path.exists() { + eprintln!(" {} already exists, skipping", repo.repo_name); + drop(permit); + return Ok::<(), anyhow::Error>(()); + } + + eprintln!(" cloning {}...", repo.to_rel_path().display()); + + let path_str = clone_path.display().to_string(); + let context = HashMap::from([ + ("ssh_url", repo.ssh_url.as_str()), + ("path", path_str.as_str()), + ]); + + let output = + template_command::render_and_execute(&clone_template, context).await?; + + if !output.status.success() { + let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default(); + anyhow::bail!("failed to clone {}: {}", repo.repo_name, stderr); + } + + custom_command + .execute_post_clone_command(&clone_path) + .await?; + + drop(permit); + Ok(()) + }); + + handles.push(handle); + } + + let results = futures::future::join_all(handles).await; + for res in results { + match res { + Ok(Ok(())) => {} + Ok(Err(e)) => { + tracing::error!("clone error: {}", e); + eprintln!("error: {}", e); + } + Err(e) => { + tracing::error!("task error: {}", e); + eprintln!("error: {}", e); + } + } + } + + eprintln!( + "project '{}' created at {} with {} repositories", + dir_name, + project_path.display(), + selected_repos.len() + ); + + // Step 6: Enter shell or print path + if !self.no_shell { + app.shell().spawn_shell_at(&project_path).await?; + } else { + println!("{}", project_path.display()); + } + + Ok(()) + } +} + +impl ProjectDeleteCommand { + async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { + let projects_dir = get_projects_dir(app); + let projects = list_existing_projects(&projects_dir)?; + + if projects.is_empty() { + anyhow::bail!("no projects found in {}", projects_dir.display()); + } + + let project = match self.name.take() { + Some(name) => projects + .iter() + .find(|p| p.name == name) + .ok_or(anyhow::anyhow!("project '{}' not found", name))? + .clone(), + None => app + .interactive() + .interactive_search_items(&projects)? + .ok_or(anyhow::anyhow!("no project selected"))?, + }; + + if !self.force { + eprint!( + "Delete project '{}' at {}? [y/N] ", + project.name, + project.path.display() + ); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if !input.trim().eq_ignore_ascii_case("y") { + eprintln!("aborted"); + return Ok(()); + } + } + + tokio::fs::remove_dir_all(&project.path).await?; + eprintln!("deleted project '{}'", project.name); + + Ok(()) + } +} diff --git a/crates/gitnow/src/config.rs b/crates/gitnow/src/config.rs index 74d95e2..b0088b3 100644 --- a/crates/gitnow/src/config.rs +++ b/crates/gitnow/src/config.rs @@ -30,6 +30,17 @@ pub struct Settings { /// Worktree configuration. #[serde(default)] pub worktree: Option, + + /// Project scratch-pad configuration. + #[serde(default)] + pub project: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct ProjectSettings { + /// Directory where projects are stored. + /// Default: "~/.gitnow/projects" + pub directory: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] @@ -424,6 +435,7 @@ mod test { post_clone_command: None, clone_command: None, worktree: None, + project: None, } }, config @@ -453,6 +465,7 @@ mod test { post_clone_command: None, clone_command: None, worktree: None, + project: None, } }, config diff --git a/crates/gitnow/src/interactive.rs b/crates/gitnow/src/interactive.rs index b08e0d6..9c2d137 100644 --- a/crates/gitnow/src/interactive.rs +++ b/crates/gitnow/src/interactive.rs @@ -47,6 +47,16 @@ impl Interactive { App::new(self.app, items).run(terminal) } + + pub fn interactive_multi_search( + &mut self, + items: &[T], + ) -> anyhow::Result> { + let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?; + let terminal = Terminal::new(backend)?; + + multi_select::MultiSelectApp::new(self.app, items).run(terminal) + } } pub trait InteractiveApp { @@ -197,3 +207,180 @@ mod app { } } } + +pub mod multi_select { + use std::collections::HashSet; + + use crossterm::event::KeyModifiers; + use ratatui::{ + crossterm::event::{self, Event, KeyCode}, + layout::{Constraint, Layout}, + prelude::TermwizBackend, + style::{Style, Stylize}, + text::{Line, Span}, + widgets::{ListItem, ListState, Paragraph, StatefulWidget}, + Frame, Terminal, + }; + + use crate::fuzzy_matcher::FuzzyMatcherApp; + + use super::Searchable; + + pub struct MultiSelectApp<'a, T: Searchable> { + app: &'static crate::app::App, + items: &'a [T], + current_search: String, + matched_items: Vec, + selected_labels: HashSet, + list: ListState, + } + + impl<'a, T: Searchable> MultiSelectApp<'a, T> { + pub fn new(app: &'static crate::app::App, items: &'a [T]) -> Self { + Self { + app, + items, + current_search: String::default(), + matched_items: Vec::default(), + selected_labels: HashSet::new(), + list: ListState::default(), + } + } + + fn update_matched_items(&mut self) { + let labels: Vec = self.items.iter().map(|i| i.display_label()).collect(); + let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect(); + + let matched_keys = self + .app + .fuzzy_matcher() + .match_pattern(&self.current_search, &label_refs); + + self.matched_items = matched_keys + .into_iter() + .filter_map(|key| { + self.items + .iter() + .find(|i| i.display_label() == key) + .cloned() + }) + .collect(); + + if self.list.selected().is_none() { + self.list.select_first(); + } + } + + fn toggle_current(&mut self) { + if let Some(selected) = self.list.selected() { + if let Some(item) = self.matched_items.get(selected) { + let label = item.display_label(); + if !self.selected_labels.remove(&label) { + self.selected_labels.insert(label); + } + } + } + } + + pub fn run( + mut self, + mut terminal: Terminal, + ) -> anyhow::Result> { + self.update_matched_items(); + + loop { + terminal.draw(|frame| self.draw(frame))?; + + if let Event::Key(key) = event::read()? { + if let KeyCode::Char('c') = key.code + && key.modifiers.contains(KeyModifiers::CONTROL) + { + return Ok(Vec::new()); + } + + match key.code { + KeyCode::Tab => { + self.toggle_current(); + } + KeyCode::Char(letter) => { + self.current_search.push(letter); + self.update_matched_items(); + } + KeyCode::Backspace => { + if !self.current_search.is_empty() { + let _ = + self.current_search.remove(self.current_search.len() - 1); + self.update_matched_items(); + } + } + KeyCode::Esc => { + return Ok(Vec::new()); + } + KeyCode::Enter => { + terminal.resize(ratatui::layout::Rect::ZERO)?; + let selected: Vec = self + .items + .iter() + .filter(|i| self.selected_labels.contains(&i.display_label())) + .cloned() + .collect(); + return Ok(selected); + } + KeyCode::Up => self.list.select_next(), + KeyCode::Down => self.list.select_previous(), + _ => {} + } + } + } + } + + fn draw(&mut self, frame: &mut Frame) { + let vertical = Layout::vertical([ + Constraint::Percentage(100), + Constraint::Min(1), + Constraint::Min(1), + ]); + let [list_area, input_area, hint_area] = vertical.areas(frame.area()); + + let list_items: Vec = self + .matched_items + .iter() + .map(|i| { + let label = i.display_label(); + let marker = if self.selected_labels.contains(&label) { + "[x] " + } else { + "[ ] " + }; + ListItem::from(Line::from(vec![ + Span::from(marker).green(), + Span::from(label), + ])) + }) + .collect(); + + let list = ratatui::widgets::List::new(list_items) + .direction(ratatui::widgets::ListDirection::BottomToTop) + .scroll_padding(3) + .highlight_symbol("> ") + .highlight_spacing(ratatui::widgets::HighlightSpacing::Always) + .highlight_style(Style::default().bold().white()); + + StatefulWidget::render(list, list_area, frame.buffer_mut(), &mut self.list); + + let input = Paragraph::new(Line::from(vec![ + Span::from("> ").blue(), + Span::from(self.current_search.as_str()), + Span::from(" ").on_white(), + ])); + frame.render_widget(input, input_area); + + let count = self.selected_labels.len(); + let hint = Paragraph::new(Line::from(vec![ + Span::from(format!("{count} selected")).dim(), + Span::from(" | Tab: toggle, Enter: confirm").dim(), + ])); + frame.render_widget(hint, hint_area); + } + } +} diff --git a/crates/gitnow/src/main.rs b/crates/gitnow/src/main.rs index 579d415..75ae32e 100644 --- a/crates/gitnow/src/main.rs +++ b/crates/gitnow/src/main.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use anyhow::Context; use clap::{Parser, Subcommand}; use commands::{ - clone::CloneCommand, root::RootCommand, shell::Shell, update::Update, + clone::CloneCommand, project::ProjectCommand, root::RootCommand, shell::Shell, update::Update, worktree::WorktreeCommand, }; use config::Config; @@ -61,6 +61,8 @@ enum Commands { Update(Update), Clone(CloneCommand), Worktree(WorktreeCommand), + /// Manage scratch-pad projects with multiple repositories + Project(ProjectCommand), } const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml"; @@ -108,6 +110,9 @@ async fn main() -> anyhow::Result<()> { Commands::Worktree(mut wt) => { wt.execute(app).await?; } + Commands::Project(mut project) => { + project.execute(app).await?; + } }, None => { RootCommand::new(app)