patch/list: Return to app and keep state

This commit is contained in:
Erik Kundt
2026-01-13 11:39:37 +01:00
parent dbb297075d
commit b95c5dfff3
2 changed files with 157 additions and 79 deletions
+92 -65
View File
@@ -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<Option<Selection<list::PatchOperation>>> {
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<PatchId>,
search: Option<String>,
}
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)?;
+65 -14
View File
@@ -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<Patch>, &AppState)> for OperationArguments {
type Error = anyhow::Error;
fn try_from(value: (&Vec<Patch>, &AppState)) -> Result<Self> {
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<PatchOperation>;
@@ -72,6 +104,8 @@ pub struct Context {
pub profile: Profile,
pub repository: Repository,
pub filter: PatchFilter,
pub patch_id: Option<PatchId>,
pub search: Option<String>,
}
pub struct Tui {
@@ -155,9 +189,16 @@ impl TryFrom<&Context> for App {
.collect::<Vec<_>>();
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 }),
});
}
}