feat: persist local reviews automatically in SQLite
This commit is contained in:
+253
@@ -990,6 +990,12 @@ core_actions![
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.new",
|
||||
Action::ReviewNew,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.word_wrap.toggle",
|
||||
Action::WordWrapToggle,
|
||||
@@ -2072,6 +2078,7 @@ pub struct Editor {
|
||||
render_pane_focused: bool,
|
||||
diff_owner: Option<usize>,
|
||||
parked_diff: Option<DiffState>,
|
||||
review_directory: Option<PathBuf>,
|
||||
diff_pane_bounds: [(Focus, PaneRect); 3],
|
||||
// Closed destinations stay until bounded eviction so they cannot shift jump_index.
|
||||
jumps: VecDeque<JumpLocation>,
|
||||
@@ -2178,6 +2185,7 @@ impl Editor {
|
||||
render_pane_focused: true,
|
||||
diff_owner: None,
|
||||
parked_diff: None,
|
||||
review_directory: None,
|
||||
diff_pane_bounds: [(
|
||||
Focus::Files,
|
||||
PaneRect {
|
||||
@@ -4502,6 +4510,9 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
self.finish_review_navigation();
|
||||
if let Err(error) = self.initialize_local_review() {
|
||||
self.set_message(format!("Local review: {error}"));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -5250,6 +5261,7 @@ impl Editor {
|
||||
| Action::ReviewVerdict
|
||||
| Action::ReviewSave
|
||||
| Action::ReviewOpen
|
||||
| Action::ReviewNew
|
||||
| Action::WordWrapToggle
|
||||
| Action::GitBlameToggle
|
||||
| Action::Quit
|
||||
@@ -8448,6 +8460,7 @@ impl Editor {
|
||||
| Action::ReviewVerdict
|
||||
| Action::ReviewSave
|
||||
| Action::ReviewOpen
|
||||
| Action::ReviewNew
|
||||
| Action::WordWrapToggle
|
||||
| Action::GitBlameToggle
|
||||
| Action::LspDefinition
|
||||
@@ -9361,6 +9374,7 @@ impl Editor {
|
||||
| Action::ReviewVerdict
|
||||
| Action::ReviewSave
|
||||
| Action::ReviewOpen
|
||||
| Action::ReviewNew
|
||||
) {
|
||||
self.finish_prompt();
|
||||
if let Err(error) = self.local_review_command(entry.action, args) {
|
||||
@@ -9368,6 +9382,21 @@ impl Editor {
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if self.review_directory.is_some()
|
||||
&& matches!(
|
||||
entry.action,
|
||||
Action::TabPrevious
|
||||
| Action::TabNext
|
||||
| Action::TabNew
|
||||
| Action::TabClose
|
||||
| Action::Quit
|
||||
| Action::Restart
|
||||
)
|
||||
&& let Err(error) = self.autosave_local_review()
|
||||
{
|
||||
self.set_message(format!("Review not saved: {error}"));
|
||||
return None;
|
||||
}
|
||||
if self.review_panel().is_some()
|
||||
&& matches!(
|
||||
entry.action,
|
||||
@@ -15911,11 +15940,235 @@ mod tests {
|
||||
}],
|
||||
})),
|
||||
);
|
||||
if editor.review_panel().is_some() {
|
||||
return;
|
||||
}
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||
assert!(editor.diff_interacting());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() {
|
||||
use crate::review::{Review, Store, Verdict};
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let storage = root.join("data");
|
||||
let mut first = editor();
|
||||
first.set_review_directory(storage.clone());
|
||||
historical_diff(
|
||||
&mut first,
|
||||
&root,
|
||||
vec!["p界e\u{301}z".into()],
|
||||
vec!["changed".into()],
|
||||
);
|
||||
type_text(&mut first, "lvl:diff.comment");
|
||||
first.handle(Input::Enter);
|
||||
type_text(&mut first, "iFirst feedback");
|
||||
first.handle(Input::Escape);
|
||||
assert!(
|
||||
Store::open(&storage)
|
||||
.unwrap()
|
||||
.list(&root)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
first.handle(Input::Escape);
|
||||
let id = first
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_id
|
||||
.unwrap();
|
||||
let store = Store::open(&storage).unwrap();
|
||||
let persisted = store.load(&root, id).unwrap();
|
||||
assert_eq!(persisted.comments[0].body, "First feedback");
|
||||
assert_eq!(
|
||||
persisted.comments[0].anchor.start,
|
||||
TextPosition { row: 0, column: 1 }
|
||||
);
|
||||
assert_eq!(
|
||||
persisted.comments[0].anchor.end,
|
||||
TextPosition { row: 0, column: 3 }
|
||||
);
|
||||
first.execute("diff.review.summary");
|
||||
type_text(&mut first, "iSummary");
|
||||
first.handle(Input::Escape);
|
||||
first.execute("diff.review.verdict changes_requested");
|
||||
let persisted = store.load(&root, id).unwrap();
|
||||
assert_eq!(persisted.summary, "Summary");
|
||||
assert_eq!(persisted.verdict, Some(Verdict::ChangesRequested));
|
||||
let exported = root.join("shared.json");
|
||||
first.execute(&format!("diff.review.save {}", exported.display()));
|
||||
first.execute("diff.review.edit 1");
|
||||
type_text(&mut first, "A revised");
|
||||
first.handle(Input::Escape);
|
||||
first.handle(Input::Escape);
|
||||
assert_eq!(
|
||||
store.load(&root, id).unwrap().comments[0].body,
|
||||
"First feedback revised"
|
||||
);
|
||||
assert_eq!(
|
||||
Review::load(&exported, &root).unwrap().comments[0].body,
|
||||
"First feedback"
|
||||
);
|
||||
let mut reopened = editor();
|
||||
reopened.set_review_directory(storage.clone());
|
||||
historical_diff(
|
||||
&mut reopened,
|
||||
&root,
|
||||
vec!["different revision".into()],
|
||||
vec!["changed".into()],
|
||||
);
|
||||
let restored = &reopened.workspace_diff().unwrap().local_review.document;
|
||||
assert_eq!(restored.storage_id, Some(id));
|
||||
assert_eq!(restored.comments[0].body, "First feedback revised");
|
||||
assert!(
|
||||
!reopened
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.review_anchor_current(&restored.comments[0].anchor)
|
||||
);
|
||||
reopened.execute("diff.review.new");
|
||||
let second_id = reopened
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_id
|
||||
.unwrap();
|
||||
assert_ne!(id, second_id);
|
||||
let mut choosing = editor();
|
||||
choosing.set_review_directory(storage);
|
||||
historical_diff(&mut choosing, &root, vec!["old".into()], vec!["new".into()]);
|
||||
assert_eq!(
|
||||
choosing
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.choices
|
||||
.iter()
|
||||
.map(|(id, _)| *id)
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
std::collections::BTreeSet::from([id, second_id])
|
||||
);
|
||||
choosing.handle(Input::Enter);
|
||||
assert_eq!(
|
||||
choosing
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_id,
|
||||
Some(second_id)
|
||||
);
|
||||
choosing.execute(&format!("diff.review.open {id}"));
|
||||
assert_eq!(
|
||||
choosing
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_id,
|
||||
Some(id)
|
||||
);
|
||||
choosing.execute("diff.review.delete 1");
|
||||
assert!(store.load(&root, id).unwrap().comments.is_empty());
|
||||
choosing.execute(&format!("diff.review.open {}", exported.display()));
|
||||
let imported_id = choosing
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_id
|
||||
.unwrap();
|
||||
assert_ne!(imported_id, id);
|
||||
assert_eq!(
|
||||
store.load(&root, imported_id).unwrap().comments[0].body,
|
||||
"First feedback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_review_conflicts_and_write_failures_keep_dirty_drafts() {
|
||||
use crate::review::Store;
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let storage = root.join("data");
|
||||
let mut first = editor();
|
||||
first.set_review_directory(storage.clone());
|
||||
historical_diff(&mut first, &root, vec!["old".into()], vec!["new".into()]);
|
||||
first.execute("diff.comment");
|
||||
type_text(&mut first, "iStored feedback");
|
||||
first.handle(Input::Escape);
|
||||
first.handle(Input::Escape);
|
||||
let id = first
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_id
|
||||
.unwrap();
|
||||
let mut stale = editor();
|
||||
stale.set_review_directory(storage.clone());
|
||||
historical_diff(&mut stale, &root, vec!["old".into()], vec!["new".into()]);
|
||||
let version = stale
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_revision;
|
||||
first.execute("diff.review.verdict looks_good");
|
||||
stale.execute("diff.review.verdict changes_requested");
|
||||
assert!(stale.current_review_dirty());
|
||||
assert_eq!(
|
||||
stale
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_revision,
|
||||
version
|
||||
);
|
||||
assert_eq!(
|
||||
Store::open(&storage)
|
||||
.unwrap()
|
||||
.load(&root, id)
|
||||
.unwrap()
|
||||
.verdict,
|
||||
Some(crate::review::Verdict::LooksGood)
|
||||
);
|
||||
let blocked = root.join("not-a-directory");
|
||||
fs::write(&blocked, "preserve").unwrap();
|
||||
first.set_review_directory(blocked.clone());
|
||||
first.execute("diff.review.edit 1");
|
||||
type_text(&mut first, "A unsaved");
|
||||
first.handle(Input::Escape);
|
||||
first.handle(Input::Escape);
|
||||
assert!(first.current_review_dirty());
|
||||
assert_eq!(
|
||||
first
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments[0]
|
||||
.body,
|
||||
"Stored feedback unsaved"
|
||||
);
|
||||
assert_eq!(fs::read_to_string(blocked).unwrap(), "preserve");
|
||||
assert_eq!(
|
||||
Store::open(&storage)
|
||||
.unwrap()
|
||||
.load(&root, id)
|
||||
.unwrap()
|
||||
.comments[0]
|
||||
.body,
|
||||
"Stored feedback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_review_native_composition_roundtrip_and_draft_lifecycle() {
|
||||
use crate::review::{Review, Side, Verdict};
|
||||
|
||||
+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
|
||||
|
||||
+3
-2
@@ -87,8 +87,9 @@ Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the
|
||||
Selected comments highlight their precise text
|
||||
:diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview
|
||||
:diff.review.summary; :diff.review.verdict looks_good|changes_requested|none
|
||||
:diff.review.save PATH / :diff.review.open PATH; :w in floating review editors saves local JSON
|
||||
Normal Esc or :pane.close dismisses the floating editor and retains drafts
|
||||
Local SQLite autosave on dismissal/switch; verdicts/deletions commit immediately; :w commits explicitly
|
||||
:diff.review.save PATH exports JSON; :diff.review.open [ID|PATH] selects/imports; :diff.review.new
|
||||
Normal Esc or :pane.close commits and dismisses the floating editor; failures retain drafts
|
||||
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
|
||||
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
|
||||
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
|
||||
|
||||
@@ -114,6 +114,8 @@ impl Default for Review {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ReviewState {
|
||||
pub document: Review,
|
||||
pub initialized: bool,
|
||||
pub choices: Vec<(i64, String)>,
|
||||
pub path: Option<PathBuf>,
|
||||
pub selected: Option<u64>,
|
||||
pub pending_navigation: Option<u64>,
|
||||
|
||||
+111
-4
@@ -33,6 +33,7 @@ const INVOKE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
pub struct Config {
|
||||
pub plugins: Vec<PluginConfig>,
|
||||
pub lsp: Vec<crate::lsp::ServerConfig>,
|
||||
pub review_directory: Option<PathBuf>,
|
||||
pub bindings: Vec<Binding>,
|
||||
pub leader: String,
|
||||
pub file_explorer_side: String,
|
||||
@@ -85,6 +86,7 @@ impl Default for Config {
|
||||
Self {
|
||||
plugins: Vec::new(),
|
||||
lsp: Vec::new(),
|
||||
review_directory: None,
|
||||
bindings: Vec::new(),
|
||||
leader: "space".into(),
|
||||
file_explorer_side: "right".into(),
|
||||
@@ -113,7 +115,7 @@ impl Default for Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative plugin paths are resolved against the configuration file's directory.
|
||||
/// Relative plugin and review paths are resolved against the configuration file's directory.
|
||||
pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
||||
let path = absolute_file(path)?;
|
||||
@@ -124,6 +126,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
let ConfigFrame {
|
||||
plugins,
|
||||
lsp,
|
||||
review_directory,
|
||||
bindings,
|
||||
leader,
|
||||
file_explorer_side,
|
||||
@@ -152,6 +155,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
} = *response;
|
||||
let mut config = Config::default();
|
||||
let directory = path.parent().expect("absolute file has a parent");
|
||||
config.review_directory = review_directory.map(|path| directory.join(path));
|
||||
for plugin in plugins {
|
||||
let plugin_path = plugin.path.to_string_lossy();
|
||||
if plugin_path.is_empty() || plugin_path.contains('\0') {
|
||||
@@ -490,6 +494,21 @@ fn scroll_distance<'de, D: serde::Deserializer<'de>>(
|
||||
})
|
||||
}
|
||||
|
||||
fn review_directory<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<PathBuf>, D::Error> {
|
||||
let directory = Option::<String>::deserialize(deserializer)?;
|
||||
if directory
|
||||
.as_ref()
|
||||
.is_some_and(|path| path.is_empty() || path.contains('\0'))
|
||||
{
|
||||
return Err(serde::de::Error::custom(
|
||||
"review_directory must not be empty or contain NUL",
|
||||
));
|
||||
}
|
||||
Ok(directory.map(PathBuf::from))
|
||||
}
|
||||
|
||||
// Startup-only configuration does not inflate every queued plugin response.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -499,6 +518,8 @@ struct ConfigFrame {
|
||||
// Keep this wire field required; resolve its configuration after decoding.
|
||||
#[serde(deserialize_with = "serde_json::Value::deserialize")]
|
||||
lsp: serde_json::Value,
|
||||
#[serde(deserialize_with = "review_directory")]
|
||||
review_directory: Option<PathBuf>,
|
||||
bindings: Vec<Binding>,
|
||||
leader: String,
|
||||
file_explorer_side: String,
|
||||
@@ -1003,6 +1024,87 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_directory_resolves_config_paths_and_keeps_session_startup_lazy() {
|
||||
let fixture = Fixture::new();
|
||||
let absolute = fixture.0.join("absolute-reviews");
|
||||
for (value, expected) in [
|
||||
(
|
||||
r#""relative-reviews""#.to_owned(),
|
||||
Some(fixture.0.join("relative-reviews")),
|
||||
),
|
||||
(serde_json::to_string(&absolute).unwrap(), Some(absolute)),
|
||||
("nil".to_owned(), None),
|
||||
] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| config.review_directory = {value} }}"),
|
||||
);
|
||||
assert_eq!(load_config(&path).unwrap().review_directory, expected);
|
||||
let session = EditorSession::open(None, vec![], Some(&path)).unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
if let Some(directory) = expected {
|
||||
assert!(
|
||||
!directory.exists(),
|
||||
"startup created {}",
|
||||
directory.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
|
||||
assert_eq!(load_config(&path).unwrap().review_directory, None);
|
||||
for value in [
|
||||
"7",
|
||||
"false",
|
||||
"[]",
|
||||
"{}",
|
||||
":reviews",
|
||||
r#""""#,
|
||||
r#""bad\0path""#,
|
||||
] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| config.review_directory = {value} }}"),
|
||||
);
|
||||
let error = load_config(&path).unwrap_err();
|
||||
assert!(error.contains("review_directory"), "{value}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_directory_wire_field_is_required_nullable_and_validated() {
|
||||
let mut frame = serde_json::json!({
|
||||
"version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [],
|
||||
"review_directory": null, "leader": "space",
|
||||
"file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree",
|
||||
"clipboard_mode": "system", "git_blame": true, "git_gutter": "bar",
|
||||
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, "labels": {},
|
||||
"scroll_distance": 0, "scroll_step_lines": null, "highlight_trailing_whitespace": false,
|
||||
"whitespace_characters": {}, "whitespace_scope": "all",
|
||||
"tab_spaces": null, "language_tab_spaces": {},
|
||||
"diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
"diff_follow_working_copy": true
|
||||
});
|
||||
for value in [serde_json::Value::Null, serde_json::json!("reviews")] {
|
||||
frame["review_directory"] = value;
|
||||
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
|
||||
}
|
||||
for value in [
|
||||
serde_json::json!(""),
|
||||
serde_json::json!("bad\0path"),
|
||||
serde_json::json!(7),
|
||||
serde_json::json!(false),
|
||||
serde_json::json!([]),
|
||||
serde_json::json!({}),
|
||||
] {
|
||||
frame["review_directory"] = value;
|
||||
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
||||
}
|
||||
frame.as_object_mut().unwrap().remove("review_directory");
|
||||
assert!(serde_json::from_value::<Response>(frame).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -1410,9 +1512,9 @@ end
|
||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||
}
|
||||
for frame in [
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||
}
|
||||
@@ -1485,6 +1587,7 @@ end
|
||||
}
|
||||
let mut frame = serde_json::json!({
|
||||
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
|
||||
"review_directory": null,
|
||||
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
|
||||
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
|
||||
"git_gutter": "bar",
|
||||
@@ -1627,6 +1730,7 @@ end"#,
|
||||
}
|
||||
let mut frame = serde_json::json!({
|
||||
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
|
||||
"review_directory": null,
|
||||
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
|
||||
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
|
||||
"git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false,
|
||||
@@ -1886,6 +1990,7 @@ end
|
||||
}
|
||||
let frame = serde_json::json!({
|
||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||
"review_directory": null,
|
||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||
@@ -1997,6 +2102,7 @@ end"
|
||||
let fixture = Fixture::new();
|
||||
let frame = serde_json::json!({
|
||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||
"review_directory": null,
|
||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||
"clipboard_mode": "system", "git_blame": true, "git_gutter": "bar",
|
||||
@@ -2427,6 +2533,7 @@ end"#,
|
||||
fn lsp_wire_configuration_is_resolved_after_decoding() {
|
||||
let mut frame = serde_json::json!({
|
||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||
"review_directory": null,
|
||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||
"git_blame": true,
|
||||
|
||||
@@ -133,6 +133,7 @@ module Vamp
|
||||
def initialize
|
||||
@plugins = []
|
||||
@lsp = {}
|
||||
@review_directory = nil
|
||||
@bindings = []
|
||||
@leader = "space"
|
||||
@file_explorer_side = "right"
|
||||
@@ -170,6 +171,15 @@ module Vamp
|
||||
nil
|
||||
end
|
||||
|
||||
# @param path [String, nil] relative to the configuration file; nil uses the XDG data directory
|
||||
# @return [String, nil]
|
||||
def review_directory=(path)
|
||||
path = nonempty_string!(path, "review_directory") unless path.nil?
|
||||
raise ArgumentError, "review_directory must not contain NUL" if path&.include?("\0")
|
||||
|
||||
@review_directory = path
|
||||
end
|
||||
|
||||
# @param key [String]
|
||||
# @return [String]
|
||||
def leader=(key)
|
||||
@@ -345,6 +355,7 @@ module Vamp
|
||||
# @return [Hash{String => Object}] validated startup configuration frame
|
||||
def response
|
||||
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
|
||||
"review_directory" => @review_directory,
|
||||
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
|
||||
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
||||
"git_blame" => @git_blame,
|
||||
|
||||
@@ -482,6 +482,10 @@ impl EditorSession {
|
||||
}),
|
||||
None => Config::default(),
|
||||
};
|
||||
editor.set_review_directory(match config.review_directory {
|
||||
Some(directory) => directory,
|
||||
None => crate::review::default_directory().map_err(io::Error::other)?,
|
||||
});
|
||||
if let Err(error) = editor.set_leader(&config.leader) {
|
||||
notices.push(format!("leader {:?}: {error}", config.leader));
|
||||
}
|
||||
@@ -2664,6 +2668,7 @@ mod tests {
|
||||
Some(&config),
|
||||
)
|
||||
.unwrap();
|
||||
session.editor.set_review_directory(self.0.join("reviews"));
|
||||
// Legacy editing scenarios exercise the internal register, never the user's desktop.
|
||||
session.editor.set_clipboard_mode("separate").unwrap();
|
||||
session
|
||||
|
||||
Reference in New Issue
Block a user