Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,17 @@ Mouse controls:
- Drag source lines and release to comment on the range.
- Click a comment to edit it.
- Use the wheel to scroll the document or active editor.
- Saved ranges keep a green rail beside every source line they cover.

Keyboard controls:

| Key | Action |
| --- | --- |
| `j` / `k`, arrows | Move between source lines |
| `Shift-Up` / `Shift-Down` | Select a range; release Shift to write the comment |
| `v` | Start or cancel range selection |
| `Enter` | Comment on the cursor/range, or save an active comment |
| `Ctrl-O` | Insert a newline in a comment |
| `Ctrl-A` / `Ctrl-E` | Move to the beginning/end of the editor line |
| `Esc` | Cancel selection or editing |
| `e` / `d` | Edit or delete a comment on the cursor line |
| `[` / `]` | Jump to the previous/next comment |
Expand Down
22 changes: 22 additions & 0 deletions scripts/e2e-tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ wait_for_pane "Comment on lines 1"
tmux send-keys -t "$session" -l "human comment here ..."
tmux send-keys -t "$session" Enter
wait_for_pane "Comment saved"
wait_for_pane "┃ quoted part line 1"
wait_for_pane "┃ quoted part line 2"
finish_tui

printf '> quoted part line 1\n> quoted part line 2\n\nhuman comment here ...\n' >"$tmpdir/expected.md"
Expand Down Expand Up @@ -98,3 +100,23 @@ assert review["comments"] == [
}
]
PY

start_tui "$tmpdir/shift-output.md" "--comments '$tmpdir/shift-review.json' --no-mouse"

# Select all three source lines with Shift-Down, then inject a left-Shift release
# using the Kitty keyboard protocol requested by the application.
tmux send-keys -t "$session" Escape '[1;2B'
tmux send-keys -t "$session" Escape '[1;2B'
tmux send-keys -t "$session" Escape '[57441;2:3u'
wait_for_pane "Comment on lines 1"

tmux send-keys -t "$session" -l "keyboard range"
tmux send-keys -t "$session" Enter
wait_for_pane "Comment saved"
wait_for_pane "┃ quoted part line 1"
wait_for_pane "┃ quoted part line 2"
wait_for_pane "┃ unquoted line"
finish_tui

