feat: persist diff file review checkmarks
This commit is contained in:
+118
-16
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user