Merge pull request #286 from vangel76/feat/review-comment-navigation

Review: Previous/Next Comment, Show Comments, Show All Notes, Convert to Comments (#281)
This commit is contained in:
Brandon Thomas
2026-10-11 13:00:30 -04:00
16 changed files with 277 additions and 17 deletions
+167 -2
View File
@@ -1,6 +1,8 @@
//! Review tab: protection, spelling (basic), workbook statistics.
//! Review tab: protection, spelling (basic), workbook statistics, comment navigation and the
//! Comments pane, Show All Notes and Convert to Comments.
use gridcraft_model::{EditRange, PasswordHash, SheetProtection, password_hash};
use gridcraft_core::CellRef;
use gridcraft_model::{EditRange, PasswordHash, SheetProtection, Visibility, password_hash};
use serde_json::{Value as Json, json};
use super::*;
@@ -32,9 +34,94 @@ pub fn specs() -> Vec<CommandSpec> {
undoable: true,
},
cmd!(query "review.checkAccessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → issues", has_doc, accessibility),
cmd!(noundo "review.previousComment", "Previous Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| {
step_comment(s, false)
}),
cmd!(noundo "review.nextComment", "Next Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| step_comment(
s, true
)),
cmd!(query "review.showComments", "Show Comments", ["Review", "Comments"], None, "{on?: bool} (toggles the Comments pane)", has_doc, |s, p| {
s.comments_pane = bool_param(p, "on").unwrap_or(!s.comments_pane);
Ok(json!({"on": s.comments_pane}))
}),
cmd!(query "review.showAllNotes", "Show All Notes", ["Review", "Notes"], None, "{on?: bool}", has_doc, |s, p| {
s.show_all_notes = bool_param(p, "on").unwrap_or(!s.show_all_notes);
Ok(json!({"on": s.show_all_notes}))
}),
cmd!(
"review.convertToComments",
"Convert to Comments",
["Review", "Notes"],
None,
"{} (every note in the workbook) → {converted}",
has_doc,
convert_notes
),
]
}
/// Threaded comments in navigation order: visible sheets in tab order, then row by row.
fn comment_cells(s: &Session) -> Vec<(usize, CellRef)> {
let Some(d) = s.active() else { return vec![] };
d.wb.sheets
.iter()
.enumerate()
.filter(|(_, sh)| sh.visibility == Visibility::Visible)
// `CellRef` orders by row, then column: reading order.
.flat_map(|(i, sh)| sh.comments.iter().filter(|(_, c)| c.threaded).map(move |(at, _)| (i, *at)))
.collect()
}
fn has_comments(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if comment_cells(s).is_empty() { Err("there are no comments in this workbook".into()) } else { Ok(()) }
}
/// Selects the next (or previous) threaded comment after the active cell, moving on to the next
/// sheet and wrapping around at the end of the workbook.
fn step_comment(s: &mut Session, forward: bool) -> Result<Json> {
let list = comment_cells(s);
let d = s.doc()?;
let here = (d.wb.active_sheet, d.selection.active);
let target = if forward {
list.iter().find(|c| **c > here).or_else(|| list.first())
} else {
list.iter().rev().find(|c| **c < here).or_else(|| list.last())
};
let Some(&(sheet, at)) = target else {
return Err(EngineError::Other("There are no comments in this workbook.".into()));
};
let d = s.doc_mut()?;
d.wb_switch_sheet(sheet);
d.selection = crate::Selection::at(at);
if let Some(sh) = d.wb.active() {
d.selection.expand_merges(sh);
}
let name = d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default();
Ok(json!({"sheet": name, "cell": at.a1()}))
}
/// Review › Notes › Convert to Comments: every note in the workbook becomes a threaded comment
/// with the same author and text.
fn convert_notes(s: &mut Session, _: &Json) -> Result<Json> {
let sheets: Vec<usize> =
s.doc()?.wb.sheets.iter().enumerate().filter(|(_, sh)| sh.comments.values().any(|c| !c.threaded)).map(|(i, _)| i).collect();
if sheets.is_empty() {
return Ok(json!({"converted": 0}));
}
edit(s, |cx| {
let mut n = 0usize;
for i in &sheets {
for c in cx.sheet_mut(*i)?.comments.values_mut().filter(|c| !c.threaded) {
c.threaded = true;
c.visible = false;
n += 1;
}
}
Ok(json!({"converted": n}))
})
}
fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let b = |k: &str| bool_param(p, k).unwrap_or(false);
@@ -225,6 +312,84 @@ fn accessibility(s: &mut Session, _: &Json) -> Result<Json> {
mod tests {
use super::*;
fn book() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
for (sheet, cell) in [(0, "C1"), (0, "A3"), (1, "B2")] {
s.execute("review.newComment", json!({"sheet": sheet, "cell": cell, "text": "c", "author": "Ann"})).unwrap();
}
s.execute("review.newNote", json!({"sheet": 0, "cell": "A2", "text": "note", "author": "Bob"})).unwrap();
s
}
fn at(s: &Session) -> (usize, String) {
let d = s.doc().unwrap();
(d.wb.active_sheet, d.selection.active.a1())
}
#[test]
fn next_and_previous_comment_go_in_reading_order_across_sheets_and_wrap() {
let mut s = book();
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
// Notes are skipped: A2 holds a note, not a comment.
let order = [(0, "C1"), (0, "A3"), (1, "B2"), (0, "C1")];
for (sheet, cell) in order {
s.execute("review.nextComment", json!({})).unwrap();
assert_eq!(at(&s), (sheet, cell.to_string()));
}
for (sheet, cell) in [(1, "B2"), (0, "A3"), (0, "C1")] {
s.execute("review.previousComment", json!({})).unwrap();
assert_eq!(at(&s), (sheet, cell.to_string()));
}
}
#[test]
fn comment_navigation_is_disabled_without_comments() {
let mut s = Session::new();
s.new_workbook();
s.execute("review.newNote", json!({"cell": "A1", "text": "note"})).unwrap();
assert!(s.execute("review.nextComment", json!({})).is_err());
assert!(s.execute("review.previousComment", json!({})).is_err());
assert_eq!(at(&s), (0, "A1".to_string()));
}
#[test]
fn convert_to_comments_turns_every_note_into_a_comment_and_undoes() {
let mut s = book();
s.execute("review.newNote", json!({"sheet": 1, "cell": "D4", "text": "second", "author": "Cy"})).unwrap();
s.execute("review.showNote", json!({"sheet": 1, "cell": "D4"})).unwrap();
let r = s.execute("review.convertToComments", json!({})).unwrap();
assert_eq!(r["converted"], 2);
let wb = &s.doc().unwrap().wb;
let a2 = &wb.sheets[0].comments[&CellRef::parse("A2").unwrap()];
let d4 = &wb.sheets[1].comments[&CellRef::parse("D4").unwrap()];
assert!(a2.threaded && d4.threaded && !d4.visible);
assert_eq!((a2.author.as_str(), a2.text.as_str(), d4.author.as_str(), d4.text.as_str()), ("Bob", "note", "Cy", "second"));
// Nothing left to convert; one undo restores the notes.
assert_eq!(s.execute("review.convertToComments", json!({})).unwrap()["converted"], 0);
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.doc().unwrap().wb.sheets[0].comments[&CellRef::parse("A2").unwrap()].threaded);
}
#[test]
fn show_comments_and_show_all_notes_toggle() {
let mut s = book();
assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], true);
assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], false);
assert_eq!(s.execute("review.showComments", json!({"on": true})).unwrap()["on"], true);
assert!(s.comments_pane);
assert_eq!(s.execute("review.showAllNotes", json!({})).unwrap()["on"], true);
assert_eq!(s.execute("review.showAllNotes", json!({"on": false})).unwrap()["on"], false);
assert!(!s.show_all_notes);
}
}
#[cfg(test)]
mod edit_range_tests {
use super::*;
fn protected_book() -> Session {
let mut s = Session::new();
s.new_workbook();
+5
View File
@@ -212,6 +212,11 @@ pub struct Session {
pub draw_tool: String,
pub draw_color: String,
pub draw_width: f32,
/// Review › Show Comments: the Comments pane is open.
pub comments_pane: bool,
/// Review › Notes › Show All Notes: every note is displayed. An application display setting
/// (like Excel's comment indicator option), so it's not saved in the file.
pub show_all_notes: bool,
}
impl Session {
+2 -1
View File
@@ -706,7 +706,8 @@ impl Dialog {
"saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title")})),
"start" => Dialog::custom("start", "GridCraft", json!({})),
"comments" => {
app.grid.pane = Some("comments".into());
app.grid.pane = None;
app.run_or_alert("review.showComments", json!({"on": true}));
return None;
}
"watch" | "selectionPane" | "formatChart" => {
+2 -1
View File
@@ -2310,8 +2310,9 @@ fn paint_overlays(
}
// Notes: shown on hover (or always when marked visible).
let hover = app.grid.hover_cell;
let all_notes = app.session.show_all_notes;
for (c, cm) in &sh.comments {
let show = cm.visible || hover == Some(*c);
let show = cm.visible || (all_notes && !cm.threaded) || hover == Some(*c);
if !show || app.grid.drag != Drag::None {
continue;
}
+6
View File
@@ -342,6 +342,12 @@ const JAPANESE: &[(&str, &str)] = &[
("Check\nAccessibility", "アクセシビリティ チェック"),
("Threaded Comments", "スレッド化されたコメント"),
("Show\nComments", "コメントの表示"),
("Previous", "前へ"),
("Next", "次へ"),
("Previous Comment", "前のコメント"),
("Next Comment", "次のコメント"),
("Show All Notes", "すべてのメモを表示"),
("Convert to Comments", "コメントに変換"),
("Protect\nWorkbook", "ブックの保護"),
("Allow Edit\nRanges", "範囲の編集を許可"),
("Allow Edit Ranges", "範囲の編集を許可"),
+6
View File
@@ -184,6 +184,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Mřížka",
"Headings" => "Záhlaví",
"Zoom" => "Lupa",
"Previous" => "Předchozí",
"Next" => "Další",
"Previous Comment" => "Předchozí komentář",
"Next Comment" => "Další komentář",
"Show All Notes" => "Zobrazit všechny poznámky",
"Convert to Comments" => "Převést na komentáře",
"Checkbox" => "Zaškrtávací políčko",
"Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk",
"Sheet\nRight-to-Left" => "List zprava\ndoleva",
+6
View File
@@ -184,6 +184,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Gitternetzlinien",
"Headings" => "Überschriften",
"Zoom" => "Zoom",
"Previous" => "Zurück",
"Next" => "Weiter",
"Previous Comment" => "Vorheriger Kommentar",
"Next Comment" => "Nächster Kommentar",
"Show All Notes" => "Alle Notizen anzeigen",
"Convert to Comments" => "In Kommentare konvertieren",
"Checkbox" => "Kontrollkästchen",
"Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen",
"Sheet\nRight-to-Left" => "Blatt von rechts\nnach links",
+6
View File
@@ -185,6 +185,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Líneas de cuadrícula",
"Headings" => "Encabezados",
"Zoom" => "Zoom",
"Previous" => "Anterior",
"Next" => "Siguiente",
"Previous Comment" => "Comentario anterior",
"Next Comment" => "Comentario siguiente",
"Show All Notes" => "Mostrar todas las notas",
"Convert to Comments" => "Convertir en comentarios",
"Checkbox" => "Casilla",
"Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas",
"Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda",
+6
View File
@@ -179,6 +179,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Check\nAccessibility" => "접근성\n검사",
"Threaded Comments" => "스레드 댓글",
"Show\nComments" => "댓글\n표시",
"Previous" => "이전",
"Next" => "다음",
"Previous Comment" => "이전 댓글",
"Next Comment" => "다음 댓글",
"Show All Notes" => "모든 메모 표시",
"Convert to Comments" => "댓글로 변환",
"Protect\nWorkbook" => "통합 문서\n보호",
"Allow Edit\nRanges" => "범위 편집\n허용",
"Allow Edit Ranges" => "범위 편집 허용",
+6
View File
@@ -180,6 +180,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Check\nAccessibility" => "Verificar\nAcessibilidade",
"Threaded Comments" => "Comentários Encadeados",
"Show\nComments" => "Mostrar\nComentários",
"Previous" => "Anterior",
"Next" => "Próximo",
"Previous Comment" => "Comentário Anterior",
"Next Comment" => "Próximo Comentário",
"Show All Notes" => "Mostrar Todas as Notas",
"Convert to Comments" => "Converter em Comentários",
"Protect\nWorkbook" => "Proteger\nPasta de Trabalho",
"Allow Edit\nRanges" => "Permitir Edição\nde Intervalos",
"Allow Edit Ranges" => "Permitir Edição de Intervalos",
+6
View File
@@ -179,6 +179,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Check\nAccessibility" => "Проверка\nдоступности",
"Threaded Comments" => "Комментарии с ветками",
"Show\nComments" => "Показать\nкомментарии",
"Previous" => "Назад",
"Next" => "Далее",
"Previous Comment" => "Предыдущий комментарий",
"Next Comment" => "Следующий комментарий",
"Show All Notes" => "Показать все заметки",
"Convert to Comments" => "Преобразовать в комментарии",
"Protect\nWorkbook" => "Защитить\nкнигу",
"Allow Edit\nRanges" => "Разрешить\nизменение диапазонов",
"Allow Edit Ranges" => "Разрешить изменение диапазонов",
+6
View File
@@ -179,6 +179,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Check\nAccessibility" => "检查\n辅助功能",
"Threaded Comments" => "线程式批注",
"Show\nComments" => "显示\n批注",
"Previous" => "上一条",
"Next" => "下一条",
"Previous Comment" => "上一条批注",
"Next Comment" => "下一条批注",
"Show All Notes" => "显示所有备注",
"Convert to Comments" => "转换为批注",
"Protect\nWorkbook" => "保护\n工作簿",
"Allow Edit\nRanges" => "允许编辑\n区域",
"Allow Edit Ranges" => "允许编辑区域",
+12 -1
View File
@@ -7,7 +7,15 @@ use crate::SheetApp;
use crate::theme::{self, Tokens};
pub fn show(app: &mut SheetApp, ui: &mut Ui) {
let Some(pane) = app.grid.pane.clone() else { return };
// The Comments pane is engine state (`review.showComments`, so agents can drive it); the other
// panes are UI state. One pane shows at a time: opening another one closes Comments.
if app.grid.pane.as_deref() == Some("comments") {
app.grid.pane = None;
app.run_or_alert("review.showComments", json!({"on": true}));
} else if app.grid.pane.is_some() && app.session.comments_pane {
app.run_or_alert("review.showComments", json!({"on": false}));
}
let Some(pane) = app.grid.pane.clone().or_else(|| app.session.comments_pane.then(|| "comments".to_string())) else { return };
let t = Tokens::get(ui.ctx());
egui::Panel::right("task_pane").default_size(300.0).resizable(true).frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| {
ui.horizontal(|ui| {
@@ -21,6 +29,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
ui.label(egui::RichText::new(title).font(theme::ui_bold(15.0)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(ui, crate::icons::Icon::Close, t.text_dim, "Close", vec2(22.0, 22.0)).clicked() {
if pane == "comments" {
app.run_or_alert("review.showComments", json!({"on": false}));
}
app.grid.pane = None;
}
});
+32 -4
View File
@@ -1650,15 +1650,43 @@ fn review(app: &mut SheetApp, ui: &mut Ui) {
if big_button(ui, Icon::Comment, "New\nComment", "New Comment", false).clicked() {
app.open_dialog("comment", json!({"threaded": true}));
}
if big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false).clicked() {
app.grid.pane = Some("comments".into());
}
if big_button(ui, Icon::Delete, "Delete", "Delete Comment", false).clicked() {
act(app, "review.deleteComment", json!({}));
}
ui.vertical(|ui| {
for (icon, label, tip, id) in
[(Icon::Left, "Previous", "Previous Comment", "review.previousComment"), (Icon::Right, "Next", "Next Comment", "review.nextComment")]
{
if small_button(ui, icon, label, tip, false).clicked() {
act(app, id, json!({}));
app.grid.ensure_visible = true;
}
}
});
let shown = app.session.comments_pane;
let r = big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false);
if shown {
ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
}
if r.clicked() {
act(app, "review.showComments", json!({"on": !shown}));
if app.session.comments_pane {
app.grid.pane = None;
}
}
let notes = big_button(ui, Icon::Note, "Notes", "Notes", true);
egui::Popup::menu(&notes).show(|ui| {
menu_items(app, ui, &[("New Note", "dialog:comment", json!({"threaded": false})), ("Show/Hide Note", "review.showNote", json!({}))]);
menu_items(
app,
ui,
&[
("New Note", "dialog:comment", json!({"threaded": false})),
("Show/Hide Note", "review.showNote", json!({})),
("Show All Notes", "review.showAllNotes", json!({})),
("-", "", json!({})),
("Convert to Comments", "review.convertToComments", json!({})),
],
);
});
sep(ui);
let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false);
+1
View File
@@ -153,6 +153,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | §16: comment navigation, Show Comments, Show All Notes and Convert to Comments landed (#281) |
| 2026-10-11 | minor | §15: ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added (18 functions missing) |
| 2026-10-11 | minor | §15: PERCENTOF and EUROCONVERT added (20 functions missing) |
| 2026-10-11 | minor | §15: BAHTTEXT added (21 functions missing) |
+8 -8
View File
@@ -4,7 +4,7 @@
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
**Overall: 266 / 290 commands implemented (91.7%).**
**Overall: 271 / 290 commands implemented (93.4%).**
| Tab | Implemented | Total | Parity |
|---|---|---|---|
@@ -15,7 +15,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
| Page Layout | 23 | 23 | 100% |
| Formulas | 23 | 23 | 100% |
| Data | 22 | 28 | 79% |
| Review | 10 | 18 | 56% |
| Review | 15 | 18 | 83% |
| View | 20 | 20 | 100% |
| Automate | 3 | 3 | 100% |
| Table Design | 12 | 13 | 92% |
@@ -273,7 +273,7 @@ Missing:
- `data.forecastSheet` Forecast Sheet (Forecast)
- `data.analyzeData` Analyze Data (Analysis)
## Review (10 / 18, 56%)
## Review (15 / 18, 83%)
Implemented:
@@ -282,8 +282,13 @@ Implemented:
- `review.workbookStatistics` Workbook Statistics (Proofing)
- `review.checkAccessibility` Check Accessibility (Accessibility)
- `review.deleteComment` Delete Comment (Comments)
- `review.previousComment` Previous Comment (Comments)
- `review.nextComment` Next Comment (Comments)
- `review.showComments` Show Comments (Comments)
- `review.newNote` New Note (Notes)
- `review.showNote` Show/Hide Note (Notes)
- `review.showAllNotes` Show All Notes (Notes)
- `review.convertToComments` Convert to Comments (Notes)
- `review.unprotectSheet` Unprotect Sheet (Protect)
- `review.protectWorkbook` Protect Workbook (Protect)
- `review.allowEditRanges` Allow Edit Ranges (Protect)
@@ -292,11 +297,6 @@ Missing:
- `review.smartLookup` Smart Lookup (Insights)
- `review.translate` Translate (Language)
- `review.previousComment` Previous Comment (Comments)
- `review.nextComment` Next Comment (Comments)
- `review.showComments` Show Comments (Comments)
- `review.showAllNotes` Show All Notes (Notes)
- `review.convertToComments` Convert to Comments (Notes)
- `review.showChanges` Show Changes (Changes)
## View (20 / 20, 100%)