printf '> quoted part line 1\n> quoted part line 2\n> unquoted line\n\nkeyboard range\n' >"$tmpdir/expected-shift.md"
diff -u "$tmpdir/expected-shift.md" "$tmpdir/shift-output.md"
41 changes: 41 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub struct App {
pub should_quit: bool,
pub follow_cursor: bool,
pub mouse_drag_anchor: Option<usize>,
pub keyboard_shift_anchor: Option<usize>,
pub status: Option<String>,
pub dirty: bool,
}
Expand All @@ -92,6 +93,7 @@ impl App {
should_quit: false,
follow_cursor: true,
mouse_drag_anchor: None,
keyboard_shift_anchor: None,
status: None,
dirty: false,
}
Expand Down Expand Up @@ -122,6 +124,7 @@ impl App {

pub fn begin_selection(&mut self, line: usize) {
self.move_to_line(line);
self.keyboard_shift_anchor = None;
self.selection = Some(Selection {
anchor: self.cursor_line,
current: self.cursor_line,
Expand All @@ -142,16 +145,36 @@ impl App {

pub fn cancel_selection(&mut self) {
self.selection = None;
self.keyboard_shift_anchor = None;
self.status = None;
}

pub fn extend_shift_selection(&mut self, delta: isize) {
if self.keyboard_shift_anchor.is_none() {
if self.selection.is_none() {
self.begin_selection(self.cursor_line);
}
self.keyboard_shift_anchor = self.selection.map(|selection| selection.anchor);
}
self.move_cursor(delta);
}

pub fn finish_shift_selection(&mut self) -> bool {
if self.keyboard_shift_anchor.take().is_none() || self.selection.is_none() {
return false;
}
self.open_selected_editor();
true
}

pub fn open_selected_editor(&mut self) {
let selection = self.selection.unwrap_or(Selection {
anchor: self.cursor_line,
current: self.cursor_line,
});
let (start_line, end_line) = selection.normalized();
self.cursor_line = end_line;
self.keyboard_shift_anchor = None;
self.editor = Some(CommentEditor::new(None, start_line, end_line, ""));
self.follow_cursor = true;
self.status = None;
Expand All @@ -168,6 +191,7 @@ impl App {
return false;
};
self.cursor_line = comment.end_line;
self.keyboard_shift_anchor = None;
self.active_comment_id = Some(comment.id);
self.selection = Some(Selection {
anchor: comment.start_line,
Expand Down Expand Up @@ -210,6 +234,7 @@ impl App {
});
self.editor = None;
self.selection = None;
self.keyboard_shift_anchor = None;
self.status = Some("Comment saved".into());
self.active_comment_id = Some(id);
self.dirty = true;
Expand All @@ -219,6 +244,7 @@ impl App {
pub fn cancel_editor(&mut self) {
self.editor = None;
self.selection = None;
self.keyboard_shift_anchor = None;
self.status = Some("Edit cancelled".into());
}

Expand Down Expand Up @@ -389,6 +415,21 @@ mod tests {
assert!(app.status.is_none());
}

#[test]
fn shift_selection_extends_and_opens_the_editor_on_finish() {
let mut app = app();
app.extend_shift_selection(1);
app.extend_shift_selection(1);
assert_eq!(app.selection.unwrap().normalized(), (1, 3));
assert_eq!(app.keyboard_shift_anchor, Some(1));

assert!(app.finish_shift_selection());
let editor = app.editor.as_ref().unwrap();
assert_eq!((editor.start_line, editor.end_line), (1, 3));
assert!(app.keyboard_shift_anchor.is_none());
assert!(!app.finish_shift_selection());
}

#[test]
fn missing_comment_actions_report_without_mutating() {
let mut app = app();
Expand Down
131 changes: 114 additions & 17 deletions src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@ const TAB_STOP: usize = 4;

#[derive(Debug, Clone, PartialEq, Eq)]
enum DocumentRow {
Source(usize),
Comment { id: u64, text: String, first: bool },
Source {
line_number: usize,
commented: bool,
active: bool,
},
Comment {
id: u64,
text: String,
first: bool,
},
Editor,
}

Expand Down Expand Up @@ -98,8 +106,20 @@ fn render_document(frame: &mut Frame<'_>, app: &mut App, area: Rect) -> Vec<HitA
1,
);
match row {
DocumentRow::Source(line_number) => {
render_source_row(frame, app, rect, *line_number, line_digits);
DocumentRow::Source {
line_number,
commented,
active,
} => {
render_source_row(
frame,
app,
rect,
*line_number,
line_digits,
*commented,
*active,
);
hits.push(HitArea::new(rect, HitTarget::SourceLine(*line_number)));
}
DocumentRow::Comment { id, text, first } => {
Expand All @@ -108,6 +128,7 @@ fn render_document(frame: &mut Frame<'_>, app: &mut App, area: Rect) -> Vec<HitA
rect,
text,
*first,
line_digits,
app.active_comment_id == Some(*id),
);
hits.push(HitArea::new(rect, HitTarget::Comment(*id)));
Expand Down Expand Up @@ -140,7 +161,21 @@ fn document_rows(app: &App) -> Vec<DocumentRow> {
let mut rows = Vec::new();
let editing_id = app.editor.as_ref().and_then(|editor| editor.comment_id);
let editor_end = app.editor.as_ref().map(|editor| editor.end_line);
let active_range = app.active_comment_id.and_then(|id| {
app.review
.comments
.iter()
.find(|comment| comment.id == id)
.map(|comment| (comment.start_line, comment.end_line))
});
let mut comments_by_end = BTreeMap::<usize, Vec<_>>::new();
let mut range_events = BTreeMap::<usize, (usize, usize)>::new();
for comment in &app.review.comments {
range_events.entry(comment.start_line).or_default().0 += 1;
if comment.end_line < app.source.line_count() {
range_events.entry(comment.end_line + 1).or_default().1 += 1;
}
}
for comment in app
.review
.comments
Expand All @@ -153,8 +188,23 @@ fn document_rows(app: &App) -> Vec<DocumentRow> {
.push(comment);
}

let mut events = range_events.into_iter().peekable();
let mut comments_covering_line = 0usize;
for line_number in 1..=app.source.line_count() {
rows.push(DocumentRow::Source(line_number));
if events
.peek()
.is_some_and(|(event_line, _)| *event_line == line_number)
{
let (_, (starts, ends)) = events.next().unwrap_or_default();
comments_covering_line = comments_covering_line.saturating_sub(ends);
comments_covering_line = comments_covering_line.saturating_add(starts);
}
rows.push(DocumentRow::Source {
line_number,
commented: comments_covering_line > 0,
active: active_range
.is_some_and(|(start, end)| start <= line_number && line_number <= end),
});
if let Some(comments) = comments_by_end.get(&line_number) {
for comment in comments {
for (index, body_line) in comment.body.lines().enumerate() {
Expand Down Expand Up @@ -182,15 +232,24 @@ fn update_scroll(app: &mut App, rows: &[DocumentRow], viewport_height: usize) {

let source_row = rows
.iter()
.position(|row| matches!(row, DocumentRow::Source(line) if *line == app.cursor_line))
.position(|row| {
matches!(
row,
DocumentRow::Source { line_number, .. } if *line_number == app.cursor_line
)
})
.unwrap_or(0);
let target_row = if app.editor.is_some() {
rows.iter()
.enumerate()
.skip(source_row)
.take_while(
|(_, row)| !matches!(row, DocumentRow::Source(line) if *line > app.cursor_line),
)
.take_while(|(_, row)| {
!matches!(
row,
DocumentRow::Source { line_number, .. }
if *line_number > app.cursor_line
)
})
.map(|(index, _)| index)
.last()
.unwrap_or(source_row)
Expand All @@ -213,13 +272,15 @@ fn render_source_row(
area: Rect,
line_number: usize,
line_digits: usize,
commented: bool,
active: bool,
) {
let selected = app
.selection
.is_some_and(|selection| selection.contains(line_number));
let cursor = app.cursor_line == line_number && app.editor.is_none();
let marker = if cursor { "▶" } else { " " };
let number = format!("{marker}{line_number:>line_digits$} ");
let number = format!("{marker}{line_number:>line_digits$} ");
let text = horizontally_scrolled(
app.source.line(line_number).unwrap_or_default(),
app.horizontal_scroll,
Expand All @@ -234,15 +295,41 @@ fn render_source_row(
} else {
Style::default().fg(Color::DarkGray)
};
let rail = if commented { "┃" } else { "│" };
let rail_style = if active {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if commented {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
number_style
};
let line = Line::from(vec![
Span::styled(number, number_style),
Span::styled(rail, rail_style),
Span::styled(" ", style),
Span::styled(text, style),
]);
frame.render_widget(Paragraph::new(line).style(style), area);
}

fn render_comment_row(frame: &mut Frame<'_>, area: Rect, text: &str, first: bool, active: bool) {
let prefix = if first { " └─ " } else { " " };
fn render_comment_row(
frame: &mut Frame<'_>,
area: Rect,
text: &str,
first: bool,
line_digits: usize,
active: bool,
) {
let prefix = if first {
format!("{}└─ ", " ".repeat(line_digits + 2))
} else {
" ".repeat(line_digits + 5)
};
let style = if active {
Style::default().fg(Color::Black).bg(Color::Green)
} else {
Expand Down Expand Up @@ -295,16 +382,21 @@ fn expand_tabs(text: &str) -> String {

fn render_footer(frame: &mut Frame<'_>, app: &App, area: Rect) {
let help = if app.editor.is_some() {
" Enter save · Ctrl-O newline · Esc cancel · Ctrl-A/E line start/end "
" Enter save · Esc cancel "
} else if let Some(selection) = app.selection {
let (start, end) = selection.normalized();
let finish = if app.keyboard_shift_anchor.is_some() {
"release Shift or Enter"
} else {
"Enter"
};
return render_footer_text(
frame,
area,
&format!(" Lines {start}–{end} selected · Enter comment · Esc cancel "),
&format!(" Lines {start}–{end} selected · {finish} to comment · Esc cancel "),
);
} else {
" drag select · Enter comment · e edit · d delete · [/] comments · q output & quit "
" drag or Shift-↑/↓ select · Enter comment · e edit · d delete · [/] comments · q quit "
};
let text = app.status.as_deref().unwrap_or(help);
render_footer_text(frame, area, text);
Expand Down Expand Up @@ -388,7 +480,11 @@ mod tests {
terminal
.draw(|frame| hits = render_app(frame, &mut app))
.unwrap();
assert!(terminal.backend().to_string().contains("└─ first line"));
let rendered = terminal.backend().to_string();
assert!(rendered.contains("1 ┃ one"));
assert!(rendered.contains("2 ┃ two"));
assert!(rendered.contains("3 │ three"));
assert!(rendered.contains("└─ first line"));
assert!(hits.iter().any(|hit| hit.target == HitTarget::Comment(1)));

app.begin_edit(1);
Expand All @@ -397,7 +493,8 @@ mod tests {
.unwrap();
let rendered = terminal.backend().to_string();
assert!(rendered.contains("Comment on lines 1–2"));
assert!(rendered.contains("Ctrl-O newline"));
assert!(!rendered.contains("Ctrl-O"));
assert!(!rendered.contains("Ctrl-A"));

app.cancel_editor();
app.review.comments[0].body = "\tcode".into();
Expand Down
Loading