feat: persist local reviews automatically in SQLite

This commit is contained in:
Valdemar Lorenzen
2026-10-16 15:15:19 +02:00
parent ef1c377167
commit b558f82094
15 changed files with 634 additions and 48 deletions
+253
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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,
+11
View File
@@ -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,
+5
View File
@@ -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