#[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(())
}
}