d58d04fa01
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
252 lines
12 KiB
Rust
252 lines
12 KiB
Rust
//! Source panel geometry and command gating without a GPU or repository writes.
|
|
|
|
use egui::{Context, Event, Pos2, RawInput, Shape};
|
|
use notescraft_git::{Change, ChangeKind, Git, Status};
|
|
use notescraft_ui_egui::{
|
|
source_panel::{self, Repository, SourceState},
|
|
theme::Tokens,
|
|
};
|
|
|
|
fn context() -> Context {
|
|
let ctx = Context::default();
|
|
notescraft_ui_egui::fonts::install(&ctx);
|
|
egui_extras::install_image_loaders(&ctx);
|
|
notescraft_ui_egui::motion::set_enabled(&ctx, false);
|
|
let mut warm = ctx.run_ui(RawInput::default(), |_| {});
|
|
warm.textures_delta.clear();
|
|
ctx
|
|
}
|
|
|
|
fn state(read_only: bool) -> SourceState {
|
|
let mut state = SourceState::new(Git::open(&std::env::temp_dir()), read_only);
|
|
state.busy = false;
|
|
state.repository = Repository::Ready(Status {
|
|
branch: Some("main".into()),
|
|
ahead: 2,
|
|
changes: vec![
|
|
Change { path: "staged.md".into(), orig_path: None, index: ChangeKind::Added, worktree: ChangeKind::None },
|
|
Change { path: "edited.md".into(), orig_path: None, index: ChangeKind::None, worktree: ChangeKind::Modified },
|
|
],
|
|
..Status::default()
|
|
});
|
|
state
|
|
}
|
|
|
|
fn frame(ctx: &Context, state: &mut SourceState, events: Vec<Event>) -> (source_panel::SourceResponse, Vec<Shape>) {
|
|
sized_frame(ctx, state, events, 360.0)
|
|
}
|
|
|
|
fn sized_frame(ctx: &Context, state: &mut SourceState, events: Vec<Event>, width: f32) -> (source_panel::SourceResponse, Vec<Shape>) {
|
|
let Some(theme) = notescraft_themes::builtin("catppuccin-mocha") else { panic!("built-in theme") };
|
|
let tokens = Tokens::new(theme);
|
|
notescraft_ui_egui::style::apply(ctx, &tokens);
|
|
let mut response = source_panel::SourceResponse::default();
|
|
let mut output =
|
|
ctx.run_ui(RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(width, 900.0))), events, ..RawInput::default() }, |ui| {
|
|
response = source_panel::draw(ui, &tokens, state, Some("edited.md"));
|
|
});
|
|
output.textures_delta.clear();
|
|
(response, output.shapes.into_iter().map(|shape| shape.shape).collect())
|
|
}
|
|
|
|
fn text(shapes: &[Shape], label: &str) -> Option<egui::Rect> {
|
|
shapes.iter().find_map(|shape| {
|
|
let Shape::Text(text) = shape else { return None };
|
|
(text.galley.text() == label).then(|| egui::Rect::from_min_size(text.pos, text.galley.size()))
|
|
})
|
|
}
|
|
|
|
fn click(position: Pos2) -> Vec<Event> {
|
|
vec![
|
|
Event::PointerMoved(position),
|
|
Event::PointerButton { pos: position, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE },
|
|
Event::PointerButton { pos: position, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE },
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn source_cards_precede_changes_and_show_both_branch_counts() {
|
|
let ctx = context();
|
|
let mut state = state(false);
|
|
let _ = frame(&ctx, &mut state, Vec::new());
|
|
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
|
|
assert!(text(&shapes, "↑ 2").is_some());
|
|
assert!(text(&shapes, "↓ 0").is_some());
|
|
let commit = text(&shapes, "Commit 1 file");
|
|
let staged = text(&shapes, "STAGED");
|
|
assert!(commit.zip(staged).is_some_and(|(commit, staged)| commit.top() < staged.top()));
|
|
for height in [72.0, 44.0, 36.0] {
|
|
assert!(shapes.iter().any(|shape| matches!(shape, Shape::Rect(rect) if (rect.rect.height() - height).abs() < 0.1)), "missing {height} pt card");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn commit_card_gates_clicks_and_preserves_command_payload() {
|
|
let ctx = context();
|
|
let mut state = state(false);
|
|
let _ = frame(&ctx, &mut state, Vec::new());
|
|
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
|
|
let Some(position) = text(&shapes, "Commit 1 file").map(|rect| rect.center()) else { panic!("commit card") };
|
|
assert!(frame(&ctx, &mut state, click(position)).0.request.is_none());
|
|
state.message = "Save notes".into();
|
|
let _ = frame(&ctx, &mut state, Vec::new());
|
|
let (response, _) = frame(&ctx, &mut state, click(position));
|
|
assert_eq!(response.request.as_ref().map(|request| request.id), Some("git.commit"));
|
|
assert_eq!(response.request.map(|request| request.params), Some(serde_json::json!({"message":"Save notes"})));
|
|
}
|
|
|
|
#[test]
|
|
fn commit_shortcut_keeps_message_and_emits_commit_request() {
|
|
let ctx = context();
|
|
let mut state = state(false);
|
|
state.message = "Save notes".into();
|
|
let _ = frame(&ctx, &mut state, Vec::new());
|
|
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
|
|
let position = text(&shapes, "Save notes").unwrap().center();
|
|
let _ = frame(&ctx, &mut state, click(position));
|
|
let (response, _) = frame(
|
|
&ctx,
|
|
&mut state,
|
|
vec![Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }],
|
|
);
|
|
assert_eq!(response.request.as_ref().map(|request| request.id), Some("git.commit"));
|
|
assert_eq!(state.message, "Save notes");
|
|
}
|
|
|
|
#[test]
|
|
fn change_actions_preserve_payload_and_discard_requires_confirmation() {
|
|
let ctx = context();
|
|
let mut state = state(false);
|
|
let _ = frame(&ctx, &mut state, Vec::new());
|
|
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
|
|
let edited = text(&shapes, "edited").unwrap().center();
|
|
let (response, _) = frame(&ctx, &mut state, click(edited));
|
|
assert_eq!(response.request.map(|request| (request.id, request.params)), Some(("git.diff", serde_json::json!({"path":"edited.md","staged":false}))));
|
|
let _ = frame(&ctx, &mut state, vec![Event::PointerMoved(edited)]);
|
|
let plus = egui::pos2(310.0, edited.y);
|
|
let (response, _) = frame(&ctx, &mut state, click(plus));
|
|
assert_eq!(response.request.map(|request| (request.id, request.params)), Some(("git.stage", serde_json::json!({"paths":["edited.md"]}))));
|
|
let discard = egui::pos2(338.0, edited.y);
|
|
let _ = frame(&ctx, &mut state, vec![Event::PointerMoved(discard)]);
|
|
let (response, _) = frame(&ctx, &mut state, click(discard));
|
|
assert!(response.request.is_none(), "discard must wait for confirmation");
|
|
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
|
|
assert!(text(&shapes, "Discard changes from disk?").is_some());
|
|
let cancel = text(&shapes, "Cancel").unwrap().center();
|
|
let (response, _) = frame(&ctx, &mut state, click(cancel));
|
|
assert!(response.request.is_none());
|
|
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
|
|
assert!(text(&shapes, "Cancel").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn staged_action_unstages_and_loading_line_tracks_busy_worker() {
|
|
let ctx = context();
|
|
let mut state = state(false);
|
|
let _ = frame(&ctx, &mut state, Vec::new());
|
|
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
|
|
let staged = text(&shapes, "staged").unwrap().center();
|
|
let _ = frame(&ctx, &mut state, vec![Event::PointerMoved(staged)]);
|
|
let (response, _) = frame(&ctx, &mut state, click(egui::pos2(338.0, staged.y)));
|
|
assert_eq!(response.request.map(|request| (request.id, request.params)), Some(("git.unstage", serde_json::json!({"paths":["staged.md"]}))));
|
|
state.busy = true;
|
|
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
|
|
let accent = notescraft_ui_egui::theme::color(notescraft_themes::builtin("catppuccin-mocha").unwrap().colors.accent);
|
|
assert!(shapes.iter().any(|shape| matches!(shape, Shape::Rect(rect) if (rect.rect.height() - 2.0).abs() < 0.01 && rect.fill == accent)));
|
|
let (response, _) = frame(&ctx, &mut state, click(egui::pos2(338.0, staged.y)));
|
|
assert!(response.request.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn source_controls_fit_design_width_and_read_only_rows_still_open_diff() {
|
|
let ctx = context();
|
|
let mut state = state(true);
|
|
let _ = sized_frame(&ctx, &mut state, Vec::new(), 252.0);
|
|
let (_, shapes) = sized_frame(&ctx, &mut state, Vec::new(), 252.0);
|
|
let caption = text(&shapes, "Read-only vault").unwrap();
|
|
let commit = text(&shapes, "Commit 1 file").unwrap();
|
|
assert!(caption.top() > commit.bottom());
|
|
assert!(text(&shapes, "Stage all").is_none());
|
|
assert!(text(&shapes, "Unstage all").is_none());
|
|
for rect in shapes.iter().filter_map(|shape| match shape {
|
|
Shape::Rect(rect) => Some(rect),
|
|
_ => None,
|
|
}) {
|
|
assert!(rect.rect.right() <= 252.1, "overflow: {:?}", rect.rect);
|
|
if [40.0, 72.0, 44.0].iter().any(|height| (rect.rect.height() - height).abs() < 0.01) {
|
|
assert_eq!(rect.corner_radius, egui::CornerRadius::same(12));
|
|
}
|
|
}
|
|
for name in ["main", "↑ 2", "↓ 0", "edited", "Commit 1 file", "Message (⌘↩ to commit)"] {
|
|
let rect = text(&shapes, name).unwrap();
|
|
assert!(rect.right() <= 252.1, "text overflow: {name} {rect:?}");
|
|
}
|
|
let position = text(&shapes, "edited").unwrap().center();
|
|
let (response, _) = sized_frame(&ctx, &mut state, click(position), 252.0);
|
|
assert_eq!(response.request.as_ref().map(|request| request.id), Some("git.diff"));
|
|
}
|
|
|
|
#[test]
|
|
fn history_rows_show_metadata_and_open_note_comparison() {
|
|
let ctx = context();
|
|
let mut state = state(false);
|
|
state.history = vec![notescraft_git::Commit {
|
|
hash: "a1b2c3d456".into(),
|
|
author: "Marcos".into(),
|
|
date: "2026-01-01T10:00:00+00:00".into(),
|
|
subject: "Add roadmap".into(),
|
|
}];
|
|
let _ = frame(&ctx, &mut state, Vec::new());
|
|
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
|
|
let heading = text(&shapes, "HISTORY").unwrap();
|
|
let vault = text(&shapes, "Vault").unwrap();
|
|
let note = text(&shapes, "Active note").unwrap();
|
|
assert!(vault.top() > heading.bottom());
|
|
assert!((vault.center().y - note.center().y).abs() < 0.1);
|
|
assert!(shapes.iter().any(|shape| matches!(shape, Shape::Text(text) if text.galley.text().starts_with("Marcos · ") && text.galley.text().ends_with(" · a1b2c3d") && (text.galley.job.sections.first().unwrap().format.font_id.size - 12.0).abs() < 0.01)));
|
|
let subject = text(&shapes, "Add roadmap").unwrap();
|
|
let (response, _) = frame(&ctx, &mut state, click(subject.center()));
|
|
assert_eq!(response.history, Some(("edited.md".into(), "a1b2c3d456".into())));
|
|
}
|
|
|
|
#[test]
|
|
fn valid_commit_keeps_full_accent_and_changes_have_design_pitch() {
|
|
let ctx = context();
|
|
let mut state = state(false);
|
|
state.message = "Save notes".into();
|
|
if let Repository::Ready(status) = &mut state.repository {
|
|
status.changes.push(Change { path: "another.md".into(), orig_path: None, index: ChangeKind::Added, worktree: ChangeKind::None });
|
|
}
|
|
let _ = frame(&ctx, &mut state, Vec::new());
|
|
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
|
|
let accent = notescraft_ui_egui::theme::color(notescraft_themes::builtin("catppuccin-mocha").unwrap().colors.accent);
|
|
assert!(shapes.iter().any(|shape| matches!(shape, Shape::Rect(rect) if (rect.rect.height() - 44.0).abs() < 0.1 && rect.fill == accent)));
|
|
assert!(text(&shapes, "Read-only vault").is_none());
|
|
let first = text(&shapes, "staged").unwrap();
|
|
assert!((first.left() - 36.0).abs() < 0.1);
|
|
let second = text(&shapes, "another").unwrap();
|
|
assert!((second.center().y - first.center().y - 48.0).abs() < 0.1);
|
|
}
|
|
|
|
#[test]
|
|
fn repository_empty_states_use_muted_cards_and_init_is_gated() {
|
|
let ctx = context();
|
|
let mut state = state(true);
|
|
for repository in [Repository::MissingGit, Repository::NotInitialized] {
|
|
state.repository = repository;
|
|
let _ = frame(&ctx, &mut state, Vec::new());
|
|
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
|
|
let surface = notescraft_ui_egui::theme::color(notescraft_themes::builtin("catppuccin-mocha").unwrap().colors.surface_2);
|
|
assert!(shapes.iter().any(|shape| matches!(shape, Shape::Rect(rect) if rect.fill == surface && rect.rect.height() > 36.0)));
|
|
}
|
|
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
|
|
let initialize = text(&shapes, "Initialize repository").unwrap().center();
|
|
assert!(frame(&ctx, &mut state, click(initialize)).0.request.is_none());
|
|
let mut writable = self::state(false);
|
|
writable.repository = Repository::NotInitialized;
|
|
let _ = frame(&ctx, &mut writable, Vec::new());
|
|
let (_, shapes) = frame(&ctx, &mut writable, Vec::new());
|
|
let initialize = text(&shapes, "Initialize repository").unwrap().center();
|
|
assert_eq!(frame(&ctx, &mut writable, click(initialize)).0.request.as_ref().map(|request| request.id), Some("git.init"));
|
|
}
|