feat: persist local reviews automatically in SQLite
This commit is contained in:
+205
-21
@@ -6,6 +6,7 @@ enum ReviewText {
|
||||
Comment(u64),
|
||||
Summary,
|
||||
Overview,
|
||||
Reviews,
|
||||
}
|
||||
|
||||
pub(super) struct ReviewBuffer {
|
||||
@@ -15,6 +16,109 @@ pub(super) struct ReviewBuffer {
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn set_review_directory(&mut self, directory: PathBuf) {
|
||||
self.review_directory = Some(directory);
|
||||
}
|
||||
|
||||
pub(super) fn initialize_local_review(&mut self) -> Result<(), String> {
|
||||
let Some(directory) = self.review_directory.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(diff) = self
|
||||
.workspace_diff()
|
||||
.filter(|diff| diff.phase == Phase::View)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if diff.local_review.initialized {
|
||||
return Ok(());
|
||||
}
|
||||
let root = diff
|
||||
.comparison
|
||||
.as_ref()
|
||||
.ok_or("Comparison is loading")?
|
||||
.root
|
||||
.clone();
|
||||
let directory = directory.clone();
|
||||
self.review_diff_mut().unwrap().local_review.initialized = true;
|
||||
self.review_diff_mut().unwrap().local_review.document = Review::new(root.clone());
|
||||
let store = crate::review::Store::open(&directory)?;
|
||||
let choices = store.list(&root)?;
|
||||
if choices.len() == 1 {
|
||||
self.review_diff_mut().unwrap().local_review.document =
|
||||
store.load(&root, choices[0].0)?;
|
||||
} else if choices.len() > 1 {
|
||||
self.review_diff_mut().unwrap().local_review.choices = choices;
|
||||
self.open_review_selector()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_review_selector(&mut self) -> Result<(), String> {
|
||||
let diff = self.workspace_diff().ok_or("No comparison is open")?;
|
||||
let directory = self
|
||||
.review_directory
|
||||
.as_deref()
|
||||
.ok_or("Review storage is not configured")?;
|
||||
let root = &diff
|
||||
.comparison
|
||||
.as_ref()
|
||||
.ok_or("Comparison is loading")?
|
||||
.root;
|
||||
let choices = crate::review::Store::open(directory)?.list(root)?;
|
||||
let mut text = String::from(
|
||||
"# Local reviews\n\nEnter opens the review under the caret.\n:diff.review.new starts a separate review.\n",
|
||||
);
|
||||
for (id, label) in &choices {
|
||||
use std::fmt::Write as _;
|
||||
writeln!(text, "\n## Review #{id} · {label}").unwrap();
|
||||
}
|
||||
self.review_diff_mut().unwrap().local_review.choices = choices;
|
||||
let row = text
|
||||
.lines()
|
||||
.position(|line| line.starts_with("## Review #"))
|
||||
.unwrap_or(0);
|
||||
self.open_review_buffer(
|
||||
ReviewText::Reviews,
|
||||
&text,
|
||||
"Local reviews · Enter opens · :diff.review.new".into(),
|
||||
)?;
|
||||
self.place_navigation(TextPosition { row, column: 0 });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_review_saved(&mut self) {
|
||||
self.review_diff_mut().unwrap().local_review.dirty = false;
|
||||
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||||
if buffer
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||
{
|
||||
buffer.saved_lines.clone_from(&buffer.lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn autosave_local_review(&mut self) -> Result<(), String> {
|
||||
self.sync_review_text();
|
||||
let Some(directory) = self.review_directory.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self.current_review_dirty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut store = crate::review::Store::open(directory)?;
|
||||
store.save(
|
||||
&mut self
|
||||
.review_diff_mut()
|
||||
.ok_or("No comparison is open")?
|
||||
.local_review
|
||||
.document,
|
||||
)?;
|
||||
self.mark_review_saved();
|
||||
Ok(())
|
||||
}
|
||||
pub fn review_panel_title(&self) -> Option<&str> {
|
||||
self.active
|
||||
.review_buffer
|
||||
@@ -35,6 +139,9 @@ impl Editor {
|
||||
.origin
|
||||
.expect("review panel has a source pane");
|
||||
self.finish_pane_interaction();
|
||||
if let Err(error) = self.autosave_local_review() {
|
||||
self.set_message(format!("Review not saved: {error}"));
|
||||
}
|
||||
self.select_pane(source);
|
||||
self.bind_diff_buffer();
|
||||
true
|
||||
@@ -71,6 +178,12 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub(super) fn local_review_close_blocked(&mut self) -> bool {
|
||||
if self.review_directory.is_some()
|
||||
&& let Err(error) = self.autosave_local_review()
|
||||
{
|
||||
self.set_message(format!("Review not saved: {error}"));
|
||||
return true;
|
||||
}
|
||||
if self.current_review_dirty() {
|
||||
self.set_message("Unsaved local review; use :diff.review.save <path> before closing");
|
||||
true
|
||||
@@ -97,8 +210,9 @@ impl Editor {
|
||||
.chain(self.parked.values())
|
||||
.filter_map(|buffer| {
|
||||
let binding = buffer.review_buffer.as_ref()?;
|
||||
(binding.owner == owner && binding.kind != ReviewText::Overview)
|
||||
.then(|| (binding.kind, buffer.lines.join("\n")))
|
||||
(binding.owner == owner
|
||||
&& matches!(binding.kind, ReviewText::Comment(_) | ReviewText::Summary))
|
||||
.then(|| (binding.kind, buffer.lines.join("\n")))
|
||||
})
|
||||
.collect();
|
||||
let Some(diff) = self.review_diff_mut() else {
|
||||
@@ -115,6 +229,7 @@ impl Editor {
|
||||
.map(|comment| &mut comment.body),
|
||||
ReviewText::Summary => Some(&mut diff.local_review.document.summary),
|
||||
ReviewText::Overview => None,
|
||||
ReviewText::Reviews => None,
|
||||
};
|
||||
if let Some(destination) = destination
|
||||
&& *destination != text
|
||||
@@ -127,6 +242,23 @@ impl Editor {
|
||||
|
||||
pub(super) fn save_local_review(&mut self, path: Option<&Path>) -> Result<(), String> {
|
||||
self.sync_review_text();
|
||||
if self.review_directory.is_some() {
|
||||
if let Some(path) = path {
|
||||
self.review_diff_mut()
|
||||
.ok_or("No comparison is open")?
|
||||
.local_review
|
||||
.document
|
||||
.save(path)?;
|
||||
self.review_diff_mut().unwrap().local_review.path = Some(path.to_path_buf());
|
||||
}
|
||||
self.autosave_local_review()?;
|
||||
self.set_message(if path.is_some() {
|
||||
"Review exported as JSON; autosave remains in SQLite"
|
||||
} else {
|
||||
"Local review saved to SQLite"
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
let state = &mut self
|
||||
.review_diff_mut()
|
||||
.ok_or("No comparison is open")?
|
||||
@@ -159,6 +291,7 @@ impl Editor {
|
||||
if self.resolver().is_some() {
|
||||
return Err("Local reviews require an ordinary diff comparison".into());
|
||||
}
|
||||
self.initialize_local_review()?;
|
||||
let diff = self
|
||||
.workspace_diff()
|
||||
.filter(|diff| diff.phase == Phase::View)
|
||||
@@ -191,26 +324,49 @@ impl Editor {
|
||||
{
|
||||
self.review_diff_mut().unwrap().local_review.document = Review::new(root.clone());
|
||||
}
|
||||
if !matches!(action, Action::ReviewSave) {
|
||||
self.autosave_local_review()?;
|
||||
}
|
||||
if action == Action::ReviewOpen {
|
||||
if args.is_empty() {
|
||||
return Err("Usage: diff.review.open <path>".into());
|
||||
}
|
||||
if self.current_review_dirty() {
|
||||
return Err("Save the current local review before opening another".into());
|
||||
}
|
||||
let document = Review::load(Path::new(args), &root)?;
|
||||
if args.is_empty() {
|
||||
return self.open_review_selector();
|
||||
}
|
||||
let document = if let (Some(directory), Ok(id)) =
|
||||
(self.review_directory.as_deref(), args.parse::<i64>())
|
||||
{
|
||||
crate::review::Store::open(directory)?.load(&root, id)?
|
||||
} else {
|
||||
Review::load(Path::new(args), &root)?
|
||||
};
|
||||
let imported = document.storage_id.is_none();
|
||||
let dirty = imported && self.review_directory.is_some();
|
||||
self.detach_review_buffers();
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
state.document = document;
|
||||
state.path = Some(PathBuf::from(args));
|
||||
state.path = imported.then(|| PathBuf::from(args));
|
||||
state.selected = None;
|
||||
state.pending_navigation = None;
|
||||
self.open_review_overview()?;
|
||||
return Ok(());
|
||||
state.dirty = dirty;
|
||||
self.autosave_local_review()?;
|
||||
return self.open_review_overview();
|
||||
}
|
||||
self.sync_review_text();
|
||||
match action {
|
||||
Action::DiffComment => self.add_review_comment()?,
|
||||
Action::ReviewNew => {
|
||||
self.detach_review_buffers();
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
state.document = Review::new(root);
|
||||
state.selected = None;
|
||||
state.pending_navigation = None;
|
||||
state.path = None;
|
||||
state.dirty = true;
|
||||
self.autosave_local_review()?;
|
||||
self.open_review_overview()?;
|
||||
}
|
||||
Action::ReviewEdit => {
|
||||
let id = self.review_comment_id(args)?;
|
||||
self.show_review_comment(id)?;
|
||||
@@ -231,6 +387,7 @@ impl Editor {
|
||||
buffer.saved_lines.clone_from(&buffer.lines);
|
||||
}
|
||||
}
|
||||
self.autosave_local_review()?;
|
||||
self.open_review_overview()?;
|
||||
}
|
||||
Action::ReviewNext | Action::ReviewPrevious => {
|
||||
@@ -281,6 +438,7 @@ impl Editor {
|
||||
state.document.verdict = verdict;
|
||||
state.dirty = true;
|
||||
}
|
||||
self.autosave_local_review()?;
|
||||
self.open_review_overview()?;
|
||||
}
|
||||
Action::ReviewSave => {
|
||||
@@ -517,12 +675,24 @@ impl Editor {
|
||||
Some(Verdict::ChangesRequested) => "Changes requested",
|
||||
None => "Not set",
|
||||
};
|
||||
let mut text = format!(
|
||||
"# Local review\n\nVerdict: {verdict}\nFile: {}\n\n## Summary\n{}\n\nEnter edits the comment under the caret.\n:diff.review.summary · :diff.review.verdict · :diff.review.save\n",
|
||||
let storage = if self.review_directory.is_some() {
|
||||
state.document.storage_id.map_or_else(
|
||||
|| "SQLite · uncommitted draft".into(),
|
||||
|id| {
|
||||
format!(
|
||||
"SQLite · review #{id} · revision {}",
|
||||
state.document.storage_revision
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
state
|
||||
.path
|
||||
.as_ref()
|
||||
.map_or_else(|| "not saved".into(), |path| path.display().to_string()),
|
||||
.map_or_else(|| "not saved".into(), |path| path.display().to_string())
|
||||
};
|
||||
let mut text = format!(
|
||||
"# Local review\n\nVerdict: {verdict}\nStorage: {storage}\n\n## Summary\n{}\n\nEnter edits the comment under the caret.\n:diff.review.summary · :diff.review.verdict · :diff.review.save\n",
|
||||
state.document.summary
|
||||
);
|
||||
for comment in &state.document.comments {
|
||||
@@ -604,7 +774,7 @@ impl Editor {
|
||||
self.select_pane(panel);
|
||||
if let Some(id) = existing {
|
||||
self.switch_buffer(id);
|
||||
if kind == ReviewText::Overview {
|
||||
if matches!(kind, ReviewText::Overview | ReviewText::Reviews) {
|
||||
self.active.lines = split_lines(text);
|
||||
self.active.saved_lines.clone_from(&self.active.lines);
|
||||
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||
@@ -618,10 +788,14 @@ impl Editor {
|
||||
kind,
|
||||
title,
|
||||
});
|
||||
buffer.resolver_readonly = kind == ReviewText::Overview;
|
||||
buffer.resolver_readonly = matches!(kind, ReviewText::Overview | ReviewText::Reviews);
|
||||
self.add_buffer(buffer);
|
||||
}
|
||||
self.set_message("Local review · :w saves JSON; first choose :diff.review.save <path>");
|
||||
self.set_message(if self.review_directory.is_some() {
|
||||
"Local review · dismiss to commit · :w saves · :diff.review.save path exports JSON"
|
||||
} else {
|
||||
"Local review · :w saves JSON; first choose :diff.review.save <path>"
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -638,13 +812,23 @@ impl Editor {
|
||||
if *input == Input::Enter
|
||||
&& self.active.view.mode == Mode::Normal
|
||||
&& self.palette.is_none()
|
||||
&& self
|
||||
.active
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.kind == ReviewText::Overview)
|
||||
&& self.active.review_buffer.as_ref().is_some_and(|binding| {
|
||||
matches!(binding.kind, ReviewText::Overview | ReviewText::Reviews)
|
||||
})
|
||||
{
|
||||
if let Err(error) = self.local_review_command(Action::ReviewEdit, "") {
|
||||
let choice = if self.active.review_buffer.as_ref().unwrap().kind == ReviewText::Reviews
|
||||
{
|
||||
self.active.lines[..=self.cursor().row]
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|line| line.strip_prefix("## Review #")?.split_whitespace().next())
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| "Place the caret on a review".to_string())
|
||||
.and_then(|id| self.local_review_command(Action::ReviewOpen, &id))
|
||||
} else {
|
||||
self.local_review_command(Action::ReviewEdit, "")
|
||||
};
|
||||
if let Err(error) = choice {
|
||||
self.set_message(error);
|
||||
}
|
||||
true
|
||||
|
||||
Reference in New Issue
Block a user