feat: add project command for scratch-pad multi-repo workspaces
Some checks failed
continuous-integration/drone/push Build encountered an error
Some checks failed
continuous-integration/drone/push Build encountered an error
Adds `gitnow project` with create, delete, and open subcommands. Create allows multi-selecting repositories to clone into a shared project directory. Includes multi-select TUI support in interactive module.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod project;
|
||||||
pub mod root;
|
pub mod root;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|||||||
358
crates/gitnow/src/commands/project.rs
Normal file
358
crates/gitnow/src/commands/project.rs
Normal file
@@ -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<ProjectSubcommand>,
|
||||||
|
|
||||||
|
/// Search string to filter existing projects
|
||||||
|
#[arg()]
|
||||||
|
search: Option<String>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
|
||||||
|
/// 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<Vec<ProjectEntry>> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,17 @@ pub struct Settings {
|
|||||||
/// Worktree configuration.
|
/// Worktree configuration.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub worktree: Option<WorktreeSettings>,
|
pub worktree: Option<WorktreeSettings>,
|
||||||
|
|
||||||
|
/// Project scratch-pad configuration.
|
||||||
|
#[serde(default)]
|
||||||
|
pub project: Option<ProjectSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
|
pub struct ProjectSettings {
|
||||||
|
/// Directory where projects are stored.
|
||||||
|
/// Default: "~/.gitnow/projects"
|
||||||
|
pub directory: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
@@ -424,6 +435,7 @@ mod test {
|
|||||||
post_clone_command: None,
|
post_clone_command: None,
|
||||||
clone_command: None,
|
clone_command: None,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
project: None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
config
|
config
|
||||||
@@ -453,6 +465,7 @@ mod test {
|
|||||||
post_clone_command: None,
|
post_clone_command: None,
|
||||||
clone_command: None,
|
clone_command: None,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
project: None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
config
|
config
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ impl Interactive {
|
|||||||
|
|
||||||
App::new(self.app, items).run(terminal)
|
App::new(self.app, items).run(terminal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn interactive_multi_search<T: Searchable>(
|
||||||
|
&mut self,
|
||||||
|
items: &[T],
|
||||||
|
) -> anyhow::Result<Vec<T>> {
|
||||||
|
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 {
|
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<T>,
|
||||||
|
selected_labels: HashSet<String>,
|
||||||
|
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<String> = 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<TermwizBackend>,
|
||||||
|
) -> anyhow::Result<Vec<T>> {
|
||||||
|
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<T> = 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<ListItem> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use commands::{
|
use commands::{
|
||||||
clone::CloneCommand, root::RootCommand, shell::Shell, update::Update,
|
clone::CloneCommand, project::ProjectCommand, root::RootCommand, shell::Shell, update::Update,
|
||||||
worktree::WorktreeCommand,
|
worktree::WorktreeCommand,
|
||||||
};
|
};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
@@ -61,6 +61,8 @@ enum Commands {
|
|||||||
Update(Update),
|
Update(Update),
|
||||||
Clone(CloneCommand),
|
Clone(CloneCommand),
|
||||||
Worktree(WorktreeCommand),
|
Worktree(WorktreeCommand),
|
||||||
|
/// Manage scratch-pad projects with multiple repositories
|
||||||
|
Project(ProjectCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
|
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
|
||||||
@@ -108,6 +110,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Commands::Worktree(mut wt) => {
|
Commands::Worktree(mut wt) => {
|
||||||
wt.execute(app).await?;
|
wt.execute(app).await?;
|
||||||
}
|
}
|
||||||
|
Commands::Project(mut project) => {
|
||||||
|
project.execute(app).await?;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
RootCommand::new(app)
|
RootCommand::new(app)
|
||||||
|
|||||||
Reference in New Issue
Block a user