From b95c5dfff3cbc4e7a7376ded206d07fb6a901a0e Mon Sep 17 00:00:00 2001 From: Erik Kundt Date: Mon, 12 Jan 2026 16:07:49 +0100 Subject: [PATCH] patch/list: Return to app and keep state --- bin/commands/patch.rs | 157 ++++++++++++++++++++++--------------- bin/commands/patch/list.rs | 79 +++++++++++++++---- 2 files changed, 157 insertions(+), 79 deletions(-) diff --git a/bin/commands/patch.rs b/bin/commands/patch.rs index 1619d4d4..d6d535bc 100644 --- a/bin/commands/patch.rs +++ b/bin/commands/patch.rs @@ -9,7 +9,6 @@ use anyhow::anyhow; use radicle::cob::ObjectId; use radicle::identity::RepoId; -use radicle::patch::cache::Patches; use radicle::patch::{Patch, Revision, RevisionId, Status}; use radicle::prelude::Did; use radicle::storage::git::Repository; @@ -297,7 +296,6 @@ impl Args for Options { #[tokio::main] pub async fn run(options: Options, ctx: impl radicle_cli::terminal::Context) -> anyhow::Result<()> { - use crate::tui_patch::list::PatchOperation; use radicle::storage::ReadStorage; let (_, rid) = radicle::rad::cwd() @@ -309,53 +307,10 @@ pub async fn run(options: Options, ctx: impl radicle_cli::terminal::Context) -> match options.op { Operation::List { opts } => { - let profile = ctx.profile()?; + log::info!("Starting patch selection interface in project {rid}.."); + let rid = options.repo.unwrap_or(rid); - - // Run TUI with patch list interface - let selection = interface::list(opts.clone(), profile.clone(), rid).await?; - - if opts.json { - let selection = selection - .map(|o| serde_json::to_string(&o).unwrap_or_default()) - .unwrap_or_default(); - - log::info!("About to print to `stderr`: {selection}"); - log::info!("Exiting patch list interface.."); - - eprint!("{selection}"); - } else if let Some(selection) = selection { - if let Some(operation) = selection.operation.clone() { - match operation { - PatchOperation::Show { id } => { - terminal::run_rad( - Some("patch"), - &["show".into(), id.to_string().into()], - )?; - } - PatchOperation::Diff { id } => { - let repo = profile.storage.repository(rid)?; - let cache = profile.patches(&repo)?; - let patch = cache - .get(&id)? - .ok_or_else(|| anyhow!("unknown patch '{id}'"))?; - let range = format!("{}..{}", patch.base(), patch.head()); - - terminal::run_git(Some("diff"), &[range.into()])?; - } - PatchOperation::Checkout { id } => { - terminal::run_rad( - Some("patch"), - &["checkout".into(), id.to_string().into()], - )?; - } - PatchOperation::_Review { id } => { - let opts = ReviewOptions::default(); - interface::review(opts, profile, rid, id).await?; - } - } - } - } + interface::list(opts.clone(), ctx.profile()?, rid).await?; } Operation::Review { ref opts } => { log::info!("Starting patch review interface in project {rid}.."); @@ -395,11 +350,8 @@ mod interface { use radicle::storage::ReadStorage; use radicle::Profile; - use radicle_cli::terminal; - - use radicle_tui::Selection; - use crate::cob; + use crate::terminal; use crate::tui_patch::list; use crate::tui_patch::review::builder::CommentBuilder; use crate::tui_patch::review::ReviewAction; @@ -409,23 +361,96 @@ mod interface { use super::review::builder::ReviewBuilder; use super::{ListOptions, ReviewOptions}; - pub async fn list( - opts: ListOptions, - profile: Profile, - rid: RepoId, - ) -> anyhow::Result>> { - let repository = profile.storage.repository(rid).unwrap(); + pub async fn list(opts: ListOptions, profile: Profile, rid: RepoId) -> anyhow::Result<()> { let me = profile.did(); - log::info!("Starting patch selection interface in project {rid}.."); + #[derive(Default)] + struct PreviousState { + patch_id: Option, + search: Option, + } - let context = list::Context { - profile, - repository, - filter: (me, opts.filter.clone()).into(), - }; + // Store issue and comment selection across app runs in order to + // preselect them when re-running the app. + let mut state = PreviousState::default(); - list::Tui::new(context).run().await + loop { + let context = list::Context { + profile: profile.clone(), + repository: profile.storage.repository(rid).unwrap(), + filter: (me, opts.filter.clone()).into(), + search: state.search.clone(), + patch_id: state.patch_id, + }; + + // Run TUI with patch list interface + let selection = list::Tui::new(context).run().await?; + + if opts.json { + let selection = selection + .map(|o| serde_json::to_string(&o).unwrap_or_default()) + .unwrap_or_default(); + + log::info!("About to print to `stderr`: {selection}"); + log::info!("Exiting patch list interface.."); + + eprint!("{selection}"); + + break; + } else if let Some(selection) = selection { + if let Some(operation) = selection.operation.clone() { + match operation { + list::PatchOperation::Show { args } => { + state = PreviousState { + patch_id: Some(args.id()), + search: Some(args.search()), + }; + terminal::run_rad( + Some("patch"), + &["show".into(), args.id().to_string().into()], + )?; + } + list::PatchOperation::Diff { args } => { + let repo = profile.clone().storage.repository(rid)?; + let cache = profile.patches(&repo)?; + let patch = cache + .get(&args.id())? + .ok_or_else(|| anyhow!("unknown patch '{}'", args.id()))?; + let range = format!("{}..{}", patch.base(), patch.head()); + + state = PreviousState { + patch_id: Some(args.id()), + search: Some(args.search()), + }; + + terminal::run_git(Some("diff"), &[range.into()])?; + } + list::PatchOperation::Checkout { args } => { + state = PreviousState { + patch_id: Some(args.id()), + search: Some(args.search()), + }; + terminal::run_rad( + Some("patch"), + &["checkout".into(), args.id().to_string().into()], + )?; + } + list::PatchOperation::_Review { args } => { + state = PreviousState { + patch_id: Some(args.id()), + search: Some(args.search()), + }; + let opts = ReviewOptions::default(); + review(opts, profile.clone(), rid, args.id()).await?; + } + } + } + } else { + break; + } + } + + Ok(()) } pub async fn review( @@ -434,6 +459,8 @@ mod interface { rid: RepoId, patch_id: PatchId, ) -> anyhow::Result<()> { + use radicle_cli::terminal; + let repo = profile.storage.repository(rid)?; let signer = terminal::signer(&profile)?; let cache = profile.patches(&repo)?; diff --git a/bin/commands/patch/list.rs b/bin/commands/patch/list.rs index ef5da133..b70dc7a3 100644 --- a/bin/commands/patch/list.rs +++ b/bin/commands/patch/list.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use std::sync::{Arc, Mutex}; -use anyhow::Result; +use anyhow::{anyhow, Result}; use serde::Serialize; @@ -56,14 +56,46 @@ const HELP: &str = r#"# Generic keybindings Examples: state=open bugfix state=merged author=(did:key:... or did:key:...)"#; +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct OperationArguments { + id: PatchId, + search: String, +} + +impl OperationArguments { + pub fn id(&self) -> PatchId { + self.id + } + + pub fn search(&self) -> String { + self.search.clone() + } +} + +impl TryFrom<(&Vec, &AppState)> for OperationArguments { + type Error = anyhow::Error; + + fn try_from(value: (&Vec, &AppState)) -> Result { + let (patches, state) = value; + let selected = state.patches.selected(); + let id = selected + .and_then(|s| patches.get(s)) + .ok_or(anyhow!("No patch selected"))? + .id; + let search = state.search.read().text; + + Ok(Self { id, search }) + } +} + /// The selected patch operation returned by the operation /// selection widget. #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub enum PatchOperation { - Checkout { id: PatchId }, - Diff { id: PatchId }, - Show { id: PatchId }, - _Review { id: PatchId }, + Checkout { args: OperationArguments }, + Diff { args: OperationArguments }, + Show { args: OperationArguments }, + _Review { args: OperationArguments }, } type Selection = tui::Selection; @@ -72,6 +104,8 @@ pub struct Context { pub profile: Profile, pub repository: Repository, pub filter: PatchFilter, + pub patch_id: Option, + pub search: Option, } pub struct Tui { @@ -155,9 +189,16 @@ impl TryFrom<&Context> for App { .collect::>(); patches.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); - let search = { - let raw = context.filter.to_string(); - raw.trim().to_string() + let search = context.search.as_ref().map(|s| s.trim().to_string()); + let (search, filter) = match search { + Some(search) => ( + search.clone(), + PatchFilter::from_str(search.trim()).unwrap_or(PatchFilter::Invalid), + ), + None => { + let filter = context.filter.clone(); + (filter.to_string().trim().to_string(), filter) + } }; Ok(App { @@ -165,14 +206,24 @@ impl TryFrom<&Context> for App { state: AppState { page: Page::Main, main_group: ContainerState::new(3, Some(0)), - patches: TableState::new(Some(0)), + patches: TableState::new(Some( + context + .patch_id + .and_then(|id| { + patches + .iter() + .filter(|item| filter.matches(item)) + .position(|item| item.id == id) + }) + .unwrap_or(0), + )), search: BufferedValue::new(TextEditState { text: search.clone(), cursor: search.len(), }), show_search: false, help: TextViewState::new(Position::default()), - filter: context.filter.clone(), + filter, }, }) } @@ -361,20 +412,20 @@ impl App { ui.send_message(Message::ShowSearch); } - if let Some(patch) = selected.and_then(|s| patches.get(s)) { + if let Ok(args) = OperationArguments::try_from((&patches, &self.state)) { if ui.has_input(|key| key == Key::Enter) { ui.send_message(Message::Exit { - operation: Some(PatchOperation::Show { id: patch.id }), + operation: Some(PatchOperation::Show { args: args.clone() }), }); } if ui.has_input(|key| key == Key::Char('d')) { ui.send_message(Message::Exit { - operation: Some(PatchOperation::Diff { id: patch.id }), + operation: Some(PatchOperation::Diff { args: args.clone() }), }); } if ui.has_input(|key| key == Key::Char('c')) { ui.send_message(Message::Exit { - operation: Some(PatchOperation::Checkout { id: patch.id }), + operation: Some(PatchOperation::Checkout { args }), }); } }