mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-10-27 07:11:04 +08:00
975 lines
30 KiB
Rust
975 lines
30 KiB
Rust
#[path = "review/builder.rs"]
|
|
pub mod builder;
|
|
|
|
use std::fmt::Debug;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use anyhow::Result;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use ratatui::layout::{Alignment, Constraint, Layout, Position};
|
|
use ratatui::style::{Style, Stylize};
|
|
use ratatui::text::Text;
|
|
use ratatui::{Frame, Viewport};
|
|
|
|
use radicle::identity::RepoId;
|
|
use radicle::patch::{PatchId, Review, Revision, RevisionId};
|
|
use radicle::storage::ReadStorage;
|
|
use radicle::Storage;
|
|
|
|
use radicle_tui as tui;
|
|
|
|
use tui::event::Key;
|
|
use tui::store;
|
|
use tui::task::EmptyProcessors;
|
|
use tui::ui::layout::Spacing;
|
|
use tui::ui::span;
|
|
use tui::ui::theme::Theme;
|
|
use tui::ui::widget::{Borders, Column, ContainerState, TableState, TextViewState, Window};
|
|
use tui::ui::{Context, Show, Ui};
|
|
use tui::{Channel, Exit};
|
|
|
|
use crate::git::HunkState;
|
|
use crate::settings;
|
|
use crate::state::{self, FileIdentifier, FileStore, ReadState, WriteState};
|
|
use crate::ui::format;
|
|
use crate::ui::items::patch::{HunkItem, StatefulHunkItem};
|
|
use crate::ui::layout;
|
|
|
|
use self::builder::Hunks;
|
|
|
|
/// The actions that a user can carry out on a review item.
|
|
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
|
pub enum ReviewAction {
|
|
Comment,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct Args(String);
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Response {
|
|
pub state: AppState,
|
|
pub action: Option<ReviewAction>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
|
pub enum ReviewMode {
|
|
Show,
|
|
Edit { resume: bool },
|
|
}
|
|
|
|
pub struct Tui {
|
|
pub mode: ReviewMode,
|
|
pub storage: Storage,
|
|
pub rid: RepoId,
|
|
pub patch: PatchId,
|
|
pub title: String,
|
|
pub revision: Revision,
|
|
pub review: Review,
|
|
pub hunks: Hunks,
|
|
}
|
|
|
|
impl Tui {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
mode: ReviewMode,
|
|
storage: Storage,
|
|
rid: RepoId,
|
|
patch: PatchId,
|
|
title: String,
|
|
revision: Revision,
|
|
review: Review,
|
|
hunks: Hunks,
|
|
) -> Self {
|
|
Self {
|
|
mode,
|
|
storage,
|
|
rid,
|
|
patch,
|
|
title,
|
|
revision,
|
|
review,
|
|
hunks,
|
|
}
|
|
}
|
|
|
|
pub async fn run(self) -> Result<Option<Response>> {
|
|
let viewport = Viewport::Fullscreen;
|
|
let channel = Channel::default();
|
|
|
|
let identifier = FileIdentifier::new("patch", "review", &self.rid, Some(&self.patch));
|
|
let store = FileStore::new(identifier)?;
|
|
|
|
let default = AppState::new(
|
|
ReviewMode::Show,
|
|
self.rid,
|
|
self.patch,
|
|
self.title,
|
|
self.revision.id(),
|
|
&self.hunks,
|
|
);
|
|
|
|
let state = store
|
|
.read()
|
|
.map(|bytes| state::from_json::<AppState>(&bytes).ok())?
|
|
.unwrap_or(default);
|
|
|
|
let app = App::new(self.storage, self.review, self.hunks, state, self.mode)?;
|
|
let response = tui::im(app, viewport, channel, EmptyProcessors::new()).await?;
|
|
|
|
if let Some(response) = response.as_ref() {
|
|
store.write(&state::to_json(&response.state)?)?;
|
|
}
|
|
|
|
Ok(response)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum Message {
|
|
ShowMain,
|
|
PanesChanged { state: ContainerState },
|
|
HunkChanged { state: TableState },
|
|
HunkViewChanged { state: DiffViewState },
|
|
ShowHelp,
|
|
HelpChanged { state: TextViewState },
|
|
Comment,
|
|
Accept,
|
|
Reject,
|
|
Quit,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
|
pub enum AppPage {
|
|
Main,
|
|
Help,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
|
|
pub struct DiffViewState {
|
|
cursor: Position,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct AppState {
|
|
/// Review mode: edit or show.
|
|
mode: ReviewMode,
|
|
/// The repository to operate on.
|
|
rid: RepoId,
|
|
/// Patch this review belongs to.
|
|
patch: PatchId,
|
|
/// Patch title.
|
|
title: String,
|
|
/// Revision this review belongs to.
|
|
revision: RevisionId,
|
|
/// Current app page.
|
|
page: AppPage,
|
|
/// State of panes widget on the main page.
|
|
panes: ContainerState,
|
|
/// The hunks' table widget state.
|
|
hunks: (TableState, Vec<HunkState>),
|
|
/// Diff view states (cursor position is stored per hunk)
|
|
views: Vec<DiffViewState>,
|
|
/// State of text view widget on the help page.
|
|
help: TextViewState,
|
|
/// The active theme
|
|
theme: Theme,
|
|
}
|
|
|
|
impl AppState {
|
|
pub fn new(
|
|
mode: ReviewMode,
|
|
rid: RepoId,
|
|
patch: PatchId,
|
|
title: String,
|
|
revision: RevisionId,
|
|
hunks: &Hunks,
|
|
) -> Self {
|
|
let settings = settings::Settings::default();
|
|
let theme = settings::configure_theme(&settings);
|
|
|
|
Self {
|
|
mode,
|
|
rid,
|
|
patch,
|
|
title,
|
|
revision,
|
|
page: AppPage::Main,
|
|
panes: ContainerState::new(2, Some(0)),
|
|
hunks: (
|
|
TableState::new(Some(0)),
|
|
vec![HunkState::Rejected; hunks.len()],
|
|
),
|
|
views: vec![DiffViewState::default(); hunks.len()],
|
|
help: TextViewState::new(Position::default()),
|
|
theme,
|
|
}
|
|
}
|
|
|
|
pub fn view_state(&self, index: usize) -> Option<&DiffViewState> {
|
|
self.views.get(index)
|
|
}
|
|
|
|
pub fn update_view_state(&mut self, index: usize, state: DiffViewState) {
|
|
if let Some(view) = self.views.get_mut(index) {
|
|
*view = state;
|
|
}
|
|
}
|
|
|
|
pub fn update_hunks(&mut self, hunks: TableState) {
|
|
self.hunks.0 = hunks;
|
|
}
|
|
|
|
pub fn selected_hunk(&self) -> Option<usize> {
|
|
self.hunks.0.selected()
|
|
}
|
|
|
|
pub fn accept_hunk(&mut self, index: usize) {
|
|
if let Some(state) = self.hunks.1.get_mut(index) {
|
|
*state = HunkState::Accepted;
|
|
}
|
|
}
|
|
|
|
pub fn reject_hunk(&mut self, index: usize) {
|
|
if let Some(state) = self.hunks.1.get_mut(index) {
|
|
*state = HunkState::Rejected;
|
|
}
|
|
}
|
|
|
|
pub fn hunk_states(&self) -> &Vec<HunkState> {
|
|
&self.hunks.1
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct App<'a> {
|
|
/// All hunks.
|
|
hunks: Arc<Mutex<Vec<StatefulHunkItem<'a>>>>,
|
|
/// The app state.
|
|
state: Arc<Mutex<AppState>>,
|
|
}
|
|
|
|
impl App<'_> {
|
|
pub fn new(
|
|
storage: Storage,
|
|
review: Review,
|
|
hunks: Hunks,
|
|
state: AppState,
|
|
mode: ReviewMode,
|
|
) -> Result<Self, anyhow::Error> {
|
|
let repo = storage.repository(state.rid)?;
|
|
// TODO: Check, if it's necessary to protect the app state.
|
|
// let mode = match state.mode {
|
|
// ReviewMode::Edit { resume: _ } if mode == ReviewMode::Show => {
|
|
// // TODO: Ask user what to do.
|
|
// anyhow::bail!("Review not finalized, yet. Current state would be lost.")
|
|
// }
|
|
// _ => mode,
|
|
// };
|
|
|
|
let hunks = hunks
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, item)| {
|
|
StatefulHunkItem::new(
|
|
HunkItem::from((&repo, &review, item)),
|
|
state.hunk_states().get(idx).cloned().unwrap_or_default(),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
Ok(Self {
|
|
hunks: Arc::new(Mutex::new(hunks)),
|
|
state: Arc::new(Mutex::new(AppState { mode, ..state })),
|
|
})
|
|
}
|
|
|
|
pub fn accept_selected_hunk(&mut self) -> Result<()> {
|
|
if let Some(selected) = self.selected_hunk() {
|
|
let mut state = self.state.lock().unwrap();
|
|
state.accept_hunk(selected);
|
|
}
|
|
self.synchronize_hunk_state();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn reject_selected_hunk(&mut self) -> Result<()> {
|
|
if let Some(selected) = self.selected_hunk() {
|
|
let mut state = self.state.lock().unwrap();
|
|
state.reject_hunk(selected);
|
|
}
|
|
self.synchronize_hunk_state();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn selected_hunk(&self) -> Option<usize> {
|
|
let state = self.state.lock().unwrap();
|
|
state.selected_hunk()
|
|
}
|
|
|
|
fn synchronize_hunk_state(&mut self) {
|
|
let state = self.state.lock().unwrap();
|
|
let mut hunks = self.hunks.lock().unwrap();
|
|
|
|
if let Some(selected) = state.selected_hunk() {
|
|
if let Some(item) = hunks.get_mut(selected) {
|
|
if let Some(state) = state.hunk_states().get(selected) {
|
|
item.update_state(state);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl App<'_> {
|
|
fn show_hunk_list(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
|
|
let hunks = self.hunks.lock().unwrap();
|
|
let state = self.state.lock().unwrap();
|
|
|
|
let state_column_width = match state.mode {
|
|
ReviewMode::Show => 0,
|
|
ReviewMode::Edit { resume: _ } => 2,
|
|
};
|
|
let header = [Column::new(" Hunks ", Constraint::Fill(1))].to_vec();
|
|
let columns = [
|
|
Column::new("", Constraint::Length(state_column_width)),
|
|
Column::new("", Constraint::Fill(1)),
|
|
Column::new("", Constraint::Length(15)),
|
|
]
|
|
.to_vec();
|
|
|
|
let mut selected = state.selected_hunk();
|
|
|
|
ui.layout(
|
|
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
|
|
Some(1),
|
|
|ui| {
|
|
ui.column_bar(
|
|
frame,
|
|
header.to_vec(),
|
|
Spacing::default(),
|
|
Some(Borders::Top),
|
|
);
|
|
|
|
let table = ui.table(
|
|
frame,
|
|
&mut selected,
|
|
&hunks,
|
|
columns,
|
|
None,
|
|
Spacing::from(1),
|
|
Some(Borders::BottomSides),
|
|
);
|
|
if table.changed {
|
|
ui.send_message(Message::HunkChanged {
|
|
state: TableState::new(selected),
|
|
})
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
fn show_hunk(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
|
|
let hunks = self.hunks.lock().unwrap();
|
|
let state = self.state.lock().unwrap();
|
|
|
|
let selected = state.selected_hunk();
|
|
let hunk = selected.and_then(|selected| hunks.get(selected));
|
|
|
|
if let Some(hunk) = hunk {
|
|
let mut cursor = selected
|
|
.and_then(|selected| state.view_state(selected))
|
|
.map(|state| state.cursor)
|
|
.unwrap_or_default();
|
|
|
|
ui.container(layout::container(), &mut Some(1), |ui| {
|
|
ui.column_bar(
|
|
frame,
|
|
hunk.inner().header(),
|
|
Spacing::from(0),
|
|
Some(Borders::Top),
|
|
);
|
|
|
|
if let Some(text) = hunk.inner().hunk_text() {
|
|
let diff = ui.text_view(frame, text, &mut cursor, Some(Borders::BottomSides));
|
|
if diff.changed {
|
|
ui.send_message(Message::HunkViewChanged {
|
|
state: DiffViewState { cursor },
|
|
})
|
|
}
|
|
} else {
|
|
let empty_text = hunk
|
|
.inner()
|
|
.hunk_text()
|
|
.unwrap_or(Text::raw("Nothing to show.").dark_gray());
|
|
ui.centered_text_view(frame, empty_text, Some(Borders::BottomSides));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn show_context_bar(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
|
|
let hunks = &self.hunks.lock().unwrap();
|
|
let state = self.state.lock().unwrap();
|
|
|
|
let id = format!(" {} ", format::cob(&state.patch));
|
|
let title = &state.title;
|
|
|
|
let hunks_total = hunks.len();
|
|
let hunks_accepted = state
|
|
.hunks
|
|
.1
|
|
.iter()
|
|
.filter(|state| **state == HunkState::Accepted)
|
|
.collect::<Vec<_>>()
|
|
.len();
|
|
|
|
let (mode, context, context_style) = match state.mode {
|
|
ReviewMode::Show => (
|
|
" Show ",
|
|
"".into(),
|
|
Style::default().cyan().dim().reversed(),
|
|
),
|
|
ReviewMode::Edit { resume: _ } => (
|
|
" Edit ",
|
|
format!(" Accepted {hunks_accepted}/{hunks_total} "),
|
|
Style::default().light_red().dim().reversed(),
|
|
),
|
|
};
|
|
|
|
ui.column_bar(
|
|
frame,
|
|
[
|
|
Column::new(
|
|
span::default(mode).style(context_style),
|
|
Constraint::Length(mode.chars().count() as u16),
|
|
),
|
|
Column::new(
|
|
span::default(&id)
|
|
.style(ui.theme().bar_on_black_style)
|
|
.magenta(),
|
|
Constraint::Length(9),
|
|
),
|
|
Column::new(
|
|
span::default(title)
|
|
.style(ui.theme().bar_on_black_style)
|
|
.magenta()
|
|
.dim(),
|
|
Constraint::Length(title.chars().count() as u16),
|
|
),
|
|
Column::new(
|
|
span::default(" ")
|
|
.into_left_aligned_line()
|
|
.style(ui.theme().bar_on_black_style),
|
|
Constraint::Fill(1),
|
|
),
|
|
Column::new(
|
|
span::default(&context)
|
|
.into_right_aligned_line()
|
|
.style(context_style),
|
|
Constraint::Length(context.chars().count() as u16),
|
|
),
|
|
]
|
|
.to_vec(),
|
|
Spacing::from(0),
|
|
Some(Borders::None),
|
|
);
|
|
}
|
|
|
|
fn show_footer(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
|
|
let state = self.state.lock().unwrap();
|
|
match state.mode {
|
|
ReviewMode::Edit { resume: _ } => {
|
|
ui.shortcuts(
|
|
frame,
|
|
&[
|
|
("c", "comment"),
|
|
("a", "accept"),
|
|
("r", "reject"),
|
|
("?", "help"),
|
|
("q", "quit"),
|
|
],
|
|
'∙',
|
|
Alignment::Left,
|
|
);
|
|
|
|
if ui.has_input(|key| key == Key::Char('?')) {
|
|
ui.send_message(Message::ShowHelp);
|
|
}
|
|
if ui.has_input(|key| key == Key::Char('c')) {
|
|
ui.send_message(Message::Comment);
|
|
}
|
|
if ui.has_input(|key| key == Key::Char('a')) {
|
|
ui.send_message(Message::Accept);
|
|
}
|
|
if ui.has_input(|key| key == Key::Char('r')) {
|
|
ui.send_message(Message::Reject);
|
|
}
|
|
}
|
|
ReviewMode::Show => {
|
|
ui.shortcuts(frame, &[("?", "help"), ("q", "quit")], '∙', Alignment::Left);
|
|
|
|
if ui.has_input(|key| key == Key::Char('?')) {
|
|
ui.send_message(Message::ShowHelp);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Show<Message> for App<'_> {
|
|
fn show(&self, ctx: &Context<Message>, frame: &mut Frame) -> Result<(), anyhow::Error> {
|
|
let (page, theme) = {
|
|
let state = self.state.lock().unwrap();
|
|
(state.page.clone(), state.theme.clone())
|
|
};
|
|
|
|
Window::default().show(ctx, theme, |ui| {
|
|
match page {
|
|
AppPage::Main => {
|
|
let (mut focus, count) = {
|
|
let state = self.state.lock().unwrap();
|
|
(state.panes.focus(), state.panes.len())
|
|
};
|
|
|
|
ui.layout(layout::page(), Some(0), |ui| {
|
|
let group = ui.container(layout::list_item(), &mut focus, |ui| {
|
|
self.show_hunk_list(ui, frame);
|
|
self.show_hunk(ui, frame);
|
|
});
|
|
if group.response.changed {
|
|
ui.send_message(Message::PanesChanged {
|
|
state: ContainerState::new(count, focus),
|
|
});
|
|
}
|
|
|
|
self.show_context_bar(ui, frame);
|
|
self.show_footer(ui, frame);
|
|
});
|
|
}
|
|
AppPage::Help => {
|
|
ui.layout(layout::page(), Some(0), |ui| {
|
|
ui.container(layout::container(), &mut Some(1), |ui| {
|
|
let mut cursor = {
|
|
let state = self.state.lock().unwrap();
|
|
state.help.cursor()
|
|
};
|
|
let header = [Column::new(" Help ", Constraint::Fill(1))].to_vec();
|
|
|
|
ui.column_bar(frame, header, Spacing::from(0), Some(Borders::Top));
|
|
let help = ui.text_view(
|
|
frame,
|
|
help_text().to_string(),
|
|
&mut cursor,
|
|
Some(Borders::BottomSides),
|
|
);
|
|
if help.changed {
|
|
ui.send_message(Message::HelpChanged {
|
|
state: TextViewState::new(cursor),
|
|
})
|
|
}
|
|
});
|
|
|
|
self.show_context_bar(ui, frame);
|
|
|
|
ui.shortcuts(
|
|
frame,
|
|
&[("?", "close"), ("q", "quit")],
|
|
'∙',
|
|
Alignment::Left,
|
|
);
|
|
});
|
|
|
|
if ui.has_input(|key| key == Key::Char('?')) {
|
|
ui.send_message(Message::ShowMain);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ui.has_input(|key| key == Key::Char('q')) {
|
|
ui.send_message(Message::Quit);
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl store::Update<Message> for App<'_> {
|
|
type Return = Response;
|
|
|
|
fn update(&mut self, message: Message) -> Option<Exit<Self::Return>> {
|
|
match message {
|
|
Message::ShowMain => {
|
|
let mut state = self.state.lock().unwrap();
|
|
state.page = AppPage::Main;
|
|
None
|
|
}
|
|
Message::ShowHelp => {
|
|
let mut state = self.state.lock().unwrap();
|
|
state.page = AppPage::Help;
|
|
None
|
|
}
|
|
Message::PanesChanged { state } => {
|
|
let mut app_state = self.state.lock().unwrap();
|
|
app_state.panes = state;
|
|
None
|
|
}
|
|
Message::HunkChanged { state } => {
|
|
let mut app_state = self.state.lock().unwrap();
|
|
app_state.update_hunks(state);
|
|
None
|
|
}
|
|
Message::HunkViewChanged { state } => {
|
|
let mut app_state = self.state.lock().unwrap();
|
|
if let Some(selected) = app_state.selected_hunk() {
|
|
app_state.update_view_state(selected, state);
|
|
}
|
|
None
|
|
}
|
|
Message::HelpChanged { state } => {
|
|
let mut app_state = self.state.lock().unwrap();
|
|
app_state.help = state;
|
|
None
|
|
}
|
|
Message::Comment => {
|
|
let state = self.state.lock().unwrap();
|
|
Some(Exit {
|
|
value: Some(Response {
|
|
action: Some(ReviewAction::Comment),
|
|
state: state.clone(),
|
|
}),
|
|
})
|
|
}
|
|
Message::Accept => {
|
|
match self.accept_selected_hunk() {
|
|
Ok(()) => log::debug!("Accepted selected hunk ({:?}).", self.selected_hunk()),
|
|
Err(err) => log::error!("An error occured while accepting hunk: {err}"),
|
|
}
|
|
None
|
|
}
|
|
Message::Reject => {
|
|
match self.reject_selected_hunk() {
|
|
Ok(()) => log::debug!("Rejected selected hunk ({:?}).", self.selected_hunk()),
|
|
Err(err) => log::error!("An error occured while rejecting hunk: {err}"),
|
|
}
|
|
None
|
|
}
|
|
Message::Quit => {
|
|
let state = self.state.lock().unwrap();
|
|
Some(Exit {
|
|
value: Some(Response {
|
|
action: None,
|
|
state: state.clone(),
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn help_text() -> String {
|
|
r#"# About
|
|
|
|
A terminal interface for reviewing patch revisions.
|
|
|
|
Starts a new or resumes an existing review for a given revision (default: latest). When the
|
|
review is done, it needs to be finalized via `rad patch review --accept | --reject <id>`.
|
|
|
|
# Keybindings
|
|
|
|
`←,h` move cursor to the left
|
|
`↑,k` move cursor one line up
|
|
`↓,j` move cursor one line down
|
|
`→,l` move cursor to the right
|
|
`PageUp` move cursor one page up
|
|
`PageDown` move cursor one page down
|
|
`Home` move cursor to the first line
|
|
`End` move cursor to the last line
|
|
|
|
`Tab` Focus next pane
|
|
`BackTab` Focus previous pane
|
|
|
|
`?` toogle help
|
|
`q` quit / cancel
|
|
|
|
## Specific keybindings
|
|
|
|
`c` comment on hunk
|
|
`a` accept hunk
|
|
`d` discard accepted hunks (reject all)"#
|
|
.into()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use anyhow::*;
|
|
|
|
use radicle::patch::Cache;
|
|
|
|
use store::Update;
|
|
|
|
use super::*;
|
|
use crate::test;
|
|
|
|
impl App<'_> {
|
|
pub fn hunks(&self) -> Vec<StatefulHunkItem<'_>> {
|
|
self.hunks.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
mod fixtures {
|
|
use anyhow::*;
|
|
|
|
use radicle::cob::cache::NoCache;
|
|
use radicle::patch::{Cache, PatchMut, Review, ReviewId, Revision, Verdict};
|
|
use radicle::storage::git::cob::DraftStore;
|
|
use radicle::storage::git::Repository;
|
|
|
|
use crate::cob;
|
|
use crate::test::setup::NodeWithRepo;
|
|
|
|
use super::builder::ReviewBuilder;
|
|
use super::{App, AppState, ReviewMode};
|
|
|
|
pub fn app<'a>(
|
|
node: &NodeWithRepo,
|
|
patch: PatchMut<Repository, NoCache>,
|
|
) -> Result<App<'a>> {
|
|
let draft_store = DraftStore::new(&node.repo.repo, *node.signer.public_key());
|
|
let mut drafts = Cache::no_cache(&draft_store)?;
|
|
let mut draft = drafts.get_mut(patch.id())?;
|
|
|
|
let (_, revision) = patch.latest();
|
|
let (_, review) = draft_review(node, &mut draft, revision)?;
|
|
|
|
let hunks = ReviewBuilder::new(&node.repo).hunks(revision)?;
|
|
|
|
let mode = ReviewMode::Edit { resume: false };
|
|
let state = AppState::new(
|
|
mode.clone(),
|
|
node.repo.id,
|
|
*patch.id(),
|
|
patch.title().to_string(),
|
|
revision.id(),
|
|
&hunks,
|
|
);
|
|
|
|
App::new(node.storage.clone(), review.clone(), hunks, state, mode)
|
|
}
|
|
|
|
pub fn draft_review<'a>(
|
|
node: &NodeWithRepo,
|
|
draft: &'a mut PatchMut<DraftStore<Repository>, NoCache>,
|
|
revision: &Revision,
|
|
) -> Result<(ReviewId, &'a Review)> {
|
|
let id = draft.review(
|
|
revision.id(),
|
|
Some(Verdict::Reject),
|
|
None,
|
|
vec![],
|
|
&node.node.signer,
|
|
)?;
|
|
|
|
let (_, review) = cob::find_review(draft, revision, &node.node.signer)
|
|
.ok_or_else(|| anyhow!("Could not find review."))?;
|
|
|
|
Ok((id, review))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn app_with_single_hunk_can_be_constructed() -> Result<()> {
|
|
let alice = test::fixtures::node_with_repo();
|
|
let branch = test::fixtures::branch_with_main_emptied(&alice);
|
|
|
|
let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
|
|
let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
|
|
|
|
let app = fixtures::app(&alice, patch)?;
|
|
|
|
assert_eq!(app.hunks().len(), 1);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn app_with_single_file_multiple_hunks_can_be_constructed() -> Result<()> {
|
|
let alice = test::fixtures::node_with_repo();
|
|
let branch = test::fixtures::branch_with_eof_removed(&alice);
|
|
|
|
let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
|
|
let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
|
|
|
|
let app = fixtures::app(&alice, patch)?;
|
|
|
|
assert_eq!(app.hunks().len(), 2);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn first_hunk_is_selected_by_default() -> Result<()> {
|
|
let alice = test::fixtures::node_with_repo();
|
|
let branch = test::fixtures::branch_with_main_emptied(&alice);
|
|
|
|
let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
|
|
let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
|
|
|
|
let app = fixtures::app(&alice, patch)?;
|
|
|
|
assert_eq!(app.selected_hunk(), Some(0));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn hunks_are_rejected_by_default() -> Result<()> {
|
|
let alice = test::fixtures::node_with_repo();
|
|
let branch = test::fixtures::branch_with_main_deleted_and_file_added(&alice);
|
|
|
|
let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
|
|
let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
|
|
|
|
let app = fixtures::app(&alice, patch)?;
|
|
let state = app.state.lock().unwrap();
|
|
let states = &state.hunk_states();
|
|
|
|
assert_eq!(**states, [HunkState::Rejected, HunkState::Rejected]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn hunk_can_be_selected() -> Result<()> {
|
|
let alice = test::fixtures::node_with_repo();
|
|
let branch = test::fixtures::branch_with_eof_removed(&alice);
|
|
|
|
let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
|
|
let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
|
|
|
|
let mut app = fixtures::app(&alice, patch)?;
|
|
app.update(Message::HunkChanged {
|
|
state: TableState::new(Some(1)),
|
|
});
|
|
|
|
assert_eq!(app.selected_hunk(), Some(1));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn single_file_single_hunk_can_be_accepted() -> Result<()> {
|
|
let alice = test::fixtures::node_with_repo();
|
|
let branch = test::fixtures::branch_with_main_emptied(&alice);
|
|
|
|
let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
|
|
let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
|
|
|
|
let mut app = fixtures::app(&alice, patch)?;
|
|
app.update(Message::Accept);
|
|
|
|
let state = app.state.lock().unwrap();
|
|
let state = &state.hunk_states().first().unwrap();
|
|
|
|
assert_eq!(**state, HunkState::Accepted);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn single_file_multiple_hunks_only_first_can_be_accepted() -> Result<()> {
|
|
let alice = test::fixtures::node_with_repo();
|
|
let branch = test::fixtures::branch_with_main_changed(&alice);
|
|
|
|
let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
|
|
let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
|
|
|
|
let mut app = fixtures::app(&alice, patch)?;
|
|
app.update(Message::Accept);
|
|
|
|
let state = app.state.lock().unwrap();
|
|
let states = &state.hunk_states();
|
|
|
|
assert_eq!(**states, [HunkState::Accepted, HunkState::Rejected]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn single_file_multiple_hunks_only_last_can_be_accepted() -> Result<()> {
|
|
let alice = test::fixtures::node_with_repo();
|
|
let branch = test::fixtures::branch_with_main_changed(&alice);
|
|
|
|
let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
|
|
let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
|
|
|
|
let mut app = fixtures::app(&alice, patch)?;
|
|
|
|
app.update(Message::HunkChanged {
|
|
state: TableState::new(Some(1)),
|
|
});
|
|
app.update(Message::Accept);
|
|
|
|
let state = app.state.lock().unwrap();
|
|
let states = &state.hunk_states();
|
|
|
|
assert_eq!(**states, [HunkState::Rejected, HunkState::Accepted]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_files_single_hunk_can_be_accepted() -> Result<()> {
|
|
let alice = test::fixtures::node_with_repo();
|
|
let branch = test::fixtures::branch_with_main_deleted_and_file_added(&alice);
|
|
|
|
let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
|
|
let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
|
|
|
|
let mut app = fixtures::app(&alice, patch)?;
|
|
app.update(Message::Accept);
|
|
|
|
app.update(Message::HunkChanged {
|
|
state: TableState::new(Some(1)),
|
|
});
|
|
app.update(Message::Accept);
|
|
|
|
let state = app.state.lock().unwrap();
|
|
let states = &state.hunk_states();
|
|
|
|
assert_eq!(**states, [HunkState::Accepted, HunkState::Accepted]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn hunk_state_is_synchronized() -> Result<()> {
|
|
let alice = test::fixtures::node_with_repo();
|
|
let branch = test::fixtures::branch_with_main_changed(&alice);
|
|
|
|
let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
|
|
let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
|
|
|
|
let mut app = fixtures::app(&alice, patch)?;
|
|
app.update(Message::Accept);
|
|
|
|
let state = app.state.lock().unwrap();
|
|
let hunks = app.hunks.lock().unwrap();
|
|
|
|
let item_states = hunks
|
|
.iter()
|
|
.map(|item| item.state().clone())
|
|
.collect::<Vec<_>>();
|
|
let states = &state.hunk_states();
|
|
|
|
assert_eq!(**states, item_states);
|
|
|
|
Ok(())
|
|
}
|
|
}
|