Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Store and load patch review state
Merged did:key:z6MkgFq6...nBGz opened 1 year ago
10 files changed +491 -637 6b9e82e3 c5930ad4
modified Cargo.lock
@@ -300,6 +300,9 @@ name = "bitflags"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07"
+
dependencies = [
+
 "serde",
+
]

[[package]]
name = "block-buffer"
@@ -468,6 +471,7 @@ dependencies = [
 "itoa",
 "rustversion",
 "ryu",
+
 "serde",
 "static_assertions",
]

@@ -1296,6 +1300,12 @@ dependencies = [
]

[[package]]
+
name = "md5"
+
version = "0.7.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
+

+
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2027,6 +2037,7 @@ dependencies = [
 "lexopt",
 "libc",
 "log",
+
 "md5",
 "nom",
 "predicates",
 "pretty_assertions",
@@ -2095,6 +2106,7 @@ dependencies = [
 "itertools",
 "lru",
 "paste",
+
 "serde",
 "strum",
 "termion 4.0.2",
 "time 0.3.34",
modified Cargo.toml
@@ -37,7 +37,8 @@ radicle-term = { version = "0.12.0" }
radicle-cli = { version = "0.12.1" }
radicle-surf = { version = "0.22.0" }
radicle-signals = { version = "0.10.0" }
-
ratatui = { version = "0.29.0", default-features = false, features = ["all-widgets", "termion"] }
+
ratatui = { version = "0.29.0", default-features = false, features = ["all-widgets", "termion", "serde"] }
+
md5 = { version = "0.7.0" }
simple-logging = { version = "2.0.2" }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
modified bin/commands/patch.rs
@@ -191,8 +191,7 @@ impl Args for Options {
                }
                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
                    "list" => op = OperationName::List,
-
                    // TODO(erikli): Enable if interface was fixed.
-
                    // "review" => op = OperationName::Review,
+
                    "review" => op = OperationName::Review,
                    _ => op = OperationName::Other,
                },
                Value(val) if patch_id.is_none() => {
@@ -326,6 +325,7 @@ mod interface {
    use crate::tui_patch::list;
    use crate::tui_patch::review::builder::CommentBuilder;
    use crate::tui_patch::review::ReviewAction;
+
    use crate::tui_patch::review::ReviewMode;

    use super::review;
    use super::review::builder::ReviewBuilder;
@@ -379,9 +379,11 @@ mod interface {
            return Ok(());
        };

-
        if let Some((id, _)) = patch::find_review(&patch, revision, &signer) {
+
        let mode = if let Some((id, _)) = patch::find_review(&patch, revision, &signer) {
            // Review already started, resume.
            log::info!("Resuming review {id}..");
+

+
            ReviewMode::Resume
        } else {
            // No review to resume, start a new one.
            let id = patch.review(
@@ -394,9 +396,10 @@ mod interface {
                vec![],
                &signer,
            )?;
-

            log::info!("Starting new review {id}..");
-
        }
+

+
            ReviewMode::Create
+
        };

        loop {
            // Reload review
@@ -404,10 +407,10 @@ mod interface {
            let (review_id, review) = patch::find_review(&patch, revision, &signer)
                .ok_or_else(|| anyhow!("Could not find review."))?;

-
            let selection = review::Tui::new(
+
            let response = review::Tui::new(
+
                mode.clone(),
                profile.storage.clone(),
                rid,
-
                signer,
                patch_id,
                patch.title().to_string(),
                revision.clone(),
@@ -417,42 +420,43 @@ mod interface {
            .run()
            .await?;

-
            log::info!("Received selection from TUI: {:?}", selection);
-

-
            if let Some(selection) = selection.as_ref() {
-
                match selection.action {
-
                    ReviewAction::Comment => {
-
                        let hunk = selection
-
                            .hunk
-
                            .ok_or_else(|| anyhow!("expected a selected hunk"))?;
-
                        let item = hunks
-
                            .get(hunk)
-
                            .ok_or_else(|| anyhow!("expected a hunk to comment on"))?;
-

-
                        let (old, new) = item.paths();
-
                        let path = old.or(new);
-

-
                        if let (Some(hunk), Some((path, _))) = (item.hunk(), path) {
-
                            let builder = CommentBuilder::new(revision.head(), path.to_path_buf());
-
                            let comments = builder.edit(hunk)?;
-

-
                            let signer = profile.signer()?;
-
                            patch.transaction("Review comments", &signer, |tx| {
-
                                for comment in comments {
-
                                    tx.review_comment(
-
                                        review_id,
-
                                        comment.body,
-
                                        Some(comment.location),
-
                                        None,   // Not a reply.
-
                                        vec![], // No embeds.
-
                                    )?;
-
                                }
-
                                Ok(())
-
                            })?;
-
                        } else {
-
                            log::warn!("Commenting on binary blobs is not yet implemented");
-
                        }
+
            log::debug!("Received response from TUI: {:?}", response);
+

+
            if let Some(response) = response.as_ref() {
+
                if let Some(ReviewAction::Comment) = response.action {
+
                    let hunk = response
+
                        .state
+
                        .selected_hunk()
+
                        .ok_or_else(|| anyhow!("expected a selected hunk"))?;
+
                    let item = hunks
+
                        .get(hunk)
+
                        .ok_or_else(|| anyhow!("expected a hunk to comment on"))?;
+

+
                    let (old, new) = item.paths();
+
                    let path = old.or(new);
+

+
                    if let (Some(hunk), Some((path, _))) = (item.hunk(), path) {
+
                        let builder = CommentBuilder::new(revision.head(), path.to_path_buf());
+
                        let comments = builder.edit(hunk)?;
+

+
                        let signer = profile.signer()?;
+
                        patch.transaction("Review comments", &signer, |tx| {
+
                            for comment in comments {
+
                                tx.review_comment(
+
                                    review_id,
+
                                    comment.body,
+
                                    Some(comment.location),
+
                                    None,   // Not a reply.
+
                                    vec![], // No embeds.
+
                                )?;
+
                            }
+
                            Ok(())
+
                        })?;
+
                    } else {
+
                        log::warn!("Commenting on binary blobs is not yet implemented");
                    }
+
                } else {
+
                    break;
                }
            } else {
                break;
modified bin/commands/patch/review.rs
@@ -2,12 +2,12 @@
pub mod builder;

use std::fmt::Debug;
-
use std::path::PathBuf;
-
use std::sync::Arc;
-
use std::sync::Mutex;
+
use std::sync::{Arc, Mutex};

use anyhow::Result;

+
use serde::{Deserialize, Serialize};
+

use termion::event::Key;

use ratatui::layout::{Constraint, Position};
@@ -15,11 +15,9 @@ use ratatui::style::Stylize;
use ratatui::text::Text;
use ratatui::{Frame, Viewport};

-
use radicle::crypto::Signer;
use radicle::identity::RepoId;
-
use radicle::patch::{PatchId, Review, Revision};
-
use radicle::storage::git::Repository;
-
use radicle::storage::{ReadStorage, WriteRepository};
+
use radicle::patch::{PatchId, Review, Revision, RevisionId};
+
use radicle::storage::ReadStorage;
use radicle::Storage;

use radicle_tui as tui;
@@ -31,15 +29,14 @@ use tui::ui::span;
use tui::ui::Column;
use tui::{Channel, Exit};

-
use crate::git::HunkDiff;
-
use crate::git::{HunkState, StatefulHunkDiff};
+
use crate::git::HunkState;
+
use crate::state::{self, FileIdentifier, FileStore, ReadState, WriteState};
use crate::ui::format;
use crate::ui::items::HunkItem;
+
use crate::ui::items::StatefulHunkItem;
use crate::ui::layout;

-
use super::review::builder::DiffUtil;
-

-
use self::builder::{Brain, FileReviewBuilder, Hunks};
+
use self::builder::Hunks;

/// The actions that a user can carry out on a review item.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
@@ -50,17 +47,22 @@ pub enum ReviewAction {
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Args(String);

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub struct Selection {
-
    pub action: ReviewAction,
-
    pub hunk: Option<usize>,
-
    pub args: Option<Args>,
+
#[derive(Clone, Debug)]
+
pub struct Response {
+
    pub state: AppState,
+
    pub action: Option<ReviewAction>,
+
}
+

+
#[derive(Clone)]
+
pub enum ReviewMode {
+
    Create,
+
    Resume,
}

pub struct Tui {
+
    pub mode: ReviewMode,
    pub storage: Storage,
    pub rid: RepoId,
-
    pub signer: Box<dyn Signer>,
    pub patch: PatchId,
    pub title: String,
    pub revision: Revision,
@@ -71,9 +73,9 @@ pub struct Tui {
impl Tui {
    #[allow(clippy::too_many_arguments)]
    pub fn new(
+
        mode: ReviewMode,
        storage: Storage,
        rid: RepoId,
-
        signer: Box<dyn Signer>,
        patch: PatchId,
        title: String,
        revision: Revision,
@@ -81,9 +83,9 @@ impl Tui {
        hunks: Hunks,
    ) -> Self {
        Self {
+
            mode,
            storage,
            rid,
-
            signer,
            patch,
            title,
            revision,
@@ -92,22 +94,39 @@ impl Tui {
        }
    }

-
    pub async fn run(self) -> Result<Option<Selection>> {
+
    pub async fn run(self) -> Result<Option<Response>> {
        let viewport = Viewport::Fullscreen;
-

        let channel = Channel::default();
-
        let state = App::new(
-
            self.storage,
+

+
        let identifier = FileIdentifier::new("patch", "review", &self.rid, Some(&self.patch));
+
        let store = FileStore::new(identifier)?;
+

+
        let default = AppState::new(
            self.rid,
-
            self.signer,
            self.patch,
            self.title,
-
            self.revision,
-
            self.review,
-
            self.hunks,
-
        )?;
+
            self.revision.id(),
+
            &self.hunks,
+
        );
+
        let state = match self.mode {
+
            ReviewMode::Resume => match store.read() {
+
                Ok(bytes) => state::from_json(&bytes)?,
+
                _ => {
+
                    log::warn!("Failed to load state. Falling back to default.");
+
                    default
+
                }
+
            },
+
            ReviewMode::Create => default,
+
        };

-
        tui::im(state, viewport, channel).await
+
        let app = App::new(self.mode, self.storage, self.review, self.hunks, state)?;
+
        let response = tui::im(app, viewport, channel).await?;
+

+
        if let Some(response) = response.as_ref() {
+
            store.write(&state::to_json(&response.state)?)?;
+
        }
+

+
        Ok(response)
    }
}

@@ -121,44 +140,67 @@ pub enum Message {
    HelpChanged { state: TextViewState },
    Comment,
    Accept,
-
    Discard,
+
    Reject,
    Quit,
}

-
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
+
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum AppPage {
    Main,
    Help,
}

-
#[derive(Clone, Debug)]
+
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
pub struct DiffViewState {
    cursor: Position,
}

-
pub struct HunkList<'a> {
-
    items: Vec<HunkItem<'a>>,
+
#[derive(Clone, Debug, Serialize, Deserialize)]
+
pub struct AppState {
+
    /// 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: PanesState,
+
    /// The hunks' table widget state.
+
    hunks: (TableState, Vec<HunkState>),
+
    /// Diff view states (cursor position is stored per hunk)
    views: Vec<DiffViewState>,
-
    table: TableState,
+
    /// State of text view widget on the help page.
+
    help: TextViewState,
}

-
impl<'a> HunkList<'a> {
+
impl AppState {
    pub fn new(
-
        items: impl IntoIterator<Item = HunkItem<'a>>,
-
        views: impl IntoIterator<Item = DiffViewState>,
-
        table: TableState,
+
        rid: RepoId,
+
        patch: PatchId,
+
        title: String,
+
        revision: RevisionId,
+
        hunks: &Hunks,
    ) -> Self {
        Self {
-
            items: items.into_iter().collect(),
-
            views: views.into_iter().collect(),
-
            table,
+
            rid,
+
            patch,
+
            title,
+
            revision,
+
            page: AppPage::Main,
+
            panes: PanesState::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()),
        }
    }

-
    pub fn item(&self, index: usize) -> Option<&HunkItem> {
-
        self.items.get(index)
-
    }
-

    pub fn view_state(&self, index: usize) -> Option<&DiffViewState> {
        self.views.get(index)
    }
@@ -169,199 +211,112 @@ impl<'a> HunkList<'a> {
        }
    }

-
    pub fn update_table(&mut self, table: TableState) {
-
        self.table = table;
+
    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 selected(&self) -> Option<usize> {
-
        self.table.selected()
+
    pub fn hunk_states(&self) -> &Vec<HunkState> {
+
        &self.hunks.1
    }
}

#[derive(Clone)]
pub struct App<'a> {
-
    /// The nodes' storage.
-
    storage: Storage,
-
    /// The repository to operate on.
-
    rid: RepoId,
-
    /// Signer of all writes to the storage or repo.
-
    signer: Arc<Mutex<Box<dyn Signer>>>,
-
    /// Patch this review belongs to.
-
    patch: PatchId,
-
    /// Title of the patch this patch this review belongs to.
-
    title: String,
-
    /// Revision this review belongs to.
-
    revision: Revision,
-
    /// All hunks, their view states (cursor position is stored per hunk)
-
    /// and the lists' table widget state.
-
    hunks: Arc<Mutex<HunkList<'a>>>,
-
    /// Current app page.
-
    page: AppPage,
-
    /// State of panes widget on the main page.
-
    group: PanesState,
-
    /// State of text view widget on the help page.
-
    help: TextViewState,
-
}
-

-
impl<'a> TryFrom<Tui> for App<'a> {
-
    type Error = anyhow::Error;
-

-
    fn try_from(tui: Tui) -> Result<Self, Self::Error> {
-
        App::new(
-
            tui.storage,
-
            tui.rid,
-
            tui.signer,
-
            tui.patch,
-
            tui.title,
-
            tui.revision,
-
            tui.review,
-
            tui.hunks,
-
        )
-
    }
+
    /// All hunks.
+
    hunks: Arc<Mutex<Vec<StatefulHunkItem<'a>>>>,
+
    /// The app state.
+
    state: Arc<Mutex<AppState>>,
+
    /// Review mode: create or resume.
+
    _mode: ReviewMode,
}

impl<'a> App<'a> {
-
    #[allow(clippy::too_many_arguments)]
    pub fn new(
+
        mode: ReviewMode,
        storage: Storage,
-
        rid: RepoId,
-
        signer: Box<dyn Signer>,
-
        patch: PatchId,
-
        title: String,
-
        revision: Revision,
        review: Review,
        hunks: Hunks,
+
        state: AppState,
    ) -> Result<Self, anyhow::Error> {
-
        let repo = storage.repository(rid)?;
-
        let states = hunks
-
            .iter()
-
            .map(|_| DiffViewState {
-
                cursor: Position::new(0, 0),
-
            })
-
            .collect::<Vec<_>>();
+
        let repo = storage.repository(state.rid)?;
        let hunks = hunks
            .iter()
-
            .map(|item| HunkItem::from((&repo, &review, StatefulHunkDiff::from(item))))
+
            .enumerate()
+
            .map(|(idx, item)| {
+
                StatefulHunkItem::new(
+
                    HunkItem::from((&repo, &review, item)),
+
                    state.hunk_states().get(idx).cloned().unwrap_or_default(),
+
                )
+
            })
            .collect::<Vec<_>>();

-
        let mut app = App {
-
            storage,
-
            signer: Arc::new(Mutex::new(signer)),
-
            rid,
-
            patch,
-
            title,
-
            revision,
-
            hunks: Arc::new(Mutex::new(HunkList::new(
-
                hunks,
-
                states,
-
                TableState::new(Some(0)),
-
            ))),
-
            page: AppPage::Main,
-
            group: PanesState::new(2, Some(0)),
-
            help: TextViewState::new(Position::default()),
-
        };
-

-
        app.reload_states()?;
-

-
        Ok(app)
+
        Ok(Self {
+
            hunks: Arc::new(Mutex::new(hunks)),
+
            state: Arc::new(Mutex::new(state)),
+
            _mode: mode,
+
        })
    }

-
    #[allow(clippy::borrowed_box)]
-
    pub fn accept_current_hunk(&self) -> Result<()> {
-
        let repo = self.storage.repository(self.rid).unwrap();
-
        let signer: &Box<dyn Signer> = &self.signer.lock().unwrap();
-

-
        if let Some(selected) = self.selected_hunk_idx() {
-
            let items = &self.hunks.lock().unwrap().items;
-
            let mut brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), signer)?;
-

-
            let mut last_path: Option<&PathBuf> = None;
-
            let mut file: Option<FileReviewBuilder> = None;
-

-
            for (idx, item) in items.iter().enumerate() {
-
                // Get file path.
-
                let path = match item.inner.hunk() {
-
                    HunkDiff::Added { path, .. } => path,
-
                    HunkDiff::Deleted { path, .. } => path,
-
                    HunkDiff::Modified { path, .. } => path,
-
                    HunkDiff::Copied { copied } => &copied.new_path,
-
                    HunkDiff::Moved { moved } => &moved.new_path,
-
                    HunkDiff::EofChanged { path, .. } => path,
-
                    HunkDiff::ModeChanged { path, .. } => path,
-
                };
-

-
                // Set new review builder if hunk belongs to new file.
-
                if last_path.is_none() || last_path.unwrap() != path {
-
                    last_path = Some(path);
-
                    file = Some(FileReviewBuilder::new(item.inner.hunk()));
-
                }
-

-
                if let Some(file) = file.as_mut() {
-
                    file.set_item(item.inner.hunk());
-

-
                    if idx == selected {
-
                        let diff = file.item_diff(item.inner.hunk())?;
-
                        brain.accept(diff, repo.raw())?;
-
                    } else {
-
                        file.ignore_item(item.inner.hunk())
-
                    }
-
                }
-
            }
+
    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(())
    }

-
    #[allow(clippy::borrowed_box)]
-
    pub fn discard_accepted_hunks(&self) -> Result<()> {
-
        let repo = self.repo()?;
-
        let signer: &Box<dyn Signer> = &self.signer.lock().unwrap();
-

-
        let mut brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), signer)?;
-
        brain.discard_accepted(repo.raw())?;
-

-
        Ok(())
-
    }
-

-
    #[allow(clippy::borrowed_box)]
-
    pub fn reload_states(&mut self) -> anyhow::Result<()> {
-
        let repo = self.repo()?;
-
        let signer: &Box<dyn Signer> = &self.signer.lock().unwrap();
-
        let items = &mut self.hunks.lock().unwrap().items;
-

-
        let brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), signer)?;
-
        let rejected_hunks =
-
            Hunks::new(DiffUtil::new(&repo).rejected_diffs(&brain, &self.revision)?);
-

-
        log::debug!("Reloaded hunk states..");
-
        log::debug!("Rejected hunks: {:?}", rejected_hunks);
-
        log::debug!("Requested to reload hunks: {:?}", items);
-

-
        for item in &mut *items {
-
            let state = if rejected_hunks.contains(item.inner.hunk()) {
-
                HunkState::Rejected
-
            } else {
-
                HunkState::Accepted
-
            };
-
            *item.inner.state_mut() = state;
+
    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);
        }
-

-
        log::debug!("Reloaded hunks: {:?}", items);
+
        self.synchronize_hunk_state();

        Ok(())
    }

-
    pub fn selected_hunk_idx(&self) -> Option<usize> {
-
        self.hunks.lock().unwrap().selected()
+
    pub fn selected_hunk(&self) -> Option<usize> {
+
        let state = self.state.lock().unwrap();
+
        state.selected_hunk()
    }

-
    pub fn repo(&self) -> Result<Repository> {
-
        Ok(self.storage.repository(self.rid)?)
+
    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<'a> App<'a> {
    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 header = [Column::new(" Hunks ", Constraint::Fill(1))].to_vec();
        let columns = [
            Column::new("", Constraint::Length(2)),
@@ -370,10 +325,9 @@ impl<'a> App<'a> {
        ]
        .to_vec();

-
        let hunks = self.hunks.lock().unwrap();
-
        let mut selected = hunks.selected();
+
        let mut selected = state.selected_hunk();

-
        let table = ui.headered_table(frame, &mut selected, &hunks.items, header, columns);
+
        let table = ui.headered_table(frame, &mut selected, &hunks, header, columns);
        if table.changed {
            ui.send_message(Message::HunkChanged {
                state: TableState::new(selected),
@@ -383,24 +337,26 @@ impl<'a> App<'a> {

    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 = hunks.selected();
-
        let hunk = selected.and_then(|selected| hunks.item(selected));
+
        let selected = state.selected_hunk();
+
        let hunk = selected.and_then(|selected| hunks.get(selected));

        if let Some(hunk) = hunk {
            let empty_text = hunk
+
                .inner()
                .hunk_text()
                .unwrap_or(Text::raw("Nothing to show.").dark_gray());

            let mut cursor = selected
-
                .and_then(|selected| hunks.view_state(selected))
+
                .and_then(|selected| state.view_state(selected))
                .map(|state| state.cursor)
                .unwrap_or_default();

            ui.composite(layout::container(), 1, |ui| {
-
                ui.columns(frame, hunk.header(), Some(Borders::Top));
+
                ui.columns(frame, hunk.inner().header(), Some(Borders::Top));

-
                if let Some(text) = hunk.hunk_text() {
+
                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 {
@@ -415,15 +371,18 @@ impl<'a> App<'a> {
    }

    fn show_context_bar(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
-
        let hunks = &self.hunks.lock().unwrap().items;
+
        let hunks = &self.hunks.lock().unwrap();
+
        let state = self.state.lock().unwrap();

-
        let id = format!(" {} ", format::cob(&self.patch));
-
        let title = &self.title;
+
        let id = format!(" {} ", format::cob(&state.patch));
+
        let title = &state.title;

        let hunks_total = hunks.len();
-
        let hunks_accepted = hunks
+
        let hunks_accepted = state
+
            .hunks
+
            .1
            .iter()
-
            .filter(|hunk| *hunk.inner.state() == HunkState::Accepted)
+
            .filter(|state| **state == HunkState::Accepted)
            .collect::<Vec<_>>()
            .len();

@@ -473,18 +432,26 @@ impl<'a> App<'a> {
impl<'a> Show<Message> for App<'a> {
    fn show(&self, ctx: &Context<Message>, frame: &mut Frame) -> Result<(), anyhow::Error> {
        Window::default().show(ctx, |ui| {
-
            let mut page_focus = self.group.focus();
+
            let page = {
+
                let state = self.state.lock().unwrap();
+
                state.page.clone()
+
            };

-
            match self.page {
+
            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.panes(layout::list_item(), &mut page_focus, |ui| {
+
                        let group = ui.panes(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: PanesState::new(self.group.len(), page_focus),
+
                                state: PanesState::new(count, focus),
                            });
                        }

@@ -495,7 +462,7 @@ impl<'a> Show<Message> for App<'a> {
                            &[
                                ("c", "comment"),
                                ("a", "accept"),
-
                                ("d", "discard accepted"),
+
                                ("r", "reject"),
                                ("?", "help"),
                                ("q", "quit"),
                            ],
@@ -511,16 +478,19 @@ impl<'a> Show<Message> for App<'a> {
                        if ui.input_global(|key| key == Key::Char('a')) {
                            ui.send_message(Message::Accept);
                        }
-
                        if ui.input_global(|key| key == Key::Char('d')) {
-
                            ui.send_message(Message::Discard);
+
                        if ui.input_global(|key| key == Key::Char('r')) {
+
                            ui.send_message(Message::Reject);
                        }
                    });
                }
                AppPage::Help => {
-
                    ui.panes(layout::page(), &mut page_focus, |ui| {
+
                    ui.layout(layout::page(), Some(0), |ui| {
                        ui.composite(layout::container(), 1, |ui| {
+
                            let mut cursor = {
+
                                let state = self.state.lock().unwrap();
+
                                state.help.cursor()
+
                            };
                            let header = [Column::new(" Help ", Constraint::Fill(1))].to_vec();
-
                            let mut cursor = self.help.cursor();

                            ui.columns(frame, header, Some(Borders::Top));
                            let help = ui.text_view(
@@ -556,67 +526,76 @@ impl<'a> Show<Message> for App<'a> {
}

impl<'a> store::Update<Message> for App<'a> {
-
    type Return = Selection;
+
    type Return = Response;

    fn update(&mut self, message: Message) -> Option<Exit<Self::Return>> {
        log::info!("Received message: {:?}", message);

        match message {
            Message::ShowMain => {
-
                self.page = AppPage::Main;
+
                let mut state = self.state.lock().unwrap();
+
                state.page = AppPage::Main;
                None
            }
            Message::ShowHelp => {
-
                self.page = AppPage::Help;
+
                let mut state = self.state.lock().unwrap();
+
                state.page = AppPage::Help;
                None
            }
            Message::PanesChanged { state } => {
-
                self.group = state;
+
                let mut app_state = self.state.lock().unwrap();
+
                app_state.panes = state;
                None
            }
            Message::HunkChanged { state } => {
-
                let mut hunks = self.hunks.lock().unwrap();
-
                hunks.update_table(state);
+
                let mut app_state = self.state.lock().unwrap();
+
                app_state.update_hunks(state);
                None
            }
            Message::HunkViewChanged { state } => {
-
                let hunks = &mut self.hunks.lock().unwrap();
-
                if let Some(selected) = hunks.selected() {
-
                    hunks.update_view_state(selected, 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 } => {
-
                self.help = state;
+
                let mut app_state = self.state.lock().unwrap();
+
                app_state.help = state;
                None
            }
            Message::Comment => {
-
                let hunks = self.hunks.lock().unwrap();
+
                let state = self.state.lock().unwrap();
                Some(Exit {
-
                    value: Some(Selection {
-
                        action: ReviewAction::Comment,
-
                        hunk: hunks.selected(),
-
                        args: None,
+
                    value: Some(Response {
+
                        action: Some(ReviewAction::Comment),
+
                        state: state.clone(),
                    }),
                })
            }
            Message::Accept => {
-
                match self.accept_current_hunk() {
-
                    Ok(()) => log::info!("Hunk accepted."),
+
                match self.accept_selected_hunk() {
+
                    Ok(()) => log::info!("Accepted selected hunk."),
                    Err(err) => log::info!("An error occured while accepting hunk: {}", err),
                }
-
                let _ = self.reload_states();
                None
            }
-
            Message::Discard => {
-
                match self.discard_accepted_hunks() {
-
                    Ok(()) => log::info!("Discarded all hunks."),
-
                    Err(err) => log::info!("An error occured while discarding hunks: {}", err),
+
            Message::Reject => {
+
                match self.reject_selected_hunk() {
+
                    Ok(()) => log::info!("Rejected selected hunk."),
+
                    Err(err) => log::info!("An error occured while rejecting hunk: {}", err),
                }
-
                let _ = self.reload_states();
                None
            }
-
            Message::Quit => Some(Exit { value: None }),
+
            Message::Quit => {
+
                let state = self.state.lock().unwrap();
+
                Some(Exit {
+
                    value: Some(Response {
+
                        action: None,
+
                        state: state.clone(),
+
                    }),
+
                })
+
            }
        }
    }
}
@@ -666,8 +645,8 @@ mod test {
    use crate::test;

    impl<'a> App<'a> {
-
        pub fn hunks(&self) -> Vec<HunkItem> {
-
            self.hunks.lock().unwrap().items.clone()
+
        pub fn hunks(&self) -> Vec<StatefulHunkItem> {
+
            self.hunks.lock().unwrap().clone()
        }
    }

@@ -684,7 +663,7 @@ mod test {
        use crate::test::setup::NodeWithRepo;

        use super::builder::ReviewBuilder;
-
        use super::App;
+
        use super::{App, AppState, ReviewMode};

        pub fn app<'a>(
            node: &NodeWithRepo,
@@ -699,15 +678,20 @@ mod test {

            let hunks = ReviewBuilder::new(&node.repo).hunks(revision)?;

-
            App::new(
-
                node.storage.clone(),
+
            let state = AppState::new(
                node.repo.id,
-
                Box::new(node.signer.clone()),
                *patch.id(),
                patch.title().to_string(),
-
                revision.clone(),
+
                revision.id(),
+
                &hunks,
+
            );
+

+
            App::new(
+
                ReviewMode::Create,
+
                node.storage.clone(),
                review.clone(),
                hunks,
+
                state,
            )
        }

@@ -771,7 +755,7 @@ mod test {

        let app = fixtures::app(&alice, patch)?;

-
        assert_eq!(app.selected_hunk_idx(), Some(0));
+
        assert_eq!(app.selected_hunk(), Some(0));

        Ok(())
    }
@@ -785,14 +769,10 @@ mod test {
        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();

-
        let hunks = app.hunks();
-
        let states = hunks
-
            .iter()
-
            .map(|item| item.inner.state())
-
            .collect::<Vec<_>>();
-

-
        assert_eq!(states, [&HunkState::Rejected, &HunkState::Rejected,]);
+
        assert_eq!(**states, [HunkState::Rejected, HunkState::Rejected]);

        Ok(())
    }
@@ -810,7 +790,7 @@ mod test {
            state: TableState::new(Some(1)),
        });

-
        assert_eq!(app.selected_hunk_idx(), Some(1));
+
        assert_eq!(app.selected_hunk(), Some(1));

        Ok(())
    }
@@ -826,8 +806,8 @@ mod test {
        let mut app = fixtures::app(&alice, patch)?;
        app.update(Message::Accept);

-
        let hunks = app.hunks();
-
        let state = &hunks.get(0).unwrap().inner.state();
+
        let state = app.state.lock().unwrap();
+
        let state = &state.hunk_states().get(0).unwrap();

        assert_eq!(**state, HunkState::Accepted);

@@ -835,7 +815,6 @@ mod test {
    }

    #[test]
-
    #[ignore]
    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);
@@ -846,13 +825,10 @@ mod test {
        let mut app = fixtures::app(&alice, patch)?;
        app.update(Message::Accept);

-
        let hunks = app.hunks();
-
        let states = hunks
-
            .iter()
-
            .map(|item| item.inner.state())
-
            .collect::<Vec<_>>();
+
        let state = app.state.lock().unwrap();
+
        let states = &state.hunk_states();

-
        assert_eq!(states, [&HunkState::Accepted, &HunkState::Rejected]);
+
        assert_eq!(**states, [HunkState::Accepted, HunkState::Rejected]);

        Ok(())
    }
@@ -872,13 +848,10 @@ mod test {
        });
        app.update(Message::Accept);

-
        let hunks = app.hunks();
-
        let states = hunks
-
            .iter()
-
            .map(|item| item.inner.state())
-
            .collect::<Vec<_>>();
+
        let state = app.state.lock().unwrap();
+
        let states = &state.hunk_states();

-
        assert_eq!(states, [&HunkState::Rejected, &HunkState::Accepted]);
+
        assert_eq!(**states, [HunkState::Rejected, HunkState::Accepted]);

        Ok(())
    }
@@ -899,13 +872,35 @@ mod test {
        });
        app.update(Message::Accept);

-
        let hunks = app.hunks();
-
        let states = hunks
+
        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.inner.state())
+
            .map(|item| item.state().clone())
            .collect::<Vec<_>>();
+
        let states = &state.hunk_states();

-
        assert_eq!(states, [&HunkState::Accepted, &HunkState::Accepted]);
+
        assert_eq!(**states, item_states);

        Ok(())
    }
modified bin/commands/patch/review/builder.rs
@@ -15,7 +15,7 @@ use std::io;
use std::ops::{Not, Range};
use std::path::PathBuf;

-
use radicle::cob::patch::{PatchId, Revision};
+
use radicle::cob::patch::Revision;
use radicle::cob::{CodeLocation, CodeRange};
use radicle::git;
use radicle::git::Oid;
@@ -152,16 +152,6 @@ impl Hunks {
    fn add_item(&mut self, item: HunkDiff) {
        self.hunks.push(item);
    }
-

-
    pub fn contains(&self, other: &HunkDiff) -> bool {
-
        for item in &self.hunks {
-
            if item.path() == other.path() && item.hunk() == other.hunk() {
-
                return true;
-
            }
-
        }
-

-
        false
-
    }
}

impl std::ops::Deref for Hunks {
@@ -178,205 +168,6 @@ impl std::ops::DerefMut for Hunks {
    }
}

-
/// Builds a review for a single file.
-
/// Adjusts line deltas when a hunk is ignored.
-
#[derive(Debug)]
-
pub struct FileReviewBuilder {
-
    delta: i32,
-
    header: FileHeader,
-
}
-

-
impl FileReviewBuilder {
-
    pub fn new(item: &HunkDiff) -> Self {
-
        Self {
-
            delta: 0,
-
            header: item.file_header(),
-
        }
-
    }
-

-
    pub fn set_item(&mut self, item: &HunkDiff) -> &mut Self {
-
        let header = item.file_header();
-
        if self.header != header {
-
            self.header = header;
-
            self.delta = 0;
-
        }
-
        self
-
    }
-

-
    pub fn ignore_item(&mut self, item: &HunkDiff) {
-
        if let Some(h) = item.hunk_header() {
-
            self.delta += h.new_size as i32 - h.old_size as i32;
-
        }
-
    }
-

-
    pub fn item_diff(&mut self, item: &HunkDiff) -> Result<git::raw::Diff, Error> {
-
        let mut buf = Vec::new();
-
        let mut writer = unified_diff::Writer::new(&mut buf);
-
        writer.encode(&self.header)?;
-

-
        if let (Some(h), Some(mut header)) = (item.hunk(), item.hunk_header()) {
-
            header.old_line_no -= self.delta as u32;
-
            header.new_line_no -= self.delta as u32;
-

-
            let h = Hunk {
-
                header: header.to_unified_string()?.as_bytes().to_owned().into(),
-
                lines: h.lines.clone(),
-
                old: h.old.clone(),
-
                new: h.new.clone(),
-
            };
-
            writer.encode(&h)?;
-
        }
-
        drop(writer);
-

-
        log::debug!("Building item diff ({:?})", String::from_utf8(buf.clone()));
-
        git::raw::Diff::from_buffer(&buf).map_err(Error::from)
-
    }
-
}
-

-
/// Represents the reviewer's brain, ie. what they have seen or not seen in terms
-
/// of changes introduced by a patch.
-
#[derive(Clone, Debug)]
-
pub struct Brain<'a> {
-
    /// Where the review draft is being stored.
-
    refname: git::Namespaced<'a>,
-
    /// The merge base
-
    base: git::raw::Commit<'a>,
-
    /// The commit pointed to by the ref.
-
    head: git::raw::Commit<'a>,
-
    /// The tree of accepted changes pointed to by the head commit.
-
    accepted: git::raw::Tree<'a>,
-
}
-

-
impl<'a> Brain<'a> {
-
    /// Create a new brain in the repository.
-
    pub fn new(
-
        patch: PatchId,
-
        remote: &NodeId,
-
        base: git::raw::Commit<'a>,
-
        repo: &'a git::raw::Repository,
-
    ) -> Result<Self, git::raw::Error> {
-
        let refname = Self::refname(&patch, remote);
-
        let author = repo.signature()?;
-
        let oid = repo.commit(
-
            Some(refname.as_str()),
-
            &author,
-
            &author,
-
            &format!("Review for {patch}"),
-
            &base.tree()?,
-
            // TODO: Verify this is necessary, shouldn't matter.
-
            &[&base],
-
        )?;
-
        let head = repo.find_commit(oid)?;
-
        let tree = head.tree()?;
-

-
        Ok(Self {
-
            refname,
-
            base,
-
            head,
-
            accepted: tree,
-
        })
-
    }
-

-
    /// Load an existing brain from the repository.
-
    pub fn load(
-
        patch: PatchId,
-
        remote: &NodeId,
-
        base: git::raw::Commit<'a>,
-
        repo: &'a git::raw::Repository,
-
    ) -> Result<Self, git::raw::Error> {
-
        // TODO: Validate this leads to correct UX for potentially abandoned drafts on
-
        // past revisions.
-
        let refname = Self::refname(&patch, remote);
-
        let head = repo.find_reference(&refname)?.peel_to_commit()?;
-
        let tree = head.tree()?;
-

-
        Ok(Self {
-
            refname,
-
            base,
-
            head,
-
            accepted: tree,
-
        })
-
    }
-

-
    pub fn load_or_new<G: Signer>(
-
        patch: PatchId,
-
        revision: &Revision,
-
        repo: &'a git::raw::Repository,
-
        signer: &'a G,
-
    ) -> Result<Self, git::raw::Error> {
-
        let base = repo.find_commit((*revision.base()).into())?;
-

-
        let brain = if let Ok(b) = Brain::load(patch, signer.public_key(), base.clone(), repo) {
-
            log::info!(
-
                "Loaded existing brain {} for patch {}",
-
                b.head().id(),
-
                &patch
-
            );
-
            b
-
        } else {
-
            Brain::new(patch, signer.public_key(), base, repo)?
-
        };
-

-
        Ok(brain)
-
    }
-

-
    pub fn discard_accepted(
-
        &mut self,
-
        repo: &'a git::raw::Repository,
-
    ) -> Result<(), git::raw::Error> {
-
        // Reset brain
-
        let head = self.head.amend(
-
            Some(&self.refname),
-
            None,
-
            None,
-
            None,
-
            None,
-
            Some(&self.base.tree()?),
-
        )?;
-
        self.head = repo.find_commit(head)?;
-
        self.accepted = self.head.tree()?;
-

-
        Ok(())
-
    }
-

-
    /// Accept changes to the brain.
-
    pub fn accept(
-
        &mut self,
-
        diff: git::raw::Diff,
-
        repo: &'a git::raw::Repository,
-
    ) -> Result<(), git::raw::Error> {
-
        let mut index = repo.apply_to_tree(&self.accepted, &diff, None)?;
-
        let accepted = index.write_tree_to(repo)?;
-
        self.accepted = repo.find_tree(accepted)?;
-

-
        // Update review with new brain.
-
        let head = self.head.amend(
-
            Some(&self.refname),
-
            None,
-
            None,
-
            None,
-
            None,
-
            Some(&self.accepted),
-
        )?;
-
        self.head = repo.find_commit(head)?;
-

-
        Ok(())
-
    }
-

-
    /// Get the brain's refname given the patch and remote.
-
    pub fn refname(patch: &PatchId, remote: &NodeId) -> git::Namespaced<'a> {
-
        git::refs::storage::draft::review(remote, patch)
-
    }
-

-
    pub fn head(&self) -> &git::raw::Commit<'a> {
-
        &self.head
-
    }
-

-
    pub fn accepted(&self) -> &git::raw::Tree<'a> {
-
        &self.accepted
-
    }
-
}
-

pub struct DiffUtil<'a> {
    repo: &'a Repository,
}
@@ -403,21 +194,6 @@ impl<'a> DiffUtil<'a> {
        Ok(base_diff)
    }

-
    pub fn rejected_diffs(&self, brain: &Brain<'a>, revision: &Revision) -> anyhow::Result<Diff> {
-
        let repo = self.repo.raw();
-
        let revision = {
-
            let commit = repo.find_commit(revision.head().into())?;
-
            commit.tree()?
-
        };
-

-
        let mut opts = git::raw::DiffOptions::new();
-
        opts.patience(true).minimal(true).context_lines(3_u32);
-

-
        let rejected = self.diff(brain.accepted(), &revision, repo, &mut opts)?;
-

-
        Ok(rejected)
-
    }
-

    pub fn diff(
        &self,
        brain: &git::raw::Tree<'_>,
modified bin/git.rs
@@ -10,9 +10,10 @@ use radicle_surf::diff::{Copied, DiffFile, EofNewLine, FileStats, Hunk, Modifica
use radicle::git;
use radicle::git::Oid;

-
use radicle_cli::git::unified_diff::{FileHeader, HunkHeader};
+
use radicle_cli::git::unified_diff::FileHeader;
use radicle_cli::terminal;
use radicle_cli::terminal::highlight::Highlighter;
+
use serde::{Deserialize, Serialize};

pub type FilePaths<'a> = (Option<(&'a Path, Oid)>, Option<(&'a Path, Oid)>);

@@ -217,7 +218,7 @@ impl From<&Hunk<Modification>> for HunkStats {
    }
}

-
#[derive(Clone, Default, Debug, PartialEq)]
+
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum HunkState {
    #[default]
    Unknown,
@@ -282,40 +283,6 @@ impl HunkDiff {
        }
    }

-
    pub fn path(&self) -> &PathBuf {
-
        match self {
-
            Self::Added { path, .. } => path,
-
            Self::Deleted { path, .. } => path,
-
            Self::Moved { moved } => &moved.new_path,
-
            Self::Copied { copied } => &copied.new_path,
-
            Self::Modified { path, .. } => path,
-
            Self::EofChanged { path, .. } => path,
-
            Self::ModeChanged { path, .. } => path,
-
        }
-
    }
-

-
    pub fn file_header(&self) -> FileHeader {
-
        match self {
-
            Self::Added { header, .. } => header.clone(),
-
            Self::Deleted { header, .. } => header.clone(),
-
            Self::Moved { moved } => FileHeader::Moved {
-
                old_path: moved.old_path.clone(),
-
                new_path: moved.new_path.clone(),
-
            },
-
            Self::Copied { copied } => FileHeader::Copied {
-
                old_path: copied.old_path.clone(),
-
                new_path: copied.new_path.clone(),
-
            },
-
            Self::Modified { header, .. } => header.clone(),
-
            Self::EofChanged { header, .. } => header.clone(),
-
            Self::ModeChanged { header, .. } => header.clone(),
-
        }
-
    }
-

-
    pub fn hunk_header(&self) -> Option<HunkHeader> {
-
        self.hunk().and_then(|h| HunkHeader::try_from(h).ok())
-
    }
-

    pub fn paths(&self) -> FilePaths {
        match self {
            Self::Added { path, new, .. } => (None, Some((path, new.oid))),
@@ -366,26 +333,3 @@ impl Debug for HunkDiff {
        }
    }
}
-

-
#[derive(Clone, Debug)]
-
pub struct StatefulHunkDiff(HunkDiff, HunkState);
-

-
impl StatefulHunkDiff {
-
    pub fn hunk(&self) -> &HunkDiff {
-
        &self.0
-
    }
-

-
    pub fn state(&self) -> &HunkState {
-
        &self.1
-
    }
-

-
    pub fn state_mut(&mut self) -> &mut HunkState {
-
        &mut self.1
-
    }
-
}
-

-
impl From<&HunkDiff> for StatefulHunkDiff {
-
    fn from(diff: &HunkDiff) -> Self {
-
        Self(diff.clone(), HunkState::Unknown)
-
    }
-
}
modified bin/main.rs
@@ -3,6 +3,7 @@ mod commands;
mod git;
mod log;
mod settings;
+
mod state;
mod terminal;
#[cfg(test)]
mod test;
added bin/state.rs
@@ -0,0 +1,98 @@
+
use std::path::PathBuf;
+
use std::{fmt::Display, fs};
+

+
use anyhow::Result;
+

+
use homedir::my_home;
+

+
use serde::{Deserialize, Serialize};
+

+
use radicle::cob::ObjectId;
+
use radicle::identity::RepoId;
+

+
const PATH: &str = ".radicle-tui/states";
+

+
/// Converts bytes to a deserializable type.
+
pub fn from_json<'a, D>(bytes: &'a [u8]) -> Result<D>
+
where
+
    D: Deserialize<'a>,
+
{
+
    Ok(serde_json::from_slice(bytes)?)
+
}
+

+
/// Converts serializable type to bytes.
+
pub fn to_json<S>(state: S) -> Result<Vec<u8>>
+
where
+
    S: Serialize,
+
{
+
    Ok(serde_json::to_vec(&state)?)
+
}
+

+
/// Trait for state readers.
+
pub trait ReadState {
+
    fn read(&self) -> Result<Vec<u8>>;
+
}
+

+
/// Trait for state writers.
+
pub trait WriteState: ReadState {
+
    fn write(&self, bytes: &[u8]) -> Result<()>;
+
}
+

+
/// A state storage that reads from and writes to a file.
+
pub struct FileStore {
+
    path: PathBuf,
+
}
+

+
impl FileStore {
+
    pub fn new(filename: impl ToString) -> Result<Self> {
+
        let folder = match my_home()? {
+
            Some(home) => format!("{}/{}", home.to_string_lossy(), PATH),
+
            _ => anyhow::bail!("Failed to read home directory"),
+
        };
+
        let path = format!("{}/{}.json", folder, filename.to_string());
+

+
        fs::create_dir_all(folder.clone())?;
+

+
        Ok(Self {
+
            path: PathBuf::from(path),
+
        })
+
    }
+
}
+

+
impl ReadState for FileStore {
+
    fn read(&self) -> Result<Vec<u8>> {
+
        let path = PathBuf::from(&self.path);
+

+
        Ok(fs::read(path)?)
+
    }
+
}
+

+
impl WriteState for FileStore {
+
    fn write(&self, contents: &[u8]) -> Result<()> {
+
        let path = PathBuf::from(&self.path);
+

+
        Ok(fs::write(path, contents)?)
+
    }
+
}
+

+
pub struct FileIdentifier {
+
    id: String,
+
}
+

+
impl FileIdentifier {
+
    pub fn new(command: &str, operation: &str, rid: &RepoId, id: Option<&ObjectId>) -> Self {
+
        let id = match id {
+
            Some(id) => format!("{}-{}-{}-{}", command, operation, rid, id),
+
            _ => format!("{}-{}-{}", command, operation, rid),
+
        };
+
        let id = format!("{:x}", md5::compute(id));
+

+
        Self { id }
+
    }
+
}
+

+
impl Display for FileIdentifier {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        write!(f, "{}", self.id)
+
    }
+
}
modified bin/ui/items.rs
@@ -43,7 +43,7 @@ use tui::ui::utils::LineMerger;
use tui::ui::{span, Column};
use tui::ui::{ToRow, ToTree};

-
use crate::git::{Blobs, DiffStats, HunkDiff, HunkStats, StatefulHunkDiff};
+
use crate::git::{Blobs, DiffStats, HunkDiff, HunkState, HunkStats};
use crate::ui;

use super::super::git;
@@ -1095,7 +1095,7 @@ impl From<Vec<(EntryId, Comment<CodeLocation>)>> for HunkComments {
#[derive(Clone)]
pub struct HunkItem<'a> {
    /// The underlying hunk type and its current state (accepted / rejected).
-
    pub inner: StatefulHunkDiff,
+
    pub diff: HunkDiff,
    /// Raw or highlighted hunk lines. Highlighting is expensive and needs to be asynchronously.
    /// Therefor, a hunks' lines need to stored separately.
    pub lines: Blobs<Vec<Line<'a>>>,
@@ -1103,13 +1103,13 @@ pub struct HunkItem<'a> {
    pub comments: HunkComments,
}

-
impl<'a> From<(&Repository, &Review, StatefulHunkDiff)> for HunkItem<'a> {
-
    fn from(value: (&Repository, &Review, StatefulHunkDiff)) -> Self {
+
impl<'a> From<(&Repository, &Review, &HunkDiff)> for HunkItem<'a> {
+
    fn from(value: (&Repository, &Review, &HunkDiff)) -> Self {
        let (repo, review, item) = value;
        let hi = Highlighter::default();
-
        let hunk = item.hunk();
+
        // let hunk = item.hunk();

-
        let path = match &hunk {
+
        let path = match &item {
            HunkDiff::Added { path, .. } => path,
            HunkDiff::Modified { path, .. } => path,
            HunkDiff::Deleted { path, .. } => path,
@@ -1122,7 +1122,7 @@ impl<'a> From<(&Repository, &Review, StatefulHunkDiff)> for HunkItem<'a> {
        // TODO(erikli): Start with raw, non-highlighted lines and
        // move highlighting to separate task / thread, e.g. here:
        // `let lines = blobs.raw()`
-
        let blobs = hunk.clone().blobs(repo.raw());
+
        let blobs = item.clone().blobs(repo.raw());
        let lines = blobs.highlight(hi);
        let comments = review
            .comments()
@@ -1132,21 +1132,22 @@ impl<'a> From<(&Repository, &Review, StatefulHunkDiff)> for HunkItem<'a> {
            .collect::<Vec<_>>();

        Self {
-
            inner: item.clone(),
+
            diff: item.clone(),
            lines,
            comments: HunkComments::from(comments),
        }
    }
}

-
impl<'a> ToRow<3> for HunkItem<'a> {
+
impl<'a> ToRow<3> for StatefulHunkItem<'a> {
    fn to_row(&self) -> [Cell; 3] {
        let build_stats_spans = |stats: &DiffStats| -> Vec<Span<'_>> {
            let mut cell = vec![];
+
            let comments = &self.inner().comments;

-
            if !self.comments.is_empty() {
+
            if !comments.is_empty() {
                cell.push(
-
                    span::default(&format!(" {} ", self.comments.len()))
+
                    span::default(&format!(" {} ", comments.len()))
                        .dim()
                        .reversed(),
                );
@@ -1173,7 +1174,7 @@ impl<'a> ToRow<3> for HunkItem<'a> {
            cell
        };

-
        match &self.inner.hunk() {
+
        match &self.inner().diff {
            HunkDiff::Added {
                path,
                header: _,
@@ -1189,7 +1190,7 @@ impl<'a> ToRow<3> for HunkItem<'a> {
                .concat();

                [
-
                    Line::from(ui::span::hunk_state(self.inner.state()))
+
                    Line::from(ui::span::hunk_state(self.state()))
                        .right_aligned()
                        .into(),
                    Line::from(ui::span::pretty_path(path, false, false)).into(),
@@ -1212,7 +1213,7 @@ impl<'a> ToRow<3> for HunkItem<'a> {
                .concat();

                [
-
                    Line::from(ui::span::hunk_state(self.inner.state()))
+
                    Line::from(ui::span::hunk_state(self.state()))
                        .right_aligned()
                        .into(),
                    Line::from(ui::span::pretty_path(path, false, false)).into(),
@@ -1234,7 +1235,7 @@ impl<'a> ToRow<3> for HunkItem<'a> {
                .concat();

                [
-
                    Line::from(ui::span::hunk_state(self.inner.state()))
+
                    Line::from(ui::span::hunk_state(self.state()))
                        .right_aligned()
                        .into(),
                    Line::from(ui::span::pretty_path(path, false, false)).into(),
@@ -1250,7 +1251,7 @@ impl<'a> ToRow<3> for HunkItem<'a> {
                .concat();

                [
-
                    Line::from(ui::span::hunk_state(self.inner.state()))
+
                    Line::from(ui::span::hunk_state(self.state()))
                        .right_aligned()
                        .into(),
                    Line::from(ui::span::pretty_path(&copied.new_path, false, false)).into(),
@@ -1266,7 +1267,7 @@ impl<'a> ToRow<3> for HunkItem<'a> {
                .concat();

                [
-
                    Line::from(ui::span::hunk_state(self.inner.state()))
+
                    Line::from(ui::span::hunk_state(self.state()))
                        .right_aligned()
                        .into(),
                    Line::from(ui::span::pretty_path(&moved.new_path, false, false)).into(),
@@ -1280,7 +1281,7 @@ impl<'a> ToRow<3> for HunkItem<'a> {
                new: _,
                _eof: _,
            } => [
-
                Line::from(ui::span::hunk_state(self.inner.state()))
+
                Line::from(ui::span::hunk_state(self.state()))
                    .right_aligned()
                    .into(),
                Line::from(ui::span::pretty_path(path, false, false)).into(),
@@ -1294,7 +1295,7 @@ impl<'a> ToRow<3> for HunkItem<'a> {
                old: _,
                new: _,
            } => [
-
                Line::from(ui::span::hunk_state(self.inner.state()))
+
                Line::from(ui::span::hunk_state(self.state()))
                    .right_aligned()
                    .into(),
                Line::from(ui::span::pretty_path(path, false, false)).into(),
@@ -1321,7 +1322,7 @@ impl<'a> HunkItem<'a> {
            span::blank()
        };

-
        match &self.inner.hunk() {
+
        match &self.diff {
            HunkDiff::Added {
                path,
                header: _,
@@ -1504,7 +1505,7 @@ impl<'a> HunkItem<'a> {
    }

    pub fn hunk_text(&'a self) -> Option<Text<'a>> {
-
        match &self.inner.hunk() {
+
        match &self.diff {
            HunkDiff::Added { hunk, .. }
            | HunkDiff::Modified { hunk, .. }
            | HunkDiff::Deleted { hunk, .. } => {
@@ -1585,12 +1586,33 @@ impl<'a> HunkItem<'a> {
impl<'a> Debug for HunkItem<'a> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("HunkItem")
-
            .field("inner", &self.inner)
+
            .field("inner", &self.diff)
            .field("comments", &self.comments)
            .finish()
    }
}

+
#[derive(Clone)]
+
pub struct StatefulHunkItem<'a>(HunkItem<'a>, HunkState);
+

+
impl<'a> StatefulHunkItem<'a> {
+
    pub fn new(inner: HunkItem<'a>, state: HunkState) -> Self {
+
        Self(inner, state)
+
    }
+

+
    pub fn inner(&self) -> &HunkItem<'a> {
+
        &self.0
+
    }
+

+
    pub fn state(&self) -> &HunkState {
+
        &self.1
+
    }
+

+
    pub fn update_state(&mut self, state: &HunkState) {
+
        self.1 = state.clone();
+
    }
+
}
+

pub struct HighlightedLine<'a>(Line<'a>);

impl<'a> From<Line<'a>> for HighlightedLine<'a> {
modified src/ui/im/widget.rs
@@ -6,6 +6,7 @@ use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, BorderType, Row, Scrollbar, ScrollbarState};
use ratatui::Frame;
use ratatui::{layout::Constraint, widgets::Paragraph};
+
use serde::{Deserialize, Serialize};
use termion::event::Key;

use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
@@ -60,7 +61,7 @@ impl Window {
    }
}

-
#[derive(Clone, Debug)]
+
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PanesState {
    len: usize,
    focus: Option<usize>,
@@ -151,7 +152,7 @@ impl<'a> Panes<'a> {
    }
}

-
#[derive(Clone, Debug)]
+
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CompositeState {
    len: usize,
    focus: usize,
@@ -235,7 +236,7 @@ impl<'a> Widget for Label<'a> {
    }
}

-
#[derive(Clone, Debug)]
+
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TableState {
    internal: ratatui::widgets::TableState,
}
@@ -660,7 +661,7 @@ impl<'a> Widget for Bar<'a> {
    }
}

-
#[derive(Clone, Debug)]
+
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TextViewState {
    cursor: Position,
}
@@ -879,7 +880,7 @@ impl<'a> Widget for CenteredTextView<'a> {
    }
}

-
#[derive(Clone, Debug)]
+
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TextEditState {
    pub text: String,
    pub cursor: usize,