feat: persist diff file review checkmarks

This commit is contained in:
2026-10-19 18:48:44 +02:00
parent fe039e75c8
commit ea04e30072
11 changed files with 655 additions and 61 deletions
+118 -16
View File
@@ -587,6 +587,8 @@ pub struct DiffState {
pub live_revision: Option<u64>,
pub follow_caret: bool,
live_files: BTreeSet<usize>,
/// Files edited since their frozen comparison was loaded; undo does not restore review marks.
modified_after: BTreeSet<usize>,
frozen_after: BTreeMap<usize, (Vec<String>, bool)>,
context_lines: usize,
expand_lines: usize,
@@ -648,6 +650,7 @@ impl DiffState {
live_revision: None,
follow_caret: false,
live_files: BTreeSet::new(),
modified_after: BTreeSet::new(),
frozen_after: BTreeMap::new(),
context_lines: 10,
expand_lines: 15,
@@ -1005,6 +1008,7 @@ impl DiffState {
self.snapshot_roots = [None, None];
self.frozen_after.clear();
self.live_files.clear();
self.modified_after.clear();
self.live_buffer = None;
self.live_revision = None;
self.refresh_view = None;
@@ -1082,6 +1086,7 @@ impl DiffState {
}
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
self.live_files.clear();
self.modified_after.clear();
self.frozen_after.clear();
self.refresh_view = None;
self.reviewed_files.clear();
@@ -1777,14 +1782,82 @@ impl DiffState {
} else {
self.file
};
if comparison.files.get(index).is_none() {
return Err("No changed file is selected");
}
if self.reviewed_files.remove(&index) {
Ok(false)
} else {
let file = comparison
.files
.get(index)
.ok_or("No changed file is selected")?;
let reviewed = !self.reviewed_files.remove(&index);
if reviewed {
self.reviewed_files.insert(index);
Ok(true)
}
// Resolver comparisons have no immutable revision identity or local review.
if comparison
.revisions
.iter()
.all(|revision| !revision.is_empty())
{
if self.local_review.document.root.as_os_str().is_empty() {
self.local_review.document = crate::review::Review::new(comparison.root.clone());
}
let records = &mut self.local_review.document.reviewed_files;
records.retain(|record| {
record.revisions != comparison.revisions || record.path != file.path
});
if reviewed {
records.push(crate::review::ReviewedFile {
revisions: comparison.revisions.clone(),
path: file.path.clone(),
old_path: file.old_path.clone(),
after: self
.live_files
.contains(&index)
.then(|| file.after.join("\n")),
});
}
self.local_review.dirty = true;
}
Ok(reviewed)
}
pub(crate) fn restore_reviewed_files(&mut self) {
self.reviewed_files.clear();
let Some(comparison) = self.comparison.as_ref() else {
return;
};
if self.local_review.document.root != comparison.root {
return;
}
let records: BTreeMap<_, _> = self
.local_review
.document
.reviewed_files
.iter()
.filter(|record| record.revisions == comparison.revisions)
.map(|record| (&record.path, record))
.collect();
for (index, file) in comparison.files.iter().enumerate() {
let Some(record) = records
.get(&file.path)
.filter(|record| record.old_path == file.old_path)
else {
continue;
};
let matches = match &record.after {
None => !self.modified_after.contains(&index),
Some(text) => {
file.loaded
&& (text
.split('\n')
.eq(file.after.iter().map(String::as_str).chain(
((!self.live_files.contains(&index) && file.after_eof)
|| file.after.is_empty())
.then_some(""),
)))
}
};
if matches {
self.reviewed_files.insert(index);
}
}
}
@@ -2173,15 +2246,44 @@ impl DiffState {
}
let was_live = self.live_files.contains(&self.file);
let changed_lines = changed_line_range(&file.after, lines);
if self.reviewed_files.contains(&self.file) {
let unchanged = if was_live {
changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2
} else {
let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
file.after_eof == eof && file.after == live_content(lines, eof)
};
if !unchanged {
self.reviewed_files.remove(&self.file);
let unchanged = if was_live {
changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2
} else {
let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
file.after_eof == eof && file.after == live_content(lines, eof)
};
if !unchanged {
self.modified_after.insert(self.file);
}
let comparison = self.comparison.as_ref().unwrap();
let record = (self.local_review.document.root == comparison.root)
.then(|| {
self.local_review
.document
.reviewed_files
.iter()
.position(|record| {
record.revisions == comparison.revisions
&& record.path == file.path
&& record.old_path == file.old_path
})
})
.flatten();
let matches = record
.and_then(|position| {
self.local_review.document.reviewed_files[position]
.after
.as_ref()
})
.map(|text| text.split('\n').eq(lines.iter().map(String::as_str)));
if matches == Some(true) {
self.reviewed_files.insert(self.file);
}
if self.reviewed_files.contains(&self.file) && !matches.unwrap_or(unchanged) {
self.reviewed_files.remove(&self.file);
if let Some(position) = record {
self.local_review.document.reviewed_files.remove(position);
self.local_review.dirty = true;
}
}
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
+29 -5
View File
@@ -4579,6 +4579,13 @@ impl Editor {
}
let refresh = diff.refresh_pending();
let previous_comparison = diff.comparison_id;
let restore_reviewed = matches!(
&result,
Ok(Outcome::Comparison(_)
| Outcome::File { .. }
| Outcome::CheckoutRefresh(Some(_))
| Outcome::BookmarkRefresh(_))
);
if !diff.apply_result(id, result) {
return false;
}
@@ -4601,6 +4608,12 @@ impl Editor {
view.clamp(&self.active.lines);
self.active.view = view;
}
if let Err(error) = self.initialize_local_review() {
self.set_message(format!("Local review: {error}"));
}
if restore_reviewed && let Some(diff) = &mut self.diff {
diff.restore_reviewed_files();
}
let reviewed = self.diff.as_mut().and_then(|diff| {
(refresh
&& diff
@@ -4657,9 +4670,6 @@ impl Editor {
}
}
self.finish_review_navigation();
if let Err(error) = self.initialize_local_review() {
self.set_message(format!("Local review: {error}"));
}
true
}
@@ -5177,6 +5187,9 @@ impl Editor {
diff.follow_caret = true;
}
}
if let Err(error) = self.autosave_local_review() {
self.set_message(format!("Review not saved: {error}"));
}
let Some(focus) = self.diff_buffer_focus() else {
return;
};
@@ -10027,8 +10040,19 @@ impl Editor {
};
if entry.action == Action::DiffReviewToggle {
match diff.toggle_reviewed() {
Ok(true) => self.set_message("File marked reviewed"),
Ok(false) => self.set_message("File marked unreviewed"),
Ok(reviewed) => {
if self.resolver().is_none()
&& let Err(error) = self.autosave_local_review()
{
self.set_message(format!("Review not saved: {error}"));
return None;
}
self.set_message(if reviewed {
"File marked reviewed"
} else {
"File marked unreviewed"
});
}
Err(error) => self.set_message(error),
}
return None;
+1
View File
@@ -264,6 +264,7 @@ impl Editor {
state.selected = None;
state.pending_navigation = None;
state.dirty = dirty;
self.review_diff_mut().unwrap().restore_reviewed_files();
// Persistence failure leaves the identity change applied and unsaved.
self.autosave_local_review()?;
self.open_review_overview()
+134 -16
View File
@@ -86,6 +86,15 @@ pub struct Comment {
pub resolved: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewedFile {
pub revisions: [String; 2],
pub path: PathBuf,
pub old_path: Option<PathBuf>,
pub after: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Review {
@@ -94,6 +103,7 @@ pub struct Review {
pub summary: String,
pub verdict: Option<Verdict>,
pub comments: Vec<Comment>,
pub reviewed_files: Vec<ReviewedFile>,
#[serde(skip)]
pub storage_id: Option<i64>,
#[serde(skip)]
@@ -126,11 +136,12 @@ impl Review {
fs::canonicalize(&root).unwrap_or(root)
};
Self {
version: 3,
version: 4,
root,
summary: String::new(),
verdict: None,
comments: Vec::new(),
reviewed_files: Vec::new(),
storage_id: None,
storage_revision: 0,
saved: RefCell::new(None),
@@ -138,7 +149,7 @@ impl Review {
}
fn validate(&self) -> Result<(), String> {
if self.version != 3 {
if self.version != 4 {
return Err(format!("Unsupported review version {}", self.version));
}
if !self.root.is_absolute() || !self.root.is_dir() {
@@ -150,10 +161,7 @@ impl Review {
return Err("Review comment IDs must be unique and nonzero".into());
}
let anchor = &comment.anchor;
crate::repository::safe_path(&anchor.path)?;
if anchor.path.to_str().is_none_or(|path| path.contains('\0')) {
return Err("Review paths must be UTF-8 without NUL bytes".into());
}
validate_path(&anchor.path)?;
if anchor.revision.is_empty() || anchor.revision.chars().any(char::is_control) {
return Err("Review anchors require an immutable revision identity".into());
}
@@ -181,6 +189,23 @@ impl Review {
return Err("Review rows must be within the anchor text".into());
}
}
let mut files = BTreeSet::new();
for file in &self.reviewed_files {
validate_path(&file.path)?;
if let Some(path) = &file.old_path {
validate_path(path)?;
}
if file
.revisions
.iter()
.any(|revision| revision.is_empty() || revision.chars().any(char::is_control))
{
return Err("Reviewed files require immutable revision identities".into());
}
if !files.insert((&file.revisions, &file.path)) {
return Err("Reviewed files must be unique by revision pair and path".into());
}
}
Ok(())
}
@@ -191,7 +216,7 @@ impl Review {
}
let header: Header = serde_json::from_slice(bytes)
.map_err(|error| format!("Invalid review JSON: {error}"))?;
if header.version != 3 {
if header.version != 4 {
return Err(format!("Unsupported review version {}", header.version));
}
serde_json::from_slice(bytes).map_err(|error| format!("Invalid review JSON: {error}"))
@@ -264,6 +289,14 @@ impl Review {
}
}
fn validate_path(path: &Path) -> Result<(), String> {
crate::repository::safe_path(path)?;
if path.to_str().is_none_or(|path| path.contains('\0')) {
return Err("Review paths must be UTF-8 without NUL bytes".into());
}
Ok(())
}
pub fn default_directory() -> Result<PathBuf, String> {
directory_from(
std::env::var_os("XDG_DATA_HOME").as_deref(),
@@ -313,6 +346,18 @@ const COMMENTS_SCHEMA: &str = "CREATE TABLE comments (
CHECK (end_row > start_row OR (end_row = start_row AND end_column >= start_column))
) STRICT";
const REVIEWED_FILES_SCHEMA: &str = "CREATE TABLE reviewed_files (
review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
position INTEGER NOT NULL CHECK (position >= 0),
before_revision TEXT NOT NULL CHECK (length(before_revision) > 0),
after_revision TEXT NOT NULL CHECK (length(after_revision) > 0),
path TEXT NOT NULL,
old_path TEXT,
after_content TEXT,
PRIMARY KEY (review_id, before_revision, after_revision, path),
UNIQUE (review_id, position)
) STRICT";
pub struct Store {
connection: Connection,
}
@@ -357,7 +402,7 @@ impl Store {
.map_err(database_error)?
.collect::<Result<_, _>>()
.map_err(database_error)?;
if version != 2 || schema != [COMMENTS_SCHEMA, REVIEWS_SCHEMA] {
if version != 3 || schema != [COMMENTS_SCHEMA, REVIEWED_FILES_SCHEMA, REVIEWS_SCHEMA] {
return Err(format!(
"Unsupported review database schema (version {version}); existing data was not changed"
));
@@ -422,7 +467,10 @@ impl Store {
.execute_batch(COMMENTS_SCHEMA)
.map_err(database_error)?;
transaction
.pragma_update(None, "user_version", 2)
.execute_batch(REVIEWED_FILES_SCHEMA)
.map_err(database_error)?;
transaction
.pragma_update(None, "user_version", 3)
.map_err(database_error)?;
transaction.commit().map_err(database_error)?;
} else {
@@ -518,6 +566,21 @@ impl Store {
)
.ok_or("Review exceeds the 64 MiB limit")?;
}
for file in &review.reviewed_files {
size = size
.checked_add(
file.revisions[0].len()
+ file.revisions[1].len()
+ file.path.as_os_str().len()
+ file
.old_path
.as_ref()
.map_or(0, |path| path.as_os_str().len())
+ file.after.as_ref().map_or(0, String::len)
+ 64,
)
.ok_or("Review exceeds the 64 MiB limit")?;
}
if size > LIMIT {
return Err("Review exceeds the 64 MiB limit".into());
}
@@ -550,7 +613,7 @@ impl Store {
.map_err(database_error)?;
transaction.last_insert_rowid()
};
// ponytail: replace comment rows per save; diff rows if snapshot writes become costly.
// ponytail: replace child rows per save; diff rows if snapshot writes become costly.
transaction
.execute("DELETE FROM comments WHERE review_id = ?1", [id])
.map_err(database_error)?;
@@ -584,6 +647,31 @@ impl Store {
.map_err(database_error)?;
}
}
transaction
.execute("DELETE FROM reviewed_files WHERE review_id = ?1", [id])
.map_err(database_error)?;
{
let mut insert = transaction
.prepare(
"INSERT INTO reviewed_files (review_id, position, before_revision,
after_revision, path, old_path, after_content)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)
.map_err(database_error)?;
for (position, file) in review.reviewed_files.iter().enumerate() {
insert
.execute(params![
id,
sql_index(position)?,
file.revisions[0],
file.revisions[1],
file.path.to_str().unwrap(),
file.old_path.as_ref().map(|path| path.to_str().unwrap()),
file.after,
])
.map_err(database_error)?;
}
}
transaction.commit().map_err(database_error)?;
review.storage_id = Some(id);
review.storage_revision = revision;
@@ -624,17 +712,23 @@ fn stored_review(
.map_err(database_error)?
.as_str()
.map_err(|error| error.to_string())?;
let comment_bytes: i64 = connection
let stored_bytes: i64 = connection
.query_row(
"SELECT COALESCE(SUM(length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB))
+ length(CAST(path AS BLOB)) + length(CAST(content AS BLOB)) + 64), 0)
FROM comments WHERE review_id = ?1",
"SELECT COALESCE(SUM(size), 0) FROM (
SELECT length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB))
+ length(CAST(path AS BLOB)) + length(CAST(content AS BLOB)) + 64 AS size
FROM comments WHERE review_id = ?1
UNION ALL
SELECT length(CAST(before_revision AS BLOB)) + length(CAST(after_revision AS BLOB))
+ length(CAST(path AS BLOB)) + COALESCE(length(CAST(old_path AS BLOB)), 0)
+ COALESCE(length(CAST(after_content AS BLOB)), 0) + 64
FROM reviewed_files WHERE review_id = ?1)",
[id],
|row| row.get(0),
)
.map_err(database_error)?;
if comment_bytes < 0
|| comment_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64
if stored_bytes < 0
|| stored_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64
{
return Err("Review exceeds the 64 MiB limit".into());
}
@@ -699,6 +793,30 @@ fn stored_review(
},
});
}
let mut statement = connection
.prepare(
"SELECT position, before_revision, after_revision, path, old_path, after_content
FROM reviewed_files WHERE review_id = ?1 ORDER BY position",
)
.map_err(database_error)?;
let mut rows = statement.query([id]).map_err(database_error)?;
while let Some(row) = rows.next().map_err(database_error)? {
if stored_index(row, 0)? != review.reviewed_files.len() {
return Err("Invalid stored reviewed-file order".into());
}
review.reviewed_files.push(ReviewedFile {
revisions: [
row.get(1).map_err(database_error)?,
row.get(2).map_err(database_error)?,
],
path: PathBuf::from(row.get::<_, String>(3).map_err(database_error)?),
old_path: row
.get::<_, Option<String>>(4)
.map_err(database_error)?
.map(PathBuf::from),
after: row.get(5).map_err(database_error)?,
});
}
review.validate()?;
review.storage_id = Some(id);
review.storage_revision = revision;