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
+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,