From 46b9d6568d03e8369ebe3655add12756afd9f154 Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 18:16:36 +0000 Subject: [PATCH 01/25] fix(ui): surface renderer and VCS worker failures instead of swallowing them Window/renderer init failures now show a native error dialog (rfd is already a dependency) and make run() return Err so the process exits non-zero, instead of printing to stderr and silently quitting. VcsWorker dispatch methods now route through a guarded send: if the worker thread died, the failure is logged and a RepositoryEvent:: WorkerStopped flows through the runtime event loop, where state turns it into a visible error toast telling the user to restart Diffy. --- src/apprt/vcs_worker.rs | 43 +++++++++++++++++++++++++------------- src/events.rs | 3 +++ src/ui/app.rs | 31 +++++++++++++++++++++++---- src/ui/state/repository.rs | 8 +++++++ 4 files changed, 66 insertions(+), 19 deletions(-) diff --git a/src/apprt/vcs_worker.rs b/src/apprt/vcs_worker.rs index d28be5d3..839e84c7 100644 --- a/src/apprt/vcs_worker.rs +++ b/src/apprt/vcs_worker.rs @@ -22,13 +22,28 @@ const VCS_DIRTY_DEBOUNCE: Duration = Duration::from_millis(150); pub struct VcsWorker { sender: Sender, + event_sender: RuntimeEventSender, } impl VcsWorker { pub fn new(event_sender: RuntimeEventSender) -> Self { let (sender, receiver) = mpsc::channel(); - thread::spawn(move || vcs_worker_loop(event_sender, receiver)); - Self { sender } + let worker_events = event_sender.clone(); + thread::spawn(move || vcs_worker_loop(worker_events, receiver)); + Self { + sender, + event_sender, + } + } + + /// Sends a command to the worker thread. If the thread died, every repo + /// operation would otherwise silently evaporate, so report it through the + /// runtime event loop where state turns it into a visible error. + fn send(&self, command: VcsWorkerCommand) { + if self.sender.send(command).is_err() { + tracing::error!("VCS worker thread stopped; dropping repository command"); + self.event_sender.send(RepositoryEvent::WorkerStopped); + } } pub fn dispatch_sync( @@ -37,7 +52,7 @@ impl VcsWorker { reason: RepositorySyncReason, reporter_generation: Option, ) { - let _ = self.sender.send(VcsWorkerCommand::Sync { + self.send(VcsWorkerCommand::Sync { path, reason, reporter_generation, @@ -50,7 +65,7 @@ impl VcsWorker { file_change: FileChange, operation: FileOperation, ) { - let _ = self.sender.send(VcsWorkerCommand::ApplyOperation { + self.send(VcsWorkerCommand::ApplyOperation { path, file_change, operation, @@ -63,7 +78,7 @@ impl VcsWorker { file_changes: Vec, operation: FileOperation, ) { - let _ = self.sender.send(VcsWorkerCommand::ApplyBatchOperation { + self.send(VcsWorkerCommand::ApplyBatchOperation { path, file_changes, operation, @@ -77,7 +92,7 @@ impl VcsWorker { bucket: ChangeBucket, operation: FileOperation, ) { - let _ = self.sender.send(VcsWorkerCommand::ApplyPatch { + self.send(VcsWorkerCommand::ApplyPatch { path, patch, bucket, @@ -86,7 +101,7 @@ impl VcsWorker { } pub fn dispatch_commit(&self, path: PathBuf, message: String) { - let _ = self.sender.send(VcsWorkerCommand::Commit { path, message }); + self.send(VcsWorkerCommand::Commit { path, message }); } pub fn dispatch_operation_command( @@ -95,7 +110,7 @@ impl VcsWorker { operation: VcsOperation, toast_id: u64, ) { - let _ = self.sender.send(VcsWorkerCommand::RunOperation { + self.send(VcsWorkerCommand::RunOperation { path, operation, toast_id, @@ -103,7 +118,7 @@ impl VcsWorker { } pub fn dispatch_fetch(&self, path: PathBuf, remote: String, toast_id: u64) { - let _ = self.sender.send(VcsWorkerCommand::Fetch { + self.send(VcsWorkerCommand::Fetch { path, remote, toast_id, @@ -118,7 +133,7 @@ impl VcsWorker { force_with_lease: bool, toast_id: u64, ) { - let _ = self.sender.send(VcsWorkerCommand::Push { + self.send(VcsWorkerCommand::Push { path, remote, refspec, @@ -128,7 +143,7 @@ impl VcsWorker { } pub fn dispatch_publish(&self, path: PathBuf, action: Option, toast_id: u64) { - let _ = self.sender.send(VcsWorkerCommand::Publish { + self.send(VcsWorkerCommand::Publish { path, action, toast_id, @@ -136,13 +151,11 @@ impl VcsWorker { } pub fn dispatch_publish_plan(&self, path: PathBuf, toast_id: Option) { - let _ = self - .sender - .send(VcsWorkerCommand::PublishPlan { path, toast_id }); + self.send(VcsWorkerCommand::PublishPlan { path, toast_id }); } pub fn dispatch_pull_ff(&self, path: PathBuf, remote: String, branch: String, toast_id: u64) { - let _ = self.sender.send(VcsWorkerCommand::PullFf { + self.send(VcsWorkerCommand::PullFf { path, remote, branch, diff --git a/src/events.rs b/src/events.rs index 9f25aeaa..40669189 100644 --- a/src/events.rs +++ b/src/events.rs @@ -240,6 +240,9 @@ pub enum RepositoryEvent { branch: String, message: String, }, + /// The VCS worker thread is gone, so dispatched repository commands are + /// being dropped. Surfaced so the user knows repo operations stopped. + WorkerStopped, } #[derive(Debug, Clone)] diff --git a/src/ui/app.rs b/src/ui/app.rs index 465d1421..4d74f2e3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -75,6 +75,9 @@ pub fn run() -> Result<(), Box> { app.hot_reload_pending = Some(hot_reload_pending); } event_loop.run_app(&mut app)?; + if let Some(message) = app.startup_failure.take() { + return Err(message.into()); + } Ok(()) } @@ -98,6 +101,7 @@ struct NativeApp { next_update_check_at: Option, needs_redraw: bool, exit_requested: bool, + startup_failure: Option, has_seen_focus: bool, skip_next_focus_regain_rescan: bool, rescan_on_next_focus: bool, @@ -147,6 +151,7 @@ impl NativeApp { .then(|| Instant::now() + UPDATE_POLL_INTERVAL), needs_redraw: true, exit_requested: false, + startup_failure: None, has_seen_focus: false, skip_next_focus_regain_rescan: true, rescan_on_next_focus: false, @@ -162,6 +167,20 @@ impl NativeApp { self.needs_redraw = true; } + /// Window or renderer setup failed before anything can be drawn, so there + /// is no in-app surface for the error. Show a native message box, then + /// exit the loop; `run()` turns the stored failure into a non-zero exit. + fn fail_startup(&mut self, event_loop: &ActiveEventLoop, message: String) { + tracing::error!("startup failed: {message}"); + rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("Diffy failed to start") + .set_description(message.as_str()) + .show(); + self.startup_failure = Some(message); + event_loop.exit(); + } + fn paint_tooltip(&mut self) { use crate::render::{ BorderPrimitive, FontKind, FontWeight, Rect, RoundedRectPrimitive, ShadowPrimitive, @@ -998,8 +1017,10 @@ impl ApplicationHandler for NativeApp { self.window = Some(window); } Err(error) => { - eprintln!("failed to create renderer: {error}"); - event_loop.exit(); + self.fail_startup( + event_loop, + format!("Could not initialize the GPU renderer: {error}"), + ); return; } } @@ -1009,8 +1030,10 @@ impl ApplicationHandler for NativeApp { self.position_traffic_lights(); } Err(error) => { - eprintln!("failed to create native window: {error}"); - event_loop.exit(); + self.fail_startup( + event_loop, + format!("Could not create the native window: {error}"), + ); } } } diff --git a/src/ui/state/repository.rs b/src/ui/state/repository.rs index fb2154ed..a10856eb 100644 --- a/src/ui/state/repository.rs +++ b/src/ui/state/repository.rs @@ -43,6 +43,14 @@ pub(super) fn reduce_event(state: &mut AppState, event: RepositoryEvent) -> Vec< } Vec::new() } + RepositoryEvent::WorkerStopped => { + state + .workspace + .status_operation_pending + .set(&state.store, false); + state.push_error("Version control worker stopped. Restart Diffy."); + Vec::new() + } RepositoryEvent::FileOperationFailed { path, message } => { if state .compare From a205dafacd0c792c99b531d240d77bc179027b21 Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 18:36:15 +0000 Subject: [PATCH 02/25] perf(ui): eliminate per-frame clones in frame construction Frame builds were cloning editor navigation vectors and compare-progress state every frame even when nothing changed: - EditorState.hunk_positions / file_positions / search_match_y_positions and SearchState.matches are now Arc-shared, so per-frame store snapshots and set_if_changed write-backs are pointer bumps/compares instead of Vec clones. - EditorElement::rebuild_navigation_positions previously iterated every display row (and scanned rows per search match) every frame. It now memoizes the computed positions, keyed on row rebuilds (layout_key changes invalidate via clear_document_caches) and on the search match set (Arc identity), and hands out shared Arcs. - compare_progress is now Signal>> so the toolbar's per-frame snapshot clones a pointer rather than the LoadingSubject label strings; writers mutate via Arc::make_mut. Verified with cargo check --workspace --all-targets and the full diffy lib test suite (401 passed). --- src/editor/diff/element.rs | 80 +++++++++++++++++++++++++++++--------- src/editor/diff/state.rs | 34 ++++++++++------ src/ui/state/mod.rs | 53 ++++++++++++------------- 3 files changed, 110 insertions(+), 57 deletions(-) diff --git a/src/editor/diff/element.rs b/src/editor/diff/element.rs index b575fd86..c0e0c8d5 100644 --- a/src/editor/diff/element.rs +++ b/src/editor/diff/element.rs @@ -28,7 +28,9 @@ use super::render_doc::{ RenderLine, RenderRowKind, RunRange, STYLE_FLAG_CHANGE, STYLE_FLAG_UNCHANGED_CTX, StyleRun, advance_display_col, }; -use super::state::{EditorState, ViewportTextPoint, ViewportTextSelection, ViewportTextSide}; +use super::state::{ + EditorState, SearchMatch, ViewportTextPoint, ViewportTextSelection, ViewportTextSide, +}; use super::strip_layout::{StripLayout, build_strip_layouts, visible_strip_range}; const BASE_VIEWPORT_PADDING: f32 = 14.0; @@ -324,6 +326,16 @@ pub struct EditorElement { sticky_header_hit: Option<(Rect, String)>, file_header_hits: Vec, mouse_pos: Option<(f32, f32)>, + /// Memoized navigation positions. Hunk/file positions depend only on + /// `rows` (rebuilt when `layout_key` changes); search Y positions + /// additionally depend on the search match set. Recomputing these every + /// frame meant iterating every row per frame, so cache and hand out + /// shared Arcs instead. + nav_positions_valid: bool, + nav_hunk_positions: Arc>, + nav_file_positions: Arc>, + nav_search_matches: Option>>, + nav_search_y_positions: Arc>, } #[derive(Debug, Clone)] @@ -366,6 +378,11 @@ impl Default for EditorElement { sticky_header_hit: None, file_header_hits: Vec::new(), mouse_pos: None, + nav_positions_valid: false, + nav_hunk_positions: Arc::default(), + nav_file_positions: Arc::default(), + nav_search_matches: None, + nav_search_y_positions: Arc::default(), } } } @@ -870,27 +887,50 @@ impl EditorElement { } } - fn rebuild_navigation_positions(&self, state: &mut EditorState) { - state.hunk_positions.clear(); - state.file_positions.clear(); - for row in &self.rows { - if row.kind == RenderRowKind::HunkSeparator as u8 { - state.hunk_positions.push(row.y_px); - } else if row.kind == RenderRowKind::FileHeader as u8 { - state.file_positions.push(row.y_px); + fn rebuild_navigation_positions(&mut self, state: &mut EditorState) { + if !self.nav_positions_valid { + let mut hunk_positions = Vec::new(); + let mut file_positions = Vec::new(); + for row in &self.rows { + if row.kind == RenderRowKind::HunkSeparator as u8 { + hunk_positions.push(row.y_px); + } else if row.kind == RenderRowKind::FileHeader as u8 { + file_positions.push(row.y_px); + } } + self.nav_hunk_positions = Arc::new(hunk_positions); + self.nav_file_positions = Arc::new(file_positions); + // Search Y positions are derived from row geometry too. + self.nav_search_matches = None; + self.nav_positions_valid = true; } + state.hunk_positions = Arc::clone(&self.nav_hunk_positions); + state.file_positions = Arc::clone(&self.nav_file_positions); - state.search_match_y_positions.clear(); if state.search.open && !state.search.matches.is_empty() { - for m in &state.search.matches { - let y = self - .rows - .iter() - .find(|r| !r.is_block() && r.line_index == m.line_index) - .map(|r| r.y_px) - .unwrap_or(0); - state.search_match_y_positions.push(y); + let cached = self + .nav_search_matches + .as_ref() + .is_some_and(|matches| Arc::ptr_eq(matches, &state.search.matches)); + if !cached { + let mut y_positions = Vec::with_capacity(state.search.matches.len()); + for m in state.search.matches.iter() { + let y = self + .rows + .iter() + .find(|r| !r.is_block() && r.line_index == m.line_index) + .map(|r| r.y_px) + .unwrap_or(0); + y_positions.push(y); + } + self.nav_search_y_positions = Arc::new(y_positions); + self.nav_search_matches = Some(Arc::clone(&state.search.matches)); + } + state.search_match_y_positions = Arc::clone(&self.nav_search_y_positions); + } else { + self.nav_search_matches = None; + if !state.search_match_y_positions.is_empty() { + state.search_match_y_positions = Arc::default(); } } } @@ -2924,6 +2964,10 @@ impl EditorElement { self.wrapped_text_cache.clear(); self.text_layout_cache.clear(); self.gutter_text_cache.clear(); + // Rows changed (or went away) — navigation positions must be + // recomputed from the new row geometry. + self.nav_positions_valid = false; + self.nav_search_matches = None; } fn sync_theme_cache(&mut self, theme: &Theme) { diff --git a/src/editor/diff/state.rs b/src/editor/diff/state.rs index 2ad26264..59d54253 100644 --- a/src/editor/diff/state.rs +++ b/src/editor/diff/state.rs @@ -1,4 +1,5 @@ use std::collections::BTreeSet; +use std::sync::Arc; use halogen::Store; @@ -81,7 +82,8 @@ impl ViewportTextSelection { pub struct SearchState { pub open: bool, pub query: String, - pub matches: Vec, + /// Shared so per-frame snapshots are pointer bumps, not Vec clones. + pub matches: Arc>, pub active_index: Option, } @@ -90,7 +92,7 @@ impl Default for SearchState { Self { open: false, query: String::new(), - matches: Vec::new(), + matches: Arc::default(), active_index: None, } } @@ -113,11 +115,13 @@ pub struct EditorState { pub visible_row_end: Option, pub focused: bool, pub review_enabled: bool, - pub hunk_positions: Vec, - pub file_positions: Vec, + /// Arc-shared so per-frame snapshot reads and `set_if_changed` + /// write-backs are pointer swaps/compares instead of Vec clones. + pub hunk_positions: Arc>, + pub file_positions: Arc>, #[store(flatten)] pub search: SearchState, - pub search_match_y_positions: Vec, + pub search_match_y_positions: Arc>, pub line_selection: LineSelection, pub text_selection: Option, } @@ -207,10 +211,10 @@ impl Default for EditorState { visible_row_end: None, focused: false, review_enabled: false, - hunk_positions: Vec::new(), - file_positions: Vec::new(), + hunk_positions: Arc::default(), + file_positions: Arc::default(), search: SearchState::default(), - search_match_y_positions: Vec::new(), + search_match_y_positions: Arc::default(), line_selection: LineSelection::default(), text_selection: None, } @@ -228,9 +232,17 @@ impl EditorState { self.visible_row_start = None; self.visible_row_end = None; self.review_enabled = false; - self.hunk_positions.clear(); - self.file_positions.clear(); - self.search_match_y_positions.clear(); + // Swap in empty Arcs (only when non-empty, to avoid churning + // allocations when this runs every frame without a document). + if !self.hunk_positions.is_empty() { + self.hunk_positions = Arc::default(); + } + if !self.file_positions.is_empty() { + self.file_positions = Arc::default(); + } + if !self.search_match_y_positions.is_empty() { + self.search_match_y_positions = Arc::default(); + } self.line_selection.clear(); self.text_selection = None; } diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index 2e1217dc..60f1dd19 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -3800,7 +3800,9 @@ fn locate_sparse_height( #[derive(Debug)] pub struct AppState { pub workspace_mode: Signal, - pub compare_progress: Signal>, + /// Arc-wrapped so per-frame UI snapshots clone a pointer, not the + /// label strings inside. + pub compare_progress: Signal>>, pub app_view: Signal, pub settings_section: Signal, pub keymap_capture: Signal>, @@ -3866,7 +3868,7 @@ impl Default for AppState { let text_focused = store.create_memo(move |s| s.read(focus).is_some_and(|t| t.is_text_field())); let workspace_mode = store.create(WorkspaceMode::default()); - let compare_progress = store.create(None::); + let compare_progress = store.create(None::>); let app_view = store.create(AppView::default()); let settings_section = store.create(SettingsSection::default()); let keymap_capture = store.create(None::); @@ -3998,7 +4000,7 @@ impl AppState { } else { WorkspaceMode::Empty }); - let compare_progress = store.create(None::); + let compare_progress = store.create(None::>); let app_view = store.create(AppView::default()); let settings_section = store.create(SettingsSection::default()); let keymap_capture = store.create(None::); @@ -4151,7 +4153,7 @@ impl AppState { .to_owned(); state.compare_progress.set( &state.store, - Some(CompareProgress { + Some(Arc::new(CompareProgress { generation: boot_gen, phase: ComparePhase::OpeningRepo, subject: if bootstrap_compare_started { @@ -4170,7 +4172,7 @@ impl AppState { reveal_at_ms: COMPARE_REVEAL_DELAY_MS, file_count_total: None, files_loaded: 0, - }), + })), ); effects.push( @@ -4416,7 +4418,7 @@ impl AppState { let reveal_at_ms = started_at_ms.saturating_add(COMPARE_REVEAL_DELAY_MS); self.compare_progress.set( &self.store, - Some(CompareProgress { + Some(Arc::new(CompareProgress { generation: next_gen, phase: ComparePhase::OpeningRepo, subject: LoadingSubject::RepoOpen { name: repo_name }, @@ -4424,7 +4426,7 @@ impl AppState { reveal_at_ms, file_count_total: None, files_loaded: 0, - }), + })), ); vec![ @@ -4938,6 +4940,7 @@ impl AppState { // small-file fast paths, is cleared by install_compare_active_file). self.compare_progress.update(&self.store, |slot| { if let Some(p) = slot.as_mut() { + let p = Arc::make_mut(p); p.file_count_total = Some(total_files); p.phase = ComparePhase::PopulatingList; } @@ -6334,7 +6337,7 @@ impl AppState { let right_label = profile.compare_ref_display_label(&right_ref); self.compare_progress.set( &self.store, - Some(CompareProgress { + Some(Arc::new(CompareProgress { generation: next_gen, phase: ComparePhase::OpeningRepo, subject: LoadingSubject::Compare { @@ -6345,7 +6348,7 @@ impl AppState { reveal_at_ms, file_count_total: None, files_loaded: 0, - }), + })), ); let renderer = self.compare.renderer.get(&self.store); @@ -6400,6 +6403,7 @@ impl AppState { if let Some(p) = slot.as_mut() && p.generation == generation { + let p = Arc::make_mut(p); // Pull counts out of LoadingFiles so the determinate bar // reads directly from durable struct fields (cheaper than // pattern-matching in the render path, and lets the total @@ -9303,7 +9307,7 @@ impl AppState { // file…". Subsequent selections don't touch compare_progress. self.compare_progress.update(&self.store, |slot| { if let Some(p) = slot.as_mut() { - p.phase = ComparePhase::RenderingFirstFile; + Arc::make_mut(p).phase = ComparePhase::RenderingFirstFile; } }); @@ -11654,10 +11658,7 @@ impl AppState { fn close_search(&mut self) { self.editor.search.open.set(&self.store, false); - self.editor - .search - .matches - .update(&self.store, |matches| matches.clear()); + self.editor.search.matches.set(&self.store, Arc::default()); self.editor.search.active_index.set(&self.store, None); self.set_focus(Some(FocusTarget::Editor)); } @@ -11665,10 +11666,7 @@ impl AppState { fn recompute_search_matches(&mut self) { use crate::editor::diff::state::MatchSide; - self.editor - .search - .matches - .update(&self.store, |matches| matches.clear()); + self.editor.search.matches.set(&self.store, Arc::default()); self.editor.search.active_index.set(&self.store, None); let query = self @@ -11723,7 +11721,10 @@ impl AppState { }); let has_matches = !new_matches.is_empty(); - self.editor.search.matches.set(&self.store, new_matches); + self.editor + .search + .matches + .set(&self.store, Arc::new(new_matches)); if has_matches { self.editor.search.active_index.set(&self.store, Some(0)); } @@ -11805,15 +11806,11 @@ impl AppState { self.editor.hovered_hunk_index.set(&self.store, None); self.editor.visible_row_start.set(&self.store, None); self.editor.visible_row_end.set(&self.store, None); - self.editor - .hunk_positions - .update(&self.store, |v| v.clear()); - self.editor - .file_positions - .update(&self.store, |v| v.clear()); + self.editor.hunk_positions.set(&self.store, Arc::default()); + self.editor.file_positions.set(&self.store, Arc::default()); self.editor .search_match_y_positions - .update(&self.store, |v| v.clear()); + .set(&self.store, Arc::default()); self.editor .line_selection .update(&self.store, |ls| ls.clear()); @@ -14819,8 +14816,8 @@ diff --git a/src/lib.rs b/src/lib.rs .compare_progress .with(&state.store, |p| p.clone()) .expect("progress seeded for repo open"); - match progress.subject { - LoadingSubject::RepoOpen { ref name } => { + match &progress.subject { + LoadingSubject::RepoOpen { name } => { assert_eq!(name, "linux"); } other => panic!("expected RepoOpen subject, got {other:?}"), From f1b961dc5b0a622163d297f5b8d4f0f5bacc0f06 Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 18:47:10 +0000 Subject: [PATCH 03/25] perf(render): cache blur bind groups and tighten flatten_scene hot path - Cache the texture bind group on each PooledTexture entry: layout and sampler never change, so the blur path's three per-frame bind group creations (scene/h/v) become lookups after the first frame. The bind group is dropped with its entry when the pool trims it, so resize invalidation falls out of the existing pooling lifecycle. - Skip icon SVG rasterization (and the RGBA copy out of the icon cache) in flatten_scene when the icon's texture is already in the GPU image cache; draw_images now gates on the cache lookup alone, which already covers never-uploaded images. - Replace Range in QuadDrawCommand with start/end fields, removing the per-draw .clone() noise in draw_layers and making the command Copy. TextPrimitive/RichTextPrimitive clones in flatten_scene were left as-is: their payloads are Arc/Arc<[RichTextSpan]>, so the clones are already cheap refcount bumps. --- src/render/renderer.rs | 96 +++++++++++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 25 deletions(-) diff --git a/src/render/renderer.rs b/src/render/renderer.rs index 85813e5c..f3a0aee2 100644 --- a/src/render/renderer.rs +++ b/src/render/renderer.rs @@ -67,12 +67,20 @@ pub enum RenderError { PngWrite(String), } +/// GPU-resident images keyed by content hash (icons, avatars). +type ImageCache = HashMap; + // --------------------------------------------------------------------------- // TexturePool — reusable offscreen render targets // --------------------------------------------------------------------------- struct PooledTexture { view: wgpu::TextureView, + /// Lazily created bind group for sampling this texture. The view is + /// immutable for the entry's lifetime and the layout/sampler never change, + /// so the bind group is created once and reused across frames. It is + /// dropped together with the entry when `trim_unused` evicts it. + bind_group: Option, width: u32, height: u32, in_use: bool, @@ -206,6 +214,7 @@ impl TexturePool { let _ = texture; self.textures.push(PooledTexture { view, + bind_group: None, width: w, height: h, in_use: true, @@ -222,6 +231,24 @@ impl TexturePool { &self.textures[target.pool_index].view } + /// Get the cached bind group for sampling `target`, creating it on first + /// use. Returns an owned handle (`wgpu::BindGroup` is internally + /// refcounted) so multiple targets can be bound in the same pass. + fn bind_group( + &mut self, + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + sampler: &wgpu::Sampler, + target: &OffscreenTarget, + ) -> wgpu::BindGroup { + let entry = &mut self.textures[target.pool_index]; + let view = &entry.view; + entry + .bind_group + .get_or_insert_with(|| create_texture_bind_group(device, layout, view, sampler)) + .clone() + } + fn release(&mut self, target: OffscreenTarget) { self.textures[target.pool_index].in_use = false; self.textures[target.pool_index].last_used_frame = self.frame; @@ -254,7 +281,7 @@ pub struct Renderer { sampler: wgpu::Sampler, texture_pool: TexturePool, instance_buffer_pool: TransientBufferPool, - image_cache: HashMap, + image_cache: ImageCache, viewport_buffer: wgpu::Buffer, viewport_bind_group: wgpu::BindGroup, font_system: FontSystem, @@ -858,7 +885,7 @@ impl Renderer { }, ); - let flattened = flatten_scene(scene, viewport_rect); + let flattened = flatten_scene(scene, viewport_rect, &self.image_cache); // Owned target texture (COPY_SRC so we can read it back). Format matches // the surface format the pipelines were built against. @@ -1142,7 +1169,7 @@ impl Renderer { bytemuck::bytes_of(&viewport_uniform), ); - let flattened = flatten_scene(scene, viewport_rect); + let flattened = flatten_scene(scene, viewport_rect, &self.image_cache); let surface = self .surface @@ -1448,23 +1475,25 @@ impl Renderer { let h_target = self.texture_pool.acquire(&self.device, sw, sh); let v_target = self.texture_pool.acquire(&self.device, sw, sh); - let scene_bind = create_texture_bind_group( + // Bind groups are cached per pooled texture: layout and sampler + // never change, so a steady-state blur frame creates none. + let scene_bind = self.texture_pool.bind_group( &self.device, &self.texture_bind_group_layout, - self.texture_pool.view(&scene_target), &self.sampler, + &scene_target, ); - let h_bind = create_texture_bind_group( + let h_bind = self.texture_pool.bind_group( &self.device, &self.texture_bind_group_layout, - self.texture_pool.view(&h_target), &self.sampler, + &h_target, ); - let v_bind = create_texture_bind_group( + let v_bind = self.texture_pool.bind_group( &self.device, &self.texture_bind_group_layout, - self.texture_pool.view(&v_target), &self.sampler, + &v_target, ); let sigma = (blur.blur_radius * 0.5).max(0.5); @@ -1739,7 +1768,7 @@ fn draw_layers<'pass>( continue; }; pass.set_scissor_rect(sx, sy, sw, sh); - pass.draw(0..4, command.instance_range.clone()); + pass.draw(0..4, command.instance_range()); } } @@ -1753,7 +1782,7 @@ fn draw_layers<'pass>( continue; }; pass.set_scissor_rect(sx, sy, sw, sh); - pass.draw(0..4, command.instance_range.clone()); + pass.draw(0..4, command.instance_range()); } } @@ -1767,7 +1796,7 @@ fn draw_layers<'pass>( continue; }; pass.set_scissor_rect(sx, sy, sw, sh); - pass.draw(0..4, command.instance_range.clone()); + pass.draw(0..4, command.instance_range()); } } } @@ -1781,15 +1810,15 @@ fn draw_images<'pass>( queue: &wgpu::Queue, blit_pipeline: &'pass wgpu::RenderPipeline, viewport_bind_group: &'pass wgpu::BindGroup, - image_cache: &'pass HashMap, + image_cache: &'pass ImageCache, viewport_w: u32, viewport_h: u32, ) { for img in images { - if img.primitive.rgba.is_empty() || img.primitive.width == 0 || img.primitive.height == 0 { - continue; - } - + // The cache lookup is the gate: icons resolved from a prior frame + // carry an empty `rgba` (rasterization skipped) but still draw via + // their uploaded texture. Images that were never uploadable simply + // miss the cache. let bind_group = match image_cache.get(&img.primitive.cache_key) { Some((_, _, bg)) => bg, None => continue, @@ -2223,11 +2252,19 @@ pub(super) struct ClippedRichText { pub(super) clip: Rect, } +#[derive(Clone, Copy)] struct QuadDrawCommand { - instance_range: std::ops::Range, + instance_start: u32, + instance_end: u32, clip: Rect, } +impl QuadDrawCommand { + fn instance_range(&self) -> std::ops::Range { + self.instance_start..self.instance_end + } +} + #[derive(Debug)] pub(super) struct CachedTextBuffer { pub(super) buffer: Buffer, @@ -2303,7 +2340,7 @@ impl ActiveClip { } } -fn flatten_scene(scene: &Scene, viewport: Rect) -> FlattenedScene { +fn flatten_scene(scene: &Scene, viewport: Rect, image_cache: &ImageCache) -> FlattenedScene { use std::collections::BTreeMap; let mut clips = vec![ActiveClip::root(viewport)]; @@ -2515,8 +2552,14 @@ fn flatten_scene(scene: &Scene, viewport: Rect) -> FlattenedScene { let px_size = icon.rect.width.max(icon.rect.height).ceil() as u32; let cache_key = crate::ui::icons::cache_key(&icon.name, px_size, icon.color); - let (rgba, w, h) = - crate::ui::icons::rasterize_svg(&icon.name, px_size, icon.color); + // Only rasterize (and copy RGBA out of the icon cache) + // when the texture is not on the GPU yet; once + // uploaded, the cache key alone is enough to draw. + let (rgba, w, h) = if image_cache.contains_key(&cache_key) { + (Vec::new(), 0, 0) + } else { + crate::ui::icons::rasterize_svg(&icon.name, px_size, icon.color) + }; let zl = current_z!(); zl.images.push(ClippedImage { primitive: crate::render::scene::ImagePrimitive { @@ -2617,7 +2660,8 @@ fn build_quad_instances(quads: &[ClippedQuad]) -> (Vec, Vec = flat .z_layers .iter() From 09420fd1cdc83e2ea2bebf45d9abc264c931eb2e Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 18:57:27 +0000 Subject: [PATCH 04/25] style(ui): promote hardcoded layout constants to design tokens Move one-off pixel constants from settings_page.rs (NAV_WIDTH, CONTENT_MAX_WIDTH, KEYMAPS_MAX_WIDTH) and shell.rs (COMPOSER_H_BASE, INLINE_REPLY_BODY_H) into Sz in src/ui/design.rs as named tokens. Values are unchanged; no visual behavior change. --- src/ui/design.rs | 9 +++++++++ src/ui/settings_page.rs | 10 +++------- src/ui/shell.rs | 13 +++---------- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/ui/design.rs b/src/ui/design.rs index 363bbcd9..9b37941b 100644 --- a/src/ui/design.rs +++ b/src/ui/design.rs @@ -66,6 +66,15 @@ impl Sz { pub const COMMIT_BOX_H: f32 = 140.0; pub const MAIN_SURFACE_MIN_W: f32 = 320.0; pub const AUTH_MODAL_HEIGHT: f32 = 320.0; + pub const SETTINGS_NAV_W: f32 = 220.0; + pub const SETTINGS_CONTENT_MAX_W: f32 = 720.0; + pub const SETTINGS_KEYMAPS_MAX_W: f32 = 1500.0; + /// Unscaled height reserved for the open review-comment composer block. + pub const COMPOSER_H: f32 = 248.0; + /// Unscaled height of the editor/preview body region inside the inline + /// reply composer. Fixed (not flex) so the card's measured height is + /// determinate. + pub const INLINE_REPLY_BODY_H: f32 = 112.0; pub const PICKER_MAX_ROWS: usize = 8; } diff --git a/src/ui/settings_page.rs b/src/ui/settings_page.rs index 800f1ecf..96a12ba9 100644 --- a/src/ui/settings_page.rs +++ b/src/ui/settings_page.rs @@ -22,10 +22,6 @@ use crate::ui::state::{AppState, FocusTarget, SettingsSection, UpdateState}; use crate::ui::style::Styled; use crate::ui::theme::{Color, Theme, ThemeMode}; -const NAV_WIDTH: f32 = 220.0; -const CONTENT_MAX_WIDTH: f32 = 720.0; -const KEYMAPS_MAX_WIDTH: f32 = 1500.0; - pub fn settings_page(state: &AppState, theme: &Theme) -> AnyElement { let tc = &theme.colors; let active = state.settings_section.get(&state.store); @@ -46,7 +42,7 @@ pub fn settings_page(state: &AppState, theme: &Theme) -> AnyElement { fn nav_panel(_state: &AppState, theme: &Theme, active: SettingsSection) -> AnyElement { let tc = &theme.colors; let scale = theme.metrics.ui_scale(); - let nav_w = (NAV_WIDTH * scale).round(); + let nav_w = (Sz::SETTINGS_NAV_W * scale).round(); let entries: Vec = SettingsSection::ALL .iter() @@ -118,7 +114,7 @@ fn section_content(state: &AppState, theme: &Theme, section: SettingsSection) -> let tc = &theme.colors; let scale = theme.metrics.ui_scale(); - let max_w = (CONTENT_MAX_WIDTH * scale).round(); + let max_w = (Sz::SETTINGS_CONTENT_MAX_W * scale).round(); let (title, description, body) = match section { SettingsSection::Appearance => ( @@ -169,7 +165,7 @@ fn section_content(state: &AppState, theme: &Theme, section: SettingsSection) -> fn keymaps_layout(state: &AppState, theme: &Theme) -> AnyElement { let tc = &theme.colors; let scale = theme.metrics.ui_scale(); - let inner_max_w = (KEYMAPS_MAX_WIDTH * scale).round(); + let inner_max_w = (Sz::SETTINGS_KEYMAPS_MAX_W * scale).round(); let capture = state.keymap_capture.get(&state.store); let scroll_px = state.keymaps_scroll_top_px.get(&state.store); let total_h = state.keymaps_content_height_px.get(&state.store); diff --git a/src/ui/shell.rs b/src/ui/shell.rs index f7ed268a..d6e07369 100644 --- a/src/ui/shell.rs +++ b/src/ui/shell.rs @@ -27,13 +27,6 @@ use crate::ui::window_chrome; pub use halogen::CursorHint; -/// Unscaled height reserved for the open review-comment composer block. -const COMPOSER_H_BASE: f32 = 248.0; - -/// Unscaled height of the editor/preview body region inside the inline reply -/// composer. Fixed (not flex) so the card's measured height is determinate. -const INLINE_REPLY_BODY_H: f32 = 112.0; - #[derive(Debug, Clone, Default)] pub struct UiFrame { pub scene: Scene, @@ -435,7 +428,7 @@ pub fn build_ui_frame( editor.blocks_mut(), doc, &review_card_heights, - (COMPOSER_H_BASE * ui_scale).round() as u16, + (Sz::COMPOSER_H * ui_scale).round() as u16, ); editor.set_hunk_expand_caps(Vec::new()); } else if let Some(active_file) = active_file_snapshot.as_ref() { @@ -497,7 +490,7 @@ pub fn build_ui_frame( &active_file.render_doc, rside, line, - (COMPOSER_H_BASE * ui_scale).round() as u16, + (Sz::COMPOSER_H * ui_scale).round() as u16, ); } editor.set_hunk_expand_caps(caps); @@ -1518,7 +1511,7 @@ pub(crate) fn build_inline_reply_composer( .active_file .get(&state.store) .map(|file| file.path); - let body_h = (INLINE_REPLY_BODY_H * ui_scale).round(); + let body_h = (Sz::INLINE_REPLY_BODY_H * ui_scale).round(); view! { ui_scale,
{composer_editor_box(state, theme, ui_scale, width, preview, preview_path.as_deref(), Some(body_h))} From 0ff54a6baf3765eb2445a2904f130314f2bdd21e Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 19:07:56 +0000 Subject: [PATCH 05/25] fix(ui): restore focus to previous target when overlays close Overlay stack entries already record a focus_return target, but two paths dropped it: open_confirmation cleared focus before push_overlay snapshotted it, and clear_overlays dismissed the whole stack while leaving focus dangling on the closed surface (e.g. the command palette input after running a command). Restore the bottom entry's pre-overlay target in clear_overlays, let push_overlay capture the live focus for confirmations, and reorder the compare-load path so its explicit file-list focus wins over the restore. Focus restoration runs through set_focus, so text fields get cursor and IME/text-input state back the same way normal focusing does. --- src/ui/state/mod.rs | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index 60f1dd19..c837ccec 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -3239,12 +3239,19 @@ impl AppState { } pub fn clear_overlays(&mut self) { - self.overlays - .stack - .update(&self.store, |stack| stack.clear()); + // The bottom-most entry recorded the focus from before any overlay + // opened; restore it so focus never dangles on a dismissed surface. + let mut focus_return: Option> = None; + self.overlays.stack.update(&self.store, |stack| { + focus_return = stack.first().map(|entry| entry.focus_return); + stack.clear(); + }); self.reset_picker(); self.reset_command_palette(); self.reset_confirmation(); + if let Some(target) = focus_return { + self.set_focus(target); + } } } @@ -4958,9 +4965,11 @@ impl AppState { self.file_list .commits_scroll_offset_px .set(&self.store, 0.0); - self.set_focus(Some(FocusTarget::FileList)); self.editor_clear_document(); + // Clear overlays before claiming focus so the overlay restore target + // does not clobber the file list focus below. self.clear_overlays(); + self.set_focus(Some(FocusTarget::FileList)); let preferred_index = self .startup @@ -7243,7 +7252,9 @@ impl AppState { .confirmation .action .set(&self.store, Some(action)); - self.set_focus(None); + // Let push_overlay snapshot the current focus as the restore target + // before it moves focus off the field; closing the confirmation then + // returns focus (and IME state) to wherever the user was. self.push_overlay(OverlaySurface::Confirmation, None); } @@ -13939,6 +13950,53 @@ diff --git a/src/lib.rs b/src/lib.rs ); } + #[test] + fn closing_overlays_restores_previous_focus() { + let mut state = AppState::default(); + state.apply_action(crate::actions::AppAction::SetFocus(Some( + FocusTarget::FileList, + ))); + + state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); + assert_eq!( + state.focus.get(&state.store), + Some(FocusTarget::CommandPaletteInput) + ); + + // Each nested overlay records its own restore target. + state.apply_action(crate::actions::OverlayAction::OpenGitHubAuthModal); + assert_eq!( + state.focus.get(&state.store), + Some(FocusTarget::AuthPrimaryAction) + ); + + state.apply_action(crate::actions::OverlayAction::CloseOverlay); + assert_eq!(state.overlays_top(), Some(OverlaySurface::CommandPalette)); + assert_eq!( + state.focus.get(&state.store), + Some(FocusTarget::CommandPaletteInput) + ); + + state.apply_action(crate::actions::OverlayAction::CloseOverlay); + assert_eq!(state.overlays_top(), None); + assert_eq!(state.focus.get(&state.store), Some(FocusTarget::FileList)); + } + + #[test] + fn clearing_overlay_stack_restores_pre_overlay_focus() { + let mut state = AppState::default(); + state.apply_action(crate::actions::AppAction::SetFocus(Some( + FocusTarget::FileList, + ))); + state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); + state.apply_action(crate::actions::OverlayAction::OpenGitHubAuthModal); + + state.clear_overlays(); + + assert_eq!(state.overlays_top(), None); + assert_eq!(state.focus.get(&state.store), Some(FocusTarget::FileList)); + } + #[test] fn stage_hunk_at_stages_the_given_index() { let mut state = status_state_with_two_hunks(); From 77417532b17be21fa3213ff6ae78bfd7a4dd2cff Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 19:20:20 +0000 Subject: [PATCH 06/25] test(vcs): add unit tests for the git adapter Cover the GitRepository read paths with temp-repo tests: backend detection, ref resolution (including the @ shorthand), snapshot building (refs, history, status buckets), file content lookup at a revision, and edge cases (empty repo, detached HEAD, staged rename, binary file), plus the pure request-mapping and publish helpers. --- src/core/vcs/git/adapter.rs | 491 ++++++++++++++++++++++++++++++++++++ 1 file changed, 491 insertions(+) diff --git a/src/core/vcs/git/adapter.rs b/src/core/vcs/git/adapter.rs index 2eae67d0..4e01935d 100644 --- a/src/core/vcs/git/adapter.rs +++ b/src/core/vcs/git/adapter.rs @@ -690,3 +690,494 @@ fn sanitize_status_bits(status: StatusBits) -> StatusBits { | StatusBits::WT_RENAMED | StatusBits::CONFLICTED) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use git2::{Oid, Repository, Signature}; + use tempfile::TempDir; + + use super::{ + GitBackend, GitRepository, completed_publish_label, git_compare_spec, + git_location_or_error, preferred_git_remote, status_item_from_file_change, upstream_pair, + }; + use crate::core::compare::{CompareMode, LayoutMode, RendererKind}; + use crate::core::vcs::backend::{VcsBackend, VcsRepository}; + use crate::core::vcs::git::{BranchInfo, StatusScope, WORKDIR_REF}; + use crate::core::vcs::model::{ + ChangeBucket, FileChange, FileChangeStatus, RefKind, RevisionId, VcsCompareRequest, + VcsCompareSpec, VcsKind, + }; + use crate::events::RepositorySyncReason; + + fn commit_file( + repo: &Repository, + relative_path: &str, + content: &[u8], + message: &str, + ) -> String { + // Pin `core.autocrlf=false` so LF content written from these tests + // survives index round-trips unchanged on every platform. + repo.config() + .and_then(|mut cfg| cfg.set_bool("core.autocrlf", false)) + .expect("disable autocrlf on test repo"); + + let workdir = repo.workdir().expect("repo workdir"); + let full_path = workdir.join(relative_path); + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&full_path, content).unwrap(); + + let mut index = repo.index().unwrap(); + index.add_path(Path::new(relative_path)).unwrap(); + index.write().unwrap(); + + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let signature = Signature::now("Diffy", "diffy@example.com").unwrap(); + let parents = repo + .head() + .ok() + .and_then(|head| head.target()) + .map(|oid| repo.find_commit(oid).unwrap()) + .into_iter() + .collect::>(); + let parent_refs = parents.iter().collect::>(); + repo.commit( + Some("HEAD"), + &signature, + &signature, + message, + &tree, + &parent_refs, + ) + .unwrap() + .to_string() + } + + fn open_adapter(path: &Path) -> GitRepository { + GitRepository::open(git_location_or_error(path).unwrap()).unwrap() + } + + fn request(spec: VcsCompareSpec) -> VcsCompareRequest { + VcsCompareRequest { + spec, + layout: LayoutMode::Unified, + renderer: RendererKind::Builtin, + } + } + + fn remote_branch(name: &str) -> BranchInfo { + BranchInfo { + name: name.to_owned(), + is_remote: true, + is_head: false, + target_oid: "0".repeat(40), + upstream: None, + ahead_behind: None, + } + } + + #[test] + fn detect_reports_repo_location_and_ignores_plain_directories() { + let repo_dir = TempDir::new().unwrap(); + Repository::init(repo_dir.path()).unwrap(); + + let location = GitBackend + .detect(repo_dir.path()) + .unwrap() + .expect("repository detected"); + assert_eq!(location.kind, VcsKind::GIT); + assert_eq!( + location.workspace_root.canonicalize().unwrap(), + repo_dir.path().canonicalize().unwrap() + ); + assert!(location.store_root.is_some()); + + let plain_dir = TempDir::new().unwrap(); + assert!(GitBackend.detect(plain_dir.path()).unwrap().is_none()); + } + + #[test] + fn resolve_ref_returns_short_oid_and_summary() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + let oid = commit_file(&repo, "src/lib.rs", b"hello\n", "initial commit"); + + let mut adapter = open_adapter(repo_dir.path()); + let (short_oid, summary) = adapter.resolve_ref("HEAD").unwrap(); + + assert!(oid.starts_with(&short_oid), "{short_oid} prefixes {oid}"); + assert!(short_oid.len() < oid.len()); + assert_eq!(summary, "initial commit"); + } + + #[test] + fn resolve_ref_normalizes_at_shorthand_to_head() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + commit_file(&repo, "src/lib.rs", b"one\n", "first"); + commit_file(&repo, "src/lib.rs", b"two\n", "second"); + + let mut adapter = open_adapter(repo_dir.path()); + + assert_eq!( + adapter.resolve_ref("@").unwrap(), + adapter.resolve_ref("HEAD").unwrap() + ); + let (_, parent_summary) = adapter.resolve_ref("@~1").unwrap(); + assert_eq!(parent_summary, "first"); + } + + #[test] + fn resolve_ref_rejects_unknown_reference() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + commit_file(&repo, "src/lib.rs", b"hello\n", "initial"); + + let mut adapter = open_adapter(repo_dir.path()); + assert!(adapter.resolve_ref("does-not-exist").is_err()); + } + + #[test] + fn snapshot_collects_refs_changes_and_file_changes() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + let oid = commit_file(&repo, "src/lib.rs", b"hello\n", "initial commit"); + let target = repo + .find_object(Oid::from_str(&oid).unwrap(), None) + .unwrap(); + repo.tag_lightweight("v1", &target, false).unwrap(); + fs::write(repo_dir.path().join("src/lib.rs"), "changed\n").unwrap(); + fs::write(repo_dir.path().join("notes.txt"), "untracked\n").unwrap(); + let head_branch = repo.head().unwrap().shorthand().unwrap().to_owned(); + + let mut adapter = open_adapter(repo_dir.path()); + let snapshot = adapter.snapshot(RepositorySyncReason::Open, None).unwrap(); + + assert!(snapshot.capabilities.staging_area); + assert_eq!(snapshot.refs[0].name, WORKDIR_REF); + assert_eq!(snapshot.refs[0].kind, RefKind::WorkingCopy); + + let branch_ref = snapshot + .refs + .iter() + .find(|vcs_ref| vcs_ref.name == head_branch) + .expect("head branch listed"); + assert_eq!(branch_ref.kind, RefKind::Branch); + assert!(branch_ref.active); + assert_eq!(branch_ref.target, RevisionId::git(oid.clone())); + + let tag_ref = snapshot + .refs + .iter() + .find(|vcs_ref| vcs_ref.name == "v1") + .expect("tag listed"); + assert_eq!(tag_ref.kind, RefKind::Tag); + assert_eq!(tag_ref.target, RevisionId::git(oid.clone())); + + assert_eq!(snapshot.changes.len(), 1); + assert_eq!(snapshot.changes[0].revision, RevisionId::git(oid)); + assert_eq!(snapshot.changes[0].summary, "initial commit"); + assert!(snapshot.changes[0].flags.current); + + assert!(snapshot.file_changes.contains(&FileChange { + path: "src/lib.rs".to_owned(), + old_path: None, + status: FileChangeStatus::Modified, + bucket: ChangeBucket::Unstaged, + })); + assert!(snapshot.file_changes.contains(&FileChange { + path: "notes.txt".to_owned(), + old_path: None, + status: FileChangeStatus::Untracked, + bucket: ChangeBucket::Untracked, + })); + } + + #[test] + fn snapshot_of_empty_repo_lists_untracked_files_without_history() { + let repo_dir = TempDir::new().unwrap(); + Repository::init(repo_dir.path()).unwrap(); + fs::write(repo_dir.path().join("readme.md"), "hello\n").unwrap(); + + let mut adapter = open_adapter(repo_dir.path()); + let snapshot = adapter.snapshot(RepositorySyncReason::Open, None).unwrap(); + + assert_eq!(snapshot.refs.len(), 1); + assert_eq!(snapshot.refs[0].name, WORKDIR_REF); + assert!(snapshot.changes.is_empty()); + assert_eq!( + snapshot.file_changes, + vec![FileChange { + path: "readme.md".to_owned(), + old_path: None, + status: FileChangeStatus::Untracked, + bucket: ChangeBucket::Untracked, + }] + ); + } + + #[test] + fn snapshot_with_detached_head_marks_no_branch_active() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + commit_file(&repo, "src/lib.rs", b"one\n", "first"); + let second = commit_file(&repo, "src/lib.rs", b"two\n", "second"); + repo.set_head_detached(Oid::from_str(&second).unwrap()) + .unwrap(); + + let mut adapter = open_adapter(repo_dir.path()); + let snapshot = adapter + .snapshot(RepositorySyncReason::Rescan, None) + .unwrap(); + + assert!(snapshot.refs.iter().all(|vcs_ref| !vcs_ref.active)); + assert!( + snapshot + .refs + .iter() + .any(|vcs_ref| vcs_ref.kind == RefKind::Branch) + ); + assert_eq!(snapshot.changes.len(), 2); + assert!(snapshot.changes.iter().all(|change| !change.flags.current)); + } + + #[test] + fn snapshot_reports_staged_rename_with_old_path() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + commit_file(&repo, "src/old.rs", b"same content\n", "initial"); + fs::rename( + repo_dir.path().join("src/old.rs"), + repo_dir.path().join("src/new.rs"), + ) + .unwrap(); + let mut index = repo.index().unwrap(); + index.remove_path(Path::new("src/old.rs")).unwrap(); + index.add_path(Path::new("src/new.rs")).unwrap(); + index.write().unwrap(); + + let mut adapter = open_adapter(repo_dir.path()); + let snapshot = adapter.snapshot(RepositorySyncReason::Dirty, None).unwrap(); + + assert_eq!( + snapshot.file_changes, + vec![FileChange { + path: "src/new.rs".to_owned(), + old_path: Some("src/old.rs".to_owned()), + status: FileChangeStatus::Renamed, + bucket: ChangeBucket::Staged, + }] + ); + } + + #[test] + fn read_file_text_returns_content_at_revision() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + let first = commit_file(&repo, "src/lib.rs", b"hello\nworld\n", "first"); + commit_file(&repo, "src/lib.rs", b"changed\n", "second"); + + let mut adapter = open_adapter(repo_dir.path()); + + let old_text = adapter + .read_file_text(&RevisionId::git(first), "src/lib.rs") + .unwrap(); + assert_eq!(old_text.as_str(), Some("hello\nworld\n")); + + let workdir_text = adapter + .read_file_text(&RevisionId::git(WORKDIR_REF), "src/lib.rs") + .unwrap(); + assert_eq!(workdir_text.as_str(), Some("changed\n")); + + assert!( + adapter + .read_file_text(&RevisionId::git("HEAD"), "src/missing.rs") + .is_err() + ); + } + + #[test] + fn read_file_text_rejects_binary_content() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + let oid = commit_file(&repo, "blob.bin", b"\x00\x01\x02binary", "add binary"); + + let mut adapter = open_adapter(repo_dir.path()); + let error = adapter + .read_file_text(&RevisionId::git(oid), "blob.bin") + .expect_err("binary content rejected"); + assert!(error.to_string().contains("binary"), "{error}"); + } + + #[test] + fn resolve_compare_request_resolves_each_spec() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + let first = commit_file(&repo, "src/lib.rs", b"one\n", "first"); + let second = commit_file(&repo, "src/lib.rs", b"two\n", "second"); + + let mut adapter = open_adapter(repo_dir.path()); + + assert_eq!( + adapter + .resolve_compare_request(&request(VcsCompareSpec::WorkingCopy)) + .unwrap(), + (second.clone(), WORKDIR_REF.to_owned()) + ); + assert_eq!( + adapter + .resolve_compare_request(&request(VcsCompareSpec::Range { + from: first.clone(), + to: second.clone(), + })) + .unwrap(), + (first.clone(), second.clone()) + ); + // Single-commit mode diffs the commit against its first parent. + assert_eq!( + adapter + .resolve_compare_request(&request(VcsCompareSpec::Change { + revision: second.clone(), + })) + .unwrap(), + (first, second) + ); + } + + #[test] + fn compare_range_produces_file_diff() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + let first = commit_file(&repo, "src/lib.rs", b"old line\n", "first"); + let second = commit_file(&repo, "src/lib.rs", b"new line\n", "second"); + + let mut adapter = open_adapter(repo_dir.path()); + let output = adapter + .compare( + &request(VcsCompareSpec::Range { + from: first, + to: second, + }), + None, + ) + .unwrap(); + + assert_eq!(output.file_count(), 1); + let summary = output.summary_at(0).expect("file summary"); + assert_eq!(summary.paths.display_path(), "src/lib.rs"); + assert!(!output.used_fallback); + } + + #[test] + fn compare_working_file_requires_status_scope() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + commit_file(&repo, "src/lib.rs", b"hello\n", "initial"); + + let mut adapter = open_adapter(repo_dir.path()); + assert!(adapter.compare_working_file("src/lib.rs").is_err()); + } + + #[test] + fn git_compare_spec_maps_request_specs() { + let working = git_compare_spec(&request(VcsCompareSpec::WorkingCopy)); + assert_eq!(working.left_ref, "HEAD"); + assert_eq!(working.right_ref, WORKDIR_REF); + assert_eq!(working.mode, CompareMode::TwoDot); + + let change = git_compare_spec(&request(VcsCompareSpec::Change { + revision: "abc123".to_owned(), + })); + assert_eq!(change.left_ref, ""); + assert_eq!(change.right_ref, "abc123"); + assert_eq!(change.mode, CompareMode::SingleCommit); + + let range = git_compare_spec(&request(VcsCompareSpec::Range { + from: "left".to_owned(), + to: "right".to_owned(), + })); + assert_eq!( + (range.left_ref.as_str(), range.right_ref.as_str()), + ("left", "right") + ); + assert_eq!(range.mode, CompareMode::TwoDot); + + let merge_base = git_compare_spec(&request(VcsCompareSpec::MergeBaseRange { + base: "main".to_owned(), + head: "feature".to_owned(), + })); + assert_eq!( + (merge_base.left_ref.as_str(), merge_base.right_ref.as_str()), + ("main", "feature") + ); + assert_eq!(merge_base.mode, CompareMode::ThreeDot); + } + + #[test] + fn status_item_from_file_change_maps_buckets_and_labels() { + let staged_added = status_item_from_file_change(&FileChange { + path: "src/new.rs".to_owned(), + old_path: None, + status: FileChangeStatus::Added, + bucket: ChangeBucket::Staged, + }); + assert_eq!(staged_added.scope, StatusScope::Staged); + assert_eq!(staged_added.status, "A"); + + let untracked = status_item_from_file_change(&FileChange { + path: "notes.txt".to_owned(), + old_path: None, + status: FileChangeStatus::Untracked, + bucket: ChangeBucket::Untracked, + }); + assert_eq!(untracked.scope, StatusScope::Untracked); + assert_eq!(untracked.status, "U"); + + let conflicted = status_item_from_file_change(&FileChange { + path: "src/lib.rs".to_owned(), + old_path: None, + status: FileChangeStatus::Modified, + bucket: ChangeBucket::Conflicted, + }); + assert_eq!(conflicted.scope, StatusScope::Unstaged); + assert_eq!(conflicted.status, "!"); + + let renamed = status_item_from_file_change(&FileChange { + path: "src/new.rs".to_owned(), + old_path: Some("src/old.rs".to_owned()), + status: FileChangeStatus::Renamed, + bucket: ChangeBucket::Unstaged, + }); + assert_eq!(renamed.status, "R"); + assert_eq!(renamed.old_path.as_deref(), Some("src/old.rs")); + } + + #[test] + fn publish_helpers_pick_remote_and_label() { + assert_eq!( + upstream_pair("origin/main"), + Some(("origin".to_owned(), "main".to_owned())) + ); + assert_eq!(upstream_pair("main"), None); + + let with_origin = [remote_branch("upstream/main"), remote_branch("origin/main")]; + assert_eq!( + preferred_git_remote(&with_origin).as_deref(), + Some("origin") + ); + let without_origin = [remote_branch("fork/main"), remote_branch("alt/dev")]; + assert_eq!( + preferred_git_remote(&without_origin).as_deref(), + Some("alt") + ); + assert_eq!(preferred_git_remote(&[]), None); + + assert_eq!(completed_publish_label("Push main"), "Pushed main"); + assert_eq!(completed_publish_label("Publish"), "Publish"); + } +} From 76e6b29cc4f8dd46e7523c24b1cea8adc11badeb Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 19:31:30 +0000 Subject: [PATCH 07/25] test(halogen-macros): add view! expansion coverage Add compile-and-run integration tests for the view! macro in crates/halogen-macros/tests/view_macro.rs. Since view! is duck-typed over call-site builders, the tests provide a minimal recording DSL (div/text/spacer plus Button/Toolbar components) and assert the built tree at runtime, covering: basic element emit, nested children and expression forms ({expr}, {?opt}, {...spread}, fragment flattening), if/else and else-if chains (including the spacer fallback and the multi-child spread-no-wrapper rule), for loops, match arms, component value/child slot mapping, class->builder-method lowering, event handler binding, and the {@sig} reactive attribute form against the real halogen SignalStore (dev-dependency only; trybuild is not in the tree, so no error-message tests were added). --- Cargo.lock | 1 + crates/halogen-macros/Cargo.toml | 5 + crates/halogen-macros/tests/view_macro.rs | 524 ++++++++++++++++++++++ 3 files changed, 530 insertions(+) create mode 100644 crates/halogen-macros/tests/view_macro.rs diff --git a/Cargo.lock b/Cargo.lock index cf63e13e..4ebfefaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2848,6 +2848,7 @@ dependencies = [ name = "halogen-macros" version = "0.1.11" dependencies = [ + "halogen", "proc-macro2", "quote", "syn", diff --git a/crates/halogen-macros/Cargo.toml b/crates/halogen-macros/Cargo.toml index 93b67c27..4cd95f7f 100644 --- a/crates/halogen-macros/Cargo.toml +++ b/crates/halogen-macros/Cargo.toml @@ -10,3 +10,8 @@ proc-macro = true proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full", "extra-traits"] } + +[dev-dependencies] +# Dev-only cycle (halogen depends on this crate) is allowed by cargo and gives +# `{@sig}` integration coverage against the real `SignalStore`. +halogen = { path = "../halogen" } diff --git a/crates/halogen-macros/tests/view_macro.rs b/crates/halogen-macros/tests/view_macro.rs new file mode 100644 index 00000000..af9c364f --- /dev/null +++ b/crates/halogen-macros/tests/view_macro.rs @@ -0,0 +1,524 @@ +//! Compile-and-run integration tests for the `view!` macro. +//! +//! `view!` is duck-typed: it emits calls to whatever `div()`, `text()`, +//! `spacer()`, component constructors, and builder methods are in scope at +//! the call site (in Diffy those live in `src/ui/element.rs`). The `dsl` +//! module below provides a minimal recording implementation of that contract +//! so each lowering rule can be asserted at runtime instead of only as token +//! snapshots (those live in `src/lib.rs` unit tests). + +use std::cell::Cell; +use std::rc::Rc; + +use halogen::reactive::{Signal, SignalStore}; +use halogen_macros::view; + +use dsl::*; + +#[allow(dead_code)] +mod dsl { + use std::rc::Rc; + + /// Built element tree node; records the tag, builder calls in order, + /// the slot it was assigned to (for component child slots), and children. + pub struct AnyElement { + pub tag: &'static str, + pub value: Option, + pub calls: Vec, + pub slot: Option<&'static str>, + pub children: Vec, + pub on_click: Option>, + } + + impl AnyElement { + fn new(tag: &'static str) -> Self { + AnyElement { + tag, + value: None, + calls: Vec::new(), + slot: None, + children: Vec::new(), + on_click: None, + } + } + } + + pub struct Div { + el: AnyElement, + } + + pub fn div() -> Div { + Div { + el: AnyElement::new("div"), + } + } + + impl Div { + pub fn child(mut self, child: AnyElement) -> Self { + self.el.children.push(child); + self + } + + pub fn optional_child(mut self, child: Option) -> Self { + if let Some(child) = child { + self.el.children.push(child); + } + self + } + + pub fn children(mut self, children: impl IntoIterator) -> Self { + self.el.children.extend(children); + self + } + + pub fn gap(mut self, value: f32) -> Self { + self.el.calls.push(format!("gap({value})")); + self + } + + pub fn flex_row(mut self) -> Self { + self.el.calls.push("flex_row".into()); + self + } + + pub fn flex_grow(mut self) -> Self { + self.el.calls.push("flex_grow".into()); + self + } + + pub fn flex_shrink_0(mut self) -> Self { + self.el.calls.push("flex_shrink_0".into()); + self + } + + pub fn px_2(mut self) -> Self { + self.el.calls.push("px_2".into()); + self + } + + pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self { + self.el.calls.push("on_click".into()); + self.el.on_click = Some(Rc::new(handler)); + self + } + + pub fn into_any(self) -> AnyElement { + self.el + } + } + + pub struct Text { + el: AnyElement, + } + + pub fn text(content: impl ToString) -> Text { + let mut el = AnyElement::new("text"); + el.value = Some(content.to_string()); + Text { el } + } + + impl Text { + pub fn color(mut self, value: impl ToString) -> Self { + self.el.calls.push(format!("color({})", value.to_string())); + self + } + + pub fn bold(mut self) -> Self { + self.el.calls.push("bold".into()); + self + } + + pub fn mono(mut self) -> Self { + self.el.calls.push("mono".into()); + self + } + + pub fn into_any(self) -> AnyElement { + self.el + } + } + + pub struct Spacer { + el: AnyElement, + } + + pub fn spacer() -> Spacer { + Spacer { + el: AnyElement::new("spacer"), + } + } + + impl Spacer { + pub fn into_any(self) -> AnyElement { + self.el + } + } + + /// Component with one constructor arg (`action`, per + /// `constructor_arg_order`) plus `Icon`/`Label` value slots. + pub struct Button { + el: AnyElement, + } + + impl Button { + pub fn new(action: &'static str) -> Self { + let mut el = AnyElement::new("Button"); + el.calls.push(format!("action({action})")); + Button { el } + } + + pub fn tooltip(mut self, value: &'static str) -> Self { + self.el.calls.push(format!("tooltip({value})")); + self + } + + pub fn icon(mut self, value: &'static str) -> Self { + self.el.calls.push(format!("icon({value})")); + self + } + + pub fn label(mut self, value: impl ToString) -> Self { + self.el.calls.push(format!("label({})", value.to_string())); + self + } + + pub fn flex_grow(mut self) -> Self { + self.el.calls.push("flex_grow".into()); + self + } + + pub fn into_any(self) -> AnyElement { + self.el + } + } + + /// Component with `Left`/`Right` child slots. + pub struct Toolbar { + el: AnyElement, + } + + impl Toolbar { + pub fn new() -> Self { + Toolbar { + el: AnyElement::new("Toolbar"), + } + } + + pub fn compact(mut self) -> Self { + self.el.calls.push("compact".into()); + self + } + + pub fn left_child(mut self, mut child: AnyElement) -> Self { + child.slot = Some("left"); + self.el.children.push(child); + self + } + + pub fn right_child(mut self, mut child: AnyElement) -> Self { + child.slot = Some("right"); + self.el.children.push(child); + self + } + + pub fn into_any(self) -> AnyElement { + self.el + } + } +} + +/// `cx` contract required by `{@sig}` attributes: any type with a +/// `read(Signal) -> T` method, named `cx` at the call site. +struct Cx<'a> { + store: &'a SignalStore, +} + +impl Cx<'_> { + fn read(&self, signal: Signal) -> T { + self.store.read(signal) + } +} + +#[test] +fn basic_element_emit() { + let el = view! { +
+ "hello" + +
+ }; + + assert_eq!(el.tag, "div"); + assert_eq!(el.calls, ["flex_row", "gap(4)"]); + assert_eq!(el.children.len(), 2); + assert_eq!(el.children[0].tag, "text"); + assert_eq!(el.children[0].value.as_deref(), Some("hello")); + assert_eq!(el.children[0].calls, ["color(red)"]); + assert_eq!(el.children[1].tag, "spacer"); +} + +#[test] +fn nested_children_expression_forms_and_fragment() { + fn badge(n: usize) -> AnyElement { + text(format!("badge-{n}")).into_any() + } + + let extras = vec![badge(1), badge(2)]; + let present: Option = Some(badge(3)); + let absent: Option = None; + + let el = view! { +
+
+ {badge(0)} +
+ {?present} + {?absent} + {...extras} + + "a" + "b" + +
+ }; + + // nested div + present optional + 2 spread + 2 fragment children, with the + // fragment flattened into the parent (no wrapper node). + let kinds: Vec<&str> = el + .children + .iter() + .map(|c| c.value.as_deref().unwrap_or(c.tag)) + .collect(); + assert_eq!(kinds, ["div", "badge-3", "badge-1", "badge-2", "a", "b"]); + assert_eq!(el.children[0].children[0].value.as_deref(), Some("badge-0")); +} + +#[test] +fn if_without_else_is_optional_child() { + let make = |cond: bool| { + view! { +
+ if cond { "shown" } +
+ } + }; + + assert_eq!(make(true).children.len(), 1); + assert_eq!(make(true).children[0].value.as_deref(), Some("shown")); + assert!(make(false).children.is_empty()); +} + +#[test] +fn if_else_and_else_if_chain_pick_one_branch() { + let pick = |a: bool, b: bool| { + view! { +
+ if a { "a" } + else if b { "b" } + else { "c" } +
+ } + }; + + assert_eq!(pick(true, false).children[0].value.as_deref(), Some("a")); + assert_eq!(pick(false, true).children[0].value.as_deref(), Some("b")); + assert_eq!(pick(false, false).children[0].value.as_deref(), Some("c")); +} + +#[test] +fn else_if_without_final_else_falls_back_to_spacer() { + let pick = |a: bool, b: bool| { + view! { +
+ if a { "a" } + else if b { "b" } +
+ } + }; + + assert_eq!(pick(false, true).children[0].value.as_deref(), Some("b")); + assert_eq!(pick(false, false).children[0].tag, "spacer"); +} + +#[test] +fn multi_child_if_spreads_into_parent_without_wrapper() { + let make = |cond: bool| { + view! { +
+ "lead" + if cond { + "x" + "y" + } + "tail" +
+ } + }; + + let on = make(true); + let values: Vec<&str> = on + .children + .iter() + .map(|c| c.value.as_deref().unwrap_or(c.tag)) + .collect(); + // Branch children flow inline into the parent — no wrapper div node. + assert_eq!(values, ["lead", "x", "y", "tail"]); + assert!(on.children.iter().all(|c| c.tag == "text")); + + let off = make(false); + let values: Vec<&str> = off + .children + .iter() + .map(|c| c.value.as_deref().unwrap_or(c.tag)) + .collect(); + assert_eq!(values, ["lead", "tail"]); +} + +#[test] +fn for_loop_flattens_each_iteration() { + let items = vec!["a", "b", "c"]; + + let el = view! { +
+ for (i, item) in items.iter().enumerate() { + {format!("{i}:{item}")} + } +
+ }; + + let values: Vec<&str> = el + .children + .iter() + .map(|c| c.value.as_deref().unwrap_or_default()) + .collect(); + assert_eq!(values, ["0:a", "1:b", "2:c"]); +} + +#[test] +fn match_arms_emit_child() { + let render = |n: u8| { + view! { +
+ match n { + 0 => view! { "zero" }, + 1 => text("one").into_any(), + _ => spacer().into_any(), + } +
+ } + }; + + assert_eq!(render(0).children[0].value.as_deref(), Some("zero")); + assert_eq!(render(1).children[0].value.as_deref(), Some("one")); + assert_eq!(render(7).children[0].tag, "spacer"); +} + +#[test] +fn component_value_slots_and_constructor_args() { + let el = view! { + + }; + + assert_eq!(el.tag, "Button"); + // Constructor arg first, then builder calls in attribute order (with the + // class lowered to its mapped builder method), then slot calls. + assert_eq!( + el.calls, + [ + "action(save)", + "tooltip(Save file)", + "flex_grow", + "icon(disk)", + "label(Save)", + ] + ); +} + +#[test] +fn component_child_slots_map_to_repeated_builder_calls() { + let el = view! { + + + + + + "r1" + "r2" + + + }; + + assert_eq!(el.tag, "Toolbar"); + assert_eq!(el.calls, ["compact"]); + let slots: Vec<(&str, &str)> = el + .children + .iter() + .map(|c| { + ( + c.slot.unwrap_or("none"), + c.value.as_deref().unwrap_or(c.tag), + ) + }) + .collect(); + assert_eq!( + slots, + [("left", "spacer"), ("right", "r1"), ("right", "r2")] + ); +} + +#[test] +fn class_attribute_lowers_to_builder_methods() { + let el = view! {
}; + assert_eq!(el.calls, ["flex_row", "flex_grow", "flex_shrink_0", "px_2"]); + + let el = view! { "x" }; + assert_eq!(el.calls, ["bold", "mono"]); +} + +#[test] +fn event_handler_attribute_binds_closure() { + let clicks = Rc::new(Cell::new(0u32)); + let sink = clicks.clone(); + + let el = view! { +
+ "button" +
+ }; + + assert!(el.calls.iter().any(|call| call == "on_click")); + let handler = el.on_click.as_ref().unwrap(); + handler(); + handler(); + assert_eq!(clicks.get(), 2); +} + +#[test] +fn reactive_attribute_reads_through_cx() { + let store = SignalStore::default(); + let gap = store.create(4.0f32); + let label = store.create(String::from("alpha")); + let cx = Cx { store: &store }; + + let build = || { + view! { +
+ "t" +
+ } + }; + + let el = build(); + assert_eq!(el.calls, ["gap(4)"]); + assert_eq!(el.children[0].calls, ["color(alpha)"]); + + store.write(gap, 12.0); + store.write(label, String::from("beta")); + + let el = build(); + assert_eq!(el.calls, ["gap(12)"]); + assert_eq!(el.children[0].calls, ["color(beta)"]); +} From 50d673e8811c189b5cdcc1da984f3d0f172f3c26 Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 19:50:49 +0000 Subject: [PATCH 08/25] refactor(core): introduce structured DiffyError variants Replace the stringly Http variant and many General(String) call sites with structured failure classes so the UI can distinguish retryable network failures from fatal ones: - Vcs { backend, op, details, recoverable } for git/jj failures, with helpers and a network-failure heuristic for remote git operations - Network { details, retryable } classified from transport errors and HTTP status (5xx/408/429 retryable) - Auth { details } for missing/rejected GitHub credentials - Permission { path, op } via DiffyError::io() for denied filesystem writes - General(String) kept as an incremental fallback Toast/event messages now use DiffyError::user_message(), which appends a retry hint for transient network failures. --- src/apprt/runtime.rs | 70 +++++++-------- src/apprt/services.rs | 2 +- src/apprt/vcs_worker.rs | 24 ++--- src/core/error.rs | 126 ++++++++++++++++++++++++++- src/core/forge/github/api.rs | 53 +++++------ src/core/forge/github/device_flow.rs | 8 +- src/core/http.rs | 27 ++++-- src/core/update.rs | 10 +-- src/core/vcs/backend.rs | 60 +++++-------- src/core/vcs/git/adapter.rs | 16 ++-- src/core/vcs/git/service.rs | 85 +++++++++++++----- src/core/vcs/jj/cli.rs | 19 ++-- src/core/vcs/jj/service.rs | 43 +++++---- 13 files changed, 359 insertions(+), 184 deletions(-) diff --git a/src/apprt/runtime.rs b/src/apprt/runtime.rs index 10f99deb..bee5e82a 100644 --- a/src/apprt/runtime.rs +++ b/src/apprt/runtime.rs @@ -217,7 +217,7 @@ impl EffectRunner { Ok(payload) => CompareEvent::CompareFinished(payload), Err(error) => CompareEvent::CompareFailed { generation, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -233,7 +233,7 @@ impl EffectRunner { Ok(payload) => CompareEvent::TextCompareFinished(payload), Err(error) => CompareEvent::TextCompareFailed { generation, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -252,7 +252,7 @@ impl EffectRunner { Ok(payload) => CompareEvent::CompareHistoryReady(payload), Err(error) => CompareEvent::CompareHistoryFailed { generation, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -275,7 +275,7 @@ impl EffectRunner { Err(error) => CompareEvent::StatusDiffFailed { generation, index, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -314,7 +314,7 @@ impl EffectRunner { }, Err(error) => GitHubEvent::PullRequestLoadFailed { url, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -327,7 +327,7 @@ impl EffectRunner { let event = match services.start_device_flow(&client_id) { Ok(state) => GitHubEvent::DeviceFlowStarted(state), Err(error) => GitHubEvent::DeviceFlowStartFailed { - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -346,7 +346,7 @@ impl EffectRunner { { Ok(token) => GitHubEvent::DeviceFlowCompleted { token }, Err(error) => GitHubEvent::DeviceFlowFailed { - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -370,7 +370,7 @@ impl EffectRunner { let event = match result { Ok(token) => GitHubEvent::GitHubTokenLoaded { token }, Err(error) => GitHubEvent::GitHubTokenLoadFailed { - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -391,7 +391,7 @@ impl EffectRunner { }; if let Err(error) = result { event_sender.send(GitHubEvent::GitHubTokenSaveFailed { - message: error.to_string(), + message: error.user_message(), }); } }); @@ -420,7 +420,7 @@ impl EffectRunner { let event = match services.fetch_github_user(&token) { Ok(user) => GitHubEvent::GitHubUserFetched { user }, Err(error) => GitHubEvent::GitHubUserFetchFailed { - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -447,7 +447,7 @@ impl EffectRunner { owner, repo, number, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -478,7 +478,7 @@ impl EffectRunner { owner, repo, number, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -509,7 +509,7 @@ impl EffectRunner { owner, repo, number, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -542,7 +542,7 @@ impl EffectRunner { owner, repo, number, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -577,7 +577,7 @@ impl EffectRunner { owner, repo, number, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -612,7 +612,7 @@ impl EffectRunner { repo, number, comment_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -645,7 +645,7 @@ impl EffectRunner { repo, number, comment_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -678,7 +678,7 @@ impl EffectRunner { owner, repo, number, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -718,7 +718,7 @@ impl EffectRunner { repo, number, draft_ids, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -754,7 +754,7 @@ impl EffectRunner { repo, number, thread_node_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -787,7 +787,7 @@ impl EffectRunner { repo, number, comment_node_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -818,7 +818,7 @@ impl EffectRunner { repo, number, comment_node_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -851,7 +851,7 @@ impl EffectRunner { repo, number, thread_node_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -887,7 +887,7 @@ impl EffectRunner { repo, number, review_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -904,7 +904,7 @@ impl EffectRunner { Ok(session) => GitHubEvent::ReviewSessionLoaded { target, session }, Err(error) => GitHubEvent::ReviewSessionLoadFailed { target, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -919,7 +919,7 @@ impl EffectRunner { Ok(key) => GitHubEvent::ReviewSessionSaved { key }, Err(error) => GitHubEvent::ReviewSessionSaveFailed { key, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -938,7 +938,7 @@ impl EffectRunner { }, Err(error) => GitHubEvent::AvatarFetchFailed { url, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -979,7 +979,7 @@ impl EffectRunner { UpdateEvent::UpdateNotAvailable { silent } } Err(error) => UpdateEvent::UpdateCheckFailed { - message: error.to_string(), + message: error.user_message(), silent, }, }; @@ -992,7 +992,7 @@ impl EffectRunner { thread::spawn(move || match services.stage_update(&update) { Ok(staged) => event_sender.send(UpdateEvent::UpdateStaged { staged, silent }), Err(error) => event_sender.send(UpdateEvent::UpdateInstallFailed { - message: error.to_string(), + message: error.user_message(), silent, }), }); @@ -1003,7 +1003,7 @@ impl EffectRunner { thread::spawn(move || { if let Err(error) = services.apply_staged_update(&staged) { event_sender.send(UpdateEvent::UpdateInstallFailed { - message: error.to_string(), + message: error.user_message(), silent: false, }); } @@ -1015,7 +1015,7 @@ impl EffectRunner { thread::spawn(move || { if let Err(error) = services.open_browser(&url) { event_sender.send(UiEvent::BrowserOpenFailed { - message: error.to_string(), + message: error.user_message(), }); } }); @@ -1047,7 +1047,7 @@ impl EffectRunner { Err(error) => RepositoryEvent::ContextLinesFailed { generation: request.generation, file_index: request.file_index, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -1192,7 +1192,7 @@ impl EffectRunner { let event = match crate::apprt::services::load_ai_keys() { Ok((openai, anthropic)) => AiEvent::AiKeysLoaded { openai, anthropic }, Err(error) => AiEvent::AiKeysLoadFailed { - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -1206,7 +1206,7 @@ impl EffectRunner { thread::spawn(move || { if let Err(error) = crate::platform::secrets::save_ai_key(kind, &value) { event_sender.send(AiEvent::AiKeySaveFailed { - message: error.to_string(), + message: error.user_message(), }); } }); @@ -1282,7 +1282,7 @@ fn persist_settings( SettingsEvent::SettingsSaved } Err(error) => SettingsEvent::SettingsSaveFailed { - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); diff --git a/src/apprt/services.rs b/src/apprt/services.rs index e15966c9..e9d6503f 100644 --- a/src/apprt/services.rs +++ b/src/apprt/services.rs @@ -841,7 +841,7 @@ impl AppServices { .header("User-Agent", "diffy/0.1") .send() .await - .map_err(|error| DiffyError::Http(format!("avatar fetch failed: {error}")))?; + .map_err(|error| DiffyError::network(format!("avatar fetch failed: {error}")))?; http::response_bytes(response, "avatar fetch").await })?; let img = image::load_from_memory(&bytes) diff --git a/src/apprt/vcs_worker.rs b/src/apprt/vcs_worker.rs index 839e84c7..7bd092ca 100644 --- a/src/apprt/vcs_worker.rs +++ b/src/apprt/vcs_worker.rs @@ -404,7 +404,7 @@ fn apply_status_operation( if let Err(error) = result { event_sender.send(RepositoryEvent::FileOperationFailed { path: path.clone(), - message: error.to_string(), + message: error.user_message(), }); return; } @@ -424,7 +424,7 @@ fn apply_batch_status_operation( if let Err(error) = result { event_sender.send(RepositoryEvent::FileOperationFailed { path: path.clone(), - message: error.to_string(), + message: error.user_message(), }); return; } @@ -471,7 +471,7 @@ fn apply_commit( { event_sender.send(RepositoryEvent::CommitFailed { path, - message: error.to_string(), + message: error.user_message(), }); return; } @@ -521,7 +521,7 @@ fn apply_vcs_operation( event_sender.send(RepositoryEvent::VcsOperationFailed { toast_id, operation, - message: error.to_string(), + message: error.user_message(), }); } } @@ -552,7 +552,7 @@ fn apply_fetch( event_sender.send(RepositoryEvent::FetchFailed { toast_id, remote, - message: error.to_string(), + message: error.user_message(), }); } } @@ -603,7 +603,7 @@ fn apply_push( event_sender.send(RepositoryEvent::PushFailed { toast_id, remote, - message: error.to_string(), + message: error.user_message(), }); } } @@ -639,7 +639,7 @@ fn apply_publish( tracing::warn!(path = %path.display(), %error, "vcs: publish failed"); event_sender.send(RepositoryEvent::PublishFailed { toast_id, - message: error.to_string(), + message: error.user_message(), }); } } @@ -662,7 +662,7 @@ fn apply_publish_plan(event_sender: &RuntimeEventSender, path: PathBuf, toast_id tracing::warn!(path = %path.display(), %error, "vcs: publish-plan failed"); event_sender.send(RepositoryEvent::PublishPlanFailed { toast_id, - message: error.to_string(), + message: error.user_message(), }); } } @@ -722,7 +722,7 @@ fn apply_pull_ff( toast_id, remote, branch, - message: error.to_string(), + message: error.user_message(), }); } } @@ -786,7 +786,7 @@ fn sync_repository_inner( event_sender.send(RepositoryEvent::RepositorySnapshotFailed { path, reason, - message: error.to_string(), + message: error.user_message(), }); return; } @@ -829,7 +829,7 @@ fn sync_vcs_repository( event_sender.send(RepositoryEvent::RepositorySnapshotFailed { path, reason, - message: error.to_string(), + message: error.user_message(), }); return; } @@ -849,7 +849,7 @@ fn sync_vcs_repository( event_sender.send(RepositoryEvent::RepositorySnapshotFailed { path, reason, - message: error.to_string(), + message: error.user_message(), }); return; } diff --git a/src/core/error.rs b/src/core/error.rs index 13e9e1d9..b3bae96f 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -1,19 +1,141 @@ +use std::fmt; +use std::path::PathBuf; + use thiserror::Error; +/// VCS backend that produced a [`DiffyError::Vcs`] failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VcsBackendKind { + Git, + Jj, +} + +impl fmt::Display for VcsBackendKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Git => "git", + Self::Jj => "jj", + }) + } +} + #[derive(Error, Debug)] pub enum DiffyError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), - #[error("HTTP error: {0}")] - Http(String), + /// A VCS operation failed. `recoverable` is true when retrying after + /// fixing repository state can succeed, false for operations the backend + /// cannot perform at all (or invariant violations). + #[error("{backend} {op} failed: {details}")] + Vcs { + backend: VcsBackendKind, + op: String, + details: String, + recoverable: bool, + }, + /// A network request failed. `retryable` marks transient transport or + /// server failures where retrying the same request can succeed. + #[error("{details}")] + Network { details: String, retryable: bool }, + /// An operation required authentication that is missing or was rejected. + #[error("{details}")] + Auth { details: String }, + /// The OS denied access to a path. + #[error("permission denied: cannot {op} {}", path.display())] + Permission { path: PathBuf, op: String }, #[error("Parse error: {0}")] Parse(String), #[error("Syntax error: {0}")] Syntax(String), + /// Fallback for failures that have not been classified yet. #[error("{0}")] General(String), } +impl DiffyError { + /// VCS failure that retrying after fixing repository state may resolve. + pub fn vcs(backend: VcsBackendKind, op: impl Into, details: impl Into) -> Self { + Self::Vcs { + backend, + op: op.into(), + details: details.into(), + recoverable: true, + } + } + + /// VCS failure that retrying will not fix (unsupported operation or + /// broken invariant). + pub fn vcs_fatal( + backend: VcsBackendKind, + op: impl Into, + details: impl Into, + ) -> Self { + Self::Vcs { + backend, + op: op.into(), + details: details.into(), + recoverable: false, + } + } + + /// Transient network failure worth retrying (connection, timeout, 5xx). + pub fn network(details: impl Into) -> Self { + Self::Network { + details: details.into(), + retryable: true, + } + } + + /// Network failure that retrying alone will not fix (4xx, protocol). + pub fn network_fatal(details: impl Into) -> Self { + Self::Network { + details: details.into(), + retryable: false, + } + } + + pub fn auth(details: impl Into) -> Self { + Self::Auth { + details: details.into(), + } + } + + /// Classify an IO error against the path/op it touched so permission + /// failures surface distinctly from generic IO failures. + pub fn io(path: impl Into, op: &str, error: std::io::Error) -> Self { + if error.kind() == std::io::ErrorKind::PermissionDenied { + Self::Permission { + path: path.into(), + op: op.to_owned(), + } + } else { + Self::Io(error) + } + } + + /// True when retrying the same operation unchanged can plausibly succeed. + pub fn is_retryable(&self) -> bool { + matches!( + self, + Self::Network { + retryable: true, + .. + } + ) + } + + /// Message for user-facing surfaces (toasts, error states). Appends a + /// retry hint for transient failures. + pub fn user_message(&self) -> String { + let base = self.to_string(); + if self.is_retryable() { + format!("{base} Check your connection and retry.") + } else { + base + } + } +} + pub type Result = std::result::Result; diff --git a/src/core/forge/github/api.rs b/src/core/forge/github/api.rs index 7a71dc98..aa965697 100644 --- a/src/core/forge/github/api.rs +++ b/src/core/forge/github/api.rs @@ -326,10 +326,9 @@ impl GitHubApi { if !self.token.is_empty() { request = request.header("Authorization", &format!("Bearer {}", self.token)); } - let response = request - .send() - .await - .map_err(|error| DiffyError::Http(format!("GitHub user fetch failed: {error}")))?; + let response = request.send().await.map_err(|error| { + DiffyError::network(format!("GitHub user fetch failed: {error}")) + })?; http::response_text(response, "GitHub user fetch").await })?; let json: Value = serde_json::from_str(&body)?; @@ -367,7 +366,7 @@ impl GitHubApi { request = request.header("Authorization", &format!("Bearer {}", self.token)); } let response = request.send().await.map_err(|error| { - DiffyError::Http(format!("GitHub pull request fetch failed: {error}")) + DiffyError::network(format!("GitHub pull request fetch failed: {error}")) })?; http::response_text(response, "GitHub pull request fetch").await })?; @@ -427,7 +426,7 @@ impl GitHubApi { request = request.header("Authorization", &format!("Bearer {}", self.token)); } let response = request.send().await.map_err(|error| { - DiffyError::Http(format!("GitHub review comments fetch failed: {error}")) + DiffyError::network(format!("GitHub review comments fetch failed: {error}")) })?; let body = http::response_text(response, "GitHub review comments fetch").await?; let mut page_comments: Vec = serde_json::from_str(&body)?; @@ -450,7 +449,7 @@ impl GitHubApi { number: i32, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to fetch pull request review data".to_owned(), )); } @@ -510,7 +509,7 @@ impl GitHubApi { comment: &CreatePullRequestReviewComment, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to add review comments".to_owned(), )); } @@ -528,7 +527,7 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub review comment create failed: {error}")) + DiffyError::network(format!("GitHub review comment create failed: {error}")) })?; http::response_text(response, "GitHub review comment create").await })?; @@ -544,7 +543,7 @@ impl GitHubApi { reply: &CreatePullRequestReviewReply, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to reply to review comments".to_owned(), )); } @@ -564,7 +563,7 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub review comment reply failed: {error}")) + DiffyError::network(format!("GitHub review comment reply failed: {error}")) })?; http::response_text(response, "GitHub review comment reply").await })?; @@ -579,7 +578,7 @@ impl GitHubApi { update: &UpdatePullRequestReviewComment, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to update review comments".to_owned(), )); } @@ -598,7 +597,7 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub review comment update failed: {error}")) + DiffyError::network(format!("GitHub review comment update failed: {error}")) })?; http::response_text(response, "GitHub review comment update").await })?; @@ -612,7 +611,7 @@ impl GitHubApi { comment_id: i64, ) -> Result<()> { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to delete review comments".to_owned(), )); } @@ -628,7 +627,7 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub review comment delete failed: {error}")) + DiffyError::network(format!("GitHub review comment delete failed: {error}")) })?; http::response_text(response, "GitHub review comment delete").await })?; @@ -643,7 +642,7 @@ impl GitHubApi { review: &CreatePullRequestReview, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to create pull request reviews".to_owned(), )); } @@ -661,7 +660,9 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub pull request review create failed: {error}")) + DiffyError::network(format!( + "GitHub pull request review create failed: {error}" + )) })?; http::response_text(response, "GitHub pull request review create").await })?; @@ -677,7 +678,7 @@ impl GitHubApi { submit: &SubmitPullRequestReview, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to submit pull request reviews".to_owned(), )); } @@ -697,7 +698,9 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub pull request review submit failed: {error}")) + DiffyError::network(format!( + "GitHub pull request review submit failed: {error}" + )) })?; http::response_text(response, "GitHub pull request review submit").await })?; @@ -711,7 +714,7 @@ impl GitHubApi { body: &str, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to reply to review threads".to_owned(), )); } @@ -737,7 +740,7 @@ impl GitHubApi { body: &str, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to update review comments".to_owned(), )); } @@ -761,7 +764,7 @@ impl GitHubApi { comment_node_id: &str, ) -> Result> { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to delete review comments".to_owned(), )); } @@ -783,7 +786,7 @@ impl GitHubApi { resolved: bool, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to update review thread resolution".to_owned(), )); } @@ -831,7 +834,7 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub GraphQL request failed: {error}")) + DiffyError::network(format!("GitHub GraphQL request failed: {error}")) })?; http::response_text(response, "GitHub GraphQL request").await })?; @@ -839,7 +842,7 @@ impl GitHubApi { if let Some(errors) = value.get("errors").and_then(Value::as_array) && !errors.is_empty() { - return Err(DiffyError::Http(format!( + return Err(DiffyError::network_fatal(format!( "GitHub GraphQL request failed: {}", graphql_error_message(errors) ))); diff --git a/src/core/forge/github/device_flow.rs b/src/core/forge/github/device_flow.rs index 7f4321ff..6ce1e6ca 100644 --- a/src/core/forge/github/device_flow.rs +++ b/src/core/forge/github/device_flow.rs @@ -20,7 +20,7 @@ pub fn start_device_flow(client_id: &str) -> Result { .form(&[("client_id", client_id), ("scope", "repo")]) .send() .await - .map_err(|error| DiffyError::Http(format!("GitHub device flow failed: {error}")))?; + .map_err(|error| DiffyError::network(format!("GitHub device flow failed: {error}")))?; http::response_text(response, "GitHub device flow").await })?; @@ -62,19 +62,19 @@ pub fn poll_for_token(client_id: &str, device_code: &str) -> Result Ok(None), - Some("expired_token") => Err(DiffyError::Http("device code expired".to_owned())), + Some("expired_token") => Err(DiffyError::auth("device code expired")), Some(other) => { let description = form_value(&body, "error_description") .map(decode_form_value) .filter(|value| !value.is_empty()) .unwrap_or_else(|| other.to_owned()); - Err(DiffyError::Http(description)) + Err(DiffyError::auth(description)) } None => { let token = form_value(&body, "access_token").unwrap_or_default(); diff --git a/src/core/http.rs b/src/core/http.rs index b34986ed..d9a1a3e0 100644 --- a/src/core/http.rs +++ b/src/core/http.rs @@ -6,22 +6,30 @@ pub(crate) fn block_on(future: impl Future>) -> Result let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|error| DiffyError::Http(format!("failed to start HTTP runtime: {error}")))?; + .map_err(|error| DiffyError::General(format!("failed to start HTTP runtime: {error}")))?; runtime.block_on(future) } +/// Whether a failed HTTP status is worth retrying unchanged. +fn status_is_retryable(status: reqwest::StatusCode) -> bool { + status.is_server_error() + || status == reqwest::StatusCode::TOO_MANY_REQUESTS + || status == reqwest::StatusCode::REQUEST_TIMEOUT +} + pub(crate) async fn response_text(response: reqwest::Response, context: &str) -> Result { let status = response.status(); let body = response .text() .await - .map_err(|error| DiffyError::Http(format!("{context} read failed: {error}")))?; + .map_err(|error| DiffyError::network(format!("{context} read failed: {error}")))?; if status.is_success() { Ok(body) } else { - Err(DiffyError::Http(format!( - "{context} returned {status}: {body}" - ))) + Err(DiffyError::Network { + details: format!("{context} returned {status}: {body}"), + retryable: status_is_retryable(status), + }) } } @@ -30,13 +38,14 @@ pub(crate) async fn response_bytes(response: reqwest::Response, context: &str) - let body = response .bytes() .await - .map_err(|error| DiffyError::Http(format!("{context} read failed: {error}")))?; + .map_err(|error| DiffyError::network(format!("{context} read failed: {error}")))?; if status.is_success() { Ok(body.to_vec()) } else { let body = String::from_utf8_lossy(&body); - Err(DiffyError::Http(format!( - "{context} returned {status}: {body}" - ))) + Err(DiffyError::Network { + details: format!("{context} returned {status}: {body}"), + retryable: status_is_retryable(status), + }) } } diff --git a/src/core/update.rs b/src/core/update.rs index 4cb9adb9..3c419619 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -135,7 +135,7 @@ fn fetch_manifest() -> Result { let url = manifest_url(); let text = http::block_on(async { let response = reqwest::get(&url).await.map_err(|error| { - DiffyError::Http(format!("update manifest request failed: {error}")) + DiffyError::network(format!("update manifest request failed: {error}")) })?; http::response_text(response, "update manifest").await })?; @@ -146,10 +146,10 @@ fn download_file(url: &str, path: &Path) -> Result<()> { let bytes = http::block_on(async { let response = reqwest::get(url) .await - .map_err(|error| DiffyError::Http(format!("update download failed: {error}")))?; + .map_err(|error| DiffyError::network(format!("update download failed: {error}")))?; http::response_bytes(response, "update download").await })?; - fs::write(path, bytes)?; + fs::write(path, bytes).map_err(|error| DiffyError::io(path, "write", error))?; Ok(()) } @@ -228,9 +228,9 @@ fn persist_update_artifact(path: &Path) -> Result { .file_name() .ok_or_else(|| DiffyError::General("update artifact has no file name".to_owned()))?; let dir = env::temp_dir().join(format!("diffy-update-{}", std::process::id())); - fs::create_dir_all(&dir)?; + fs::create_dir_all(&dir).map_err(|error| DiffyError::io(&dir, "create", error))?; let dest = dir.join(file_name); - fs::copy(path, &dest)?; + fs::copy(path, &dest).map_err(|error| DiffyError::io(&dest, "write", error))?; Ok(dest) } diff --git a/src/core/vcs/backend.rs b/src/core/vcs/backend.rs index 6c7216d2..4b6ae7e6 100644 --- a/src/core/vcs/backend.rs +++ b/src/core/vcs/backend.rs @@ -5,7 +5,7 @@ use carbon::TextStore; use crate::core::compare::{ CompareFileStatsTarget, CompareFileSummary, CompareOutput, ProgressSink, RendererKind, }; -use crate::core::error::Result; +use crate::core::error::{DiffyError, Result, VcsBackendKind}; use crate::core::forge::github::PullRequestInfo; use crate::core::vcs::model::{ FileChange, FileOperation, PublishAction, PublishOutcome, PublishPlan, PullFastForwardOutcome, @@ -14,6 +14,15 @@ use crate::core::vcs::model::{ }; use crate::events::RepositorySyncReason; +fn unsupported_operation(location: &RepoLocation, op: &str) -> DiffyError { + let backend = if location.kind == VcsKind::JJ { + VcsBackendKind::Jj + } else { + VcsBackendKind::Git + }; + DiffyError::vcs_fatal(backend, op, "not supported by this backend") +} + pub trait VcsBackend: Send + Sync { fn kind(&self) -> VcsKind; fn owns_location(&self, location: &RepoLocation) -> bool { @@ -77,23 +86,17 @@ pub trait VcsRepository: Send { _change: &FileChange, _renderer: RendererKind, ) -> Result { - Err(crate::core::error::DiffyError::General( - "file-change diff unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "file-change diff")) } fn commit_diff(&mut self, _has_staged: bool) -> Result { - Err(crate::core::error::DiffyError::General( - "commit diff unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "commit diff")) } fn apply_file_operation( &mut self, _change: &FileChange, _operation: FileOperation, ) -> Result<()> { - Err(crate::core::error::DiffyError::General( - "file operation unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "file operation")) } fn apply_batch_file_operation( &mut self, @@ -106,56 +109,41 @@ pub trait VcsRepository: Send { Ok(()) } fn apply_patch_operation(&mut self, _patch: &str, _operation: FileOperation) -> Result<()> { - Err(crate::core::error::DiffyError::General( - "patch operation unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "patch operation")) } fn create_commit(&mut self, _message: &str) -> Result<()> { - Err(crate::core::error::DiffyError::General( - "commit unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "commit")) } fn run_operation(&mut self, _operation: &VcsOperation) -> Result { - Err(crate::core::error::DiffyError::General( - "operation unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "operation")) } fn fetch_remote(&mut self, _remote: &str) -> Result<()> { - Err(crate::core::error::DiffyError::General( - "fetch unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "fetch")) } fn push(&mut self, _remote: &str, _refspec: &str, _force_with_lease: bool) -> Result<()> { - Err(crate::core::error::DiffyError::General( - "push unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "push")) } fn publish_plan(&mut self) -> Result { - Err(crate::core::error::DiffyError::General( - "publish unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "publish")) } fn publish(&mut self, _action: &PublishAction) -> Result { - Err(crate::core::error::DiffyError::General( - "publish unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "publish")) } fn pull_fast_forward( &mut self, _remote: &str, _branch: &str, ) -> Result { - Err(crate::core::error::DiffyError::General( - "fast-forward pull unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "fast-forward pull")) } fn resolve_pull_request_comparison( &mut self, _pull_request_url: &str, _github_token: &str, ) -> Result<(PullRequestInfo, String, String)> { - Err(crate::core::error::DiffyError::General( - "GitHub pull request comparison unsupported by this backend".to_owned(), + Err(unsupported_operation( + self.location(), + "GitHub pull request comparison", )) } fn compare_working_file(&mut self, path: &str) -> Result; diff --git a/src/core/vcs/git/adapter.rs b/src/core/vcs/git/adapter.rs index 4e01935d..b14d0a8e 100644 --- a/src/core/vcs/git/adapter.rs +++ b/src/core/vcs/git/adapter.rs @@ -7,7 +7,7 @@ use crate::core::compare::{ CompareFileStatsTarget, CompareFileSummary, CompareMode, ComparePhase, CompareService, CompareSpec, ProgressSink, RendererKind, }; -use crate::core::error::{DiffyError, Result}; +use crate::core::error::{DiffyError, Result, VcsBackendKind}; use crate::core::vcs::backend::{VcsBackend, VcsRepository, VcsWatchPaths}; use crate::core::vcs::git::status::StatusBits; use crate::core::vcs::git::{ @@ -54,7 +54,7 @@ impl VcsBackend for GitBackend { fn watch_paths(&self, location: &RepoLocation) -> Result { let repo = gix::open(&location.workspace_root) - .map_err(|error| DiffyError::General(error.to_string()))?; + .map_err(|error| DiffyError::vcs(VcsBackendKind::Git, "open", error.to_string()))?; let metadata_dir = repo.git_dir().to_path_buf(); let workdir = repo.workdir().map(Path::to_path_buf); let watched_paths = match workdir.as_ref() { @@ -334,7 +334,9 @@ impl VcsRepository for GitRepository { let branch = branches .iter() .find(|branch| branch.is_head && !branch.is_remote) - .ok_or_else(|| DiffyError::General("No current branch to push.".to_owned()))?; + .ok_or_else(|| { + DiffyError::vcs(VcsBackendKind::Git, "publish", "no current branch to push") + })?; let (remote, upstream_branch) = branch .upstream .as_deref() @@ -372,8 +374,10 @@ impl VcsRepository for GitRepository { label: completed_publish_label(&action.label), }) } - _ => Err(DiffyError::General( - "Git cannot run this publish action".to_owned(), + _ => Err(DiffyError::vcs_fatal( + VcsBackendKind::Git, + "publish", + "Git cannot run this publish action", )), } } @@ -387,7 +391,7 @@ impl VcsRepository for GitRepository { PullFastForwardOutcome::FastForwarded { behind } } }) - .map_err(|error| DiffyError::General(error.to_string())) + .map_err(|error| DiffyError::vcs(VcsBackendKind::Git, "pull", error.to_string())) } fn resolve_pull_request_comparison( diff --git a/src/core/vcs/git/service.rs b/src/core/vcs/git/service.rs index 1124ec5c..6b3b2fab 100644 --- a/src/core/vcs/git/service.rs +++ b/src/core/vcs/git/service.rs @@ -12,7 +12,7 @@ use crate::core::compare::backends::{RENAME_DETECTION_LIMIT, compare_output_from use crate::core::compare::service::CompareOutput; use crate::core::compare::spec::CompareMode; use crate::core::compare::stats::COMPARE_SUMMARY_FILE_LIMIT; -use crate::core::error::{DiffyError, Result}; +use crate::core::error::{DiffyError, Result, VcsBackendKind}; use crate::core::forge::github::{GitHubApi, PullRequestInfo, parse_pr_url}; use crate::core::vcs::git::status::{StatusBits, StatusItem, StatusOperation, StatusScope}; @@ -164,7 +164,7 @@ fn git_workdir(repo: &gix::Repository) -> Result { repo.workdir() .map(Path::to_path_buf) .or_else(|| repo.git_dir().parent().map(Path::to_path_buf)) - .ok_or_else(|| DiffyError::General("repository has no working directory".to_owned())) + .ok_or_else(|| git_error_fatal("open", "repository has no working directory")) } struct GitOutput { @@ -196,7 +196,7 @@ fn run_system_git_inner( .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_OPTIONAL_LOCKS", "0") .output() - .map_err(|e| DiffyError::General(format!("failed to run git: {e}")))?; + .map_err(|e| git_error_fatal(git_command_label(args), format!("failed to run git: {e}")))?; if output.status.success() || (allow_diff_exit && output.status.code() == Some(1)) { return Ok(GitOutput { @@ -214,9 +214,30 @@ fn run_system_git_inner( .map(str::to_owned) .unwrap_or_else(|| format!("git exited with {}", output.status)); let command = git_command_label(args); - Err(DiffyError::General(format!( - "git {command} failed: {detail}" - ))) + if failure_is_network(&detail) { + return Err(DiffyError::network(format!( + "git {command} failed: {detail}" + ))); + } + Err(git_error(command, detail)) +} + +/// Heuristic for remote-operation failures (fetch/push/pull) caused by the +/// network rather than repository state, so the UI can suggest retrying. +fn failure_is_network(detail: &str) -> bool { + let detail = detail.to_ascii_lowercase(); + [ + "could not resolve host", + "connection refused", + "connection reset", + "connection timed out", + "operation timed out", + "network is unreachable", + "could not read from remote repository", + "unable to access", + ] + .iter() + .any(|needle| detail.contains(needle)) } fn git_command_label(args: &[OsString]) -> String { @@ -256,8 +277,16 @@ fn sanitize_git_arg(arg: &str) -> String { } } +fn git_error(op: impl Into, details: impl Into) -> DiffyError { + DiffyError::vcs(VcsBackendKind::Git, op, details) +} + +fn git_error_fatal(op: impl Into, details: impl Into) -> DiffyError { + DiffyError::vcs_fatal(VcsBackendKind::Git, op, details) +} + fn gix_error(error: impl std::fmt::Display) -> DiffyError { - DiffyError::General(format!("Gitoxide error: {error}")) + git_error("repository read", error.to_string()) } fn github_repo_key_from_remote_url(url: &str) -> Option<(String, String)> { @@ -615,7 +644,10 @@ impl GitService { let entry = index .entry_by_path(path.as_bytes().as_bstr()) .ok_or_else(|| { - DiffyError::General(format!("path {path} is not present at {INDEX_REF}")) + git_error( + "read file", + format!("path {path} is not present at {INDEX_REF}"), + ) })?; Ok(self .repo()? @@ -648,10 +680,16 @@ impl GitService { .lookup_entry_by_path(path) .map_err(gix_error)? .ok_or_else(|| { - DiffyError::General(format!("path {path} is not present at {reference}")) + git_error( + "read file", + format!("path {path} is not present at {reference}"), + ) })?; if !entry.mode().is_blob_or_symlink() { - return Err(DiffyError::General(format!("path {path} is not a file"))); + return Err(git_error_fatal( + "read file", + format!("path {path} is not a file"), + )); } Ok(self .repo()? @@ -702,15 +740,17 @@ impl GitService { fn validate_text_bytes(reference: &str, path: &str, bytes: &[u8]) -> Result<()> { if bytes.contains(&0u8) { - return Err(DiffyError::General(format!( - "path {path} is binary at {reference}", - ))); + return Err(git_error_fatal( + "read file", + format!("path {path} is binary at {reference}"), + )); } std::str::from_utf8(bytes).map_err(|e| { - DiffyError::General(format!( - "path {path} at {reference} is not valid UTF-8: {e}" - )) + git_error_fatal( + "read file", + format!("path {path} at {reference} is not valid UTF-8: {e}"), + ) })?; Ok(()) } @@ -1341,12 +1381,12 @@ impl GitService { pub fn repo(&self) -> Result<&gix::Repository> { self.repo .as_ref() - .ok_or_else(|| DiffyError::General("repository is not open".to_owned())) + .ok_or_else(|| git_error_fatal("open", "repository is not open")) } fn repo_path_ref(&self) -> Result<&Path> { if self.repo_path.is_empty() { - return Err(DiffyError::General("repository is not open".to_owned())); + return Err(git_error_fatal("open", "repository is not open")); } Ok(Path::new(&self.repo_path)) } @@ -1430,22 +1470,19 @@ impl GitService { .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() - .map_err(|e| DiffyError::General(format!("failed to run git apply: {e}")))?; + .map_err(|e| git_error_fatal("apply", format!("failed to run git apply: {e}")))?; use std::io::Write; child .stdin .as_mut() - .ok_or_else(|| DiffyError::General("failed to open git apply stdin".to_owned()))? + .ok_or_else(|| git_error_fatal("apply", "failed to open git apply stdin"))? .write_all(patch_text.as_bytes())?; let output = child.wait_with_output()?; if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr); - Err(DiffyError::General(format!( - "git apply failed: {}", - stderr.trim() - ))) + Err(git_error("apply", stderr.trim())) } } diff --git a/src/core/vcs/jj/cli.rs b/src/core/vcs/jj/cli.rs index 3e840815..7dab6e79 100644 --- a/src/core/vcs/jj/cli.rs +++ b/src/core/vcs/jj/cli.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Instant; -use crate::core::error::{DiffyError, Result}; +use crate::core::error::{DiffyError, Result, VcsBackendKind}; #[derive(Debug, Clone)] pub struct JjCli { @@ -42,7 +42,7 @@ impl JjCli { fn run_inner(&self, args: &[OsString], ignore_working_copy: bool) -> Result { let stdout = self.run_inner_bytes(args, ignore_working_copy)?; String::from_utf8(stdout) - .map_err(|error| DiffyError::General(format!("jj emitted non-UTF8 output: {error}"))) + .map_err(|error| DiffyError::Parse(format!("jj emitted non-UTF8 output: {error}"))) } fn run_inner_bytes(&self, args: &[OsString], ignore_working_copy: bool) -> Result> { @@ -60,9 +60,13 @@ impl JjCli { } command.args(args); - let output = command - .output() - .map_err(|error| DiffyError::General(format!("failed to run jj: {error}")))?; + let output = command.output().map_err(|error| { + DiffyError::vcs_fatal( + VcsBackendKind::Jj, + command_label(args), + format!("failed to run jj: {error}"), + ) + })?; let command_label = command_label(args); let elapsed = started.elapsed(); if output.status.success() { @@ -85,10 +89,7 @@ impl JjCli { .or_else(|| stdout.trim().lines().last()) .map(str::to_owned) .unwrap_or_else(|| format!("jj exited with {}", output.status)); - Err(DiffyError::General(format!( - "jj {} failed: {detail}", - command_label - ))) + Err(DiffyError::vcs(VcsBackendKind::Jj, command_label, detail)) } } diff --git a/src/core/vcs/jj/service.rs b/src/core/vcs/jj/service.rs index 95636dfc..8006621e 100644 --- a/src/core/vcs/jj/service.rs +++ b/src/core/vcs/jj/service.rs @@ -11,7 +11,7 @@ use crate::core::compare::{ COMPARE_SUMMARY_FILE_LIMIT, CompareFileStatsTarget, CompareFileSummary, CompareOutput, ProgressSink, RendererKind, }; -use crate::core::error::{DiffyError, Result}; +use crate::core::error::{DiffyError, Result, VcsBackendKind}; use crate::core::vcs::backend::{VcsBackend, VcsRepository, VcsWatchPaths}; use crate::core::vcs::jj::cli::JjCli; use crate::core::vcs::jj::parse::{ @@ -389,9 +389,7 @@ impl JjRepository { .find(|remote| remote.as_str() == "origin") .cloned() .or_else(|| remotes.first().cloned()) - .ok_or_else(|| { - DiffyError::General("No remotes are configured for this repository.".to_owned()) - }) + .ok_or_else(|| jj_error("publish", "no remotes are configured for this repository")) } fn default_publish_target(&self) -> Result { @@ -403,8 +401,9 @@ impl JjRepository { let head_target = self.publish_target("@")?; let target = if head_target.summary.trim().is_empty() { let mut parent = self.publish_target("@-").map_err(|_| { - DiffyError::General( - "Describe the current jj change before publishing it.".to_owned(), + jj_error( + "publish", + "describe the current jj change before publishing it", ) })?; parent.fell_back_from_empty_working_copy = true; @@ -413,8 +412,9 @@ impl JjRepository { head_target }; if target.summary.trim().is_empty() { - Err(DiffyError::General( - "Describe the jj change before publishing it.".to_owned(), + Err(jj_error( + "publish", + "describe the jj change before publishing it", )) } else { Ok(target) @@ -439,9 +439,10 @@ impl JjRepository { let change_id_rest = fields.next().unwrap_or_default(); let summary = fields.next().unwrap_or_default().to_owned(); if commit_id.is_empty() { - return Err(DiffyError::General(format!( - "Could not resolve jj revision {revision} for publishing." - ))); + return Err(jj_error( + "publish", + format!("could not resolve jj revision {revision} for publishing"), + )); } let short_change_id = format!("{change_id_prefix}{change_id_rest}"); let short_change_id_prefix_len = change_id_prefix.len(); @@ -866,8 +867,9 @@ impl VcsRepository for JjRepository { operation: FileOperation, ) -> Result<()> { if operation != FileOperation::Discard { - return Err(DiffyError::General( - "jj does not support stage or unstage operations".to_owned(), + return Err(jj_error_fatal( + "stage", + "jj does not support stage or unstage operations", )); } let mut args = vec![OsString::from("restore")]; @@ -985,7 +987,7 @@ impl VcsRepository for JjRepository { fn push(&mut self, remote: &str, refspec: &str, _force_with_lease: bool) -> Result<()> { let bookmark = bookmark_from_refspec(refspec) - .ok_or_else(|| DiffyError::General("jj push requires a bookmark refspec".to_owned()))?; + .ok_or_else(|| jj_error_fatal("push", "jj push requires a bookmark refspec"))?; self.cli.run(&[ OsString::from("git"), OsString::from("push"), @@ -1202,8 +1204,9 @@ impl VcsRepository for JjRepository { ])?; } PublishActionKind::PushRef { .. } => { - return Err(DiffyError::General( - "jj cannot run a Git refspec publish action".to_owned(), + return Err(jj_error_fatal( + "publish", + "jj cannot run a Git refspec publish action", )); } } @@ -1258,6 +1261,14 @@ impl VcsRepository for JjRepository { } } +fn jj_error(op: impl Into, details: impl Into) -> DiffyError { + DiffyError::vcs(VcsBackendKind::Jj, op, details) +} + +fn jj_error_fatal(op: impl Into, details: impl Into) -> DiffyError { + DiffyError::vcs_fatal(VcsBackendKind::Jj, op, details) +} + fn u32_to_i32_saturating(value: u32) -> i32 { i32::try_from(value).unwrap_or(i32::MAX) } From 1e40ad7a3dfbbb1353f010029d071e4172567076 Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 20:05:56 +0000 Subject: [PATCH 09/25] fix(apprt): guard compare results with epochs and generation-key editor scroll Apply the syntax scheduler's atomic-epoch stale-result pattern to the compare scheduler: bump the epoch when a new compare (or text compare) request starts, drop queued jobs from older generations before running them, and drop results whose generation is stale at emission so rapid file/rev switching cannot deliver stale compare output. Give EditorState generation awareness: stamp doc_generation in prepare (scroll is re-clamped against the freshly built layout; per-file resets stay with the reducer so recompares keep the user's place), reset it when the document clears, write it back from the frame snapshot, and guard paint with a graceful skip-frame (warn, not panic) when the row geometry was built for a different compare generation or file. --- src/apprt/compare.rs | 167 +++++++++++++++++++++++++++++++++++-- src/apprt/runtime.rs | 5 ++ src/editor/diff/element.rs | 30 ++++++- src/editor/diff/state.rs | 8 ++ src/ui/shell.rs | 4 + src/ui/state/mod.rs | 1 + 6 files changed, 207 insertions(+), 8 deletions(-) diff --git a/src/apprt/compare.rs b/src/apprt/compare.rs index a835f09d..c84a63b1 100644 --- a/src/apprt/compare.rs +++ b/src/apprt/compare.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::thread; @@ -18,6 +19,10 @@ pub(crate) struct CompareScheduler { struct CompareQueue { state: Mutex, ready: Condvar, + /// Highest compare generation observed. Jobs and results stamped with an + /// older generation are stale (a newer compare superseded them) and are + /// dropped instead of being run or emitted, mirroring `SyntaxScheduler`. + current_epoch: AtomicU64, } #[derive(Default)] @@ -60,6 +65,7 @@ impl CompareScheduler { let queue = Arc::new(CompareQueue { state: Mutex::new(CompareQueueState::default()), ready: Condvar::new(), + current_epoch: AtomicU64::new(0), }); let worker_count = std::thread::available_parallelism() .map(usize::from) @@ -86,10 +92,31 @@ impl CompareScheduler { self.enqueue(CompareJob::FileStats(task)); } + /// Mark every compare job older than `epoch` stale. Called when a new + /// compare request starts so rapid file/rev switching cannot leave older + /// queued work racing newer state. + pub(crate) fn set_epoch(&self, epoch: u64) { + let previous = self.queue.current_epoch.fetch_max(epoch, Ordering::AcqRel); + if epoch < previous { + return; + } + let mut state = self.queue.state.lock().expect("compare queue poisoned"); + let current = self.queue.current_epoch.load(Ordering::Acquire); + state.jobs.retain(|job| job.job.generation() >= current); + } + fn enqueue(&self, job: CompareJob) { + let epoch = job.generation(); + let previous = self.queue.current_epoch.fetch_max(epoch, Ordering::AcqRel); + let key = job.key(); let priority = job.priority(); let mut state = self.queue.state.lock().expect("compare queue poisoned"); + let current = self.queue.current_epoch.load(Ordering::Acquire); + state.jobs.retain(|job| job.job.generation() >= current); + if epoch < previous || epoch < current { + return; + } state.jobs.retain(|job| job.key != key); let sequence = state.next_sequence; state.next_sequence = state.next_sequence.saturating_add(1); @@ -105,6 +132,14 @@ impl CompareScheduler { } impl CompareJob { + fn generation(&self) -> u64 { + match self { + CompareJob::Stats(task) => task.generation, + CompareJob::File(task) => task.generation, + CompareJob::FileStats(task) => task.generation, + } + } + fn priority(&self) -> CompareWorkPriority { match self { CompareJob::Stats(task) => task.request.priority, @@ -140,13 +175,20 @@ fn compare_worker_loop( let job = { let mut state = queue.state.lock().expect("compare queue poisoned"); loop { + let current_epoch = queue.current_epoch.load(Ordering::Acquire); + state + .jobs + .retain(|job| job.job.generation() >= current_epoch); if let Some(index) = next_job_index(&state.jobs) { break state.jobs.swap_remove(index); } state = queue.ready.wait(state).expect("compare queue poisoned"); } }; - run_job(job, &services, &event_sender); + if job.job.generation() < queue.current_epoch.load(Ordering::Acquire) { + continue; + } + run_job(job, &services, &event_sender, &queue.current_epoch); } } @@ -157,18 +199,30 @@ fn next_job_index(jobs: &[QueuedCompareJob]) -> Option { .map(|(index, _)| index) } -fn run_job(job: QueuedCompareJob, services: &AppServices, event_sender: &RuntimeEventSender) { +fn run_job( + job: QueuedCompareJob, + services: &AppServices, + event_sender: &RuntimeEventSender, + current_epoch: &AtomicU64, +) { match job.job { - CompareJob::Stats(task) => run_load_stats(task, services, event_sender), - CompareJob::File(task) => run_load_file(task, services, event_sender), - CompareJob::FileStats(task) => run_load_file_stats(task, services, event_sender), + CompareJob::Stats(task) => run_load_stats(task, services, event_sender, current_epoch), + CompareJob::File(task) => run_load_file(task, services, event_sender, current_epoch), + CompareJob::FileStats(task) => { + run_load_file_stats(task, services, event_sender, current_epoch) + } } } +fn is_stale(generation: u64, current_epoch: &AtomicU64) -> bool { + generation < current_epoch.load(Ordering::Acquire) +} + fn run_load_stats( task: Task, services: &AppServices, event_sender: &RuntimeEventSender, + current_epoch: &AtomicU64, ) { let generation = task.generation; let request = task.request; @@ -179,6 +233,10 @@ fn run_load_stats( message: error.to_string(), }, }; + if is_stale(generation, current_epoch) { + tracing::debug!(generation, "stale compare stats result dropped"); + return; + } event_sender.send(event); } @@ -186,6 +244,7 @@ fn run_load_file( task: Task, services: &AppServices, event_sender: &RuntimeEventSender, + current_epoch: &AtomicU64, ) { let generation = task.generation; let request = task.request; @@ -194,10 +253,14 @@ fn run_load_file( Ok(payload) => CompareEvent::CompareFileFinished(payload), Err(error) => CompareEvent::CompareFileFailed { generation, - path, + path: path.clone(), message: error.to_string(), }, }; + if is_stale(generation, current_epoch) { + tracing::debug!(generation, path = %path, "stale compare file result dropped"); + return; + } event_sender.send(event); } @@ -205,12 +268,16 @@ fn run_load_file_stats( task: Task, services: &AppServices, event_sender: &RuntimeEventSender, + current_epoch: &AtomicU64, ) { let generation = task.generation; let request = task.request; let payload = match services.load_compare_file_stats(generation, request) { Ok(payload) => payload, Err(error) => { + if is_stale(generation, current_epoch) { + return; + } event_sender.send(CompareEvent::CompareFileStatsFailed { generation, message: error.to_string(), @@ -218,13 +285,18 @@ fn run_load_file_stats( return; } }; - send_file_stats_payload(generation, payload, event_sender); + if is_stale(generation, current_epoch) { + tracing::debug!(generation, "stale compare file stats result dropped"); + return; + } + send_file_stats_payload(generation, payload, event_sender, current_epoch); } fn send_file_stats_payload( generation: u64, payload: CompareFileStatsReady, event_sender: &RuntimeEventSender, + current_epoch: &AtomicU64, ) { if payload.stats.len() <= FILE_STATS_STREAM_CHUNK_SIZE { event_sender.send(CompareEvent::CompareFileStatsReady(payload)); @@ -233,6 +305,12 @@ fn send_file_stats_payload( let mut stats = payload.stats.into_iter(); loop { + // A newer compare invalidates the remainder of the stream; the + // state-side generation guard ignores any chunks already sent. + if is_stale(generation, current_epoch) { + tracing::debug!(generation, "stale compare file stats stream dropped"); + return; + } let chunk = stats .by_ref() .take(FILE_STATS_STREAM_CHUNK_SIZE) @@ -255,8 +333,83 @@ fn send_file_stats_payload( #[cfg(test)] mod tests { + use std::path::PathBuf; + + use super::*; + use crate::core::compare::{LayoutMode, RendererKind}; + use crate::core::vcs::model::{VcsCompareRequest, VcsCompareSpec}; use crate::effects::CompareWorkPriority; + fn scheduler_for_test() -> CompareScheduler { + CompareScheduler { + queue: Arc::new(CompareQueue { + state: Mutex::new(CompareQueueState::default()), + ready: Condvar::new(), + current_epoch: AtomicU64::new(0), + }), + } + } + + fn file_task(generation: u64, index: usize) -> Task { + Task { + generation, + request: CompareFileRequest { + repo_path: PathBuf::from("/repo"), + request: VcsCompareRequest { + spec: VcsCompareSpec::WorkingCopy, + layout: LayoutMode::Unified, + renderer: RendererKind::Builtin, + }, + path: format!("src/file-{index}.rs"), + index, + deferred_file: None, + priority: CompareWorkPriority::InteractiveSelectedFile, + }, + } + } + + #[test] + fn newer_compare_generation_drops_queued_older_jobs() { + let scheduler = scheduler_for_test(); + scheduler.dispatch_load_file(file_task(1, 1)); + scheduler.dispatch_load_file(file_task(1, 2)); + + scheduler.dispatch_load_file(file_task(2, 3)); + + let state = scheduler + .queue + .state + .lock() + .expect("compare queue poisoned"); + assert_eq!(scheduler.queue.current_epoch.load(Ordering::Acquire), 2); + assert_eq!(state.jobs.len(), 1); + assert_eq!(state.jobs[0].job.generation(), 2); + } + + #[test] + fn explicit_compare_epoch_drops_queued_older_jobs() { + let scheduler = scheduler_for_test(); + scheduler.dispatch_load_file(file_task(1, 1)); + scheduler.dispatch_load_file(file_task(1, 2)); + + scheduler.set_epoch(2); + scheduler.dispatch_load_file(file_task(1, 3)); + scheduler.dispatch_load_file(file_task(2, 4)); + + let state = scheduler + .queue + .state + .lock() + .expect("compare queue poisoned"); + assert_eq!(scheduler.queue.current_epoch.load(Ordering::Acquire), 2); + assert_eq!(state.jobs.len(), 1); + assert_eq!(state.jobs[0].job.generation(), 2); + match &state.jobs[0].job { + CompareJob::File(task) => assert_eq!(task.request.index, 4), + other => panic!("unexpected job kind: {:?}", other.key()), + } + } + #[test] fn visible_diff_work_outprioritizes_stats_work() { assert!( diff --git a/src/apprt/runtime.rs b/src/apprt/runtime.rs index bee5e82a..19e5412c 100644 --- a/src/apprt/runtime.rs +++ b/src/apprt/runtime.rs @@ -207,6 +207,10 @@ impl EffectRunner { } Effect::Compare(CompareEffect::Run(task)) => { let generation = task.generation; + // A new compare supersedes any queued/in-flight scheduler work + // from older generations; drop it instead of racing the new + // results. + self.compare_scheduler.set_epoch(generation); let request = task.request; let services = self.services.clone(); let event_sender = self.event_sender.clone(); @@ -225,6 +229,7 @@ impl EffectRunner { } Effect::Compare(CompareEffect::RunText(task)) => { let generation = task.generation; + self.compare_scheduler.set_epoch(generation); let request = task.request; let services = self.services.clone(); let event_sender = self.event_sender.clone(); diff --git a/src/editor/diff/element.rs b/src/editor/diff/element.rs index c0e0c8d5..b7a61a91 100644 --- a/src/editor/diff/element.rs +++ b/src/editor/diff/element.rs @@ -461,6 +461,16 @@ impl EditorElement { self.layout_key = Some(key); } + // Stamp the generation this geometry belongs to. When a new + // compare generation replaces the document, the carried-over + // scroll offset may point past geometry that no longer + // exists; the `clamp_scroll` below re-clamps it against the + // freshly built layout. Scroll is intentionally not reset + // here: per-file resets (and continuous-scroll restore) are + // owned by the reducer so a recompare of the same file keeps + // the user's place. + state.doc_generation = compare_generation; + state.content_height_px = self .summary .content_height_px @@ -998,11 +1008,29 @@ impl EditorElement { } EditorDocument::Text { compare_generation, + file_index, path, doc, show_file_headers, - .. } => { + // Row geometry is only valid for the exact document `prepare` + // built it from. If a stale document (older compare + // generation or different file) reaches paint, skip the body + // for this frame instead of painting mismatched geometry; + // the next prepare/paint pass recovers. + let layout_matches = self.layout_key.is_some_and(|key| { + key.compare_generation == compare_generation && key.file_index == file_index + }); + if !layout_matches || _state.doc_generation != compare_generation { + tracing::warn!( + compare_generation, + file_index, + layout_generation = ?self.layout_key.map(|key| key.compare_generation), + state_generation = _state.doc_generation, + "editor layout/document generation mismatch; skipping paint" + ); + return; + } self.sync_theme_cache(theme); scene.clip(self.layout.content_bounds); diff --git a/src/editor/diff/state.rs b/src/editor/diff/state.rs index 59d54253..1a0dc3eb 100644 --- a/src/editor/diff/state.rs +++ b/src/editor/diff/state.rs @@ -103,6 +103,12 @@ pub struct EditorState { pub layout: LayoutMode, pub wrap_enabled: bool, pub wrap_column: u32, + /// Compare generation the document geometry below (scroll, content + /// height, visible rows, hunk/file positions) was last prepared for. + /// `0` means "no document". Lets prepare re-clamp scroll when a new + /// compare generation replaces the active document and lets paint guard + /// against layout/state mismatches without panicking. + pub doc_generation: u64, pub scroll_top_px: u32, pub content_height_px: u32, pub viewport_width_px: u32, @@ -199,6 +205,7 @@ impl Default for EditorState { layout: LayoutMode::Unified, wrap_enabled: false, wrap_column: 0, + doc_generation: 0, scroll_top_px: 0, content_height_px: 0, viewport_width_px: 0, @@ -223,6 +230,7 @@ impl Default for EditorState { impl EditorState { pub fn clear_document(&mut self) { + self.doc_generation = 0; self.scroll_top_px = 0; self.content_height_px = 0; self.hovered_row = None; diff --git a/src/ui/shell.rs b/src/ui/shell.rs index d6e07369..4cd4de6c 100644 --- a/src/ui/shell.rs +++ b/src/ui/shell.rs @@ -597,6 +597,10 @@ pub fn build_ui_frame( } }; // Write back every field prepare may have mutated. + state + .editor + .doc_generation + .set_if_changed(&state.store, editor_snap.doc_generation); state .editor .viewport_width_px diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index c837ccec..bcb71f6b 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -11810,6 +11810,7 @@ impl AppState { /// Clear document-specific editor state (scroll, content, hunks, etc.) pub fn editor_clear_document(&mut self) { + self.editor.doc_generation.set(&self.store, 0); self.editor.scroll_top_px.set(&self.store, 0); self.editor.content_height_px.set(&self.store, 0); self.editor.hovered_row.set(&self.store, None); From 04a05f2a97a0ef6f4b84b9e35f9b330f6a5a8faa Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 20:23:37 +0000 Subject: [PATCH 10/25] perf(syntax): viewport-windowed highlighting with proper LRU caching Replace the O(n) min_by_key eviction scan in FileSyntaxCache with a true LRU: an intrusive doubly-linked recency list over reusable Vec slots plus a key->slot index map, making get/insert/evict all O(1). Add lock-free hit/miss counters (relaxed atomics) readable for debugging via AppServices::file_syntax_cache_stats, logged when the cache is cleared. Window span extraction for large files: tree-sitter must still parse the full source for correct context (phosphor contract), so files <= 512 KiB keep the amortized full-file highlight cached once per file/ref. Above that, query/span extraction - the dominant cost and span-memory driver on huge files - is restricted to the requested viewport's source lines, quantized to 2048-line buckets cached per (repo, ref, path, generation, epoch, bucket). Each side's source-line bounds are derived from the projected-row window via carbon_window_source_line_bounds, and windowed entries keep full-file line tables so byte mapping stays valid while tokens only cover the bucket. --- src/apprt/services.rs | 321 +++++++++++++++++++++++++++------ src/core/syntax/annotator.rs | 86 ++++++++- src/core/syntax/highlighter.rs | 14 ++ 3 files changed, 362 insertions(+), 59 deletions(-) diff --git a/src/apprt/services.rs b/src/apprt/services.rs index e9d6503f..8d5c0e4e 100644 --- a/src/apprt/services.rs +++ b/src/apprt/services.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -18,7 +19,9 @@ use crate::core::forge::github::{ }; use crate::core::http; use crate::core::review::{ReviewDecision, ReviewSession, ReviewSessionKey, ReviewTarget}; -use crate::core::syntax::annotator::FullFileSyntax; +use crate::core::syntax::annotator::{ + FullFileSyntax, SourceLineWindow, carbon_window_source_line_bounds, +}; use crate::core::vcs::discovery; use crate::core::vcs::git::pr_ref_path; use crate::core::vcs::model::RevisionId; @@ -39,45 +42,126 @@ use crate::ui::state::prepare_active_file; const DEV_GITHUB_TOKEN_FILE_NAME: &str = "github-token.dev"; +/// Files at or below this size are highlighted in full and cached once; above +/// it, span extraction is windowed to the requested source-line bucket so +/// huge files never pay (or cache) full-file query extraction. +const SYNTAX_FULL_HIGHLIGHT_BYTE_LIMIT: usize = 512 * 1024; +/// Quantization for windowed cache entries, in source lines. Buckets are +/// large relative to viewport tiles so scrolling reuses cached windows. +const SYNTAX_WINDOW_BUCKET_LINES: usize = 2048; + #[derive(Debug, Clone)] pub struct AppServices { settings_store: SettingsStore, review_store: ReviewStore, syntax_cache: Arc>, syntax_cache_ready: Arc, + syntax_cache_stats: Arc, +} + +/// Lock-free hit/miss counters for the file syntax cache, readable for +/// debugging without taking the cache mutex. +#[derive(Debug, Default)] +pub struct FileSyntaxCacheStats { + hits: AtomicU64, + misses: AtomicU64, } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +impl FileSyntaxCacheStats { + pub fn hits(&self) -> u64 { + self.hits.load(Ordering::Relaxed) + } + + pub fn misses(&self) -> u64 { + self.misses.load(Ordering::Relaxed) + } + + fn record_hit(&self) { + self.hits.fetch_add(1, Ordering::Relaxed); + } + + fn record_miss(&self) { + self.misses.fetch_add(1, Ordering::Relaxed); + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] struct FileSyntaxCacheKey { repo_path: String, reference: String, path: String, generation: u64, epoch: u64, + /// Quantized source-line window for large files; `None` means the entry + /// covers the whole file. + line_window: Option<(u32, u32)>, } -#[derive(Debug, Default)] +const LRU_NIL: usize = usize::MAX; + +/// LRU cache of highlighted file syntax. Recency is tracked with an intrusive +/// doubly-linked list over slot indices, so lookups, inserts, and evictions +/// are all O(1) instead of scanning every entry on insert. +#[derive(Debug)] struct FileSyntaxCache { - entries: HashMap, + map: HashMap, + slots: Vec, + free_slots: Vec, + /// Most recently used slot index, or `LRU_NIL` when empty. + head: usize, + /// Least recently used slot index, or `LRU_NIL` when empty. + tail: usize, inflight: HashSet, bytes: usize, - tick: u64, epoch: u64, } #[derive(Debug)] -struct FileSyntaxCacheEntry { - syntax: Arc, +struct FileSyntaxCacheSlot { + key: FileSyntaxCacheKey, + syntax: Option>, bytes: usize, - last_used: u64, + prev: usize, + next: usize, +} + +impl Default for FileSyntaxCache { + fn default() -> Self { + Self { + map: HashMap::new(), + slots: Vec::new(), + free_slots: Vec::new(), + head: LRU_NIL, + tail: LRU_NIL, + inflight: HashSet::new(), + bytes: 0, + epoch: 0, + } + } +} + +/// Expands a requested source-line window to stable bucket boundaries so +/// nearby viewport requests share one cached windowed entry. +fn bucketed_line_window(lines: SourceLineWindow) -> (u32, u32) { + let bucket = SYNTAX_WINDOW_BUCKET_LINES.max(1); + let start = (lines.start / bucket).saturating_mul(bucket); + let end = lines + .end + .max(lines.start.saturating_add(1)) + .div_ceil(bucket) + .saturating_mul(bucket); + ( + carbon::usize_to_u32_saturating(start), + carbon::usize_to_u32_saturating(end), + ) } impl FileSyntaxCache { fn get(&mut self, key: &FileSyntaxCacheKey) -> Option> { - let tick = self.next_tick(); - let entry = self.entries.get_mut(key)?; - entry.last_used = tick; - Some(entry.syntax.clone()) + let index = self.map.get(key).copied()?; + self.detach(index); + self.attach_front(index); + self.slots.get(index).and_then(|slot| slot.syntax.clone()) } fn insert(&mut self, key: FileSyntaxCacheKey, syntax: Arc) { @@ -85,43 +169,108 @@ impl FileSyntaxCache { const BYTE_BUDGET: usize = 48 * 1024 * 1024; let bytes = syntax.estimated_bytes().max(1); - let tick = self.next_tick(); - if let Some(previous) = self.entries.insert( - key, - FileSyntaxCacheEntry { - syntax, + if let Some(index) = self.map.get(&key).copied() { + if let Some(slot) = self.slots.get_mut(index) { + let previous = std::mem::replace(&mut slot.bytes, bytes); + slot.syntax = Some(syntax); + self.bytes = self.bytes.saturating_sub(previous).saturating_add(bytes); + } + self.detach(index); + self.attach_front(index); + } else { + let slot = FileSyntaxCacheSlot { + key: key.clone(), + syntax: Some(syntax), bytes, - last_used: tick, - }, - ) { - self.bytes = self.bytes.saturating_sub(previous.bytes); + prev: LRU_NIL, + next: LRU_NIL, + }; + let index = match self.free_slots.pop() { + Some(free) if free < self.slots.len() => { + self.slots[free] = slot; + free + } + _ => { + self.slots.push(slot); + self.slots.len().saturating_sub(1) + } + }; + self.map.insert(key, index); + self.attach_front(index); + self.bytes = self.bytes.saturating_add(bytes); } - self.bytes = self.bytes.saturating_add(bytes); - - while self.entries.len() > MAX_ENTRIES - || (self.entries.len() > 1 && self.bytes > BYTE_BUDGET) - { - let Some(victim) = self - .entries - .iter() - .min_by_key(|(_, entry)| entry.last_used) - .map(|(key, _)| key.clone()) - else { + + while self.map.len() > MAX_ENTRIES || (self.map.len() > 1 && self.bytes > BYTE_BUDGET) { + if !self.evict_lru() { break; - }; - if let Some(entry) = self.entries.remove(&victim) { - self.bytes = self.bytes.saturating_sub(entry.bytes); } } } - fn next_tick(&mut self) -> u64 { - self.tick = self.tick.saturating_add(1); - self.tick + fn evict_lru(&mut self) -> bool { + let index = self.tail; + if index == LRU_NIL { + return false; + } + self.detach(index); + let Some(slot) = self.slots.get_mut(index) else { + return false; + }; + let key = std::mem::take(&mut slot.key); + slot.syntax = None; + let freed = std::mem::take(&mut slot.bytes); + self.bytes = self.bytes.saturating_sub(freed); + self.map.remove(&key); + self.free_slots.push(index); + true + } + + fn detach(&mut self, index: usize) { + let (prev, next) = match self.slots.get(index) { + Some(slot) => (slot.prev, slot.next), + None => return, + }; + if prev == LRU_NIL { + if self.head == index { + self.head = next; + } + } else if let Some(prev_slot) = self.slots.get_mut(prev) { + prev_slot.next = next; + } + if next == LRU_NIL { + if self.tail == index { + self.tail = prev; + } + } else if let Some(next_slot) = self.slots.get_mut(next) { + next_slot.prev = prev; + } + if let Some(slot) = self.slots.get_mut(index) { + slot.prev = LRU_NIL; + slot.next = LRU_NIL; + } + } + + fn attach_front(&mut self, index: usize) { + let old_head = self.head; + let Some(slot) = self.slots.get_mut(index) else { + return; + }; + slot.prev = LRU_NIL; + slot.next = old_head; + if old_head == LRU_NIL { + self.tail = index; + } else if let Some(head_slot) = self.slots.get_mut(old_head) { + head_slot.prev = index; + } + self.head = index; } fn clear(&mut self) { - self.entries.clear(); + self.map.clear(); + self.slots.clear(); + self.free_slots.clear(); + self.head = LRU_NIL; + self.tail = LRU_NIL; self.inflight.clear(); self.bytes = 0; self.epoch = self.epoch.saturating_add(1); @@ -136,9 +285,14 @@ impl AppServices { review_store, syntax_cache: Arc::new(Mutex::new(FileSyntaxCache::default())), syntax_cache_ready: Arc::new(Condvar::new()), + syntax_cache_stats: Arc::new(FileSyntaxCacheStats::default()), } } + pub fn file_syntax_cache_stats(&self) -> &FileSyntaxCacheStats { + &self.syntax_cache_stats + } + pub fn run_compare( &self, generation: u64, @@ -329,6 +483,11 @@ impl AppServices { }; let annotator = crate::core::syntax::DiffSyntaxAnnotator::new(); + let (old_lines, new_lines) = carbon_window_source_line_bounds( + &request.carbon_file, + &request.carbon_expansion, + request.window, + ); let old_syntax = request .carbon_file .old_path @@ -339,6 +498,7 @@ impl AppServices { request, &request.left_ref, old_path, + old_lines, &annotator, is_current, ) @@ -356,6 +516,7 @@ impl AppServices { request, &request.right_ref, new_path, + new_lines, &annotator, is_current, ) @@ -374,12 +535,14 @@ impl AppServices { ) } + #[allow(clippy::too_many_arguments)] fn cached_file_syntax( &self, repo: &mut dyn crate::core::vcs::backend::VcsRepository, request: &LoadFileSyntaxRequest, reference: &str, source_path: &str, + lines: Option, annotator: &crate::core::syntax::DiffSyntaxAnnotator, is_current: &F, ) -> Option> @@ -389,22 +552,38 @@ impl AppServices { if reference.is_empty() || !is_current() { return None; } + // No rows from this side fall inside the requested window, so no + // tokens are needed for it. + let lines = lines?; + let bucket = bucketed_line_window(lines); let mut cache = self.syntax_cache.lock().ok()?; - let key = FileSyntaxCacheKey { + let whole_key = FileSyntaxCacheKey { repo_path: request.repo_path.to_string_lossy().into_owned(), reference: reference.to_owned(), path: source_path.to_owned(), generation: request.cache_generation, epoch: cache.epoch, + line_window: None, + }; + let window_key = FileSyntaxCacheKey { + line_window: Some(bucket), + ..whole_key.clone() }; loop { - if cache.epoch != key.epoch { + if cache.epoch != whole_key.epoch { return None; } - if let Some(cached) = cache.get(&key) { + // A whole-file entry covers every window, so prefer it. + if let Some(cached) = cache.get(&whole_key).or_else(|| cache.get(&window_key)) { + self.syntax_cache_stats.record_hit(); return Some(cached); } - if cache.inflight.insert(key.clone()) { + // Mark both keys in flight: until the file size is known we do + // not know whether the result will be whole-file or windowed, and + // this also serializes overlapping work on the same file. + if !cache.inflight.contains(&whole_key) && !cache.inflight.contains(&window_key) { + cache.inflight.insert(whole_key.clone()); + cache.inflight.insert(window_key.clone()); break; } cache = self @@ -412,16 +591,18 @@ impl AppServices { .wait_timeout(cache, Duration::from_millis(25)) .ok()? .0; - if cache.epoch != key.epoch || !is_current() { + if cache.epoch != whole_key.epoch || !is_current() { return None; } } - if cache.epoch != key.epoch || !is_current() { - cache.inflight.remove(&key); + if cache.epoch != whole_key.epoch || !is_current() { + cache.inflight.remove(&whole_key); + cache.inflight.remove(&window_key); self.syntax_cache_ready.notify_all(); return None; } drop(cache); + self.syntax_cache_stats.record_miss(); let revision = RevisionId { backend: repo.location().kind, @@ -430,29 +611,36 @@ impl AppServices { let text = match repo.read_file_text(&revision, source_path) { Ok(text) => text, Err(_) => { - if let Ok(mut cache) = self.syntax_cache.lock() { - cache.inflight.remove(&key); - self.syntax_cache_ready.notify_all(); - } + self.release_syntax_inflight(&whole_key, &window_key); return None; } }; if !is_current() { - if let Ok(mut cache) = self.syntax_cache.lock() { - cache.inflight.remove(&key); - self.syntax_cache_ready.notify_all(); - } + self.release_syntax_inflight(&whole_key, &window_key); return None; } - let syntax = Arc::new(annotator.highlight_full_text_store(source_path, &text)); + let windowed = + carbon::u32_to_usize_saturating(text.len()) > SYNTAX_FULL_HIGHLIGHT_BYTE_LIMIT; + let (store_key, syntax) = if windowed { + let window = SourceLineWindow { + start: carbon::u32_to_usize_saturating(bucket.0), + end: carbon::u32_to_usize_saturating(bucket.1), + }; + let syntax = annotator.highlight_window_text_store(source_path, &text, window); + (window_key.clone(), Arc::new(syntax)) + } else { + let syntax = annotator.highlight_full_text_store(source_path, &text); + (whole_key.clone(), Arc::new(syntax)) + }; match self.syntax_cache.lock() { Ok(mut cache) => { - cache.inflight.remove(&key); - if cache.epoch != key.epoch || !is_current() { + cache.inflight.remove(&whole_key); + cache.inflight.remove(&window_key); + if cache.epoch != whole_key.epoch || !is_current() { self.syntax_cache_ready.notify_all(); return None; } - cache.insert(key, syntax.clone()); + cache.insert(store_key, syntax.clone()); self.syntax_cache_ready.notify_all(); } Err(_) => self.syntax_cache_ready.notify_all(), @@ -460,11 +648,28 @@ impl AppServices { Some(syntax) } + fn release_syntax_inflight( + &self, + whole_key: &FileSyntaxCacheKey, + window_key: &FileSyntaxCacheKey, + ) { + if let Ok(mut cache) = self.syntax_cache.lock() { + cache.inflight.remove(whole_key); + cache.inflight.remove(window_key); + } + self.syntax_cache_ready.notify_all(); + } + pub fn clear_file_syntax_cache(&self) { if let Ok(mut cache) = self.syntax_cache.lock() { cache.clear(); self.syntax_cache_ready.notify_all(); } + tracing::debug!( + hits = self.syntax_cache_stats.hits(), + misses = self.syntax_cache_stats.misses(), + "syntax: file syntax cache cleared" + ); } pub fn load_pull_request( diff --git a/src/core/syntax/annotator.rs b/src/core/syntax/annotator.rs index d1800b4d..32e4d67e 100644 --- a/src/core/syntax/annotator.rs +++ b/src/core/syntax/annotator.rs @@ -1,6 +1,6 @@ use crate::core::syntax::Highlighter; use crate::core::text::DiffTokenSpan; -use carbon::{LineId, TextStore}; +use carbon::{LineId, TextByteRange, TextStore}; #[derive(Debug, Clone, Copy)] struct LineRef { @@ -24,6 +24,13 @@ impl SyntaxRowWindow { } } +/// Half-open window of 0-based source lines on one diff side. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SourceLineWindow { + pub start: usize, + pub end: usize, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SyntaxLineTokens { pub hunk_index: usize, @@ -111,6 +118,58 @@ impl DiffSyntaxAnnotator { } } + /// Highlights only the given source-line window of `text`. Tree-sitter + /// still parses the full source (windowed parsing would lose syntactic + /// context), but query/span extraction — the expensive part on very large + /// files — is restricted to the window's byte range. The returned value + /// keeps full-file line tables so byte mapping works for any line, while + /// tokens only cover the window. + pub fn highlight_window_text_store( + &self, + path: &str, + text: &TextStore, + lines: SourceLineWindow, + ) -> FullFileSyntax { + let (line_offsets, line_lengths) = line_ranges_from_text_store(text); + let line_count = line_lengths.len(); + let start_line = lines.start.min(line_count); + let end_line = lines.end.clamp(start_line, line_count); + let text_len = carbon::u32_to_usize_saturating(text.len()); + let start_byte = line_offsets.get(start_line).copied().unwrap_or(text_len); + // `line_offsets` carries a trailing entry at `text.len()`, so the + // exclusive end line maps to the byte just past the window. + let end_byte = line_offsets.get(end_line).copied().unwrap_or(text_len); + + let mut tokens = Vec::new(); + if end_byte > start_byte { + let language = self.highlighter.resolve_language(path); + let range = TextByteRange { + start: usize_to_u32_saturating(start_byte), + len: usize_to_u32_saturating(end_byte - start_byte), + }; + match self + .highlighter + .highlight_text_store_resolved_ranges(language, text, &[range]) + { + Ok(spans) => tokens = spans, + Err(error) => { + tracing::warn!( + path = %path, + ?language, + %error, + "windowed syntax highlight failed" + ); + } + } + } + + FullFileSyntax { + line_offsets, + line_lengths, + tokens, + } + } + pub fn annotate_carbon_full_file_window_from_cache( &self, file: &carbon::FileDiff, @@ -138,6 +197,31 @@ impl DiffSyntaxAnnotator { } } +/// Returns the (old, new) source-line bounds touched by a projected-row +/// window, so callers can request windowed highlighting per side. `None` +/// means the side has no content rows inside the window. +pub fn carbon_window_source_line_bounds( + file: &carbon::FileDiff, + expansion: &carbon::ExpansionState, + window: SyntaxRowWindow, +) -> (Option, Option) { + if file.is_binary || window.end <= window.start { + return (None, None); + } + let (old_refs, new_refs) = build_carbon_full_file_refs(file, expansion, 0, window); + (source_line_bounds(&old_refs), source_line_bounds(&new_refs)) +} + +fn source_line_bounds(refs: &[LineRef]) -> Option { + // At this stage `content_offset` holds the 0-based source line index. + let min = refs.iter().map(|r| r.content_offset).min()?; + let max = refs.iter().map(|r| r.content_offset).max()?; + Some(SourceLineWindow { + start: min, + end: max.saturating_add(1), + }) +} + fn build_carbon_full_file_refs( file: &carbon::FileDiff, expansion: &carbon::ExpansionState, diff --git a/src/core/syntax/highlighter.rs b/src/core/syntax/highlighter.rs index 761d73c4..5c84de7a 100644 --- a/src/core/syntax/highlighter.rs +++ b/src/core/syntax/highlighter.rs @@ -65,6 +65,20 @@ impl Highlighter { self.highlight_resolved(language, source) } + pub fn highlight_text_store_resolved_ranges( + &self, + language: Option, + text: &TextStore, + byte_ranges: &[TextByteRange], + ) -> Result> { + let Some(source) = text.as_str() else { + return Err(DiffyError::Syntax( + "syntax source is not valid UTF-8".to_owned(), + )); + }; + self.highlight_resolved_ranges(language, source, byte_ranges) + } + pub fn highlight_resolved_ranges( &self, language: Option, From 81271b892455de790d802bb9c15e1226ba866eb7 Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 20:44:12 +0000 Subject: [PATCH 11/25] refactor(ui): extract shared virtual list machinery Dedupe the filter-indices -> sectioned-rows -> virtual-window -> selection logic that the sidebar, file tree, picker overlay, and command palette each re-implemented, into src/ui/virtual_list.rs: - build_sectioned_rows: shared sectioned row construction, now backing the sidebar's grouped/ungrouped file rows and the theme picker's grouped variant entries. - virtual_row_wrapper_extent: shared last-row-drops-trailing-gap wrapper math for sidebar rows and file tree rows. - step_selection: shared header-skipping selection stepping for the theme/ repo/ref/font picker and command palette overlay arms. - picker_list now derives its list/total extents from virtual_list_total_extent instead of inline stride math. Pure dedup; no behavior change. --- src/ui/components/file_tree.rs | 12 ++-- src/ui/components/picker.rs | 14 +--- src/ui/sidebar.rs | 63 +++++------------- src/ui/state/mod.rs | 107 +++++++++++------------------- src/ui/virtual_list.rs | 116 ++++++++++++++++++++++++++++++++- 5 files changed, 179 insertions(+), 133 deletions(-) diff --git a/src/ui/components/file_tree.rs b/src/ui/components/file_tree.rs index d79dd937..7b76f800 100644 --- a/src/ui/components/file_tree.rs +++ b/src/ui/components/file_tree.rs @@ -11,6 +11,7 @@ use crate::ui::element::{ use crate::ui::icons::lucide; use crate::ui::style::Styled; use crate::ui::theme::Color; +use crate::ui::virtual_list::virtual_row_wrapper_extent; pub struct FileTreeEntry { pub path: String, @@ -358,11 +359,12 @@ impl RenderOnce for FileTree { for (offset, row) in rows.into_iter().enumerate() { let global_index = window_start + offset; - let wrapper_height = if global_index + 1 == total_rows { - row_height - } else { - row_height + row_gap - }; + let wrapper_height = virtual_row_wrapper_extent( + global_index, + total_rows, + row_height, + row_height + row_gap, + ); let row_element = match row { FlatRow::Folder { name, diff --git a/src/ui/components/picker.rs b/src/ui/components/picker.rs index a5c3d409..14ba8eb5 100644 --- a/src/ui/components/picker.rs +++ b/src/ui/components/picker.rs @@ -6,6 +6,7 @@ use crate::ui::shell::CursorHint; use crate::ui::state::{PickerItem, PickerLabelStyle}; use crate::ui::style::Styled; use crate::ui::theme::{Color, Theme, ThemeColors}; +use crate::ui::virtual_list::virtual_list_total_extent; pub fn picker_list( entries: &[T], @@ -19,18 +20,9 @@ pub fn picker_list( let row_h = theme.metrics.ui_row_height.round(); let gap = (Sp::XS * scale).round(); let icon_size = Ico::XS; - let stride = row_h + gap; let visible_count = entries.len().min(max_visible); - let list_h = if visible_count == 0 { - 0.0 - } else { - visible_count as f32 * stride - gap - }; - let total_h = if entries.is_empty() { - 0.0 - } else { - entries.len() as f32 * stride - gap - }; + let list_h = virtual_list_total_extent(visible_count, row_h, gap); + let total_h = virtual_list_total_extent(entries.len(), row_h, gap); let scroll = scroll_top_px.min((total_h - list_h).max(0.0)); view! { scale, diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 16c3330d..25b0f271 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -22,7 +22,9 @@ use crate::ui::state::{ use crate::ui::style::Styled; use crate::ui::theme::{Color, Theme}; use crate::ui::vcs::change_summary_label; -use crate::ui::virtual_list::virtual_list_window; +use crate::ui::virtual_list::{ + build_sectioned_rows, virtual_list_window, virtual_row_wrapper_extent, +}; pub(crate) struct SidebarResizeDrag { origin_x: f32, @@ -73,38 +75,16 @@ fn build_sidebar_rows<'a>( filtered_indices: &[usize], status_changes: Option<&[FileChange]>, ) -> Vec> { - let mut rows = Vec::with_capacity(filtered_indices.len()); - let mut last_bucket = None; - - for &index in filtered_indices { - let Some(entry) = all_files.get(index) else { - continue; - }; - let bucket = - status_changes.and_then(|changes| changes.get(index).map(|change| change.bucket)); - if bucket != last_bucket { - if let Some(bucket) = bucket { - rows.push(SidebarRow::Section(bucket)); - } - last_bucket = bucket; - } - rows.push(SidebarRow::File { index, entry }); - } - - rows -} - -fn sidebar_row_wrapper_height( - global_index: usize, - total_rows: usize, - row_height: f32, - stride: f32, -) -> f32 { - if global_index + 1 == total_rows { - row_height - } else { - stride - } + build_sectioned_rows( + filtered_indices, + |index| status_changes.and_then(|changes| changes.get(index).map(|change| change.bucket)), + |&bucket| SidebarRow::Section(bucket), + |index| { + all_files + .get(index) + .map(|entry| SidebarRow::File { index, entry }) + }, + ) } fn render_sidebar_row( @@ -647,7 +627,7 @@ pub(crate) fn sidebar( let rendered_rows: Vec = visible_files .iter() .map(|(index, entry)| { - let wrapper_height = sidebar_row_wrapper_height(*index, file_count, row_h, stride); + let wrapper_height = virtual_row_wrapper_extent(*index, file_count, row_h, stride); view! { scale,
{file_row(entry, *index, state, tc, scale, selected_index)} @@ -759,18 +739,7 @@ pub(crate) fn sidebar( let all_files = (0..file_count) .filter_map(|index| state.workspace_file_entry_at(index)) .collect::>(); - let rows = if grouped_status { - build_sidebar_rows(&all_files, &filtered_indices, status_rows.as_deref()) - } else { - filtered_indices - .iter() - .filter_map(|&index| { - all_files - .get(index) - .map(|entry| SidebarRow::File { index, entry }) - }) - .collect() - }; + let rows = build_sidebar_rows(&all_files, &filtered_indices, status_rows.as_deref()); let scroll_px = state.file_list.scroll_offset_px.get(&state.store); let stride = state.file_list_row_stride(); let viewport_height = state.file_list.viewport_height.get(&state.store); @@ -794,7 +763,7 @@ pub(crate) fn sidebar( .map(|(offset, row)| { let global_index = window.start + offset; let wrapper_height = - sidebar_row_wrapper_height(global_index, rows.len(), row_h, stride); + virtual_row_wrapper_extent(global_index, rows.len(), row_h, stride); view! { scale,
{render_sidebar_row(*row, state, tc, scale, row_h, selected_index)} diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index bcb71f6b..66132c24 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -71,6 +71,7 @@ use crate::ui::components::ContextMenuState; use crate::ui::design::{Sp, Sz}; use crate::ui::icons::lucide; use crate::ui::theme::ThemeMode; +use crate::ui::virtual_list::{build_sectioned_rows, step_selection}; const MAX_VISIBLE_TOASTS: usize = 5; const TOAST_LIFETIME_MS: u64 = 5_000; @@ -6924,36 +6925,29 @@ impl AppState { section_header: true, }; - let mut dual = Vec::new(); - let mut dark = Vec::new(); - let mut light = Vec::new(); - for (i, name) in self.theme_names.iter().enumerate() { - let variant = self - .theme_variants - .get(i) + let variant_of = |index: usize| { + self.theme_variants + .get(index) .copied() - .unwrap_or(ThemeVariant::Dark); - match variant { - ThemeVariant::Dual => dual.push(make_entry(name)), - ThemeVariant::Dark => dark.push(make_entry(name)), - ThemeVariant::Light => light.push(make_entry(name)), - } - } - - let mut entries = Vec::with_capacity(dual.len() + dark.len() + light.len() + 3); - if !dual.is_empty() { - entries.push(make_header("Dark & Light")); - entries.extend(dual); - } - if !dark.is_empty() { - entries.push(make_header("Dark")); - entries.extend(dark); - } - if !light.is_empty() { - entries.push(make_header("Light")); - entries.extend(light); - } - entries + .unwrap_or(ThemeVariant::Dark) + }; + let mut ordered: Vec = Vec::with_capacity(self.theme_names.len()); + for group in [ThemeVariant::Dual, ThemeVariant::Dark, ThemeVariant::Light] { + ordered.extend((0..self.theme_names.len()).filter(|&index| variant_of(index) == group)); + } + + build_sectioned_rows( + &ordered, + |index| Some(variant_of(index)), + |variant| { + make_header(match variant { + ThemeVariant::Dual => "Dark & Light", + ThemeVariant::Dark => "Dark", + ThemeVariant::Light => "Light", + }) + }, + |index| self.theme_names.get(index).map(make_entry), + ) } fn rebuild_theme_picker(&mut self) { @@ -7264,30 +7258,18 @@ impl AppState { let current = self.overlays.picker.selected_index.get(&self.store); let (idx, len, value) = self.overlays.picker.entries.with(&self.store, |entries| { let len = entries.len(); - if len == 0 { - return (current, len, None); - } - let max = len.saturating_sub(1) as i32; - let mut idx = (current as i32 + delta).clamp(0, max) as usize; - while idx < len && entries[idx].section_header { - if delta > 0 { - idx = (idx + 1).min(len.saturating_sub(1)); - } else { - if idx == 0 { - break; - } - idx -= 1; - } - } - let value = entries - .get(idx) - .filter(|e| !e.section_header) - .map(|e| e.value.clone()); + let idx = step_selection(current, delta, len, |i| entries[i].section_header); + let value = idx.and_then(|idx| { + entries + .get(idx) + .filter(|e| !e.section_header) + .map(|e| e.value.clone()) + }); (idx, len, value) }); - if len == 0 { + let Some(idx) = idx else { return; - } + }; self.overlays.picker.selected_index.set(&self.store, idx); self.overlays .picker @@ -7304,26 +7286,12 @@ impl AppState { let current = self.overlays.picker.selected_index.get(&self.store); let (idx, len) = self.overlays.picker.entries.with(&self.store, |entries| { let len = entries.len(); - if len == 0 { - return (current, len); - } - let max = len.saturating_sub(1) as i32; - let mut idx = (current as i32 + delta).clamp(0, max) as usize; - while idx < len && entries[idx].section_header { - if delta > 0 { - idx = (idx + 1).min(len.saturating_sub(1)); - } else { - if idx == 0 { - break; - } - idx -= 1; - } - } + let idx = step_selection(current, delta, len, |i| entries[i].section_header); (idx, len) }); - if len == 0 { + let Some(idx) = idx else { return; - } + }; self.overlays.picker.selected_index.set(&self.store, idx); self.overlays .picker @@ -7336,13 +7304,14 @@ impl AppState { .command_palette .entries .with(&self.store, |e| e.len()); - let max = entry_count.saturating_sub(1) as i32; let current = self .overlays .command_palette .selected_index .get(&self.store); - let idx = (current as i32 + delta).clamp(0, max.max(0)) as usize; + // Palette entries have no section headers; an empty palette + // still pins the selection to row zero. + let idx = step_selection(current, delta, entry_count, |_| false).unwrap_or(0); self.overlays .command_palette .selected_index diff --git a/src/ui/virtual_list.rs b/src/ui/virtual_list.rs index ffdc36c3..34116caa 100644 --- a/src/ui/virtual_list.rs +++ b/src/ui/virtual_list.rs @@ -18,6 +18,83 @@ pub(crate) fn virtual_list_total_extent(item_count: usize, item_extent: f32, ite item_count as f32 * (item_extent + item_gap) - item_gap } +/// Extent reserved by the wrapper around one windowed row: every row keeps +/// its full stride (row + gap) except the last, which drops the trailing gap +/// so the column height matches `virtual_list_total_extent`. +pub(crate) fn virtual_row_wrapper_extent( + global_index: usize, + total_rows: usize, + row_extent: f32, + stride: f32, +) -> f32 { + if global_index + 1 == total_rows { + row_extent + } else { + stride + } +} + +/// Build a flat row list from filtered item indices, inserting a section +/// header row whenever the section key changes between consecutive items. +/// Indices whose item fails to resolve are skipped without affecting the +/// current section. +pub(crate) fn build_sectioned_rows( + filtered_indices: &[usize], + mut section_of: impl FnMut(usize) -> Option, + mut section_row: impl FnMut(&S) -> R, + mut item_row: impl FnMut(usize) -> Option, +) -> Vec { + let mut rows = Vec::with_capacity(filtered_indices.len()); + let mut last_section: Option = None; + + for &index in filtered_indices { + let Some(row) = item_row(index) else { + continue; + }; + let section = section_of(index); + if section != last_section { + if let Some(section) = §ion { + rows.push(section_row(section)); + } + last_section = section; + } + rows.push(row); + } + + rows +} + +/// Step a list selection by `delta` rows, clamping to bounds and skipping +/// section-header rows in the direction of travel. Returns `None` when the +/// list is empty. +pub(crate) fn step_selection( + current: usize, + delta: i32, + len: usize, + mut is_header: impl FnMut(usize) -> bool, +) -> Option { + if len == 0 { + return None; + } + let max = len.saturating_sub(1) as i32; + let mut idx = (current as i32 + delta).clamp(0, max) as usize; + while idx < len && is_header(idx) { + if delta > 0 { + let next = (idx + 1).min(len.saturating_sub(1)); + if next == idx { + break; + } + idx = next; + } else { + if idx == 0 { + break; + } + idx -= 1; + } + } + Some(idx) +} + pub(crate) fn virtual_list_window( item_count: usize, scroll_offset: f32, @@ -67,7 +144,10 @@ pub(crate) fn virtual_list_window( #[cfg(test)] mod tests { - use super::{virtual_list_total_extent, virtual_list_window}; + use super::{ + build_sectioned_rows, step_selection, virtual_list_total_extent, virtual_list_window, + virtual_row_wrapper_extent, + }; #[test] fn virtual_list_window_overscans_and_clamps() { @@ -94,4 +174,38 @@ mod tests { assert_eq!(virtual_list_total_extent(1, 36.0, 4.0), 36.0); assert_eq!(virtual_list_total_extent(3, 36.0, 4.0), 116.0); } + + #[test] + fn last_row_wrapper_drops_trailing_gap() { + assert_eq!(virtual_row_wrapper_extent(0, 3, 36.0, 40.0), 40.0); + assert_eq!(virtual_row_wrapper_extent(2, 3, 36.0, 40.0), 36.0); + } + + #[test] + fn sectioned_rows_insert_headers_and_skip_missing_items() { + let sections = [Some(1_u8), Some(1), None, Some(2)]; + let rows = build_sectioned_rows( + &[0, 1, 2, 3], + |index| sections[index], + |section| format!("section {section}"), + |index| (index != 2).then(|| format!("item {index}")), + ); + + assert_eq!( + rows, + ["section 1", "item 0", "item 1", "section 2", "item 3"] + ); + } + + #[test] + fn step_selection_clamps_and_skips_headers() { + let headers = [true, false, false, true, false]; + let is_header = |i: usize| headers[i]; + + assert_eq!(step_selection(0, 1, 0, is_header), None); + assert_eq!(step_selection(2, 1, headers.len(), is_header), Some(4)); + assert_eq!(step_selection(4, -1, headers.len(), is_header), Some(2)); + assert_eq!(step_selection(1, -1, headers.len(), is_header), Some(0)); + assert_eq!(step_selection(4, 10, headers.len(), is_header), Some(4)); + } } From 5ed938d94995f341a957db4580514b0e51e4ecc9 Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 21:20:43 +0000 Subject: [PATCH 12/25] refactor(ui): split AppState module into feature modules Pure code motion: src/ui/state/mod.rs shrinks from ~15.6k to ~900 lines, keeping only the AppState struct, Default/bootstrap, the action/event dispatch, and glob re-exports so external call sites keep their paths. - compare.rs: CompareState/CompareProgress/stats hydration + compare lifecycle reducers and helpers - repository.rs: RepositoryState + snapshot/status/publish/fetch methods - file_list.rs: file list state/entries + selection and sidebar methods - overlay.rs: overlay stack, picker/palette state and all picker methods - github.rs: PR peek/review state types and avatar helpers - presentation.rs (new): viewport document cache, virtual scroll model, file height index, scroll anchoring and scrollbar methods - working_set.rs: ActiveFile + file working-set cache and prefetch - syntax.rs: syntax window bookkeeping, pack installs, token application - editor.rs: context expansion, line selection, hunk ops, search - ui.rs (new): app view/settings sections, focus targets, toasts, clock - text_compare.rs, text_edit.rs, workspace.rs, update.rs, app.rs, settings.rs, ai.rs: their embedded state structs and methods - tests.rs (new): the inline #[cfg(test)] mod from mod.rs Private items that crossed the new module boundaries were bumped to pub(super); no public renames and no behavior changes. --- src/ui/state/ai.rs | 9 + src/ui/state/app.rs | 16 + src/ui/state/compare.rs | 1462 +++ src/ui/state/editor.rs | 836 ++ src/ui/state/file_list.rs | 857 ++ src/ui/state/github.rs | 339 + src/ui/state/mod.rs | 15949 +-------------------------------- src/ui/state/overlay.rs | 2984 ++++++ src/ui/state/presentation.rs | 2154 +++++ src/ui/state/repository.rs | 1083 +++ src/ui/state/settings.rs | 62 + src/ui/state/syntax.rs | 670 +- src/ui/state/tests.rs | 3291 +++++++ src/ui/state/text_compare.rs | 140 + src/ui/state/text_edit.rs | 162 +- src/ui/state/ui.rs | 362 + src/ui/state/update.rs | 12 + src/ui/state/working_set.rs | 670 +- src/ui/state/workspace.rs | 72 + 19 files changed, 15638 insertions(+), 15492 deletions(-) create mode 100644 src/ui/state/presentation.rs create mode 100644 src/ui/state/tests.rs create mode 100644 src/ui/state/ui.rs diff --git a/src/ui/state/ai.rs b/src/ui/state/ai.rs index 6b04eb3f..3cc6371e 100644 --- a/src/ui/state/ai.rs +++ b/src/ui/state/ai.rs @@ -207,3 +207,12 @@ impl AppState { ] } } + +impl AppState { + pub(super) fn ai_key_editable(&self, kind: AiKeyKind) -> bool { + match kind { + AiKeyKind::OpenAi => self.ai_openai_key.is_empty() || self.ai_openai_editing, + AiKeyKind::Anthropic => self.ai_anthropic_key.is_empty() || self.ai_anthropic_editing, + } + } +} diff --git a/src/ui/state/app.rs b/src/ui/state/app.rs index 6d074890..f34729a1 100644 --- a/src/ui/state/app.rs +++ b/src/ui/state/app.rs @@ -81,3 +81,19 @@ impl AppState { } } } + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StartupState { + pub keyring_enabled: bool, + pub github_token_store: GitHubTokenStore, + pub auto_compare_pending: bool, + pub bootstrap_compare_started: bool, + pub pending_pr_url: Option, + pub preferred_file_index: Option, + pub preferred_file_path: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct DebugState { + pub overlay_visible: bool, +} diff --git a/src/ui/state/compare.rs b/src/ui/state/compare.rs index 91fb6b91..88eb784b 100644 --- a/src/ui/state/compare.rs +++ b/src/ui/state/compare.rs @@ -320,3 +320,1465 @@ impl AppState { self.kickoff_compare() } } + +pub(super) const LARGE_COMPARE_FILE_LINES: i32 = 1_500; + +pub(super) const COMPARE_STATS_CHUNK_SIZE: usize = 64; + +pub(super) const COMPARE_STATS_BACKGROUND_CHUNK_SIZE: usize = 128 * 1024; + +pub(super) const COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT: usize = 10_000; + +pub(super) const COMPARE_STATS_VISIBLE_OVERSCAN_ROWS: usize = 32; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum CompareField { + #[default] + Left, + Right, +} + +#[derive(Debug, Clone, PartialEq, Eq, Store)] +pub struct CompareState { + pub repo_path: Option, + pub left_ref: String, + pub right_ref: String, + pub mode: CompareMode, + pub layout: LayoutMode, + pub renderer: RendererKind, + pub resolved_left: Option, + pub resolved_right: Option, +} + +impl Default for CompareState { + fn default() -> Self { + Self { + repo_path: None, + left_ref: String::new(), + right_ref: String::new(), + mode: CompareMode::default(), + layout: LayoutMode::default(), + renderer: RendererKind::default(), + resolved_left: None, + resolved_right: None, + } + } +} + +pub use crate::core::compare::ComparePhase; + +/// What the progress panel is about. Drives chip rendering: compare +/// shows a left⇄right ref pair, repo-open shows a single folder chip. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoadingSubject { + Compare { + left_label: String, + right_label: String, + }, + RepoOpen { + name: String, + }, +} + +/// Transient progress state for a long-running workspace operation +/// (compare or repo open). Present iff something is in flight and the +/// reveal delay has either elapsed or was set to zero. Cleared when the +/// operation lands or the user cancels. +/// +/// `reveal_at_ms` controls when the panel is rendered. Compares show +/// immediately; repo-open still uses the short delay to avoid flashing a +/// loading panel for tiny repositories. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompareProgress { + pub generation: u64, + pub phase: ComparePhase, + pub subject: LoadingSubject, + pub started_at_ms: u64, + pub reveal_at_ms: u64, + /// Total file count — first known from a backend `LoadingFiles` + /// emission, re-confirmed by `CompareFinished`. Unused for RepoOpen. + pub file_count_total: Option, + /// Files read so far during `LoadingFiles`. Zero before, frozen + /// after. + pub files_loaded: u32, +} + +/// Delay between kicking off an op and revealing the loading UI — +/// fast ops under this threshold show no loading flash at all. +pub const COMPARE_REVEAL_DELAY_MS: u64 = 500; + +pub(super) fn vcs_compare_request( + mode: CompareMode, + left_ref: String, + right_ref: String, + layout: LayoutMode, + renderer: RendererKind, +) -> VcsCompareRequest { + let compare_spec = match mode { + CompareMode::SingleCommit => { + let revision = if right_ref.is_empty() { + left_ref + } else { + right_ref + }; + VcsCompareSpec::Change { revision } + } + CompareMode::TwoDot => VcsCompareSpec::Range { + from: left_ref, + to: right_ref, + }, + CompareMode::ThreeDot => VcsCompareSpec::MergeBaseRange { + base: left_ref, + head: right_ref, + }, + }; + VcsCompareRequest { + spec: compare_spec, + layout, + renderer, + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum CompareStatsHydrationState { + #[default] + Idle, + Running, + Failed, +} + +pub(super) fn matching_persisted_compare<'a>( + startup: &'a StartupOptions, + settings: &'a Settings, +) -> Option<&'a PersistedCompare> { + settings.last_compare.as_ref().filter(|compare| { + startup.args.repo.is_some() && compare.repo_path.as_ref() == startup.args.repo.as_ref() + }) +} + +pub(super) fn compare_refs_are_valid(mode: CompareMode, left_ref: &str, right_ref: &str) -> bool { + match mode { + CompareMode::SingleCommit => !left_ref.is_empty() || !right_ref.is_empty(), + CompareMode::TwoDot | CompareMode::ThreeDot => { + !left_ref.is_empty() && !right_ref.is_empty() + } + } +} + +pub(super) fn estimated_carbon_file_rows_with_overhead(file: &carbon::FileDiff) -> u32 { + if file.is_binary { + return 4; + } + estimated_carbon_file_rows(file).saturating_add(1).max(1) +} + +pub(super) fn estimated_carbon_file_rows(file: &carbon::FileDiff) -> u32 { + if file.hunks.is_empty() { + return file.additions.saturating_add(file.deletions).max(1); + } + + let mut rows = 0_u32; + for (hunk_index, hunk) in file.hunks.iter().enumerate() { + if !file.is_partial { + let gap_len = if hunk_index == 0 { + hunk.old_start_index().min(hunk.new_start_index()) + } else { + let prev = &file.hunks[hunk_index - 1]; + hunk.old_start_index() + .saturating_sub(prev.old_end_index()) + .min(hunk.new_start_index().saturating_sub(prev.new_end_index())) + }; + rows = rows.saturating_add((gap_len > 0) as u32); + } + + rows = rows.saturating_add(1); + for block in file.hunk_blocks(hunk) { + rows = rows.saturating_add(match block.kind { + carbon::BlockKind::Context => block.old.len.min(block.new.len), + carbon::BlockKind::Change => block.old.len.saturating_add(block.new.len), + }); + } + + if !file.is_partial && hunk_index + 1 == file.hunks.len() { + let old_end = file + .old_text + .as_ref() + .map(|text| text.line_count()) + .unwrap_or_else(|| hunk.old_end_index()); + let new_end = file + .new_text + .as_ref() + .map(|text| text.line_count()) + .unwrap_or_else(|| hunk.new_end_index()); + let gap_len = old_end + .saturating_sub(hunk.old_end_index()) + .min(new_end.saturating_sub(hunk.new_end_index())); + rows = rows.saturating_add((gap_len > 0) as u32); + } + } + rows +} + +pub(super) fn compare_summary_file_entry(summary: &CompareFileSummary) -> FileListEntry { + FileListEntry { + path: summary.paths.display_path_ref(), + } +} + +pub(super) fn compare_output_file_entry_meta( + output: &CompareOutput, + index: usize, +) -> Option { + if let Some(summary) = output.file_summaries.get(index) { + let (additions, deletions) = summary.fallback_stats(); + return Some(FileListEntryMeta { + status: carbon_list_status(summary.status), + additions, + deletions, + is_binary: summary.is_binary, + }); + } + output.carbon.files.get(index).map(carbon_file_entry_meta) +} + +pub(super) fn carbon_file_entry_meta(file: &carbon::FileDiff) -> FileListEntryMeta { + let (additions, deletions) = carbon_file_stats(file); + FileListEntryMeta { + status: carbon_list_status(file.status), + additions, + deletions, + is_binary: file.is_binary, + } +} + +pub(super) fn compare_output_summary_is_deferred(output: &CompareOutput, index: usize) -> bool { + if let Some(summary) = output.file_summaries.get(index) { + return summary.is_partial; + } + output + .carbon + .files + .get(index) + .is_some_and(|file| file.is_partial && file.hunks.is_empty()) +} + +pub(super) fn compare_output_deferred_summary( + output: &CompareOutput, + index: usize, +) -> Option { + if let Some(summary) = output.file_summaries.get(index) { + return summary.is_partial.then(|| summary.clone()); + } + output + .carbon + .files + .get(index) + .filter(|file| file.is_partial && file.hunks.is_empty()) + .map(CompareFileSummary::from_file) +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(super) struct CompareStatsSnapshot { + pub(super) hydrated_total: (i32, i32), + pub(super) deferred_count: usize, +} + +pub(super) fn compare_output_stats_snapshot(output: &CompareOutput) -> CompareStatsSnapshot { + let mut snapshot = CompareStatsSnapshot::default(); + output.for_each_summary(|_, summary| { + if summary.stats_deferred { + snapshot.deferred_count = snapshot.deferred_count.saturating_add(1); + } else { + let stats = summary.fallback_stats(); + snapshot.hydrated_total = ( + snapshot.hydrated_total.0.saturating_add(stats.0), + snapshot.hydrated_total.1.saturating_add(stats.1), + ); + } + }); + snapshot +} + +pub(super) fn compare_output_has_deferred_stats(output: &CompareOutput) -> bool { + if output.file_summaries.is_empty() { + output.carbon.files.iter().any(|file| file.stats_deferred) + } else { + output + .file_summaries + .iter() + .any(|summary| summary.stats_deferred) + } +} + +pub(super) fn carbon_file_stats(file: &carbon::FileDiff) -> (i32, i32) { + if file.additions > 0 || file.deletions > 0 || file.stats_deferred { + return ( + u32_to_i32_saturating(file.additions), + u32_to_i32_saturating(file.deletions), + ); + } + let mut additions = 0_i32; + let mut deletions = 0_i32; + for block in &file.blocks { + if block.kind == carbon::BlockKind::Change { + additions = additions.saturating_add(block.new.len.min(i32::MAX as u32) as i32); + deletions = deletions.saturating_add(block.old.len.min(i32::MAX as u32) as i32); + } + } + (additions, deletions) +} + +pub(super) fn u32_to_i32_saturating(value: u32) -> i32 { + i32::try_from(value).unwrap_or(i32::MAX) +} + +impl AppState { + pub(super) fn compare_file_is_large(&self, index: usize) -> bool { + if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { + return false; + } + if self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .is_some_and(|output| compare_output_summary_is_deferred(output, index)) + }) { + return true; + } + + let meta = self.file_list_entry_meta(index); + !meta.is_binary && meta.additions.saturating_add(meta.deletions) >= LARGE_COMPARE_FILE_LINES + } + + pub(super) fn compare_refs(&self) -> (String, String) { + let left_ref = self + .compare + .resolved_left + .get(&self.store) + .unwrap_or_else(|| self.compare.left_ref.get(&self.store)); + let right_ref = self + .compare + .resolved_right + .get(&self.store) + .unwrap_or_else(|| self.compare.right_ref.get(&self.store)); + (left_ref, right_ref) + } +} + +impl AppState { + /// Clear the workspace back to a blank "no compare loaded" state. Replaces + /// the former `WorkspaceState::clear_compare(&mut self)` method. + pub(super) fn workspace_clear_compare(&mut self) { + self.workspace + .source + .set(&self.store, WorkspaceSource::None); + self.workspace.status.set(&self.store, AsyncStatus::Idle); + self.workspace + .status_operation_pending + .set(&self.store, false); + self.workspace.status_generation.set(&self.store, 0); + self.clear_syntax_inflight(); + self.workspace.files.set(&self.store, Vec::new()); + self.workspace + .status_file_changes + .set(&self.store, Vec::new()); + self.workspace.selected_file_index.set(&self.store, None); + self.workspace.selected_file_path.set(&self.store, None); + self.workspace.selected_change_bucket.set(&self.store, None); + self.workspace.compare_output.set(&self.store, None); + self.workspace.compare_total_stats.set(&self.store, None); + self.workspace.compare_hydrated_stats.set(&self.store, None); + self.workspace + .compare_deferred_stats_remaining + .set(&self.store, None); + self.workspace + .compare_deferred_stats_cursor + .set(&self.store, 0); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); + self.workspace.active_file.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + self.clear_file_cache(); + self.workspace.raw_diff_len.set(&self.store, 0); + self.workspace.used_fallback.set(&self.store, false); + self.workspace + .fallback_message + .set(&self.store, String::new()); + self.workspace.sidebar_auto_width.set(&self.store, None); + self.workspace.range_commits.set(&self.store, Vec::new()); + self.workspace + .compare_history_pending + .set(&self.store, None); + self.workspace.pre_drill_compare.set(&self.store, None); + self.workspace.expansions.update(&self.store, |m| m.clear()); + self.clear_file_scroll_layout(); + self.workspace.global_scroll_top_px.set(&self.store, 0); + } + + #[profiling::function] + pub(super) fn handle_compare_finished(&mut self, payload: CompareFinished) -> Vec { + if payload.generation != self.workspace.compare_generation.get(&self.store) { + return Vec::new(); + } + + let history_left = payload.resolved_left.clone(); + let history_right = self + .vcs_ui_profile() + .history_right_ref(&payload.resolved_right); + self.workspace + .status_operation_pending + .set(&self.store, false); + self.workspace + .source + .set(&self.store, WorkspaceSource::Compare); + self.workspace.status.set(&self.store, AsyncStatus::Ready); + self.workspace_mode.set(&self.store, WorkspaceMode::Ready); + self.compare.layout.set(&self.store, payload.request.layout); + self.compare + .renderer + .set(&self.store, payload.request.renderer); + self.compare + .resolved_left + .set(&self.store, Some(payload.resolved_left)); + self.compare + .resolved_right + .set(&self.store, Some(payload.resolved_right)); + self.workspace + .raw_diff_len + .set(&self.store, payload.output.raw_diff_len); + self.workspace + .used_fallback + .set(&self.store, payload.output.used_fallback); + self.workspace + .fallback_message + .set(&self.store, payload.output.fallback_message.clone()); + let total_files = payload.output.file_count() as u32; + let stats_snapshot = compare_output_stats_snapshot(&payload.output); + let has_deferred_stats = stats_snapshot.deferred_count > 0; + let eager_total_stats = (!has_deferred_stats).then_some(stats_snapshot.hydrated_total); + self.workspace + .compare_output + .set(&self.store, Some(payload.output)); + self.workspace.files.set(&self.store, Vec::new()); + self.workspace + .compare_total_stats + .set(&self.store, eager_total_stats); + self.workspace.compare_hydrated_stats.set( + &self.store, + has_deferred_stats.then_some(stats_snapshot.hydrated_total), + ); + self.workspace + .compare_deferred_stats_remaining + .set(&self.store, Some(stats_snapshot.deferred_count)); + self.workspace + .compare_deferred_stats_cursor + .set(&self.store, 0); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); + self.workspace.active_file_loading.set(&self.store, None); + self.workspace.sidebar_auto_width.set(&self.store, None); + self.clear_file_cache(); + self.reset_file_scroll_layout(); + self.workspace.global_scroll_top_px.set(&self.store, 0); + // Record the discovered file count + advance the phase. The progress + // panel stays up until the first file finishes mounting (or, for + // small-file fast paths, is cleared by install_compare_active_file). + self.compare_progress.update(&self.store, |slot| { + if let Some(p) = slot.as_mut() { + let p = Arc::make_mut(p); + p.file_count_total = Some(total_files); + p.phase = ComparePhase::PopulatingList; + } + }); + if self + .workspace + .pre_drill_compare + .with(&self.store, |p| p.is_none()) + { + self.workspace + .range_commits + .set(&self.store, payload.range_commits); + } + self.file_list.scroll_offset_px.set(&self.store, 0.0); + self.file_list + .commits_scroll_offset_px + .set(&self.store, 0.0); + self.editor_clear_document(); + // Clear overlays before claiming focus so the overlay restore target + // does not clobber the file list focus below. + self.clear_overlays(); + self.set_focus(Some(FocusTarget::FileList)); + + let preferred_index = self + .startup + .preferred_file_index + .or(self.workspace.selected_file_index.get(&self.store)); + let preferred_path = self + .startup + .preferred_file_path + .clone() + .or_else(|| self.workspace.selected_file_path.get(&self.store)); + + let file_count = self.workspace_file_count(); + let index_for_path = preferred_path + .as_deref() + .and_then(|path| self.workspace_file_index_for_path(path)); + + let mut effects = Vec::new(); + let mut selected_syntax_paths = Vec::new(); + let should_load_history = self + .workspace + .pre_drill_compare + .with(&self.store, |p| p.is_none()); + let history_effect = should_load_history + .then(|| self.compare_history_request(history_left, history_right)) + .flatten() + .and_then(|request| { + if has_deferred_stats { + self.workspace + .compare_history_pending + .set(&self.store, Some(request)); + None + } else { + Some(self.compare_history_effect(request)) + } + }); + if let Some(index) = index_for_path + .or(preferred_index.filter(|index| *index < file_count)) + .or_else(|| (file_count > 0).then_some(0)) + { + if let Some(path) = self.workspace_file_path_at(index) { + selected_syntax_paths.push(path); + } + effects.extend(self.select_file(index, true)); + if let Some(effect) = self.start_compare_stats_hydration_if_idle() { + effects.push(effect); + } + if let Some(effect) = self.start_compare_total_stats_if_needed() { + effects.push(effect); + } + } else { + self.workspace.selected_file_index.set(&self.store, None); + self.workspace.selected_file_path.set(&self.store, None); + self.workspace.selected_change_bucket.set(&self.store, None); + self.workspace.active_file.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + // No files to select — the compare succeeded but has no diffs. + // Tear down the progress panel; the "repo ready" hint takes over. + self.compare_progress.set(&self.store, None); + self.editor_clear_document(); + } + if let Some(effect) = self.syntax_pack_warmup_effect_for_compare(&selected_syntax_paths) { + effects.insert(0, effect); + } + if let Some(effect) = history_effect { + effects.push(effect); + } + + let (used_fallback, fallback_message) = ( + self.workspace.used_fallback.get(&self.store), + self.workspace.fallback_message.get(&self.store), + ); + if used_fallback && !fallback_message.is_empty() { + self.push_info(&fallback_message); + } + effects + } + + pub(super) fn handle_compare_history_ready( + &mut self, + payload: CompareHistoryReady, + ) -> Vec { + if payload.generation != self.workspace.compare_generation.get(&self.store) { + return Vec::new(); + } + if self + .workspace + .pre_drill_compare + .with(&self.store, |p| p.is_some()) + { + return Vec::new(); + } + self.workspace + .range_commits + .set(&self.store, payload.range_commits); + Vec::new() + } + + #[profiling::function] + pub(super) fn handle_compare_file_finished( + &mut self, + payload: CompareFileFinished, + ) -> Vec { + if payload.generation != self.workspace.compare_generation.get(&self.store) { + return Vec::new(); + } + + let matches_selected = self + .workspace + .selected_file_path + .get(&self.store) + .as_deref() + == Some(payload.path.as_str()); + let matches_loading = self + .workspace + .active_file_loading + .with(&self.store, |loading| { + loading.as_ref().is_some_and(|loading| { + loading.index == payload.index && loading.path == payload.path + }) + }); + let matches_cache_loading = + self.workspace + .file_cache_loading + .with(&self.store, |loading| { + loading + .get(&payload.index) + .is_some_and(|loading| loading.path == payload.path) + }); + if !matches_selected && !matches_cache_loading { + return Vec::new(); + } + + if matches_selected && matches_loading { + self.install_compare_active_file(payload.index, payload.path, payload.prepared); + } else { + let left_ref = self + .compare + .resolved_left + .get(&self.store) + .unwrap_or_else(|| self.compare.left_ref.get(&self.store)); + let right_ref = self + .compare + .resolved_right + .get(&self.store) + .unwrap_or_else(|| self.compare.right_ref.get(&self.store)); + let active_file = self.build_active_file( + payload.index, + payload.path, + payload.prepared, + left_ref, + right_ref, + ); + self.cache_active_file(active_file); + } + let mut effects = self.sync_editor_scroll_from_global(); + if matches_selected { + effects.extend(self.request_active_file_syntax_effect()); + } + if let Some(effect) = self.start_compare_stats_hydration_if_idle() { + effects.push(effect); + } else if let Some(effect) = self.start_compare_total_stats_if_needed() { + effects.push(effect); + } + effects + } + + pub(super) fn handle_compare_stats_ready(&mut self, payload: CompareStatsReady) -> Vec { + if payload.generation != self.workspace.compare_generation.get(&self.store) { + return Vec::new(); + } + + self.workspace + .compare_total_stats + .set(&self.store, Some((payload.additions, payload.deletions))); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + let mut effects = Vec::new(); + if let Some(effect) = self.start_compare_stats_hydration_if_idle() { + let is_background_stats = matches!( + &effect, + Effect::Compare(CompareEffect::LoadFileStats(task)) + if task.request.priority == CompareWorkPriority::Warmup + ); + effects.push(effect); + if is_background_stats && let Some(effect) = self.take_pending_compare_history_effect() + { + effects.push(effect); + } + } else if !self.compare_stats_hydration_running() + && let Some(effect) = self.take_pending_compare_history_effect() + { + effects.push(effect); + } + effects + } + + pub(super) fn handle_compare_file_stats_ready( + &mut self, + payload: CompareFileStatsReady, + ) -> Vec { + if payload.generation != self.workspace.compare_generation.get(&self.store) { + return Vec::new(); + } + + self.apply_compare_file_stats(&payload.stats); + let mut effects = self.sync_editor_scroll_from_global(); + if !payload.request_complete { + return effects; + } + if let Some(effect) = self.next_compare_stats_hydration_effect() { + effects.push(effect); + effects + } else { + self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); + let history_effect = self.take_pending_compare_history_effect(); + if let Some(stats) = self.exact_compare_total_stats_if_ready() { + if !self.workspace.compare_total_stats_loading.get(&self.store) { + self.workspace + .compare_total_stats + .set(&self.store, Some(stats)); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + } + if let Some(effect) = history_effect { + effects.push(effect); + } + return effects; + } + if let Some(effect) = self.start_compare_total_stats_if_needed() { + effects.push(effect); + } + if let Some(effect) = history_effect { + effects.push(effect); + } + effects + } + } + + pub(super) fn compare_stats_hydration_running(&self) -> bool { + self.workspace.compare_stats_hydration.get(&self.store) + == CompareStatsHydrationState::Running + } + + pub(super) fn compare_stats_hydration_failed(&self) -> bool { + self.workspace.compare_stats_hydration.get(&self.store) + == CompareStatsHydrationState::Failed + } + + pub(super) fn set_compare_stats_hydration(&self, state: CompareStatsHydrationState) { + self.workspace + .compare_stats_hydration + .set(&self.store, state); + } + + pub(super) fn start_compare_stats_hydration_if_idle(&mut self) -> Option { + if self.compare_stats_hydration_running() || self.compare_stats_hydration_failed() { + return None; + } + + let effect = self.next_compare_stats_hydration_effect()?; + self.set_compare_stats_hydration(CompareStatsHydrationState::Running); + Some(effect) + } + + pub(super) fn start_visible_compare_stats_hydration(&mut self) -> Option { + if self.compare_stats_hydration_failed() { + return None; + } + let prioritize_visible = self + .workspace + .compare_output + .with(&self.store, |maybe_output| { + maybe_output.as_ref().is_some_and(|output| { + output.file_count() > COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT + }) + }); + if !prioritize_visible { + return self.start_compare_stats_hydration_if_idle(); + } + let visible_files = self.visible_compare_stats_hydration_items(); + if visible_files.is_empty() { + return self.start_compare_stats_hydration_if_idle(); + } + let effect = self.compare_file_stats_hydration_effect( + visible_files, + CompareWorkPriority::VisibleSidebarStats, + )?; + self.set_compare_stats_hydration(CompareStatsHydrationState::Running); + Some(effect) + } + + pub(super) fn start_compare_total_stats_if_needed(&mut self) -> Option { + if self + .workspace + .compare_total_stats + .get(&self.store) + .is_some() + || self.workspace.compare_total_stats_loading.get(&self.store) + { + return None; + } + let repo_path = self.compare.repo_path.get(&self.store)?; + self.workspace + .compare_total_stats_loading + .set(&self.store, true); + + Some( + CompareEffect::LoadStats(Task { + generation: self.workspace.compare_generation.get(&self.store), + request: CompareStatsRequest { + repo_path, + request: vcs_compare_request( + self.compare.mode.get(&self.store), + self.compare.left_ref.get(&self.store), + self.compare.right_ref.get(&self.store), + self.compare.layout.get(&self.store), + self.compare.renderer.get(&self.store), + ), + priority: CompareWorkPriority::TotalStats, + }, + }) + .into(), + ) + } + + pub(super) fn next_compare_stats_hydration_effect(&self) -> Option { + let prioritize_visible = self + .workspace + .compare_output + .with(&self.store, |maybe_output| { + maybe_output.as_ref().is_some_and(|output| { + output.file_count() > COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT + }) + }); + let (files, priority) = if prioritize_visible { + let visible_files = self.visible_compare_stats_hydration_items(); + if !visible_files.is_empty() { + (visible_files, CompareWorkPriority::VisibleSidebarStats) + } else { + ( + self.next_deferred_compare_stats_items(COMPARE_STATS_BACKGROUND_CHUNK_SIZE), + CompareWorkPriority::Warmup, + ) + } + } else { + ( + self.next_deferred_compare_stats_items(COMPARE_STATS_BACKGROUND_CHUNK_SIZE), + CompareWorkPriority::Warmup, + ) + }; + if files.is_empty() { + return None; + } + + self.compare_file_stats_hydration_effect(files, priority) + } + + pub(super) fn compare_file_stats_hydration_effect( + &self, + files: Vec, + priority: CompareWorkPriority, + ) -> Option { + if files.is_empty() { + return None; + } + let repo_path = self.compare.repo_path.get(&self.store)?; + Some( + CompareEffect::LoadFileStats(Task { + generation: self.workspace.compare_generation.get(&self.store), + request: CompareFileStatsRequest { + repo_path, + request: vcs_compare_request( + self.compare.mode.get(&self.store), + self.compare.left_ref.get(&self.store), + self.compare.right_ref.get(&self.store), + self.compare.layout.get(&self.store), + self.compare.renderer.get(&self.store), + ), + files, + priority, + }, + }) + .into(), + ) + } + + pub(super) fn compare_history_request( + &self, + left_ref: String, + right_ref: String, + ) -> Option { + Some(CompareHistoryRequest { + repo_path: self.compare.repo_path.get(&self.store)?, + left_ref, + right_ref, + }) + } + + pub(super) fn compare_history_effect(&self, request: CompareHistoryRequest) -> Effect { + CompareEffect::LoadHistory(Task { + generation: self.workspace.compare_generation.get(&self.store), + request, + }) + .into() + } + + pub(super) fn take_pending_compare_history_effect(&mut self) -> Option { + if self + .workspace + .pre_drill_compare + .with(&self.store, |p| p.is_some()) + { + self.workspace + .compare_history_pending + .set(&self.store, None); + return None; + } + let pending = self.workspace.compare_history_pending.get(&self.store)?; + self.workspace + .compare_history_pending + .set(&self.store, None); + Some(self.compare_history_effect(pending)) + } + + pub(super) fn next_deferred_compare_stats_items( + &self, + limit: usize, + ) -> Vec { + if limit == 0 + || self + .workspace + .compare_deferred_stats_remaining + .get(&self.store) + == Some(0) + { + return Vec::new(); + } + + let cursor = self + .workspace + .compare_deferred_stats_cursor + .get(&self.store); + let (items, next_cursor) = + self.workspace + .compare_output + .with(&self.store, |maybe_output| { + let Some(output) = maybe_output.as_ref() else { + return (Vec::new(), None); + }; + let file_count = output.file_count(); + if file_count == 0 { + return (Vec::new(), None); + } + let mut items = Vec::new(); + let mut index = cursor.min(file_count - 1); + let mut scanned = 0_usize; + while scanned < file_count && items.len() < limit { + if let Some(target) = output.deferred_stats_target_at(index) { + items.push(CompareFileStatsItem { index, target }); + } + index = if index + 1 == file_count { + 0 + } else { + index + 1 + }; + scanned += 1; + } + (items, Some(index)) + }); + if let Some(next_cursor) = next_cursor { + self.workspace + .compare_deferred_stats_cursor + .set(&self.store, next_cursor); + } + items + } + + pub(super) fn visible_compare_stats_hydration_items(&self) -> Vec { + if self.workspace.source.get(&self.store) != WorkspaceSource::Compare + || self.file_list.tab.get(&self.store) != SidebarTab::Files + { + return Vec::new(); + } + + let stride = self.file_list_row_stride(); + if stride <= 0.0 { + return Vec::new(); + } + let scroll_px = self.file_list.scroll_offset_px.get(&self.store); + let viewport_px = self.file_list.viewport_height.get(&self.store); + let first = (scroll_px / stride).floor().max(0.0) as usize; + let visible = (viewport_px / stride).ceil().max(1.0) as usize; + let start = first.saturating_sub(COMPARE_STATS_VISIBLE_OVERSCAN_ROWS); + let end = first + .saturating_add(visible) + .saturating_add(COMPARE_STATS_VISIBLE_OVERSCAN_ROWS); + + let filter = self + .file_list + .filter + .with(&self.store, |filter| filter.clone()); + if !filter.is_empty() { + let filtered_indices = self.workspace_file_filter_matches(&filter); + let end = end.min(filtered_indices.len()); + if start >= end { + return Vec::new(); + } + return self.compare_stats_hydration_items_for_indices( + filtered_indices[start..end].iter().copied(), + ); + } + + if self.file_list.mode.get(&self.store) == SidebarMode::TreeView { + let expanded_folders = self.file_list.expanded_folders.get(&self.store); + let tree_indices = crate::ui::components::file_tree_visible_file_indices_by( + |visit| { + self.for_each_workspace_file_path(|index, path| visit(index, path)); + }, + &expanded_folders, + start..end, + ); + return self.compare_stats_hydration_items_for_indices(tree_indices); + } + + let end = end.min(self.workspace_file_count()); + if start >= end { + return Vec::new(); + } + self.compare_stats_hydration_items_for_indices(start..end) + } + + pub(super) fn compare_stats_hydration_items_for_indices( + &self, + indices: impl IntoIterator, + ) -> Vec { + self.workspace + .compare_output + .with(&self.store, |maybe_output| { + let Some(output) = maybe_output.as_ref() else { + return Vec::new(); + }; + let mut items = Vec::new(); + for index in indices { + if items.len() >= COMPARE_STATS_CHUNK_SIZE { + break; + } + if let Some(target) = output.deferred_stats_target_at(index) { + items.push(CompareFileStatsItem { index, target }); + } + } + items + }) + } + + pub(super) fn exact_compare_total_stats_if_ready(&self) -> Option<(i32, i32)> { + if let Some(remaining) = self + .workspace + .compare_deferred_stats_remaining + .get(&self.store) + { + if remaining > 0 { + return None; + } + if let Some(total) = self.workspace.compare_hydrated_stats.get(&self.store) { + return Some(total); + } + } + + let ready = self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .is_some_and(|output| !compare_output_has_deferred_stats(output)) + }); + if !ready { + return None; + } + self.workspace.compare_output.with(&self.store, |output| { + let output = output.as_ref()?; + let mut total = (0_i32, 0_i32); + output.for_each_summary(|_, summary| { + let stats = summary.fallback_stats(); + total = ( + total.0.saturating_add(stats.0), + total.1.saturating_add(stats.1), + ); + }); + Some(total) + }) + } + + pub(super) fn apply_compare_file_stats(&mut self, stats: &[CompareFileStat]) { + if stats.is_empty() { + return; + } + + let old_scroll_heights = stats + .iter() + .map(|stat| (stat.index, self.file_scroll_height_px(stat.index))) + .collect::>(); + + let mut stats_delta = (0_i32, 0_i32); + let mut newly_hydrated = 0_usize; + self.workspace + .compare_output + .update(&self.store, |maybe_output| { + let Some(output) = maybe_output.as_mut() else { + return; + }; + for stat in stats { + let additions = i32_to_u32_nonnegative(stat.additions); + let deletions = i32_to_u32_nonnegative(stat.deletions); + + if !output.file_summaries.is_empty() { + let Some(summary) = output.file_summaries.get_mut(stat.index) else { + continue; + }; + if summary.path() != stat.path { + continue; + } + let old_stats = summary.fallback_stats(); + let was_deferred = summary.stats_deferred; + summary.additions = additions; + summary.deletions = deletions; + summary.stats_deferred = false; + stats_delta = ( + stats_delta + .0 + .saturating_add(stat.additions.saturating_sub(old_stats.0)), + stats_delta + .1 + .saturating_add(stat.deletions.saturating_sub(old_stats.1)), + ); + newly_hydrated = newly_hydrated.saturating_add(was_deferred as usize); + continue; + } + + if let Some(file) = output.carbon.files.get_mut(stat.index) + && file.path() == stat.path + { + let old_stats = carbon_file_stats(file); + let was_deferred = file.stats_deferred; + file.additions = additions; + file.deletions = deletions; + file.stats_deferred = false; + stats_delta = ( + stats_delta + .0 + .saturating_add(stat.additions.saturating_sub(old_stats.0)), + stats_delta + .1 + .saturating_add(stat.deletions.saturating_sub(old_stats.1)), + ); + newly_hydrated = newly_hydrated.saturating_add(was_deferred as usize); + } + } + }); + + if stats_delta != (0, 0) { + self.workspace + .compare_hydrated_stats + .update(&self.store, |total| { + let current = total.get_or_insert((0, 0)); + *current = ( + current.0.saturating_add(stats_delta.0), + current.1.saturating_add(stats_delta.1), + ); + }); + } + if newly_hydrated > 0 { + self.workspace + .compare_deferred_stats_remaining + .update(&self.store, |remaining| { + if let Some(count) = remaining.as_mut() { + *count = count.saturating_sub(newly_hydrated); + } + }); + } + + let mut rebuilt_viewport_doc = false; + self.workspace.active_file.update(&self.store, |slot| { + let Some(active) = slot.as_mut() else { + return; + }; + for stat in stats { + if apply_compare_stat_to_active_file(active, stat) { + rebuilt_viewport_doc = true; + break; + } + } + }); + self.workspace.file_cache.update(&self.store, |files| { + for active in files.values_mut() { + for stat in stats { + if apply_compare_stat_to_active_file(active, stat) { + rebuilt_viewport_doc = true; + break; + } + } + } + }); + if rebuilt_viewport_doc { + self.viewport_document_cache = None; + } + + let dragging_scrollbar = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| drag.is_some()); + if dragging_scrollbar { + self.workspace + .file_scroll_recompute_pending + .set(&self.store, true); + } else { + self.update_file_scroll_heights(old_scroll_heights); + if self.settings.continuous_scroll { + self.clamp_global_scroll_top_px(); + } + } + } + + pub(super) fn kickoff_compare(&mut self) -> Vec { + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + self.push_error("Open a repository before starting a compare."); + return Vec::new(); + }; + + let mode = self.compare.mode.get(&self.store); + let left_ref = self.compare.left_ref.get(&self.store); + let right_ref = self.compare.right_ref.get(&self.store); + if !compare_refs_are_valid(mode, &left_ref, &right_ref) { + self.push_error("Provide the required refs for the selected mode."); + return Vec::new(); + } + + let active_pr = self.github.pull_request.active.get(&self.store); + let active_pr_still_matches = active_pr.as_ref().is_some_and(|key| { + self.github.pull_request.cache.with(&self.store, |cache| { + matches!( + cache.get(key).map(|entry| &entry.diff), + Some(PrPeekDiff::Ready { + left_ref: pr_left, + right_ref: pr_right, + .. + }) if pr_left == &left_ref && pr_right == &right_ref + ) + }) + }); + if !active_pr_still_matches { + self.github.pull_request.active.set(&self.store, None); + self.github + .pull_request + .review_composer + .set(&self.store, ReviewCommentComposerState::default()); + self.review_comment_editor.request_clear(); + } + + self.workspace + .source + .set(&self.store, WorkspaceSource::Compare); + let next_gen = self + .workspace + .compare_generation + .get(&self.store) + .saturating_add(1); + self.workspace.compare_generation.set(&self.store, next_gen); + let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); + self.workspace.compare_total_stats.set(&self.store, None); + self.workspace.compare_hydrated_stats.set(&self.store, None); + self.workspace + .compare_deferred_stats_remaining + .set(&self.store, None); + self.workspace + .compare_deferred_stats_cursor + .set(&self.store, 0); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); + self.workspace.expansions.update(&self.store, |m| m.clear()); + self.clear_overlays(); + self.sync_settings_snapshot(); + + let started_at_ms = self.clock_ms; + let reveal_at_ms = started_at_ms; + let has_prior_state = self.workspace_file_count() > 0 + || self + .workspace + .active_file + .with(&self.store, |af| af.is_some()); + + if !has_prior_state { + self.workspace_mode.set(&self.store, WorkspaceMode::Loading); + self.workspace.status.set(&self.store, AsyncStatus::Loading); + } + + let profile = self.vcs_ui_profile(); + let left_label = profile.compare_ref_display_label(&left_ref); + let right_label = profile.compare_ref_display_label(&right_ref); + self.compare_progress.set( + &self.store, + Some(Arc::new(CompareProgress { + generation: next_gen, + phase: ComparePhase::OpeningRepo, + subject: LoadingSubject::Compare { + left_label, + right_label, + }, + started_at_ms, + reveal_at_ms, + file_count_total: None, + files_loaded: 0, + })), + ); + + let renderer = self.compare.renderer.get(&self.store); + let layout = self.compare.layout.get(&self.store); + vec![ + syntax_epoch_effect, + SettingsEffect::SaveSettings(self.settings.clone()).into(), + CompareEffect::Run(Task { + generation: next_gen, + request: CompareRequest { + repo_path, + request: vcs_compare_request(mode, left_ref, right_ref, layout, renderer), + github_token: self.github_access_token.clone(), + }, + }) + .into(), + ] + } + + /// Soft-cancel an in-flight compare. Bumps the generation so any + /// result that eventually arrives is dropped by the guard, clears the + /// progress panel, and returns the viewport to the default empty state. + /// We do not attempt to interrupt backend work mid-flight; stale-result + /// guards keep late answers from mutating newer state. + pub(super) fn cancel_compare(&mut self) -> Vec { + if self.compare_progress.with(&self.store, |p| p.is_none()) { + return Vec::new(); + } + let next_gen = self + .workspace + .compare_generation + .get(&self.store) + .saturating_add(1); + self.workspace.compare_generation.set(&self.store, next_gen); + let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); + self.compare_progress.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + // Only revert the workspace mode if kickoff flipped it to Loading + // (i.e. no prior state was preserved). When the user cancels a + // re-compare, the old diff is still mounted and should stay visible. + if self.workspace_mode.get(&self.store) == WorkspaceMode::Loading { + self.workspace_mode.set(&self.store, WorkspaceMode::Empty); + self.workspace.status.set(&self.store, AsyncStatus::Idle); + } + vec![syntax_epoch_effect] + } + + pub(super) fn handle_compare_progress_update(&mut self, generation: u64, phase: ComparePhase) { + // Only apply when the progress slot matches the reporter's + // generation — stale workers silently lose their updates. + self.compare_progress.update(&self.store, |slot| { + if let Some(p) = slot.as_mut() + && p.generation == generation + { + let p = Arc::make_mut(p); + // Pull counts out of LoadingFiles so the determinate bar + // reads directly from durable struct fields (cheaper than + // pattern-matching in the render path, and lets the total + // survive the phase transition to PopulatingList). + if let ComparePhase::LoadingFiles { + files_seen, + files_total, + } = phase + { + p.files_loaded = files_seen; + if files_total > 0 { + p.file_count_total = Some(files_total); + } + } + p.phase = phase; + } + }); + } + + pub(super) fn swap_refs(&mut self) -> Vec { + let left = self.compare.left_ref.get(&self.store); + let right = self.compare.right_ref.get(&self.store); + let profile = self.vcs_ui_profile(); + if left.trim().is_empty() + || right.trim().is_empty() + || !profile.can_swap_ref(&right) + || !profile.can_swap_ref(&left) + { + return Vec::new(); + } + let resolved_left = self.compare.resolved_left.get(&self.store); + let resolved_right = self.compare.resolved_right.get(&self.store); + self.compare.left_ref.set(&self.store, right); + self.compare.right_ref.set(&self.store, left); + self.compare.resolved_left.set(&self.store, resolved_right); + self.compare.resolved_right.set(&self.store, resolved_left); + self.workspace.pre_drill_compare.set(&self.store, None); + let mut effects = self.persist_settings_effect(); + let has_repo = self.compare.repo_path.with(&self.store, |p| p.is_some()); + let not_loading = self.workspace.status.get(&self.store) != AsyncStatus::Loading; + let refs_valid = compare_refs_are_valid( + self.compare.mode.get(&self.store), + &self.compare.left_ref.get(&self.store), + &self.compare.right_ref.get(&self.store), + ); + if has_repo && not_loading && refs_valid { + effects.extend(self.kickoff_compare()); + } + effects + } + + pub(super) fn update_compare_field( + &mut self, + field: CompareField, + value: String, + ) -> Vec { + self.workspace.pre_drill_compare.set(&self.store, None); + match field { + CompareField::Left => { + self.compare.left_ref.set(&self.store, value); + self.compare.resolved_left.set(&self.store, None); + } + CompareField::Right => { + self.compare.right_ref.set(&self.store, value); + self.compare.resolved_right.set(&self.store, None); + } + } + self.auto_select_compare_mode(); + let active_field = self.overlays.ref_picker.active_field.get(&self.store); + let mut effects = if matches!(self.overlays_top(), Some(OverlaySurface::RefPicker)) + && active_field == field + { + self.rebuild_ref_picker(field) + } else { + Vec::new() + }; + effects.extend(self.rebuild_command_palette()); + effects + } + + pub(super) fn auto_select_compare_mode(&mut self) { + let profile = self.vcs_ui_profile(); + if !profile.should_auto_select_trunk_mode() { + return; + } + let left = self.compare.left_ref.get(&self.store); + let right = self.compare.right_ref.get(&self.store); + if left.is_empty() || right.is_empty() { + return; + } + if left == right && !profile.is_working_copy_ref(&right) { + self.compare + .mode + .set(&self.store, CompareMode::SingleCommit); + return; + } + let is_trunk = |r: &str| matches!(r, "main" | "master" | "develop" | "development"); + if is_trunk(&left) != is_trunk(&right) { + self.compare.mode.set(&self.store, CompareMode::ThreeDot); + } + } +} diff --git a/src/ui/state/editor.rs b/src/ui/state/editor.rs index b2d47ea5..7d8e13c8 100644 --- a/src/ui/state/editor.rs +++ b/src/ui/state/editor.rs @@ -230,3 +230,839 @@ impl AppState { } } } + +impl AppState { + pub(super) fn expand_context( + &mut self, + hunk_index: usize, + direction: crate::editor::diff::expansion::ExpandDirection, + amount: u32, + ) -> Vec { + use crate::editor::diff::expansion::ExpandDirection; + use crate::events::ContextDirection; + + if amount == 0 { + return Vec::new(); + } + + let ctx_direction = match direction { + ExpandDirection::Above => ContextDirection::Above, + ExpandDirection::Below => ContextDirection::Below, + }; + self.dispatch_context_expansion(hunk_index, ctx_direction, amount) + } + + pub(super) fn expand_all_context(&mut self) -> Vec { + use crate::events::ContextDirection; + self.dispatch_context_expansion(0, ContextDirection::All, 0) + } + + pub(super) fn dispatch_context_expansion( + &mut self, + hunk_index: usize, + direction: crate::events::ContextDirection, + amount: u32, + ) -> Vec { + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + + let generation = self.workspace.compare_generation.get(&self.store); + let Some(( + file_index, + path, + old_reference, + new_reference, + cached_old_lines, + cached_new_lines, + )) = self.workspace.active_file.with(&self.store, |af| { + let active = af.as_ref()?; + if active.carbon_file.hunks.is_empty() { + return None; + } + Some(( + active.index, + active.path.clone(), + active.left_ref.clone(), + if active.right_ref.is_empty() { + active.left_ref.clone() + } else { + active.right_ref.clone() + }, + active.old_file_lines.clone(), + active.file_lines.clone(), + )) + }) + else { + return Vec::new(); + }; + + if let (Some(old_lines), Some(new_lines)) = (cached_old_lines, cached_new_lines) { + self.apply_context_expansion(direction, hunk_index, amount, old_lines, new_lines); + let mut effects = vec![self.invalidate_syntax_epoch_effect()]; + effects.extend(self.request_active_file_syntax_effect()); + return effects; + } + + vec![ + RepositoryEffect::FetchContextLines(crate::effects::FetchContextLinesRequest { + repo_path, + old_reference, + new_reference, + path, + generation, + file_index, + hunk_index, + direction, + amount, + }) + .into(), + ] + } + + pub(super) fn handle_context_lines_ready( + &mut self, + payload: crate::events::ContextLinesReady, + ) -> Vec { + if payload.generation != self.workspace.compare_generation.get(&self.store) { + return Vec::new(); + } + + let matches_active = self.workspace.active_file.with(&self.store, |af| { + af.as_ref() + .is_some_and(|a| a.index == payload.file_index && a.path == payload.path) + }); + if !matches_active { + return Vec::new(); + } + + let old_lines = Arc::new(payload.old_lines); + let new_lines = Arc::new(payload.new_lines); + self.apply_context_expansion( + payload.direction, + payload.hunk_index, + payload.amount, + old_lines, + new_lines, + ); + let mut effects = vec![self.invalidate_syntax_epoch_effect()]; + effects.extend(self.request_active_file_syntax_effect()); + effects + } + + pub(super) fn apply_context_expansion( + &mut self, + direction: crate::events::ContextDirection, + hunk_index: usize, + amount: u32, + old_lines: Arc>, + new_lines: Arc>, + ) { + use crate::events::ContextDirection; + + let Some(( + active_index, + active_path, + mut carbon_file, + mut expansion, + mut carbon_overlays, + mut token_buffer, + )) = self.workspace.active_file.with(&self.store, |af| { + af.as_ref().map(|a| { + ( + a.index, + a.path.clone(), + (*a.carbon_file).clone(), + a.carbon_expansion.clone(), + a.carbon_overlays.clone(), + a.token_buffer.clone(), + ) + }) + }) + else { + return; + }; + + hydrate_carbon_full_text(&mut carbon_file, &old_lines, &new_lines); + match direction { + ContextDirection::Above => { + carbon::expand_context( + &carbon_file, + &mut expansion, + carbon::HunkId(hunk_index as u32), + carbon::ExpansionDirection::Above, + amount, + ); + } + ContextDirection::Below => { + carbon::expand_context( + &carbon_file, + &mut expansion, + carbon::HunkId(hunk_index as u32), + carbon::ExpansionDirection::Below, + amount, + ); + } + ContextDirection::All => { + let hunk_ids = carbon_file + .hunks + .iter() + .map(|hunk| hunk.id) + .collect::>(); + for hunk_id in hunk_ids { + let caps = carbon::expansion_caps(&carbon_file, hunk_id); + carbon::expand_context( + &carbon_file, + &mut expansion, + hunk_id, + carbon::ExpansionDirection::Above, + caps.above, + ); + carbon::expand_context( + &carbon_file, + &mut expansion, + hunk_id, + carbon::ExpansionDirection::Below, + caps.below, + ); + } + } + } + self.workspace.expansions.update(&self.store, |map| { + map.insert(active_path.clone(), expansion.clone()); + }); + + let preserve_change_tokens = carbon_overlays.has_change_tokens(); + carbon_overlays.clear_syntax(); + if !preserve_change_tokens { + token_buffer.clear(); + } + let render_doc = build_render_doc_from_carbon( + &carbon_file, + active_index, + &expansion, + &carbon_overlays, + &token_buffer, + ); + let total_lines = new_lines.len() as u32; + + let preserved_scroll = self.editor.scroll_top_px.get(&self.store); + + self.workspace.active_file.update(&self.store, |af| { + if let Some(active) = af.as_mut() { + active.carbon_file = Arc::new(carbon_file); + active.carbon_expansion = expansion; + active.carbon_overlays = carbon_overlays; + active.token_buffer = token_buffer; + active.render_doc = Arc::new(render_doc); + active.file_line_count = Some(total_lines); + active.old_file_lines = Some(old_lines); + active.file_lines = Some(new_lines); + active.syntax_pending.clear(); + active.syntax_covered.clear(); + } + }); + self.editor_clear_document(); + self.editor.scroll_top_px.set(&self.store, preserved_scroll); + } + + pub(super) fn current_hunk_index_from_hover(&self) -> Option { + self.editor + .hovered_hunk_index + .get(&self.store) + .or_else(|| self.editor_current_hunk_index().map(|(idx, _)| idx as i16)) + } + + pub(super) fn current_render_line_index_from_hover(&self) -> Option { + self.editor + .hovered_render_line_index + .get(&self.store) + .or_else(|| self.editor.hovered_row.get(&self.store)) + } + + pub(super) fn apply_hunk_operation( + &mut self, + operation: FileOperation, + explicit_hunk: Option, + ) -> Vec { + tracing::debug!( + ?operation, + ?explicit_hunk, + source = ?self.workspace.source.get(&self.store), + pending = self.workspace.status_operation_pending.get(&self.store), + hovered_row = ?self.editor.hovered_row.get(&self.store), + hovered_hunk_index = ?self.editor.hovered_hunk_index.get(&self.store), + "apply_hunk_operation: entered" + ); + if self.workspace.source.get(&self.store) != WorkspaceSource::Status { + tracing::debug!("apply_hunk_operation: bail: source != Status"); + return Vec::new(); + } + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.partial_hunk_mutation) + }) + { + self.push_error("This repository backend does not support hunk operations."); + return Vec::new(); + } + if self.workspace.status_operation_pending.get(&self.store) { + tracing::debug!("apply_hunk_operation: bail: status_operation_pending=true"); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + tracing::debug!("apply_hunk_operation: bail: no repo_path"); + return Vec::new(); + }; + let Some(bucket) = self.workspace.selected_change_bucket.get(&self.store) else { + tracing::debug!("apply_hunk_operation: bail: no selected_change_bucket"); + return Vec::new(); + }; + let resolved = explicit_hunk.or_else(|| self.current_hunk_index_from_hover()); + let hunk_index = match resolved { + Some(idx) if idx >= 0 => idx as usize, + _ => { + tracing::debug!(?resolved, "apply_hunk_operation: bail: no hunk_index"); + return Vec::new(); + } + }; + + let patch_text = self.workspace.active_file.with(&self.store, |af| { + let active = af.as_ref()?; + patch::format_carbon_hunk_patch( + &active.carbon_file, + hunk_index, + operation != FileOperation::Stage, + ) + }); + let Some(patch) = patch_text else { + tracing::debug!( + hunk_index, + "apply_hunk_operation: bail: format_hunk_patch returned None" + ); + return Vec::new(); + }; + + tracing::debug!( + ?operation, + hunk_index, + "apply_hunk_operation: dispatching ApplyPatchOperation" + ); + self.workspace + .status_operation_pending + .set(&self.store, true); + vec![ + RepositoryEffect::ApplyPatchOperation(PatchOperationRequest { + repo_path, + patch, + bucket, + operation, + }) + .into(), + ] + } + + pub(super) fn toggle_line_selection(&mut self, row: usize, _extend: bool) { + let line_opt = self.workspace.active_file.with(&self.store, |af| { + af.as_ref() + .and_then(|active| active.render_doc.lines.get(row).copied()) + }); + let Some(line) = line_opt else { + return; + }; + let kind = line.row_kind(); + if !matches!( + kind, + crate::editor::diff::render_doc::RenderRowKind::Added + | crate::editor::diff::render_doc::RenderRowKind::Removed + | crate::editor::diff::render_doc::RenderRowKind::Modified + ) { + return; + } + if line.hunk_index < 0 { + return; + } + let hunk_id = line.hunk_index as u32; + self.editor.line_selection.update(&self.store, |ls| { + if line.old_line_index >= 0 { + ls.toggle(hunk_id, carbon::DiffSide::Old, line.old_line_index as u32); + } + if line.new_line_index >= 0 { + ls.toggle(hunk_id, carbon::DiffSide::New, line.new_line_index as u32); + } + ls.last_toggled_row = Some(row); + }); + } + + pub(super) fn toggle_line_selection_range(&mut self, row: usize, anchor: usize) { + self.insert_line_selection_range(row, anchor, false); + } + + pub(super) fn set_line_selection_range(&mut self, row: usize, anchor: usize) { + self.insert_line_selection_range(row, anchor, true); + } + + pub(super) fn insert_line_selection_range( + &mut self, + row: usize, + anchor: usize, + clear_first: bool, + ) { + let (start, end) = if row <= anchor { + (row, anchor) + } else { + (anchor, row) + }; + let lines = self.workspace.active_file.with(&self.store, |af| { + let Some(active) = af.as_ref() else { + return Vec::new(); + }; + (start..=end) + .filter_map(|r| active.render_doc.lines.get(r).copied()) + .collect::>() + }); + if lines.is_empty() { + return; + } + // Staging only selects changed lines; in PR review mode a comment can anchor + // to any line (incl. context), like GitHub. + let review = self.pull_request_review_enabled(); + self.editor.line_selection.update(&self.store, |ls| { + if clear_first { + ls.clear(); + } + for line in &lines { + use crate::editor::diff::render_doc::RenderRowKind; + let kind = line.row_kind(); + if !kind.is_body() || line.hunk_index < 0 { + continue; + } + if !review + && !matches!( + kind, + RenderRowKind::Added | RenderRowKind::Removed | RenderRowKind::Modified + ) + { + continue; + } + let hunk_id = line.hunk_index as u32; + if line.old_line_index >= 0 { + ls.entries + .insert(crate::editor::diff::state::LineSelectionKey { + file_path: None, + hunk_id, + side: carbon::DiffSide::Old, + source_index: line.old_line_index as u32, + }); + } + if line.new_line_index >= 0 { + ls.entries + .insert(crate::editor::diff::state::LineSelectionKey { + file_path: None, + hunk_id, + side: carbon::DiffSide::New, + source_index: line.new_line_index as u32, + }); + } + } + ls.last_toggled_row = Some(row); + }); + } + + pub(super) fn toggle_current_line_selection(&mut self) { + let Some(row) = self.current_render_line_index_from_hover() else { + self.push_error("Move the row cursor to a changed line before selecting lines."); + return; + }; + self.toggle_line_selection(row, false); + } + + pub(super) fn toggle_current_line_selection_range(&mut self) { + let Some(row) = self.current_render_line_index_from_hover() else { + self.push_error("Move the row cursor to a changed line before selecting lines."); + return; + }; + let anchor = self + .editor + .line_selection + .with(&self.store, |ls| ls.last_toggled_row); + if let Some(anchor) = anchor { + self.toggle_line_selection_range(row, anchor); + } else { + self.toggle_line_selection(row, false); + } + } + + pub(super) fn apply_line_selection_operation( + &mut self, + operation: FileOperation, + ) -> Vec { + if self.workspace.source.get(&self.store) != WorkspaceSource::Status { + return Vec::new(); + } + if self.workspace.status_operation_pending.get(&self.store) { + return Vec::new(); + } + if self + .editor + .line_selection + .with(&self.store, |ls| ls.is_empty()) + { + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + let Some(bucket) = self.workspace.selected_change_bucket.get(&self.store) else { + return Vec::new(); + }; + let reverse = operation != FileOperation::Stage; + + let (hunk_indices, selection_snapshot) = + self.editor.line_selection.with(&self.store, |ls| { + let indices: Vec = ls + .entries + .iter() + .map(|key| key.hunk_id) + .collect::>() + .into_iter() + .collect(); + (indices, ls.clone()) + }); + + let patches = self.workspace.active_file.with(&self.store, |af| { + let Some(active) = af.as_ref() else { + return Vec::new(); + }; + let mut patches = Vec::new(); + for hunk_idx in hunk_indices { + let selected = selection_snapshot + .selected_lines_for_hunk(hunk_idx) + .into_iter() + .map(|key| patch::CarbonLineSelection { + side: key.side, + source_index: key.source_index, + }) + .collect::>(); + let patch = patch::format_carbon_lines_patch( + &active.carbon_file, + carbon::u32_to_usize_saturating(hunk_idx), + &selected, + reverse, + ); + if let Some(p) = patch { + patches.push(p); + } + } + patches + }); + + self.editor + .line_selection + .update(&self.store, |ls| ls.clear()); + + if patches.is_empty() { + return Vec::new(); + } + + self.workspace + .status_operation_pending + .set(&self.store, true); + patches + .into_iter() + .map(|p| { + RepositoryEffect::ApplyPatchOperation(PatchOperationRequest { + repo_path: repo_path.clone(), + patch: p, + bucket, + operation, + }) + .into() + }) + .collect() + } + + pub(super) fn navigate_to_hunk(&mut self, forward: bool) { + let current = self.editor.scroll_top_px.get(&self.store); + let target = self.editor.hunk_positions.with(&self.store, |positions| { + if positions.is_empty() { + return None; + } + if forward { + positions + .iter() + .find(|&&y| y > current) + .or_else(|| positions.first()) + .copied() + } else { + positions + .iter() + .rev() + .find(|&&y| y < current) + .or_else(|| positions.last()) + .copied() + } + }); + if let Some(y) = target { + self.editor.scroll_top_px.set(&self.store, y); + self.editor_clamp_scroll(); + } + } + + pub(super) fn navigate_to_file(&mut self, forward: bool) -> Vec { + let Some(current) = self.reconcile_selected_file_index_from_path() else { + return Vec::new(); + }; + let count = self.workspace_file_count(); + if count == 0 { + return Vec::new(); + } + let target = if forward { + current.saturating_add(1).min(count.saturating_sub(1)) + } else { + current.saturating_sub(1) + }; + if target == current { + return Vec::new(); + } + + if self.settings.continuous_scroll { + return self.select_file(target, true); + } + + self.select_file(target, true) + } + + pub(super) fn open_search(&mut self) { + self.editor.search.open.set(&self.store, true); + let len = self.editor.search.query.with(&self.store, |q| q.len()); + self.text_edit.cursor.set(&self.store, len); + self.text_edit.anchor.set(&self.store, 0); + self.text_edit + .cursor_moved_at_ms + .set(&self.store, self.clock_ms); + self.focus.set(&self.store, Some(FocusTarget::SearchInput)); + self.editor.focused.set(&self.store, false); + self.recompute_search_matches(); + } + + pub(super) fn close_search(&mut self) { + self.editor.search.open.set(&self.store, false); + self.editor.search.matches.set(&self.store, Arc::default()); + self.editor.search.active_index.set(&self.store, None); + self.set_focus(Some(FocusTarget::Editor)); + } + + pub(super) fn recompute_search_matches(&mut self) { + use crate::editor::diff::state::MatchSide; + + self.editor.search.matches.set(&self.store, Arc::default()); + self.editor.search.active_index.set(&self.store, None); + + let query = self + .editor + .search + .query + .with(&self.store, |q| q.to_ascii_lowercase()); + if query.is_empty() { + return; + } + + let new_matches: Vec = self.workspace.active_file.with(&self.store, |af| { + let Some(active_file) = af.as_ref() else { + return Vec::new(); + }; + let doc = &active_file.render_doc; + let mut new_matches: Vec = Vec::new(); + for (line_idx, line) in doc.lines.iter().enumerate() { + let line_idx = line_idx as u32; + if line.left_text.is_valid() { + let text = doc.line_text(line.left_text); + let lower = text.to_ascii_lowercase(); + let mut start = 0; + while let Some(pos) = lower[start..].find(&query) { + let byte_start = (start + pos) as u32; + new_matches.push(SearchMatch { + line_index: line_idx, + byte_start, + byte_len: query.len() as u32, + side: MatchSide::Left, + }); + start += pos + query.len(); + } + } + if line.right_text.is_valid() { + let text = doc.line_text(line.right_text); + let lower = text.to_ascii_lowercase(); + let mut start = 0; + while let Some(pos) = lower[start..].find(&query) { + let byte_start = (start + pos) as u32; + new_matches.push(SearchMatch { + line_index: line_idx, + byte_start, + byte_len: query.len() as u32, + side: MatchSide::Right, + }); + start += pos + query.len(); + } + } + } + new_matches + }); + + let has_matches = !new_matches.is_empty(); + self.editor + .search + .matches + .set(&self.store, Arc::new(new_matches)); + if has_matches { + self.editor.search.active_index.set(&self.store, Some(0)); + } + } + + pub(super) fn search_navigate(&mut self, direction: i32) { + let count = self.editor.search.matches.with(&self.store, |m| m.len()); + if count == 0 { + return; + } + + let current = self + .editor + .search + .active_index + .get(&self.store) + .unwrap_or(0); + let next = if direction > 0 { + if current + 1 >= count { 0 } else { current + 1 } + } else { + if current == 0 { count - 1 } else { current - 1 } + }; + self.editor.search.active_index.set(&self.store, Some(next)); + self.scroll_to_search_match(next); + } + + pub(super) fn scroll_to_search_match(&mut self, match_index: usize) { + let y_pos = self + .editor + .search_match_y_positions + .with(&self.store, |v| v.get(match_index).copied()); + let target_y = if let Some(y) = y_pos { + y + } else { + let m = self + .editor + .search + .matches + .with(&self.store, |m| m.get(match_index).copied()); + let Some(m) = m else { + return; + }; + self.estimate_line_y(m.line_index) + }; + + let viewport_h = self.editor.viewport_height_px.get(&self.store); + let centered = target_y.saturating_sub(viewport_h / 3); + let max = self.editor_max_scroll_top_px(); + self.editor + .scroll_top_px + .set(&self.store, centered.min(max)); + } + + pub(super) fn estimate_line_y(&self, line_index: u32) -> u32 { + let content_height = self.editor.content_height_px.get(&self.store); + if content_height == 0 { + return 0; + } + let total_lines = self.workspace.active_file.with(&self.store, |af| { + af.as_ref() + .map(|active_file| active_file.render_doc.lines.len() as u32) + .unwrap_or(0) + }); + if total_lines == 0 { + return 0; + } + let avg_height = content_height / total_lines; + line_index.saturating_mul(avg_height) + } + + /// Clear document-specific editor state (scroll, content, hunks, etc.) + pub fn editor_clear_document(&mut self) { + self.editor.doc_generation.set(&self.store, 0); + self.editor.scroll_top_px.set(&self.store, 0); + self.editor.content_height_px.set(&self.store, 0); + self.editor.hovered_row.set(&self.store, None); + self.editor.hovered_render_line_index.set(&self.store, None); + self.editor.hovered_hunk_index.set(&self.store, None); + self.editor.visible_row_start.set(&self.store, None); + self.editor.visible_row_end.set(&self.store, None); + self.editor.hunk_positions.set(&self.store, Arc::default()); + self.editor.file_positions.set(&self.store, Arc::default()); + self.editor + .search_match_y_positions + .set(&self.store, Arc::default()); + self.editor + .line_selection + .update(&self.store, |ls| ls.clear()); + self.editor.text_selection.set(&self.store, None); + self.context_menu.close(); + } + + pub fn editor_max_scroll_top_px(&self) -> u32 { + let content = self.editor.content_height_px.get(&self.store); + let viewport = self.editor.viewport_height_px.get(&self.store); + content.saturating_sub(viewport.max(1)) + } + + pub fn editor_clamp_scroll(&mut self) { + let max = self.editor_max_scroll_top_px(); + let cur = self.editor.scroll_top_px.get(&self.store); + self.editor.scroll_top_px.set(&self.store, cur.min(max)); + } + + pub fn editor_current_hunk_index(&self) -> Option<(usize, usize)> { + let scroll = self.editor.scroll_top_px.get(&self.store); + self.editor.hunk_positions.with(&self.store, |positions| { + if positions.is_empty() { + return None; + } + let idx = positions + .partition_point(|&y| y <= scroll) + .saturating_sub(1); + Some((idx, positions.len())) + }) + } + + pub(super) fn move_editor_row_cursor(&mut self, delta: i32) { + let Some(start) = self.editor.visible_row_start.get(&self.store) else { + return; + }; + let Some(end) = self.editor.visible_row_end.get(&self.store) else { + return; + }; + if start >= end { + return; + } + let max = end.saturating_sub(1); + let Some(current) = self + .editor + .hovered_row + .get(&self.store) + .filter(|row| *row >= start && *row <= max) + else { + self.editor + .hovered_row + .set(&self.store, Some(if delta < 0 { max } else { start })); + return; + }; + let next = if delta < 0 { + current + .saturating_sub(delta.unsigned_abs() as usize) + .max(start) + } else { + current.saturating_add(delta as usize).min(max) + }; + self.editor.hovered_row.set(&self.store, Some(next)); + } +} diff --git a/src/ui/state/file_list.rs b/src/ui/state/file_list.rs index 49c63978..163c5979 100644 --- a/src/ui/state/file_list.rs +++ b/src/ui/state/file_list.rs @@ -178,3 +178,860 @@ fn insert_folder_prefixes(path: &str, set: &mut HashSet) { set.insert(path[..index].to_owned()); } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileListEntry { + pub path: ComparePath, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum FileListStatus { + #[default] + None, + Added, + Deleted, + Modified, + Renamed, + Copied, + Untracked, + Conflicted, + TypeChanged, + Binary, +} + +impl FileListStatus { + pub fn label(self) -> &'static str { + match self { + Self::None => "", + Self::Added => "A", + Self::Deleted => "D", + Self::Modified => "M", + Self::Renamed => "R", + Self::Copied => "C", + Self::Untracked => "U", + Self::Conflicted => "!", + Self::TypeChanged => "T", + Self::Binary => "B", + } + } + + pub fn is_empty(self) -> bool { + matches!(self, Self::None) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FileListEntryMeta { + pub status: FileListStatus, + pub additions: i32, + pub deletions: i32, + pub is_binary: bool, +} + +pub(super) fn file_change_list_status( + status: FileChangeStatus, + bucket: ChangeBucket, +) -> FileListStatus { + match (status, bucket) { + (FileChangeStatus::Added, _) => FileListStatus::Added, + (FileChangeStatus::Deleted, _) => FileListStatus::Deleted, + (FileChangeStatus::Renamed, _) => FileListStatus::Renamed, + (FileChangeStatus::Copied, _) => FileListStatus::Copied, + (FileChangeStatus::Untracked, _) => FileListStatus::Untracked, + (FileChangeStatus::Conflicted, _) | (_, ChangeBucket::Conflicted) => { + FileListStatus::Conflicted + } + (FileChangeStatus::TypeChanged, _) => FileListStatus::TypeChanged, + (FileChangeStatus::Binary, _) => FileListStatus::Binary, + (FileChangeStatus::Modified, _) => FileListStatus::Modified, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SidebarMode { + #[default] + FlatList, + TreeView, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SidebarTab { + #[default] + Files, + Commits, +} + +#[derive(Debug, Clone, PartialEq, Store)] +pub struct FileListState { + pub scroll_offset_px: f32, + pub commits_scroll_offset_px: f32, + pub hovered_index: Option, + pub row_height: f32, + pub gap: f32, + pub viewport_height: f32, + pub filter: String, + pub mode: SidebarMode, + pub tab: SidebarTab, + pub expanded_folders: HashSet, + pub viewed_files: HashSet, +} + +impl Default for FileListState { + fn default() -> Self { + Self { + scroll_offset_px: 0.0, + commits_scroll_offset_px: 0.0, + hovered_index: None, + row_height: 36.0, + gap: 4.0, + viewport_height: 0.0, + filter: String::new(), + mode: SidebarMode::FlatList, + tab: SidebarTab::Files, + expanded_folders: HashSet::new(), + viewed_files: HashSet::new(), + } + } +} + +pub(super) fn carbon_list_status(status: carbon::FileStatus) -> FileListStatus { + match status { + carbon::FileStatus::Added => FileListStatus::Added, + carbon::FileStatus::Deleted => FileListStatus::Deleted, + carbon::FileStatus::Renamed | carbon::FileStatus::RenamedModified => { + FileListStatus::Renamed + } + carbon::FileStatus::Binary => FileListStatus::Binary, + carbon::FileStatus::ModeChanged | carbon::FileStatus::Modified => FileListStatus::Modified, + } +} + +pub(super) fn build_status_file_entries(changes: &[FileChange]) -> Vec { + changes.iter().map(FileListEntry::from).collect() +} + +pub(super) fn status_section_count(changes: &[FileChange]) -> usize { + let mut last_bucket = None; + let mut count = 0; + for change in changes { + if Some(change.bucket) != last_bucket { + count += 1; + last_bucket = Some(change.bucket); + } + } + count +} + +pub(super) fn status_section_count_before(changes: &[FileChange], len: usize) -> usize { + status_section_count(&changes[..len.min(changes.len())]) +} + +impl From<&FileChange> for FileListEntry { + fn from(value: &FileChange) -> Self { + Self { + path: ComparePath::from(value.path.as_str()), + } + } +} + +pub(super) fn status_file_entry_meta(change: &FileChange) -> FileListEntryMeta { + FileListEntryMeta { + status: file_change_list_status(change.status, change.bucket), + additions: 0, + deletions: 0, + is_binary: matches!(change.status, FileChangeStatus::Binary), + } +} + +impl AppState { + pub fn workspace_file_entry_at(&self, index: usize) -> Option { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + if let Some(entry) = self.workspace.compare_output.with(&self.store, |output| { + output.as_ref().and_then(|output| { + output + .summary_at(index) + .map(|summary| compare_summary_file_entry(&summary)) + }) + }) { + return Some(entry); + } + self.workspace + .files + .with(&self.store, |files| files.get(index).cloned()) + } + WorkspaceSource::Status => self + .workspace + .status_file_changes + .with(&self.store, |changes| { + changes.get(index).map(FileListEntry::from) + }) + .or_else(|| { + self.workspace + .files + .with(&self.store, |files| files.get(index).cloned()) + }), + WorkspaceSource::None => self + .workspace + .files + .with(&self.store, |files| files.get(index).cloned()), + } + } + + pub fn for_each_workspace_file_path(&self, mut visit: impl FnMut(usize, &str)) { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + let visited = self.workspace.compare_output.with(&self.store, |output| { + let Some(output) = output.as_ref() else { + return false; + }; + output.for_each_path(|index, path| visit(index, path)); + true + }); + if !visited { + self.workspace.files.with(&self.store, |files| { + for (index, file) in files.iter().enumerate() { + let path = file.path.path(); + visit(index, path.as_ref()); + } + }); + } + } + WorkspaceSource::Status => { + self.workspace + .status_file_changes + .with(&self.store, |changes| { + for (index, change) in changes.iter().enumerate() { + visit(index, &change.path); + } + }); + } + WorkspaceSource::None => { + self.workspace.files.with(&self.store, |files| { + for (index, file) in files.iter().enumerate() { + let path = file.path.path(); + visit(index, path.as_ref()); + } + }); + } + } + } + + pub fn workspace_max_file_path_chars(&self) -> usize { + if matches!( + self.workspace.source.get(&self.store), + WorkspaceSource::Compare | WorkspaceSource::TextCompare + ) { + let chars = self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .map(CompareOutput::max_path_chars) + .unwrap_or(0) + }); + if chars > 0 { + return chars; + } + } + let mut max_chars = 0; + self.for_each_workspace_file_path(|_, path| { + max_chars = max_chars.max(path.chars().count()); + }); + max_chars + } + + pub fn workspace_file_filter_matches(&self, filter: &str) -> Vec { + let config = neo_frizbee::Config { + max_typos: Some(2), + sort: false, + ..Default::default() + }; + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + let matches = self.workspace.compare_output.with(&self.store, |output| { + let Some(output) = output.as_ref() else { + return None; + }; + let mut matcher = neo_frizbee::Matcher::new(filter, &config); + let mut matches = Vec::new(); + output.for_each_path(|index, path| { + if let Ok(offset) = u32::try_from(index) { + matcher.match_list_into( + std::slice::from_ref(&path), + offset, + &mut matches, + ); + } + }); + matches.sort_by(|a, b| b.score.cmp(&a.score)); + Some(matches.iter().map(|m| m.index as usize).collect()) + }); + if let Some(matches) = matches { + matches + } else { + self.workspace.files.with(&self.store, |files| { + let mut matcher = neo_frizbee::Matcher::new(filter, &config); + let mut matches = Vec::new(); + for (index, file) in files.iter().enumerate() { + if let Ok(offset) = u32::try_from(index) { + let path = file.path.path(); + let path_ref = path.as_ref(); + matcher.match_list_into( + std::slice::from_ref(&path_ref), + offset, + &mut matches, + ); + } + } + matches.sort_by(|a, b| b.score.cmp(&a.score)); + matches.iter().map(|m| m.index as usize).collect() + }) + } + } + WorkspaceSource::Status => { + self.workspace + .status_file_changes + .with(&self.store, |changes| { + let haystack = changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + let mut matches = neo_frizbee::match_list(filter, &haystack, &config); + matches.sort_by(|a, b| b.score.cmp(&a.score)); + matches.iter().map(|m| m.index as usize).collect() + }) + } + WorkspaceSource::None => self.workspace.files.with(&self.store, |files| { + let mut matcher = neo_frizbee::Matcher::new(filter, &config); + let mut matches = Vec::new(); + for (index, file) in files.iter().enumerate() { + if let Ok(offset) = u32::try_from(index) { + let path = file.path.path(); + let path_ref = path.as_ref(); + matcher.match_list_into( + std::slice::from_ref(&path_ref), + offset, + &mut matches, + ); + } + } + matches.sort_by(|a, b| b.score.cmp(&a.score)); + matches.iter().map(|m| m.index as usize).collect() + }), + } + } + + pub fn workspace_file_tree_visible_row_count( + &self, + expanded_folders: &HashSet, + ) -> usize { + crate::ui::components::file_tree_visible_row_count_by( + |visit| { + self.for_each_workspace_file_path(|_, path| visit(path)); + }, + expanded_folders, + ) + } + + pub fn workspace_file_index_for_path(&self, path: &str) -> Option { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + if let Some(index) = self.workspace.compare_output.with(&self.store, |output| { + let output = output.as_ref()?; + let mut found = None; + output.for_each_path(|index, candidate| { + if found.is_none() && candidate == path { + found = Some(index); + } + }); + found + }) { + return Some(index); + } + self.workspace.files.with(&self.store, |files| { + files.iter().position(|file| file.path == path) + }) + } + WorkspaceSource::Status => self + .workspace + .status_file_changes + .with(&self.store, |changes| { + changes.iter().position(|change| change.path == path) + }), + WorkspaceSource::None => self.workspace.files.with(&self.store, |files| { + files.iter().position(|file| file.path == path) + }), + } + } + + pub fn file_list_row_stride(&self) -> f32 { + self.file_list.row_height.get(&self.store) + self.file_list.gap.get(&self.store) + } + + pub fn file_list_total_content_height(&self, file_count: usize) -> f32 { + if file_count == 0 { + return 0.0; + } + file_count as f32 * self.file_list_row_stride() - self.file_list.gap.get(&self.store) + } + + pub fn file_list_max_scroll_px(&self, file_count: usize) -> f32 { + (self.file_list_total_content_height(file_count) + - self.file_list.viewport_height.get(&self.store)) + .max(0.0) + } + + pub fn file_list_clamp_scroll(&mut self, file_count: usize) { + let max = self.file_list_max_scroll_px(file_count); + let cur = self.file_list.scroll_offset_px.get(&self.store); + self.file_list + .scroll_offset_px + .set_if_changed(&self.store, cur.clamp(0.0, max)); + } + + /// Scroll by a number of rows (positive = down). + pub fn file_list_scroll_rows(&mut self, delta: i32, file_count: usize) { + let px_delta = delta as f32 * self.file_list_row_stride(); + let cur = self.file_list.scroll_offset_px.get(&self.store); + self.file_list + .scroll_offset_px + .set(&self.store, cur + px_delta); + self.file_list_clamp_scroll(file_count); + } + + /// Scroll by a raw pixel delta (positive = down). + pub fn file_list_scroll_px(&mut self, delta_px: f32, file_count: usize) { + let cur = self.file_list.scroll_offset_px.get(&self.store); + self.file_list + .scroll_offset_px + .set(&self.store, cur + delta_px); + self.file_list_clamp_scroll(file_count); + } + + /// Reset every file-list signal back to its default value. + pub fn reset_file_list(&mut self) { + let d = FileListState::default(); + self.file_list + .scroll_offset_px + .set(&self.store, d.scroll_offset_px); + self.file_list + .commits_scroll_offset_px + .set(&self.store, d.commits_scroll_offset_px); + self.file_list + .hovered_index + .set(&self.store, d.hovered_index); + self.file_list.row_height.set(&self.store, d.row_height); + self.file_list.gap.set(&self.store, d.gap); + self.file_list + .viewport_height + .set(&self.store, d.viewport_height); + self.file_list.filter.set(&self.store, d.filter); + self.file_list.mode.set(&self.store, d.mode); + self.file_list.tab.set(&self.store, d.tab); + self.file_list + .expanded_folders + .set(&self.store, d.expanded_folders); + self.file_list.viewed_files.set(&self.store, d.viewed_files); + } + + pub fn sidebar_row_count(&self) -> usize { + if matches!( + self.workspace.source.get(&self.store), + WorkspaceSource::Compare | WorkspaceSource::TextCompare + ) && self.file_list.tab.get(&self.store) == SidebarTab::Files + && self.file_list.mode.get(&self.store) == SidebarMode::TreeView + && self.file_list.filter.with(&self.store, |s| s.is_empty()) + { + let expanded_folders = self.file_list.expanded_folders.get(&self.store); + return self.workspace_file_tree_visible_row_count(&expanded_folders); + } + + if self.workspace.source.get(&self.store) == WorkspaceSource::Status + && self.file_list.filter.with(&self.store, |s| s.is_empty()) + { + self.workspace.files.with(&self.store, |f| f.len()) + + self + .workspace + .status_file_changes + .with(&self.store, |s| status_section_count(s)) + } else { + self.workspace_file_count() + } + } + + pub fn file_list_entry_meta(&self, index: usize) -> FileListEntryMeta { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .and_then(|output| compare_output_file_entry_meta(output, index)) + .unwrap_or_default() + }) + } + WorkspaceSource::Status => { + self.workspace + .status_file_changes + .with(&self.store, |changes| { + changes + .get(index) + .map(status_file_entry_meta) + .unwrap_or_default() + }) + } + WorkspaceSource::None => FileListEntryMeta::default(), + } + } + + pub(super) fn sidebar_row_index_for_file(&self, index: usize) -> usize { + if self.workspace.source.get(&self.store) != WorkspaceSource::Status + || !self.file_list.filter.with(&self.store, |s| s.is_empty()) + { + return index; + } + index + + self + .workspace + .status_file_changes + .with(&self.store, |s| status_section_count_before(s, index + 1)) + } +} + +impl AppState { + pub(super) fn clamp_sidebar_width_px(&self, width: u32) -> u32 { + let min_width = (280.0 * self.ui_scale_factor() * 0.64).round() as u32; + width.max(min_width.max(120)) + } + + pub(super) fn shift_loaded_file(&mut self, delta: isize) -> Vec { + let file_count = self.workspace_file_count(); + if file_count == 0 { + return Vec::new(); + } + let current = self.reconcile_selected_file_index_from_path().unwrap_or(0); + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current + .saturating_add(delta as usize) + .min(file_count.saturating_sub(1)) + }; + self.select_file(next, true) + } + + pub(super) fn select_file(&mut self, index: usize, reveal: bool) -> Vec { + if self.settings.continuous_scroll + && !matches!( + self.workspace.source.get(&self.store), + WorkspaceSource::None + ) + { + let target = self + .file_start_offset_px(index) + .min(self.global_max_scroll_top_px()); + self.set_viewport_anchor_for_global(target, ViewportAnchorBias::PreserveTop); + self.workspace.global_scroll_top_px.set(&self.store, target); + } + self.select_file_inner(index, reveal) + } + + pub(super) fn select_file_inner(&mut self, index: usize, reveal: bool) -> Vec { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare => self.select_compare_file(index, reveal), + WorkspaceSource::TextCompare => self.select_text_compare_file(index, reveal), + WorkspaceSource::Status => self.select_status_item(index, reveal), + WorkspaceSource::None => { + self.startup.preferred_file_index = Some(index); + Vec::new() + } + } + } + + pub(super) fn active_file_matches_workspace_file(&self, index: usize) -> bool { + let Some(path) = self.workspace_file_path_at(index) else { + return false; + }; + let source = self.workspace.source.get(&self.store); + let selected_bucket = self.workspace.selected_change_bucket.get(&self.store); + self.workspace.active_file.with(&self.store, |active| { + active.as_ref().is_some_and(|active| { + if active.index != index || active.path != path { + return false; + } + match source { + WorkspaceSource::Status => selected_bucket.is_some_and(|bucket| { + let (left_ref, right_ref) = self.status_refs_for_bucket(bucket); + active.left_ref == left_ref && active.right_ref == right_ref + }), + WorkspaceSource::Compare | WorkspaceSource::TextCompare => true, + WorkspaceSource::None => false, + } + }) + }) + } + + pub(super) fn select_text_compare_file(&mut self, index: usize, reveal: bool) -> Vec { + let Some(entry) = self.workspace_file_entry_at(index) else { + self.push_error("Selected file index is out of range."); + return Vec::new(); + }; + let mut effects = vec![ + SyntaxEffect::EnsureSyntaxPackForPath { + path: entry.path.to_string(), + } + .into(), + ]; + effects.extend(self.select_loaded_compare_file(index, reveal)); + effects + } + + pub(super) fn select_compare_file(&mut self, index: usize, reveal: bool) -> Vec { + let Some(entry) = self.workspace_file_entry_at(index) else { + self.push_error("Selected file index is out of range."); + return Vec::new(); + }; + + if !self.compare_file_is_large(index) { + let mut effects = vec![ + SyntaxEffect::EnsureSyntaxPackForPath { + path: entry.path.to_string(), + } + .into(), + ]; + effects.extend(self.select_loaded_compare_file(index, reveal)); + return effects; + } + + let entry_path = entry.path.to_string(); + + if let Some(mut active_file) = self.cached_compare_file_at(index, &entry_path) { + active_file.last_used_tick = self.next_file_working_set_tick(); + self.workspace + .selected_file_index + .set(&self.store, Some(index)); + self.workspace + .selected_file_path + .set(&self.store, Some(entry_path.clone())); + self.workspace.selected_change_bucket.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + self.workspace + .active_file + .set(&self.store, Some(active_file.clone())); + self.cache_active_file(active_file); + self.compare_progress.set(&self.store, None); + self.editor_clear_document(); + self.file_list.hovered_index.set(&self.store, Some(index)); + if reveal { + self.reveal_file_list_row(index); + } + let mut effects = self.sync_editor_scroll_from_global(); + effects.push(SyntaxEffect::EnsureSyntaxPackForPath { path: entry_path }.into()); + effects.extend(self.request_active_file_syntax_effect()); + return effects; + } + + let should_load = self.should_enqueue_file_load( + index, + &entry_path, + CompareWorkPriority::InteractiveSelectedFile, + ); + + // If we're mid-compare (first file selection post-CompareFinished), + // flip the phase so the progress panel reports "Preparing first + // file…". Subsequent selections don't touch compare_progress. + self.compare_progress.update(&self.store, |slot| { + if let Some(p) = slot.as_mut() { + Arc::make_mut(p).phase = ComparePhase::RenderingFirstFile; + } + }); + + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + self.push_error("Open a repository before selecting a compare file."); + return Vec::new(); + }; + let deferred_file = self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .and_then(|output| compare_output_deferred_summary(output, index)) + }); + + self.workspace + .selected_file_index + .set(&self.store, Some(index)); + self.workspace + .selected_file_path + .set(&self.store, Some(entry_path.clone())); + self.workspace.selected_change_bucket.set(&self.store, None); + self.workspace.active_file.set(&self.store, None); + self.workspace.active_file_loading.set( + &self.store, + Some(ActiveFileLoading { + index, + path: entry_path.clone(), + priority: CompareWorkPriority::InteractiveSelectedFile, + }), + ); + self.mark_file_cache_loading( + index, + entry_path.clone(), + CompareWorkPriority::InteractiveSelectedFile, + ); + self.editor_clear_document(); + self.file_list.hovered_index.set(&self.store, Some(index)); + if reveal { + self.reveal_file_list_row(index); + } + + let mut effects = vec![ + SyntaxEffect::EnsureSyntaxPackForPath { + path: entry_path.clone(), + } + .into(), + ]; + if should_load { + effects.push( + CompareEffect::LoadFile(Task { + generation: self.workspace.compare_generation.get(&self.store), + request: CompareFileRequest { + repo_path, + request: vcs_compare_request( + self.compare.mode.get(&self.store), + self.compare.left_ref.get(&self.store), + self.compare.right_ref.get(&self.store), + self.compare.layout.get(&self.store), + self.compare.renderer.get(&self.store), + ), + path: entry_path, + index, + deferred_file, + priority: CompareWorkPriority::InteractiveSelectedFile, + }, + }) + .into(), + ); + } + effects + } + + #[profiling::function] + pub(super) fn select_loaded_compare_file(&mut self, index: usize, reveal: bool) -> Vec { + let mut selected_path = None; + let mut prepared = None; + let mut oob = false; + self.workspace + .compare_output + .update(&self.store, |maybe_output| { + let Some(output) = maybe_output.as_mut() else { + return; + }; + let Some(carbon_file) = output.carbon.files.get(index) else { + oob = true; + return; + }; + selected_path = Some(carbon_file.path().to_owned()); + prepared = Some(prepare_active_file(index, carbon_file)); + }); + + let Some(prepared) = prepared else { + if oob { + self.push_error("Selected file index is out of range."); + return Vec::new(); + } + self.startup.preferred_file_index = Some(index); + return Vec::new(); + }; + + let Some(path) = selected_path else { + self.startup.preferred_file_index = Some(index); + return Vec::new(); + }; + + self.install_compare_active_file(index, path, prepared); + if reveal { + self.reveal_file_list_row(index); + } + let mut effects = self.sync_editor_scroll_from_global(); + effects.extend(self.request_active_file_syntax_effect()); + effects + } + + pub(super) fn reveal_file_list_row(&mut self, index: usize) { + let row_top = self.sidebar_row_index_for_file(index) as f32 * self.file_list_row_stride(); + let row_bottom = row_top + self.file_list.row_height.get(&self.store); + let scroll = self.file_list.scroll_offset_px.get(&self.store); + let viewport = self.file_list.viewport_height.get(&self.store); + if row_top < scroll { + self.file_list.scroll_offset_px.set(&self.store, row_top); + } else if row_bottom > scroll + viewport { + self.file_list + .scroll_offset_px + .set(&self.store, row_bottom - viewport); + } + self.file_list_clamp_scroll(self.sidebar_row_count()); + } + + pub fn workspace_file_count(&self) -> usize { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + let count = self.workspace.compare_output.with(&self.store, |output| { + output.as_ref().map(CompareOutput::file_count).unwrap_or(0) + }); + count.max(self.workspace.files.with(&self.store, |f| f.len())) + } + WorkspaceSource::Status => self + .workspace + .status_file_changes + .with(&self.store, |s| s.len()), + WorkspaceSource::None => self.workspace.files.with(&self.store, |f| f.len()), + } + } + + pub fn workspace_file_path_at(&self, index: usize) -> Option { + self.workspace_file_entry_at(index) + .map(|entry| entry.path.to_string()) + } + + pub fn selected_workspace_file_index(&self) -> Option { + let count = self.workspace_file_count(); + let selected_index = self + .workspace + .selected_file_index + .get(&self.store) + .filter(|index| *index < count); + + if let Some(path) = self.workspace.selected_file_path.get(&self.store) { + if let Some(index) = selected_index + && self + .workspace_file_entry_at(index) + .is_some_and(|entry| entry.path == path.as_str()) + { + return Some(index); + } + if let Some(index) = self.workspace_file_index_for_path(&path) { + return Some(index); + } + } + + selected_index + } + + pub(super) fn reconcile_selected_file_index_from_path(&mut self) -> Option { + let resolved = self.selected_workspace_file_index(); + if let Some(index) = resolved + && self.workspace.selected_file_index.get(&self.store) != Some(index) + { + self.workspace + .selected_file_index + .set(&self.store, Some(index)); + } + resolved + } + + pub fn workspace_render_generation(&self) -> u64 { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare => self.workspace.compare_generation.get(&self.store), + WorkspaceSource::TextCompare => self.text_compare.generation, + WorkspaceSource::Status => self.workspace.status_generation.get(&self.store), + WorkspaceSource::None => 0, + } + } +} diff --git a/src/ui/state/github.rs b/src/ui/state/github.rs index 8e44d5de..69b0394c 100644 --- a/src/ui/state/github.rs +++ b/src/ui/state/github.rs @@ -2143,3 +2143,342 @@ mod tests { assert_eq!(range.3, Some(5)); } } + +pub(super) fn build_pr_palette_entry( + cache: &HashMap, + key: &PrKey, + has_repo: bool, +) -> PaletteEntry { + let (owner, repo, number) = key; + let fallback_label = format!("#{number} in {owner}/{repo}"); + let entry = cache.get(key); + let (label, rhs, detail, disabled) = match entry.map(|e| (&e.meta, &e.diff)) { + None | Some((PrPeekMeta::Loading, _)) => ( + fallback_label, + Some("Resolving\u{2026}".to_owned()), + if has_repo { + "Fetching PR metadata".to_owned() + } else { + "Open a repo to view this diff".to_owned() + }, + false, + ), + Some((PrPeekMeta::Ready(info), diff)) => { + let label = format!("#{} {}", info.number, info.title); + let rhs = format!( + "{} \u{00B7} +{} \u{2212}{} \u{00B7} @{}", + info.state, info.additions, info.deletions, info.author_login + ); + let detail = match diff { + PrPeekDiff::Ready { .. } => "Ready \u{2014} press Enter to open".to_owned(), + PrPeekDiff::Loading => "Preparing diff\u{2026}".to_owned(), + PrPeekDiff::Failed(msg) => format!("Diff load failed: {msg}"), + PrPeekDiff::Idle => { + if has_repo { + "Queued".to_owned() + } else { + "Open a repo to view this diff".to_owned() + } + } + }; + let disabled = !has_repo; + (label, Some(rhs), detail, disabled) + } + Some((PrPeekMeta::Failed(msg), _)) => { + (fallback_label, Some("error".to_owned()), msg.clone(), true) + } + }; + PaletteEntry { + label, + detail, + kind: PaletteEntryKind::PullRequest(key.clone()), + highlights: Vec::new(), + rhs, + disabled, + } +} +pub type PrKey = (String, String, i32); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrPeekMeta { + Loading, + Ready(PullRequestInfo), + Failed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrPeekDiff { + Idle, + Loading, + Ready { + url: String, + left_ref: String, + right_ref: String, + info: PullRequestInfo, + }, + Failed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrCacheEntry { + pub meta: PrPeekMeta, + pub diff: PrPeekDiff, + pub last_peek_ms: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PrReviewCommentsEntry { + pub status: AsyncStatus, + pub comments: Vec, + pub message: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReviewCommentDraft { + pub key: PrKey, + pub request: CreatePullRequestReviewComment, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ReviewCommentComposerState { + pub draft: Option, + pub status: AsyncStatus, + pub message: Option, + /// When set, submitting the composer replies to this thread instead of + /// creating a new inline draft. + pub reply_target: Option, + /// When set, submitting the composer edits this comment (by GraphQL node id) + /// instead of creating a new draft. + pub edit_target: Option, + /// Write (false) vs Preview (true) tab — Preview renders the markdown. + pub preview: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveReviewStatus { + pub status: ReviewSessionStatus, + pub message: Option, + pub unresolved_threads: usize, + pub resolved_threads: usize, + pub outdated_threads: usize, + pub pending_drafts: usize, + pub failed_drafts: usize, + pub review_decision: Option, + pub viewer_latest_review_state: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct PullRequestState { + pub status: AsyncStatus, + pub cache: HashMap, + pub pending_confirm: Option, + pub active: Option, + pub review_comments: HashMap, + pub review_sessions: HashMap, + pub review_composer: ReviewCommentComposerState, + /// Ephemeral, UI-only expand/collapse override per thread. Takes precedence + /// over the default (unresolved=expanded, resolved=collapsed). Not persisted + /// and intentionally separate from the backend `ReviewThreadStatus.collapsed`. + pub review_thread_expanded: HashMap, + /// Fetched comment-author avatars, keyed by `avatar_cache_key` of the sized + /// URL. Shared across PRs (avatars are immutable per URL); populated by the + /// shared `AvatarFetched` handler and read by the review card overlay. + pub review_avatars: HashMap, + /// Active drag-selection within a single review comment body, or `None`. + /// Mutually exclusive with the editor's viewport text selection. + pub card_text_selection: Option, +} + +/// Drag-selection within one review comment body. Offsets are byte indices into +/// `text` (a snapshot of the cleaned, wrapped-source body), so they remain valid +/// across re-wrap; `text` is stored so copy never has to re-derive it. Only the +/// comment whose `source_key` matches renders the highlight. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CardTextSelection { + pub source_key: u64, + pub text: String, + pub anchor: usize, + pub focus: usize, +} + +impl CardTextSelection { + pub fn new(source_key: u64, text: String, byte: usize) -> Self { + let byte = byte.min(text.len()); + Self { + source_key, + text, + anchor: byte, + focus: byte, + } + } + + pub fn normalized(&self) -> (usize, usize) { + (self.anchor.min(self.focus), self.anchor.max(self.focus)) + } + + pub fn is_collapsed(&self) -> bool { + self.anchor == self.focus + } + + /// The selected substring, or `None` when the selection is empty/invalid. + pub fn selected_text(&self) -> Option { + let (lo, hi) = self.normalized(); + if lo >= hi { + return None; + } + self.text.get(lo..hi).map(str::to_owned) + } +} + +/// Lifecycle of a single comment-author avatar fetch. `Failed` is terminal (no +/// retry) so a persistently-broken URL falls back to initials without re-fetching. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReviewAvatar { + Fetching, + Ready(AvatarBitmap), + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AvatarBitmap { + pub url: String, + pub rgba: Arc>, + pub width: u32, + pub height: u32, + pub cache_key: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct GitHubAuthState { + pub status: AsyncStatus, + pub device_flow: Option, + pub token_present: bool, + pub user: Option, + pub avatar: Option, + pub avatar_fetching: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct GitHubState { + pub client_id: String, + #[store(flatten)] + pub auth: GitHubAuthState, + #[store(flatten)] + pub pull_request: PullRequestState, +} + +/// Request a fixed-size avatar from GitHub by rewriting (or appending) the `s=` query +/// parameter. Returns `None` if the input URL is empty. +pub(crate) fn avatar_url_sized(base: &str, size: u32) -> Option { + let base = base.trim(); + if base.is_empty() { + return None; + } + let (path, query) = match base.split_once('?') { + Some((p, q)) => (p, q), + None => (base, ""), + }; + let mut parts: Vec = query + .split('&') + .filter(|part| !part.is_empty() && !part.starts_with("s=")) + .map(|part| part.to_owned()) + .collect(); + parts.push(format!("s={size}")); + Some(format!("{path}?{}", parts.join("&"))) +} + +/// Deterministic cache key for an avatar URL so the GPU texture cache dedupes it. +pub(crate) fn avatar_cache_key(url: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + "avatar".hash(&mut h); + url.hash(&mut h); + h.finish() +} + +impl AppState { + pub(super) fn preview_pull_request(&mut self) -> Vec { + let profile = self.vcs_ui_profile(); + if !profile.accepts_compare_mode(CompareMode::ThreeDot) + || self.repository.location.with(&self.store, |location| { + !location + .as_ref() + .is_some_and(|location| location.profile == VCS_PROFILE_GIT) + }) + { + self.push_error("PR preview is only available for Git repositories."); + return Vec::new(); + } + let Some(base_ref) = self.default_pull_request_base_ref() else { + self.push_error("No default branch found for PR preview."); + return Vec::new(); + }; + let (_, workdir_ref, _) = profile.working_copy_compare(); + self.workspace.pre_drill_compare.set(&self.store, None); + self.compare.left_ref.set(&self.store, base_ref); + self.compare + .right_ref + .set(&self.store, workdir_ref.to_owned()); + self.compare.resolved_left.set(&self.store, None); + self.compare.resolved_right.set(&self.store, None); + self.compare.mode.set(&self.store, CompareMode::ThreeDot); + let mut effects = self.persist_settings_effect(); + effects.extend(self.kickoff_compare()); + effects + } + + pub(super) fn default_pull_request_base_ref(&self) -> Option { + let refs = self.repository.refs.get(&self.store); + let active = refs + .iter() + .find(|reference| reference.active && reference.kind == RefKind::Branch) + .map(|reference| reference.name.as_str()); + let branch_ref = |name: &str| { + refs.iter() + .find(|reference| { + reference.name == name + && active != Some(reference.name.as_str()) + && matches!(reference.kind, RefKind::Branch | RefKind::RemoteBranch) + }) + .map(|reference| reference.name.clone()) + }; + for name in [ + "origin/main", + "origin/master", + "upstream/main", + "upstream/master", + "origin/develop", + "origin/development", + "main", + "master", + "develop", + "development", + ] { + if let Some(reference) = branch_ref(name) { + return Some(reference); + } + } + for trunk in ["main", "master", "develop", "development"] { + let suffix = format!("/{trunk}"); + if let Some(reference) = refs + .iter() + .find(|reference| { + reference.name.ends_with(&suffix) + && active != Some(reference.name.as_str()) + && reference.kind == RefKind::RemoteBranch + }) + .map(|reference| reference.name.clone()) + { + return Some(reference); + } + } + None + } + + pub(super) fn apply_pr_compare(&mut self, left: String, right: String) -> Vec { + let _ = self.update_compare_field(CompareField::Left, left); + let _ = self.update_compare_field(CompareField::Right, right); + self.compare.mode.set(&self.store, CompareMode::ThreeDot); + self.kickoff_compare() + } +} diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index 66132c24..e7c2cf80 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -5,15 +5,32 @@ mod editor; mod file_list; mod github; mod overlay; +mod presentation; mod repository; mod settings; mod syntax; mod text_compare; mod text_edit; +mod ui; mod update; mod working_set; mod workspace; +pub use self::app::*; +pub use self::compare::*; +pub use self::file_list::*; +pub use self::github::*; +pub use self::overlay::*; +pub use self::presentation::*; +pub use self::repository::*; +use self::syntax::*; +pub use self::text_compare::*; +pub use self::text_edit::*; +pub use self::ui::*; +pub use self::update::*; +pub use self::working_set::*; +pub use self::workspace::*; + use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::rc::Rc; @@ -33,7 +50,7 @@ use crate::core::forge::github::{ PullRequestReviewComment, }; use crate::core::frecency::FrecencyStore; -use crate::core::review::{ReviewSession, ReviewSessionStatus, ReviewTarget, ReviewThreadId}; +use crate::core::review::{ReviewSession, ReviewSessionStatus, ReviewTarget}; use crate::core::syntax::Highlighter; use crate::core::syntax::annotator::{SyntaxLineTokens, SyntaxRowWindow}; use crate::core::text::TokenBuffer; @@ -54,9 +71,9 @@ use crate::effects::{ AiEffect, BatchFileOperationRequest, CommitRequest, CompareEffect, CompareFileRequest, CompareFileStatsItem, CompareFileStatsRequest, CompareHistoryRequest, CompareRequest, CompareStatsRequest, CompareWorkPriority, Effect, FetchRemoteRequest, FileOperationRequest, - GitHubEffect, LoadFileSyntaxRequest, PatchOperationRequest, PublishPlanRequest, PublishRequest, - PullFfRequest, PushRequest, RepositoryEffect, SettingsEffect, StatusDiffRequest, SyntaxEffect, - Task, TextCompareRequest, UiEffect, UpdateEffect, VcsOperationRequest, + GitHubEffect, PatchOperationRequest, PublishPlanRequest, PublishRequest, PullFfRequest, + PushRequest, RepositoryEffect, SettingsEffect, StatusDiffRequest, SyntaxEffect, Task, + TextCompareRequest, UiEffect, UpdateEffect, VcsOperationRequest, }; use crate::events::{ AppEvent, CompareFileFinished, CompareFileStat, CompareFileStatsReady, CompareFinished, @@ -73,181 +90,6 @@ use crate::ui::icons::lucide; use crate::ui::theme::ThemeMode; use crate::ui::virtual_list::{build_sectioned_rows, step_selection}; -const MAX_VISIBLE_TOASTS: usize = 5; -const TOAST_LIFETIME_MS: u64 = 5_000; -const TOAST_ANIM_MS: u64 = 150; -const CURSOR_BLINK_INTERVAL_MS: u64 = 530; -const LARGE_COMPARE_FILE_LINES: i32 = 1_500; -const COMPARE_STATS_CHUNK_SIZE: usize = 64; -const COMPARE_STATS_BACKGROUND_CHUNK_SIZE: usize = 128 * 1024; -const COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT: usize = 10_000; -const COMPARE_STATS_VISIBLE_OVERSCAN_ROWS: usize = 32; -const SYNTAX_INITIAL_ROWS: usize = 200; -const SYNTAX_OVERSCAN_ROWS: usize = 160; -const MAX_PENDING_SYNTAX_WINDOWS: usize = 96; -const COMPARE_WORKING_SET_MAX_FILES: usize = 96; -const COMPARE_WORKING_SET_MIN_FILES: usize = 24; -const COMPARE_WORKING_SET_BYTE_BUDGET: usize = 64 * 1024 * 1024; -const COMPARE_WORKING_SET_PREFETCH_PAGES: u32 = 3; -const COMPARE_WORKING_SET_TRAILING_PAGES: u32 = 1; -const CONTINUOUS_BOTTOM_ANCHOR_TOLERANCE_PX: u32 = 2; - -fn build_pr_palette_entry( - cache: &HashMap, - key: &PrKey, - has_repo: bool, -) -> PaletteEntry { - let (owner, repo, number) = key; - let fallback_label = format!("#{number} in {owner}/{repo}"); - let entry = cache.get(key); - let (label, rhs, detail, disabled) = match entry.map(|e| (&e.meta, &e.diff)) { - None | Some((PrPeekMeta::Loading, _)) => ( - fallback_label, - Some("Resolving\u{2026}".to_owned()), - if has_repo { - "Fetching PR metadata".to_owned() - } else { - "Open a repo to view this diff".to_owned() - }, - false, - ), - Some((PrPeekMeta::Ready(info), diff)) => { - let label = format!("#{} {}", info.number, info.title); - let rhs = format!( - "{} \u{00B7} +{} \u{2212}{} \u{00B7} @{}", - info.state, info.additions, info.deletions, info.author_login - ); - let detail = match diff { - PrPeekDiff::Ready { .. } => "Ready \u{2014} press Enter to open".to_owned(), - PrPeekDiff::Loading => "Preparing diff\u{2026}".to_owned(), - PrPeekDiff::Failed(msg) => format!("Diff load failed: {msg}"), - PrPeekDiff::Idle => { - if has_repo { - "Queued".to_owned() - } else { - "Open a repo to view this diff".to_owned() - } - } - }; - let disabled = !has_repo; - (label, Some(rhs), detail, disabled) - } - Some((PrPeekMeta::Failed(msg), _)) => { - (fallback_label, Some("error".to_owned()), msg.clone(), true) - } - }; - PaletteEntry { - label, - detail, - kind: PaletteEntryKind::PullRequest(key.clone()), - highlights: Vec::new(), - rhs, - disabled, - } -} - -/// Request a fixed-size avatar from GitHub by rewriting (or appending) the `s=` query -/// parameter. Returns `None` if the input URL is empty. -pub(crate) fn avatar_url_sized(base: &str, size: u32) -> Option { - let base = base.trim(); - if base.is_empty() { - return None; - } - let (path, query) = match base.split_once('?') { - Some((p, q)) => (p, q), - None => (base, ""), - }; - let mut parts: Vec = query - .split('&') - .filter(|part| !part.is_empty() && !part.starts_with("s=")) - .map(|part| part.to_owned()) - .collect(); - parts.push(format!("s={size}")); - Some(format!("{path}?{}", parts.join("&"))) -} - -/// Deterministic cache key for an avatar URL so the GPU texture cache dedupes it. -pub(crate) fn avatar_cache_key(url: &str) -> u64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - "avatar".hash(&mut h); - url.hash(&mut h); - h.finish() -} - -const DEFAULT_UI_SCALE_PCT: u16 = 100; -const MIN_UI_SCALE_PCT: u16 = 70; -const MAX_UI_SCALE_PCT: u16 = 180; -const UI_SCALE_STEP_PCT: u16 = 10; - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum WorkspaceMode { - #[default] - Empty, - Loading, - Ready, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum AppView { - #[default] - Workspace, - Settings, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum SettingsSection { - #[default] - Appearance, - Editor, - Behavior, - Keymaps, - Clankers, - About, -} - -impl SettingsSection { - pub fn label(self) -> &'static str { - match self { - Self::Appearance => "Appearance", - Self::Editor => "Editor", - Self::Behavior => "Behavior", - Self::Keymaps => "Keymaps", - Self::Clankers => "Clankers", - Self::About => "About", - } - } - - pub fn icon(self) -> &'static str { - match self { - Self::Appearance => lucide::SUN, - Self::Editor => lucide::FILE_CODE, - Self::Behavior => lucide::SETTINGS, - Self::Keymaps => lucide::KEY, - Self::Clankers => lucide::SPARKLES, - Self::About => lucide::INFO, - } - } - - pub const ALL: [Self; 6] = [ - Self::Appearance, - Self::Editor, - Self::Behavior, - Self::Keymaps, - Self::Clankers, - Self::About, - ]; -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum WorkspaceSource { - #[default] - None, - Status, - Compare, - TextCompare, -} - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum AsyncStatus { #[default] @@ -257,15349 +99,478 @@ pub enum AsyncStatus { Failed, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum CompareField { - #[default] - Left, - Right, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum TextCompareView { - #[default] - Edit, - Diff, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TextCompareSide { - Left, - Right, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum TextCompareLanguage { - #[default] - Auto, - PlainText, - Rust, - TypeScript, - JavaScript, - Python, - Go, - Json, - Toml, - Shell, - Nix, - C, - Cpp, - Zig, -} - -impl TextCompareLanguage { - pub const OPTIONS: &'static [Self] = &[ - Self::Auto, - Self::PlainText, - Self::Rust, - Self::TypeScript, - Self::JavaScript, - Self::Python, - Self::Go, - Self::Json, - Self::Toml, - Self::Shell, - Self::Nix, - Self::C, - Self::Cpp, - Self::Zig, - ]; - - pub fn label(self) -> &'static str { - match self { - Self::Auto => "Auto", - Self::PlainText => "Plain text", - Self::Rust => "Rust", - Self::TypeScript => "TypeScript", - Self::JavaScript => "JavaScript", - Self::Python => "Python", - Self::Go => "Go", - Self::Json => "JSON", - Self::Toml => "TOML", - Self::Shell => "Shell", - Self::Nix => "Nix", - Self::C => "C", - Self::Cpp => "C++", - Self::Zig => "Zig", - } - } - - pub fn short_label(self) -> &'static str { - match self { - Self::Auto => "Auto", - Self::PlainText => "Text", - Self::Rust => "Rust", - Self::TypeScript => "TS", - Self::JavaScript => "JS", - Self::Python => "Py", - Self::Go => "Go", - Self::Json => "JSON", - Self::Toml => "TOML", - Self::Shell => "Sh", - Self::Nix => "Nix", - Self::C => "C", - Self::Cpp => "C++", - Self::Zig => "Zig", - } - } - - pub fn scratch_path(self) -> &'static str { - match self { - Self::Auto | Self::PlainText => "text.txt", - Self::Rust => "scratch.rs", - Self::TypeScript => "scratch.ts", - Self::JavaScript => "scratch.js", - Self::Python => "scratch.py", - Self::Go => "scratch.go", - Self::Json => "scratch.json", - Self::Toml => "scratch.toml", - Self::Shell => "scratch.sh", - Self::Nix => "scratch.nix", - Self::C => "scratch.c", - Self::Cpp => "scratch.cpp", - Self::Zig => "scratch.zig", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FocusTarget { - WorkspacePrimaryButton, - TitleBar, - ThemeToggle, - FileList, - Editor, - PickerInput, - PickerList, - CommandPaletteInput, - CommandPaletteList, - AuthPrimaryAction, - SidebarSearch, - SearchInput, - CommitEditor, - ReviewCommentEditor, - TextCompareLeft, - TextCompareRight, - SettingsOpenAiKey, - SettingsAnthropicKey, - SettingsSteeringPrompt, -} - -impl FocusTarget { - pub fn is_text_field(self) -> bool { - matches!( - self, - Self::PickerInput - | Self::CommandPaletteInput - | Self::SidebarSearch - | Self::SearchInput - | Self::CommitEditor - | Self::ReviewCommentEditor - | Self::TextCompareLeft - | Self::TextCompareRight - | Self::SettingsOpenAiKey - | Self::SettingsAnthropicKey - | Self::SettingsSteeringPrompt - ) - } -} - // Focus is stored directly as a Signal on AppState — no wrapper struct. -/// Cursor/selection state for the currently focused text field. -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct TextEditState { - /// Byte offset of the caret. - pub cursor: usize, - /// Byte offset of the selection anchor. Equal to `cursor` when nothing is selected. - pub anchor: usize, - /// Timestamp (clock_ms) when the cursor last moved — used to reset blink phase. - pub cursor_moved_at_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Store)] -pub struct CompareState { - pub repo_path: Option, - pub left_ref: String, - pub right_ref: String, - pub mode: CompareMode, - pub layout: LayoutMode, - pub renderer: RendererKind, - pub resolved_left: Option, - pub resolved_right: Option, +#[derive(Debug)] +pub struct AppState { + pub workspace_mode: Signal, + /// Arc-wrapped so per-frame UI snapshots clone a pointer, not the + /// label strings inside. + pub compare_progress: Signal>>, + pub app_view: Signal, + pub settings_section: Signal, + pub keymap_capture: Signal>, + pub keymaps_scroll_top_px: Signal, + pub keymaps_viewport_height_px: Signal, + pub keymaps_content_height_px: Signal, + pub compare: CompareStateStore, + pub repository: RepositoryStateStore, + pub workspace: WorkspaceStateStore, + pub file_list: FileListStateStore, + pub overlays: OverlayStackStateStore, + pub focus: Signal>, + pub text_edit: TextEditStateStore, + pub editor: EditorStateStore, + pub github: GitHubStateStore, + pub settings: Settings, + pub startup: StartupState, + pub last_error: Signal>, + pub toasts: Signal>, + pub syntax_pack_installs: Signal>, + pub update: Signal, + pub context_menu: ContextMenuState, + /// Memoized: `true` when `focus` targets a text-editing field. + pub text_focused: Signal, + pub animation: crate::ui::animation::AnimationState, + pub commit_editor: Editor, + pub review_comment_editor: Editor, + pub steering_prompt_editor: Editor, + pub text_compare: TextCompareState, + pub ai_openai_key: String, + pub ai_anthropic_key: String, + pub ai_openai_editing: bool, + pub ai_anthropic_editing: bool, + pub ai_generation_id: u64, + pub ai_generation_active: bool, + pub ai_generation_error: Option, + /// Shared reactive store. Signals (like `sidebar_visible`) are handles + /// into this store. Kept in `AppState` so state methods (apply_action etc.) + /// can freely read/write signals without threading a store parameter. + pub store: Rc, + pub sidebar_visible: Signal, + pub debug: DebugStateStore, + pub clock_ms: u64, + pub next_toast_id: u64, + pub frecency: Option, + pub theme_names: Vec, + pub theme_variants: Vec, + pub theme_preview_original: Signal>, + pub github_access_token: Option, + viewport_document_cache: Option, + virtual_diff_document: VirtualDiffDocument, + virtual_scroll: VirtualScrollModel, + file_working_set: FileWorkingSet, + syntax_requests: SyntaxRequestTracker, + last_virtual_scroll_top_px: Option, } -impl Default for CompareState { +impl Default for AppState { fn default() -> Self { + let store = Rc::new(SignalStore::default()); + let sidebar_visible = store.create(true); + let focus = store.create(None::); + let text_focused = + store.create_memo(move |s| s.read(focus).is_some_and(|t| t.is_text_field())); + let workspace_mode = store.create(WorkspaceMode::default()); + let compare_progress = store.create(None::>); + let app_view = store.create(AppView::default()); + let settings_section = store.create(SettingsSection::default()); + let keymap_capture = store.create(None::); + let keymaps_scroll_top_px = store.create(0.0_f32); + let keymaps_viewport_height_px = store.create(0.0_f32); + let keymaps_content_height_px = store.create(0.0_f32); + let last_error = store.create(None::); + let theme_preview_original = store.create(None::); + let toasts = store.create(Vec::::new()); + let syntax_pack_installs = store.create(Vec::::new()); + let update = store.create(UpdateState::default()); + let debug = DebugStateStore::new(&store, DebugState::default()); + let file_list = FileListStateStore::new_default(&store); + let editor = EditorStateStore::new_default(&store); + let overlays = OverlayStackStateStore::new_default(&store); + let compare = CompareStateStore::new_default(&store); + let repository = RepositoryStateStore::new_default(&store); + let workspace = WorkspaceStateStore::new_default(&store); + let text_edit = TextEditStateStore::new_default(&store); + let github = GitHubStateStore::new_default(&store); Self { - repo_path: None, - left_ref: String::new(), - right_ref: String::new(), - mode: CompareMode::default(), - layout: LayoutMode::default(), - renderer: RendererKind::default(), - resolved_left: None, - resolved_right: None, + workspace_mode, + compare_progress, + app_view, + settings_section, + keymap_capture, + keymaps_scroll_top_px, + keymaps_viewport_height_px, + keymaps_content_height_px, + compare, + repository, + workspace, + file_list, + overlays, + focus, + text_edit, + editor, + github, + settings: Settings::default(), + startup: StartupState::default(), + last_error, + toasts, + syntax_pack_installs, + update, + context_menu: ContextMenuState::default(), + text_focused, + animation: crate::ui::animation::AnimationState::default(), + commit_editor: Editor::default(), + review_comment_editor: Editor::default(), + steering_prompt_editor: Editor::default(), + text_compare: TextCompareState::default(), + ai_openai_key: String::new(), + ai_anthropic_key: String::new(), + ai_openai_editing: false, + ai_anthropic_editing: false, + ai_generation_id: 0, + ai_generation_active: false, + ai_generation_error: None, + sidebar_visible, + debug, + store, + clock_ms: 0, + next_toast_id: 1, + frecency: None, + theme_names: Vec::new(), + theme_variants: Vec::new(), + theme_preview_original, + github_access_token: None, + viewport_document_cache: None, + virtual_diff_document: VirtualDiffDocument::default(), + virtual_scroll: VirtualScrollModel::default(), + file_working_set: FileWorkingSet::default(), + syntax_requests: SyntaxRequestTracker::default(), + last_virtual_scroll_top_px: None, } } } -#[derive(Debug, Clone)] -pub struct TextCompareState { - pub left_editor: Editor, - pub right_editor: Editor, - pub language: TextCompareLanguage, - pub detected_language: Option, - pub path_hint: String, - pub view: TextCompareView, - pub generation: u64, - pub last_compared_generation: Option, - pub status: AsyncStatus, -} +impl AppState { + pub fn bootstrap(startup: StartupOptions, settings: Settings) -> (Self, Vec) { + let persisted = matching_persisted_compare(&startup, &settings).cloned(); + let repo_path = startup.args.repo.clone(); + let left_ref = startup + .args + .left + .clone() + .or_else(|| persisted.as_ref().map(|compare| compare.left_ref.clone())) + .unwrap_or_default(); + let right_ref = startup + .args + .right + .clone() + .or_else(|| persisted.as_ref().map(|compare| compare.right_ref.clone())) + .unwrap_or_default(); + let mode = startup + .args + .compare_mode + .or_else(|| persisted.as_ref().map(|compare| compare.mode)) + .unwrap_or_default(); + let layout = startup + .args + .layout + .or_else(|| persisted.as_ref().map(|compare| compare.layout)) + .unwrap_or(settings.viewport.layout); + let renderer = startup + .args + .renderer + .or_else(|| persisted.as_ref().map(|compare| compare.renderer)) + .unwrap_or_default(); + let auto_compare_pending = startup.wants_compare(mode, &left_ref, &right_ref); + let bootstrap_compare_started = repo_path.is_some() + && startup.args.open_pr.is_none() + && auto_compare_pending + && (startup.args.left.is_some() + || startup.args.right.is_some() + || startup.args.compare_mode.is_some()); -impl Default for TextCompareState { - fn default() -> Self { - let mut left_editor = Editor::new(EditorMode::CodeInput); - let mut right_editor = Editor::new(EditorMode::CodeInput); - left_editor.set_syntax_path("text.txt"); - right_editor.set_syntax_path("text.txt"); - Self { - left_editor, - right_editor, - language: TextCompareLanguage::Auto, - detected_language: None, - path_hint: "text.txt".to_owned(), - view: TextCompareView::default(), - generation: 0, - last_compared_generation: None, - status: AsyncStatus::Idle, - } - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct RepositoryState { - pub status: AsyncStatus, - pub location: Option, - pub capabilities: Option, - pub refs: Vec, - pub changes: Vec, - pub operation_log: Vec, - pub file_changes: Vec, - pub publish_plan: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FileListEntry { - pub path: ComparePath, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum FileListStatus { - #[default] - None, - Added, - Deleted, - Modified, - Renamed, - Copied, - Untracked, - Conflicted, - TypeChanged, - Binary, -} - -impl FileListStatus { - pub fn label(self) -> &'static str { - match self { - Self::None => "", - Self::Added => "A", - Self::Deleted => "D", - Self::Modified => "M", - Self::Renamed => "R", - Self::Copied => "C", - Self::Untracked => "U", - Self::Conflicted => "!", - Self::TypeChanged => "T", - Self::Binary => "B", - } - } - - pub fn is_empty(self) -> bool { - matches!(self, Self::None) - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct FileListEntryMeta { - pub status: FileListStatus, - pub additions: i32, - pub deletions: i32, - pub is_binary: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActiveFileLoading { - pub index: usize, - pub path: String, - pub priority: CompareWorkPriority, -} - -pub use crate::core::compare::ComparePhase; - -/// What the progress panel is about. Drives chip rendering: compare -/// shows a left⇄right ref pair, repo-open shows a single folder chip. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LoadingSubject { - Compare { - left_label: String, - right_label: String, - }, - RepoOpen { - name: String, - }, -} - -/// Transient progress state for a long-running workspace operation -/// (compare or repo open). Present iff something is in flight and the -/// reveal delay has either elapsed or was set to zero. Cleared when the -/// operation lands or the user cancels. -/// -/// `reveal_at_ms` controls when the panel is rendered. Compares show -/// immediately; repo-open still uses the short delay to avoid flashing a -/// loading panel for tiny repositories. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CompareProgress { - pub generation: u64, - pub phase: ComparePhase, - pub subject: LoadingSubject, - pub started_at_ms: u64, - pub reveal_at_ms: u64, - /// Total file count — first known from a backend `LoadingFiles` - /// emission, re-confirmed by `CompareFinished`. Unused for RepoOpen. - pub file_count_total: Option, - /// Files read so far during `LoadingFiles`. Zero before, frozen - /// after. - pub files_loaded: u32, -} - -/// Delay between kicking off an op and revealing the loading UI — -/// fast ops under this threshold show no loading flash at all. -pub const COMPARE_REVEAL_DELAY_MS: u64 = 500; - -#[derive(Debug, Clone)] -pub struct PreparedActiveFile { - pub carbon_file: carbon::FileDiff, - pub carbon_expansion: carbon::ExpansionState, - pub carbon_overlays: CarbonStyleOverlays, - pub render_doc: Arc, - pub token_buffer: TokenBuffer, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ViewportDocumentMode { - Single, - Continuous, -} - -#[derive(Debug, Clone)] -pub struct ViewportDocument { - pub doc: Arc, - pub mode: ViewportDocumentMode, - pub generation: u64, - pub start_index: usize, - pub start_offset_px: u32, - pub scroll_top_px: u32, - pub slot_indices: Vec, - pub slot_item_ids: Vec, - pub stream_items: Vec, - pub slot_loading: Vec, - pub path: String, -} - -impl ViewportDocument { - pub fn single(doc: Arc, generation: u64, file_index: usize, path: String) -> Self { - Self { - doc, - mode: ViewportDocumentMode::Single, - generation, - start_index: file_index, - start_offset_px: 0, - scroll_top_px: 0, - slot_indices: vec![file_index], - slot_item_ids: vec![VirtualDiffItemId::file( - WorkspaceSource::None, - generation, - file_index, - )], - stream_items: Vec::new(), - slot_loading: vec![false], - path, - } - } - - pub fn is_continuous(&self) -> bool { - self.mode == ViewportDocumentMode::Continuous - } - - pub fn insert_stream_item(&mut self, item: VirtualDiffStreamItem) { - let index = self - .stream_items - .partition_point(|existing| existing.sort_key <= item.sort_key); - self.stream_items.insert(index, item); - } -} - -fn virtual_stream_item_kind( - slot: &ViewportSlotKey, - line: &RenderLine, -) -> Option { - match line.row_kind() { - RenderRowKind::FileHeader => Some(VirtualDiffItemKind::FileHeader), - RenderRowKind::HunkSeparator - if matches!(slot.kind, ViewportSlotKind::Loading) || line.hunk_index < 0 => - { - Some(VirtualDiffItemKind::LoadingPlaceholder) - } - RenderRowKind::HunkSeparator => Some(VirtualDiffItemKind::Hunk), - RenderRowKind::Context - | RenderRowKind::Added - | RenderRowKind::Removed - | RenderRowKind::Modified => Some(VirtualDiffItemKind::DiffRow), - RenderRowKind::Block => None, - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VirtualDiffItemKind { - File, - FileHeader, - Hunk, - DiffRow, - ReviewThread, - ReviewComment, - Composer, - LoadingPlaceholder, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct VirtualDiffItemId { - pub source: WorkspaceSource, - pub generation: u64, - pub kind: VirtualDiffItemKind, - pub index: usize, - pub ordinal: u32, - pub stable_key: u64, -} - -impl VirtualDiffItemId { - fn file(source: WorkspaceSource, generation: u64, index: usize) -> Self { - Self { - source, - generation, - kind: VirtualDiffItemKind::File, - index, - ordinal: 0, - stable_key: 0, - } - } - - pub fn new( - source: WorkspaceSource, - generation: u64, - kind: VirtualDiffItemKind, - index: usize, - ordinal: u32, - stable_key: u64, - ) -> Self { - Self { - source, - generation, - kind, - index, - ordinal, - stable_key, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct VirtualDiffStreamItem { - pub id: VirtualDiffItemId, - pub sort_key: u64, - pub estimated_height_px: u32, - pub measured_height_px: Option, -} - -impl VirtualDiffStreamItem { - pub fn new( - id: VirtualDiffItemId, - sort_key: u64, - estimated_height_px: u32, - measured_height_px: Option, - ) -> Self { - Self { - id, - sort_key, - estimated_height_px, - measured_height_px, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ViewportAnchorBias { - PreserveTop, - PreserveBottom, - FollowEnd, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ViewportAnchor { - pub item_id: VirtualDiffItemId, - pub intra_item_offset_px: u32, - pub bias: ViewportAnchorBias, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ViewportSlotKey { - source: WorkspaceSource, - index: usize, - path: String, - left_ref: String, - right_ref: String, - kind: ViewportSlotKind, -} - -impl ViewportSlotKey { - fn working_set_key(&self) -> Option { - if self.source == WorkspaceSource::None { - return None; - } - Some(WorkingSetFileKey::new( - self.index, - self.path.clone(), - self.left_ref.clone(), - self.right_ref.clone(), - )) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ViewportSlotKind { - Text { - line_count: usize, - text_len: usize, - style_run_count: usize, - syntax_covered_count: usize, - }, - Binary, - Loading, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ViewportDocumentKey { - source: WorkspaceSource, - generation: u64, - start_index: usize, - slots: Vec, -} - -#[derive(Debug, Clone)] -struct ViewportDocumentCache { - key: ViewportDocumentKey, - doc: Arc, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ScrollDirection { - Backward, - Forward, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SyntaxPendingWindow { - request_id: u64, - window: SyntaxRowWindow, -} - -fn file_change_list_status(status: FileChangeStatus, bucket: ChangeBucket) -> FileListStatus { - match (status, bucket) { - (FileChangeStatus::Added, _) => FileListStatus::Added, - (FileChangeStatus::Deleted, _) => FileListStatus::Deleted, - (FileChangeStatus::Renamed, _) => FileListStatus::Renamed, - (FileChangeStatus::Copied, _) => FileListStatus::Copied, - (FileChangeStatus::Untracked, _) => FileListStatus::Untracked, - (FileChangeStatus::Conflicted, _) | (_, ChangeBucket::Conflicted) => { - FileListStatus::Conflicted - } - (FileChangeStatus::TypeChanged, _) => FileListStatus::TypeChanged, - (FileChangeStatus::Binary, _) => FileListStatus::Binary, - (FileChangeStatus::Modified, _) => FileListStatus::Modified, - } -} - -fn vcs_compare_request( - mode: CompareMode, - left_ref: String, - right_ref: String, - layout: LayoutMode, - renderer: RendererKind, -) -> VcsCompareRequest { - let compare_spec = match mode { - CompareMode::SingleCommit => { - let revision = if right_ref.is_empty() { - left_ref - } else { - right_ref - }; - VcsCompareSpec::Change { revision } - } - CompareMode::TwoDot => VcsCompareSpec::Range { - from: left_ref, - to: right_ref, - }, - CompareMode::ThreeDot => VcsCompareSpec::MergeBaseRange { - base: left_ref, - head: right_ref, - }, - }; - VcsCompareRequest { - spec: compare_spec, - layout, - renderer, - } -} - -fn append_active_file_doc(out: &mut RenderDoc, active: &ActiveFile) { - if active.carbon_file.is_binary { - out.append_doc(&build_placeholder_render_doc( - &active.path, - "Binary file. Diffy only shows text diffs here.", - )); - } else { - out.append_doc(&active.render_doc); - } -} - -fn request_syntax_for_active_file( - active: &mut ActiveFile, - repo_path: PathBuf, - generation: u64, - syntax_epoch: u64, - window: SyntaxRowWindow, - request_id: u64, -) -> Option { - let window = next_missing_syntax_tile(active, window)?; - if active - .syntax_pending - .iter() - .any(|pending| pending.window.contains(window)) - || active - .syntax_covered - .iter() - .any(|covered| covered.contains(window)) - { - return None; - } - - active - .syntax_pending - .push(SyntaxPendingWindow { request_id, window }); - Some(LoadFileSyntaxRequest { - repo_path, - file_index: active.index, - path: active.path.clone(), - carbon_file: active.carbon_file.clone(), - carbon_expansion: active.carbon_expansion.clone(), - left_ref: active.left_ref.clone(), - right_ref: active.right_ref.clone(), - window, - request_id, - cache_generation: generation, - syntax_epoch, - }) -} - -fn next_missing_syntax_tile( - active: &ActiveFile, - requested: SyntaxRowWindow, -) -> Option { - let line_count = active.render_doc.lines.len(); - let start = requested.start.min(line_count); - let end = requested.end.min(line_count); - if line_count == 0 || end <= start { - return None; - } - - let tile_rows = SYNTAX_INITIAL_ROWS.max(1); - let mut tile_start = (start / tile_rows) * tile_rows; - while tile_start < end { - let tile_end = tile_start.saturating_add(tile_rows).min(line_count); - let candidate = SyntaxRowWindow { - start: tile_start, - end: tile_end, - }; - let already_requested = active - .syntax_pending - .iter() - .any(|pending| pending.window.contains(candidate)) - || active - .syntax_covered - .iter() - .any(|covered| covered.contains(candidate)); - if !already_requested { - return Some(candidate); - } - if tile_end == line_count { - break; - } - tile_start = tile_end; - } - None -} - -fn apply_syntax_tokens_to_file( - carbon_overlays: &mut CarbonStyleOverlays, - token_buffer: &mut TokenBuffer, - updates: &[SyntaxLineTokens], -) { - for update in updates { - if let (Some(side), Some(source_index)) = (update.side, update.source_index) { - if update.tokens.is_empty() { - continue; - } - let range = token_buffer.append(&update.tokens); - carbon_overlays.insert_syntax(update.hunk_index as u32, side, source_index, range); - } - } -} - -fn active_file_matches_language( - active: &ActiveFile, - highlighter: &Highlighter, - language: &str, -) -> bool { - !active.carbon_file.is_binary - && [ - Some(active.path.as_str()), - active.carbon_file.old_path.as_deref(), - active.carbon_file.new_path.as_deref(), - ] - .into_iter() - .flatten() - .any(|path| { - highlighter - .resolve_language(path) - .is_some_and(|resolved| resolved.name() == language) - }) -} - -fn file_change_syntax_paths(change: &FileChange) -> Vec { - let mut paths = Vec::with_capacity(2); - if let Some(old_path) = change.old_path.as_ref() { - paths.push(old_path.clone()); - } - if !paths.iter().any(|path| path == &change.path) { - paths.push(change.path.clone()); - } - paths -} - -fn ensure_syntax_packs_for_file_change_effect(change: &FileChange) -> Effect { - let mut paths = file_change_syntax_paths(change); - if paths.len() == 1 { - return SyntaxEffect::EnsureSyntaxPackForPath { - path: paths.pop().unwrap_or_else(|| change.path.clone()), - } - .into(); - } - SyntaxEffect::EnsureSyntaxPacksForPaths { paths }.into() -} - -fn reset_active_file_syntax(active: &mut ActiveFile) { - active.syntax_pending.clear(); - active.syntax_covered.clear(); - let preserve_change_tokens = active.carbon_overlays.has_change_tokens(); - active.carbon_overlays.clear_syntax(); - if !preserve_change_tokens { - active.token_buffer.clear(); - } - active.render_doc = Arc::new(build_render_doc_from_carbon( - &active.carbon_file, - active.index, - &active.carbon_expansion, - &active.carbon_overlays, - &active.token_buffer, - )); -} - -fn apply_compare_stat_to_active_file(active: &mut ActiveFile, stat: &CompareFileStat) -> bool { - if active.index != stat.index || active.path != stat.path { - return false; - } - - let additions = i32_to_u32_nonnegative(stat.additions); - let deletions = i32_to_u32_nonnegative(stat.deletions); - let carbon_file = Arc::make_mut(&mut active.carbon_file); - if carbon_file.additions == additions - && carbon_file.deletions == deletions - && !carbon_file.stats_deferred - { - return false; - } - - carbon_file.additions = additions; - carbon_file.deletions = deletions; - carbon_file.stats_deferred = false; - active.render_doc = Arc::new(build_render_doc_from_carbon( - &active.carbon_file, - active.index, - &active.carbon_expansion, - &active.carbon_overlays, - &active.token_buffer, - )); - true -} - -fn push_syntax_covered_window(windows: &mut Vec, window: SyntaxRowWindow) { - if window.end <= window.start { - return; - } - windows.push(window); - windows.sort_by_key(|window| window.start); - let mut merged: Vec = Vec::with_capacity(windows.len()); - for window in windows.drain(..) { - if let Some(last) = merged.last_mut() - && window.start <= last.end - { - last.end = last.end.max(window.end); - continue; - } - merged.push(window); - } - *windows = merged; -} - -fn remove_pending_syntax_window( - pending: &mut Vec, - request_id: u64, - window: SyntaxRowWindow, -) -> bool { - let Some(index) = pending - .iter() - .position(|pending| pending.request_id == request_id && pending.window == window) - else { - return false; - }; - pending.swap_remove(index); - true -} - -fn hydrate_carbon_full_text( - file: &mut carbon::FileDiff, - old_lines: &[String], - new_lines: &[String], -) { - if !old_lines.is_empty() { - file.old_text = Some(carbon::TextStore::from_text(lines_to_text(old_lines))); - } - if !new_lines.is_empty() { - file.new_text = Some(carbon::TextStore::from_text(lines_to_text(new_lines))); - } - for block in &mut file.blocks { - block.old.start = block.old_line_start.saturating_sub(1); - block.new.start = block.new_line_start.saturating_sub(1); - } - file.is_partial = false; -} - -fn lines_to_text(lines: &[String]) -> String { - if lines.is_empty() { - return String::new(); - } - let mut text = - String::with_capacity(lines.iter().map(|line| line.len().saturating_add(1)).sum()); - for line in lines { - text.push_str(line); - text.push('\n'); - } - text -} - -fn text_store_estimated_bytes(text: &carbon::TextStore) -> usize { - text.as_bytes() - .len() - .saturating_add(text.line_count() as usize * std::mem::size_of::()) -} - -fn render_doc_estimated_bytes(doc: &RenderDoc) -> usize { - doc.text_bytes - .len() - .saturating_add( - doc.style_runs.len() * std::mem::size_of::(), - ) - .saturating_add( - doc.lines.len() * std::mem::size_of::(), - ) - .saturating_add( - doc.file_metadata - .iter() - .map(|meta| { - meta.path - .len() - .saturating_add(meta.old_path.as_ref().map_or(0, String::len)) - }) - .sum::(), - ) -} - -fn carbon_file_estimated_bytes(file: &carbon::FileDiff) -> usize { - file.old_path - .as_ref() - .map_or(0, String::len) - .saturating_add(file.new_path.as_ref().map_or(0, String::len)) - .saturating_add(file.old_oid.as_ref().map_or(0, |oid| oid.0.len())) - .saturating_add(file.new_oid.as_ref().map_or(0, |oid| oid.0.len())) - .saturating_add(file.old_mode.as_ref().map_or(0, |mode| mode.0.len())) - .saturating_add(file.new_mode.as_ref().map_or(0, |mode| mode.0.len())) - .saturating_add(file.old_text.as_ref().map_or(0, text_store_estimated_bytes)) - .saturating_add(file.new_text.as_ref().map_or(0, text_store_estimated_bytes)) - .saturating_add(file.hunks.len() * std::mem::size_of::()) - .saturating_add( - file.hunks - .iter() - .map(|hunk| hunk.header.len()) - .sum::(), - ) - .saturating_add(file.blocks.len() * std::mem::size_of::()) - .saturating_add( - file.blocks - .iter() - .map(|block| { - block.old_inline.len() * std::mem::size_of::() - + block.new_inline.len() * std::mem::size_of::() - }) - .sum::(), - ) -} - -fn line_vec_estimated_bytes(lines: &Arc>) -> usize { - lines - .iter() - .map(|line| { - std::mem::size_of::() - .saturating_add(line.len()) - .saturating_add(1) - }) - .fold(0usize, usize::saturating_add) -} - -fn i32_to_u32_nonnegative(value: i32) -> u32 { - u32::try_from(value).unwrap_or_default() -} - -#[derive(Debug, Clone)] -pub struct ActiveFile { - pub index: usize, - pub path: String, - pub carbon_file: Arc, - pub carbon_expansion: carbon::ExpansionState, - pub carbon_overlays: CarbonStyleOverlays, - pub render_doc: Arc, - pub token_buffer: TokenBuffer, - pub left_ref: String, - pub right_ref: String, - pub file_line_count: Option, - pub old_file_lines: Option>>, - pub file_lines: Option>>, - pub syntax_pending: Vec, - pub syntax_covered: Vec, - pub last_used_tick: u64, -} - -impl ActiveFile { - fn working_set_key(&self) -> WorkingSetFileKey { - WorkingSetFileKey::new( - self.index, - self.path.clone(), - self.left_ref.clone(), - self.right_ref.clone(), - ) - } - - fn working_set_bytes(&self) -> usize { - self.path - .len() - .saturating_add(self.left_ref.len()) - .saturating_add(self.right_ref.len()) - .saturating_add(render_doc_estimated_bytes(&self.render_doc)) - .saturating_add( - self.token_buffer - .len() - .saturating_mul(std::mem::size_of::()), - ) - .saturating_add(carbon_file_estimated_bytes(&self.carbon_file)) - .saturating_add( - self.old_file_lines - .as_ref() - .map_or(0, line_vec_estimated_bytes), - ) - .saturating_add(self.file_lines.as_ref().map_or(0, line_vec_estimated_bytes)) - } -} - -pub(crate) fn prepare_active_file( - file_index: usize, - carbon_file: &carbon::FileDiff, -) -> PreparedActiveFile { - let token_buffer = TokenBuffer::default(); - let carbon_overlays = CarbonStyleOverlays::default(); - - let carbon_expansion = carbon::ExpansionState::default(); - let render_doc = build_render_doc_from_carbon( - carbon_file, - file_index, - &carbon_expansion, - &carbon_overlays, - &token_buffer, - ); - PreparedActiveFile { - carbon_file: carbon_file.clone(), - carbon_expansion, - carbon_overlays, - render_doc: Arc::new(render_doc), - token_buffer, - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct SidebarWidthCache { - pub compare_generation: u64, - pub ui_scale_pct: u16, - pub intrinsic_width_px: f32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ViewportScrollbarMetrics { - pub content_height_px: u32, - pub viewport_height_px: u32, - pub scroll_top_px: u32, - pub max_scroll_top_px: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ViewportScrollbarDragState { - pub metrics: ViewportScrollbarMetrics, - pub file_heights_px: Vec, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum CompareStatsHydrationState { - #[default] - Idle, - Running, - Failed, -} - -#[derive(Debug, Clone, Default, Store)] -pub struct WorkspaceState { - pub source: WorkspaceSource, - pub status: AsyncStatus, - pub status_operation_pending: bool, - pub compare_generation: u64, - pub status_generation: u64, - pub files: Vec, - pub status_file_changes: Vec, - pub selected_file_index: Option, - pub selected_file_path: Option, - pub selected_change_bucket: Option, - pub compare_output: Option, - pub compare_total_stats: Option<(i32, i32)>, - pub compare_hydrated_stats: Option<(i32, i32)>, - pub compare_deferred_stats_remaining: Option, - pub compare_deferred_stats_cursor: usize, - pub compare_total_stats_loading: bool, - pub compare_stats_hydration: CompareStatsHydrationState, - pub active_file: Option, - pub active_file_loading: Option, - pub file_cache: HashMap, - pub file_cache_loading: HashMap, - pub raw_diff_len: usize, - pub used_fallback: bool, - pub fallback_message: String, - pub sidebar_auto_width: Option, - pub range_commits: Vec, - pub compare_history_pending: Option, - pub pre_drill_compare: Option<(String, String, CompareMode)>, - pub expansions: HashMap, - pub file_content_heights: Vec>, - pub file_scroll_total_height_px: u32, - pub pending_file_content_heights: HashMap, - pub file_scroll_recompute_pending: bool, - pub global_scroll_top_px: u32, - pub measured_px_per_row_q16: u32, - pub viewport_scrollbar_drag: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SidebarMode { - #[default] - FlatList, - TreeView, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SidebarTab { - #[default] - Files, - Commits, -} - -#[derive(Debug, Clone, PartialEq, Store)] -pub struct FileListState { - pub scroll_offset_px: f32, - pub commits_scroll_offset_px: f32, - pub hovered_index: Option, - pub row_height: f32, - pub gap: f32, - pub viewport_height: f32, - pub filter: String, - pub mode: SidebarMode, - pub tab: SidebarTab, - pub expanded_folders: HashSet, - pub viewed_files: HashSet, -} - -impl Default for FileListState { - fn default() -> Self { - Self { - scroll_offset_px: 0.0, - commits_scroll_offset_px: 0.0, - hovered_index: None, - row_height: 36.0, - gap: 4.0, - viewport_height: 0.0, - filter: String::new(), - mode: SidebarMode::FlatList, - tab: SidebarTab::Files, - expanded_folders: HashSet::new(), - viewed_files: HashSet::new(), - } - } -} - -impl AppState { - pub fn workspace_file_entry_at(&self, index: usize) -> Option { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - if let Some(entry) = self.workspace.compare_output.with(&self.store, |output| { - output.as_ref().and_then(|output| { - output - .summary_at(index) - .map(|summary| compare_summary_file_entry(&summary)) - }) - }) { - return Some(entry); - } - self.workspace - .files - .with(&self.store, |files| files.get(index).cloned()) - } - WorkspaceSource::Status => self - .workspace - .status_file_changes - .with(&self.store, |changes| { - changes.get(index).map(FileListEntry::from) - }) - .or_else(|| { - self.workspace - .files - .with(&self.store, |files| files.get(index).cloned()) - }), - WorkspaceSource::None => self - .workspace - .files - .with(&self.store, |files| files.get(index).cloned()), - } - } - - pub fn for_each_workspace_file_path(&self, mut visit: impl FnMut(usize, &str)) { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - let visited = self.workspace.compare_output.with(&self.store, |output| { - let Some(output) = output.as_ref() else { - return false; - }; - output.for_each_path(|index, path| visit(index, path)); - true - }); - if !visited { - self.workspace.files.with(&self.store, |files| { - for (index, file) in files.iter().enumerate() { - let path = file.path.path(); - visit(index, path.as_ref()); - } - }); - } - } - WorkspaceSource::Status => { - self.workspace - .status_file_changes - .with(&self.store, |changes| { - for (index, change) in changes.iter().enumerate() { - visit(index, &change.path); - } - }); - } - WorkspaceSource::None => { - self.workspace.files.with(&self.store, |files| { - for (index, file) in files.iter().enumerate() { - let path = file.path.path(); - visit(index, path.as_ref()); - } - }); - } - } - } - - pub fn workspace_max_file_path_chars(&self) -> usize { - if matches!( - self.workspace.source.get(&self.store), - WorkspaceSource::Compare | WorkspaceSource::TextCompare - ) { - let chars = self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .map(CompareOutput::max_path_chars) - .unwrap_or(0) - }); - if chars > 0 { - return chars; - } - } - let mut max_chars = 0; - self.for_each_workspace_file_path(|_, path| { - max_chars = max_chars.max(path.chars().count()); - }); - max_chars - } - - pub fn workspace_file_filter_matches(&self, filter: &str) -> Vec { - let config = neo_frizbee::Config { - max_typos: Some(2), - sort: false, - ..Default::default() - }; - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - let matches = self.workspace.compare_output.with(&self.store, |output| { - let Some(output) = output.as_ref() else { - return None; - }; - let mut matcher = neo_frizbee::Matcher::new(filter, &config); - let mut matches = Vec::new(); - output.for_each_path(|index, path| { - if let Ok(offset) = u32::try_from(index) { - matcher.match_list_into( - std::slice::from_ref(&path), - offset, - &mut matches, - ); - } - }); - matches.sort_by(|a, b| b.score.cmp(&a.score)); - Some(matches.iter().map(|m| m.index as usize).collect()) - }); - if let Some(matches) = matches { - matches - } else { - self.workspace.files.with(&self.store, |files| { - let mut matcher = neo_frizbee::Matcher::new(filter, &config); - let mut matches = Vec::new(); - for (index, file) in files.iter().enumerate() { - if let Ok(offset) = u32::try_from(index) { - let path = file.path.path(); - let path_ref = path.as_ref(); - matcher.match_list_into( - std::slice::from_ref(&path_ref), - offset, - &mut matches, - ); - } - } - matches.sort_by(|a, b| b.score.cmp(&a.score)); - matches.iter().map(|m| m.index as usize).collect() - }) - } - } - WorkspaceSource::Status => { - self.workspace - .status_file_changes - .with(&self.store, |changes| { - let haystack = changes - .iter() - .map(|change| change.path.as_str()) - .collect::>(); - let mut matches = neo_frizbee::match_list(filter, &haystack, &config); - matches.sort_by(|a, b| b.score.cmp(&a.score)); - matches.iter().map(|m| m.index as usize).collect() - }) - } - WorkspaceSource::None => self.workspace.files.with(&self.store, |files| { - let mut matcher = neo_frizbee::Matcher::new(filter, &config); - let mut matches = Vec::new(); - for (index, file) in files.iter().enumerate() { - if let Ok(offset) = u32::try_from(index) { - let path = file.path.path(); - let path_ref = path.as_ref(); - matcher.match_list_into( - std::slice::from_ref(&path_ref), - offset, - &mut matches, - ); - } - } - matches.sort_by(|a, b| b.score.cmp(&a.score)); - matches.iter().map(|m| m.index as usize).collect() - }), - } - } - - pub fn workspace_file_tree_visible_row_count( - &self, - expanded_folders: &HashSet, - ) -> usize { - crate::ui::components::file_tree_visible_row_count_by( - |visit| { - self.for_each_workspace_file_path(|_, path| visit(path)); - }, - expanded_folders, - ) - } - - pub fn workspace_file_index_for_path(&self, path: &str) -> Option { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - if let Some(index) = self.workspace.compare_output.with(&self.store, |output| { - let output = output.as_ref()?; - let mut found = None; - output.for_each_path(|index, candidate| { - if found.is_none() && candidate == path { - found = Some(index); - } - }); - found - }) { - return Some(index); - } - self.workspace.files.with(&self.store, |files| { - files.iter().position(|file| file.path == path) - }) - } - WorkspaceSource::Status => self - .workspace - .status_file_changes - .with(&self.store, |changes| { - changes.iter().position(|change| change.path == path) - }), - WorkspaceSource::None => self.workspace.files.with(&self.store, |files| { - files.iter().position(|file| file.path == path) - }), - } - } - - pub fn file_list_row_stride(&self) -> f32 { - self.file_list.row_height.get(&self.store) + self.file_list.gap.get(&self.store) - } - - pub fn file_list_total_content_height(&self, file_count: usize) -> f32 { - if file_count == 0 { - return 0.0; - } - file_count as f32 * self.file_list_row_stride() - self.file_list.gap.get(&self.store) - } - - pub fn file_list_max_scroll_px(&self, file_count: usize) -> f32 { - (self.file_list_total_content_height(file_count) - - self.file_list.viewport_height.get(&self.store)) - .max(0.0) - } - - pub fn file_list_clamp_scroll(&mut self, file_count: usize) { - let max = self.file_list_max_scroll_px(file_count); - let cur = self.file_list.scroll_offset_px.get(&self.store); - self.file_list - .scroll_offset_px - .set_if_changed(&self.store, cur.clamp(0.0, max)); - } - - pub fn keymaps_max_scroll_px(&self) -> f32 { - let content = self.keymaps_content_height_px.get(&self.store); - let viewport = self.keymaps_viewport_height_px.get(&self.store); - (content - viewport).max(0.0) - } - - pub fn clamp_keymaps_scroll(&mut self) { - let max = self.keymaps_max_scroll_px(); - let cur = self.keymaps_scroll_top_px.get(&self.store); - self.keymaps_scroll_top_px - .set(&self.store, cur.clamp(0.0, max)); - } - - /// Scroll by a number of rows (positive = down). - pub fn file_list_scroll_rows(&mut self, delta: i32, file_count: usize) { - let px_delta = delta as f32 * self.file_list_row_stride(); - let cur = self.file_list.scroll_offset_px.get(&self.store); - self.file_list - .scroll_offset_px - .set(&self.store, cur + px_delta); - self.file_list_clamp_scroll(file_count); - } - - /// Scroll by a raw pixel delta (positive = down). - pub fn file_list_scroll_px(&mut self, delta_px: f32, file_count: usize) { - let cur = self.file_list.scroll_offset_px.get(&self.store); - self.file_list - .scroll_offset_px - .set(&self.store, cur + delta_px); - self.file_list_clamp_scroll(file_count); - } - - /// Reset every file-list signal back to its default value. - pub fn reset_file_list(&mut self) { - let d = FileListState::default(); - self.file_list - .scroll_offset_px - .set(&self.store, d.scroll_offset_px); - self.file_list - .commits_scroll_offset_px - .set(&self.store, d.commits_scroll_offset_px); - self.file_list - .hovered_index - .set(&self.store, d.hovered_index); - self.file_list.row_height.set(&self.store, d.row_height); - self.file_list.gap.set(&self.store, d.gap); - self.file_list - .viewport_height - .set(&self.store, d.viewport_height); - self.file_list.filter.set(&self.store, d.filter); - self.file_list.mode.set(&self.store, d.mode); - self.file_list.tab.set(&self.store, d.tab); - self.file_list - .expanded_folders - .set(&self.store, d.expanded_folders); - self.file_list.viewed_files.set(&self.store, d.viewed_files); - } - - pub fn sidebar_row_count(&self) -> usize { - if matches!( - self.workspace.source.get(&self.store), - WorkspaceSource::Compare | WorkspaceSource::TextCompare - ) && self.file_list.tab.get(&self.store) == SidebarTab::Files - && self.file_list.mode.get(&self.store) == SidebarMode::TreeView - && self.file_list.filter.with(&self.store, |s| s.is_empty()) - { - let expanded_folders = self.file_list.expanded_folders.get(&self.store); - return self.workspace_file_tree_visible_row_count(&expanded_folders); - } - - if self.workspace.source.get(&self.store) == WorkspaceSource::Status - && self.file_list.filter.with(&self.store, |s| s.is_empty()) - { - self.workspace.files.with(&self.store, |f| f.len()) - + self - .workspace - .status_file_changes - .with(&self.store, |s| status_section_count(s)) - } else { - self.workspace_file_count() - } - } - - pub fn file_list_entry_meta(&self, index: usize) -> FileListEntryMeta { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .and_then(|output| compare_output_file_entry_meta(output, index)) - .unwrap_or_default() - }) - } - WorkspaceSource::Status => { - self.workspace - .status_file_changes - .with(&self.store, |changes| { - changes - .get(index) - .map(status_file_entry_meta) - .unwrap_or_default() - }) - } - WorkspaceSource::None => FileListEntryMeta::default(), - } - } - - fn sidebar_row_index_for_file(&self, index: usize) -> usize { - if self.workspace.source.get(&self.store) != WorkspaceSource::Status - || !self.file_list.filter.with(&self.store, |s| s.is_empty()) - { - return index; - } - index - + self - .workspace - .status_file_changes - .with(&self.store, |s| status_section_count_before(s, index + 1)) - } - - fn compare_file_is_large(&self, index: usize) -> bool { - if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { - return false; - } - if self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .is_some_and(|output| compare_output_summary_is_deferred(output, index)) - }) { - return true; - } - - let meta = self.file_list_entry_meta(index); - !meta.is_binary && meta.additions.saturating_add(meta.deletions) >= LARGE_COMPARE_FILE_LINES - } - - fn build_active_file( - &self, - index: usize, - path: String, - prepared: PreparedActiveFile, - left_ref: String, - right_ref: String, - ) -> ActiveFile { - ActiveFile { - index, - path, - carbon_file: Arc::new(prepared.carbon_file), - carbon_expansion: prepared.carbon_expansion.clone(), - carbon_overlays: prepared.carbon_overlays, - render_doc: prepared.render_doc, - token_buffer: prepared.token_buffer, - left_ref, - right_ref, - file_line_count: None, - old_file_lines: None, - file_lines: None, - syntax_pending: Vec::new(), - syntax_covered: Vec::new(), - last_used_tick: 0, - } - } - - fn clear_file_cache(&mut self) { - self.workspace.file_cache.set(&self.store, HashMap::new()); - self.workspace - .file_cache_loading - .set(&self.store, HashMap::new()); - self.viewport_document_cache = None; - self.last_virtual_scroll_top_px = None; - self.file_working_set.reset(); - } - - fn next_file_working_set_tick(&mut self) -> u64 { - self.file_working_set.next_tick() - } - - fn syntax_pending_window_count(&self) -> usize { - let active_count = self.workspace.active_file.with(&self.store, |active| { - active - .as_ref() - .map_or(0, |active| active.syntax_pending.len()) - }); - let cache_count = self.workspace.file_cache.with(&self.store, |files| { - files - .values() - .map(|file| file.syntax_pending.len()) - .sum::() - }); - active_count.saturating_add(cache_count) - } - - fn syntax_outstanding_window_count(&self) -> usize { - self.syntax_requests - .outstanding_count(self.syntax_pending_window_count()) - } - - fn syntax_request_budget_available(&self) -> bool { - self.syntax_requests - .budget_available(self.syntax_pending_window_count()) - } - - fn track_syntax_request(&mut self, request: &LoadFileSyntaxRequest) { - self.syntax_requests.track(request); - } - - fn finish_syntax_request(&mut self, generation: u64, request_id: u64) { - self.syntax_requests.finish(generation, request_id); - } - - fn clear_syntax_pending_windows(&mut self) { - self.workspace.active_file.update(&self.store, |active| { - if let Some(active) = active.as_mut() { - active.syntax_pending.clear(); - } - }); - self.workspace.file_cache.update(&self.store, |files| { - for active in files.values_mut() { - active.syntax_pending.clear(); - } - }); - } - - fn clear_syntax_inflight(&mut self) { - self.clear_syntax_pending_windows(); - self.syntax_requests.invalidate(); - } - - fn syntax_epoch_effect(&self) -> Effect { - SyntaxEffect::SetFileSyntaxEpoch { - epoch: self.syntax_requests.epoch(), - } - .into() - } - - fn invalidate_syntax_epoch_effect(&mut self) -> Effect { - self.clear_syntax_inflight(); - self.syntax_epoch_effect() - } - - fn protect_working_set_slots(&mut self, slots: &[ViewportSlotKey]) { - self.file_working_set.protect_slots(slots); - } - - fn cache_active_file(&mut self, mut active_file: ActiveFile) -> ActiveFile { - let index = active_file.index; - active_file.last_used_tick = self.next_file_working_set_tick(); - let cached = active_file.clone(); - self.workspace.file_cache.update(&self.store, |files| { - files.insert(index, cached); - }); - self.workspace - .file_cache_loading - .update(&self.store, |files| { - files.remove(&index); - }); - self.trim_file_working_set(); - active_file - } - - fn touch_viewport_slot(&mut self, key: &ViewportSlotKey) { - let tick = self.next_file_working_set_tick(); - self.workspace.active_file.update(&self.store, |slot| { - if let Some(active) = slot.as_mut() - && active.index == key.index - && active.path == key.path - && active.left_ref == key.left_ref - && active.right_ref == key.right_ref - { - active.last_used_tick = tick; - } - }); - self.workspace.file_cache.update(&self.store, |files| { - if let Some(active) = files.get_mut(&key.index) - && active.index == key.index - && active.path == key.path - && active.left_ref == key.left_ref - && active.right_ref == key.right_ref - { - active.last_used_tick = tick; - } - }); - } - - fn trim_file_working_set(&mut self) { - let mut keep = self.file_working_set.protected_snapshot(); - if let Some(active) = self.workspace.active_file.with(&self.store, |active| { - active.as_ref().map(ActiveFile::working_set_key) - }) { - keep.insert(active); - } - if let Some(cache) = self.viewport_document_cache.as_ref() { - keep.extend( - cache - .key - .slots - .iter() - .filter_map(ViewportSlotKey::working_set_key), - ); - } - - self.workspace.file_cache.update(&self.store, |files| { - let mut bytes = files - .values() - .map(ActiveFile::working_set_bytes) - .fold(0usize, usize::saturating_add); - if files.len() <= COMPARE_WORKING_SET_MAX_FILES - && bytes <= COMPARE_WORKING_SET_BYTE_BUDGET - { - return; - } - - let mut victims = files - .iter() - .filter(|(_, file)| !keep.contains(&file.working_set_key())) - .map(|(index, file)| (*index, file.last_used_tick)) - .collect::>(); - victims.sort_by_key(|(_, last_used)| *last_used); - - for (index, _) in victims { - if files.len() <= COMPARE_WORKING_SET_MAX_FILES - && (files.len() <= COMPARE_WORKING_SET_MIN_FILES - || bytes <= COMPARE_WORKING_SET_BYTE_BUDGET) - { - break; - } - if let Some(file) = files.remove(&index) { - bytes = bytes.saturating_sub(file.working_set_bytes()); - } - } - }); - } - - fn cached_file_at(&self, index: usize) -> Option { - self.workspace - .file_cache - .with(&self.store, |files| files.get(&index).cloned()) - } - - pub(crate) fn viewport_file_snapshot(&self, index: usize) -> Option { - if let Some(active) = self.workspace.active_file.with(&self.store, |file| { - file.as_ref() - .filter(|active| active.index == index) - .cloned() - }) { - return Some(active); - } - self.cached_file_at(index) - } - - fn file_load_pending_priority(&self, index: usize, path: &str) -> Option { - self.workspace - .active_file_loading - .with(&self.store, |loading| { - loading - .as_ref() - .filter(|loading| loading.index == index && loading.path == path) - .map(|loading| loading.priority) - }) - .or_else(|| { - self.workspace - .file_cache_loading - .with(&self.store, |loading| { - loading - .get(&index) - .filter(|loading| loading.path == path) - .map(|loading| loading.priority) - }) - }) - } - - fn should_enqueue_file_load( - &self, - index: usize, - path: &str, - priority: CompareWorkPriority, - ) -> bool { - self.file_load_pending_priority(index, path) - .is_none_or(|pending| priority.rank() > pending.rank()) - } - - fn mark_file_cache_loading( - &mut self, - index: usize, - path: String, - priority: CompareWorkPriority, - ) { - self.workspace - .file_cache_loading - .update(&self.store, |loading| { - loading.insert( - index, - ActiveFileLoading { - index, - path, - priority, - }, - ); - }); - } - - fn clear_file_cache_loading(&mut self, index: usize) { - self.workspace - .file_cache_loading - .update(&self.store, |loading| { - loading.remove(&index); - }); - } - - fn compare_refs(&self) -> (String, String) { - let left_ref = self - .compare - .resolved_left - .get(&self.store) - .unwrap_or_else(|| self.compare.left_ref.get(&self.store)); - let right_ref = self - .compare - .resolved_right - .get(&self.store) - .unwrap_or_else(|| self.compare.right_ref.get(&self.store)); - (left_ref, right_ref) - } - - fn cached_compare_file_at(&self, index: usize, path: &str) -> Option { - let (left_ref, right_ref) = self.compare_refs(); - if let Some(active_file) = self.workspace.active_file.with(&self.store, |file| { - file.as_ref() - .filter(|file| { - file.index == index - && file.path == path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - .cloned() - }) { - return Some(active_file); - } - self.cached_file_at(index).filter(|file| { - file.index == index - && file.path == path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - } - - fn cached_status_file_at(&self, index: usize, change: &FileChange) -> Option { - let (left_ref, right_ref) = self.status_refs_for_bucket(change.bucket); - if let Some(active_file) = self.workspace.active_file.with(&self.store, |file| { - file.as_ref() - .filter(|file| { - file.index == index - && file.path == change.path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - .cloned() - }) { - return Some(active_file); - } - self.cached_file_at(index).filter(|file| { - file.index == index - && file.path == change.path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - } - - fn status_refs_for_bucket(&self, bucket: ChangeBucket) -> (String, String) { - self.vcs_ui_profile().status_compare_refs(bucket) - } - - fn vcs_ui_profile(&self) -> crate::ui::vcs::VcsUiProfile { - self.repository.location.with(&self.store, |location| { - crate::ui::vcs::profile(location.as_ref()) - }) - } - - fn active_file_slot_key( - &self, - source: WorkspaceSource, - active: &ActiveFile, - ) -> ViewportSlotKey { - let kind = if active.carbon_file.is_binary { - ViewportSlotKind::Binary - } else { - ViewportSlotKind::Text { - line_count: active.render_doc.lines.len(), - text_len: active.render_doc.text_bytes.len(), - style_run_count: active.render_doc.style_runs.len(), - syntax_covered_count: active.syntax_covered.len(), - } - }; - ViewportSlotKey { - source, - index: active.index, - path: active.path.clone(), - left_ref: active.left_ref.clone(), - right_ref: active.right_ref.clone(), - kind, - } - } - - fn loading_slot_key( - &self, - source: WorkspaceSource, - index: usize, - path: &str, - left_ref: String, - right_ref: String, - ) -> ViewportSlotKey { - ViewportSlotKey { - source, - index, - path: path.to_owned(), - left_ref, - right_ref, - kind: ViewportSlotKind::Loading, - } - } - - fn compare_slot_key_at(&self, index: usize, path: &str) -> ViewportSlotKey { - let source = match self.workspace.source.get(&self.store) { - WorkspaceSource::TextCompare => WorkspaceSource::TextCompare, - _ => WorkspaceSource::Compare, - }; - let (left_ref, right_ref) = self.compare_refs(); - if let Some(key) = self.workspace.active_file.with(&self.store, |file| { - file.as_ref() - .filter(|file| { - file.index == index - && file.path == path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - .map(|file| self.active_file_slot_key(source, file)) - }) { - return key; - } - if let Some(key) = self.workspace.file_cache.with(&self.store, |files| { - files - .get(&index) - .filter(|file| { - file.index == index - && file.path == path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - .map(|file| self.active_file_slot_key(source, file)) - }) { - return key; - } - self.loading_slot_key(source, index, path, left_ref, right_ref) - } - - fn status_slot_key_at(&self, index: usize, change: &FileChange) -> ViewportSlotKey { - let (left_ref, right_ref) = self.status_refs_for_bucket(change.bucket); - if let Some(key) = self.workspace.active_file.with(&self.store, |file| { - file.as_ref() - .filter(|file| { - file.index == index - && file.path == change.path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - .map(|file| self.active_file_slot_key(WorkspaceSource::Status, file)) - }) { - return key; - } - if let Some(key) = self.workspace.file_cache.with(&self.store, |files| { - files - .get(&index) - .filter(|file| { - file.index == index - && file.path == change.path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - .map(|file| self.active_file_slot_key(WorkspaceSource::Status, file)) - }) { - return key; - } - self.loading_slot_key( - WorkspaceSource::Status, - index, - &change.path, - left_ref, - right_ref, - ) - } - - fn append_viewport_slot_doc( - &self, - out: &mut RenderDoc, - key: &ViewportSlotKey, - loading_message: &str, - ) { - if let ViewportSlotKind::Loading = key.kind { - out.append_doc(&build_placeholder_render_doc(&key.path, loading_message)); - return; - } - - let mut appended = false; - self.workspace.active_file.with(&self.store, |file| { - let Some(active) = file.as_ref() else { - return; - }; - if active.index == key.index - && active.path == key.path - && active.left_ref == key.left_ref - && active.right_ref == key.right_ref - { - append_active_file_doc(out, active); - appended = true; - } - }); - if appended { - return; - } - - self.workspace.file_cache.with(&self.store, |files| { - let Some(active) = files.get(&key.index).filter(|active| { - active.index == key.index - && active.path == key.path - && active.left_ref == key.left_ref - && active.right_ref == key.right_ref - }) else { - return; - }; - append_active_file_doc(out, active); - appended = true; - }); - - if !appended { - out.append_doc(&build_placeholder_render_doc(&key.path, loading_message)); - } - } - - fn viewport_slot_syntax_window( - &self, - key: &ViewportSlotKey, - slot_top_px: u32, - slot_height_px: u32, - viewport_top_px: u32, - viewport_height_px: u32, - ) -> Option { - let ViewportSlotKind::Text { line_count, .. } = key.kind else { - return None; - }; - if line_count == 0 { - return None; - } - - let slot_bottom_px = slot_top_px.saturating_add(slot_height_px.max(1)); - let viewport_bottom_px = viewport_top_px.saturating_add(viewport_height_px.max(1)); - let visible_top_px = slot_top_px.max(viewport_top_px); - let visible_bottom_px = slot_bottom_px.min(viewport_bottom_px); - if visible_bottom_px <= visible_top_px { - return None; - } - - let row_height_q16 = self.workspace.measured_px_per_row_q16.get(&self.store); - let row_height_q16 = if row_height_q16 == 0 { - 24_u32 << 16 - } else { - row_height_q16 - }; - let row_height_q16 = u64::from(row_height_q16.max(1)); - let start_px = visible_top_px.saturating_sub(slot_top_px); - let end_px = visible_bottom_px.saturating_sub(slot_top_px); - let row_floor = |px: u32| ((u64::from(px) << 16) / row_height_q16) as usize; - let row_ceil = |px: u32| { - (((u64::from(px) << 16).saturating_add(row_height_q16 - 1)) / row_height_q16) as usize - }; - - let start = row_floor(start_px) - .saturating_sub(SYNTAX_OVERSCAN_ROWS) - .min(line_count); - let mut end = row_ceil(end_px) - .saturating_add(SYNTAX_OVERSCAN_ROWS) - .min(line_count); - if end <= start { - end = start.saturating_add(SYNTAX_INITIAL_ROWS).min(line_count); - } - Some(SyntaxRowWindow { start, end }) - } - - fn request_viewport_slot_syntax_window( - &mut self, - key: &ViewportSlotKey, - window: SyntaxRowWindow, - ) -> Option { - if window.end <= window.start { - return None; - } - if !self.syntax_request_budget_available() { - return None; - } - let repo_path = self.compare.repo_path.get(&self.store)?; - let generation = self.active_syntax_generation(); - let syntax_epoch = self.syntax_requests.epoch(); - let mut request = None; - let request_id = self.syntax_requests.next_request_id(); - let mut matched_active = false; - let mut active_to_cache = None; - - self.workspace.active_file.update(&self.store, |slot| { - let Some(active) = slot.as_mut() else { - return; - }; - if active.index != key.index - || active.path != key.path - || active.left_ref != key.left_ref - || active.right_ref != key.right_ref - { - return; - } - matched_active = true; - if let Some(next_request) = request_syntax_for_active_file( - active, - repo_path.clone(), - generation, - syntax_epoch, - window, - request_id, - ) { - active_to_cache = Some(active.clone()); - request = Some(next_request); - } - }); - if let Some(active_file) = active_to_cache { - self.cache_active_file(active_file); - } - if matched_active { - if let Some(request) = request { - self.track_syntax_request(&request); - return Some( - SyntaxEffect::LoadFileSyntax(Task { - generation, - request, - }) - .into(), - ); - } - return None; - } - - let request_id = self.syntax_requests.next_request_id(); - self.workspace.file_cache.update(&self.store, |files| { - let Some(active) = files.get_mut(&key.index).filter(|active| { - active.index == key.index - && active.path == key.path - && active.left_ref == key.left_ref - && active.right_ref == key.right_ref - }) else { - return; - }; - request = request_syntax_for_active_file( - active, - repo_path, - generation, - syntax_epoch, - window, - request_id, - ); - }); - - request.map(|request| { - self.track_syntax_request(&request); - SyntaxEffect::LoadFileSyntax(Task { - generation, - request, - }) - .into() - }) - } - - fn cache_compare_file_from_output(&mut self, index: usize, path: &str) -> Option { - let carbon_file = self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .and_then(|output| output.carbon.files.get(index)) - .filter(|file| file.path() == path) - .filter(|file| !(file.is_partial && file.hunks.is_empty())) - .cloned() - })?; - let prepared = prepare_active_file(index, &carbon_file); - let (left_ref, right_ref) = self.compare_refs(); - let active_file = - self.build_active_file(index, path.to_owned(), prepared, left_ref, right_ref); - let active_file = self.cache_active_file(active_file); - Some(active_file) - } - - fn ensure_compare_file_cached_for_viewport( - &mut self, - index: usize, - path: &str, - priority: CompareWorkPriority, - ) -> Vec { - if self.cached_compare_file_at(index, path).is_some() { - return Vec::new(); - } - if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { - if self.cache_compare_file_from_output(index, path).is_some() { - return vec![ - SyntaxEffect::EnsureSyntaxPackForPath { - path: path.to_owned(), - } - .into(), - ]; - } - return Vec::new(); - } - if !self.compare_file_is_large(index) - && self.cache_compare_file_from_output(index, path).is_some() - { - return vec![ - SyntaxEffect::EnsureSyntaxPackForPath { - path: path.to_owned(), - } - .into(), - ]; - } - if !self.should_enqueue_file_load(index, path, priority) { - return Vec::new(); - } - - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - let deferred_file = self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .and_then(|output| compare_output_deferred_summary(output, index)) - .filter(|summary| summary.path() == path) - }); - self.mark_file_cache_loading(index, path.to_owned(), priority); - vec![ - SyntaxEffect::EnsureSyntaxPackForPath { - path: path.to_owned(), - } - .into(), - CompareEffect::LoadFile(Task { - generation: self.workspace.compare_generation.get(&self.store), - request: CompareFileRequest { - repo_path, - request: vcs_compare_request( - self.compare.mode.get(&self.store), - self.compare.left_ref.get(&self.store), - self.compare.right_ref.get(&self.store), - self.compare.layout.get(&self.store), - self.compare.renderer.get(&self.store), - ), - path: path.to_owned(), - index, - deferred_file, - priority, - }, - }) - .into(), - ] - } - - fn ensure_status_file_cached_for_viewport(&mut self, index: usize) -> Vec { - let Some(file_change) = self - .workspace - .status_file_changes - .with(&self.store, |changes| changes.get(index).cloned()) - else { - return Vec::new(); - }; - if self.cached_status_file_at(index, &file_change).is_some() { - return Vec::new(); - } - if !self.should_enqueue_file_load( - index, - &file_change.path, - CompareWorkPriority::VisibleViewportDiff, - ) { - return Vec::new(); - } - - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - self.mark_file_cache_loading( - index, - file_change.path.clone(), - CompareWorkPriority::VisibleViewportDiff, - ); - let generation = self.workspace.status_generation.get(&self.store); - let renderer = self.compare.renderer.get(&self.store); - vec![ - ensure_syntax_packs_for_file_change_effect(&file_change), - RepositoryEffect::LoadStatusDiff { - task: Task { - generation, - request: StatusDiffRequest { - repo_path, - file_change, - renderer, - }, - }, - index, - } - .into(), - ] - } - - fn install_compare_active_file( - &mut self, - index: usize, - path: String, - prepared: PreparedActiveFile, - ) { - let left_ref = self - .compare - .resolved_left - .get(&self.store) - .unwrap_or_else(|| self.compare.left_ref.get(&self.store)); - let right_ref = self - .compare - .resolved_right - .get(&self.store) - .unwrap_or_else(|| self.compare.right_ref.get(&self.store)); - let active_file = - self.build_active_file(index, path.clone(), prepared, left_ref, right_ref); - let active_file = self.cache_active_file(active_file); - let stats = CompareFileStat { - index, - path: path.clone(), - additions: u32_to_i32_saturating(active_file.carbon_file.additions), - deletions: u32_to_i32_saturating(active_file.carbon_file.deletions), - }; - - self.workspace - .selected_file_index - .set(&self.store, Some(index)); - self.workspace - .selected_file_path - .set(&self.store, Some(path)); - self.workspace.selected_change_bucket.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - self.workspace - .active_file - .set(&self.store, Some(active_file)); - self.apply_compare_file_stats(&[stats]); - // The first real file has landed — tear down the progress panel. - // Subsequent file loads use the sidebar row spinner, not this. - self.compare_progress.set(&self.store, None); - self.editor_clear_document(); - self.editor - .line_selection - .update(&self.store, |ls| ls.clear()); - if self.editor.search.open.get(&self.store) { - self.recompute_search_matches(); - } - self.file_list.hovered_index.set(&self.store, Some(index)); - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OverlayListState { - pub scroll_top_px: u32, - pub viewport_height_px: u32, - pub row_height_px: u32, - pub gap_px: u32, -} - -impl Default for OverlayListState { - fn default() -> Self { - Self { - scroll_top_px: 0, - viewport_height_px: 0, - row_height_px: 36, - gap_px: 0, - } - } -} - -impl OverlayListState { - pub fn stride_px(&self) -> u32 { - self.row_height_px + self.gap_px - } - - pub fn total_content_height_px(&self, entry_count: usize) -> u32 { - if entry_count == 0 { - return 0; - } - self.stride_px() - .saturating_mul(entry_count as u32) - .saturating_sub(self.gap_px) - } - - pub fn viewport_for_max_rows(&self, max_rows: usize, entry_count: usize) -> u32 { - let visible = entry_count.min(max_rows); - if visible == 0 { - return 0; - } - self.stride_px() - .saturating_mul(visible as u32) - .saturating_sub(self.gap_px) - } - - pub fn max_scroll_top_px(&self, entry_count: usize) -> u32 { - self.total_content_height_px(entry_count) - .saturating_sub(self.viewport_height_px) - } - - pub fn clamp_scroll(&mut self, entry_count: usize) { - self.scroll_top_px = self.scroll_top_px.min(self.max_scroll_top_px(entry_count)); - } - - pub fn scroll_px(&mut self, delta_px: i32, entry_count: usize) { - self.scroll_top_px = apply_scroll_delta_px( - self.scroll_top_px, - delta_px, - self.max_scroll_top_px(entry_count), - ); - } - - pub fn reveal_index(&mut self, index: usize, entry_count: usize) { - let stride = self.stride_px().max(1); - let item_top = stride.saturating_mul(index as u32); - let item_bottom = item_top.saturating_add(self.row_height_px); - let viewport_bottom = self.scroll_top_px.saturating_add(self.viewport_height_px); - - if item_top < self.scroll_top_px { - self.scroll_top_px = item_top; - } else if item_bottom > viewport_bottom { - self.scroll_top_px = item_bottom.saturating_sub(self.viewport_height_px); - } - - self.clamp_scroll(entry_count); - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum PickerKind { - #[default] - Repository, - LeftRef, - RightRef, - Theme, - UiFont, - MonoFont, -} - -pub trait PickerItem { - fn label(&self) -> &str; - fn detail(&self) -> Option<&str>; - fn label_style(&self) -> PickerLabelStyle { - PickerLabelStyle::Default - } - fn highlight_ranges(&self) -> &[(usize, usize)] { - &[] - } - fn icon_svg(&self) -> Option<&'static str> { - None - } - fn is_section_header(&self) -> bool { - false - } - fn rhs(&self) -> Option<&str> { - None - } - fn is_disabled(&self) -> bool { - false - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum PickerLabelStyle { - #[default] - Default, - JjChangeId { - prefix_len: usize, - working_copy: bool, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PickerEntry { - pub label: String, - pub detail: String, - pub value: String, - pub highlights: Vec<(usize, usize)>, - pub label_style: PickerLabelStyle, - pub icon: Option<&'static str>, - pub section_header: bool, -} - -impl PickerItem for PickerEntry { - fn label(&self) -> &str { - &self.label - } - fn detail(&self) -> Option<&str> { - Some(&self.detail) - } - fn label_style(&self) -> PickerLabelStyle { - self.label_style - } - fn highlight_ranges(&self) -> &[(usize, usize)] { - &self.highlights - } - fn icon_svg(&self) -> Option<&'static str> { - self.icon - } - fn is_section_header(&self) -> bool { - self.section_header - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct PickerState { - pub kind: PickerKind, - pub query: String, - pub entries: Vec, - pub selected_index: usize, - pub hovered_index: Option, - pub list: OverlayListState, - pub browse_path: Option, - pub ref_resolve_generation: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PaletteCommand { - OpenRepoPicker, - NewTextCompare, - OpenGitHubAuthModal, - OpenGitHubAccountMenu, - SignOutGitHub, - FocusFileList, - FocusViewport, - ShowWorkingTree, - RefreshRepository, - OpenBaseRefPicker, - OpenHeadRefPicker, - SwapRefs, - StartCompare, - OpenCompareMenu, - ShowKeyboardShortcuts, - RestoreCompare, - ToggleSidebar, - ToggleFileTree, - ExpandAllFolders, - CollapseAllFolders, - ToggleWrap, - ToggleContinuousScroll, - SetSettingsSection(SettingsSection), - SetThemeMode(ThemeMode), - SetUiScalePct(u16), - SetWrapColumn(u32), - SetWheelScrollLines(u8), - ToggleAutoUpdate, - ToggleThemeMode, - ChangeTheme, - SetLayout(LayoutMode), - SetRenderer(RendererKind), - SetTheme(String), - ExpandAllContext, - ClearLineSelection, - GenerateCommitMessage, - OpenReviewComment, - OpenPullRequestInGitHub, - CheckForUpdates, - InstallUpdate, - RestartToUpdate, - RunOperation(VcsOperation), - FetchOrigin, - FetchAllRemotes, - PushCurrentBranch, - PublishOptions, - PushCurrentBranchForceWithLease, - PullCurrentBranch, - OpenSettings, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PaletteEntryKind { - Command(PaletteCommand), - File(usize), - Commit(String), - Repo(PathBuf), - Ref(CompareField, String), - PullRequest(PrKey), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PaletteEntry { - pub label: String, - pub detail: String, - pub kind: PaletteEntryKind, - pub highlights: Vec<(usize, usize)>, - /// Extra right-aligned summary (e.g. "+12 āˆ’3 Ā· open"). - pub rhs: Option, - /// Disables the entry when set; `detail` usually explains why. - pub disabled: bool, -} - -fn palette_command_available( - command: &PaletteCommand, - capabilities: Option, -) -> bool { - match command { - PaletteCommand::FetchOrigin - | PaletteCommand::FetchAllRemotes - | PaletteCommand::PushCurrentBranch - | PaletteCommand::PublishOptions => { - capabilities.is_some_and(|capabilities| capabilities.remotes) - } - PaletteCommand::PushCurrentBranchForceWithLease => { - capabilities.is_some_and(|capabilities| capabilities.remotes && capabilities.branches) - } - PaletteCommand::PullCurrentBranch => { - capabilities.is_some_and(|capabilities| capabilities.pull_fast_forward) - } - _ => true, - } -} - -fn vcs_operation_available_for_location( - operation: &VcsOperation, - location: Option<&RepoLocation>, -) -> bool { - match operation { - VcsOperation::Jj(_) => location.is_some_and(|location| location.profile == VCS_PROFILE_JJ), - VcsOperation::JjRebaseCurrentChangeOnto { .. } => { - location.is_some_and(|location| location.profile == VCS_PROFILE_JJ) - } - VcsOperation::JjEditRevision { .. } => { - location.is_some_and(|location| location.profile == VCS_PROFILE_JJ) - } - VcsOperation::JjRestoreOperation { .. } => { - location.is_some_and(|location| location.profile == VCS_PROFILE_JJ) - } - } -} - -fn operation_log_entry_detail(entry: &VcsOperationLogEntry) -> String { - match ( - entry.description.is_empty(), - entry.user.is_empty(), - entry.time.is_empty(), - ) { - (false, false, false) => format!("{} - {} - {}", entry.description, entry.user, entry.time), - (false, false, true) => format!("{} - {}", entry.description, entry.user), - (false, true, false) => format!("{} - {}", entry.description, entry.time), - (false, true, true) => entry.description.clone(), - (true, false, false) => format!("{} - {}", entry.user, entry.time), - (true, false, true) => entry.user.clone(), - (true, true, false) => entry.time.clone(), - (true, true, true) => "jj operation log entry".to_owned(), - } -} - -impl PickerItem for PaletteEntry { - fn label(&self) -> &str { - &self.label - } - fn detail(&self) -> Option<&str> { - Some(&self.detail) - } - fn highlight_ranges(&self) -> &[(usize, usize)] { - &self.highlights - } - fn rhs(&self) -> Option<&str> { - self.rhs.as_deref() - } - fn is_disabled(&self) -> bool { - self.disabled - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct CommandPaletteState { - pub query: String, - pub entries: Vec, - pub selected_index: usize, - pub list: OverlayListState, -} - -/// Ephemeral ref-picker overlay state. `active_field` tracks which chip the -/// search input currently drives; `original_*` snapshots the refs at the moment -/// the picker opened so we can revert cleanly on cancel/backdrop. -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct RefPickerState { - pub active_field: CompareField, - pub original_left: String, - pub original_right: String, -} - -pub type PrKey = (String, String, i32); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PrPeekMeta { - Loading, - Ready(PullRequestInfo), - Failed(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PrPeekDiff { - Idle, - Loading, - Ready { - url: String, - left_ref: String, - right_ref: String, - info: PullRequestInfo, - }, - Failed(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PrCacheEntry { - pub meta: PrPeekMeta, - pub diff: PrPeekDiff, - pub last_peek_ms: u64, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct PrReviewCommentsEntry { - pub status: AsyncStatus, - pub comments: Vec, - pub message: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReviewCommentDraft { - pub key: PrKey, - pub request: CreatePullRequestReviewComment, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ReviewCommentComposerState { - pub draft: Option, - pub status: AsyncStatus, - pub message: Option, - /// When set, submitting the composer replies to this thread instead of - /// creating a new inline draft. - pub reply_target: Option, - /// When set, submitting the composer edits this comment (by GraphQL node id) - /// instead of creating a new draft. - pub edit_target: Option, - /// Write (false) vs Preview (true) tab — Preview renders the markdown. - pub preview: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActiveReviewStatus { - pub status: ReviewSessionStatus, - pub message: Option, - pub unresolved_threads: usize, - pub resolved_threads: usize, - pub outdated_threads: usize, - pub pending_drafts: usize, - pub failed_drafts: usize, - pub review_decision: Option, - pub viewer_latest_review_state: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct PullRequestState { - pub status: AsyncStatus, - pub cache: HashMap, - pub pending_confirm: Option, - pub active: Option, - pub review_comments: HashMap, - pub review_sessions: HashMap, - pub review_composer: ReviewCommentComposerState, - /// Ephemeral, UI-only expand/collapse override per thread. Takes precedence - /// over the default (unresolved=expanded, resolved=collapsed). Not persisted - /// and intentionally separate from the backend `ReviewThreadStatus.collapsed`. - pub review_thread_expanded: HashMap, - /// Fetched comment-author avatars, keyed by `avatar_cache_key` of the sized - /// URL. Shared across PRs (avatars are immutable per URL); populated by the - /// shared `AvatarFetched` handler and read by the review card overlay. - pub review_avatars: HashMap, - /// Active drag-selection within a single review comment body, or `None`. - /// Mutually exclusive with the editor's viewport text selection. - pub card_text_selection: Option, -} - -/// Drag-selection within one review comment body. Offsets are byte indices into -/// `text` (a snapshot of the cleaned, wrapped-source body), so they remain valid -/// across re-wrap; `text` is stored so copy never has to re-derive it. Only the -/// comment whose `source_key` matches renders the highlight. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CardTextSelection { - pub source_key: u64, - pub text: String, - pub anchor: usize, - pub focus: usize, -} - -impl CardTextSelection { - pub fn new(source_key: u64, text: String, byte: usize) -> Self { - let byte = byte.min(text.len()); - Self { - source_key, - text, - anchor: byte, - focus: byte, - } - } - - pub fn normalized(&self) -> (usize, usize) { - (self.anchor.min(self.focus), self.anchor.max(self.focus)) - } - - pub fn is_collapsed(&self) -> bool { - self.anchor == self.focus - } - - /// The selected substring, or `None` when the selection is empty/invalid. - pub fn selected_text(&self) -> Option { - let (lo, hi) = self.normalized(); - if lo >= hi { - return None; - } - self.text.get(lo..hi).map(str::to_owned) - } -} - -/// Lifecycle of a single comment-author avatar fetch. `Failed` is terminal (no -/// retry) so a persistently-broken URL falls back to initials without re-fetching. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReviewAvatar { - Fetching, - Ready(AvatarBitmap), - Failed, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AvatarBitmap { - pub url: String, - pub rgba: Arc>, - pub width: u32, - pub height: u32, - pub cache_key: u64, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct GitHubAuthState { - pub status: AsyncStatus, - pub device_flow: Option, - pub token_present: bool, - pub user: Option, - pub avatar: Option, - pub avatar_fetching: bool, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct GitHubState { - pub client_id: String, - #[store(flatten)] - pub auth: GitHubAuthState, - #[store(flatten)] - pub pull_request: PullRequestState, -} - -/// Overlays live as normal elements in the main tree with a z-index above the -/// viewport. Occluding the viewport is the overlay's own responsibility: modal -/// surfaces (pickers, auth, shortcuts) render a full-screen `overlay_scrim` -/// backdrop; anchored dropdowns (AccountMenu, CompareMenu) render a transparent -/// backdrop and let the viewport show through. Do NOT gate viewport rendering -/// on overlay presence — let z-index handle layering. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OverlaySurface { - RepoPicker, - RefPicker, - CommandPalette, - Confirmation, - GitHubAuthModal, - KeyboardShortcuts, - ThemePicker, - FontPicker, - CompareMenu, - AccountMenu, - PublishMenu, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OverlayEntry { - pub surface: OverlaySurface, - pub focus_return: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct ConfirmationState { - pub title: String, - pub message: String, - pub confirm_label: String, - pub action: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct OverlayStackState { - pub stack: Vec, - #[store(flatten)] - pub picker: PickerState, - #[store(flatten)] - pub command_palette: CommandPaletteState, - #[store(flatten)] - pub ref_picker: RefPickerState, - #[store(flatten)] - pub confirmation: ConfirmationState, -} - -impl AppState { - pub fn overlays_top(&self) -> Option { - self.overlays - .stack - .with(&self.store, |stack| stack.last().map(|e| e.surface)) - } - - pub fn overlays_active_name(&self) -> Option<&'static str> { - self.overlays_top().map(overlay_name) - } - - /// `(pending, failed)` draft counts for the active pull request's review - /// session, for the submit bar. Zeroes when no PR/session is active. - pub fn active_review_draft_metrics(&self) -> (usize, usize) { - let Some(key) = self.active_pull_request_key() else { - return (0, 0); - }; - self.github - .pull_request - .review_sessions - .with(&self.store, |sessions| { - sessions - .get(&key) - .map(|session| { - let metrics = session.metrics(); - (metrics.pending_drafts, metrics.failed_drafts) - }) - .unwrap_or((0, 0)) - }) - } - - pub fn reset_picker(&mut self) { - let d = PickerState::default(); - self.overlays.picker.kind.set(&self.store, d.kind); - self.overlays.picker.query.set(&self.store, d.query); - self.overlays.picker.entries.set(&self.store, d.entries); - self.overlays - .picker - .selected_index - .set(&self.store, d.selected_index); - self.overlays - .picker - .hovered_index - .set(&self.store, d.hovered_index); - self.overlays.picker.list.set(&self.store, d.list); - self.overlays - .picker - .browse_path - .set(&self.store, d.browse_path); - self.overlays - .picker - .ref_resolve_generation - .set(&self.store, d.ref_resolve_generation); - } - - pub fn reset_command_palette(&mut self) { - let d = CommandPaletteState::default(); - self.overlays - .command_palette - .query - .set(&self.store, d.query); - self.overlays - .command_palette - .entries - .set(&self.store, d.entries); - self.overlays - .command_palette - .selected_index - .set(&self.store, d.selected_index); - self.overlays.command_palette.list.set(&self.store, d.list); - } - - pub fn reset_confirmation(&mut self) { - let d = ConfirmationState::default(); - self.overlays.confirmation.title.set(&self.store, d.title); - self.overlays - .confirmation - .message - .set(&self.store, d.message); - self.overlays - .confirmation - .confirm_label - .set(&self.store, d.confirm_label); - self.overlays.confirmation.action.set(&self.store, d.action); - } - - pub fn clear_overlays(&mut self) { - // The bottom-most entry recorded the focus from before any overlay - // opened; restore it so focus never dangles on a dismissed surface. - let mut focus_return: Option> = None; - self.overlays.stack.update(&self.store, |stack| { - focus_return = stack.first().map(|entry| entry.focus_return); - stack.clear(); - }); - self.reset_picker(); - self.reset_command_palette(); - self.reset_confirmation(); - if let Some(target) = focus_return { - self.set_focus(target); - } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Toast { - pub id: u64, - pub kind: ToastKind, - pub message: String, - pub description: Option, - pub created_at_ms: u64, - pub hovered: bool, - /// When `Some`, the toast renders an externally-driven progress bar in - /// place of the time-based one and is pinned (not auto-dismissed). - pub progress: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToastKind { - Info, - Error, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum UpdateState { - #[default] - Idle, - Checking, - Available(AvailableUpdate), - Downloading(AvailableUpdate), - ReadyToRestart(StagedUpdate), - Restarting(StagedUpdate), - Failed(String), -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct StartupState { - pub keyring_enabled: bool, - pub github_token_store: GitHubTokenStore, - pub auto_compare_pending: bool, - pub bootstrap_compare_started: bool, - pub pending_pr_url: Option, - pub preferred_file_index: Option, - pub preferred_file_path: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct DebugState { - pub overlay_visible: bool, -} - -const FILE_HEIGHT_SPARSE_MIN_COUNT: usize = 4096; - -#[derive(Debug)] -enum FileHeightIndex { - Empty, - Dense { - heights: Vec, - tree: Vec, - }, - Sparse { - count: usize, - default_height: u32, - total: u64, - overrides: BTreeMap, - tree: Vec, - }, -} - -impl Default for FileHeightIndex { - fn default() -> Self { - Self::Empty - } -} - -impl FileHeightIndex { - fn rebuild(&mut self, heights: Vec) { - if heights.is_empty() { - self.clear(); - return; - } - - if let Some((default_height, overrides, total)) = sparse_height_index_parts(&heights) { - let mut tree = vec![0; heights.len() + 1]; - for (index, height) in heights.iter().copied().enumerate() { - height_tree_add(&mut tree, index, u64::from(height)); - } - *self = Self::Sparse { - count: heights.len(), - default_height, - total, - overrides, - tree, - }; - return; - } - - let mut tree = vec![0; heights.len() + 1]; - for (index, height) in heights.iter().copied().enumerate() { - dense_tree_add(&mut tree, index, height); - } - *self = Self::Dense { heights, tree }; - } - - fn clear(&mut self) { - *self = Self::Empty; - } - - fn len(&self) -> usize { - match self { - Self::Empty => 0, - Self::Dense { heights, .. } => heights.len(), - Self::Sparse { count, .. } => *count, - } - } - - fn total_u64(&self) -> u64 { - match self { - Self::Empty => 0, - Self::Dense { heights, .. } => self.prefix_u64(heights.len()), - Self::Sparse { total, .. } => *total, - } - } - - fn total_u32(&self) -> u32 { - self.total_u64().min(u64::from(u32::MAX)) as u32 - } - - fn prefix_u32(&self, index: usize) -> u32 { - self.prefix_u64(index).min(u64::from(u32::MAX)) as u32 - } - - fn update(&mut self, index: usize, height: u32) { - match self { - Self::Empty => {} - Self::Dense { heights, tree } => { - if index >= heights.len() { - return; - } - let old = heights[index]; - if old == height { - return; - } - heights[index] = height; - if height >= old { - dense_tree_add(tree, index, height - old); - } else { - dense_tree_sub(tree, index, old - height); - } - } - Self::Sparse { - count, - default_height, - total, - overrides, - tree, - } => { - if index >= *count { - return; - } - let old = overrides.get(&index).copied().unwrap_or(*default_height); - if old == height { - return; - } - if height == *default_height { - overrides.remove(&index); - } else { - overrides.insert(index, height); - } - *total = total - .saturating_sub(u64::from(old)) - .saturating_add(u64::from(height)); - if height >= old { - height_tree_add(tree, index, u64::from(height - old)); - } else { - height_tree_sub(tree, index, u64::from(old - height)); - } - if overrides.len() > *count / 4 { - self.promote_sparse_to_dense(); - } - } - } - } - - fn locate(&self, target_px: u32) -> Option<(usize, u32)> { - match self { - Self::Empty => None, - Self::Dense { heights, tree } => locate_dense_height(heights, tree, target_px), - Self::Sparse { - count, total, tree, .. - } => locate_sparse_height(self, *count, *total, tree, target_px), - } - } - - fn prefix_u64(&self, index: usize) -> u64 { - match self { - Self::Empty => 0, - Self::Dense { heights, tree } => dense_prefix_u64(heights, tree, index), - Self::Sparse { count, tree, .. } => height_tree_prefix_u64(tree, index.min(*count)), - } - } - - fn height_at(&self, index: usize) -> u32 { - match self { - Self::Empty => 0, - Self::Dense { heights, .. } => heights.get(index).copied().unwrap_or(0), - Self::Sparse { - count, - default_height, - overrides, - .. - } => { - if index >= *count { - 0 - } else { - overrides.get(&index).copied().unwrap_or(*default_height) - } - } - } - } - - fn promote_sparse_to_dense(&mut self) { - let Self::Sparse { - count, - default_height, - overrides, - .. - } = self - else { - return; - }; - let mut heights = vec![*default_height; *count]; - for (index, height) in overrides.iter() { - if let Some(slot) = heights.get_mut(*index) { - *slot = *height; - } - } - self.rebuild(heights); - } -} - -#[derive(Debug, Default)] -struct VirtualDiffDocument { - source: WorkspaceSource, - generation: u64, - file_count: usize, - height_index: FileHeightIndex, -} - -impl VirtualDiffDocument { - fn sync_identity( - &mut self, - source: WorkspaceSource, - generation: u64, - file_count: usize, - ) -> bool { - let changed = - self.source != source || self.generation != generation || self.file_count != file_count; - if changed { - self.source = source; - self.generation = generation; - self.file_count = file_count; - self.height_index.clear(); - } - changed - } - - fn clear(&mut self) { - self.source = WorkspaceSource::None; - self.generation = 0; - self.file_count = 0; - self.height_index.clear(); - } - - fn rebuild_heights(&mut self, heights: Vec) { - self.file_count = heights.len(); - self.height_index.rebuild(heights); - } - - fn item_id(&self, index: usize) -> Option { - (index < self.file_count) - .then(|| VirtualDiffItemId::file(self.source, self.generation, index)) - } - - fn anchor_is_current(&self, anchor: ViewportAnchor) -> bool { - anchor.item_id.source == self.source - && anchor.item_id.generation == self.generation - && anchor.item_id.kind == VirtualDiffItemKind::File - && anchor.item_id.index < self.file_count - } - - fn len(&self) -> usize { - self.height_index.len() - } - - fn total_u32(&self) -> u32 { - self.height_index.total_u32() - } - - fn prefix_u32(&self, index: usize) -> u32 { - self.height_index.prefix_u32(index) - } - - fn locate(&self, target_px: u32) -> Option<(usize, u32)> { - self.height_index.locate(target_px) - } - - fn height_at(&self, index: usize) -> u32 { - self.height_index.height_at(index) - } - - fn update_height(&mut self, index: usize, height: u32) { - self.height_index.update(index, height); - } -} - -#[derive(Debug, Default)] -struct VirtualScrollModel { - anchor: Option, -} - -impl VirtualScrollModel { - fn clear(&mut self) { - self.anchor = None; - } - - fn set_anchor(&mut self, anchor: ViewportAnchor) { - self.anchor = Some(anchor); - } -} - -const VIRTUAL_STREAM_SORT_STRIDE: u64 = 1024; -const VIRTUAL_STREAM_ROW_OFFSET: u64 = 512; -const VIRTUAL_STREAM_BLOCK_BELOW_OFFSET: u64 = 768; - -fn virtual_row_sort_key(line_index: usize) -> u64 { - (line_index as u64) - .saturating_mul(VIRTUAL_STREAM_SORT_STRIDE) - .saturating_add(VIRTUAL_STREAM_ROW_OFFSET) -} - -pub fn virtual_block_below_sort_key(anchor_line_index: u32, block_order: usize) -> u64 { - u64::from(anchor_line_index) - .saturating_mul(VIRTUAL_STREAM_SORT_STRIDE) - .saturating_add(VIRTUAL_STREAM_BLOCK_BELOW_OFFSET) - .saturating_add(block_order.min(255) as u64) -} - -pub fn stable_virtual_key(text: &str) -> u64 { - let mut key = 0xcbf2_9ce4_8422_2325_u64; - for byte in text.as_bytes() { - key ^= u64::from(*byte); - key = key.wrapping_mul(0x100_0000_01b3); - } - key -} - -fn estimated_virtual_item_height_px(kind: VirtualDiffItemKind) -> u32 { - match kind { - VirtualDiffItemKind::File => 192, - VirtualDiffItemKind::FileHeader => 40, - VirtualDiffItemKind::Hunk => 28, - VirtualDiffItemKind::DiffRow => 24, - VirtualDiffItemKind::ReviewThread => 160, - VirtualDiffItemKind::ReviewComment => 96, - VirtualDiffItemKind::Composer => 248, - VirtualDiffItemKind::LoadingPlaceholder => 48, - } -} - -fn virtual_row_stable_key(line: &RenderLine, local_ordinal: u32) -> u64 { - let mut key = u64::from(line.kind); - key = key - .wrapping_mul(1_099_511_628_211) - .wrapping_add(line.hunk_index as i64 as u64); - key = key - .wrapping_mul(1_099_511_628_211) - .wrapping_add(u64::from(line.old_line_no)); - key = key - .wrapping_mul(1_099_511_628_211) - .wrapping_add(u64::from(line.new_line_no)); - key = key - .wrapping_mul(1_099_511_628_211) - .wrapping_add(line.line_index as i64 as u64); - key.wrapping_mul(1_099_511_628_211) - .wrapping_add(u64::from(local_ordinal)) -} - -fn sparse_height_index_parts(heights: &[u32]) -> Option<(u32, BTreeMap, u64)> { - if heights.len() < FILE_HEIGHT_SPARSE_MIN_COUNT { - return None; - } - let default_height = most_common_height(heights); - let mut overrides = BTreeMap::new(); - let mut total = 0_u64; - for (index, height) in heights.iter().copied().enumerate() { - total = total.saturating_add(u64::from(height)); - if height != default_height { - overrides.insert(index, height); - } - } - - if overrides.len() <= heights.len() / 4 { - Some((default_height, overrides, total)) - } else { - None - } -} - -fn most_common_height(heights: &[u32]) -> u32 { - let mut counts: HashMap = HashMap::new(); - let mut best_height = heights[0]; - let mut best_count = 0; - for height in heights { - let count = counts - .entry(*height) - .and_modify(|count| *count += 1) - .or_insert(1); - if *count > best_count { - best_height = *height; - best_count = *count; - } - } - best_height -} - -fn dense_tree_add(tree: &mut [u32], index: usize, delta: u32) { - let mut idx = index + 1; - while idx < tree.len() { - tree[idx] = tree[idx].saturating_add(delta); - idx += idx & idx.wrapping_neg(); - } -} - -fn dense_tree_sub(tree: &mut [u32], index: usize, delta: u32) { - let mut idx = index + 1; - while idx < tree.len() { - tree[idx] = tree[idx].saturating_sub(delta); - idx += idx & idx.wrapping_neg(); - } -} - -fn height_tree_add(tree: &mut [u64], index: usize, delta: u64) { - let mut idx = index + 1; - while idx < tree.len() { - tree[idx] = tree[idx].saturating_add(delta); - idx += idx & idx.wrapping_neg(); - } -} - -fn height_tree_sub(tree: &mut [u64], index: usize, delta: u64) { - let mut idx = index + 1; - while idx < tree.len() { - tree[idx] = tree[idx].saturating_sub(delta); - idx += idx & idx.wrapping_neg(); - } -} - -fn dense_prefix_u64(heights: &[u32], tree: &[u32], index: usize) -> u64 { - let mut idx = index.min(heights.len()); - let mut sum = 0_u64; - while idx > 0 { - sum = sum.saturating_add(u64::from(tree[idx])); - idx &= idx - 1; - } - sum -} - -fn height_tree_prefix_u64(tree: &[u64], index: usize) -> u64 { - let mut idx = index.min(tree.len().saturating_sub(1)); - let mut sum = 0_u64; - while idx > 0 { - sum = sum.saturating_add(tree[idx]); - idx &= idx - 1; - } - sum -} - -fn locate_dense_height(heights: &[u32], tree: &[u32], target_px: u32) -> Option<(usize, u32)> { - if heights.is_empty() { - return None; - } - let target = u64::from(target_px); - let total = dense_prefix_u64(heights, tree, heights.len()); - if target >= total { - let index = heights.len() - 1; - return Some((index, heights[index].saturating_sub(1))); - } - - let mut idx = 0_usize; - let mut bit = 1_usize; - while bit < tree.len() { - bit <<= 1; - } - let mut sum = 0_u64; - while bit > 0 { - let next = idx + bit; - if next < tree.len() { - let next_sum = sum.saturating_add(u64::from(tree[next])); - if next_sum <= target { - idx = next; - sum = next_sum; - } - } - bit >>= 1; - } - let index = idx.min(heights.len().saturating_sub(1)); - Some(( - index, - target.saturating_sub(sum).min(u64::from(u32::MAX)) as u32, - )) -} - -fn locate_sparse_height( - index: &FileHeightIndex, - count: usize, - total: u64, - tree: &[u64], - target_px: u32, -) -> Option<(usize, u32)> { - if count == 0 { - return None; - } - let target = u64::from(target_px); - if target >= total { - let slot = count - 1; - return Some((slot, index.height_at(slot).saturating_sub(1))); - } - - let mut slot = 0_usize; - let mut bit = 1_usize; - while bit < tree.len() { - bit <<= 1; - } - let mut sum = 0_u64; - while bit > 0 { - let next = slot + bit; - if next < tree.len() { - let next_sum = sum.saturating_add(tree[next]); - if next_sum <= target { - slot = next; - sum = next_sum; - } - } - bit >>= 1; - } - let slot = slot.min(count.saturating_sub(1)); - Some(( - slot, - target.saturating_sub(sum).min(u64::from(u32::MAX)) as u32, - )) -} - -#[derive(Debug)] -pub struct AppState { - pub workspace_mode: Signal, - /// Arc-wrapped so per-frame UI snapshots clone a pointer, not the - /// label strings inside. - pub compare_progress: Signal>>, - pub app_view: Signal, - pub settings_section: Signal, - pub keymap_capture: Signal>, - pub keymaps_scroll_top_px: Signal, - pub keymaps_viewport_height_px: Signal, - pub keymaps_content_height_px: Signal, - pub compare: CompareStateStore, - pub repository: RepositoryStateStore, - pub workspace: WorkspaceStateStore, - pub file_list: FileListStateStore, - pub overlays: OverlayStackStateStore, - pub focus: Signal>, - pub text_edit: TextEditStateStore, - pub editor: EditorStateStore, - pub github: GitHubStateStore, - pub settings: Settings, - pub startup: StartupState, - pub last_error: Signal>, - pub toasts: Signal>, - pub syntax_pack_installs: Signal>, - pub update: Signal, - pub context_menu: ContextMenuState, - /// Memoized: `true` when `focus` targets a text-editing field. - pub text_focused: Signal, - pub animation: crate::ui::animation::AnimationState, - pub commit_editor: Editor, - pub review_comment_editor: Editor, - pub steering_prompt_editor: Editor, - pub text_compare: TextCompareState, - pub ai_openai_key: String, - pub ai_anthropic_key: String, - pub ai_openai_editing: bool, - pub ai_anthropic_editing: bool, - pub ai_generation_id: u64, - pub ai_generation_active: bool, - pub ai_generation_error: Option, - /// Shared reactive store. Signals (like `sidebar_visible`) are handles - /// into this store. Kept in `AppState` so state methods (apply_action etc.) - /// can freely read/write signals without threading a store parameter. - pub store: Rc, - pub sidebar_visible: Signal, - pub debug: DebugStateStore, - pub clock_ms: u64, - pub next_toast_id: u64, - pub frecency: Option, - pub theme_names: Vec, - pub theme_variants: Vec, - pub theme_preview_original: Signal>, - pub github_access_token: Option, - viewport_document_cache: Option, - virtual_diff_document: VirtualDiffDocument, - virtual_scroll: VirtualScrollModel, - file_working_set: FileWorkingSet, - syntax_requests: SyntaxRequestTracker, - last_virtual_scroll_top_px: Option, -} - -impl Default for AppState { - fn default() -> Self { - let store = Rc::new(SignalStore::default()); - let sidebar_visible = store.create(true); - let focus = store.create(None::); - let text_focused = - store.create_memo(move |s| s.read(focus).is_some_and(|t| t.is_text_field())); - let workspace_mode = store.create(WorkspaceMode::default()); - let compare_progress = store.create(None::>); - let app_view = store.create(AppView::default()); - let settings_section = store.create(SettingsSection::default()); - let keymap_capture = store.create(None::); - let keymaps_scroll_top_px = store.create(0.0_f32); - let keymaps_viewport_height_px = store.create(0.0_f32); - let keymaps_content_height_px = store.create(0.0_f32); - let last_error = store.create(None::); - let theme_preview_original = store.create(None::); - let toasts = store.create(Vec::::new()); - let syntax_pack_installs = store.create(Vec::::new()); - let update = store.create(UpdateState::default()); - let debug = DebugStateStore::new(&store, DebugState::default()); - let file_list = FileListStateStore::new_default(&store); - let editor = EditorStateStore::new_default(&store); - let overlays = OverlayStackStateStore::new_default(&store); - let compare = CompareStateStore::new_default(&store); - let repository = RepositoryStateStore::new_default(&store); - let workspace = WorkspaceStateStore::new_default(&store); - let text_edit = TextEditStateStore::new_default(&store); - let github = GitHubStateStore::new_default(&store); - Self { - workspace_mode, - compare_progress, - app_view, - settings_section, - keymap_capture, - keymaps_scroll_top_px, - keymaps_viewport_height_px, - keymaps_content_height_px, - compare, - repository, - workspace, - file_list, - overlays, - focus, - text_edit, - editor, - github, - settings: Settings::default(), - startup: StartupState::default(), - last_error, - toasts, - syntax_pack_installs, - update, - context_menu: ContextMenuState::default(), - text_focused, - animation: crate::ui::animation::AnimationState::default(), - commit_editor: Editor::default(), - review_comment_editor: Editor::default(), - steering_prompt_editor: Editor::default(), - text_compare: TextCompareState::default(), - ai_openai_key: String::new(), - ai_anthropic_key: String::new(), - ai_openai_editing: false, - ai_anthropic_editing: false, - ai_generation_id: 0, - ai_generation_active: false, - ai_generation_error: None, - sidebar_visible, - debug, - store, - clock_ms: 0, - next_toast_id: 1, - frecency: None, - theme_names: Vec::new(), - theme_variants: Vec::new(), - theme_preview_original, - github_access_token: None, - viewport_document_cache: None, - virtual_diff_document: VirtualDiffDocument::default(), - virtual_scroll: VirtualScrollModel::default(), - file_working_set: FileWorkingSet::default(), - syntax_requests: SyntaxRequestTracker::default(), - last_virtual_scroll_top_px: None, - } - } -} - -impl AppState { - pub fn bootstrap(startup: StartupOptions, settings: Settings) -> (Self, Vec) { - let persisted = matching_persisted_compare(&startup, &settings).cloned(); - let repo_path = startup.args.repo.clone(); - let left_ref = startup - .args - .left - .clone() - .or_else(|| persisted.as_ref().map(|compare| compare.left_ref.clone())) - .unwrap_or_default(); - let right_ref = startup - .args - .right - .clone() - .or_else(|| persisted.as_ref().map(|compare| compare.right_ref.clone())) - .unwrap_or_default(); - let mode = startup - .args - .compare_mode - .or_else(|| persisted.as_ref().map(|compare| compare.mode)) - .unwrap_or_default(); - let layout = startup - .args - .layout - .or_else(|| persisted.as_ref().map(|compare| compare.layout)) - .unwrap_or(settings.viewport.layout); - let renderer = startup - .args - .renderer - .or_else(|| persisted.as_ref().map(|compare| compare.renderer)) - .unwrap_or_default(); - let auto_compare_pending = startup.wants_compare(mode, &left_ref, &right_ref); - let bootstrap_compare_started = repo_path.is_some() - && startup.args.open_pr.is_none() - && auto_compare_pending - && (startup.args.left.is_some() - || startup.args.right.is_some() - || startup.args.compare_mode.is_some()); - - let store = Rc::new(SignalStore::default()); - let sidebar_visible = store.create(true); - let focus = store.create(if repo_path.is_some() { - Some(FocusTarget::TitleBar) - } else { - Some(FocusTarget::WorkspacePrimaryButton) - }); - let text_focused = - store.create_memo(move |s| s.read(focus).is_some_and(|t| t.is_text_field())); - let workspace_mode = store.create(if repo_path.is_some() && auto_compare_pending { - WorkspaceMode::Loading - } else { - WorkspaceMode::Empty - }); - let compare_progress = store.create(None::>); - let app_view = store.create(AppView::default()); - let settings_section = store.create(SettingsSection::default()); - let keymap_capture = store.create(None::); - let keymaps_scroll_top_px = store.create(0.0_f32); - let keymaps_viewport_height_px = store.create(0.0_f32); - let keymaps_content_height_px = store.create(0.0_f32); - let last_error = store.create(None::); - let theme_preview_original = store.create(None::); - let toasts = store.create(Vec::::new()); - let syntax_pack_installs = store.create(Vec::::new()); - let update = store.create(UpdateState::default()); - let debug = DebugStateStore::new(&store, DebugState::default()); - let file_list = FileListStateStore::new_default(&store); - let editor = EditorStateStore::new( - &store, - EditorState { - layout, - wrap_enabled: settings.viewport.wrap_enabled, - wrap_column: settings.viewport.wrap_column, - ..EditorState::default() - }, - ); - let overlays = OverlayStackStateStore::new_default(&store); - let compare = CompareStateStore::new( - &store, - CompareState { - repo_path: repo_path.clone(), - left_ref, - right_ref, - mode, - layout, - renderer, - resolved_left: None, - resolved_right: None, - }, - ); - let repository = RepositoryStateStore::new_default(&store); - let workspace = WorkspaceStateStore::new_default(&store); - let text_edit = TextEditStateStore::new_default(&store); - let initial_token_present = settings.github_user.is_some(); - let github = GitHubStateStore::new( - &store, - GitHubState { - client_id: startup.github_client_id.clone(), - auth: GitHubAuthState { - token_present: initial_token_present, - user: settings.github_user.clone(), - ..GitHubAuthState::default() - }, - pull_request: PullRequestState::default(), - }, - ); - let mut state = Self { - workspace_mode, - compare_progress, - app_view, - settings_section, - keymap_capture, - keymaps_scroll_top_px, - keymaps_viewport_height_px, - keymaps_content_height_px, - compare, - repository, - workspace, - file_list, - overlays, - focus, - text_edit, - editor, - github, - settings, - startup: StartupState { - keyring_enabled: startup.keyring_enabled, - github_token_store: startup.github_token_store, - auto_compare_pending: auto_compare_pending && !bootstrap_compare_started, - bootstrap_compare_started, - pending_pr_url: startup.args.open_pr.clone(), - preferred_file_index: startup.args.file_index, - preferred_file_path: startup.args.file_path.clone(), - }, - last_error, - toasts, - syntax_pack_installs, - update, - context_menu: ContextMenuState::default(), - text_focused, - animation: crate::ui::animation::AnimationState::default(), - commit_editor: Editor::default(), - review_comment_editor: Editor::default(), - steering_prompt_editor: Editor::default(), - text_compare: TextCompareState::default(), - ai_openai_key: String::new(), - ai_anthropic_key: String::new(), - ai_openai_editing: false, - ai_anthropic_editing: false, - ai_generation_id: 0, - ai_generation_active: false, - ai_generation_error: None, - sidebar_visible, - debug, - store, - clock_ms: 0, - next_toast_id: 1, - frecency: crate::core::frecency::open_default_store(), - theme_names: Vec::new(), - theme_variants: Vec::new(), - theme_preview_original, - github_access_token: None, - viewport_document_cache: None, - virtual_diff_document: VirtualDiffDocument::default(), - virtual_scroll: VirtualScrollModel::default(), - file_working_set: FileWorkingSet::default(), - syntax_requests: SyntaxRequestTracker::default(), - last_virtual_scroll_top_px: None, - }; - let seed_prompt = if state.settings.ai_steering_prompt.trim().is_empty() { - crate::ai::DEFAULT_STEERING_PROMPT - } else { - state.settings.ai_steering_prompt.as_str() - }; - state.steering_prompt_editor.set_text(seed_prompt); - state.sync_settings_snapshot(); - - let mut effects = Vec::new(); - if let Some(path) = repo_path { - state - .repository - .status - .set(&state.store, AsyncStatus::Loading); - - // Bootstrap: seed the loading panel so a slow cold-boot open - // shows staged progress. Reveal is gated by the same 500ms - // threshold as user-initiated opens — if the whole bootstrap - // open completes within the threshold the panel never appears - // and the user lands straight in the ready UI. - let boot_gen = state - .workspace - .compare_generation - .get(&state.store) - .saturating_add(1); - state - .workspace - .compare_generation - .set(&state.store, boot_gen); - effects.push(state.invalidate_syntax_epoch_effect()); - let repo_name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("repository") - .to_owned(); - state.compare_progress.set( - &state.store, - Some(Arc::new(CompareProgress { - generation: boot_gen, - phase: ComparePhase::OpeningRepo, - subject: if bootstrap_compare_started { - LoadingSubject::Compare { - left_label: state.vcs_ui_profile().compare_ref_display_label( - &state.compare.left_ref.get(&state.store), - ), - right_label: state.vcs_ui_profile().compare_ref_display_label( - &state.compare.right_ref.get(&state.store), - ), - } - } else { - LoadingSubject::RepoOpen { name: repo_name } - }, - started_at_ms: 0, - reveal_at_ms: COMPARE_REVEAL_DELAY_MS, - file_count_total: None, - files_loaded: 0, - })), - ); - - effects.push( - RepositoryEffect::SyncRepository { - path: path.clone(), - reason: RepositorySyncReason::Open, - reporter_generation: (!bootstrap_compare_started).then_some(boot_gen), - } - .into(), - ); - effects.push(RepositoryEffect::WatchRepository { path: Some(path) }.into()); - if bootstrap_compare_started { - effects.push( - CompareEffect::Run(Task { - generation: boot_gen, - request: CompareRequest { - repo_path: state.compare.repo_path.get(&state.store).unwrap(), - request: vcs_compare_request( - state.compare.mode.get(&state.store), - state.compare.left_ref.get(&state.store), - state.compare.right_ref.get(&state.store), - state.compare.layout.get(&state.store), - state.compare.renderer.get(&state.store), - ), - github_token: startup.github_token.clone(), - }, - }) - .into(), - ); - } - } - if let Some(token) = startup.github_token.clone() { - state.github_access_token = Some(token.clone()); - state.github.auth.token_present.set(&state.store, true); - if startup.github_token_store.is_enabled() { - effects.push(GitHubEffect::SaveGitHubToken(token).into()); - } - } else if startup.github_token_store.is_enabled() { - effects.push(GitHubEffect::LoadGitHubToken.into()); - } - - // Show the cached user + avatar optimistically while the token loads. - if let Some(user) = state.settings.github_user.as_ref() - && let Some(url) = avatar_url_sized(&user.avatar_url, 128) - { - state.github.auth.avatar_fetching.set(&state.store, true); - effects.push(GitHubEffect::FetchAvatar { url }.into()); - } - - effects.push(SyntaxEffect::InstallCommonSyntaxPacks.into()); - if startup.keyring_enabled { - effects.push(AiEffect::LoadAiKeys.into()); - } - if state.update_polling_enabled() { - effects.push(UpdateEffect::CheckForUpdates { silent: true }.into()); - } - (state, effects) - } - - pub fn apply_action>(&mut self, action: A) -> Vec { - let action = action.into(); - match action { - Action::App(action) => app::reduce_action(self, action), - Action::Workspace(action) => workspace::reduce_action(self, action), - Action::TextCompare(action) => text_compare::reduce_action(self, action), - Action::Compare(action) => compare::reduce_action(self, action), - Action::Repository(action) => repository::reduce_action(self, action), - Action::FileList(action) => file_list::reduce_action(self, action), - Action::Overlay(action) => overlay::reduce_action(self, action), - Action::Editor(action) => editor::reduce_action(self, action), - Action::TextEdit(action) => text_edit::reduce_action(self, action), - Action::Settings(action) => settings::reduce_action(self, action), - Action::GitHub(action) => github::reduce_action(self, action), - Action::Update(action) => update::reduce_action(self, action), - Action::Window(_) => Vec::new(), - Action::Syntax(action) => syntax::reduce_action(self, action), - Action::Ai(action) => ai::reduce_action(self, action), - Action::Noop => Vec::new(), - } - } - - pub fn apply_event(&mut self, event: AppEvent) -> Vec { - match event { - AppEvent::Ui(event) => app::reduce_event(self, event), - AppEvent::Repository(event) => repository::reduce_event(self, event), - AppEvent::Compare(event) => compare::reduce_event(self, event), - AppEvent::GitHub(event) => github::reduce_event(self, event), - AppEvent::Settings(event) => settings::reduce_event(self, event), - AppEvent::Update(event) => update::reduce_event(self, event), - AppEvent::Syntax(event) => syntax::reduce_event(self, event), - AppEvent::Ai(event) => ai::reduce_event(self, event), - } - } - - pub fn window_title(&self) -> String { - let workspace_mode = if self.compare_progress.with(&self.store, |p| p.is_some()) { - "loading" - } else { - workspace_mode_name(self.workspace_mode.get(&self.store)) - }; - let title_prefix = crate::platform::startup::window_title_prefix(); - if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { - return format!("{title_prefix} - Text Compare [{workspace_mode}]"); - } - let repo = self.compare.repo_path.with(&self.store, |p| { - p.as_deref() - .and_then(Path::file_name) - .and_then(|value| value.to_str()) - .unwrap_or("native") - .to_owned() - }); - let selected_path = self.workspace.selected_file_path.get(&self.store); - if let Some(path) = selected_path.as_deref() { - format!("{title_prefix} - {repo} [{workspace_mode}] {path}") - } else { - format!("{title_prefix} - {repo} [{workspace_mode}]") - } - } - - pub fn update_time(&mut self, now_ms: u64) { - self.clock_ms = now_ms; - self.animation.tick(now_ms); - let has_expired_toast = self.toasts.with(&self.store, |toasts| { - toasts.iter().any(|toast| { - !toast.hovered - && toast.progress.is_none() - && now_ms.saturating_sub(toast.created_at_ms) >= TOAST_LIFETIME_MS - }) - }); - if has_expired_toast { - self.toasts.update(&self.store, |toasts| { - toasts.retain(|toast| { - toast.hovered - || toast.progress.is_some() - || now_ms.saturating_sub(toast.created_at_ms) < TOAST_LIFETIME_MS - }); - }); - } - } - - pub fn update_polling_enabled(&self) -> bool { - self.settings.auto_update - && crate::core::update::updates_configured() - && !cfg!(debug_assertions) - && !matches!( - self.update.get(&self.store), - UpdateState::Downloading(_) - | UpdateState::ReadyToRestart(_) - | UpdateState::Restarting(_) - ) - } - - pub fn cursor_blink_epoch(&self) -> Option { - self.is_text_focused().then(|| { - self.clock_ms - .saturating_sub(self.text_edit.cursor_moved_at_ms.get(&self.store)) - / CURSOR_BLINK_INTERVAL_MS - }) - } - - pub fn next_cursor_blink_at_ms(&self) -> Option { - self.is_text_focused().then(|| { - let moved_at = self.text_edit.cursor_moved_at_ms.get(&self.store); - let elapsed = self.clock_ms.saturating_sub(moved_at); - let next_epoch = elapsed / CURSOR_BLINK_INTERVAL_MS + 1; - moved_at.saturating_add(next_epoch.saturating_mul(CURSOR_BLINK_INTERVAL_MS)) - }) - } - - pub fn next_toast_expiry_at_ms(&self) -> Option { - self.toasts.with(&self.store, |toasts| { - toasts - .iter() - .filter(|toast| !toast.hovered && toast.progress.is_none()) - .map(|toast| toast.created_at_ms.saturating_add(TOAST_LIFETIME_MS)) - .min() - }) - } - - pub fn active_overlay_name(&self) -> Option<&'static str> { - self.overlays_active_name() - } - - fn open_repository(&mut self, path: PathBuf) -> Vec { - let path = normalize_repository_open_path(path); - self.workspace_mode.set(&self.store, WorkspaceMode::Loading); - self.compare.repo_path.set(&self.store, Some(path.clone())); - self.compare.left_ref.set(&self.store, String::new()); - self.compare.right_ref.set(&self.store, String::new()); - self.compare.mode.set(&self.store, CompareMode::default()); - self.compare.resolved_left.set(&self.store, None); - self.compare.resolved_right.set(&self.store, None); - self.repository - .status - .set(&self.store, AsyncStatus::Loading); - self.repository.location.set(&self.store, None); - self.repository.capabilities.set(&self.store, None); - self.repository.refs.set(&self.store, Vec::new()); - self.repository.changes.set(&self.store, Vec::new()); - self.repository.operation_log.set(&self.store, Vec::new()); - self.repository.file_changes.set(&self.store, Vec::new()); - self.repository.publish_plan.set(&self.store, None); - self.workspace_clear_compare(); - self.reset_file_list(); - self.editor_clear_document(); - self.editor.focused.set(&self.store, false); - self.last_error.set(&self.store, None); - self.github.pull_request.cache.update(&self.store, |c| { - c.clear(); - }); - self.github - .pull_request - .pending_confirm - .set(&self.store, None); - self.clear_overlays(); - self.focus.set(&self.store, Some(FocusTarget::TitleBar)); - self.sync_settings_snapshot(); - - // Seed the progress panel with a repo-open subject. We piggy-back - // on `compare_generation` as the loading generation — any in-flight - // compare is invalidated when the user opens a new repo anyway, - // and `handle_compare_progress_update` just matches on whatever - // generation the panel records. - let next_gen = self - .workspace - .compare_generation - .get(&self.store) - .saturating_add(1); - self.workspace.compare_generation.set(&self.store, next_gen); - let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); - let repo_name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("repository") - .to_owned(); - // Always delay the panel reveal — a tiny repo that opens in under - // the threshold should finish without ever flashing a loading UI. - // Unlike re-compare (which can preserve the old diff during the - // grace window), repo-open has nothing to fall back to visually; - // the empty background / previous workspace is what the user sees - // for 500ms, which is a cheap price for zero flash on fast ops. - let started_at_ms = self.clock_ms; - let reveal_at_ms = started_at_ms.saturating_add(COMPARE_REVEAL_DELAY_MS); - self.compare_progress.set( - &self.store, - Some(Arc::new(CompareProgress { - generation: next_gen, - phase: ComparePhase::OpeningRepo, - subject: LoadingSubject::RepoOpen { name: repo_name }, - started_at_ms, - reveal_at_ms, - file_count_total: None, - files_loaded: 0, - })), - ); - - vec![ - syntax_epoch_effect, - SettingsEffect::SaveSettings(self.settings.clone()).into(), - RepositoryEffect::SyncRepository { - path: path.clone(), - reason: RepositorySyncReason::Open, - reporter_generation: Some(next_gen), - } - .into(), - RepositoryEffect::WatchRepository { path: Some(path) }.into(), - ] - } - - /// Clear the workspace back to a blank "no compare loaded" state. Replaces - /// the former `WorkspaceState::clear_compare(&mut self)` method. - fn workspace_clear_compare(&mut self) { - self.workspace - .source - .set(&self.store, WorkspaceSource::None); - self.workspace.status.set(&self.store, AsyncStatus::Idle); - self.workspace - .status_operation_pending - .set(&self.store, false); - self.workspace.status_generation.set(&self.store, 0); - self.clear_syntax_inflight(); - self.workspace.files.set(&self.store, Vec::new()); - self.workspace - .status_file_changes - .set(&self.store, Vec::new()); - self.workspace.selected_file_index.set(&self.store, None); - self.workspace.selected_file_path.set(&self.store, None); - self.workspace.selected_change_bucket.set(&self.store, None); - self.workspace.compare_output.set(&self.store, None); - self.workspace.compare_total_stats.set(&self.store, None); - self.workspace.compare_hydrated_stats.set(&self.store, None); - self.workspace - .compare_deferred_stats_remaining - .set(&self.store, None); - self.workspace - .compare_deferred_stats_cursor - .set(&self.store, 0); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); - self.workspace.active_file.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - self.clear_file_cache(); - self.workspace.raw_diff_len.set(&self.store, 0); - self.workspace.used_fallback.set(&self.store, false); - self.workspace - .fallback_message - .set(&self.store, String::new()); - self.workspace.sidebar_auto_width.set(&self.store, None); - self.workspace.range_commits.set(&self.store, Vec::new()); - self.workspace - .compare_history_pending - .set(&self.store, None); - self.workspace.pre_drill_compare.set(&self.store, None); - self.workspace.expansions.update(&self.store, |m| m.clear()); - self.clear_file_scroll_layout(); - self.workspace.global_scroll_top_px.set(&self.store, 0); - } - - fn handle_repository_snapshot(&mut self, payload: RepositorySnapshot) -> Vec { - tracing::debug!( - path = %payload.path.display(), - reason = ?payload.reason, - change_kind = ?payload.change_kind, - pending = self.workspace.status_operation_pending.get(&self.store), - status_gen = self.workspace.status_generation.get(&self.store), - "handle_repository_snapshot: entered" - ); - if self - .compare - .repo_path - .with(&self.store, |p| p.as_ref() != Some(&payload.path)) - { - tracing::warn!("handle_repository_snapshot: path mismatch, ignored"); - return Vec::new(); - } - - self.repository.status.set(&self.store, AsyncStatus::Ready); - self.repository - .location - .set(&self.store, Some(payload.location.clone())); - self.repository - .capabilities - .set(&self.store, Some(payload.capabilities)); - let file_changes = payload.file_changes; - self.repository.refs.set(&self.store, payload.refs); - self.repository.changes.set(&self.store, payload.changes); - self.repository - .operation_log - .set(&self.store, payload.operation_log); - self.repository - .file_changes - .set(&self.store, file_changes.clone()); - self.repository.publish_plan.set(&self.store, None); - self.workspace - .status_file_changes - .set(&self.store, file_changes); - - // Tear down a repo-open progress panel. Compare-subject progress - // survives — a kickoff_compare may be queued below and will - // replace it atomically via its own seeding path. - self.compare_progress.update(&self.store, |slot| { - if let Some(p) = slot.as_ref() - && matches!(p.subject, LoadingSubject::RepoOpen { .. }) - { - *slot = None; - } - }); - - match payload.reason { - RepositorySyncReason::Open => { - if let Some(ref store) = self.frecency { - store.record_access(&format!("repo:{}", payload.path.display())); - } - let mut effects = self.persist_settings_effect(); - if let Some(url) = self.startup.pending_pr_url.clone() { - self.startup.pending_pr_url = None; - self.startup.auto_compare_pending = false; - self.github - .pull_request - .status - .set(&self.store, AsyncStatus::Loading); - if let Some(parsed) = crate::core::forge::github::parse_pr_url(&url) { - let key: PrKey = (parsed.owner, parsed.repo, parsed.number); - self.github.pull_request.cache.update(&self.store, |c| { - c.entry(key.clone()).or_insert_with(|| PrCacheEntry { - meta: PrPeekMeta::Loading, - diff: PrPeekDiff::Loading, - last_peek_ms: 0, - }); - }); - self.github - .pull_request - .pending_confirm - .set(&self.store, Some(key)); - } - effects.push( - GitHubEffect::LoadPullRequest { - url, - repo_path: payload.path, - github_token: self.github_access_token.clone(), - } - .into(), - ); - } else if self.startup.auto_compare_pending { - self.startup.auto_compare_pending = false; - effects.extend(self.kickoff_compare()); - } else if self.startup.bootstrap_compare_started { - self.startup.bootstrap_compare_started = false; - } else if let Some(persisted) = self.settings.last_compare.as_ref().filter(|c| { - c.repo_path.as_ref() == Some(&payload.path) - && compare_refs_are_valid(c.mode, &c.left_ref, &c.right_ref) - }) { - self.compare - .left_ref - .set(&self.store, persisted.left_ref.clone()); - self.compare - .right_ref - .set(&self.store, persisted.right_ref.clone()); - self.compare.mode.set(&self.store, persisted.mode); - effects.extend(self.kickoff_compare()); - } else { - let profile = crate::ui::vcs::profile(Some(&payload.location)); - let (left, right, mode) = profile.default_compare(); - self.compare.left_ref.set(&self.store, left.to_owned()); - self.compare.right_ref.set(&self.store, right.to_owned()); - self.compare.mode.set(&self.store, mode); - effects.extend(self.activate_status_view(true)); - } - effects - } - RepositorySyncReason::Dirty | RepositorySyncReason::Rescan => { - if self.workspace.source.get(&self.store) == WorkspaceSource::Status { - return self.activate_status_view(false); - } - - let (mode, left_ref, right_ref) = ( - self.compare.mode.get(&self.store), - self.compare.left_ref.get(&self.store), - self.compare.right_ref.get(&self.store), - ); - if !compare_refs_are_valid(mode, &left_ref, &right_ref) { - return Vec::new(); - } - - match payload.change_kind { - Some(RepositoryChangeKind::Metadata | RepositoryChangeKind::Both) => { - self.kickoff_compare() - } - Some(RepositoryChangeKind::Worktree) - if self.vcs_ui_profile().is_working_copy_ref(&right_ref) => - { - self.kickoff_compare() - } - _ => Vec::new(), - } - } - } - } - - fn expand_context( - &mut self, - hunk_index: usize, - direction: crate::editor::diff::expansion::ExpandDirection, - amount: u32, - ) -> Vec { - use crate::editor::diff::expansion::ExpandDirection; - use crate::events::ContextDirection; - - if amount == 0 { - return Vec::new(); - } - - let ctx_direction = match direction { - ExpandDirection::Above => ContextDirection::Above, - ExpandDirection::Below => ContextDirection::Below, - }; - self.dispatch_context_expansion(hunk_index, ctx_direction, amount) - } - - fn expand_all_context(&mut self) -> Vec { - use crate::events::ContextDirection; - self.dispatch_context_expansion(0, ContextDirection::All, 0) - } - - fn dispatch_context_expansion( - &mut self, - hunk_index: usize, - direction: crate::events::ContextDirection, - amount: u32, - ) -> Vec { - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - - let generation = self.workspace.compare_generation.get(&self.store); - let Some(( - file_index, - path, - old_reference, - new_reference, - cached_old_lines, - cached_new_lines, - )) = self.workspace.active_file.with(&self.store, |af| { - let active = af.as_ref()?; - if active.carbon_file.hunks.is_empty() { - return None; - } - Some(( - active.index, - active.path.clone(), - active.left_ref.clone(), - if active.right_ref.is_empty() { - active.left_ref.clone() - } else { - active.right_ref.clone() - }, - active.old_file_lines.clone(), - active.file_lines.clone(), - )) - }) - else { - return Vec::new(); - }; - - if let (Some(old_lines), Some(new_lines)) = (cached_old_lines, cached_new_lines) { - self.apply_context_expansion(direction, hunk_index, amount, old_lines, new_lines); - let mut effects = vec![self.invalidate_syntax_epoch_effect()]; - effects.extend(self.request_active_file_syntax_effect()); - return effects; - } - - vec![ - RepositoryEffect::FetchContextLines(crate::effects::FetchContextLinesRequest { - repo_path, - old_reference, - new_reference, - path, - generation, - file_index, - hunk_index, - direction, - amount, - }) - .into(), - ] - } - - fn handle_context_lines_ready( - &mut self, - payload: crate::events::ContextLinesReady, - ) -> Vec { - if payload.generation != self.workspace.compare_generation.get(&self.store) { - return Vec::new(); - } - - let matches_active = self.workspace.active_file.with(&self.store, |af| { - af.as_ref() - .is_some_and(|a| a.index == payload.file_index && a.path == payload.path) - }); - if !matches_active { - return Vec::new(); - } - - let old_lines = Arc::new(payload.old_lines); - let new_lines = Arc::new(payload.new_lines); - self.apply_context_expansion( - payload.direction, - payload.hunk_index, - payload.amount, - old_lines, - new_lines, - ); - let mut effects = vec![self.invalidate_syntax_epoch_effect()]; - effects.extend(self.request_active_file_syntax_effect()); - effects - } - - fn apply_context_expansion( - &mut self, - direction: crate::events::ContextDirection, - hunk_index: usize, - amount: u32, - old_lines: Arc>, - new_lines: Arc>, - ) { - use crate::events::ContextDirection; - - let Some(( - active_index, - active_path, - mut carbon_file, - mut expansion, - mut carbon_overlays, - mut token_buffer, - )) = self.workspace.active_file.with(&self.store, |af| { - af.as_ref().map(|a| { - ( - a.index, - a.path.clone(), - (*a.carbon_file).clone(), - a.carbon_expansion.clone(), - a.carbon_overlays.clone(), - a.token_buffer.clone(), - ) - }) - }) - else { - return; - }; - - hydrate_carbon_full_text(&mut carbon_file, &old_lines, &new_lines); - match direction { - ContextDirection::Above => { - carbon::expand_context( - &carbon_file, - &mut expansion, - carbon::HunkId(hunk_index as u32), - carbon::ExpansionDirection::Above, - amount, - ); - } - ContextDirection::Below => { - carbon::expand_context( - &carbon_file, - &mut expansion, - carbon::HunkId(hunk_index as u32), - carbon::ExpansionDirection::Below, - amount, - ); - } - ContextDirection::All => { - let hunk_ids = carbon_file - .hunks - .iter() - .map(|hunk| hunk.id) - .collect::>(); - for hunk_id in hunk_ids { - let caps = carbon::expansion_caps(&carbon_file, hunk_id); - carbon::expand_context( - &carbon_file, - &mut expansion, - hunk_id, - carbon::ExpansionDirection::Above, - caps.above, - ); - carbon::expand_context( - &carbon_file, - &mut expansion, - hunk_id, - carbon::ExpansionDirection::Below, - caps.below, - ); - } - } - } - self.workspace.expansions.update(&self.store, |map| { - map.insert(active_path.clone(), expansion.clone()); - }); - - let preserve_change_tokens = carbon_overlays.has_change_tokens(); - carbon_overlays.clear_syntax(); - if !preserve_change_tokens { - token_buffer.clear(); - } - let render_doc = build_render_doc_from_carbon( - &carbon_file, - active_index, - &expansion, - &carbon_overlays, - &token_buffer, - ); - let total_lines = new_lines.len() as u32; - - let preserved_scroll = self.editor.scroll_top_px.get(&self.store); - - self.workspace.active_file.update(&self.store, |af| { - if let Some(active) = af.as_mut() { - active.carbon_file = Arc::new(carbon_file); - active.carbon_expansion = expansion; - active.carbon_overlays = carbon_overlays; - active.token_buffer = token_buffer; - active.render_doc = Arc::new(render_doc); - active.file_line_count = Some(total_lines); - active.old_file_lines = Some(old_lines); - active.file_lines = Some(new_lines); - active.syntax_pending.clear(); - active.syntax_covered.clear(); - } - }); - self.editor_clear_document(); - self.editor.scroll_top_px.set(&self.store, preserved_scroll); - } - - #[profiling::function] - fn handle_compare_finished(&mut self, payload: CompareFinished) -> Vec { - if payload.generation != self.workspace.compare_generation.get(&self.store) { - return Vec::new(); - } - - let history_left = payload.resolved_left.clone(); - let history_right = self - .vcs_ui_profile() - .history_right_ref(&payload.resolved_right); - self.workspace - .status_operation_pending - .set(&self.store, false); - self.workspace - .source - .set(&self.store, WorkspaceSource::Compare); - self.workspace.status.set(&self.store, AsyncStatus::Ready); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); - self.compare.layout.set(&self.store, payload.request.layout); - self.compare - .renderer - .set(&self.store, payload.request.renderer); - self.compare - .resolved_left - .set(&self.store, Some(payload.resolved_left)); - self.compare - .resolved_right - .set(&self.store, Some(payload.resolved_right)); - self.workspace - .raw_diff_len - .set(&self.store, payload.output.raw_diff_len); - self.workspace - .used_fallback - .set(&self.store, payload.output.used_fallback); - self.workspace - .fallback_message - .set(&self.store, payload.output.fallback_message.clone()); - let total_files = payload.output.file_count() as u32; - let stats_snapshot = compare_output_stats_snapshot(&payload.output); - let has_deferred_stats = stats_snapshot.deferred_count > 0; - let eager_total_stats = (!has_deferred_stats).then_some(stats_snapshot.hydrated_total); - self.workspace - .compare_output - .set(&self.store, Some(payload.output)); - self.workspace.files.set(&self.store, Vec::new()); - self.workspace - .compare_total_stats - .set(&self.store, eager_total_stats); - self.workspace.compare_hydrated_stats.set( - &self.store, - has_deferred_stats.then_some(stats_snapshot.hydrated_total), - ); - self.workspace - .compare_deferred_stats_remaining - .set(&self.store, Some(stats_snapshot.deferred_count)); - self.workspace - .compare_deferred_stats_cursor - .set(&self.store, 0); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); - self.workspace.active_file_loading.set(&self.store, None); - self.workspace.sidebar_auto_width.set(&self.store, None); - self.clear_file_cache(); - self.reset_file_scroll_layout(); - self.workspace.global_scroll_top_px.set(&self.store, 0); - // Record the discovered file count + advance the phase. The progress - // panel stays up until the first file finishes mounting (or, for - // small-file fast paths, is cleared by install_compare_active_file). - self.compare_progress.update(&self.store, |slot| { - if let Some(p) = slot.as_mut() { - let p = Arc::make_mut(p); - p.file_count_total = Some(total_files); - p.phase = ComparePhase::PopulatingList; - } - }); - if self - .workspace - .pre_drill_compare - .with(&self.store, |p| p.is_none()) - { - self.workspace - .range_commits - .set(&self.store, payload.range_commits); - } - self.file_list.scroll_offset_px.set(&self.store, 0.0); - self.file_list - .commits_scroll_offset_px - .set(&self.store, 0.0); - self.editor_clear_document(); - // Clear overlays before claiming focus so the overlay restore target - // does not clobber the file list focus below. - self.clear_overlays(); - self.set_focus(Some(FocusTarget::FileList)); - - let preferred_index = self - .startup - .preferred_file_index - .or(self.workspace.selected_file_index.get(&self.store)); - let preferred_path = self - .startup - .preferred_file_path - .clone() - .or_else(|| self.workspace.selected_file_path.get(&self.store)); - - let file_count = self.workspace_file_count(); - let index_for_path = preferred_path - .as_deref() - .and_then(|path| self.workspace_file_index_for_path(path)); - - let mut effects = Vec::new(); - let mut selected_syntax_paths = Vec::new(); - let should_load_history = self - .workspace - .pre_drill_compare - .with(&self.store, |p| p.is_none()); - let history_effect = should_load_history - .then(|| self.compare_history_request(history_left, history_right)) - .flatten() - .and_then(|request| { - if has_deferred_stats { - self.workspace - .compare_history_pending - .set(&self.store, Some(request)); - None - } else { - Some(self.compare_history_effect(request)) - } - }); - if let Some(index) = index_for_path - .or(preferred_index.filter(|index| *index < file_count)) - .or_else(|| (file_count > 0).then_some(0)) - { - if let Some(path) = self.workspace_file_path_at(index) { - selected_syntax_paths.push(path); - } - effects.extend(self.select_file(index, true)); - if let Some(effect) = self.start_compare_stats_hydration_if_idle() { - effects.push(effect); - } - if let Some(effect) = self.start_compare_total_stats_if_needed() { - effects.push(effect); - } - } else { - self.workspace.selected_file_index.set(&self.store, None); - self.workspace.selected_file_path.set(&self.store, None); - self.workspace.selected_change_bucket.set(&self.store, None); - self.workspace.active_file.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - // No files to select — the compare succeeded but has no diffs. - // Tear down the progress panel; the "repo ready" hint takes over. - self.compare_progress.set(&self.store, None); - self.editor_clear_document(); - } - if let Some(effect) = self.syntax_pack_warmup_effect_for_compare(&selected_syntax_paths) { - effects.insert(0, effect); - } - if let Some(effect) = history_effect { - effects.push(effect); - } - - let (used_fallback, fallback_message) = ( - self.workspace.used_fallback.get(&self.store), - self.workspace.fallback_message.get(&self.store), - ); - if used_fallback && !fallback_message.is_empty() { - self.push_info(&fallback_message); - } - effects - } - - fn handle_compare_history_ready(&mut self, payload: CompareHistoryReady) -> Vec { - if payload.generation != self.workspace.compare_generation.get(&self.store) { - return Vec::new(); - } - if self - .workspace - .pre_drill_compare - .with(&self.store, |p| p.is_some()) - { - return Vec::new(); - } - self.workspace - .range_commits - .set(&self.store, payload.range_commits); - Vec::new() - } - - fn handle_status_diff_finished(&mut self, payload: StatusDiffFinished) -> Vec { - let current_gen = self.workspace.status_generation.get(&self.store); - tracing::debug!( - payload_gen = payload.generation, - current_gen, - payload_index = payload.index, - payload_path = %payload.file_change.path, - payload_bucket = ?payload.file_change.bucket, - "handle_status_diff_finished: entered" - ); - if payload.generation != current_gen { - tracing::debug!( - "handle_status_diff_finished: generation mismatch, discarding (pending NOT cleared)" - ); - return Vec::new(); - } - let matches = self.repository.file_changes.with(&self.store, |changes| { - match changes.get(payload.index) { - Some(current) => current == &payload.file_change, - None => false, - } - }); - if !matches { - let current_change_at_idx = self.repository.file_changes.with(&self.store, |changes| { - changes - .get(payload.index) - .map(|change| format!("{}:{:?}", change.path, change.bucket)) - .unwrap_or_else(|| "".to_owned()) - }); - tracing::debug!( - current_change_at_idx, - "handle_status_diff_finished: file change mismatch, discarding (pending NOT cleared)" - ); - return Vec::new(); - } - let matches_selection = self.workspace.selected_file_index.get(&self.store) - == Some(payload.index) - && self - .workspace - .selected_file_path - .get(&self.store) - .as_deref() - == Some(payload.file_change.path.as_str()) - && self.workspace.selected_change_bucket.get(&self.store) - == Some(payload.file_change.bucket); - let output = payload.output; - - let Some(carbon_file) = output.carbon.files.first() else { - self.clear_file_cache_loading(payload.index); - if matches_selection { - self.workspace.active_file.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - self.editor_clear_document(); - } - return Vec::new(); - }; - let prepared = prepare_active_file(payload.index, carbon_file); - let bucket = payload.file_change.bucket; - let (left_ref, right_ref) = self.status_refs_for_bucket(bucket); - let active_file = self.build_active_file( - payload.index, - payload.file_change.path.clone(), - prepared, - left_ref, - right_ref, - ); - let active_file = self.cache_active_file(active_file); - if !matches_selection { - return Vec::new(); - } - - tracing::debug!("handle_status_diff_finished: clearing status_operation_pending"); - self.workspace - .source - .set(&self.store, WorkspaceSource::Status); - self.workspace - .status_operation_pending - .set(&self.store, false); - self.workspace.status.set(&self.store, AsyncStatus::Ready); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); - self.workspace - .used_fallback - .set(&self.store, output.used_fallback); - self.workspace - .fallback_message - .set(&self.store, output.fallback_message.clone()); - self.workspace - .raw_diff_len - .set(&self.store, output.raw_diff_len); - self.workspace.compare_output.set(&self.store, None); - self.workspace.compare_total_stats.set(&self.store, None); - self.workspace.compare_hydrated_stats.set(&self.store, None); - self.workspace - .compare_deferred_stats_remaining - .set(&self.store, None); - self.workspace - .compare_deferred_stats_cursor - .set(&self.store, 0); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); - self.workspace.active_file_loading.set(&self.store, None); - - self.workspace - .selected_file_index - .set(&self.store, Some(payload.index)); - self.workspace - .selected_file_path - .set(&self.store, Some(payload.file_change.path.clone())); - self.workspace - .selected_change_bucket - .set(&self.store, Some(bucket)); - // Preserve scroll/hover/positional editor state when refreshing the - // same file (e.g. after staging a hunk). Only reset when the path - // changed (navigating to a different file). - let same_file = self.workspace.active_file.with(&self.store, |af| { - af.as_ref().is_some_and(|a| { - a.path == payload.file_change.path - && a.left_ref == active_file.left_ref - && a.right_ref == active_file.right_ref - }) - }); - self.workspace - .active_file - .set(&self.store, Some(active_file)); - if !same_file { - self.editor_clear_document(); - self.editor - .line_selection - .update(&self.store, |ls| ls.clear()); - } - if self.editor.search.open.get(&self.store) { - self.recompute_search_matches(); - } - let mut effects = self.sync_editor_scroll_from_global(); - effects.extend(self.request_active_file_syntax_effect()); - effects - } - - #[profiling::function] - fn handle_compare_file_finished(&mut self, payload: CompareFileFinished) -> Vec { - if payload.generation != self.workspace.compare_generation.get(&self.store) { - return Vec::new(); - } - - let matches_selected = self - .workspace - .selected_file_path - .get(&self.store) - .as_deref() - == Some(payload.path.as_str()); - let matches_loading = self - .workspace - .active_file_loading - .with(&self.store, |loading| { - loading.as_ref().is_some_and(|loading| { - loading.index == payload.index && loading.path == payload.path - }) - }); - let matches_cache_loading = - self.workspace - .file_cache_loading - .with(&self.store, |loading| { - loading - .get(&payload.index) - .is_some_and(|loading| loading.path == payload.path) - }); - if !matches_selected && !matches_cache_loading { - return Vec::new(); - } - - if matches_selected && matches_loading { - self.install_compare_active_file(payload.index, payload.path, payload.prepared); - } else { - let left_ref = self - .compare - .resolved_left - .get(&self.store) - .unwrap_or_else(|| self.compare.left_ref.get(&self.store)); - let right_ref = self - .compare - .resolved_right - .get(&self.store) - .unwrap_or_else(|| self.compare.right_ref.get(&self.store)); - let active_file = self.build_active_file( - payload.index, - payload.path, - payload.prepared, - left_ref, - right_ref, - ); - self.cache_active_file(active_file); - } - let mut effects = self.sync_editor_scroll_from_global(); - if matches_selected { - effects.extend(self.request_active_file_syntax_effect()); - } - if let Some(effect) = self.start_compare_stats_hydration_if_idle() { - effects.push(effect); - } else if let Some(effect) = self.start_compare_total_stats_if_needed() { - effects.push(effect); - } - effects - } - - fn handle_compare_stats_ready(&mut self, payload: CompareStatsReady) -> Vec { - if payload.generation != self.workspace.compare_generation.get(&self.store) { - return Vec::new(); - } - - self.workspace - .compare_total_stats - .set(&self.store, Some((payload.additions, payload.deletions))); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - let mut effects = Vec::new(); - if let Some(effect) = self.start_compare_stats_hydration_if_idle() { - let is_background_stats = matches!( - &effect, - Effect::Compare(CompareEffect::LoadFileStats(task)) - if task.request.priority == CompareWorkPriority::Warmup - ); - effects.push(effect); - if is_background_stats && let Some(effect) = self.take_pending_compare_history_effect() - { - effects.push(effect); - } - } else if !self.compare_stats_hydration_running() - && let Some(effect) = self.take_pending_compare_history_effect() - { - effects.push(effect); - } - effects - } - - fn handle_compare_file_stats_ready(&mut self, payload: CompareFileStatsReady) -> Vec { - if payload.generation != self.workspace.compare_generation.get(&self.store) { - return Vec::new(); - } - - self.apply_compare_file_stats(&payload.stats); - let mut effects = self.sync_editor_scroll_from_global(); - if !payload.request_complete { - return effects; - } - if let Some(effect) = self.next_compare_stats_hydration_effect() { - effects.push(effect); - effects - } else { - self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); - let history_effect = self.take_pending_compare_history_effect(); - if let Some(stats) = self.exact_compare_total_stats_if_ready() { - if !self.workspace.compare_total_stats_loading.get(&self.store) { - self.workspace - .compare_total_stats - .set(&self.store, Some(stats)); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - } - if let Some(effect) = history_effect { - effects.push(effect); - } - return effects; - } - if let Some(effect) = self.start_compare_total_stats_if_needed() { - effects.push(effect); - } - if let Some(effect) = history_effect { - effects.push(effect); - } - effects - } - } - - fn compare_stats_hydration_running(&self) -> bool { - self.workspace.compare_stats_hydration.get(&self.store) - == CompareStatsHydrationState::Running - } - - fn compare_stats_hydration_failed(&self) -> bool { - self.workspace.compare_stats_hydration.get(&self.store) - == CompareStatsHydrationState::Failed - } - - fn set_compare_stats_hydration(&self, state: CompareStatsHydrationState) { - self.workspace - .compare_stats_hydration - .set(&self.store, state); - } - - fn start_compare_stats_hydration_if_idle(&mut self) -> Option { - if self.compare_stats_hydration_running() || self.compare_stats_hydration_failed() { - return None; - } - - let effect = self.next_compare_stats_hydration_effect()?; - self.set_compare_stats_hydration(CompareStatsHydrationState::Running); - Some(effect) - } - - fn start_visible_compare_stats_hydration(&mut self) -> Option { - if self.compare_stats_hydration_failed() { - return None; - } - let prioritize_visible = self - .workspace - .compare_output - .with(&self.store, |maybe_output| { - maybe_output.as_ref().is_some_and(|output| { - output.file_count() > COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT - }) - }); - if !prioritize_visible { - return self.start_compare_stats_hydration_if_idle(); - } - let visible_files = self.visible_compare_stats_hydration_items(); - if visible_files.is_empty() { - return self.start_compare_stats_hydration_if_idle(); - } - let effect = self.compare_file_stats_hydration_effect( - visible_files, - CompareWorkPriority::VisibleSidebarStats, - )?; - self.set_compare_stats_hydration(CompareStatsHydrationState::Running); - Some(effect) - } - - fn start_compare_total_stats_if_needed(&mut self) -> Option { - if self - .workspace - .compare_total_stats - .get(&self.store) - .is_some() - || self.workspace.compare_total_stats_loading.get(&self.store) - { - return None; - } - let repo_path = self.compare.repo_path.get(&self.store)?; - self.workspace - .compare_total_stats_loading - .set(&self.store, true); - - Some( - CompareEffect::LoadStats(Task { - generation: self.workspace.compare_generation.get(&self.store), - request: CompareStatsRequest { - repo_path, - request: vcs_compare_request( - self.compare.mode.get(&self.store), - self.compare.left_ref.get(&self.store), - self.compare.right_ref.get(&self.store), - self.compare.layout.get(&self.store), - self.compare.renderer.get(&self.store), - ), - priority: CompareWorkPriority::TotalStats, - }, - }) - .into(), - ) - } - - fn next_compare_stats_hydration_effect(&self) -> Option { - let prioritize_visible = self - .workspace - .compare_output - .with(&self.store, |maybe_output| { - maybe_output.as_ref().is_some_and(|output| { - output.file_count() > COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT - }) - }); - let (files, priority) = if prioritize_visible { - let visible_files = self.visible_compare_stats_hydration_items(); - if !visible_files.is_empty() { - (visible_files, CompareWorkPriority::VisibleSidebarStats) - } else { - ( - self.next_deferred_compare_stats_items(COMPARE_STATS_BACKGROUND_CHUNK_SIZE), - CompareWorkPriority::Warmup, - ) - } - } else { - ( - self.next_deferred_compare_stats_items(COMPARE_STATS_BACKGROUND_CHUNK_SIZE), - CompareWorkPriority::Warmup, - ) - }; - if files.is_empty() { - return None; - } - - self.compare_file_stats_hydration_effect(files, priority) - } - - fn compare_file_stats_hydration_effect( - &self, - files: Vec, - priority: CompareWorkPriority, - ) -> Option { - if files.is_empty() { - return None; - } - let repo_path = self.compare.repo_path.get(&self.store)?; - Some( - CompareEffect::LoadFileStats(Task { - generation: self.workspace.compare_generation.get(&self.store), - request: CompareFileStatsRequest { - repo_path, - request: vcs_compare_request( - self.compare.mode.get(&self.store), - self.compare.left_ref.get(&self.store), - self.compare.right_ref.get(&self.store), - self.compare.layout.get(&self.store), - self.compare.renderer.get(&self.store), - ), - files, - priority, - }, - }) - .into(), - ) - } - - fn compare_history_request( - &self, - left_ref: String, - right_ref: String, - ) -> Option { - Some(CompareHistoryRequest { - repo_path: self.compare.repo_path.get(&self.store)?, - left_ref, - right_ref, - }) - } - - fn compare_history_effect(&self, request: CompareHistoryRequest) -> Effect { - CompareEffect::LoadHistory(Task { - generation: self.workspace.compare_generation.get(&self.store), - request, - }) - .into() - } - - fn take_pending_compare_history_effect(&mut self) -> Option { - if self - .workspace - .pre_drill_compare - .with(&self.store, |p| p.is_some()) - { - self.workspace - .compare_history_pending - .set(&self.store, None); - return None; - } - let pending = self.workspace.compare_history_pending.get(&self.store)?; - self.workspace - .compare_history_pending - .set(&self.store, None); - Some(self.compare_history_effect(pending)) - } - - fn next_deferred_compare_stats_items(&self, limit: usize) -> Vec { - if limit == 0 - || self - .workspace - .compare_deferred_stats_remaining - .get(&self.store) - == Some(0) - { - return Vec::new(); - } - - let cursor = self - .workspace - .compare_deferred_stats_cursor - .get(&self.store); - let (items, next_cursor) = - self.workspace - .compare_output - .with(&self.store, |maybe_output| { - let Some(output) = maybe_output.as_ref() else { - return (Vec::new(), None); - }; - let file_count = output.file_count(); - if file_count == 0 { - return (Vec::new(), None); - } - let mut items = Vec::new(); - let mut index = cursor.min(file_count - 1); - let mut scanned = 0_usize; - while scanned < file_count && items.len() < limit { - if let Some(target) = output.deferred_stats_target_at(index) { - items.push(CompareFileStatsItem { index, target }); - } - index = if index + 1 == file_count { - 0 - } else { - index + 1 - }; - scanned += 1; - } - (items, Some(index)) - }); - if let Some(next_cursor) = next_cursor { - self.workspace - .compare_deferred_stats_cursor - .set(&self.store, next_cursor); - } - items - } - - fn visible_compare_stats_hydration_items(&self) -> Vec { - if self.workspace.source.get(&self.store) != WorkspaceSource::Compare - || self.file_list.tab.get(&self.store) != SidebarTab::Files - { - return Vec::new(); - } - - let stride = self.file_list_row_stride(); - if stride <= 0.0 { - return Vec::new(); - } - let scroll_px = self.file_list.scroll_offset_px.get(&self.store); - let viewport_px = self.file_list.viewport_height.get(&self.store); - let first = (scroll_px / stride).floor().max(0.0) as usize; - let visible = (viewport_px / stride).ceil().max(1.0) as usize; - let start = first.saturating_sub(COMPARE_STATS_VISIBLE_OVERSCAN_ROWS); - let end = first - .saturating_add(visible) - .saturating_add(COMPARE_STATS_VISIBLE_OVERSCAN_ROWS); - - let filter = self - .file_list - .filter - .with(&self.store, |filter| filter.clone()); - if !filter.is_empty() { - let filtered_indices = self.workspace_file_filter_matches(&filter); - let end = end.min(filtered_indices.len()); - if start >= end { - return Vec::new(); - } - return self.compare_stats_hydration_items_for_indices( - filtered_indices[start..end].iter().copied(), - ); - } - - if self.file_list.mode.get(&self.store) == SidebarMode::TreeView { - let expanded_folders = self.file_list.expanded_folders.get(&self.store); - let tree_indices = crate::ui::components::file_tree_visible_file_indices_by( - |visit| { - self.for_each_workspace_file_path(|index, path| visit(index, path)); - }, - &expanded_folders, - start..end, - ); - return self.compare_stats_hydration_items_for_indices(tree_indices); - } - - let end = end.min(self.workspace_file_count()); - if start >= end { - return Vec::new(); - } - self.compare_stats_hydration_items_for_indices(start..end) - } - - fn compare_stats_hydration_items_for_indices( - &self, - indices: impl IntoIterator, - ) -> Vec { - self.workspace - .compare_output - .with(&self.store, |maybe_output| { - let Some(output) = maybe_output.as_ref() else { - return Vec::new(); - }; - let mut items = Vec::new(); - for index in indices { - if items.len() >= COMPARE_STATS_CHUNK_SIZE { - break; - } - if let Some(target) = output.deferred_stats_target_at(index) { - items.push(CompareFileStatsItem { index, target }); - } - } - items - }) - } - - fn exact_compare_total_stats_if_ready(&self) -> Option<(i32, i32)> { - if let Some(remaining) = self - .workspace - .compare_deferred_stats_remaining - .get(&self.store) - { - if remaining > 0 { - return None; - } - if let Some(total) = self.workspace.compare_hydrated_stats.get(&self.store) { - return Some(total); - } - } - - let ready = self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .is_some_and(|output| !compare_output_has_deferred_stats(output)) - }); - if !ready { - return None; - } - self.workspace.compare_output.with(&self.store, |output| { - let output = output.as_ref()?; - let mut total = (0_i32, 0_i32); - output.for_each_summary(|_, summary| { - let stats = summary.fallback_stats(); - total = ( - total.0.saturating_add(stats.0), - total.1.saturating_add(stats.1), - ); - }); - Some(total) - }) - } - - fn apply_compare_file_stats(&mut self, stats: &[CompareFileStat]) { - if stats.is_empty() { - return; - } - - let old_scroll_heights = stats - .iter() - .map(|stat| (stat.index, self.file_scroll_height_px(stat.index))) - .collect::>(); - - let mut stats_delta = (0_i32, 0_i32); - let mut newly_hydrated = 0_usize; - self.workspace - .compare_output - .update(&self.store, |maybe_output| { - let Some(output) = maybe_output.as_mut() else { - return; - }; - for stat in stats { - let additions = i32_to_u32_nonnegative(stat.additions); - let deletions = i32_to_u32_nonnegative(stat.deletions); - - if !output.file_summaries.is_empty() { - let Some(summary) = output.file_summaries.get_mut(stat.index) else { - continue; - }; - if summary.path() != stat.path { - continue; - } - let old_stats = summary.fallback_stats(); - let was_deferred = summary.stats_deferred; - summary.additions = additions; - summary.deletions = deletions; - summary.stats_deferred = false; - stats_delta = ( - stats_delta - .0 - .saturating_add(stat.additions.saturating_sub(old_stats.0)), - stats_delta - .1 - .saturating_add(stat.deletions.saturating_sub(old_stats.1)), - ); - newly_hydrated = newly_hydrated.saturating_add(was_deferred as usize); - continue; - } - - if let Some(file) = output.carbon.files.get_mut(stat.index) - && file.path() == stat.path - { - let old_stats = carbon_file_stats(file); - let was_deferred = file.stats_deferred; - file.additions = additions; - file.deletions = deletions; - file.stats_deferred = false; - stats_delta = ( - stats_delta - .0 - .saturating_add(stat.additions.saturating_sub(old_stats.0)), - stats_delta - .1 - .saturating_add(stat.deletions.saturating_sub(old_stats.1)), - ); - newly_hydrated = newly_hydrated.saturating_add(was_deferred as usize); - } - } - }); - - if stats_delta != (0, 0) { - self.workspace - .compare_hydrated_stats - .update(&self.store, |total| { - let current = total.get_or_insert((0, 0)); - *current = ( - current.0.saturating_add(stats_delta.0), - current.1.saturating_add(stats_delta.1), - ); - }); - } - if newly_hydrated > 0 { - self.workspace - .compare_deferred_stats_remaining - .update(&self.store, |remaining| { - if let Some(count) = remaining.as_mut() { - *count = count.saturating_sub(newly_hydrated); - } - }); - } - - let mut rebuilt_viewport_doc = false; - self.workspace.active_file.update(&self.store, |slot| { - let Some(active) = slot.as_mut() else { - return; - }; - for stat in stats { - if apply_compare_stat_to_active_file(active, stat) { - rebuilt_viewport_doc = true; - break; - } - } - }); - self.workspace.file_cache.update(&self.store, |files| { - for active in files.values_mut() { - for stat in stats { - if apply_compare_stat_to_active_file(active, stat) { - rebuilt_viewport_doc = true; - break; - } - } - } - }); - if rebuilt_viewport_doc { - self.viewport_document_cache = None; - } - - let dragging_scrollbar = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| drag.is_some()); - if dragging_scrollbar { - self.workspace - .file_scroll_recompute_pending - .set(&self.store, true); - } else { - self.update_file_scroll_heights(old_scroll_heights); - if self.settings.continuous_scroll { - self.clamp_global_scroll_top_px(); - } - } - } - - fn handle_file_syntax_ready(&mut self, payload: FileSyntaxReady) -> Vec { - self.finish_syntax_request(payload.generation, payload.request_id); - if payload.generation != self.active_syntax_generation() { - return Vec::new(); - } - - let mut applied_file = None; - let mut applied_active = false; - let mut matched_active = false; - self.workspace.active_file.update(&self.store, |slot| { - let Some(active) = slot.as_mut() else { - return; - }; - if active.index != payload.file_index || active.path != payload.path { - return; - } - matched_active = true; - - if !remove_pending_syntax_window( - &mut active.syntax_pending, - payload.request_id, - payload.window, - ) { - return; - } - if active - .syntax_covered - .iter() - .any(|covered| covered.contains(payload.window)) - { - return; - } - push_syntax_covered_window(&mut active.syntax_covered, payload.window); - apply_syntax_tokens_to_file( - &mut active.carbon_overlays, - &mut active.token_buffer, - &payload.tokens, - ); - active.render_doc = Arc::new(build_render_doc_from_carbon( - &active.carbon_file, - active.index, - &active.carbon_expansion, - &active.carbon_overlays, - &active.token_buffer, - )); - applied_file = Some(active.clone()); - applied_active = true; - }); - if matched_active && applied_file.is_none() { - tracing::debug!( - file_index = payload.file_index, - path = %payload.path, - request_id = payload.request_id, - "stale active syntax response dropped" - ); - return Vec::new(); - } - - if applied_file.is_none() { - self.workspace.file_cache.update(&self.store, |files| { - let Some(active) = files.get_mut(&payload.file_index) else { - return; - }; - if active.index != payload.file_index || active.path != payload.path { - return; - } - - if !remove_pending_syntax_window( - &mut active.syntax_pending, - payload.request_id, - payload.window, - ) { - return; - } - if active - .syntax_covered - .iter() - .any(|covered| covered.contains(payload.window)) - { - return; - } - push_syntax_covered_window(&mut active.syntax_covered, payload.window); - apply_syntax_tokens_to_file( - &mut active.carbon_overlays, - &mut active.token_buffer, - &payload.tokens, - ); - active.render_doc = Arc::new(build_render_doc_from_carbon( - &active.carbon_file, - active.index, - &active.carbon_expansion, - &active.carbon_overlays, - &active.token_buffer, - )); - applied_file = Some(active.clone()); - }); - } - - let Some(active_file) = applied_file else { - return Vec::new(); - }; - self.cache_active_file(active_file); - self.viewport_document_cache = None; - - if applied_active { - self.request_active_file_syntax_effect() - .into_iter() - .collect() - } else { - Vec::new() - } - } - - fn handle_syntax_pack_install_started(&mut self, language: &str) { - self.syntax_pack_installs.update(&self.store, |active| { - if !active.iter().any(|item| item == language) { - active.push(language.to_owned()); - } - }); - } - - fn handle_syntax_pack_install_finished(&mut self, language: &str) { - self.syntax_pack_installs - .update(&self.store, |active| active.retain(|item| item != language)); - } - - pub fn syntax_pack_install_active(&self) -> bool { - self.syntax_pack_installs - .with(&self.store, |active| !active.is_empty()) - } - - fn syntax_pack_warmup_effect_for_compare(&self, exclude_paths: &[String]) -> Option { - let highlighter = phosphor::Highlighter::new(); - let excluded_languages = exclude_paths - .iter() - .filter_map(|path| highlighter.guess_language(Path::new(path))) - .collect::>(); - let active_languages = self.syntax_pack_installs.with(&self.store, |active| { - active.iter().cloned().collect::>() - }); - - self.workspace.compare_output.with(&self.store, |output| { - let output = output.as_ref()?; - let mut seen = HashSet::new(); - let mut warmup_paths = Vec::new(); - output.for_each_summary(|_, summary| { - for path in [summary.paths.old_path(), summary.paths.new_path()] - .into_iter() - .flatten() - { - let Some(language) = highlighter.guess_language(Path::new(path.as_ref())) - else { - continue; - }; - if excluded_languages.contains(&language) - || active_languages.contains(language.name()) - || highlighter.is_parser_available(language) - { - continue; - } - if seen.insert(language) { - warmup_paths.push(path.into_owned()); - } - } - }); - - (!warmup_paths.is_empty()).then_some( - SyntaxEffect::EnsureSyntaxPacksForPaths { - paths: warmup_paths, - } - .into(), - ) - }) - } - - fn syntax_pack_warmup_effect_for_paths( - &self, - paths: &[String], - exclude_paths: &[String], - ) -> Option { - let highlighter = phosphor::Highlighter::new(); - let excluded_languages = exclude_paths - .iter() - .filter_map(|path| highlighter.guess_language(Path::new(path))) - .collect::>(); - let active_languages = self.syntax_pack_installs.with(&self.store, |active| { - active.iter().cloned().collect::>() - }); - - let mut seen = HashSet::new(); - let mut warmup_paths = Vec::new(); - for path in paths { - let Some(language) = highlighter.guess_language(Path::new(path)) else { - continue; - }; - if excluded_languages.contains(&language) - || active_languages.contains(language.name()) - || highlighter.is_parser_available(language) - { - continue; - } - if seen.insert(language) { - warmup_paths.push(path.clone()); - } - } - - (!warmup_paths.is_empty()).then_some( - SyntaxEffect::EnsureSyntaxPacksForPaths { - paths: warmup_paths, - } - .into(), - ) - } - - fn handle_syntax_packs_installed(&mut self, languages: &[String]) -> Vec { - if languages.is_empty() { - return Vec::new(); - } - let mut effects = vec![self.invalidate_syntax_epoch_effect()]; - for language in languages { - effects.extend(self.refresh_active_file_syntax_for_language(language)); - effects.extend(self.request_cached_file_syntax_effects_for_language(language)); - } - effects - } - - fn refresh_active_file_syntax_for_language(&mut self, language: &str) -> Vec { - let highlighter = Highlighter::new(); - let mut refreshed = false; - self.workspace.active_file.update(&self.store, |slot| { - let Some(active) = slot.as_mut() else { - return; - }; - if !active_file_matches_language(active, &highlighter, language) { - return; - } - reset_active_file_syntax(active); - refreshed = true; - }); - if !refreshed { - return Vec::new(); - } - self.viewport_document_cache = None; - self.request_active_file_syntax_effect() - .into_iter() - .collect() - } - - fn request_cached_file_syntax_effects_for_language(&mut self, language: &str) -> Vec { - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - let generation = self.active_syntax_generation(); - let syntax_epoch = self.syntax_requests.epoch(); - let mut remaining_budget = - MAX_PENDING_SYNTAX_WINDOWS.saturating_sub(self.syntax_outstanding_window_count()); - if remaining_budget == 0 { - return Vec::new(); - } - let active_key = self.workspace.active_file.with(&self.store, |active| { - active.as_ref().map(ActiveFile::working_set_key) - }); - let highlighter = Highlighter::new(); - let mut requests = Vec::new(); - let mut next_request_id = self.syntax_requests.last_request_id(); - - self.workspace.file_cache.update(&self.store, |files| { - for active in files.values_mut() { - if remaining_budget == 0 { - break; - } - if active_key - .as_ref() - .is_some_and(|key| key == &active.working_set_key()) - { - continue; - } - if !active_file_matches_language(active, &highlighter, language) { - continue; - } - let line_count = active.render_doc.lines.len(); - if line_count == 0 { - continue; - } - reset_active_file_syntax(active); - let window = SyntaxRowWindow { - start: 0, - end: line_count.min(SYNTAX_INITIAL_ROWS), - }; - next_request_id = next_request_id.saturating_add(1); - if let Some(request) = request_syntax_for_active_file( - active, - repo_path.clone(), - generation, - syntax_epoch, - window, - next_request_id, - ) { - requests.push(request); - remaining_budget = remaining_budget.saturating_sub(1); - } - } - }); - self.syntax_requests.set_last_request_id(next_request_id); - - requests - .into_iter() - .map(|request| { - self.track_syntax_request(&request); - SyntaxEffect::LoadFileSyntax(Task { - generation, - request, - }) - .into() - }) - .collect() - } - - fn activate_status_view(&mut self, reset_scroll: bool) -> Vec { - tracing::debug!( - reset_scroll, - pending = self.workspace.status_operation_pending.get(&self.store), - status_gen = self.workspace.status_generation.get(&self.store), - status_file_changes_count = self - .workspace - .status_file_changes - .with(&self.store, |i| i.len()), - "activate_status_view: entered" - ); - self.workspace - .source - .set(&self.store, WorkspaceSource::Status); - self.workspace.status.set(&self.store, AsyncStatus::Ready); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); - self.workspace.compare_output.set(&self.store, None); - self.workspace.compare_total_stats.set(&self.store, None); - self.workspace.compare_hydrated_stats.set(&self.store, None); - self.workspace - .compare_deferred_stats_remaining - .set(&self.store, None); - self.workspace - .compare_deferred_stats_cursor - .set(&self.store, 0); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); - self.workspace.active_file_loading.set(&self.store, None); - let new_files = self - .workspace - .status_file_changes - .with(&self.store, |changes| build_status_file_entries(changes)); - self.workspace.files.set(&self.store, new_files); - let next_status_gen = self - .workspace - .status_generation - .get(&self.store) - .saturating_add(1); - self.workspace - .status_generation - .set(&self.store, next_status_gen); - let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); - self.clear_file_cache(); - self.workspace.sidebar_auto_width.set(&self.store, None); - self.workspace.used_fallback.set(&self.store, false); - self.workspace - .fallback_message - .set(&self.store, String::new()); - self.workspace.raw_diff_len.set(&self.store, 0); - self.reset_file_scroll_layout(); - if reset_scroll { - self.file_list.scroll_offset_px.set(&self.store, 0.0); - self.workspace.global_scroll_top_px.set(&self.store, 0); - } else if self.settings.continuous_scroll { - self.clamp_global_scroll_top_px(); - } - - let current_path = self.workspace.selected_file_path.get(&self.store); - let current_bucket = self.workspace.selected_change_bucket.get(&self.store); - let (status_syntax_paths, selected_index, selected_syntax_paths) = self - .workspace - .status_file_changes - .with(&self.store, |changes| { - let paths = changes - .iter() - .flat_map(file_change_syntax_paths) - .collect::>(); - let selected_index = - if let Some((path, bucket)) = current_path.clone().zip(current_bucket) { - if let Some(idx) = changes - .iter() - .position(|change| change.path == path && change.bucket == bucket) - { - Some(idx) - } else { - None - } - } else if let Some(path) = current_path.as_deref() { - if let Some(idx) = changes.iter().position(|change| change.path == path) { - Some(idx) - } else { - None - } - } else { - None - } - .or_else(|| (!changes.is_empty()).then_some(0)); - let selected_paths = selected_index - .and_then(|index| changes.get(index)) - .map(file_change_syntax_paths) - .unwrap_or_default(); - (paths, selected_index, selected_paths) - }); - - tracing::debug!( - ?selected_index, - "activate_status_view: resolved selected_index" - ); - match selected_index { - Some(index) => { - let mut effects = self.select_status_item(index, false); - effects.insert(0, syntax_epoch_effect); - if let Some(effect) = self.syntax_pack_warmup_effect_for_paths( - &status_syntax_paths, - &selected_syntax_paths, - ) { - effects.insert(0, effect); - } - effects - } - None => { - tracing::debug!("activate_status_view: no selection, clearing pending"); - self.workspace - .status_operation_pending - .set(&self.store, false); - self.workspace.selected_file_index.set(&self.store, None); - self.workspace.selected_file_path.set(&self.store, None); - self.workspace.selected_change_bucket.set(&self.store, None); - self.workspace.active_file.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - self.editor_clear_document(); - vec![syntax_epoch_effect] - } - } - } - - fn kickoff_compare(&mut self) -> Vec { - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - self.push_error("Open a repository before starting a compare."); - return Vec::new(); - }; - - let mode = self.compare.mode.get(&self.store); - let left_ref = self.compare.left_ref.get(&self.store); - let right_ref = self.compare.right_ref.get(&self.store); - if !compare_refs_are_valid(mode, &left_ref, &right_ref) { - self.push_error("Provide the required refs for the selected mode."); - return Vec::new(); - } - - let active_pr = self.github.pull_request.active.get(&self.store); - let active_pr_still_matches = active_pr.as_ref().is_some_and(|key| { - self.github.pull_request.cache.with(&self.store, |cache| { - matches!( - cache.get(key).map(|entry| &entry.diff), - Some(PrPeekDiff::Ready { - left_ref: pr_left, - right_ref: pr_right, - .. - }) if pr_left == &left_ref && pr_right == &right_ref - ) - }) - }); - if !active_pr_still_matches { - self.github.pull_request.active.set(&self.store, None); - self.github - .pull_request - .review_composer - .set(&self.store, ReviewCommentComposerState::default()); - self.review_comment_editor.request_clear(); - } - - self.workspace - .source - .set(&self.store, WorkspaceSource::Compare); - let next_gen = self - .workspace - .compare_generation - .get(&self.store) - .saturating_add(1); - self.workspace.compare_generation.set(&self.store, next_gen); - let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); - self.workspace.compare_total_stats.set(&self.store, None); - self.workspace.compare_hydrated_stats.set(&self.store, None); - self.workspace - .compare_deferred_stats_remaining - .set(&self.store, None); - self.workspace - .compare_deferred_stats_cursor - .set(&self.store, 0); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); - self.workspace.expansions.update(&self.store, |m| m.clear()); - self.clear_overlays(); - self.sync_settings_snapshot(); - - let started_at_ms = self.clock_ms; - let reveal_at_ms = started_at_ms; - let has_prior_state = self.workspace_file_count() > 0 - || self - .workspace - .active_file - .with(&self.store, |af| af.is_some()); - - if !has_prior_state { - self.workspace_mode.set(&self.store, WorkspaceMode::Loading); - self.workspace.status.set(&self.store, AsyncStatus::Loading); - } - - let profile = self.vcs_ui_profile(); - let left_label = profile.compare_ref_display_label(&left_ref); - let right_label = profile.compare_ref_display_label(&right_ref); - self.compare_progress.set( - &self.store, - Some(Arc::new(CompareProgress { - generation: next_gen, - phase: ComparePhase::OpeningRepo, - subject: LoadingSubject::Compare { - left_label, - right_label, - }, - started_at_ms, - reveal_at_ms, - file_count_total: None, - files_loaded: 0, - })), - ); - - let renderer = self.compare.renderer.get(&self.store); - let layout = self.compare.layout.get(&self.store); - vec![ - syntax_epoch_effect, - SettingsEffect::SaveSettings(self.settings.clone()).into(), - CompareEffect::Run(Task { - generation: next_gen, - request: CompareRequest { - repo_path, - request: vcs_compare_request(mode, left_ref, right_ref, layout, renderer), - github_token: self.github_access_token.clone(), - }, - }) - .into(), - ] - } - - /// Soft-cancel an in-flight compare. Bumps the generation so any - /// result that eventually arrives is dropped by the guard, clears the - /// progress panel, and returns the viewport to the default empty state. - /// We do not attempt to interrupt backend work mid-flight; stale-result - /// guards keep late answers from mutating newer state. - fn cancel_compare(&mut self) -> Vec { - if self.compare_progress.with(&self.store, |p| p.is_none()) { - return Vec::new(); - } - let next_gen = self - .workspace - .compare_generation - .get(&self.store) - .saturating_add(1); - self.workspace.compare_generation.set(&self.store, next_gen); - let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); - self.compare_progress.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - // Only revert the workspace mode if kickoff flipped it to Loading - // (i.e. no prior state was preserved). When the user cancels a - // re-compare, the old diff is still mounted and should stay visible. - if self.workspace_mode.get(&self.store) == WorkspaceMode::Loading { - self.workspace_mode.set(&self.store, WorkspaceMode::Empty); - self.workspace.status.set(&self.store, AsyncStatus::Idle); - } - vec![syntax_epoch_effect] - } - - fn handle_compare_progress_update(&mut self, generation: u64, phase: ComparePhase) { - // Only apply when the progress slot matches the reporter's - // generation — stale workers silently lose their updates. - self.compare_progress.update(&self.store, |slot| { - if let Some(p) = slot.as_mut() - && p.generation == generation - { - let p = Arc::make_mut(p); - // Pull counts out of LoadingFiles so the determinate bar - // reads directly from durable struct fields (cheaper than - // pattern-matching in the render path, and lets the total - // survive the phase transition to PopulatingList). - if let ComparePhase::LoadingFiles { - files_seen, - files_total, - } = phase - { - p.files_loaded = files_seen; - if files_total > 0 { - p.file_count_total = Some(files_total); - } - } - p.phase = phase; - } - }); - } - - fn show_working_tree(&mut self) -> Vec { - let (left, right, mode) = self.vcs_ui_profile().working_copy_compare(); - self.compare.left_ref.set(&self.store, left.to_owned()); - self.compare.right_ref.set(&self.store, right.to_owned()); - self.compare.mode.set(&self.store, mode); - let mut effects = self.persist_settings_effect(); - effects.extend(self.activate_status_view(true)); - effects - } - - fn preview_pull_request(&mut self) -> Vec { - let profile = self.vcs_ui_profile(); - if !profile.accepts_compare_mode(CompareMode::ThreeDot) - || self.repository.location.with(&self.store, |location| { - !location - .as_ref() - .is_some_and(|location| location.profile == VCS_PROFILE_GIT) - }) - { - self.push_error("PR preview is only available for Git repositories."); - return Vec::new(); - } - let Some(base_ref) = self.default_pull_request_base_ref() else { - self.push_error("No default branch found for PR preview."); - return Vec::new(); - }; - let (_, workdir_ref, _) = profile.working_copy_compare(); - self.workspace.pre_drill_compare.set(&self.store, None); - self.compare.left_ref.set(&self.store, base_ref); - self.compare - .right_ref - .set(&self.store, workdir_ref.to_owned()); - self.compare.resolved_left.set(&self.store, None); - self.compare.resolved_right.set(&self.store, None); - self.compare.mode.set(&self.store, CompareMode::ThreeDot); - let mut effects = self.persist_settings_effect(); - effects.extend(self.kickoff_compare()); - effects - } - - fn default_pull_request_base_ref(&self) -> Option { - let refs = self.repository.refs.get(&self.store); - let active = refs - .iter() - .find(|reference| reference.active && reference.kind == RefKind::Branch) - .map(|reference| reference.name.as_str()); - let branch_ref = |name: &str| { - refs.iter() - .find(|reference| { - reference.name == name - && active != Some(reference.name.as_str()) - && matches!(reference.kind, RefKind::Branch | RefKind::RemoteBranch) - }) - .map(|reference| reference.name.clone()) - }; - for name in [ - "origin/main", - "origin/master", - "upstream/main", - "upstream/master", - "origin/develop", - "origin/development", - "main", - "master", - "develop", - "development", - ] { - if let Some(reference) = branch_ref(name) { - return Some(reference); - } - } - for trunk in ["main", "master", "develop", "development"] { - let suffix = format!("/{trunk}"); - if let Some(reference) = refs - .iter() - .find(|reference| { - reference.name.ends_with(&suffix) - && active != Some(reference.name.as_str()) - && reference.kind == RefKind::RemoteBranch - }) - .map(|reference| reference.name.clone()) - { - return Some(reference); - } - } - None - } - - fn swap_refs(&mut self) -> Vec { - let left = self.compare.left_ref.get(&self.store); - let right = self.compare.right_ref.get(&self.store); - let profile = self.vcs_ui_profile(); - if left.trim().is_empty() - || right.trim().is_empty() - || !profile.can_swap_ref(&right) - || !profile.can_swap_ref(&left) - { - return Vec::new(); - } - let resolved_left = self.compare.resolved_left.get(&self.store); - let resolved_right = self.compare.resolved_right.get(&self.store); - self.compare.left_ref.set(&self.store, right); - self.compare.right_ref.set(&self.store, left); - self.compare.resolved_left.set(&self.store, resolved_right); - self.compare.resolved_right.set(&self.store, resolved_left); - self.workspace.pre_drill_compare.set(&self.store, None); - let mut effects = self.persist_settings_effect(); - let has_repo = self.compare.repo_path.with(&self.store, |p| p.is_some()); - let not_loading = self.workspace.status.get(&self.store) != AsyncStatus::Loading; - let refs_valid = compare_refs_are_valid( - self.compare.mode.get(&self.store), - &self.compare.left_ref.get(&self.store), - &self.compare.right_ref.get(&self.store), - ); - if has_repo && not_loading && refs_valid { - effects.extend(self.kickoff_compare()); - } - effects - } - - fn persist_settings_effect(&mut self) -> Vec { - self.sync_settings_snapshot(); - vec![SettingsEffect::SaveSettings(self.settings.clone()).into()] - } - - fn sync_settings_snapshot(&mut self) { - self.settings.ui_scale_pct = self.clamp_ui_scale_pct(self.settings.ui_scale_pct); - self.settings.fonts = self.settings.fonts.normalized(); - self.settings.sidebar_width_px = self - .settings - .sidebar_width_px - .map(|width| self.clamp_sidebar_width_px(width)); - self.settings.viewport.wrap_enabled = self.editor.wrap_enabled.get(&self.store); - self.settings.viewport.wrap_column = self.editor.wrap_column.get(&self.store); - self.settings.viewport.layout = self.compare.layout.get(&self.store); - self.settings.last_compare = Some(PersistedCompare { - repo_path: self.compare.repo_path.get(&self.store), - left_ref: self.compare.left_ref.get(&self.store), - right_ref: self.compare.right_ref.get(&self.store), - mode: self.compare.mode.get(&self.store), - layout: self.compare.layout.get(&self.store), - renderer: self.compare.renderer.get(&self.store), - }); - } - - pub fn ui_scale_factor(&self) -> f32 { - self.clamp_ui_scale_pct(self.settings.ui_scale_pct) as f32 / DEFAULT_UI_SCALE_PCT as f32 - } - - fn clamp_ui_scale_pct(&self, scale_pct: u16) -> u16 { - scale_pct.clamp(MIN_UI_SCALE_PCT, MAX_UI_SCALE_PCT) - } - - fn adjust_ui_scale(&mut self, delta_pct: i16) -> Vec { - let current = i32::from(self.clamp_ui_scale_pct(self.settings.ui_scale_pct)); - let updated = (current + i32::from(delta_pct)) - .clamp(i32::from(MIN_UI_SCALE_PCT), i32::from(MAX_UI_SCALE_PCT)) - as u16; - if updated == self.settings.ui_scale_pct { - return Vec::new(); - } - self.settings.ui_scale_pct = updated; - self.persist_settings_effect() - } - - fn clamp_sidebar_width_px(&self, width: u32) -> u32 { - let min_width = (280.0 * self.ui_scale_factor() * 0.64).round() as u32; - width.max(min_width.max(120)) - } - - fn set_focus(&mut self, target: Option) { - if target != self.focus.get(&self.store) { - // Reset cursor to end of the new field - let len = target - .and_then(|t| self.with_text_for_focus(t, |s| s.len())) - .unwrap_or(0); - self.reset_text_edit(len); - } - self.focus.set(&self.store, target); - self.editor - .focused - .set(&self.store, target == Some(FocusTarget::Editor)); - } - - /// Set cursor and anchor to the same offset and refresh the blink timestamp. - pub(super) fn reset_text_edit(&mut self, offset: usize) { - self.text_edit.cursor.set(&self.store, offset); - self.text_edit.anchor.set(&self.store, offset); - self.text_edit - .cursor_moved_at_ms - .set(&self.store, self.clock_ms); - } - - /// Run `f` against the text string for the given focus target, if it's a text field. - pub(super) fn with_text_for_focus( - &self, - target: FocusTarget, - f: impl FnOnce(&str) -> R, - ) -> Option { - match target { - FocusTarget::PickerInput => match self.overlays.picker.kind.get(&self.store) { - PickerKind::Repository - | PickerKind::Theme - | PickerKind::UiFont - | PickerKind::MonoFont => { - Some(self.overlays.picker.query.with(&self.store, |s| f(s))) - } - PickerKind::LeftRef => Some(self.compare.left_ref.with(&self.store, |s| f(s))), - PickerKind::RightRef => Some(self.compare.right_ref.with(&self.store, |s| f(s))), - }, - FocusTarget::CommandPaletteInput => Some( - self.overlays - .command_palette - .query - .with(&self.store, |s| f(s)), - ), - FocusTarget::SidebarSearch => Some(self.file_list.filter.with(&self.store, |s| f(s))), - FocusTarget::SearchInput => Some(self.editor.search.query.with(&self.store, |s| f(s))), - FocusTarget::CommitEditor => None, - FocusTarget::SettingsOpenAiKey => Some(f(&self.ai_openai_key)), - FocusTarget::SettingsAnthropicKey => Some(f(&self.ai_anthropic_key)), - FocusTarget::SettingsSteeringPrompt => None, - FocusTarget::TextCompareLeft | FocusTarget::TextCompareRight => None, - _ => None, - } - } - - pub(super) fn ai_key_editable(&self, kind: AiKeyKind) -> bool { - match kind { - AiKeyKind::OpenAi => self.ai_openai_key.is_empty() || self.ai_openai_editing, - AiKeyKind::Anthropic => self.ai_anthropic_key.is_empty() || self.ai_anthropic_editing, - } - } - - pub(super) fn with_focused_text(&self, f: impl FnOnce(&str) -> R) -> Option { - let target = self.focus.get(&self.store)?; - self.with_text_for_focus(target, f) - } - - pub(super) fn update_focused_text(&mut self, f: impl FnOnce(&mut String) -> R) -> Option { - match self.focus.get(&self.store) { - Some(FocusTarget::PickerInput) => match self.overlays.picker.kind.get(&self.store) { - PickerKind::Repository - | PickerKind::Theme - | PickerKind::UiFont - | PickerKind::MonoFont => { - let mut out = None; - self.overlays - .picker - .query - .update(&self.store, |s| out = Some(f(s))); - out - } - PickerKind::LeftRef => { - let mut out = None; - self.compare - .left_ref - .update(&self.store, |s| out = Some(f(s))); - out - } - PickerKind::RightRef => { - let mut out = None; - self.compare - .right_ref - .update(&self.store, |s| out = Some(f(s))); - out - } - }, - Some(FocusTarget::CommandPaletteInput) => { - let mut out = None; - self.overlays - .command_palette - .query - .update(&self.store, |s| out = Some(f(s))); - out - } - Some(FocusTarget::SidebarSearch) => { - let mut out = None; - self.file_list - .filter - .update(&self.store, |s| out = Some(f(s))); - out - } - Some(FocusTarget::SearchInput) => { - let mut out = None; - self.editor - .search - .query - .update(&self.store, |s| out = Some(f(s))); - out - } - Some(FocusTarget::CommitEditor) => None, - Some(FocusTarget::SettingsOpenAiKey) => { - if !self.ai_key_editable(AiKeyKind::OpenAi) { - return None; - } - let result = f(&mut self.ai_openai_key); - Some(result) - } - Some(FocusTarget::SettingsAnthropicKey) => { - if !self.ai_key_editable(AiKeyKind::Anthropic) { - return None; - } - let result = f(&mut self.ai_anthropic_key); - Some(result) - } - Some(FocusTarget::SettingsSteeringPrompt) => None, - _ => None, - } - } - - /// Returns true if the current focus target is a text editing field. - /// Backed by a memo; `focus` writes invalidate it automatically. - pub fn is_text_focused(&self) -> bool { - self.text_focused.get(&self.store) - } - - /// Returns true when the workspace is in `Ready` mode. - pub fn is_workspace_ready(&self) -> bool { - self.workspace_mode.get(&self.store) == WorkspaceMode::Ready - } - - fn touch_cursor(&mut self) { - self.text_edit - .cursor_moved_at_ms - .set(&self.store, self.clock_ms); - } - - fn clamp_cursor(&mut self) { - let cursor_now = self.text_edit.cursor.get(&self.store); - let anchor_now = self.text_edit.anchor.get(&self.store); - let Some((cursor, anchor)) = self.with_focused_text(|text| { - let len = text.len(); - let mut cursor = cursor_now.min(len); - while cursor > 0 && !text.is_char_boundary(cursor) { - cursor -= 1; - } - let mut anchor = anchor_now.min(len); - while anchor > 0 && !text.is_char_boundary(anchor) { - anchor -= 1; - } - (cursor, anchor) - }) else { - return; - }; - self.text_edit.cursor.set(&self.store, cursor); - self.text_edit.anchor.set(&self.store, anchor); - } - - // Text editing methods are in text_edit.rs - - fn update_compare_field(&mut self, field: CompareField, value: String) -> Vec { - self.workspace.pre_drill_compare.set(&self.store, None); - match field { - CompareField::Left => { - self.compare.left_ref.set(&self.store, value); - self.compare.resolved_left.set(&self.store, None); - } - CompareField::Right => { - self.compare.right_ref.set(&self.store, value); - self.compare.resolved_right.set(&self.store, None); - } - } - self.auto_select_compare_mode(); - let active_field = self.overlays.ref_picker.active_field.get(&self.store); - let mut effects = if matches!(self.overlays_top(), Some(OverlaySurface::RefPicker)) - && active_field == field - { - self.rebuild_ref_picker(field) - } else { - Vec::new() - }; - effects.extend(self.rebuild_command_palette()); - effects - } - - fn auto_select_compare_mode(&mut self) { - let profile = self.vcs_ui_profile(); - if !profile.should_auto_select_trunk_mode() { - return; - } - let left = self.compare.left_ref.get(&self.store); - let right = self.compare.right_ref.get(&self.store); - if left.is_empty() || right.is_empty() { - return; - } - if left == right && !profile.is_working_copy_ref(&right) { - self.compare - .mode - .set(&self.store, CompareMode::SingleCommit); - return; - } - let is_trunk = |r: &str| matches!(r, "main" | "master" | "develop" | "development"); - if is_trunk(&left) != is_trunk(&right) { - self.compare.mode.set(&self.store, CompareMode::ThreeDot); - } - } - - fn apply_pr_compare(&mut self, left: String, right: String) -> Vec { - let _ = self.update_compare_field(CompareField::Left, left); - let _ = self.update_compare_field(CompareField::Right, right); - self.compare.mode.set(&self.store, CompareMode::ThreeDot); - self.kickoff_compare() - } - - fn open_repo_picker(&mut self) { - let scale = self.ui_scale_factor(); - self.overlays - .picker - .kind - .set(&self.store, PickerKind::Repository); - self.overlays.picker.list.update(&self.store, |l| { - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - l.scroll_top_px = 0; - }); - self.overlays.picker.browse_path.set(&self.store, None); - self.overlays.picker.selected_index.set(&self.store, 0); - - let has_recents = crate::core::frecency::recent_repo_paths(self.frecency.as_ref(), 1) - .first() - .is_some(); - - if has_recents { - self.overlays.picker.query.set(&self.store, String::new()); - } else { - let home = dirs::home_dir() - .map(|p| format!("{}/", p.display())) - .unwrap_or_else(|| "/".to_owned()); - let home_len = home.len(); - self.overlays.picker.query.set(&self.store, home); - self.reset_text_edit(home_len); - } - - self.rebuild_repo_picker(); - self.push_overlay(OverlaySurface::RepoPicker, Some(FocusTarget::PickerInput)); - } - - fn open_theme_picker(&mut self) { - let scale = self.ui_scale_factor(); - self.theme_preview_original - .set(&self.store, Some(self.settings.theme_name.clone())); - self.overlays - .picker - .kind - .set(&self.store, PickerKind::Theme); - self.overlays.picker.query.set(&self.store, String::new()); - self.overlays.picker.list.update(&self.store, |l| { - l.scroll_top_px = 0; - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - }); - let entries = self.build_theme_entries_grouped(); - let selected = entries.iter().position(|e| !e.section_header).unwrap_or(0); - self.overlays.picker.entries.set(&self.store, entries); - self.overlays - .picker - .selected_index - .set(&self.store, selected); - self.push_overlay(OverlaySurface::ThemePicker, Some(FocusTarget::PickerInput)); - } - - fn build_theme_entries_grouped(&self) -> Vec { - use crate::core::themes::ThemeVariant; - - let original = self - .theme_preview_original - .get(&self.store) - .unwrap_or_else(|| self.settings.theme_name.clone()); - let make_entry = |name: &String| PickerEntry { - label: name.clone(), - detail: if *name == original { - "\u{2713}".to_owned() - } else { - String::new() - }, - value: name.clone(), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - icon: None, - section_header: false, - }; - let make_header = |label: &str| PickerEntry { - label: label.to_owned(), - detail: String::new(), - value: String::new(), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - icon: None, - section_header: true, - }; - - let variant_of = |index: usize| { - self.theme_variants - .get(index) - .copied() - .unwrap_or(ThemeVariant::Dark) - }; - let mut ordered: Vec = Vec::with_capacity(self.theme_names.len()); - for group in [ThemeVariant::Dual, ThemeVariant::Dark, ThemeVariant::Light] { - ordered.extend((0..self.theme_names.len()).filter(|&index| variant_of(index) == group)); - } - - build_sectioned_rows( - &ordered, - |index| Some(variant_of(index)), - |variant| { - make_header(match variant { - ThemeVariant::Dual => "Dark & Light", - ThemeVariant::Dark => "Dark", - ThemeVariant::Light => "Light", - }) - }, - |index| self.theme_names.get(index).map(make_entry), - ) - } - - fn rebuild_theme_picker(&mut self) { - let query = self - .overlays - .picker - .query - .with(&self.store, |q| q.trim().to_owned()); - let original = self - .theme_preview_original - .get(&self.store) - .unwrap_or_else(|| self.settings.theme_name.clone()); - let (entries, selected) = if query.is_empty() { - let entries = self.build_theme_entries_grouped(); - let selected = entries.iter().position(|e| !e.section_header).unwrap_or(0); - (entries, selected) - } else { - let haystack: Vec<&str> = self.theme_names.iter().map(|s| s.as_str()).collect(); - let config = neo_frizbee::Config { - max_typos: Some(2), - sort: false, - ..Default::default() - }; - let mut matches = neo_frizbee::match_list_indices(&query, &haystack, &config); - matches.sort_by(|a, b| b.score.cmp(&a.score)); - let entries: Vec = matches - .iter() - .map(|m| { - let name = &self.theme_names[m.index as usize]; - PickerEntry { - label: name.clone(), - detail: if *name == *original { - "\u{2713}".to_owned() - } else { - String::new() - }, - value: name.clone(), - highlights: highlight_ranges_from_match_indices(name, &m.indices), - label_style: PickerLabelStyle::Default, - icon: None, - section_header: false, - } - }) - .collect(); - (entries, 0) - }; - if let Some(entry) = entries.get(selected) { - if !entry.section_header { - self.settings.theme_name = entry.value.clone(); - } - } - let entry_count = entries.len(); - self.overlays.picker.entries.set(&self.store, entries); - self.overlays - .picker - .selected_index - .set(&self.store, selected); - self.overlays.picker.list.update(&self.store, |l| { - l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); - l.scroll_top_px = 0; - }); - } - - fn open_font_picker(&mut self, role: FontRole) { - let scale = self.ui_scale_factor(); - self.overlays.picker.kind.set( - &self.store, - match role { - FontRole::Ui => PickerKind::UiFont, - FontRole::Mono => PickerKind::MonoFont, - }, - ); - self.overlays.picker.query.set(&self.store, String::new()); - self.overlays.picker.list.update(&self.store, |l| { - l.scroll_top_px = 0; - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - }); - self.rebuild_font_picker(); - self.reset_text_edit(0); - self.push_overlay(OverlaySurface::FontPicker, Some(FocusTarget::PickerInput)); - } - - fn rebuild_font_picker(&mut self) { - let Some(role) = self.font_picker_role() else { - return; - }; - let query = self - .overlays - .picker - .query - .with(&self.store, |q| q.trim().to_owned()); - let selected_family = self.selected_font_family(role); - let font_entries = crate::fonts::font_family_entries(role); - let entries: Vec = if query.is_empty() { - font_entries - .iter() - .map(|entry| font_picker_entry(entry, &selected_family, Vec::new())) - .collect() - } else { - let search_texts: Vec = font_entries - .iter() - .map(|entry| { - if entry.label == entry.family { - entry.label.clone() - } else { - format!("{} {}", entry.label, entry.family) - } - }) - .collect(); - let haystack: Vec<&str> = search_texts.iter().map(|s| s.as_str()).collect(); - let config = neo_frizbee::Config { - max_typos: Some(2), - sort: false, - ..Default::default() - }; - let mut matches = neo_frizbee::match_list_indices(&query, &haystack, &config); - matches.sort_by(|a, b| b.score.cmp(&a.score).then(a.index.cmp(&b.index))); - matches - .into_iter() - .map(|m| { - let entry = &font_entries[m.index as usize]; - let highlights = highlight_ranges_for_visible_match( - &query, - &entry.label, - &m.indices, - &config, - ); - font_picker_entry(entry, &selected_family, highlights) - }) - .collect() - }; - - let selected = entries - .iter() - .position(|entry| entry.value == selected_family) - .unwrap_or(0); - let entry_count = entries.len(); - self.overlays.picker.entries.set(&self.store, entries); - self.overlays - .picker - .selected_index - .set(&self.store, selected); - self.overlays.picker.list.update(&self.store, |l| { - l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); - l.scroll_top_px = 0; - }); - } - - fn font_picker_role(&self) -> Option { - match self.overlays.picker.kind.get(&self.store) { - PickerKind::UiFont => Some(FontRole::Ui), - PickerKind::MonoFont => Some(FontRole::Mono), - _ => None, - } - } - - fn selected_font_family(&self, role: FontRole) -> String { - match role { - FontRole::Ui => { - crate::fonts::normalize_font_selection(role, &self.settings.fonts.ui_family) - } - FontRole::Mono => { - crate::fonts::normalize_font_selection(role, &self.settings.fonts.mono_family) - } - } - } - - fn open_ref_picker(&mut self, field: CompareField) -> Vec { - let scale = self.ui_scale_factor(); - let already_open = self.overlays_top() == Some(OverlaySurface::RefPicker); - // Snapshot originals only on first open; switching chips shouldn't - // refresh the revert baseline. - if !already_open { - let left = self.compare.left_ref.get(&self.store); - let right = self.compare.right_ref.get(&self.store); - self.overlays - .ref_picker - .original_left - .set(&self.store, left); - self.overlays - .ref_picker - .original_right - .set(&self.store, right); - } - self.overlays - .ref_picker - .active_field - .set(&self.store, field); - self.overlays.picker.kind.set( - &self.store, - match field { - CompareField::Left => PickerKind::LeftRef, - CompareField::Right => PickerKind::RightRef, - }, - ); - self.overlays.picker.selected_index.set(&self.store, 0); - self.overlays.picker.list.update(&self.store, |l| { - l.scroll_top_px = 0; - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - }); - let effects = self.rebuild_ref_picker(field); - self.push_overlay(OverlaySurface::RefPicker, Some(FocusTarget::PickerInput)); - // Move cursor to end of the active field's current value so typing - // continues from where the label ends. - let len = match field { - CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), - CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), - }; - self.reset_text_edit(len); - effects - } - - fn open_command_palette(&mut self) -> Vec { - let scale = self.ui_scale_factor(); - self.overlays.command_palette.list.update(&self.store, |l| { - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - l.scroll_top_px = 0; - }); - let effects = self.rebuild_command_palette(); - self.push_overlay( - OverlaySurface::CommandPalette, - Some(FocusTarget::CommandPaletteInput), - ); - effects - } - - fn push_overlay(&mut self, surface: OverlaySurface, focus_target: Option) { - if self.overlays_top() == Some(surface) { - self.set_focus(focus_target); - return; - } - let focus_return = self.focus.get(&self.store); - self.overlays.stack.update(&self.store, |stack| { - stack.push(OverlayEntry { - surface, - focus_return, - }); - }); - self.set_focus(focus_target); - } - - fn pop_overlay(&mut self) { - let mut popped: Option = None; - self.overlays.stack.update(&self.store, |stack| { - popped = stack.pop(); - }); - let Some(entry) = popped else { - return; - }; - match entry.surface { - OverlaySurface::ThemePicker => { - let original = self.theme_preview_original.get(&self.store); - self.theme_preview_original.set(&self.store, None); - if let Some(original) = original { - self.settings.theme_name = original; - } - self.reset_picker(); - } - OverlaySurface::RepoPicker | OverlaySurface::RefPicker | OverlaySurface::FontPicker => { - self.reset_picker(); - } - OverlaySurface::CommandPalette => { - self.reset_command_palette(); - } - OverlaySurface::Confirmation => { - self.reset_confirmation(); - } - _ => {} - } - self.set_focus(entry.focus_return); - } - - fn open_confirmation( - &mut self, - title: impl Into, - message: impl Into, - confirm_label: impl Into, - action: Action, - ) { - self.overlays - .confirmation - .title - .set(&self.store, title.into()); - self.overlays - .confirmation - .message - .set(&self.store, message.into()); - self.overlays - .confirmation - .confirm_label - .set(&self.store, confirm_label.into()); - self.overlays - .confirmation - .action - .set(&self.store, Some(action)); - // Let push_overlay snapshot the current focus as the restore target - // before it moves focus off the field; closing the confirmation then - // returns focus (and IME state) to wherever the user was. - self.push_overlay(OverlaySurface::Confirmation, None); - } - - fn move_overlay_selection(&mut self, delta: i32) { - match self.overlays_top() { - Some(OverlaySurface::ThemePicker) => { - let current = self.overlays.picker.selected_index.get(&self.store); - let (idx, len, value) = self.overlays.picker.entries.with(&self.store, |entries| { - let len = entries.len(); - let idx = step_selection(current, delta, len, |i| entries[i].section_header); - let value = idx.and_then(|idx| { - entries - .get(idx) - .filter(|e| !e.section_header) - .map(|e| e.value.clone()) - }); - (idx, len, value) - }); - let Some(idx) = idx else { - return; - }; - self.overlays.picker.selected_index.set(&self.store, idx); - self.overlays - .picker - .list - .update(&self.store, |l| l.reveal_index(idx, len)); - if let Some(value) = value { - tracing::debug!(theme = %value, "theme preview"); - self.settings.theme_name = value; - } - } - Some( - OverlaySurface::RepoPicker | OverlaySurface::RefPicker | OverlaySurface::FontPicker, - ) => { - let current = self.overlays.picker.selected_index.get(&self.store); - let (idx, len) = self.overlays.picker.entries.with(&self.store, |entries| { - let len = entries.len(); - let idx = step_selection(current, delta, len, |i| entries[i].section_header); - (idx, len) - }); - let Some(idx) = idx else { - return; - }; - self.overlays.picker.selected_index.set(&self.store, idx); - self.overlays - .picker - .list - .update(&self.store, |l| l.reveal_index(idx, len)); - } - Some(OverlaySurface::CommandPalette) => { - let entry_count = self - .overlays - .command_palette - .entries - .with(&self.store, |e| e.len()); - let current = self - .overlays - .command_palette - .selected_index - .get(&self.store); - // Palette entries have no section headers; an empty palette - // still pins the selection to row zero. - let idx = step_selection(current, delta, entry_count, |_| false).unwrap_or(0); - self.overlays - .command_palette - .selected_index - .set(&self.store, idx); - self.overlays - .command_palette - .list - .update(&self.store, |l| l.reveal_index(idx, entry_count)); - } - _ => {} - } - } - - fn select_overlay_entry(&mut self, index: usize) { - match self.overlays_top() { - Some(OverlaySurface::ThemePicker) => { - let (clamped, len, value) = - self.overlays.picker.entries.with(&self.store, |entries| { - let len = entries.len(); - let clamped = index.min(len.saturating_sub(1)); - let value = entries.get(clamped).map(|e| e.value.clone()); - (clamped, len, value) - }); - self.overlays - .picker - .selected_index - .set(&self.store, clamped); - if let Some(value) = value { - self.settings.theme_name = value; - } - self.overlays - .picker - .list - .update(&self.store, |l| l.reveal_index(clamped, len)); - } - Some( - OverlaySurface::RepoPicker | OverlaySurface::RefPicker | OverlaySurface::FontPicker, - ) => { - let (clamped, len, is_header) = - self.overlays.picker.entries.with(&self.store, |entries| { - let len = entries.len(); - let clamped = index.min(len.saturating_sub(1)); - let is_header = entries.get(clamped).map_or(false, |e| e.section_header); - (clamped, len, is_header) - }); - if is_header { - return; - } - self.overlays - .picker - .selected_index - .set(&self.store, clamped); - self.overlays - .picker - .list - .update(&self.store, |l| l.reveal_index(clamped, len)); - } - Some(OverlaySurface::CommandPalette) => { - let len = self - .overlays - .command_palette - .entries - .with(&self.store, |e| e.len()); - let clamped = index.min(len.saturating_sub(1)); - self.overlays - .command_palette - .selected_index - .set(&self.store, clamped); - self.overlays - .command_palette - .list - .update(&self.store, |l| l.reveal_index(clamped, len)); - } - _ => {} - } - } - - fn confirm_overlay_selection(&mut self) -> Vec { - match self.overlays_top() { - Some(OverlaySurface::ThemePicker) => { - let selected = self.overlays.picker.selected_index.get(&self.store); - let value = self.overlays.picker.entries.with(&self.store, |entries| { - entries.get(selected).map(|e| e.value.clone()) - }); - if let Some(value) = value { - tracing::info!(theme = %value, "theme confirmed"); - self.settings.theme_name = value; - } - self.theme_preview_original.set(&self.store, None); - self.pop_overlay(); - self.persist_settings_effect() - } - Some(OverlaySurface::FontPicker) => self.confirm_font_picker(), - Some(OverlaySurface::RepoPicker) => self.confirm_repo_picker(), - Some(OverlaySurface::RefPicker) => { - let field = self.overlays.ref_picker.active_field.get(&self.store); - self.confirm_ref_picker(field) - } - Some(OverlaySurface::CommandPalette) => self.confirm_command_palette(), - Some(OverlaySurface::Confirmation) => { - let action = self.overlays.confirmation.action.get(&self.store); - self.pop_overlay(); - if let Some(action) = action { - self.apply_action(action) - } else { - Vec::new() - } - } - Some(OverlaySurface::GitHubAuthModal) => { - if self - .github - .auth - .device_flow - .with(&self.store, |opt| opt.is_some()) - { - self.apply_action(crate::actions::GitHubAction::OpenDeviceFlowBrowser) - } else { - self.apply_action(crate::actions::GitHubAction::StartGitHubDeviceFlow) - } - } - Some( - OverlaySurface::KeyboardShortcuts - | OverlaySurface::CompareMenu - | OverlaySurface::AccountMenu - | OverlaySurface::PublishMenu, - ) => Vec::new(), - None => Vec::new(), - } - } - - fn confirm_font_picker(&mut self) -> Vec { - let Some(role) = self.font_picker_role() else { - return Vec::new(); - }; - let selected = self.overlays.picker.selected_index.get(&self.store); - let family = self.overlays.picker.entries.with(&self.store, |entries| { - entries.get(selected).map(|entry| entry.value.clone()) - }); - let Some(family) = family else { - return Vec::new(); - }; - let family = crate::fonts::normalize_font_selection(role, &family); - let changed = match role { - FontRole::Ui => { - if self.settings.fonts.ui_family == family { - false - } else { - self.settings.fonts.ui_family = family; - true - } - } - FontRole::Mono => { - if self.settings.fonts.mono_family == family { - false - } else { - self.settings.fonts.mono_family = family; - true - } - } - }; - self.pop_overlay(); - if changed { - self.persist_settings_effect() - } else { - Vec::new() - } - } - - fn confirm_repo_picker(&mut self) -> Vec { - let selected = self.overlays.picker.selected_index.get(&self.store); - let entry = self - .overlays - .picker - .entries - .with(&self.store, |entries| entries.get(selected).cloned()); - - let Some(entry) = entry else { - let query = self - .overlays - .picker - .query - .with(&self.store, |q| q.trim().to_owned()); - if !query.is_empty() { - let expanded = expand_tilde(&query); - let path = PathBuf::from(&expanded); - if path.is_dir() && path_looks_like_repository(&path) { - self.pop_overlay(); - return self.open_repository(path); - } - if path.is_dir() { - self.navigate_picker_to_dir(&path); - return Vec::new(); - } - } - return Vec::new(); - }; - - if entry.section_header { - return Vec::new(); - } - - if entry.value.starts_with("open:") { - let path = PathBuf::from(&entry.value[5..]); - self.pop_overlay(); - return self.open_repository(path); - } - - let path = PathBuf::from(&entry.value); - - let browsing = self - .overlays - .picker - .browse_path - .with(&self.store, |p| p.is_some()); - if browsing { - if entry.label == ".." { - self.navigate_picker_to_dir(&path); - return Vec::new(); - } - if path.is_dir() && path_looks_like_repository(&path) { - self.pop_overlay(); - return self.open_repository(path); - } - if path.is_dir() { - self.navigate_picker_to_dir(&path); - return Vec::new(); - } - return Vec::new(); - } - - self.pop_overlay(); - self.open_repository(path) - } - - fn tab_complete_picker_dir(&mut self) { - if self.overlays.picker.kind.get(&self.store) != PickerKind::Repository { - return; - } - let selected = self.overlays.picker.selected_index.get(&self.store); - let entry = self - .overlays - .picker - .entries - .with(&self.store, |entries| entries.get(selected).cloned()); - let Some(entry) = entry else { return }; - if entry.section_header || entry.value.is_empty() { - return; - } - let path = PathBuf::from(&entry.value); - if path.is_dir() { - self.navigate_picker_to_dir(&path); - } - } - - fn navigate_picker_to_dir(&mut self, path: &Path) { - let display = path.display().to_string(); - let new_query = if display.ends_with('/') || display.ends_with('\\') { - display - } else { - format!("{}/", display) - }; - let new_len = new_query.len(); - self.overlays.picker.query.set(&self.store, new_query); - self.reset_text_edit(new_len); - self.rebuild_repo_picker(); - } - - fn confirm_ref_picker(&mut self, field: CompareField) -> Vec { - let selected = self.overlays.picker.selected_index.get(&self.store); - let entry = self - .overlays - .picker - .entries - .with(&self.store, |entries| entries.get(selected).cloned()) - .or_else(|| { - let query = match field { - CompareField::Left => self - .compare - .left_ref - .with(&self.store, |s| s.trim().to_owned()), - CompareField::Right => self - .compare - .right_ref - .with(&self.store, |s| s.trim().to_owned()), - }; - (!query.is_empty()).then(|| PickerEntry { - label: query.clone(), - detail: "Use typed ref".to_owned(), - value: query.clone(), - highlights: vec![(0, query.len())], - label_style: PickerLabelStyle::Default, - icon: None, - section_header: false, - }) - }); - let Some(entry) = entry else { - return Vec::new(); - }; - // Presets apply both refs at once; treat them as an explicit commit. - if let Some(rest) = entry.value.strip_prefix("@preset:") { - return self.apply_compare_preset(rest); - } - if let Some(ref store) = self.frecency { - store.record_access(&format!("ref:{}", entry.value)); - } - let _ = self.update_compare_field(field, entry.value); - // Auto-advance to the other chip if it's still at its snapshot — the - // user is likely changing both refs. Only commit when both chips have - // diverged from their snapshots (or neither, which is a no-op). - let other = match field { - CompareField::Left => CompareField::Right, - CompareField::Right => CompareField::Left, - }; - let other_current = match other { - CompareField::Left => self.compare.left_ref.get(&self.store), - CompareField::Right => self.compare.right_ref.get(&self.store), - }; - let other_original = match other { - CompareField::Left => self.overlays.ref_picker.original_left.get(&self.store), - CompareField::Right => self.overlays.ref_picker.original_right.get(&self.store), - }; - if other_current == other_original { - let scale = self.ui_scale_factor(); - self.overlays - .ref_picker - .active_field - .set(&self.store, other); - self.overlays.picker.kind.set( - &self.store, - match other { - CompareField::Left => PickerKind::LeftRef, - CompareField::Right => PickerKind::RightRef, - }, - ); - self.overlays.picker.selected_index.set(&self.store, 0); - self.overlays.picker.list.update(&self.store, |l| { - l.scroll_top_px = 0; - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - }); - let effects = self.rebuild_ref_picker(other); - let len = match other { - CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), - CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), - }; - self.reset_text_edit(len); - return effects; - } - // Both chips changed — commit. - self.commit_ref_picker() - } - - fn commit_ref_picker(&mut self) -> Vec { - let original_left = self.overlays.ref_picker.original_left.get(&self.store); - let original_right = self.overlays.ref_picker.original_right.get(&self.store); - let current_left = self.compare.left_ref.get(&self.store); - let current_right = self.compare.right_ref.get(&self.store); - let changed = current_left != original_left || current_right != original_right; - self.pop_overlay(); - let mut effects = self.persist_settings_effect(); - if !changed { - return effects; - } - let has_repo = self.compare.repo_path.with(&self.store, |p| p.is_some()); - let not_loading = self.workspace.status.get(&self.store) != AsyncStatus::Loading; - let refs_valid = compare_refs_are_valid( - self.compare.mode.get(&self.store), - ¤t_left, - ¤t_right, - ); - if has_repo && not_loading && refs_valid { - effects.extend(self.kickoff_compare()); - } - effects - } - - fn cancel_ref_picker(&mut self) -> Vec { - let left = self.overlays.ref_picker.original_left.get(&self.store); - let right = self.overlays.ref_picker.original_right.get(&self.store); - self.compare.left_ref.set(&self.store, left); - self.compare.right_ref.set(&self.store, right); - self.compare.resolved_left.set(&self.store, None); - self.compare.resolved_right.set(&self.store, None); - self.pop_overlay(); - Vec::new() - } - - fn set_active_ref_field(&mut self, field: CompareField) -> Vec { - if self.overlays_top() != Some(OverlaySurface::RefPicker) { - return Vec::new(); - } - let scale = self.ui_scale_factor(); - self.overlays - .ref_picker - .active_field - .set(&self.store, field); - self.overlays.picker.kind.set( - &self.store, - match field { - CompareField::Left => PickerKind::LeftRef, - CompareField::Right => PickerKind::RightRef, - }, - ); - self.overlays.picker.selected_index.set(&self.store, 0); - self.overlays.picker.list.update(&self.store, |l| { - l.scroll_top_px = 0; - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - }); - let effects = self.rebuild_ref_picker(field); - let len = match field { - CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), - CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), - }; - self.reset_text_edit(len); - effects - } - - fn swap_draft_refs(&mut self) -> Vec { - if self.overlays_top() != Some(OverlaySurface::RefPicker) { - return Vec::new(); - } - let left = self.compare.left_ref.get(&self.store); - let right = self.compare.right_ref.get(&self.store); - self.compare.left_ref.set(&self.store, right); - self.compare.right_ref.set(&self.store, left); - self.compare.resolved_left.set(&self.store, None); - self.compare.resolved_right.set(&self.store, None); - // Re-sync the search input to the active chip's new value. - let field = self.overlays.ref_picker.active_field.get(&self.store); - let len = match field { - CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), - CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), - }; - self.reset_text_edit(len); - self.rebuild_ref_picker(field) - } - - fn apply_compare_preset(&mut self, preset: &str) -> Vec { - let parts: Vec<&str> = preset.splitn(3, ':').collect(); - if parts.len() != 3 { - return Vec::new(); - } - let (left, right, mode_str) = (parts[0], parts[1], parts[2]); - let mode = match mode_str { - "commit" => CompareMode::SingleCommit, - "diff" => CompareMode::TwoDot, - _ => CompareMode::ThreeDot, - }; - let profile = self.vcs_ui_profile(); - let mode = if profile.accepts_compare_mode(mode) { - mode - } else { - profile.compare_modes()[0].mode - }; - self.workspace.pre_drill_compare.set(&self.store, None); - self.compare.left_ref.set(&self.store, left.to_owned()); - self.compare.right_ref.set(&self.store, right.to_owned()); - self.compare.resolved_left.set(&self.store, None); - self.compare.resolved_right.set(&self.store, None); - self.compare.mode.set(&self.store, mode); - self.pop_overlay(); - let mut effects = self.persist_settings_effect(); - if self.compare.repo_path.with(&self.store, |p| p.is_some()) { - effects.extend(self.kickoff_compare()); - } - effects - } - - fn confirm_command_palette(&mut self) -> Vec { - let selected = self - .overlays - .command_palette - .selected_index - .get(&self.store); - let Some(entry) = self - .overlays - .command_palette - .entries - .with(&self.store, |entries| entries.get(selected).cloned()) - else { - return Vec::new(); - }; - if entry.disabled { - return Vec::new(); - } - self.clear_overlays(); - match entry.kind { - PaletteEntryKind::Command(command) => { - match command { - PaletteCommand::OpenRepoPicker => { - self.open_repo_picker(); - Vec::new() - } - PaletteCommand::NewTextCompare => { - self.apply_action(crate::actions::WorkspaceAction::NewTextCompare) - } - PaletteCommand::OpenGitHubAuthModal => { - self.push_overlay( - OverlaySurface::GitHubAuthModal, - Some(FocusTarget::AuthPrimaryAction), - ); - Vec::new() - } - PaletteCommand::OpenGitHubAccountMenu => { - self.apply_action(crate::actions::GitHubAction::OpenAccountMenu) - } - PaletteCommand::SignOutGitHub => { - self.apply_action(crate::actions::GitHubAction::SignOutGitHub) - } - PaletteCommand::FocusFileList => { - self.set_focus(Some(FocusTarget::FileList)); - Vec::new() - } - PaletteCommand::FocusViewport => { - self.set_focus(Some(FocusTarget::Editor)); - Vec::new() - } - PaletteCommand::ShowWorkingTree => { - self.apply_action(crate::actions::WorkspaceAction::ShowWorkingTree) - } - PaletteCommand::RefreshRepository => { - self.apply_action(crate::actions::WorkspaceAction::RefreshRepository) - } - PaletteCommand::OpenBaseRefPicker => self.apply_action( - crate::actions::OverlayAction::OpenRefPicker(CompareField::Left), - ), - PaletteCommand::OpenHeadRefPicker => self.apply_action( - crate::actions::OverlayAction::OpenRefPicker(CompareField::Right), - ), - PaletteCommand::SwapRefs => { - self.apply_action(crate::actions::CompareAction::SwapRefs) - } - PaletteCommand::StartCompare => { - self.apply_action(crate::actions::CompareAction::StartCompare) - } - PaletteCommand::OpenCompareMenu => { - self.apply_action(crate::actions::CompareAction::OpenCompareMenu) - } - PaletteCommand::ShowKeyboardShortcuts => { - self.apply_action(crate::actions::SettingsAction::OpenKeymaps) - } - PaletteCommand::RestoreCompare => { - self.apply_action(crate::actions::CompareAction::ClearSidebarCommit) - } - PaletteCommand::ToggleSidebar => { - self.apply_action(crate::actions::FileListAction::ToggleSidebar) - } - PaletteCommand::ToggleFileTree => { - self.apply_action(crate::actions::FileListAction::ToggleSidebarMode) - } - PaletteCommand::ExpandAllFolders => { - self.apply_action(crate::actions::FileListAction::ExpandAllFolders) - } - PaletteCommand::CollapseAllFolders => { - self.apply_action(crate::actions::FileListAction::CollapseAllFolders) - } - PaletteCommand::ToggleWrap => { - self.apply_action(crate::actions::SettingsAction::ToggleWrap) - } - PaletteCommand::ToggleContinuousScroll => { - self.apply_action(crate::actions::SettingsAction::ToggleContinuousScroll) - } - PaletteCommand::SetSettingsSection(section) => self - .apply_action(crate::actions::SettingsAction::SetSettingsSection(section)), - PaletteCommand::SetThemeMode(mode) => { - self.apply_action(crate::actions::SettingsAction::SetThemeMode(mode)) - } - PaletteCommand::SetUiScalePct(pct) => { - self.apply_action(crate::actions::SettingsAction::SetUiScalePct(pct)) - } - PaletteCommand::SetWrapColumn(column) => { - self.apply_action(crate::actions::SettingsAction::SetWrapColumn(column)) - } - PaletteCommand::SetWheelScrollLines(lines) => self - .apply_action(crate::actions::SettingsAction::SetWheelScrollLines(lines)), - PaletteCommand::ToggleAutoUpdate => { - self.apply_action(crate::actions::SettingsAction::ToggleAutoUpdate) - } - PaletteCommand::ToggleThemeMode => { - self.apply_action(crate::actions::SettingsAction::ToggleThemeMode) - } - PaletteCommand::SetLayout(layout) => { - self.apply_action(crate::actions::CompareAction::SetLayoutMode(layout)) - } - PaletteCommand::SetRenderer(renderer) => { - self.apply_action(crate::actions::CompareAction::SetRenderer(renderer)) - } - PaletteCommand::ChangeTheme => { - self.apply_action(crate::actions::SettingsAction::OpenThemePicker) - } - PaletteCommand::SetTheme(name) => { - self.apply_action(crate::actions::SettingsAction::SetThemeName(name)) - } - PaletteCommand::ExpandAllContext => { - self.apply_action(crate::actions::EditorAction::ExpandAllContext) - } - PaletteCommand::ClearLineSelection => { - self.apply_action(crate::actions::RepositoryAction::ClearLineSelection) - } - PaletteCommand::GenerateCommitMessage => { - self.apply_action(crate::actions::AiAction::GenerateCommitMessage) - } - PaletteCommand::OpenReviewComment => { - self.apply_action(crate::actions::GitHubAction::OpenReviewCommentComposer) - } - PaletteCommand::OpenPullRequestInGitHub => { - self.apply_action(crate::actions::GitHubAction::OpenPullRequestInBrowser) - } - PaletteCommand::CheckForUpdates => { - self.apply_action(crate::actions::UpdateAction::CheckForUpdates) - } - PaletteCommand::InstallUpdate => { - self.apply_action(crate::actions::UpdateAction::InstallUpdate) - } - PaletteCommand::RestartToUpdate => { - self.apply_action(crate::actions::UpdateAction::RestartToUpdate) - } - PaletteCommand::RunOperation(operation) => { - self.confirm_or_run_vcs_operation(operation) - } - PaletteCommand::FetchOrigin => self.apply_action( - crate::actions::RepositoryAction::FetchRemote("origin".to_owned()), - ), - PaletteCommand::FetchAllRemotes => { - self.apply_action(crate::actions::RepositoryAction::FetchAllRemotes) - } - PaletteCommand::PushCurrentBranch => { - self.apply_action(crate::actions::RepositoryAction::PushCurrentBranch { - force_with_lease: false, - }) - } - PaletteCommand::PublishOptions => { - self.apply_action(crate::actions::RepositoryAction::OpenPublishMenu) - } - PaletteCommand::PushCurrentBranchForceWithLease => { - self.apply_action(crate::actions::RepositoryAction::PushCurrentBranch { - force_with_lease: true, - }) - } - PaletteCommand::PullCurrentBranch => { - self.apply_action(crate::actions::RepositoryAction::PullCurrentBranch) - } - PaletteCommand::OpenSettings => { - self.apply_action(crate::actions::SettingsAction::OpenSettings) - } - } - } - PaletteEntryKind::File(index) => self.select_file(index, true), - PaletteEntryKind::Commit(oid) => { - self.apply_action(crate::actions::CompareAction::SelectSidebarCommit(oid)) - } - PaletteEntryKind::Repo(path) => self.open_repository(path), - PaletteEntryKind::Ref(field, value) => { - let _ = self.update_compare_field(field, value); - self.persist_settings_effect() - } - PaletteEntryKind::PullRequest(key) => self.confirm_pr_entry(key), - } - } - - fn confirm_pr_entry(&mut self, key: PrKey) -> Vec { - if self.compare.repo_path.with(&self.store, |p| p.is_none()) { - self.push_error("Open a repository before loading a pull request."); - return Vec::new(); - } - let diff_state = self - .github - .pull_request - .cache - .with(&self.store, |c| c.get(&key).map(|e| e.diff.clone())); - match diff_state { - Some(PrPeekDiff::Ready { - left_ref, - right_ref, - .. - }) => { - self.github - .pull_request - .pending_confirm - .set(&self.store, None); - self.github.pull_request.active.set(&self.store, Some(key)); - self.apply_pr_compare(left_ref, right_ref) - } - Some(PrPeekDiff::Loading) | Some(PrPeekDiff::Idle) => { - self.github - .pull_request - .pending_confirm - .set(&self.store, Some(key.clone())); - self.push_info(&format!("Preparing PR #{}\u{2026}", key.2)); - Vec::new() - } - Some(PrPeekDiff::Failed(message)) => { - self.push_error(&message); - Vec::new() - } - None => { - self.push_error("Pull request not available."); - Vec::new() - } - } - } - - fn confirm_or_run_vcs_operation(&mut self, operation: VcsOperation) -> Vec { - let action = crate::actions::RepositoryAction::RunOperation(operation.clone()); - if let Some(message) = operation.confirmation_message() { - self.open_confirmation( - format!("Confirm {}", operation.label()), - message, - operation.label(), - action.into(), - ); - Vec::new() - } else { - self.apply_action(action) - } - } - - fn rebuild_repo_picker(&mut self) { - let query = self.overlays.picker.query.with(&self.store, |q| q.clone()); - let trimmed = query.trim(); - - if query_looks_like_path(trimmed) { - self.rebuild_repo_picker_browse(trimmed); - } else { - self.overlays.picker.browse_path.set(&self.store, None); - self.rebuild_repo_picker_recent(trimmed); - } - - let current_selected = self.overlays.picker.selected_index.get(&self.store); - let (entry_count, new_selected) = - self.overlays.picker.entries.with(&self.store, |entries| { - let entry_count = entries.len(); - let new_selected = if entries.is_empty() { - 0 - } else { - let first_selectable = - entries.iter().position(|e| !e.section_header).unwrap_or(0); - current_selected - .max(first_selectable) - .min(entries.len().saturating_sub(1)) - }; - (entry_count, new_selected) - }); - self.overlays - .picker - .selected_index - .set(&self.store, new_selected); - self.overlays.picker.list.update(&self.store, |l| { - l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); - l.clamp_scroll(entry_count); - }); - } - - fn rebuild_repo_picker_recent(&mut self, query: &str) { - let mut entries = Vec::new(); - - let all_repos = crate::core::frecency::recent_repo_paths(self.frecency.as_ref(), 20); - - let mut seen = HashSet::new(); - let mut unique_repos = Vec::new(); - for repo in &all_repos { - if seen.insert(repo.clone()) { - unique_repos.push(repo.clone()); - } - } - - if !unique_repos.is_empty() { - entries.push(PickerEntry { - label: "Recent".to_owned(), - detail: String::new(), - value: String::new(), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - icon: None, - section_header: true, - }); - } - - if query.is_empty() { - for repo in &unique_repos { - let display = repo.display().to_string(); - let is_repo = path_looks_like_repository(repo); - entries.push(PickerEntry { - label: repo - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(&display) - .to_owned(), - detail: display.clone(), - value: repo.display().to_string(), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - icon: Some(if is_repo { - lucide::FOLDER_GIT - } else { - lucide::FOLDER - }), - section_header: false, - }); - } - } else { - let haystack: Vec = unique_repos - .iter() - .map(|r| r.display().to_string()) - .collect(); - let haystack_refs: Vec<&str> = haystack.iter().map(|s| s.as_str()).collect(); - let config = neo_frizbee::Config { - max_typos: Some(2), - sort: false, - ..Default::default() - }; - let mut matches = neo_frizbee::match_list_indices(query, &haystack_refs, &config); - matches.sort_by(|a, b| b.score.cmp(&a.score)); - if matches.is_empty() { - entries.clear(); - } - for m in matches { - let repo = &unique_repos[m.index as usize]; - let display = &haystack[m.index as usize]; - let label = repo - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(display) - .to_owned(); - let highlights = - highlight_ranges_for_visible_match(query, &label, &m.indices, &config); - let is_repo = path_looks_like_repository(repo); - entries.push(PickerEntry { - label, - detail: display.clone(), - value: repo.display().to_string(), - highlights, - label_style: PickerLabelStyle::Default, - icon: Some(if is_repo { - lucide::FOLDER_GIT - } else { - lucide::FOLDER - }), - section_header: false, - }); - } - } - self.overlays.picker.entries.set(&self.store, entries); - } - - fn rebuild_repo_picker_browse(&mut self, query: &str) { - let expanded = expand_tilde(query); - let (dir_path, filter) = split_browse_query(&expanded); - - let dir = PathBuf::from(&dir_path); - if !dir.is_dir() { - self.overlays.picker.browse_path.set(&self.store, None); - self.overlays.picker.entries.set(&self.store, Vec::new()); - return; - } - - self.overlays - .picker - .browse_path - .set(&self.store, Some(dir.clone())); - - let mut entries = Vec::new(); - - if path_looks_like_repository(&dir) { - entries.push(PickerEntry { - label: "open this directory".to_owned(), - detail: String::new(), - value: format!("open:{}", dir.display()), - icon: Some(lucide::CORNER_UP_LEFT), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - section_header: false, - }); - } - - if dir.parent().is_some() { - entries.push(PickerEntry { - label: "..".to_owned(), - detail: String::new(), - value: dir - .parent() - .map(|p| p.display().to_string()) - .unwrap_or_default(), - icon: Some(lucide::CORNER_UP_LEFT), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - section_header: false, - }); - } - - let mut dirs: Vec<(String, PathBuf, bool)> = Vec::new(); - if let Ok(read) = std::fs::read_dir(&dir) { - for entry in read.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let name = entry.file_name().to_str().unwrap_or_default().to_owned(); - if name.starts_with('.') { - continue; - } - let is_repo = path_looks_like_repository(&path); - dirs.push((name, path, is_repo)); - } - } - - dirs.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); - - if filter.is_empty() { - for (name, path, is_repo) in &dirs { - entries.push(PickerEntry { - label: name.clone(), - detail: String::new(), - value: path.display().to_string(), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - icon: Some(if *is_repo { - lucide::FOLDER_GIT - } else { - lucide::FOLDER - }), - section_header: false, - }); - } - } else { - let haystack: Vec<&str> = dirs.iter().map(|(n, _, _)| n.as_str()).collect(); - let config = neo_frizbee::Config { - max_typos: Some(1), - sort: false, - ..Default::default() - }; - let mut matches = neo_frizbee::match_list_indices(filter, &haystack, &config); - matches.sort_by(|a, b| b.score.cmp(&a.score)); - for m in matches { - let (name, path, is_repo) = &dirs[m.index as usize]; - entries.push(PickerEntry { - label: name.clone(), - detail: String::new(), - value: path.display().to_string(), - highlights: highlight_ranges_from_match_indices(name, &m.indices), - label_style: PickerLabelStyle::Default, - icon: Some(if *is_repo { - lucide::FOLDER_GIT - } else { - lucide::FOLDER - }), - section_header: false, - }); - } - } - - self.overlays.picker.entries.set(&self.store, entries); - } - - fn rebuild_ref_picker(&mut self, field: CompareField) -> Vec { - let query_owned = match field { - CompareField::Left => self - .compare - .left_ref - .with(&self.store, |s| s.trim().to_owned()), - CompareField::Right => self - .compare - .right_ref - .with(&self.store, |s| s.trim().to_owned()), - }; - let query = query_owned.as_str(); - let mut seen = HashSet::new(); - - struct RefCandidate { - search_text: String, - label: String, - detail: String, - value: String, - icon: Option<&'static str>, - default_highlights: Vec<(usize, usize)>, - label_style: PickerLabelStyle, - ordinal: usize, - } - - let mut all_candidates = Vec::new(); - let mut ordinal = 0_usize; - - let mut push = |search_text: String, - label: String, - detail: String, - value: String, - icon: Option<&'static str>, - default_highlights: Vec<(usize, usize)>, - label_style: PickerLabelStyle| { - if !seen.insert(value.clone()) { - return; - } - all_candidates.push(RefCandidate { - search_text, - label, - detail, - value, - icon, - default_highlights, - label_style, - ordinal, - }); - ordinal += 1; - }; - - let profile = self.vcs_ui_profile(); - let refs = self.repository.refs.get(&self.store); - let changes = self.repository.changes.get(&self.store); - - for reference in &refs { - let value = reference.name.clone(); - let (kind_label, icon) = profile.ref_kind_label_and_icon(reference.kind); - let mut detail = kind_label.to_owned(); - if reference.active { - detail.push_str(" \u{2022} current"); - } - let mut search_text = format!("{} {detail}", reference.name); - if reference.target.id != reference.name { - search_text.push(' '); - search_text.push_str(&reference.target.id); - } - if reference.kind == RefKind::WorkingCopy - && let Some((detail_suffix, search_suffix)) = - profile.working_copy_ref_suffix(&changes) - { - detail.push_str(&detail_suffix); - search_text.push_str(&search_suffix); - } - push( - search_text, - reference.name.clone(), - detail, - value, - icon, - Vec::new(), - PickerLabelStyle::Default, - ); - } - - for change in &changes { - let entry = profile.change_ref_entry(change); - let label_style = entry - .prefix_len - .map(|prefix_len| PickerLabelStyle::JjChangeId { - prefix_len, - working_copy: entry.working_copy, - }) - .unwrap_or_default(); - push( - entry.search_text, - entry.label, - entry.detail, - entry.value, - Some(lucide::HASH), - entry.default_highlights, - label_style, - ); - } - - let mut needs_resolve = false; - - if query.is_empty() { - let entries = all_candidates - .into_iter() - .take(10) - .map(|c| PickerEntry { - label: c.label, - detail: c.detail, - value: c.value, - highlights: c.default_highlights, - label_style: c.label_style, - icon: c.icon, - section_header: false, - }) - .collect(); - self.overlays.picker.entries.set(&self.store, entries); - } else { - let haystack: Vec<&str> = all_candidates - .iter() - .map(|c| c.search_text.as_str()) - .collect(); - let config = neo_frizbee::Config { - max_typos: Some(2), - sort: false, - ..Default::default() - }; - let matches = neo_frizbee::match_list_indices(query, &haystack, &config); - let mut scored: Vec<_> = matches - .into_iter() - .map(|m| { - let c = &all_candidates[m.index as usize]; - ( - m.score, - c.ordinal, - PickerEntry { - label: c.label.clone(), - detail: c.detail.clone(), - value: c.value.clone(), - highlights: highlight_ranges_for_visible_match( - query, &c.label, &m.indices, &config, - ), - label_style: c.label_style, - icon: c.icon, - section_header: false, - }, - ) - }) - .collect(); - scored.sort_by(|a, b| { - b.0.cmp(&a.0) - .then(a.1.cmp(&b.1)) - .then(a.2.label.cmp(&b.2.label)) - }); - let mut entries = Vec::new(); - entries.extend(scored.into_iter().map(|(_, _, entry)| entry).take(10)); - if !entries.iter().any(|entry| entry.value == query) { - entries.insert( - 0, - PickerEntry { - label: query.to_owned(), - detail: "Resolving\u{2026}".to_owned(), - value: query.to_owned(), - highlights: vec![(0, query.len())], - label_style: PickerLabelStyle::Default, - icon: None, - section_header: false, - }, - ); - needs_resolve = true; - } - self.overlays.picker.entries.set(&self.store, entries); - } - - self.overlays.picker.entries.update(&self.store, |e| { - e.truncate(10); - }); - let entry_count = self.overlays.picker.entries.with(&self.store, |e| e.len()); - let current_selected = self.overlays.picker.selected_index.get(&self.store); - self.overlays.picker.selected_index.set( - &self.store, - current_selected.min(entry_count.saturating_sub(1)), - ); - self.overlays.picker.list.update(&self.store, |l| { - l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); - l.clamp_scroll(entry_count); - }); - - if needs_resolve { - if let Some(repo_path) = self.compare.repo_path.get(&self.store) { - let new_gen = self.overlays.picker.ref_resolve_generation.get(&self.store) + 1; - self.overlays - .picker - .ref_resolve_generation - .set(&self.store, new_gen); - return vec![ - CompareEffect::ResolveRef { - repo_path, - query: query.to_owned(), - generation: new_gen, - } - .into(), - ]; - } - } - Vec::new() - } - - fn rebuild_command_palette_if_open(&mut self) -> Vec { - if self.overlays_top() == Some(OverlaySurface::CommandPalette) { - self.rebuild_command_palette() - } else { - Vec::new() - } - } - - fn rebuild_command_palette(&mut self) -> Vec { - let query_owned = self - .overlays - .command_palette - .query - .with(&self.store, |q| q.trim().to_owned()); - let query = query_owned.as_str(); - - let mut out_effects = Vec::new(); - let mut pr_entry: Option = None; - - if let Some(parsed) = crate::core::forge::github::parse_pr_url(query) { - let key: PrKey = (parsed.owner.clone(), parsed.repo.clone(), parsed.number); - let token = self.github_access_token.clone(); - let repo_path = self.compare.repo_path.get(&self.store); - let supports_github_prs = repo_path.is_some() - && self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.github_pull_requests) - }); - - let already_cached = self - .github - .pull_request - .cache - .with(&self.store, |c| c.contains_key(&key)); - if !already_cached { - self.github.pull_request.cache.update(&self.store, |c| { - c.insert( - key.clone(), - PrCacheEntry { - meta: PrPeekMeta::Loading, - diff: PrPeekDiff::Idle, - last_peek_ms: self.clock_ms, - }, - ); - }); - out_effects.push( - GitHubEffect::PeekPullRequest { - owner: parsed.owner.clone(), - repo: parsed.repo.clone(), - number: parsed.number, - github_token: token.clone(), - } - .into(), - ); - } - - // Speculative diff load — kick off as soon as we know the key, provided - // a repo is open. Dedupe via the cache's diff state. - if supports_github_prs && let Some(repo_path) = repo_path.clone() { - let diff_idle = self.github.pull_request.cache.with(&self.store, |c| { - matches!(c.get(&key).map(|e| &e.diff), Some(PrPeekDiff::Idle) | None) - }); - if diff_idle { - self.github.pull_request.cache.update(&self.store, |c| { - if let Some(e) = c.get_mut(&key) { - e.diff = PrPeekDiff::Loading; - } - }); - let url = format!( - "https://github.com/{}/{}/pull/{}", - parsed.owner, parsed.repo, parsed.number - ); - out_effects.push( - GitHubEffect::LoadPullRequest { - url, - repo_path, - github_token: token, - } - .into(), - ); - } - } - - pr_entry = Some(build_pr_palette_entry( - &self.github.pull_request.cache.get(&self.store), - &key, - supports_github_prs, - )); - } - - struct PaletteCandidate { - search_text: String, - label: String, - detail: String, - kind: PaletteEntryKind, - } - - let mut all_candidates = Vec::new(); - let repo_capabilities = self.repository.capabilities.get(&self.store); - - for (label, detail, command) in [ - ( - "Choose Repository".to_owned(), - "Open repository picker".to_owned(), - PaletteCommand::OpenRepoPicker, - ), - ( - "New Text Compare".to_owned(), - "Compare arbitrary pasted text".to_owned(), - PaletteCommand::NewTextCompare, - ), - ( - "GitHub Sign In".to_owned(), - "Start device flow".to_owned(), - PaletteCommand::OpenGitHubAuthModal, - ), - ( - "GitHub Account Menu".to_owned(), - "Open GitHub account actions".to_owned(), - PaletteCommand::OpenGitHubAccountMenu, - ), - ( - "GitHub Sign Out".to_owned(), - "Remove the saved GitHub session".to_owned(), - PaletteCommand::SignOutGitHub, - ), - ( - "Focus File List".to_owned(), - "Move keyboard focus to sidebar".to_owned(), - PaletteCommand::FocusFileList, - ), - ( - "Focus Diff Viewport".to_owned(), - "Move keyboard focus to editor".to_owned(), - PaletteCommand::FocusViewport, - ), - ( - "Show Working Tree".to_owned(), - "Return to the repository working tree view".to_owned(), - PaletteCommand::ShowWorkingTree, - ), - ( - "Refresh Repository".to_owned(), - "Refresh status or rerun the current compare".to_owned(), - PaletteCommand::RefreshRepository, - ), - ( - "Select Base Ref".to_owned(), - "Open the left-side ref picker".to_owned(), - PaletteCommand::OpenBaseRefPicker, - ), - ( - "Select Head Ref".to_owned(), - "Open the right-side ref picker".to_owned(), - PaletteCommand::OpenHeadRefPicker, - ), - ( - "Swap Compare Refs".to_owned(), - "Swap the current base and head refs".to_owned(), - PaletteCommand::SwapRefs, - ), - ( - "Run Compare".to_owned(), - "Compare the selected refs now".to_owned(), - PaletteCommand::StartCompare, - ), - ( - "Open Compare Menu".to_owned(), - "Change compare mode or preset".to_owned(), - PaletteCommand::OpenCompareMenu, - ), - ( - "Keymaps".to_owned(), - "Review and rebind keyboard shortcuts".to_owned(), - PaletteCommand::ShowKeyboardShortcuts, - ), - ( - "Toggle Sidebar".to_owned(), - "Show or hide the file sidebar".to_owned(), - PaletteCommand::ToggleSidebar, - ), - ( - "Toggle File Tree".to_owned(), - "Switch sidebar between tree and flat list".to_owned(), - PaletteCommand::ToggleFileTree, - ), - ( - "Expand All Folders".to_owned(), - "Expand every folder in the file tree".to_owned(), - PaletteCommand::ExpandAllFolders, - ), - ( - "Collapse All Folders".to_owned(), - "Collapse every folder in the file tree".to_owned(), - PaletteCommand::CollapseAllFolders, - ), - ( - "Toggle Wrap".to_owned(), - "Enable or disable line wrapping".to_owned(), - PaletteCommand::ToggleWrap, - ), - ( - "Toggle Continuous Scroll".to_owned(), - "Switch between continuous and single-file diff navigation".to_owned(), - PaletteCommand::ToggleContinuousScroll, - ), - ( - "Toggle Theme".to_owned(), - "Switch light and dark mode".to_owned(), - PaletteCommand::ToggleThemeMode, - ), - ( - "Change Theme".to_owned(), - "Browse and preview color themes".to_owned(), - PaletteCommand::ChangeTheme, - ), - ( - "Use Unified Layout".to_owned(), - "Set unified diff mode".to_owned(), - PaletteCommand::SetLayout(LayoutMode::Unified), - ), - ( - "Use Split Layout".to_owned(), - "Set side-by-side diff mode".to_owned(), - PaletteCommand::SetLayout(LayoutMode::Split), - ), - ( - "Use Built-in Renderer".to_owned(), - "Render diffs with Diffy's built-in engine".to_owned(), - PaletteCommand::SetRenderer(RendererKind::Builtin), - ), - ( - "Use Difftastic Renderer".to_owned(), - "Render diffs with Difftastic".to_owned(), - PaletteCommand::SetRenderer(RendererKind::Difftastic), - ), - ( - "Expand All Context".to_owned(), - "Show all hidden context in the active diff".to_owned(), - PaletteCommand::ExpandAllContext, - ), - ( - "Clear Line Selection".to_owned(), - "Clear the current partial-line staging selection".to_owned(), - PaletteCommand::ClearLineSelection, - ), - ( - "Generate Commit Message".to_owned(), - "Draft a commit message from the current changes".to_owned(), - PaletteCommand::GenerateCommitMessage, - ), - ( - "Fetch origin".to_owned(), - "Update remote references from origin".to_owned(), - PaletteCommand::FetchOrigin, - ), - ( - "Fetch all remotes".to_owned(), - "Update remote references from every configured remote".to_owned(), - PaletteCommand::FetchAllRemotes, - ), - ( - "Pull current branch".to_owned(), - "Fast-forward the current Git branch from its upstream".to_owned(), - PaletteCommand::PullCurrentBranch, - ), - ( - self.vcs_ui_profile().publish_command_label().to_owned(), - self.vcs_ui_profile().publish_command_detail().to_owned(), - PaletteCommand::PushCurrentBranch, - ), - ( - "Publish options".to_owned(), - "Choose a backend-provided publish action".to_owned(), - PaletteCommand::PublishOptions, - ), - ( - "Push current branch (force with lease)".to_owned(), - "Force-push the current Git branch; refuse if upstream moved".to_owned(), - PaletteCommand::PushCurrentBranchForceWithLease, - ), - ( - "Open Settings".to_owned(), - "Configure appearance, editor, and behavior".to_owned(), - PaletteCommand::OpenSettings, - ), - ] { - if !palette_command_available(&command, repo_capabilities) { - continue; - } - let search_text = format!("{label} {detail}"); - all_candidates.push(PaletteCandidate { - search_text, - label, - detail, - kind: PaletteEntryKind::Command(command), - }); - } - - for section in SettingsSection::ALL { - let label = format!("Settings: {}", section.label()); - let detail = "Switch settings section".to_owned(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::SetSettingsSection(section)), - }); - } - for (label, detail, mode) in [ - ( - "Use Dark Mode", - "Set settings appearance to dark", - ThemeMode::Dark, - ), - ( - "Use Light Mode", - "Set settings appearance to light", - ThemeMode::Light, - ), - ] { - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label: label.to_owned(), - detail: detail.to_owned(), - kind: PaletteEntryKind::Command(PaletteCommand::SetThemeMode(mode)), - }); - } - for pct in [80, 90, 100, 110, 125, 150, 180] { - let label = format!("Set UI Scale {pct}%"); - let detail = "Change interface density".to_owned(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::SetUiScalePct(pct)), - }); - } - for (column, label_suffix) in [(0, "Auto"), (80, "80"), (100, "100"), (120, "120")] { - let label = format!("Set Wrap Column {label_suffix}"); - let detail = "Set line wrapping column".to_owned(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::SetWrapColumn(column)), - }); - } - for lines in [1, 2, 3, 5, 7] { - let label = format!("Set Mouse Wheel Speed {lines}"); - let detail = "Set lines scrolled per wheel notch".to_owned(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::SetWheelScrollLines(lines)), - }); - } - all_candidates.push(PaletteCandidate { - search_text: "Toggle Automatic Updates auto update".to_owned(), - label: "Toggle Automatic Updates".to_owned(), - detail: "Enable or disable hourly update checks".to_owned(), - kind: PaletteEntryKind::Command(PaletteCommand::ToggleAutoUpdate), - }); - all_candidates.push(PaletteCandidate { - search_text: "Check For Updates update release".to_owned(), - label: "Check For Updates".to_owned(), - detail: "Check Diffy's release channel now".to_owned(), - kind: PaletteEntryKind::Command(PaletteCommand::CheckForUpdates), - }); - match self.update.get(&self.store) { - UpdateState::Available(update) => { - let label = format!("Install Update {}", update.version); - let detail = "Download and verify the available update".to_owned(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::InstallUpdate), - }); - } - UpdateState::ReadyToRestart(update) => { - let label = format!("Restart To Update {}", update.update.version); - let detail = "Restart Diffy and apply the staged update".to_owned(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::RestartToUpdate), - }); - } - _ => {} - } - - let repo_location = self.repository.location.get(&self.store); - for operation in JjOperation::ALL.map(VcsOperation::Jj) { - if !vcs_operation_available_for_location(&operation, repo_location.as_ref()) { - continue; - } - let label = format!("jj: {}", operation.label()); - let detail = operation.detail(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), - }); - } - if repo_location - .as_ref() - .is_some_and(|location| location.profile == VCS_PROFILE_JJ) - { - let mut destinations = self.repository.refs.with(&self.store, |refs| { - refs.iter() - .filter(|reference| { - !reference.active - && matches!(reference.kind, RefKind::Bookmark | RefKind::Branch) - }) - .map(|reference| reference.name.clone()) - .collect::>() - }); - destinations.sort(); - destinations.dedup(); - for destination in destinations.into_iter().take(12) { - let operation = VcsOperation::JjRebaseCurrentChangeOnto { - destination: destination.clone(), - }; - let label = format!("jj: {}", operation.label()); - let detail = operation.detail(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), - }); - } - let changes = self.repository.changes.get(&self.store); - for change in changes - .iter() - .filter(|change| { - !change.flags.current && !change.flags.working_copy && !change.flags.immutable - }) - .take(12) - { - let change_label = change - .short_change_id - .as_deref() - .unwrap_or(change.short_revision.as_str()) - .to_owned(); - let operation = VcsOperation::JjEditRevision { - revision: change.revision.id.clone(), - label: change_label.clone(), - }; - let label = format!("jj: {}", operation.label()); - let detail = crate::ui::vcs::change_summary_label(change); - all_candidates.push(PaletteCandidate { - search_text: format!( - "{label} {detail} {} {}", - change.short_revision, change.revision.id - ), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), - }); - } - let operation_log = self.repository.operation_log.get(&self.store); - for entry in operation_log.iter().skip(1).take(12) { - let operation_label = entry.short_operation_id.clone(); - let operation = VcsOperation::JjRestoreOperation { - operation_id: entry.operation_id.clone(), - label: operation_label.clone(), - }; - let label = format!("jj: {}", operation.label()); - let detail = operation_log_entry_detail(entry); - all_candidates.push(PaletteCandidate { - search_text: format!( - "{label} {detail} {} {}", - entry.operation_id, entry.short_operation_id - ), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), - }); - } - } - - if self - .workspace - .pre_drill_compare - .with(&self.store, |pre_drill| pre_drill.is_some()) - { - all_candidates.push(PaletteCandidate { - search_text: "Restore compare return range comparison commit drilldown".to_owned(), - label: "Restore Compare".to_owned(), - detail: "Return from the selected commit to the previous compare".to_owned(), - kind: PaletteEntryKind::Command(PaletteCommand::RestoreCompare), - }); - } - - if self - .editor - .line_selection - .with(&self.store, |selection| !selection.is_empty()) - { - all_candidates.push(PaletteCandidate { - search_text: "Comment on selected lines review pull request".to_owned(), - label: "Comment on Selected Lines".to_owned(), - detail: "Open the pull request review comment composer".to_owned(), - kind: PaletteEntryKind::Command(PaletteCommand::OpenReviewComment), - }); - } - - if self.active_pull_request_web_url().is_some() { - all_candidates.push(PaletteCandidate { - search_text: "Open pull request in GitHub browser web PR".to_owned(), - label: "Open Pull Request in GitHub".to_owned(), - detail: "Open the active pull request on github.com".to_owned(), - kind: PaletteEntryKind::Command(PaletteCommand::OpenPullRequestInGitHub), - }); - } - - let file_count = self.workspace_file_count(); - for index in 0..file_count { - let Some(file) = self.workspace_file_entry_at(index) else { - continue; - }; - let meta = self.file_list_entry_meta(index); - let detail = format!( - "File \u{2022} {} \u{2022} +{} -{}", - meta.status.label(), - meta.additions, - meta.deletions - ); - let search_text = format!("{} {detail}", file.path); - all_candidates.push(PaletteCandidate { - search_text, - label: file.path.to_string(), - detail, - kind: PaletteEntryKind::File(index), - }); - } - - let range_commits = self.workspace.range_commits.get(&self.store); - for change in &range_commits { - let label = crate::ui::vcs::change_summary_label(change); - let detail = format!("Commit {}", change.short_revision); - let search_text = format!("{} {} {}", change.short_revision, change.revision.id, label); - all_candidates.push(PaletteCandidate { - search_text, - label, - detail, - kind: PaletteEntryKind::Commit(change.revision.id.clone()), - }); - } - - let palette_repos = crate::core::frecency::recent_repo_paths(self.frecency.as_ref(), 10); - for repo in &palette_repos { - let repo_name = repo - .file_name() - .and_then(|name| name.to_str()) - .filter(|n| *n != ".") - .map(str::to_owned) - .unwrap_or_else(|| repo.display().to_string()); - let detail = repo.display().to_string(); - let search_text = format!("{repo_name} {detail}"); - all_candidates.push(PaletteCandidate { - search_text, - label: repo_name, - detail, - kind: PaletteEntryKind::Repo(repo.clone()), - }); - } - - let repo_refs = self.repository.refs.get(&self.store); - for reference in repo_refs.iter().filter(|reference| { - matches!( - reference.kind, - RefKind::Branch - | RefKind::RemoteBranch - | RefKind::Bookmark - | RefKind::RemoteBookmark - | RefKind::Tag - ) - }) { - let (detail, _) = self - .vcs_ui_profile() - .ref_kind_label_and_icon(reference.kind); - let search_text = format!("{} {}", reference.name, detail); - all_candidates.push(PaletteCandidate { - search_text, - label: reference.name.clone(), - detail: detail.to_owned(), - kind: PaletteEntryKind::Ref(CompareField::Left, reference.name.clone()), - }); - } - - let mut entries: Vec; - if query.is_empty() { - entries = all_candidates - .into_iter() - .map(|c| PaletteEntry { - label: c.label, - detail: c.detail, - kind: c.kind, - highlights: Vec::new(), - rhs: None, - disabled: false, - }) - .collect(); - } else { - let haystack: Vec<&str> = all_candidates - .iter() - .map(|c| c.search_text.as_str()) - .collect(); - let config = neo_frizbee::Config { - max_typos: Some(2), - sort: false, - ..Default::default() - }; - let matches = neo_frizbee::match_list_indices(query, &haystack, &config); - let mut scored: Vec<_> = matches - .into_iter() - .map(|m| { - let c = &all_candidates[m.index as usize]; - ( - m.score, - PaletteEntry { - label: c.label.clone(), - detail: c.detail.clone(), - kind: c.kind.clone(), - highlights: highlight_ranges_for_visible_match( - query, &c.label, &m.indices, &config, - ), - rhs: None, - disabled: false, - }, - ) - }) - .collect(); - scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.label.cmp(&b.1.label))); - entries = scored.into_iter().map(|(_, e)| e).collect(); - } - if let Some(pr) = pr_entry { - entries.insert(0, pr); - } - entries.truncate(18); - let entry_count = entries.len(); - self.overlays - .command_palette - .entries - .set(&self.store, entries); - let current_selected = self - .overlays - .command_palette - .selected_index - .get(&self.store); - self.overlays.command_palette.selected_index.set( - &self.store, - current_selected.min(entry_count.saturating_sub(1)), - ); - self.overlays.command_palette.list.update(&self.store, |l| { - l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); - l.clamp_scroll(entry_count); - }); - out_effects - } - - fn shift_loaded_file(&mut self, delta: isize) -> Vec { - let file_count = self.workspace_file_count(); - if file_count == 0 { - return Vec::new(); - } - let current = self.reconcile_selected_file_index_from_path().unwrap_or(0); - let next = if delta.is_negative() { - current.saturating_sub(delta.unsigned_abs()) - } else { - current - .saturating_add(delta as usize) - .min(file_count.saturating_sub(1)) - }; - self.select_file(next, true) - } - - fn select_file(&mut self, index: usize, reveal: bool) -> Vec { - if self.settings.continuous_scroll - && !matches!( - self.workspace.source.get(&self.store), - WorkspaceSource::None - ) - { - let target = self - .file_start_offset_px(index) - .min(self.global_max_scroll_top_px()); - self.set_viewport_anchor_for_global(target, ViewportAnchorBias::PreserveTop); - self.workspace.global_scroll_top_px.set(&self.store, target); - } - self.select_file_inner(index, reveal) - } - - fn select_file_inner(&mut self, index: usize, reveal: bool) -> Vec { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare => self.select_compare_file(index, reveal), - WorkspaceSource::TextCompare => self.select_text_compare_file(index, reveal), - WorkspaceSource::Status => self.select_status_item(index, reveal), - WorkspaceSource::None => { - self.startup.preferred_file_index = Some(index); - Vec::new() - } - } - } - - fn active_file_matches_workspace_file(&self, index: usize) -> bool { - let Some(path) = self.workspace_file_path_at(index) else { - return false; - }; - let source = self.workspace.source.get(&self.store); - let selected_bucket = self.workspace.selected_change_bucket.get(&self.store); - self.workspace.active_file.with(&self.store, |active| { - active.as_ref().is_some_and(|active| { - if active.index != index || active.path != path { - return false; - } - match source { - WorkspaceSource::Status => selected_bucket.is_some_and(|bucket| { - let (left_ref, right_ref) = self.status_refs_for_bucket(bucket); - active.left_ref == left_ref && active.right_ref == right_ref - }), - WorkspaceSource::Compare | WorkspaceSource::TextCompare => true, - WorkspaceSource::None => false, - } - }) - }) - } - - fn select_text_compare_file(&mut self, index: usize, reveal: bool) -> Vec { - let Some(entry) = self.workspace_file_entry_at(index) else { - self.push_error("Selected file index is out of range."); - return Vec::new(); - }; - let mut effects = vec![ - SyntaxEffect::EnsureSyntaxPackForPath { - path: entry.path.to_string(), - } - .into(), - ]; - effects.extend(self.select_loaded_compare_file(index, reveal)); - effects - } - - fn select_compare_file(&mut self, index: usize, reveal: bool) -> Vec { - let Some(entry) = self.workspace_file_entry_at(index) else { - self.push_error("Selected file index is out of range."); - return Vec::new(); - }; - - if !self.compare_file_is_large(index) { - let mut effects = vec![ - SyntaxEffect::EnsureSyntaxPackForPath { - path: entry.path.to_string(), - } - .into(), - ]; - effects.extend(self.select_loaded_compare_file(index, reveal)); - return effects; - } - - let entry_path = entry.path.to_string(); - - if let Some(mut active_file) = self.cached_compare_file_at(index, &entry_path) { - active_file.last_used_tick = self.next_file_working_set_tick(); - self.workspace - .selected_file_index - .set(&self.store, Some(index)); - self.workspace - .selected_file_path - .set(&self.store, Some(entry_path.clone())); - self.workspace.selected_change_bucket.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - self.workspace - .active_file - .set(&self.store, Some(active_file.clone())); - self.cache_active_file(active_file); - self.compare_progress.set(&self.store, None); - self.editor_clear_document(); - self.file_list.hovered_index.set(&self.store, Some(index)); - if reveal { - self.reveal_file_list_row(index); - } - let mut effects = self.sync_editor_scroll_from_global(); - effects.push(SyntaxEffect::EnsureSyntaxPackForPath { path: entry_path }.into()); - effects.extend(self.request_active_file_syntax_effect()); - return effects; - } - - let should_load = self.should_enqueue_file_load( - index, - &entry_path, - CompareWorkPriority::InteractiveSelectedFile, - ); - - // If we're mid-compare (first file selection post-CompareFinished), - // flip the phase so the progress panel reports "Preparing first - // file…". Subsequent selections don't touch compare_progress. - self.compare_progress.update(&self.store, |slot| { - if let Some(p) = slot.as_mut() { - Arc::make_mut(p).phase = ComparePhase::RenderingFirstFile; - } - }); - - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - self.push_error("Open a repository before selecting a compare file."); - return Vec::new(); - }; - let deferred_file = self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .and_then(|output| compare_output_deferred_summary(output, index)) - }); - - self.workspace - .selected_file_index - .set(&self.store, Some(index)); - self.workspace - .selected_file_path - .set(&self.store, Some(entry_path.clone())); - self.workspace.selected_change_bucket.set(&self.store, None); - self.workspace.active_file.set(&self.store, None); - self.workspace.active_file_loading.set( - &self.store, - Some(ActiveFileLoading { - index, - path: entry_path.clone(), - priority: CompareWorkPriority::InteractiveSelectedFile, - }), - ); - self.mark_file_cache_loading( - index, - entry_path.clone(), - CompareWorkPriority::InteractiveSelectedFile, - ); - self.editor_clear_document(); - self.file_list.hovered_index.set(&self.store, Some(index)); - if reveal { - self.reveal_file_list_row(index); - } - - let mut effects = vec![ - SyntaxEffect::EnsureSyntaxPackForPath { - path: entry_path.clone(), - } - .into(), - ]; - if should_load { - effects.push( - CompareEffect::LoadFile(Task { - generation: self.workspace.compare_generation.get(&self.store), - request: CompareFileRequest { - repo_path, - request: vcs_compare_request( - self.compare.mode.get(&self.store), - self.compare.left_ref.get(&self.store), - self.compare.right_ref.get(&self.store), - self.compare.layout.get(&self.store), - self.compare.renderer.get(&self.store), - ), - path: entry_path, - index, - deferred_file, - priority: CompareWorkPriority::InteractiveSelectedFile, - }, - }) - .into(), - ); - } - effects - } - - #[profiling::function] - fn select_loaded_compare_file(&mut self, index: usize, reveal: bool) -> Vec { - let mut selected_path = None; - let mut prepared = None; - let mut oob = false; - self.workspace - .compare_output - .update(&self.store, |maybe_output| { - let Some(output) = maybe_output.as_mut() else { - return; - }; - let Some(carbon_file) = output.carbon.files.get(index) else { - oob = true; - return; - }; - selected_path = Some(carbon_file.path().to_owned()); - prepared = Some(prepare_active_file(index, carbon_file)); - }); - - let Some(prepared) = prepared else { - if oob { - self.push_error("Selected file index is out of range."); - return Vec::new(); - } - self.startup.preferred_file_index = Some(index); - return Vec::new(); - }; - - let Some(path) = selected_path else { - self.startup.preferred_file_index = Some(index); - return Vec::new(); - }; - - self.install_compare_active_file(index, path, prepared); - if reveal { - self.reveal_file_list_row(index); - } - let mut effects = self.sync_editor_scroll_from_global(); - effects.extend(self.request_active_file_syntax_effect()); - effects - } - - fn reveal_file_list_row(&mut self, index: usize) { - let row_top = self.sidebar_row_index_for_file(index) as f32 * self.file_list_row_stride(); - let row_bottom = row_top + self.file_list.row_height.get(&self.store); - let scroll = self.file_list.scroll_offset_px.get(&self.store); - let viewport = self.file_list.viewport_height.get(&self.store); - if row_top < scroll { - self.file_list.scroll_offset_px.set(&self.store, row_top); - } else if row_bottom > scroll + viewport { - self.file_list - .scroll_offset_px - .set(&self.store, row_bottom - viewport); - } - self.file_list_clamp_scroll(self.sidebar_row_count()); - } - - fn select_status_item(&mut self, index: usize, reveal: bool) -> Vec { - let Some(file_change) = self - .workspace - .status_file_changes - .with(&self.store, |changes| changes.get(index).cloned()) - else { - tracing::warn!( - index, - "select_status_item: index out of range, returning empty" - ); - return Vec::new(); - }; - tracing::debug!( - index, - path = %file_change.path, - bucket = ?file_change.bucket, - status_gen = self.workspace.status_generation.get(&self.store), - "select_status_item: dispatching LoadStatusDiff" - ); - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - tracing::warn!("select_status_item: no repo_path"); - return Vec::new(); - }; - - self.workspace - .source - .set(&self.store, WorkspaceSource::Status); - // Keep the current document visible while the new diff loads — no - // Loading state, no tear-down. handle_status_diff_finished swaps the - // ActiveFile atomically when the fresh diff arrives. - self.workspace - .selected_file_index - .set(&self.store, Some(index)); - self.workspace - .selected_file_path - .set(&self.store, Some(file_change.path.clone())); - self.workspace - .selected_change_bucket - .set(&self.store, Some(file_change.bucket)); - let (left_ref, right_ref) = self.status_refs_for_bucket(file_change.bucket); - let active_matches_selection = self.workspace.active_file.with(&self.store, |af| { - af.as_ref().is_some_and(|active| { - active.index == index - && active.path == file_change.path - && active.left_ref == left_ref - && active.right_ref == right_ref - }) - }); - if active_matches_selection { - self.workspace.active_file_loading.set(&self.store, None); - self.clear_file_cache_loading(index); - self.file_list.hovered_index.set(&self.store, Some(index)); - if reveal { - self.reveal_file_list_row(index); - } - let mut effects = self.sync_editor_scroll_from_global(); - effects.push(ensure_syntax_packs_for_file_change_effect(&file_change)); - effects.extend(self.request_active_file_syntax_effect()); - return effects; - } else if let Some(mut active_file) = self.cached_status_file_at(index, &file_change) { - active_file.last_used_tick = self.next_file_working_set_tick(); - self.workspace.active_file_loading.set(&self.store, None); - self.workspace - .active_file - .set(&self.store, Some(active_file.clone())); - self.cache_active_file(active_file); - self.editor_clear_document(); - self.file_list.hovered_index.set(&self.store, Some(index)); - if reveal { - self.reveal_file_list_row(index); - } - let mut effects = self.sync_editor_scroll_from_global(); - effects.push(ensure_syntax_packs_for_file_change_effect(&file_change)); - effects.extend(self.request_active_file_syntax_effect()); - return effects; - } else { - let should_load = self.should_enqueue_file_load( - index, - &file_change.path, - CompareWorkPriority::InteractiveSelectedFile, - ); - self.workspace.active_file_loading.set( - &self.store, - Some(ActiveFileLoading { - index, - path: file_change.path.clone(), - priority: CompareWorkPriority::InteractiveSelectedFile, - }), - ); - self.mark_file_cache_loading( - index, - file_change.path.clone(), - CompareWorkPriority::InteractiveSelectedFile, - ); - self.file_list.hovered_index.set(&self.store, Some(index)); - if reveal { - self.reveal_file_list_row(index); - } - - let mut effects = vec![ensure_syntax_packs_for_file_change_effect(&file_change)]; - if should_load { - let generation = self.workspace.status_generation.get(&self.store); - let renderer = self.compare.renderer.get(&self.store); - effects.push( - RepositoryEffect::LoadStatusDiff { - task: Task { - generation, - request: StatusDiffRequest { - repo_path, - file_change, - renderer, - }, - }, - index, - } - .into(), - ); - } - return effects; - } - } - - fn apply_selected_status_operation(&mut self, operation: FileOperation) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.staging_area) - }) - { - self.push_error("This repository backend does not support staging operations."); - return Vec::new(); - } - if self.workspace.source.get(&self.store) != WorkspaceSource::Status { - return Vec::new(); - } - if self.workspace.status_operation_pending.get(&self.store) { - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - let Some(index) = self.workspace.selected_file_index.get(&self.store) else { - return Vec::new(); - }; - let Some(file_change) = self - .workspace - .status_file_changes - .with(&self.store, |changes| changes.get(index).cloned()) - else { - return Vec::new(); - }; - - self.workspace - .status_operation_pending - .set(&self.store, true); - vec![ - RepositoryEffect::ApplyFileOperation(FileOperationRequest { - repo_path, - file_change, - operation, - }) - .into(), - ] - } - - fn apply_file_status_operation( - &mut self, - index: usize, - operation: FileOperation, - ) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.staging_area) - }) - { - self.push_error("This repository backend does not support staging operations."); - return Vec::new(); - } - if self.workspace.source.get(&self.store) != WorkspaceSource::Status { - return Vec::new(); - } - if self.workspace.status_operation_pending.get(&self.store) { - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - let Some(file_change) = self - .workspace - .status_file_changes - .with(&self.store, |changes| changes.get(index).cloned()) - else { - return Vec::new(); - }; - - self.workspace - .status_operation_pending - .set(&self.store, true); - vec![ - RepositoryEffect::ApplyFileOperation(FileOperationRequest { - repo_path, - file_change, - operation, - }) - .into(), - ] - } - - fn apply_batch_scope_operation( - &mut self, - buckets: &[ChangeBucket], - operation: FileOperation, - ) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.staging_area) - }) - { - self.push_error("This repository backend does not support staging operations."); - return Vec::new(); - } - if self.workspace.source.get(&self.store) != WorkspaceSource::Status { - return Vec::new(); - } - if self.workspace.status_operation_pending.get(&self.store) { - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - let file_changes: Vec = - self.workspace - .status_file_changes - .with(&self.store, |changes| { - changes - .iter() - .filter(|change| buckets.contains(&change.bucket)) - .cloned() - .collect() - }); - if file_changes.is_empty() { - return Vec::new(); - } - - self.workspace - .status_operation_pending - .set(&self.store, true); - vec![ - RepositoryEffect::ApplyBatchFileOperation(BatchFileOperationRequest { - repo_path, - file_changes, - operation, - }) - .into(), - ] - } - - fn current_hunk_index_from_hover(&self) -> Option { - self.editor - .hovered_hunk_index - .get(&self.store) - .or_else(|| self.editor_current_hunk_index().map(|(idx, _)| idx as i16)) - } - - fn current_render_line_index_from_hover(&self) -> Option { - self.editor - .hovered_render_line_index - .get(&self.store) - .or_else(|| self.editor.hovered_row.get(&self.store)) - } - - fn apply_hunk_operation( - &mut self, - operation: FileOperation, - explicit_hunk: Option, - ) -> Vec { - tracing::debug!( - ?operation, - ?explicit_hunk, - source = ?self.workspace.source.get(&self.store), - pending = self.workspace.status_operation_pending.get(&self.store), - hovered_row = ?self.editor.hovered_row.get(&self.store), - hovered_hunk_index = ?self.editor.hovered_hunk_index.get(&self.store), - "apply_hunk_operation: entered" - ); - if self.workspace.source.get(&self.store) != WorkspaceSource::Status { - tracing::debug!("apply_hunk_operation: bail: source != Status"); - return Vec::new(); - } - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.partial_hunk_mutation) - }) - { - self.push_error("This repository backend does not support hunk operations."); - return Vec::new(); - } - if self.workspace.status_operation_pending.get(&self.store) { - tracing::debug!("apply_hunk_operation: bail: status_operation_pending=true"); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - tracing::debug!("apply_hunk_operation: bail: no repo_path"); - return Vec::new(); - }; - let Some(bucket) = self.workspace.selected_change_bucket.get(&self.store) else { - tracing::debug!("apply_hunk_operation: bail: no selected_change_bucket"); - return Vec::new(); - }; - let resolved = explicit_hunk.or_else(|| self.current_hunk_index_from_hover()); - let hunk_index = match resolved { - Some(idx) if idx >= 0 => idx as usize, - _ => { - tracing::debug!(?resolved, "apply_hunk_operation: bail: no hunk_index"); - return Vec::new(); - } - }; - - let patch_text = self.workspace.active_file.with(&self.store, |af| { - let active = af.as_ref()?; - patch::format_carbon_hunk_patch( - &active.carbon_file, - hunk_index, - operation != FileOperation::Stage, - ) - }); - let Some(patch) = patch_text else { - tracing::debug!( - hunk_index, - "apply_hunk_operation: bail: format_hunk_patch returned None" - ); - return Vec::new(); - }; - - tracing::debug!( - ?operation, - hunk_index, - "apply_hunk_operation: dispatching ApplyPatchOperation" - ); - self.workspace - .status_operation_pending - .set(&self.store, true); - vec![ - RepositoryEffect::ApplyPatchOperation(PatchOperationRequest { - repo_path, - patch, - bucket, - operation, - }) - .into(), - ] - } - - fn toggle_line_selection(&mut self, row: usize, _extend: bool) { - let line_opt = self.workspace.active_file.with(&self.store, |af| { - af.as_ref() - .and_then(|active| active.render_doc.lines.get(row).copied()) - }); - let Some(line) = line_opt else { - return; - }; - let kind = line.row_kind(); - if !matches!( - kind, - crate::editor::diff::render_doc::RenderRowKind::Added - | crate::editor::diff::render_doc::RenderRowKind::Removed - | crate::editor::diff::render_doc::RenderRowKind::Modified - ) { - return; - } - if line.hunk_index < 0 { - return; - } - let hunk_id = line.hunk_index as u32; - self.editor.line_selection.update(&self.store, |ls| { - if line.old_line_index >= 0 { - ls.toggle(hunk_id, carbon::DiffSide::Old, line.old_line_index as u32); - } - if line.new_line_index >= 0 { - ls.toggle(hunk_id, carbon::DiffSide::New, line.new_line_index as u32); - } - ls.last_toggled_row = Some(row); - }); - } - - fn toggle_line_selection_range(&mut self, row: usize, anchor: usize) { - self.insert_line_selection_range(row, anchor, false); - } - - fn set_line_selection_range(&mut self, row: usize, anchor: usize) { - self.insert_line_selection_range(row, anchor, true); - } - - fn insert_line_selection_range(&mut self, row: usize, anchor: usize, clear_first: bool) { - let (start, end) = if row <= anchor { - (row, anchor) - } else { - (anchor, row) - }; - let lines = self.workspace.active_file.with(&self.store, |af| { - let Some(active) = af.as_ref() else { - return Vec::new(); - }; - (start..=end) - .filter_map(|r| active.render_doc.lines.get(r).copied()) - .collect::>() - }); - if lines.is_empty() { - return; - } - // Staging only selects changed lines; in PR review mode a comment can anchor - // to any line (incl. context), like GitHub. - let review = self.pull_request_review_enabled(); - self.editor.line_selection.update(&self.store, |ls| { - if clear_first { - ls.clear(); - } - for line in &lines { - use crate::editor::diff::render_doc::RenderRowKind; - let kind = line.row_kind(); - if !kind.is_body() || line.hunk_index < 0 { - continue; - } - if !review - && !matches!( - kind, - RenderRowKind::Added | RenderRowKind::Removed | RenderRowKind::Modified - ) - { - continue; - } - let hunk_id = line.hunk_index as u32; - if line.old_line_index >= 0 { - ls.entries - .insert(crate::editor::diff::state::LineSelectionKey { - file_path: None, - hunk_id, - side: carbon::DiffSide::Old, - source_index: line.old_line_index as u32, - }); - } - if line.new_line_index >= 0 { - ls.entries - .insert(crate::editor::diff::state::LineSelectionKey { - file_path: None, - hunk_id, - side: carbon::DiffSide::New, - source_index: line.new_line_index as u32, - }); - } - } - ls.last_toggled_row = Some(row); - }); - } - - fn toggle_current_line_selection(&mut self) { - let Some(row) = self.current_render_line_index_from_hover() else { - self.push_error("Move the row cursor to a changed line before selecting lines."); - return; - }; - self.toggle_line_selection(row, false); - } - - fn toggle_current_line_selection_range(&mut self) { - let Some(row) = self.current_render_line_index_from_hover() else { - self.push_error("Move the row cursor to a changed line before selecting lines."); - return; - }; - let anchor = self - .editor - .line_selection - .with(&self.store, |ls| ls.last_toggled_row); - if let Some(anchor) = anchor { - self.toggle_line_selection_range(row, anchor); - } else { - self.toggle_line_selection(row, false); - } - } - - fn apply_line_selection_operation(&mut self, operation: FileOperation) -> Vec { - if self.workspace.source.get(&self.store) != WorkspaceSource::Status { - return Vec::new(); - } - if self.workspace.status_operation_pending.get(&self.store) { - return Vec::new(); - } - if self - .editor - .line_selection - .with(&self.store, |ls| ls.is_empty()) - { - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - let Some(bucket) = self.workspace.selected_change_bucket.get(&self.store) else { - return Vec::new(); - }; - let reverse = operation != FileOperation::Stage; - - let (hunk_indices, selection_snapshot) = - self.editor.line_selection.with(&self.store, |ls| { - let indices: Vec = ls - .entries - .iter() - .map(|key| key.hunk_id) - .collect::>() - .into_iter() - .collect(); - (indices, ls.clone()) - }); - - let patches = self.workspace.active_file.with(&self.store, |af| { - let Some(active) = af.as_ref() else { - return Vec::new(); - }; - let mut patches = Vec::new(); - for hunk_idx in hunk_indices { - let selected = selection_snapshot - .selected_lines_for_hunk(hunk_idx) - .into_iter() - .map(|key| patch::CarbonLineSelection { - side: key.side, - source_index: key.source_index, - }) - .collect::>(); - let patch = patch::format_carbon_lines_patch( - &active.carbon_file, - carbon::u32_to_usize_saturating(hunk_idx), - &selected, - reverse, - ); - if let Some(p) = patch { - patches.push(p); - } - } - patches - }); - - self.editor - .line_selection - .update(&self.store, |ls| ls.clear()); - - if patches.is_empty() { - return Vec::new(); - } - - self.workspace - .status_operation_pending - .set(&self.store, true); - patches - .into_iter() - .map(|p| { - RepositoryEffect::ApplyPatchOperation(PatchOperationRequest { - repo_path: repo_path.clone(), - patch: p, - bucket, - operation, - }) - .into() - }) - .collect() - } - - fn scroll_viewport_lines(&mut self, delta_lines: i32) -> Vec { - let step_px = 20_i32; - let delta_px = delta_lines.saturating_mul(step_px); - self.scroll_viewport_px(delta_px) - } - - fn scroll_active_overlay_list_px(&mut self, delta_px: i32) { - match self.overlays_top() { - Some( - OverlaySurface::RepoPicker - | OverlaySurface::RefPicker - | OverlaySurface::ThemePicker - | OverlaySurface::FontPicker, - ) => { - let count = self.overlays.picker.entries.with(&self.store, |e| e.len()); - self.overlays - .picker - .list - .update(&self.store, |l| l.scroll_px(delta_px, count)); - } - Some(OverlaySurface::CommandPalette) => { - let count = self - .overlays - .command_palette - .entries - .with(&self.store, |e| e.len()); - self.overlays - .command_palette - .list - .update(&self.store, |l| l.scroll_px(delta_px, count)); - } - _ => {} - } - } - - fn scroll_viewport_px(&mut self, delta_px: i32) -> Vec { - if !self.settings.continuous_scroll { - let current = self.editor.scroll_top_px.get(&self.store); - let max = self.editor_max_scroll_top_px(); - let next = apply_scroll_delta_px(current, delta_px, max); - self.editor.scroll_top_px.set(&self.store, next); - return Vec::new(); - } - - if delta_px == 0 { - return Vec::new(); - } - - let current = self.workspace.global_scroll_top_px.get(&self.store); - let target = apply_scroll_delta_px(current, delta_px, self.global_max_scroll_top_px()); - self.scroll_viewport_to_global(target) - } - - fn clear_file_scroll_layout(&mut self) { - self.workspace - .file_content_heights - .set(&self.store, Vec::new()); - self.workspace - .file_scroll_total_height_px - .set(&self.store, 0); - self.workspace - .pending_file_content_heights - .set(&self.store, HashMap::new()); - self.workspace - .file_scroll_recompute_pending - .set(&self.store, false); - self.workspace - .viewport_scrollbar_drag - .set(&self.store, None); - self.virtual_diff_document.clear(); - self.virtual_scroll.clear(); - self.last_virtual_scroll_top_px = None; - } - - fn reset_file_scroll_layout(&mut self) { - self.workspace - .file_content_heights - .set(&self.store, Vec::new()); - self.workspace - .pending_file_content_heights - .set(&self.store, HashMap::new()); - self.workspace - .file_scroll_recompute_pending - .set(&self.store, false); - self.workspace - .viewport_scrollbar_drag - .set(&self.store, None); - self.virtual_scroll.clear(); - self.last_virtual_scroll_top_px = None; - self.recompute_file_scroll_total_height_px(); - } - - pub fn recompute_file_scroll_total_height_px(&mut self) { - let count = self.workspace_file_count(); - let source = self.workspace.source.get(&self.store); - let generation = self.workspace_render_generation(); - if self - .virtual_diff_document - .sync_identity(source, generation, count) - { - self.virtual_scroll.clear(); - self.last_virtual_scroll_top_px = None; - } - self.workspace - .file_content_heights - .update(&self.store, |heights| { - if heights.len() > count { - heights.truncate(count); - } - }); - - let heights = (0..count) - .map(|index| self.file_scroll_height_px(index).max(1)) - .collect::>(); - self.virtual_diff_document.rebuild_heights(heights); - let total = self.virtual_diff_document.total_u32(); - self.workspace - .file_scroll_total_height_px - .set(&self.store, total); - } - - fn update_file_scroll_heights(&mut self, old_heights: Vec<(usize, u32)>) { - let count = self.workspace_file_count(); - if self.virtual_diff_document.len() != count { - self.recompute_file_scroll_total_height_px(); - return; - } - - let mut total = self.workspace.file_scroll_total_height_px.get(&self.store); - for (index, old_height) in old_heights { - if index >= count { - continue; - } - let new_height = self.file_scroll_height_px(index).max(1); - total = total.saturating_sub(old_height).saturating_add(new_height); - self.virtual_diff_document.update_height(index, new_height); - } - self.workspace - .file_scroll_total_height_px - .set(&self.store, total); - } - - pub fn update_file_content_height_px(&mut self, index: usize, height: u32) -> bool { - let count = self.workspace_file_count(); - if index >= count || height == 0 { - return false; - } - if self.settings.continuous_scroll - && self - .workspace - .viewport_scrollbar_drag - .get(&self.store) - .is_some() - { - self.workspace - .pending_file_content_heights - .update(&self.store, |pending| { - pending.insert(index, height); - }); - return false; - } - if self.virtual_diff_document.len() != count { - self.recompute_file_scroll_total_height_px(); - } - - let old_slot_height = self.file_scroll_height_px(index); - let old_total = self.total_diff_height_px(); - let anchor = self - .settings - .continuous_scroll - .then(|| self.current_or_derived_viewport_anchor()) - .flatten(); - let row_count = self.workspace_file_row_count(index); - let mut recorded_changed = false; - self.workspace - .file_content_heights - .update(&self.store, |heights| { - if heights.len() < count { - heights.resize(count, None); - } - if heights[index] != Some(height) { - heights[index] = Some(height); - recorded_changed = true; - } - }); - - let mut calibration_initialized = false; - if let Some(rows) = row_count - && rows > 0 - { - let sample_q16 = (u64::from(height) << 16) / u64::from(rows); - let prev = self.workspace.measured_px_per_row_q16.get(&self.store); - let next = if prev == 0 { - calibration_initialized = true; - sample_q16 as u32 - } else { - (((u64::from(prev) * 7) + sample_q16) / 8) as u32 - }; - self.workspace - .measured_px_per_row_q16 - .set(&self.store, next); - } - - if calibration_initialized { - self.recompute_file_scroll_total_height_px(); - } - - if recorded_changed { - let new_slot_height = self.file_scroll_height_px(index); - let slot_height_changed = new_slot_height != old_slot_height; - if calibration_initialized { - self.workspace - .file_scroll_total_height_px - .set(&self.store, self.virtual_diff_document.total_u32()); - } else { - let next_total = old_total - .saturating_sub(old_slot_height) - .saturating_add(new_slot_height); - self.workspace - .file_scroll_total_height_px - .set(&self.store, next_total); - self.virtual_diff_document - .update_height(index, new_slot_height.max(1)); - } - - if self.settings.continuous_scroll - && slot_height_changed - && let Some(anchor) = anchor - { - self.rebase_viewport_anchor(anchor); - } - } - - recorded_changed && old_slot_height != self.file_scroll_height_px(index) - } - - pub fn update_virtual_diff_item_height_px( - &mut self, - item_id: VirtualDiffItemId, - height: u32, - ) -> bool { - if item_id.kind != VirtualDiffItemKind::File - || item_id.source != self.workspace.source.get(&self.store) - || item_id.generation != self.workspace_render_generation() - { - return false; - } - self.update_file_content_height_px(item_id.index, height) - } - - pub fn virtual_stream_item( - &self, - file_index: usize, - kind: VirtualDiffItemKind, - ordinal: u32, - stable_key: u64, - sort_key: u64, - measured_height_px: Option, - ) -> VirtualDiffStreamItem { - VirtualDiffStreamItem::new( - VirtualDiffItemId::new( - self.workspace.source.get(&self.store), - self.workspace_render_generation(), - kind, - file_index, - ordinal, - stable_key, - ), - sort_key, - measured_height_px.unwrap_or_else(|| estimated_virtual_item_height_px(kind)), - measured_height_px, - ) - } - - fn virtual_stream_items_for_viewport_doc( - &self, - source: WorkspaceSource, - generation: u64, - slots: &[ViewportSlotKey], - doc: &RenderDoc, - ) -> Vec { - let mut items = Vec::new(); - let mut slot_pos = None::; - let mut local_ordinal = 0_u32; - - for (line_index, line) in doc.lines.iter().enumerate() { - if line.row_kind() == RenderRowKind::FileHeader { - slot_pos = Some(slot_pos.map_or(0, |pos| pos.saturating_add(1))); - local_ordinal = 0; - } - - let Some(slot) = slot_pos.and_then(|pos| slots.get(pos)) else { - continue; - }; - let Some(kind) = virtual_stream_item_kind(slot, line) else { - continue; - }; - let ordinal = match kind { - VirtualDiffItemKind::FileHeader => 0, - VirtualDiffItemKind::Hunk if line.hunk_index >= 0 => line.hunk_index as u32, - _ => local_ordinal, - }; - - items.push(VirtualDiffStreamItem::new( - VirtualDiffItemId::new( - source, - generation, - kind, - slot.index, - ordinal, - virtual_row_stable_key(line, ordinal), - ), - virtual_row_sort_key(line_index), - estimated_virtual_item_height_px(kind), - None, - )); - local_ordinal = local_ordinal.saturating_add(1); - } - - items - } - - fn file_scroll_height_px(&self, index: usize) -> u32 { - self.workspace - .file_content_heights - .with(&self.store, |heights| heights.get(index).copied().flatten()) - .unwrap_or_else(|| self.estimated_file_height_px(index)) - } - - fn viewport_file_scroll_height_px(&self, index: usize) -> u32 { - if let Some(height) = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| { - drag.as_ref() - .and_then(|drag| drag.file_heights_px.get(index).copied()) - }) - { - return height; - } - self.file_scroll_height_px(index) - } - - pub fn workspace_file_count(&self) -> usize { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - let count = self.workspace.compare_output.with(&self.store, |output| { - output.as_ref().map(CompareOutput::file_count).unwrap_or(0) - }); - count.max(self.workspace.files.with(&self.store, |f| f.len())) - } - WorkspaceSource::Status => self - .workspace - .status_file_changes - .with(&self.store, |s| s.len()), - WorkspaceSource::None => self.workspace.files.with(&self.store, |f| f.len()), - } - } - - pub fn workspace_file_path_at(&self, index: usize) -> Option { - self.workspace_file_entry_at(index) - .map(|entry| entry.path.to_string()) - } - - pub fn selected_workspace_file_index(&self) -> Option { - let count = self.workspace_file_count(); - let selected_index = self - .workspace - .selected_file_index - .get(&self.store) - .filter(|index| *index < count); - - if let Some(path) = self.workspace.selected_file_path.get(&self.store) { - if let Some(index) = selected_index - && self - .workspace_file_entry_at(index) - .is_some_and(|entry| entry.path == path.as_str()) - { - return Some(index); - } - if let Some(index) = self.workspace_file_index_for_path(&path) { - return Some(index); - } - } - - selected_index - } - - fn reconcile_selected_file_index_from_path(&mut self) -> Option { - let resolved = self.selected_workspace_file_index(); - if let Some(index) = resolved - && self.workspace.selected_file_index.get(&self.store) != Some(index) - { - self.workspace - .selected_file_index - .set(&self.store, Some(index)); - } - resolved - } - - pub fn workspace_render_generation(&self) -> u64 { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare => self.workspace.compare_generation.get(&self.store), - WorkspaceSource::TextCompare => self.text_compare.generation, - WorkspaceSource::Status => self.workspace.status_generation.get(&self.store), - WorkspaceSource::None => 0, - } - } - - pub fn estimated_file_height_px(&self, index: usize) -> u32 { - const BASELINE_ROWS: u32 = 8; - let row_height_q16 = { - let cal = self.workspace.measured_px_per_row_q16.get(&self.store); - if cal == 0 { 24_u32 << 16 } else { cal } - }; - let row_height_px = - |rows: u32| ((u64::from(rows) * u64::from(row_height_q16)) >> 16) as u32; - - if matches!( - self.workspace.source.get(&self.store), - WorkspaceSource::Compare | WorkspaceSource::TextCompare - ) && let Some(rows) = self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .and_then(|output| output.carbon.files.get(index)) - .map(estimated_carbon_file_rows_with_overhead) - }) { - return row_height_px(rows); - } - - let line_count = match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - if index < self.workspace_file_count() { - let meta = self.file_list_entry_meta(index); - meta.additions.saturating_add(meta.deletions).max(1) as u32 + BASELINE_ROWS - } else { - BASELINE_ROWS - } - } - WorkspaceSource::Status => BASELINE_ROWS, - WorkspaceSource::None => BASELINE_ROWS, - }; - row_height_px(line_count) - } - - fn workspace_file_row_count(&self, index: usize) -> Option { - if !matches!( - self.workspace.source.get(&self.store), - WorkspaceSource::Compare | WorkspaceSource::TextCompare - ) { - return None; - } - self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .and_then(|output| output.carbon.files.get(index)) - .map(estimated_carbon_file_rows_with_overhead) - }) - } - - pub fn total_diff_height_px(&self) -> u32 { - if let Some(total) = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| { - drag.as_ref().map(|drag| drag.metrics.content_height_px) - }) - { - return total; - } - let cached = self.workspace.file_scroll_total_height_px.get(&self.store); - if cached > 0 || self.workspace_file_count() == 0 { - return cached; - } - - self.virtual_diff_document.total_u32() - } - - pub fn file_start_offset_px(&self, index: usize) -> u32 { - if self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| drag.is_none()) - && self.virtual_diff_document.len() == self.workspace_file_count() - { - return self.virtual_diff_document.prefix_u32(index); - } - let mut total: u32 = 0; - for slot in 0..index.min(self.workspace_file_count()) { - total = total.saturating_add(self.viewport_file_scroll_height_px(slot)); - } - total - } - - pub fn global_max_scroll_top_px(&self) -> u32 { - if let Some(max) = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| { - drag.as_ref().map(|drag| drag.metrics.max_scroll_top_px) - }) - { - return max; - } - let viewport = self.editor.viewport_height_px.get(&self.store); - self.total_diff_height_px().saturating_sub(viewport.max(1)) - } - - fn viewport_anchor_bias_for_global(&self, scroll_top_px: u32) -> ViewportAnchorBias { - let max = self.global_max_scroll_top_px(); - if max > 0 && scroll_top_px.saturating_add(CONTINUOUS_BOTTOM_ANCHOR_TOLERANCE_PX) >= max { - ViewportAnchorBias::FollowEnd - } else { - ViewportAnchorBias::PreserveTop - } - } - - fn viewport_anchor_for_file_offset( - &self, - index: usize, - local_offset_px: u32, - bias: ViewportAnchorBias, - ) -> Option { - let item_id = self.virtual_diff_document.item_id(index)?; - Some(ViewportAnchor { - item_id, - intra_item_offset_px: local_offset_px, - bias, - }) - } - - fn viewport_anchor_for_global( - &self, - scroll_top_px: u32, - bias: ViewportAnchorBias, - ) -> Option { - let target_px = match bias { - ViewportAnchorBias::PreserveBottom => { - scroll_top_px.saturating_add(self.editor.viewport_height_px.get(&self.store).max(1)) - } - ViewportAnchorBias::PreserveTop | ViewportAnchorBias::FollowEnd => scroll_top_px, - }; - let (index, local_offset_px) = self.locate_global_scroll_px(target_px)?; - self.viewport_anchor_for_file_offset(index, local_offset_px, bias) - } - - fn current_or_derived_viewport_anchor(&self) -> Option { - if let Some(anchor) = self.virtual_scroll.anchor - && self.virtual_diff_document.anchor_is_current(anchor) - { - return Some(anchor); - } - let scroll_top_px = self.workspace.global_scroll_top_px.get(&self.store); - let bias = self.viewport_anchor_bias_for_global(scroll_top_px); - self.viewport_anchor_for_global(scroll_top_px, bias) - } - - fn scroll_top_for_viewport_anchor(&self, anchor: ViewportAnchor) -> Option { - if !self.virtual_diff_document.anchor_is_current(anchor) { - return None; - } - if anchor.bias == ViewportAnchorBias::FollowEnd { - return Some(self.global_max_scroll_top_px()); - } - - let index = anchor.item_id.index; - let item_height = self - .viewport_file_scroll_height_px(index) - .max(self.virtual_diff_document.height_at(index)) - .max(1); - let local_offset = anchor - .intra_item_offset_px - .min(item_height.saturating_sub(1)); - let item_top = self.file_start_offset_px(index); - let target = match anchor.bias { - ViewportAnchorBias::PreserveTop => item_top.saturating_add(local_offset), - ViewportAnchorBias::PreserveBottom => item_top - .saturating_add(local_offset) - .saturating_sub(self.editor.viewport_height_px.get(&self.store).max(1)), - ViewportAnchorBias::FollowEnd => unreachable!(), - }; - Some(target.min(self.global_max_scroll_top_px())) - } - - fn set_viewport_anchor(&mut self, anchor: ViewportAnchor) { - if let Some(scroll_top_px) = self.scroll_top_for_viewport_anchor(anchor) { - self.workspace - .global_scroll_top_px - .set(&self.store, scroll_top_px); - self.virtual_scroll.set_anchor(anchor); - } else { - self.virtual_scroll.clear(); - self.clamp_global_scroll_top_px(); - } - } - - fn set_viewport_anchor_for_global(&mut self, scroll_top_px: u32, bias: ViewportAnchorBias) { - if let Some(anchor) = self.viewport_anchor_for_global(scroll_top_px, bias) { - self.set_viewport_anchor(anchor); - } else { - self.virtual_scroll.clear(); - self.workspace.global_scroll_top_px.set(&self.store, 0); - } - } - - fn rebase_viewport_anchor(&mut self, anchor: ViewportAnchor) { - self.set_viewport_anchor(anchor); - } - - fn clamp_global_scroll_top_px(&mut self) { - if let Some(anchor) = self.virtual_scroll.anchor - && let Some(scroll_top_px) = self.scroll_top_for_viewport_anchor(anchor) - { - self.workspace - .global_scroll_top_px - .set(&self.store, scroll_top_px); - return; - } - let max = self.global_max_scroll_top_px(); - let current = self.workspace.global_scroll_top_px.get(&self.store); - self.workspace - .global_scroll_top_px - .set(&self.store, current.min(max)); - } - - fn locate_global_scroll_px(&self, target_px: u32) -> Option<(usize, u32)> { - let count = self.workspace_file_count(); - if count == 0 { - return None; - } - if self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| drag.is_none()) - && self.virtual_diff_document.len() == count - { - return self.virtual_diff_document.locate(target_px); - } - let mut prior: u32 = 0; - for index in 0..count { - let height = self.viewport_file_scroll_height_px(index).max(1); - let next_prior = prior.saturating_add(height); - if target_px < next_prior || index + 1 == count { - return Some((index, target_px.saturating_sub(prior))); - } - prior = next_prior; - } - Some((count - 1, 0)) - } - - fn scroll_viewport_to_global(&mut self, target_px: u32) -> Vec { - if self.virtual_diff_document.len() != self.workspace_file_count() { - self.recompute_file_scroll_total_height_px(); - } - let target_px = target_px.min(self.global_max_scroll_top_px()); - let bias = self.viewport_anchor_bias_for_global(target_px); - self.set_viewport_anchor_for_global(target_px, bias); - let target_px = self.workspace.global_scroll_top_px.get(&self.store); - let Some((target_index, local_offset)) = self.locate_global_scroll_px(target_px) else { - self.workspace.global_scroll_top_px.set(&self.store, 0); - self.virtual_scroll.clear(); - return Vec::new(); - }; - self.workspace - .global_scroll_top_px - .set(&self.store, target_px); - self.workspace - .viewport_scrollbar_drag - .update(&self.store, |drag| { - if let Some(drag) = drag.as_mut() { - drag.metrics.scroll_top_px = target_px.min(drag.metrics.max_scroll_top_px); - } - }); - - let dragging_scrollbar = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| drag.is_some()); - let mut effects = if dragging_scrollbar { - Vec::new() - } else if self.active_file_matches_workspace_file(target_index) { - Vec::new() - } else { - self.select_file_inner(target_index, true) - }; - - let local_max = self.editor_max_scroll_top_px(); - self.editor - .scroll_top_px - .set(&self.store, local_offset.min(local_max)); - if !dragging_scrollbar { - effects.extend(self.request_active_file_syntax_effect()); - } - effects - } - - pub fn global_scroll_position_px(&self) -> u32 { - self.workspace.global_scroll_top_px.get(&self.store) - } - - pub fn continuous_viewport_scrollbar_metrics(&self) -> ViewportScrollbarMetrics { - if let Some(metrics) = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| drag.as_ref().map(|drag| drag.metrics)) - { - return metrics; - } - let viewport_height_px = self.editor.viewport_height_px.get(&self.store); - let content_height_px = self.total_diff_height_px(); - ViewportScrollbarMetrics { - content_height_px, - viewport_height_px, - scroll_top_px: self.global_scroll_position_px(), - max_scroll_top_px: content_height_px.saturating_sub(viewport_height_px.max(1)), - } - } - - pub fn begin_viewport_scrollbar_drag( - &mut self, - content_height_px: u32, - viewport_height_px: u32, - scroll_top_px: u32, - max_scroll_top_px: u32, - ) { - if !self.settings.continuous_scroll { - return; - } - let file_heights_px = (0..self.workspace_file_count()) - .map(|index| self.file_scroll_height_px(index).max(1)) - .collect(); - self.workspace.viewport_scrollbar_drag.set( - &self.store, - Some(ViewportScrollbarDragState { - metrics: ViewportScrollbarMetrics { - content_height_px, - viewport_height_px, - scroll_top_px: scroll_top_px.min(max_scroll_top_px), - max_scroll_top_px, - }, - file_heights_px, - }), - ); - } - - pub fn end_viewport_scrollbar_drag(&mut self) { - self.workspace - .viewport_scrollbar_drag - .set(&self.store, None); - self.apply_pending_file_scroll_updates(); - } - - fn apply_pending_file_scroll_updates(&mut self) { - let pending_heights = self - .workspace - .pending_file_content_heights - .with(&self.store, |pending| pending.clone()); - self.workspace - .pending_file_content_heights - .set(&self.store, HashMap::new()); - for (index, height) in pending_heights { - self.update_file_content_height_px(index, height); - } - if self - .workspace - .file_scroll_recompute_pending - .get(&self.store) - { - self.workspace - .file_scroll_recompute_pending - .set(&self.store, false); - self.recompute_file_scroll_total_height_px(); - self.clamp_global_scroll_top_px(); - } - } - - pub fn sync_editor_scroll_from_global(&mut self) -> Vec { - if !self.settings.continuous_scroll { - return Vec::new(); - } - self.clamp_global_scroll_top_px(); - let target = self.workspace.global_scroll_top_px.get(&self.store); - let Some((_, local_offset)) = self.locate_global_scroll_px(target) else { - self.workspace.global_scroll_top_px.set(&self.store, 0); - self.virtual_scroll.clear(); - return Vec::new(); - }; - let max = self.editor_max_scroll_top_px(); - self.editor - .scroll_top_px - .set(&self.store, local_offset.min(max)); - Vec::new() - } - - pub fn sync_global_scroll_from_editor(&mut self) { - let Some(selected_index) = self.reconcile_selected_file_index_from_path() else { - self.workspace.global_scroll_top_px.set(&self.store, 0); - self.virtual_scroll.clear(); - return; - }; - let start = self.file_start_offset_px(selected_index); - let local = self.editor.scroll_top_px.get(&self.store); - let target = start - .saturating_add(local) - .min(self.global_max_scroll_top_px()); - self.workspace.global_scroll_top_px.set(&self.store, target); - if self.settings.continuous_scroll { - if let Some(anchor) = self.viewport_anchor_for_file_offset( - selected_index, - local, - self.viewport_anchor_bias_for_global(target), - ) { - self.virtual_scroll.set_anchor(anchor); - } else { - self.virtual_scroll.clear(); - } - } - } - - fn prefetch_compare_working_set( - &mut self, - render_start_index: usize, - render_end_index: usize, - direction: ScrollDirection, - viewport_height_px: u32, - ) -> Vec { - if self.workspace.source.get(&self.store) != WorkspaceSource::Compare { - return Vec::new(); - } - let count = self.workspace_file_count(); - if count == 0 { - return Vec::new(); - } - - let forward_pages = if direction == ScrollDirection::Forward { - COMPARE_WORKING_SET_PREFETCH_PAGES - } else { - COMPARE_WORKING_SET_TRAILING_PAGES - }; - let backward_pages = if direction == ScrollDirection::Backward { - COMPARE_WORKING_SET_PREFETCH_PAGES - } else { - COMPARE_WORKING_SET_TRAILING_PAGES - }; - - let mut effects = Vec::new(); - effects.extend(self.prefetch_compare_files_forward( - render_end_index, - viewport_height_px.saturating_mul(forward_pages).max(1), - )); - effects.extend(self.prefetch_compare_files_backward( - render_start_index, - viewport_height_px.saturating_mul(backward_pages).max(1), - )); - effects - } - - fn prefetch_compare_files_forward( - &mut self, - start_index: usize, - target_height: u32, - ) -> Vec { - let count = self.workspace_file_count(); - let mut effects = Vec::new(); - let mut accumulated = 0_u32; - let mut index = start_index; - while index < count && accumulated < target_height { - if let Some(path) = self.workspace_file_path_at(index) { - effects.extend(self.ensure_compare_file_cached_for_viewport( - index, - &path, - CompareWorkPriority::Overscan, - )); - } - accumulated = - accumulated.saturating_add(self.viewport_file_scroll_height_px(index).max(1)); - index += 1; - } - effects - } - - fn prefetch_compare_files_backward( - &mut self, - start_index: usize, - target_height: u32, - ) -> Vec { - let mut effects = Vec::new(); - let mut accumulated = 0_u32; - let mut index = start_index; - while index > 0 && accumulated < target_height { - index -= 1; - if let Some(path) = self.workspace_file_path_at(index) { - effects.extend(self.ensure_compare_file_cached_for_viewport( - index, - &path, - CompareWorkPriority::Overscan, - )); - } - accumulated = - accumulated.saturating_add(self.viewport_file_scroll_height_px(index).max(1)); - } - effects - } - - pub fn build_continuous_viewport_document( - &mut self, - ) -> (Option, Vec) { - if !self.settings.continuous_scroll { - return (None, Vec::new()); - } - if self.virtual_diff_document.len() != self.workspace_file_count() { - self.recompute_file_scroll_total_height_px(); - } - self.clamp_global_scroll_top_px(); - let scroll_top_px = self.workspace.global_scroll_top_px.get(&self.store); - let scroll_direction = match self.last_virtual_scroll_top_px { - Some(previous) if scroll_top_px < previous => ScrollDirection::Backward, - _ => ScrollDirection::Forward, - }; - self.last_virtual_scroll_top_px = Some(scroll_top_px); - let Some((anchor_index, _)) = self.locate_global_scroll_px(scroll_top_px) else { - return (None, Vec::new()); - }; - - let source = self.workspace.source.get(&self.store); - if source == WorkspaceSource::None { - return (None, Vec::new()); - } - let dragging_scrollbar = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| drag.is_some()); - - let count = self.workspace_file_count(); - let viewport = self.editor.viewport_height_px.get(&self.store).max(1); - let follow_end = self.virtual_scroll.anchor.is_some_and(|anchor| { - anchor.bias == ViewportAnchorBias::FollowEnd - && self.virtual_diff_document.anchor_is_current(anchor) - }) || self.viewport_anchor_bias_for_global(scroll_top_px) - == ViewportAnchorBias::FollowEnd; - let (start_index, start_offset, local_top, target_height) = if follow_end { - let mut start_index = count.saturating_sub(1); - let mut tail_height = self.viewport_file_scroll_height_px(start_index).max(1); - let target_tail_height = viewport.saturating_mul(2).max(viewport); - while start_index > 0 && tail_height < target_tail_height { - start_index -= 1; - tail_height = tail_height - .saturating_add(self.viewport_file_scroll_height_px(start_index).max(1)); - } - ( - start_index, - self.file_start_offset_px(start_index), - tail_height.saturating_sub(viewport), - tail_height.max(1), - ) - } else { - let mut start_index = anchor_index; - let mut before_viewport_px = 0_u32; - while start_index > 0 && before_viewport_px < viewport { - start_index -= 1; - before_viewport_px = before_viewport_px - .saturating_add(self.viewport_file_scroll_height_px(start_index).max(1)); - } - let start_offset = self.file_start_offset_px(start_index); - let local_top = self - .workspace - .global_scroll_top_px - .get(&self.store) - .saturating_sub(start_offset); - let target_height = local_top - .saturating_add(viewport) - .saturating_add(viewport / 2) - .max(1); - (start_index, start_offset, local_top, target_height) - }; - - let mut effects = Vec::new(); - let mut slot_keys = Vec::new(); - let mut slot_loading = Vec::new(); - let mut accumulated = 0_u32; - let mut index = start_index; - while index < count && (slot_keys.is_empty() || accumulated < target_height) { - let path = self - .workspace_file_path_at(index) - .unwrap_or_else(|| format!("File {}", index + 1)); - let slot_key = match source { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - effects.extend(self.ensure_compare_file_cached_for_viewport( - index, - &path, - CompareWorkPriority::VisibleViewportDiff, - )); - self.compare_slot_key_at(index, &path) - } - WorkspaceSource::Status => { - effects.extend(self.ensure_status_file_cached_for_viewport(index)); - let file_change = self - .workspace - .status_file_changes - .with(&self.store, |changes| changes.get(index).cloned()); - file_change.as_ref().map_or_else( - || { - self.loading_slot_key( - WorkspaceSource::Status, - index, - &path, - String::new(), - String::new(), - ) - }, - |change| self.status_slot_key_at(index, change), - ) - } - WorkspaceSource::None => self.loading_slot_key( - WorkspaceSource::None, - index, - &path, - String::new(), - String::new(), - ), - }; - let slot_height = self.viewport_file_scroll_height_px(index).max(1); - if let Some(window) = self.viewport_slot_syntax_window( - &slot_key, - accumulated, - slot_height, - local_top, - viewport, - ) { - effects.extend(self.request_viewport_slot_syntax_window(&slot_key, window)); - } - let slot_is_loading = matches!(&slot_key.kind, ViewportSlotKind::Loading); - if !slot_is_loading { - self.touch_viewport_slot(&slot_key); - } - slot_loading.push(slot_is_loading); - slot_keys.push(slot_key); - accumulated = accumulated.saturating_add(slot_height); - index += 1; - } - let render_end_index = index; - self.protect_working_set_slots(&slot_keys); - self.trim_file_working_set(); - effects.extend(self.prefetch_compare_working_set( - start_index, - render_end_index, - scroll_direction, - viewport, - )); - - let key = ViewportDocumentKey { - source, - generation: self.workspace_render_generation(), - start_index, - slots: slot_keys, - }; - let doc = if let Some(cache) = self.viewport_document_cache.as_ref() - && cache.key == key - { - cache.doc.clone() - } else { - let mut doc = RenderDoc::default(); - let loading_message = if dragging_scrollbar { - "" - } else { - "Loading diff..." - }; - for slot in &key.slots { - self.append_viewport_slot_doc(&mut doc, slot, loading_message); - } - let doc = Arc::new(doc); - self.viewport_document_cache = Some(ViewportDocumentCache { - key: key.clone(), - doc: doc.clone(), - }); - doc - }; - let slot_indices = key.slots.iter().map(|slot| slot.index).collect(); - let slot_item_ids = key - .slots - .iter() - .map(|slot| { - self.virtual_diff_document - .item_id(slot.index) - .unwrap_or_else(|| { - VirtualDiffItemId::file( - source, - self.workspace_render_generation(), - slot.index, - ) - }) - }) - .collect(); - let stream_items = self.virtual_stream_items_for_viewport_doc( - source, - self.workspace_render_generation(), - &key.slots, - doc.as_ref(), - ); - - ( - Some(ViewportDocument { - doc, - mode: ViewportDocumentMode::Continuous, - generation: self.workspace_render_generation(), - start_index, - start_offset_px: start_offset, - scroll_top_px: local_top, - slot_indices, - slot_item_ids, - stream_items, - slot_loading, - path: String::new(), - }), - effects, - ) - } - - fn scroll_viewport_pages(&mut self, delta_pages: i32) -> Vec { - let viewport = self.editor.viewport_height_px.get(&self.store); - let page_px = ((viewport as f32) * 0.85).round().max(1.0) as i32; - let delta_px = delta_pages.saturating_mul(page_px); - if self.settings.continuous_scroll { - return self.scroll_viewport_px(delta_px); - } - let current = self.editor.scroll_top_px.get(&self.store); - let max = self.editor_max_scroll_top_px(); - let next = apply_scroll_delta_px(current, delta_px, max); - self.editor.scroll_top_px.set(&self.store, next); - Vec::new() - } - - fn scroll_viewport_half_page(&mut self, direction: i32) -> Vec { - let viewport = self.editor.viewport_height_px.get(&self.store); - let half_px = ((viewport as f32) * 0.5).round().max(1.0) as i32; - let delta_px = direction.saturating_mul(half_px); - if self.settings.continuous_scroll { - return self.scroll_viewport_px(delta_px); - } - let current = self.editor.scroll_top_px.get(&self.store); - let max = self.editor_max_scroll_top_px(); - let next = apply_scroll_delta_px(current, delta_px, max); - self.editor.scroll_top_px.set(&self.store, next); - Vec::new() - } - - fn request_active_file_syntax_effect(&mut self) -> Option { - if !self.syntax_request_budget_available() { - return None; - } - let repo_path = self.compare.repo_path.get(&self.store)?; - let window = self.desired_syntax_window()?; - let generation = self.active_syntax_generation(); - let syntax_epoch = self.syntax_requests.epoch(); - let mut request = None; - let request_id = self.syntax_requests.next_request_id(); - let mut active_to_cache = None; - - self.workspace.active_file.update(&self.store, |active| { - let Some(active) = active.as_mut() else { - return; - }; - if let Some(next_request) = request_syntax_for_active_file( - active, - repo_path, - generation, - syntax_epoch, - window, - request_id, - ) { - active_to_cache = Some(active.clone()); - request = Some(next_request); - } - }); - if let Some(active_file) = active_to_cache { - self.cache_active_file(active_file); - } - - request.map(|request| { - self.track_syntax_request(&request); - SyntaxEffect::LoadFileSyntax(Task { - generation, - request, - }) - .into() - }) - } - - fn active_syntax_generation(&self) -> u64 { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Status => self.workspace.status_generation.get(&self.store), - _ => self.workspace.compare_generation.get(&self.store), - } - } - - fn desired_syntax_window(&self) -> Option { - let line_count = self.workspace.active_file.with(&self.store, |active| { - active.as_ref().map(|active| active.render_doc.lines.len()) - })?; - if line_count == 0 { - return None; - } - - if let (Some(start), Some(end)) = ( - self.editor.visible_row_start.get(&self.store), - self.editor.visible_row_end.get(&self.store), - ) && end > start - { - return Some(SyntaxRowWindow { - start: start.saturating_sub(SYNTAX_OVERSCAN_ROWS), - end: end.saturating_add(SYNTAX_OVERSCAN_ROWS).min(line_count), - }); - } - - let scroll = self.editor.scroll_top_px.get(&self.store) as usize; - let viewport = self.editor.viewport_height_px.get(&self.store) as usize; - let approx_row_height = 20usize; - let start = scroll / approx_row_height; - let visible = (viewport / approx_row_height).saturating_add(SYNTAX_INITIAL_ROWS); - Some(SyntaxRowWindow { - start: start.saturating_sub(SYNTAX_OVERSCAN_ROWS), - end: start.saturating_add(visible).min(line_count), - }) - } - - fn navigate_to_hunk(&mut self, forward: bool) { - let current = self.editor.scroll_top_px.get(&self.store); - let target = self.editor.hunk_positions.with(&self.store, |positions| { - if positions.is_empty() { - return None; - } - if forward { - positions - .iter() - .find(|&&y| y > current) - .or_else(|| positions.first()) - .copied() - } else { - positions - .iter() - .rev() - .find(|&&y| y < current) - .or_else(|| positions.last()) - .copied() - } - }); - if let Some(y) = target { - self.editor.scroll_top_px.set(&self.store, y); - self.editor_clamp_scroll(); - } - } - - fn navigate_to_file(&mut self, forward: bool) -> Vec { - let Some(current) = self.reconcile_selected_file_index_from_path() else { - return Vec::new(); - }; - let count = self.workspace_file_count(); - if count == 0 { - return Vec::new(); - } - let target = if forward { - current.saturating_add(1).min(count.saturating_sub(1)) - } else { - current.saturating_sub(1) - }; - if target == current { - return Vec::new(); - } - - if self.settings.continuous_scroll { - return self.select_file(target, true); - } - - self.select_file(target, true) - } - - fn push_error(&mut self, message: &str) -> u64 { - self.last_error.set(&self.store, Some(message.to_owned())); - self.push_toast(ToastKind::Error, message, None, None) - } - - fn push_info(&mut self, message: &str) -> u64 { - self.push_toast(ToastKind::Info, message, None, None) - } - - #[allow(dead_code)] - fn push_error_with_description(&mut self, message: &str, description: &str) -> u64 { - self.last_error.set(&self.store, Some(message.to_owned())); - self.push_toast( - ToastKind::Error, - message, - Some(description.to_owned()), - None, - ) - } - - #[allow(dead_code)] - fn push_info_with_description(&mut self, message: &str, description: &str) -> u64 { - self.push_toast(ToastKind::Info, message, Some(description.to_owned()), None) - } - - /// Create an info toast with an externally-driven progress bar (0.0-1.0). - /// The toast is pinned until `finish_progress_toast` or `fail_progress_toast` - /// is called — it does not auto-dismiss based on time. - fn push_progress_toast(&mut self, message: &str) -> u64 { - self.push_toast(ToastKind::Info, message, None, Some(0.0)) - } - - /// Convert a pinned progress toast into a normal info toast and let it - /// auto-dismiss. Also updates its message and description. - fn finish_progress_toast(&mut self, toast_id: u64, message: &str, description: Option) { - let now = self.clock_ms; - self.toasts.update(&self.store, |toasts| { - if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { - toast.kind = ToastKind::Info; - toast.message = message.to_owned(); - toast.description = description; - toast.created_at_ms = now; - toast.progress = None; - } - }); - } - - /// Convert a pinned progress toast into an error toast. - fn fail_progress_toast(&mut self, toast_id: u64, message: &str, description: Option) { - let now = self.clock_ms; - self.last_error.set(&self.store, Some(message.to_owned())); - self.toasts.update(&self.store, |toasts| { - if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { - toast.kind = ToastKind::Error; - toast.message = message.to_owned(); - toast.description = description; - toast.created_at_ms = now; - toast.progress = None; - } - }); - } - - fn update_toast_progress(&mut self, toast_id: u64, fraction: f32) { - let clamped = fraction.clamp(0.0, 1.0); - self.toasts.update(&self.store, |toasts| { - if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { - toast.progress = Some(clamped); - } - }); - } - - fn update_toast_message(&mut self, toast_id: u64, message: &str) { - self.toasts.update(&self.store, |toasts| { - if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { - toast.message = message.to_owned(); - } - }); - } - - fn start_fetch_remote(&mut self, remote: String) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }) - { - self.push_error("This repository backend does not support remotes."); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before fetching."); - return Vec::new(); - }; - let toast_id = self.push_progress_toast(&format!("Fetching {remote}\u{2026}")); - vec![ - RepositoryEffect::FetchRemote(FetchRemoteRequest { - repo_path, - remote, - toast_id, - }) - .into(), - ] - } - - fn start_fetch_all_remotes(&mut self) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }) - { - self.push_error("This repository backend does not support remotes."); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before fetching."); - return Vec::new(); - }; - let remotes = self.repository.refs.with(&self.store, |refs| { - remote_names_from_refs(refs).into_iter().collect::>() - }); - if remotes.is_empty() { - self.push_error("No remotes are configured for this repository."); - return Vec::new(); - } - remotes - .into_iter() - .flat_map(|remote| { - let toast_id = self.push_progress_toast(&format!("Fetching {remote}\u{2026}")); - std::iter::once( - RepositoryEffect::FetchRemote(FetchRemoteRequest { - repo_path: repo_path.clone(), - remote, - toast_id, - }) - .into(), - ) - }) - .collect() - } - - fn start_publish_default(&mut self) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }) - { - self.push_error("This repository backend does not support publishing."); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before publishing."); - return Vec::new(); - }; - let toast_id = self.push_progress_toast(&format!( - "{}\u{2026}", - self.vcs_ui_profile().publish_command_label() - )); - vec![ - RepositoryEffect::PublishDefault(PublishRequest { - repo_path, - action: None, - toast_id, - }) - .into(), - ] - } - - fn start_open_publish_menu(&mut self) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }) - { - self.push_error("This repository backend does not support publishing."); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before publishing."); - return Vec::new(); - }; - self.push_overlay(OverlaySurface::PublishMenu, None); - vec![ - RepositoryEffect::LoadPublishPlan(PublishPlanRequest { - repo_path, - toast_id: None, - }) - .into(), - ] - } - - fn start_publish_action(&mut self, action: PublishAction) -> Vec { - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before publishing."); - return Vec::new(); - }; - if self.overlays_top() == Some(OverlaySurface::PublishMenu) { - self.pop_overlay(); - } - let toast_id = self.push_progress_toast(&format!("{}\u{2026}", action.label)); - vec![ - RepositoryEffect::PublishDefault(PublishRequest { - repo_path, - action: Some(action), - toast_id, - }) - .into(), - ] - } - - fn start_push_current_branch(&mut self, force_with_lease: bool) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }) - { - self.push_error("This repository backend does not support push."); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before pushing."); - return Vec::new(); - }; - let Some(branch_ref) = self - .repository - .refs - .with(&self.store, |refs| active_publish_ref(refs)) - else { - self.push_error("No active branch or bookmark to push."); - return Vec::new(); - }; - let branch = branch_ref.name; - let (remote, refspec) = match branch_ref.upstream.as_deref().and_then(upstream_pair) { - Some((remote, upstream_branch)) => ( - remote, - format!("refs/heads/{branch}:refs/heads/{upstream_branch}"), - ), - None => { - // No upstream configured yet — default to `origin/`. - let remotes = self.repository.refs.with(&self.store, |refs| { - remote_names_from_refs(refs).into_iter().collect::>() - }); - let remote = if remotes.iter().any(|n| n == "origin") { - "origin".to_owned() - } else if let Some(first) = remotes.first() { - first.clone() - } else { - self.push_error("No remotes are configured for this repository."); - return Vec::new(); - }; - (remote, format!("refs/heads/{branch}:refs/heads/{branch}")) - } - }; - let label = if force_with_lease { - format!("Force-pushing {branch} to {remote}\u{2026}") - } else { - format!("Pushing {branch} to {remote}\u{2026}") - }; - let toast_id = self.push_progress_toast(&label); - vec![ - RepositoryEffect::Push(PushRequest { - repo_path, - remote, - refspec, - force_with_lease, - toast_id, - }) - .into(), - ] - } - - fn start_pull_current_branch(&mut self) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.pull_fast_forward) - }) - { - self.push_error("This repository backend does not support fast-forward pull."); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before pulling."); - return Vec::new(); - }; - let Some(branch_ref) = self - .repository - .refs - .with(&self.store, |refs| active_publish_ref(refs)) - else { - self.push_error("No active branch or bookmark to pull into."); - return Vec::new(); - }; - let branch = branch_ref.name; - let (remote, upstream_branch) = match branch_ref.upstream.as_deref().and_then(upstream_pair) - { - Some(pair) => pair, - None => { - self.push_error(&format!( - "No upstream configured for {branch}. Push once to set one." - )); - return Vec::new(); - } - }; - let toast_id = self.push_progress_toast(&format!("Pulling {branch} from {remote}\u{2026}")); - vec![ - RepositoryEffect::PullFf(PullFfRequest { - repo_path, - remote, - branch: upstream_branch, - toast_id, - }) - .into(), - ] - } - - fn push_toast( - &mut self, - kind: ToastKind, - message: &str, - description: Option, - progress: Option, - ) -> u64 { - use crate::ui::animation::AnimationKey; - let id = self.next_toast_id; - self.next_toast_id = self.next_toast_id.saturating_add(1); - self.animation.set_target( - AnimationKey::ToastEntrance(id), - 1.0, - TOAST_ANIM_MS, - self.clock_ms, - ); - let now = self.clock_ms; - self.toasts.update(&self.store, |toasts| { - toasts.push(Toast { - id, - kind, - message: message.to_owned(), - description, - created_at_ms: now, - hovered: false, - progress, - }); - if toasts.len() > MAX_VISIBLE_TOASTS { - toasts.remove(0); - } - }); - id - } - - fn open_search(&mut self) { - self.editor.search.open.set(&self.store, true); - let len = self.editor.search.query.with(&self.store, |q| q.len()); - self.text_edit.cursor.set(&self.store, len); - self.text_edit.anchor.set(&self.store, 0); - self.text_edit - .cursor_moved_at_ms - .set(&self.store, self.clock_ms); - self.focus.set(&self.store, Some(FocusTarget::SearchInput)); - self.editor.focused.set(&self.store, false); - self.recompute_search_matches(); - } - - fn close_search(&mut self) { - self.editor.search.open.set(&self.store, false); - self.editor.search.matches.set(&self.store, Arc::default()); - self.editor.search.active_index.set(&self.store, None); - self.set_focus(Some(FocusTarget::Editor)); - } - - fn recompute_search_matches(&mut self) { - use crate::editor::diff::state::MatchSide; - - self.editor.search.matches.set(&self.store, Arc::default()); - self.editor.search.active_index.set(&self.store, None); - - let query = self - .editor - .search - .query - .with(&self.store, |q| q.to_ascii_lowercase()); - if query.is_empty() { - return; - } - - let new_matches: Vec = self.workspace.active_file.with(&self.store, |af| { - let Some(active_file) = af.as_ref() else { - return Vec::new(); - }; - let doc = &active_file.render_doc; - let mut new_matches: Vec = Vec::new(); - for (line_idx, line) in doc.lines.iter().enumerate() { - let line_idx = line_idx as u32; - if line.left_text.is_valid() { - let text = doc.line_text(line.left_text); - let lower = text.to_ascii_lowercase(); - let mut start = 0; - while let Some(pos) = lower[start..].find(&query) { - let byte_start = (start + pos) as u32; - new_matches.push(SearchMatch { - line_index: line_idx, - byte_start, - byte_len: query.len() as u32, - side: MatchSide::Left, - }); - start += pos + query.len(); - } - } - if line.right_text.is_valid() { - let text = doc.line_text(line.right_text); - let lower = text.to_ascii_lowercase(); - let mut start = 0; - while let Some(pos) = lower[start..].find(&query) { - let byte_start = (start + pos) as u32; - new_matches.push(SearchMatch { - line_index: line_idx, - byte_start, - byte_len: query.len() as u32, - side: MatchSide::Right, - }); - start += pos + query.len(); - } - } - } - new_matches - }); - - let has_matches = !new_matches.is_empty(); - self.editor - .search - .matches - .set(&self.store, Arc::new(new_matches)); - if has_matches { - self.editor.search.active_index.set(&self.store, Some(0)); - } - } - - fn search_navigate(&mut self, direction: i32) { - let count = self.editor.search.matches.with(&self.store, |m| m.len()); - if count == 0 { - return; - } - - let current = self - .editor - .search - .active_index - .get(&self.store) - .unwrap_or(0); - let next = if direction > 0 { - if current + 1 >= count { 0 } else { current + 1 } - } else { - if current == 0 { count - 1 } else { current - 1 } - }; - self.editor.search.active_index.set(&self.store, Some(next)); - self.scroll_to_search_match(next); - } - - fn scroll_to_search_match(&mut self, match_index: usize) { - let y_pos = self - .editor - .search_match_y_positions - .with(&self.store, |v| v.get(match_index).copied()); - let target_y = if let Some(y) = y_pos { - y - } else { - let m = self - .editor - .search - .matches - .with(&self.store, |m| m.get(match_index).copied()); - let Some(m) = m else { - return; - }; - self.estimate_line_y(m.line_index) - }; - - let viewport_h = self.editor.viewport_height_px.get(&self.store); - let centered = target_y.saturating_sub(viewport_h / 3); - let max = self.editor_max_scroll_top_px(); - self.editor - .scroll_top_px - .set(&self.store, centered.min(max)); - } - - fn estimate_line_y(&self, line_index: u32) -> u32 { - let content_height = self.editor.content_height_px.get(&self.store); - if content_height == 0 { - return 0; - } - let total_lines = self.workspace.active_file.with(&self.store, |af| { - af.as_ref() - .map(|active_file| active_file.render_doc.lines.len() as u32) - .unwrap_or(0) - }); - if total_lines == 0 { - return 0; - } - let avg_height = content_height / total_lines; - line_index.saturating_mul(avg_height) - } - - // -------- EditorState helpers on AppState -------- - - /// Clear document-specific editor state (scroll, content, hunks, etc.) - pub fn editor_clear_document(&mut self) { - self.editor.doc_generation.set(&self.store, 0); - self.editor.scroll_top_px.set(&self.store, 0); - self.editor.content_height_px.set(&self.store, 0); - self.editor.hovered_row.set(&self.store, None); - self.editor.hovered_render_line_index.set(&self.store, None); - self.editor.hovered_hunk_index.set(&self.store, None); - self.editor.visible_row_start.set(&self.store, None); - self.editor.visible_row_end.set(&self.store, None); - self.editor.hunk_positions.set(&self.store, Arc::default()); - self.editor.file_positions.set(&self.store, Arc::default()); - self.editor - .search_match_y_positions - .set(&self.store, Arc::default()); - self.editor - .line_selection - .update(&self.store, |ls| ls.clear()); - self.editor.text_selection.set(&self.store, None); - self.context_menu.close(); - } - - pub fn editor_max_scroll_top_px(&self) -> u32 { - let content = self.editor.content_height_px.get(&self.store); - let viewport = self.editor.viewport_height_px.get(&self.store); - content.saturating_sub(viewport.max(1)) - } - - pub fn editor_clamp_scroll(&mut self) { - let max = self.editor_max_scroll_top_px(); - let cur = self.editor.scroll_top_px.get(&self.store); - self.editor.scroll_top_px.set(&self.store, cur.min(max)); - } - - pub fn editor_current_hunk_index(&self) -> Option<(usize, usize)> { - let scroll = self.editor.scroll_top_px.get(&self.store); - self.editor.hunk_positions.with(&self.store, |positions| { - if positions.is_empty() { - return None; - } - let idx = positions - .partition_point(|&y| y <= scroll) - .saturating_sub(1); - Some((idx, positions.len())) - }) - } - - fn move_editor_row_cursor(&mut self, delta: i32) { - let Some(start) = self.editor.visible_row_start.get(&self.store) else { - return; - }; - let Some(end) = self.editor.visible_row_end.get(&self.store) else { - return; - }; - if start >= end { - return; - } - let max = end.saturating_sub(1); - let Some(current) = self - .editor - .hovered_row - .get(&self.store) - .filter(|row| *row >= start && *row <= max) - else { - self.editor - .hovered_row - .set(&self.store, Some(if delta < 0 { max } else { start })); - return; - }; - let next = if delta < 0 { - current - .saturating_sub(delta.unsigned_abs() as usize) - .max(start) - } else { - current.saturating_add(delta as usize).min(max) - }; - self.editor.hovered_row.set(&self.store, Some(next)); - } -} - -fn matching_persisted_compare<'a>( - startup: &'a StartupOptions, - settings: &'a Settings, -) -> Option<&'a PersistedCompare> { - settings.last_compare.as_ref().filter(|compare| { - startup.args.repo.is_some() && compare.repo_path.as_ref() == startup.args.repo.as_ref() - }) -} - -fn compare_refs_are_valid(mode: CompareMode, left_ref: &str, right_ref: &str) -> bool { - match mode { - CompareMode::SingleCommit => !left_ref.is_empty() || !right_ref.is_empty(), - CompareMode::TwoDot | CompareMode::ThreeDot => { - !left_ref.is_empty() && !right_ref.is_empty() - } - } -} - -fn apply_scroll_delta_px(current: u32, delta: i32, max: u32) -> u32 { - let next = if delta.is_negative() { - current.saturating_sub(delta.unsigned_abs()) - } else { - current.saturating_add(delta as u32) - }; - next.min(max) -} - -fn estimated_carbon_file_rows_with_overhead(file: &carbon::FileDiff) -> u32 { - if file.is_binary { - return 4; - } - estimated_carbon_file_rows(file).saturating_add(1).max(1) -} - -fn estimated_carbon_file_rows(file: &carbon::FileDiff) -> u32 { - if file.hunks.is_empty() { - return file.additions.saturating_add(file.deletions).max(1); - } - - let mut rows = 0_u32; - for (hunk_index, hunk) in file.hunks.iter().enumerate() { - if !file.is_partial { - let gap_len = if hunk_index == 0 { - hunk.old_start_index().min(hunk.new_start_index()) - } else { - let prev = &file.hunks[hunk_index - 1]; - hunk.old_start_index() - .saturating_sub(prev.old_end_index()) - .min(hunk.new_start_index().saturating_sub(prev.new_end_index())) - }; - rows = rows.saturating_add((gap_len > 0) as u32); - } - - rows = rows.saturating_add(1); - for block in file.hunk_blocks(hunk) { - rows = rows.saturating_add(match block.kind { - carbon::BlockKind::Context => block.old.len.min(block.new.len), - carbon::BlockKind::Change => block.old.len.saturating_add(block.new.len), - }); - } - - if !file.is_partial && hunk_index + 1 == file.hunks.len() { - let old_end = file - .old_text - .as_ref() - .map(|text| text.line_count()) - .unwrap_or_else(|| hunk.old_end_index()); - let new_end = file - .new_text - .as_ref() - .map(|text| text.line_count()) - .unwrap_or_else(|| hunk.new_end_index()); - let gap_len = old_end - .saturating_sub(hunk.old_end_index()) - .min(new_end.saturating_sub(hunk.new_end_index())); - rows = rows.saturating_add((gap_len > 0) as u32); - } - } - rows -} - -fn compare_summary_file_entry(summary: &CompareFileSummary) -> FileListEntry { - FileListEntry { - path: summary.paths.display_path_ref(), - } -} - -fn compare_output_file_entry_meta( - output: &CompareOutput, - index: usize, -) -> Option { - if let Some(summary) = output.file_summaries.get(index) { - let (additions, deletions) = summary.fallback_stats(); - return Some(FileListEntryMeta { - status: carbon_list_status(summary.status), - additions, - deletions, - is_binary: summary.is_binary, - }); - } - output.carbon.files.get(index).map(carbon_file_entry_meta) -} - -fn carbon_file_entry_meta(file: &carbon::FileDiff) -> FileListEntryMeta { - let (additions, deletions) = carbon_file_stats(file); - FileListEntryMeta { - status: carbon_list_status(file.status), - additions, - deletions, - is_binary: file.is_binary, - } -} - -fn compare_output_summary_is_deferred(output: &CompareOutput, index: usize) -> bool { - if let Some(summary) = output.file_summaries.get(index) { - return summary.is_partial; - } - output - .carbon - .files - .get(index) - .is_some_and(|file| file.is_partial && file.hunks.is_empty()) -} - -fn compare_output_deferred_summary( - output: &CompareOutput, - index: usize, -) -> Option { - if let Some(summary) = output.file_summaries.get(index) { - return summary.is_partial.then(|| summary.clone()); - } - output - .carbon - .files - .get(index) - .filter(|file| file.is_partial && file.hunks.is_empty()) - .map(CompareFileSummary::from_file) -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct CompareStatsSnapshot { - hydrated_total: (i32, i32), - deferred_count: usize, -} - -fn compare_output_stats_snapshot(output: &CompareOutput) -> CompareStatsSnapshot { - let mut snapshot = CompareStatsSnapshot::default(); - output.for_each_summary(|_, summary| { - if summary.stats_deferred { - snapshot.deferred_count = snapshot.deferred_count.saturating_add(1); - } else { - let stats = summary.fallback_stats(); - snapshot.hydrated_total = ( - snapshot.hydrated_total.0.saturating_add(stats.0), - snapshot.hydrated_total.1.saturating_add(stats.1), - ); - } - }); - snapshot -} - -fn compare_output_has_deferred_stats(output: &CompareOutput) -> bool { - if output.file_summaries.is_empty() { - output.carbon.files.iter().any(|file| file.stats_deferred) - } else { - output - .file_summaries - .iter() - .any(|summary| summary.stats_deferred) - } -} - -fn carbon_file_stats(file: &carbon::FileDiff) -> (i32, i32) { - if file.additions > 0 || file.deletions > 0 || file.stats_deferred { - return ( - u32_to_i32_saturating(file.additions), - u32_to_i32_saturating(file.deletions), - ); - } - let mut additions = 0_i32; - let mut deletions = 0_i32; - for block in &file.blocks { - if block.kind == carbon::BlockKind::Change { - additions = additions.saturating_add(block.new.len.min(i32::MAX as u32) as i32); - deletions = deletions.saturating_add(block.old.len.min(i32::MAX as u32) as i32); - } - } - (additions, deletions) -} - -fn u32_to_i32_saturating(value: u32) -> i32 { - i32::try_from(value).unwrap_or(i32::MAX) -} - -fn carbon_list_status(status: carbon::FileStatus) -> FileListStatus { - match status { - carbon::FileStatus::Added => FileListStatus::Added, - carbon::FileStatus::Deleted => FileListStatus::Deleted, - carbon::FileStatus::Renamed | carbon::FileStatus::RenamedModified => { - FileListStatus::Renamed - } - carbon::FileStatus::Binary => FileListStatus::Binary, - carbon::FileStatus::ModeChanged | carbon::FileStatus::Modified => FileListStatus::Modified, - } -} - -fn build_status_file_entries(changes: &[FileChange]) -> Vec { - changes.iter().map(FileListEntry::from).collect() -} - -fn active_publish_ref(refs: &[VcsRef]) -> Option { - refs.iter() - .find(|reference| { - reference.active && matches!(reference.kind, RefKind::Branch | RefKind::Bookmark) - }) - .cloned() -} - -fn upstream_pair(upstream: &str) -> Option<(String, String)> { - upstream - .split_once('/') - .map(|(remote, branch)| (remote.to_owned(), branch.to_owned())) -} - -fn remote_names_from_refs(refs: &[VcsRef]) -> std::collections::BTreeSet { - let mut remotes = std::collections::BTreeSet::new(); - for reference in refs { - if let Some((remote, _)) = reference - .upstream - .as_deref() - .and_then(|upstream| upstream.split_once('/')) - { - remotes.insert(remote.to_owned()); - } - if matches!( - reference.kind, - RefKind::RemoteBranch | RefKind::RemoteBookmark - ) && let Some((remote, _)) = reference.name.split_once('/') - { - remotes.insert(remote.to_owned()); - } - } - remotes -} - -fn status_section_count(changes: &[FileChange]) -> usize { - let mut last_bucket = None; - let mut count = 0; - for change in changes { - if Some(change.bucket) != last_bucket { - count += 1; - last_bucket = Some(change.bucket); - } - } - count -} - -fn status_section_count_before(changes: &[FileChange], len: usize) -> usize { - status_section_count(&changes[..len.min(changes.len())]) -} - -fn overlay_name(surface: OverlaySurface) -> &'static str { - match surface { - OverlaySurface::RepoPicker => "repo-picker", - OverlaySurface::RefPicker => "ref-picker", - OverlaySurface::CommandPalette => "command-palette", - OverlaySurface::Confirmation => "confirmation", - OverlaySurface::GitHubAuthModal => "github-auth-modal", - OverlaySurface::AccountMenu => "account-menu", - OverlaySurface::KeyboardShortcuts => "keyboard-shortcuts", - OverlaySurface::ThemePicker => "theme-picker", - OverlaySurface::FontPicker => "font-picker", - OverlaySurface::CompareMenu => "compare-menu", - OverlaySurface::PublishMenu => "publish-menu", - } -} - -fn font_picker_entry( - entry: &FontFamilyEntry, - selected_family: &str, - highlights: Vec<(usize, usize)>, -) -> PickerEntry { - let source = entry.source.label(); - let detail = if entry.family == selected_family { - format!("Selected - {source}") - } else { - source.to_owned() - }; - PickerEntry { - label: entry.label.clone(), - detail, - value: entry.family.clone(), - highlights, - label_style: PickerLabelStyle::Default, - icon: Some(if entry.monospaced { - lucide::TERMINAL - } else { - lucide::FILE - }), - section_header: false, - } -} - -pub fn workspace_mode_name(mode: WorkspaceMode) -> &'static str { - match mode { - WorkspaceMode::Empty => "empty", - WorkspaceMode::Loading => "loading", - WorkspaceMode::Ready => "ready", - } -} - -impl From<&FileChange> for FileListEntry { - fn from(value: &FileChange) -> Self { - Self { - path: ComparePath::from(value.path.as_str()), - } - } -} - -fn status_file_entry_meta(change: &FileChange) -> FileListEntryMeta { - FileListEntryMeta { - status: file_change_list_status(change.status, change.bucket), - additions: 0, - deletions: 0, - is_binary: matches!(change.status, FileChangeStatus::Binary), - } -} - -// --------------------------------------------------------------------------- -// Grapheme / word boundary helpers -// --------------------------------------------------------------------------- - -// Grapheme/word boundary helpers are in text_edit.rs - -fn highlight_ranges_from_match_indices(text: &str, indices_rev: &[usize]) -> Vec<(usize, usize)> { - let len = text.len(); - let mut indices: Vec = indices_rev - .iter() - .copied() - .filter(|&idx| idx < len && text.is_char_boundary(idx)) - .collect(); - indices.sort_unstable(); - - let mut ranges = Vec::new(); - for index in indices { - let mut end = index + 1; - while end < len && !text.is_char_boundary(end) { - end += 1; - } - if let Some((_, last_end)) = ranges.last_mut() { - if index <= *last_end { - *last_end = (*last_end).max(end); - continue; - } - } - ranges.push((index, end)); - } - ranges -} - -fn highlight_ranges_for_prefix_match(text: &str, indices_rev: &[usize]) -> Vec<(usize, usize)> { - let prefix_indices: Vec = indices_rev - .iter() - .copied() - .filter(|&idx| idx < text.len()) - .collect(); - highlight_ranges_from_match_indices(text, &prefix_indices) -} - -fn highlight_ranges_for_visible_match( - query: &str, - visible_text: &str, - search_indices_rev: &[usize], - config: &neo_frizbee::Config, -) -> Vec<(usize, usize)> { - if query.is_empty() { - return Vec::new(); - } - - let visible_only = [visible_text]; - if let Some(m) = neo_frizbee::match_list_indices(query, &visible_only, config) - .into_iter() - .next() - { - return highlight_ranges_from_match_indices(visible_text, &m.indices); - } - - highlight_ranges_for_prefix_match(visible_text, search_indices_rev) -} - -fn query_looks_like_path(query: &str) -> bool { - query.starts_with('/') - || query.starts_with("~/") - || query.starts_with("./") - || (query.len() >= 2 && query.as_bytes()[1] == b':') -} - -fn path_looks_like_repository(path: &Path) -> bool { - path.join(".git").exists() || path.join(".jj").exists() -} - -fn normalize_repository_open_path(path: PathBuf) -> PathBuf { - crate::core::vcs::discovery::discover_repository(&path) - .ok() - .flatten() - .map(|location| location.workspace_root) - .unwrap_or(path) -} - -fn expand_tilde(path: &str) -> String { - if path.starts_with("~/") || path == "~" { - if let Some(home) = dirs::home_dir() { - return format!("{}{}", home.display(), &path[1..]); - } - } - path.to_owned() -} - -fn split_browse_query(expanded: &str) -> (String, &str) { - if let Some(pos) = expanded.rfind('/') { - let dir = if pos == 0 { - "/".to_owned() - } else { - expanded[..pos].to_owned() - }; - let filter = &expanded[pos + 1..]; - (dir, filter) - } else if expanded.len() >= 2 && expanded.as_bytes()[1] == b':' { - if let Some(pos) = expanded.rfind('\\') { - let dir = expanded[..pos].to_owned(); - let filter = &expanded[pos + 1..]; - (dir, filter) - } else { - (expanded.to_owned(), "") - } - } else { - (expanded.to_owned(), "") - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - use std::sync::Arc; - - use clap::Parser; - - use super::{ - ActiveFile, ActiveFileLoading, AppState, AsyncStatus, CarbonStyleOverlays, - CardTextSelection, CompareField, FILE_HEIGHT_SPARSE_MIN_COUNT, FileHeightIndex, - FileListEntry, FocusTarget, OverlayEntry, OverlaySurface, PickerItem, PickerLabelStyle, - PreparedActiveFile, SidebarMode, SidebarTab, TextCompareLanguage, TextCompareView, - ViewportAnchorBias, VirtualDiffItemKind, WorkspaceMode, WorkspaceSource, - prepare_active_file, vcs_compare_request, - }; - use crate::core::compare::{ - CompareFileSummary, CompareMode, CompareOutput, LayoutMode, RendererKind, - }; - use crate::core::text::TokenBuffer; - use crate::core::vcs::model::{ - ChangeBucket, ChangeFlags, FileChange, FileChangeStatus, JjOperation, RefKind, - RepoCapabilities, RepoLocation, RevisionId, VcsChange, VcsKind, VcsOperation, - VcsOperationLogEntry, VcsRef, - }; - use crate::editor::EditorMode; - use crate::editor::diff::render_doc::{RenderDoc, build_render_doc_from_carbon}; - use crate::effects::{ - AiEffect, CompareEffect, CompareWorkPriority, Effect, GitHubEffect, RepositoryEffect, - SettingsEffect, SyntaxEffect, - }; - use crate::events::{ - AppEvent, CompareEvent, CompareFileFinished, CompareFileStat, CompareFileStatsReady, - CompareStatsReady, GitHubEvent, RepositoryEvent, TextCompareFinished, - }; - use crate::platform::persistence::Settings; - use crate::platform::startup::{Args, StartupOptions}; - - fn carbon_summary_for_path(index: usize, path: &str) -> carbon::FileDiff { - carbon::FileDiff { - id: carbon::FileId(index as u32), - old_path: Some(path.to_owned()), - new_path: Some(path.to_owned()), - is_partial: true, - ..carbon::FileDiff::default() - } - } - - fn carbon_context_file(index: usize, path: &str, text: &str) -> carbon::FileDiff { - carbon::parse_unified_patch(&format!( - "diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1 +1 @@\n {text}\n" - )) - .unwrap() - .files - .into_iter() - .next() - .map(|mut file| { - file.id = carbon::FileId(index as u32); - file - }) - .unwrap() - } - - #[test] - fn new_text_compare_enters_text_workspace_with_left_focus() { - let mut state = AppState::default(); - - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - - assert_eq!( - state.workspace.source.get(&state.store), - WorkspaceSource::TextCompare - ); - assert_eq!(state.text_compare.view, TextCompareView::Edit); - assert_eq!(state.text_compare.left_editor.mode(), EditorMode::CodeInput); - assert_eq!( - state.text_compare.right_editor.mode(), - EditorMode::CodeInput - ); - assert_eq!(state.text_compare.language, TextCompareLanguage::Auto); - assert_eq!(state.text_compare.path_hint, "text.txt"); - assert_eq!( - state.focus.get(&state.store), - Some(FocusTarget::TextCompareLeft) - ); - } - - #[test] - fn text_compare_paste_routes_to_focused_side() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - - state.apply_action(crate::actions::TextEditAction::Paste("left".to_owned())); - state.apply_action(crate::actions::AppAction::SetFocus(Some( - FocusTarget::TextCompareRight, - ))); - state.apply_action(crate::actions::TextEditAction::Paste("right".to_owned())); - - assert_eq!(state.text_compare.left_editor.text(), "left"); - assert_eq!(state.text_compare.right_editor.text(), "right"); - assert_eq!(state.text_compare.left_editor.line_count(), 1); - assert_eq!(state.text_compare.right_editor.line_count(), 1); - } - - #[test] - fn text_compare_auto_language_detects_pasted_rust() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - - state.apply_action(crate::actions::TextEditAction::Paste( - "pub fn main() {\n println!(\"hi\");\n}\n".to_owned(), - )); - - assert_eq!( - state.text_compare.detected_language, - Some(TextCompareLanguage::Rust) - ); - assert_eq!(state.text_compare.path_hint, "scratch.rs"); - } - - #[test] - fn text_compare_auto_language_detects_pasted_typescript() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - - state.apply_action(crate::actions::TextEditAction::Paste( - "const answer: number = 42;\nexport { answer };\n".to_owned(), - )); - - assert_eq!( - state.text_compare.detected_language, - Some(TextCompareLanguage::TypeScript) - ); - assert_eq!(state.text_compare.path_hint, "scratch.ts"); - } - - #[test] - fn text_compare_language_override_sets_compare_path() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - - state.apply_action(crate::actions::TextCompareAction::SetLanguage( - TextCompareLanguage::TypeScript, - )); - state.apply_action(crate::actions::TextEditAction::Paste( - "pub fn main() {}\n".to_owned(), - )); - let effects = state.apply_action(crate::actions::TextCompareAction::CompareNow); - let request_path = effects - .iter() - .find_map(|effect| match effect { - Effect::Compare(CompareEffect::RunText(task)) => { - Some(task.request.display_path.as_str()) - } - _ => None, - }) - .unwrap(); - - assert_eq!(state.text_compare.language, TextCompareLanguage::TypeScript); - assert_eq!(state.text_compare.path_hint, "scratch.ts"); - assert_eq!(request_path, "scratch.ts"); - } - - #[test] - fn text_compare_swap_sides_preserves_text_and_marks_stale() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - state.text_compare.left_editor.set_text("old"); - state.text_compare.right_editor.set_text("new"); - let generation = state.text_compare.generation; - - state.apply_action(crate::actions::TextCompareAction::SwapSides); - - assert_eq!(state.text_compare.left_editor.text(), "new"); - assert_eq!(state.text_compare.right_editor.text(), "old"); - assert!(state.text_compare.generation > generation); - assert!(state.text_compare_is_stale()); - } - - #[test] - fn stale_text_compare_finished_event_is_ignored() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - let effects = state.apply_action(crate::actions::TextCompareAction::CompareNow); - let generation = effects - .iter() - .find_map(|effect| match effect { - Effect::Compare(CompareEffect::RunText(task)) => Some(task.generation), - _ => None, - }) - .unwrap(); - state.apply_action(crate::actions::TextEditAction::Paste("newer".to_owned())); - - state.apply_event(AppEvent::from(CompareEvent::TextCompareFinished( - TextCompareFinished { - generation, - display_path: "text.txt".to_owned(), - renderer: RendererKind::Builtin, - layout: LayoutMode::Unified, - output: CompareOutput::default(), - }, - ))); - - assert!(state.workspace.compare_output.get(&state.store).is_none()); - assert_eq!(state.text_compare.view, TextCompareView::Edit); - } - - #[test] - fn text_compare_finished_installs_diff_view() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - let generation = state.text_compare.generation.saturating_add(1); - state.text_compare.generation = generation; - let output = crate::core::compare::compare_text( - "old\n", - "new\n", - "text.txt", - RendererKind::Builtin, - LayoutMode::Unified, - ) - .unwrap(); - - state.apply_event(AppEvent::from(CompareEvent::TextCompareFinished( - TextCompareFinished { - generation, - display_path: "text.txt".to_owned(), - renderer: RendererKind::Builtin, - layout: LayoutMode::Unified, - output, - }, - ))); - - assert_eq!(state.text_compare.view, TextCompareView::Diff); - assert_eq!( - state.workspace.source.get(&state.store), - WorkspaceSource::TextCompare - ); - assert!(state.workspace.active_file.get(&state.store).is_some()); - } - - fn status_state_with_two_hunks() -> AppState { - let state = AppState::default(); - let repo_path = PathBuf::from("/repo"); - let path = "src/lib.rs".to_owned(); - let token_buffer = TokenBuffer::default(); - let carbon_file = carbon::parse_unified_patch( - "\ -diff --git a/src/lib.rs b/src/lib.rs ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -1,3 +1,2 @@ - fn one() { -- old_first(); - } -@@ -8,3 +7,2 @@ - fn two() { -- old_second(); - } -", - ) - .unwrap() - .files - .into_iter() - .next() - .unwrap(); - let carbon_expansion = carbon::ExpansionState::default(); - let render_doc = build_render_doc_from_carbon( - &carbon_file, - 0, - &carbon_expansion, - &CarbonStyleOverlays::default(), - &token_buffer, - ); - let (left_ref, right_ref) = - crate::ui::vcs::profile(None).status_compare_refs(ChangeBucket::Unstaged); - - state.compare.repo_path.set(&state.store, Some(repo_path)); - state - .repository - .capabilities - .set(&state.store, Some(RepoCapabilities::git())); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Status); - state.workspace.status.set(&state.store, AsyncStatus::Ready); - state - .workspace - .status_operation_pending - .set(&state.store, false); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - state.workspace.files.set( - &state.store, - vec![FileListEntry { - path: path.as_str().into(), - }], - ); - state.workspace.status_file_changes.set( - &state.store, - vec![FileChange { - path: path.clone(), - old_path: None, - status: FileChangeStatus::Modified, - bucket: ChangeBucket::Unstaged, - }], - ); - state - .workspace - .selected_file_index - .set(&state.store, Some(0)); - state - .workspace - .selected_file_path - .set(&state.store, Some(path.clone())); - state - .workspace - .selected_change_bucket - .set(&state.store, Some(ChangeBucket::Unstaged)); - state.workspace.active_file.set( - &state.store, - Some(ActiveFile { - index: 0, - path, - carbon_file: Arc::new(carbon_file.clone()), - carbon_expansion, - carbon_overlays: CarbonStyleOverlays::default(), - render_doc: Arc::new(render_doc), - token_buffer, - left_ref, - right_ref, - file_line_count: None, - old_file_lines: None, - file_lines: None, - syntax_pending: Vec::new(), - syntax_covered: Vec::new(), - last_used_tick: 0, - }), - ); - - state - } - - fn loaded_state_with_files(paths: &[&str]) -> AppState { - let state = AppState::default(); - let carbon_files: Vec = paths - .iter() - .enumerate() - .map(|(index, path)| carbon_context_file(index, path, "loaded")) - .collect(); - let entries: Vec = carbon_files - .iter() - .map(|file| FileListEntry { - path: file.path().into(), - }) - .collect(); - - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - carbon: carbon::DiffDocument { - files: carbon_files, - }, - ..CompareOutput::default() - }), - ); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.workspace.files.set(&state.store, entries); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - state.file_list.row_height.set(&state.store, 36.0); - state.file_list.gap.set(&state.store, 4.0); - state.file_list.viewport_height.set(&state.store, 80.0); - state - } - - #[test] - fn bootstrap_with_no_repo_starts_empty_workspace() { - let startup = StartupOptions::from_parts( - Args::parse_from(["diffy"]), - None, - "client".to_owned(), - false, - ); - - let (state, effects) = AppState::bootstrap(startup, Settings::default()); - assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Empty); - assert_eq!( - state.focus.get(&state.store), - Some(FocusTarget::WorkspacePrimaryButton) - ); - assert!(effects.iter().all(|e| matches!( - e, - Effect::GitHub(GitHubEffect::LoadGitHubToken) - | Effect::Ai(AiEffect::LoadAiKeys) - | Effect::Syntax(SyntaxEffect::InstallCommonSyntaxPacks) - ))); - } - - #[test] - fn bootstrap_with_repo_starts_repo_sync() { - let startup = StartupOptions::from_parts( - Args { - repo: Some("C:\\repo".into()), - left: Some("main".to_owned()), - right: None, - compare_mode: Some(CompareMode::TwoDot), - layout: Some(LayoutMode::Unified), - renderer: Some(RendererKind::Builtin), - file_index: None, - file_path: None, - open_pr: None, - }, - None, - "client".to_owned(), - false, - ); - - let (state, effects) = AppState::bootstrap(startup, Settings::default()); - assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Empty); - assert_eq!(state.active_overlay_name(), None); - assert_eq!( - effects - .iter() - .filter(|e| matches!( - e, - Effect::Repository(RepositoryEffect::SyncRepository { .. }) - | Effect::Repository(RepositoryEffect::WatchRepository { .. }) - )) - .count(), - 2 - ); - } - - #[test] - fn overlay_close_restores_prior_focus() { - let startup = StartupOptions::from_parts( - Args::parse_from(["diffy"]), - None, - "client".to_owned(), - false, - ); - let (mut state, _) = AppState::bootstrap(startup, Settings::default()); - state.apply_action(crate::actions::AppAction::SetFocus(Some( - FocusTarget::TitleBar, - ))); - state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); - assert_eq!(state.overlays_top(), Some(OverlaySurface::CommandPalette)); - state.apply_action(crate::actions::OverlayAction::CloseOverlay); - assert_eq!(state.focus.get(&state.store), Some(FocusTarget::TitleBar)); - } - - #[test] - fn pixel_scroll_actions_clamp_file_list_and_viewport() { - let mut state = AppState::default(); - - state.workspace.files.set( - &state.store, - vec![ - FileListEntry { - path: "a.rs".into(), - }, - FileListEntry { - path: "b.rs".into(), - }, - FileListEntry { - path: "c.rs".into(), - }, - FileListEntry { - path: "d.rs".into(), - }, - FileListEntry { - path: "e.rs".into(), - }, - ], - ); - state.file_list.row_height.set(&state.store, 36.0); - state.file_list.gap.set(&state.store, 4.0); - state.file_list.viewport_height.set(&state.store, 80.0); - - state.apply_action(crate::actions::FileListAction::ScrollFileListPx(50)); - assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 50.0); - - state.apply_action(crate::actions::FileListAction::ScrollFileListPx(500)); - assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 116.0); - - state.apply_action(crate::actions::FileListAction::ScrollFileListPx(-500)); - assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 0.0); - - state.editor.content_height_px.set(&state.store, 600); - state.editor.viewport_height_px.set(&state.store, 200); - - state.apply_action(crate::actions::EditorAction::ScrollViewportPx(75)); - assert_eq!(state.editor.scroll_top_px.get(&state.store), 75); - - state.apply_action(crate::actions::EditorAction::ScrollViewportPx(500)); - assert_eq!(state.editor.scroll_top_px.get(&state.store), 400); - - state.apply_action(crate::actions::EditorAction::ScrollViewportPx(-500)); - assert_eq!(state.editor.scroll_top_px.get(&state.store), 0); - } - - #[test] - fn file_height_index_keeps_uniform_large_lists_sparse() { - let mut index = FileHeightIndex::default(); - index.rebuild(vec![192; FILE_HEIGHT_SPARSE_MIN_COUNT + 1]); - - assert_eq!(index.len(), FILE_HEIGHT_SPARSE_MIN_COUNT + 1); - assert_eq!( - index.total_u32(), - ((FILE_HEIGHT_SPARSE_MIN_COUNT + 1) as u32) * 192 - ); - assert!(matches!(index, FileHeightIndex::Sparse { .. })); - assert_eq!(index.locate(192 * 7 + 12), Some((7, 12))); - } - - #[test] - fn sparse_file_height_index_updates_prefix_and_locate() { - let mut index = FileHeightIndex::default(); - index.rebuild(vec![100; FILE_HEIGHT_SPARSE_MIN_COUNT + 2]); - index.update(3, 250); - index.update(7, 40); - - assert_eq!(index.prefix_u32(4), 550); - assert_eq!(index.prefix_u32(8), 890); - assert_eq!(index.locate(549), Some((3, 249))); - assert_eq!(index.locate(550), Some((4, 0))); - assert_eq!(index.locate(849), Some((6, 99))); - assert_eq!(index.locate(850), Some((7, 0))); - assert_eq!(index.locate(889), Some((7, 39))); - assert_eq!(index.locate(890), Some((8, 0))); - } - - #[test] - fn clicking_a_visible_file_does_not_force_sidebar_reveal() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.file_list.scroll_offset_px.set(&state.store, 10.0); - - state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - assert_eq!( - state.workspace.selected_file_index.get(&state.store), - Some(0) - ); - assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 10.0); - } - - #[test] - fn keyboard_file_navigation_still_reveals_selection() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs", "d.rs"]); - state - .workspace - .selected_file_index - .set(&state.store, Some(0)); - state - .workspace - .selected_file_path - .set(&state.store, Some("a.rs".into())); - state.file_list.scroll_offset_px.set(&state.store, 50.0); - - state.apply_action(crate::actions::FileListAction::SelectNextFile); - - assert_eq!( - state.workspace.selected_file_index.get(&state.store), - Some(1) - ); - assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 40.0); - } - - #[test] - fn next_file_action_selects_adjacent_file_in_single_file_mode() { - let mut state = - loaded_state_with_files(&["src/ui/state/mod.rs", "src/ui/state/text_edit.rs"]); - state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - state.apply_action(crate::actions::EditorAction::GoToNextFile); - state.sync_editor_scroll_from_global(); - - assert_eq!( - state.workspace.selected_file_index.get(&state.store), - Some(1) - ); - assert_eq!( - state - .workspace - .selected_file_path - .get(&state.store) - .as_deref(), - Some("src/ui/state/text_edit.rs") - ); - assert_eq!( - state - .workspace - .active_file - .get(&state.store) - .as_ref() - .map(|file| file.path.as_str()), - Some("src/ui/state/text_edit.rs") - ); - assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 0); - } - - #[test] - fn next_file_action_selects_next_file_when_tail_is_short() { - let mut state = - loaded_state_with_files(&["src/ui/state/mod.rs", "src/ui/state/text_edit.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 10_000); - state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - state.apply_action(crate::actions::EditorAction::GoToNextFile); - - assert_eq!( - state.workspace.selected_file_index.get(&state.store), - Some(1) - ); - assert_eq!( - state - .workspace - .selected_file_path - .get(&state.store) - .as_deref(), - Some("src/ui/state/text_edit.rs") - ); - assert_eq!( - state - .workspace - .active_file - .get(&state.store) - .as_ref() - .map(|file| file.path.as_str()), - Some("src/ui/state/text_edit.rs") - ); - } - - #[test] - fn continuous_scroll_keeps_short_tail_at_natural_bottom() { - let state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.editor.viewport_height_px.set(&state.store, 10_000); - - assert_eq!(state.global_max_scroll_top_px(), 0); - } - - #[test] - fn continuous_scroll_first_height_measurement_keeps_total_cache_in_sync_with_index() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.recompute_file_scroll_total_height_px(); - - assert_eq!(state.workspace.measured_px_per_row_q16.get(&state.store), 0); - - assert!(state.update_file_content_height_px(0, 1_200)); - - assert_eq!( - state - .workspace - .file_scroll_total_height_px - .get(&state.store), - state.virtual_diff_document.total_u32() - ); - } - - #[test] - fn continuous_scroll_keeps_bottom_anchor_when_visible_file_height_grows() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 100); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(200), Some(200), Some(200)]); - state.recompute_file_scroll_total_height_px(); - - let old_max = state.global_max_scroll_top_px(); - assert_eq!(old_max, 500); - state - .workspace - .global_scroll_top_px - .set(&state.store, old_max); - - assert!(state.update_file_content_height_px(2, 350)); - - assert_eq!(state.global_max_scroll_top_px(), 650); - assert_eq!( - state.workspace.global_scroll_top_px.get(&state.store), - state.global_max_scroll_top_px() - ); - } - - #[test] - fn continuous_scroll_follow_end_anchor_is_explicit_after_scrolling_to_bottom() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 100); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(200), Some(200), Some(200)]); - state.recompute_file_scroll_total_height_px(); - - let old_max = state.global_max_scroll_top_px(); - state.scroll_viewport_to_global(old_max); - - let anchor = state.virtual_scroll.anchor.expect("bottom anchor"); - assert_eq!(anchor.bias, ViewportAnchorBias::FollowEnd); - - assert!(state.update_file_content_height_px(2, 350)); - - assert_eq!( - state.workspace.global_scroll_top_px.get(&state.store), - state.global_max_scroll_top_px() - ); - } - - #[test] - fn continuous_scroll_preserves_top_anchor_when_prior_file_height_changes() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 100); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(200), Some(200), Some(200)]); - state.recompute_file_scroll_total_height_px(); - - state.scroll_viewport_to_global(250); - let anchor = state.virtual_scroll.anchor.expect("top anchor"); - assert_eq!(anchor.item_id.index, 1); - assert_eq!(anchor.intra_item_offset_px, 50); - assert_eq!(anchor.bias, ViewportAnchorBias::PreserveTop); - - assert!(state.update_file_content_height_px(0, 300)); - - assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 350); - let anchor = state.virtual_scroll.anchor.expect("rebased anchor"); - assert_eq!(anchor.item_id.index, 1); - assert_eq!(anchor.intra_item_offset_px, 50); - } - - #[test] - fn continuous_scroll_preserves_bottom_anchor_when_prior_file_height_changes() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 100); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(200), Some(200), Some(200)]); - state.recompute_file_scroll_total_height_px(); - - state.set_viewport_anchor_for_global(350, ViewportAnchorBias::PreserveBottom); - let anchor = state.virtual_scroll.anchor.expect("bottom-edge anchor"); - assert_eq!(anchor.item_id.index, 2); - assert_eq!(anchor.intra_item_offset_px, 50); - - assert!(state.update_file_content_height_px(0, 300)); - - assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 450); - let anchor = state.virtual_scroll.anchor.expect("rebased anchor"); - assert_eq!(anchor.bias, ViewportAnchorBias::PreserveBottom); - assert_eq!(anchor.item_id.index, 2); - assert_eq!(anchor.intra_item_offset_px, 50); - } - - #[test] - fn continuous_scroll_keeps_bottom_anchor_after_pending_scrollbar_drag_height_update() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 100); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(200), Some(200), Some(200)]); - state.recompute_file_scroll_total_height_px(); - - let old_max = state.global_max_scroll_top_px(); - assert_eq!(old_max, 500); - state - .workspace - .global_scroll_top_px - .set(&state.store, old_max); - state.begin_viewport_scrollbar_drag(600, 100, old_max, old_max); - - assert!(!state.update_file_content_height_px(2, 350)); - state.end_viewport_scrollbar_drag(); - - assert_eq!(state.global_max_scroll_top_px(), 650); - assert_eq!( - state.workspace.global_scroll_top_px.get(&state.store), - state.global_max_scroll_top_px() - ); - } - - #[test] - fn continuous_scroll_does_not_treat_zero_max_as_bottom_anchor() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 1_000); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(200), Some(200), Some(200)]); - state.recompute_file_scroll_total_height_px(); - - assert_eq!(state.global_max_scroll_top_px(), 0); - assert!(state.update_file_content_height_px(2, 700)); - - assert_eq!(state.global_max_scroll_top_px(), 100); - assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 0); - } - - #[test] - fn virtual_diff_document_keeps_large_compare_ranges_sparse_and_anchorable() { - let count = FILE_HEIGHT_SPARSE_MIN_COUNT + 32; - let summaries = (0..count) - .map(|index| { - let path = format!("kernel/file_{index}.c"); - CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ) - }) - .collect::>(); - let mut state = AppState::default(); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 900); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - state.recompute_file_scroll_total_height_px(); - - assert!(matches!( - state.virtual_diff_document.height_index, - FileHeightIndex::Sparse { .. } - )); - - let target = state.global_max_scroll_top_px() / 2; - state.scroll_viewport_to_global(target); - let anchor = state.virtual_scroll.anchor.expect("compare anchor"); - - assert_eq!(anchor.item_id.source, WorkspaceSource::Compare); - assert_eq!( - anchor.item_id.generation, - state.workspace_render_generation() - ); - assert!(anchor.item_id.index < count); - } - - #[test] - fn virtual_diff_document_rejects_stale_measurement_item_ids() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs"]); - state.settings.continuous_scroll = true; - state.recompute_file_scroll_total_height_px(); - let item_id = state.virtual_diff_document.item_id(1).expect("item id"); - - assert!(state.update_virtual_diff_item_height_px(item_id, 300)); - state.workspace.compare_generation.set(&state.store, 1); - - assert!(!state.update_virtual_diff_item_height_px(item_id, 500)); - assert_eq!( - state - .workspace - .file_content_heights - .with(&state.store, |heights| heights.get(1).copied().flatten()), - Some(300) - ); - } - - #[test] - fn continuous_compare_count_keeps_sidebar_files_when_output_is_partially_hydrated() { - let mut state = AppState::default(); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 10_000); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - carbon: carbon::DiffDocument { - files: vec![carbon_context_file(0, "a.rs", "loaded")], - }, - ..CompareOutput::default() - }), - ); - state.workspace.files.set( - &state.store, - vec![ - FileListEntry { - path: "a.rs".into(), - }, - FileListEntry { - path: "b.rs".into(), - }, - FileListEntry { - path: "c.rs".into(), - }, - ], - ); - - assert_eq!(state.workspace_file_count(), 3); - - let (doc, _effects) = state.build_continuous_viewport_document(); - let doc = doc.expect("viewport doc"); - - assert_eq!(doc.slot_indices, vec![0, 1, 2]); - assert_eq!(doc.slot_loading, vec![false, true, true]); - } - - #[test] - fn continuous_viewport_document_exposes_virtual_stream_rows() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 900); - state - .cache_compare_file_from_output(0, "a.rs") - .expect("cached first file"); - state - .cache_compare_file_from_output(1, "b.rs") - .expect("cached second file"); - - let (doc, _effects) = state.build_continuous_viewport_document(); - let doc = doc.expect("viewport doc"); - - assert!( - doc.stream_items - .iter() - .any(|item| item.id.kind == VirtualDiffItemKind::FileHeader) - ); - assert!( - doc.stream_items - .iter() - .any(|item| item.id.kind == VirtualDiffItemKind::Hunk) - ); - assert!( - doc.stream_items - .iter() - .any(|item| item.id.kind == VirtualDiffItemKind::DiffRow) - ); - assert!( - doc.stream_items - .windows(2) - .all(|items| items[0].sort_key <= items[1].sort_key) - ); - assert!( - doc.stream_items - .iter() - .all(|item| item.estimated_height_px > 0) - ); - } - - #[test] - fn continuous_viewport_document_backfills_before_tail_file() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 500); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(800), Some(800), Some(800)]); - state.recompute_file_scroll_total_height_px(); - state - .workspace - .global_scroll_top_px - .set(&state.store, 1_700); - - let (doc, _effects) = state.build_continuous_viewport_document(); - let doc = doc.expect("viewport doc"); - - assert_eq!(doc.slot_indices, vec![1, 2]); - assert_eq!(doc.start_index, 1); - assert_eq!(doc.start_offset_px, 800); - assert_eq!(doc.scroll_top_px, 900); - } - - #[test] - fn continuous_viewport_document_follow_end_builds_from_tail() { - let mut state = - loaded_state_with_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs", "tail.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 500); - state.workspace.file_content_heights.set( - &state.store, - vec![ - Some(1_000), - Some(1_000), - Some(1_000), - Some(1_000), - Some(1_000), - Some(1_000), - Some(100), - ], - ); - state.recompute_file_scroll_total_height_px(); - - state.scroll_viewport_to_global(state.global_max_scroll_top_px()); - - let (doc, _effects) = state.build_continuous_viewport_document(); - let doc = doc.expect("viewport doc"); - - assert_eq!(doc.slot_indices, vec![5, 6]); - assert_eq!(doc.start_index, 5); - assert_eq!(doc.start_offset_px, 5_000); - assert_eq!(doc.scroll_top_px, 600); - } - - #[test] - fn next_file_action_resolves_current_file_from_selected_path() { - let mut state = loaded_state_with_files(&[ - "src/core/compare/backends/git_diff.rs", - "src/core/compare/mod.rs", - "src/core/compare/service.rs", - "src/core/compare/stats.rs", - "src/core/frecency.rs", - "src/ui/state/mod.rs", - "src/ui/state/text_edit.rs", - "src/ui/toolbar.rs", - ]); - state.settings.continuous_scroll = true; - state - .workspace - .selected_file_index - .set(&state.store, Some(0)); - state - .workspace - .selected_file_path - .set(&state.store, Some("src/ui/state/mod.rs".to_owned())); - - state.apply_action(crate::actions::EditorAction::GoToNextFile); - - assert_eq!( - state.workspace.selected_file_index.get(&state.store), - Some(6) - ); - assert_eq!( - state - .workspace - .selected_file_path - .get(&state.store) - .as_deref(), - Some("src/ui/state/text_edit.rs") - ); - } - - #[test] - fn selecting_a_file_requests_async_syntax_without_mutating_compare_output() { - let mut state = AppState::default(); - let mut output = CompareOutput::default(); - output.carbon.files = vec![carbon_context_file( - 0, - "src/lib.rs", - "fn answer() -> i32 { 42 }", - )]; - state - .workspace - .compare_output - .set(&state.store, Some(output)); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.workspace.files.set( - &state.store, - vec![FileListEntry { - path: "src/lib.rs".into(), - }], - ); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/tmp/repo"))); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - - let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - assert!(effects.iter().any(|effect| { - matches!( - effect, - Effect::Syntax(SyntaxEffect::LoadFileSyntax(task)) - if task.request.path == "src/lib.rs" - && task.request.window.start == 0 - && task.request.window.end > 0 - ) - })); - state.workspace.compare_output.with(&state.store, |co| { - let output = co.as_ref().expect("compare output"); - assert_eq!(output.carbon.files[0].path(), "src/lib.rs"); - assert_eq!(output.carbon.files[0].hunks.len(), 1); - }); - } - - #[test] - fn prepare_active_file_builds_from_carbon_text() { - let carbon_file = carbon::parse_unified_patch( - "\ -diff --git a/src/lib.rs b/src/lib.rs ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -1 +1 @@ - fn answer() -> i32 { 42 } -", - ) - .unwrap() - .files - .into_iter() - .next() - .unwrap(); - - let prepared = prepare_active_file(0, &carbon_file); - - assert_eq!(prepared.carbon_file.path(), "src/lib.rs"); - assert!(prepared.render_doc.lines.iter().any(|render_line| { - prepared.render_doc.line_text(render_line.left_text) == "fn answer() -> i32 { 42 }" - || prepared.render_doc.line_text(render_line.right_text) - == "fn answer() -> i32 { 42 }" - })); - } - - #[test] - fn small_compare_file_selection_stays_synchronous() { - let mut state = AppState::default(); - let mut output = CompareOutput::default(); - let mut carbon_file = carbon_context_file(0, "src/lib.rs", "fn answer() -> i32 { 42 }"); - carbon_file.additions = 10; - carbon_file.deletions = 5; - output.carbon.files = vec![carbon_file]; - - state - .workspace - .compare_output - .set(&state.store, Some(output)); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.workspace.files.set( - &state.store, - vec![FileListEntry { - path: "src/lib.rs".into(), - }], - ); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - - let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - assert!(effects.iter().any(|effect| { - matches!(effect, Effect::Syntax(SyntaxEffect::EnsureSyntaxPackForPath { path }) if path == "src/lib.rs") - })); - assert!( - !effects - .iter() - .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadFile(_)))) - ); - assert!( - state - .workspace - .active_file_loading - .get(&state.store) - .is_none() - ); - assert_eq!( - state - .workspace - .active_file - .get(&state.store) - .as_ref() - .map(|file| file.path.as_str()), - Some("src/lib.rs") - ); - } - - #[test] - fn selecting_large_compare_file_dispatches_async_load() { - let mut state = loaded_state_with_files(&["src/big.rs"]); - state - .workspace - .compare_output - .update(&state.store, |output| { - let file = &mut output.as_mut().expect("compare output").carbon.files[0]; - file.additions = 1_500; - }); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - state.compare.left_ref.set(&state.store, "v5.5".to_owned()); - state.compare.right_ref.set(&state.store, "v5.6".to_owned()); - state - .compare - .renderer - .set(&state.store, RendererKind::Builtin); - state.compare.layout.set(&state.store, LayoutMode::Unified); - state.compare.mode.set(&state.store, CompareMode::TwoDot); - - let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - assert!(matches!( - effects.as_slice(), - [ - Effect::Syntax(SyntaxEffect::EnsureSyntaxPackForPath { path }), - Effect::Compare(CompareEffect::LoadFile(task)) - ] - if path == "src/big.rs" - && task.request.index == 0 - && task.request.path == "src/big.rs" - )); - assert_eq!( - state.workspace.active_file_loading.get(&state.store), - Some(ActiveFileLoading { - index: 0, - path: "src/big.rs".to_owned(), - priority: CompareWorkPriority::InteractiveSelectedFile, - }) - ); - assert!(state.workspace.active_file.get(&state.store).is_none()); - } - - #[test] - fn selecting_deferred_compare_file_dispatches_async_load() { - let mut state = loaded_state_with_files(&["src/kernel.c"]); - state - .workspace - .compare_output - .update(&state.store, |output| { - let file = &mut output.as_mut().expect("compare output").carbon.files[0]; - file.is_partial = true; - file.hunks.clear(); - file.blocks.clear(); - }); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - - let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - assert!(effects.iter().any(|effect| { - matches!( - effect, - Effect::Compare(CompareEffect::LoadFile(task)) - if task.request.index == 0 - && task.request.path == "src/kernel.c" - && task.request.deferred_file.as_ref().is_some_and(|file| file.is_partial) - ) - })); - assert_eq!( - state.workspace.active_file_loading.get(&state.store), - Some(ActiveFileLoading { - index: 0, - path: "src/kernel.c".to_owned(), - priority: CompareWorkPriority::InteractiveSelectedFile, - }) - ); - assert!(state.workspace.active_file.get(&state.store).is_none()); - } - - #[test] - fn scrollbar_drag_loads_visible_compare_files_without_selecting_them() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 240); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - state - .workspace - .compare_output - .update(&state.store, |output| { - for file in &mut output.as_mut().expect("compare output").carbon.files { - file.is_partial = true; - file.hunks.clear(); - file.blocks.clear(); - } - }); - state.begin_viewport_scrollbar_drag(900, 240, 300, 660); - - let (_doc, effects) = state.build_continuous_viewport_document(); - - assert!( - effects - .iter() - .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadFile(_)))) - ); - assert!( - state - .workspace - .active_file_loading - .get(&state.store) - .is_none() - ); - } - - #[test] - fn overscan_prefetch_does_not_enqueue_syntax_work() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - - let effects = state.prefetch_compare_files_forward(0, 1_000); - - assert!( - !effects - .iter() - .any(|effect| matches!(effect, Effect::Syntax(SyntaxEffect::LoadFileSyntax(_)))), - "overscan should warm file diffs without adding syntax windows" - ); - state.workspace.file_cache.with(&state.store, |files| { - assert!(files.values().all(|file| file.syntax_pending.is_empty())); - }); - } - - #[test] - fn offscreen_viewport_slots_do_not_enqueue_syntax_work() { - let mut state = loaded_state_with_files(&["a.rs"]); - state - .cache_compare_file_from_output(0, "a.rs") - .expect("cached file"); - let key = state.compare_slot_key_at(0, "a.rs"); - - let window = state.viewport_slot_syntax_window(&key, 1_000, 120, 0, 240); - - assert_eq!(window, None); - } - - #[test] - fn syntax_budget_counts_inflight_requests_after_cache_eviction() { - let mut state = loaded_state_with_files(&["a.rs"]); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - state - .cache_compare_file_from_output(0, "a.rs") - .expect("cached file"); - let key = state.compare_slot_key_at(0, "a.rs"); - - let effect = state.request_viewport_slot_syntax_window( - &key, - crate::core::syntax::annotator::SyntaxRowWindow { start: 0, end: 32 }, - ); - - assert!(matches!( - effect, - Some(Effect::Syntax(SyntaxEffect::LoadFileSyntax(_))) - )); - state.workspace.file_cache.update(&state.store, |files| { - files.clear(); - }); - assert_eq!(state.syntax_pending_window_count(), 0); - assert_eq!(state.syntax_requests.inflight_len(), 1); - } - - #[test] - fn syntax_epoch_invalidation_clears_attached_pending_windows() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs"]); - state - .cache_compare_file_from_output(0, "a.rs") - .expect("cached file"); - state - .cache_compare_file_from_output(1, "b.rs") - .expect("cached file"); - let active = state - .workspace - .file_cache - .with(&state.store, |files| files.get(&0).cloned()) - .expect("cached active"); - state.workspace.active_file.set(&state.store, Some(active)); - let pending = super::SyntaxPendingWindow { - request_id: 1, - window: crate::core::syntax::annotator::SyntaxRowWindow { start: 0, end: 32 }, - }; - state.workspace.active_file.update(&state.store, |active| { - active - .as_mut() - .expect("active file") - .syntax_pending - .push(pending); - }); - state.workspace.file_cache.update(&state.store, |files| { - for file in files.values_mut() { - file.syntax_pending.push(pending); - } - }); - state.syntax_requests.insert_inflight(0, 1); - - let effect = state.invalidate_syntax_epoch_effect(); - - assert!(matches!( - effect, - Effect::Syntax(SyntaxEffect::SetFileSyntaxEpoch { .. }) - )); - assert_eq!(state.syntax_pending_window_count(), 0); - assert_eq!(state.syntax_requests.inflight_len(), 0); - } - - #[test] - fn context_expansion_invalidates_existing_syntax_windows() { - let mut state = status_state_with_two_hunks(); - let stale_window = crate::core::syntax::annotator::SyntaxRowWindow { start: 0, end: 8 }; - - state.workspace.active_file.update(&state.store, |active| { - let active = active.as_mut().expect("active file"); - active.syntax_pending.push(super::SyntaxPendingWindow { - request_id: 7, - window: stale_window, - }); - active.syntax_covered.push(stale_window); - let range = active - .token_buffer - .append(&[crate::core::text::DiffTokenSpan { - offset: 0, - length: 2, - kind: Default::default(), - intensity: Default::default(), - }]); - active - .carbon_overlays - .insert_syntax(0, carbon::DiffSide::Old, 0, range); - }); - - state.apply_context_expansion( - crate::events::ContextDirection::All, - 0, - 0, - Arc::new((0..12).map(|index| format!("old {index}")).collect()), - Arc::new((0..12).map(|index| format!("new {index}")).collect()), - ); - - state.workspace.active_file.with(&state.store, |active| { - let active = active.as_ref().expect("active file"); - assert!(active.syntax_pending.is_empty()); - assert!(active.syntax_covered.is_empty()); - assert_eq!(active.token_buffer.len(), 0); - }); - } - - #[test] - fn context_expansion_retires_old_syntax_epoch_before_requeue() { - let mut state = status_state_with_two_hunks(); - state.workspace.active_file.update(&state.store, |active| { - let active = active.as_mut().expect("active file"); - active.old_file_lines = Some(Arc::new( - (0..12).map(|index| format!("old {index}")).collect(), - )); - active.file_lines = Some(Arc::new( - (0..12).map(|index| format!("new {index}")).collect(), - )); - }); - state.workspace.compare_generation.set(&state.store, 1); - for request_id in 0..super::MAX_PENDING_SYNTAX_WINDOWS as u64 { - state.syntax_requests.insert_inflight(0, request_id); - } - - let effects = state.dispatch_context_expansion(0, crate::events::ContextDirection::All, 0); - - assert!(matches!( - effects.first(), - Some(Effect::Syntax(SyntaxEffect::SetFileSyntaxEpoch { .. })) - )); - assert!(effects.iter().any(|effect| { - matches!( - effect, - Effect::Syntax(SyntaxEffect::LoadFileSyntax(task)) - if task.request.syntax_epoch == state.syntax_requests.epoch() - ) - })); - assert_eq!(state.syntax_requests.inflight_len(), 1); - } - - #[test] - fn syntax_pack_install_retires_old_epoch_before_refresh() { - let mut state = status_state_with_two_hunks(); - for request_id in 0..super::MAX_PENDING_SYNTAX_WINDOWS as u64 { - state.syntax_requests.insert_inflight(0, request_id); - } - - let effects = state.handle_syntax_packs_installed(&["rust".to_owned()]); - - assert!(matches!( - effects.first(), - Some(Effect::Syntax(SyntaxEffect::SetFileSyntaxEpoch { .. })) - )); - assert!(effects.iter().any(|effect| { - matches!( - effect, - Effect::Syntax(SyntaxEffect::LoadFileSyntax(task)) - if task.request.syntax_epoch == state.syntax_requests.epoch() - ) - })); - assert_eq!(state.syntax_requests.inflight_len(), 1); - } - - #[test] - fn compare_file_finished_ignores_stale_path() { - let mut state = loaded_state_with_files(&["src/lib.rs"]); - state.workspace.compare_generation.set(&state.store, 7); - state - .workspace - .selected_file_index - .set(&state.store, Some(0)); - state - .workspace - .selected_file_path - .set(&state.store, Some("src/lib.rs".to_owned())); - state.workspace.active_file_loading.set( - &state.store, - Some(ActiveFileLoading { - index: 0, - path: "src/lib.rs".to_owned(), - priority: CompareWorkPriority::InteractiveSelectedFile, - }), - ); - - state.apply_event(AppEvent::from(CompareEvent::CompareFileFinished( - CompareFileFinished { - generation: 7, - index: 0, - path: "src/other.rs".to_owned(), - prepared: PreparedActiveFile { - carbon_file: carbon::FileDiff::default(), - carbon_expansion: carbon::ExpansionState::default(), - carbon_overlays: CarbonStyleOverlays::default(), - render_doc: Arc::new(RenderDoc::default()), - token_buffer: TokenBuffer::default(), - }, - }, - ))); - - assert!(state.workspace.active_file.get(&state.store).is_none()); - assert_eq!( - state.workspace.active_file_loading.get(&state.store), - Some(ActiveFileLoading { - index: 0, - path: "src/lib.rs".to_owned(), - priority: CompareWorkPriority::InteractiveSelectedFile, - }) - ); - } - - #[test] - fn overlay_list_pixel_scroll_action_clamps_active_overlay() { - let mut state = AppState::default(); - state.overlays.stack.update(&state.store, |stack| { - stack.push(super::OverlayEntry { - surface: OverlaySurface::RepoPicker, - focus_return: None, - }); - }); - let picker_entries: Vec = (0..12) - .map(|index| super::PickerEntry { - label: format!("repo-{index}"), - detail: format!("C:\\repo-{index}"), - value: format!("C:\\repo-{index}"), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - icon: None, - section_header: false, - }) - .collect(); - state - .overlays - .picker - .entries - .set(&state.store, picker_entries); - state - .overlays - .picker - .list - .update(&state.store, |l| l.viewport_height_px = 120); - - state.apply_action(crate::actions::OverlayAction::ScrollActiveOverlayListPx(50)); - assert_eq!( - state - .overlays - .picker - .list - .with(&state.store, |l| l.scroll_top_px), - 50 - ); - - state.apply_action(crate::actions::OverlayAction::ScrollActiveOverlayListPx( - 1_000, - )); - assert_eq!( - state - .overlays - .picker - .list - .with(&state.store, |l| l.scroll_top_px), - 312 - ); - - state.apply_action(crate::actions::OverlayAction::ScrollActiveOverlayListPx( - -1_000, - )); - assert_eq!( - state - .overlays - .picker - .list - .with(&state.store, |l| l.scroll_top_px), - 0 - ); - } - - #[test] - fn closing_overlays_restores_previous_focus() { - let mut state = AppState::default(); - state.apply_action(crate::actions::AppAction::SetFocus(Some( - FocusTarget::FileList, - ))); - - state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); - assert_eq!( - state.focus.get(&state.store), - Some(FocusTarget::CommandPaletteInput) - ); - - // Each nested overlay records its own restore target. - state.apply_action(crate::actions::OverlayAction::OpenGitHubAuthModal); - assert_eq!( - state.focus.get(&state.store), - Some(FocusTarget::AuthPrimaryAction) - ); - - state.apply_action(crate::actions::OverlayAction::CloseOverlay); - assert_eq!(state.overlays_top(), Some(OverlaySurface::CommandPalette)); - assert_eq!( - state.focus.get(&state.store), - Some(FocusTarget::CommandPaletteInput) - ); - - state.apply_action(crate::actions::OverlayAction::CloseOverlay); - assert_eq!(state.overlays_top(), None); - assert_eq!(state.focus.get(&state.store), Some(FocusTarget::FileList)); - } - - #[test] - fn clearing_overlay_stack_restores_pre_overlay_focus() { - let mut state = AppState::default(); - state.apply_action(crate::actions::AppAction::SetFocus(Some( - FocusTarget::FileList, - ))); - state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); - state.apply_action(crate::actions::OverlayAction::OpenGitHubAuthModal); - - state.clear_overlays(); - - assert_eq!(state.overlays_top(), None); - assert_eq!(state.focus.get(&state.store), Some(FocusTarget::FileList)); - } - - #[test] - fn stage_hunk_at_stages_the_given_index() { - let mut state = status_state_with_two_hunks(); - - let effects = state.apply_action(crate::actions::RepositoryAction::StageHunkAt(1)); - - let [Effect::Repository(RepositoryEffect::ApplyPatchOperation(request))] = - effects.as_slice() - else { - panic!("expected one patch effect, got {:?}", effects); - }; - assert!(request.patch.contains("old_second();")); - assert!(!request.patch.contains("old_first();")); - } - - #[test] - fn stage_hunk_reads_the_hovered_hunk_index() { - let mut state = status_state_with_two_hunks(); - state.editor.hovered_hunk_index.set(&state.store, Some(1)); - - let effects = state.apply_action(crate::actions::RepositoryAction::StageHunk); - - let [Effect::Repository(RepositoryEffect::ApplyPatchOperation(request))] = - effects.as_slice() - else { - panic!("expected one patch effect"); - }; - assert!(request.patch.contains("old_second();")); - } - - #[test] - fn stage_hunk_without_partial_hunk_capability_is_ignored() { - let mut state = status_state_with_two_hunks(); - let mut capabilities = RepoCapabilities::git(); - capabilities.staging_area = false; - capabilities.partial_hunk_mutation = false; - state - .repository - .capabilities - .set(&state.store, Some(capabilities)); - - let effects = state.apply_action(crate::actions::RepositoryAction::StageHunkAt(0)); - - assert!(effects.is_empty()); - } - - #[test] - fn status_operation_failure_clears_the_pending_flag() { - let mut state = status_state_with_two_hunks(); - let _ = state.apply_action(crate::actions::RepositoryAction::StageHunkAt(0)); - assert!(state.workspace.status_operation_pending.get(&state.store)); - - let _ = state.apply_event(AppEvent::from(RepositoryEvent::FileOperationFailed { - path: PathBuf::from("/repo"), - message: "patch failed".to_owned(), - })); - - assert!(!state.workspace.status_operation_pending.get(&state.store)); - } - - #[test] - fn ref_picker_rebuilds_matches_while_typing_and_keeps_raw_git_revisions_selectable() { - let mut state = AppState::default(); - state.repository.refs.set( - &state.store, - vec![VcsRef { - name: "main".to_owned(), - kind: RefKind::Branch, - target: RevisionId::git("0000000000000000000000000000000000000000"), - active: true, - upstream: None, - ahead_behind: None, - }], - ); - - state.open_ref_picker(CompareField::Left); - state.apply_action(crate::actions::TextEditAction::InsertText("mai".to_owned())); - - let branch_highlights = state.overlays.picker.entries.with(&state.store, |entries| { - entries - .iter() - .find(|entry| entry.value == "main") - .expect("main branch entry") - .highlights - .clone() - }); - assert_eq!(branch_highlights, vec![(0, 3)]); - - let mut state = AppState::default(); - state.open_ref_picker(CompareField::Left); - state.apply_action(crate::actions::TextEditAction::InsertText( - "HEAD~2".to_owned(), - )); - - let (typed_value, typed_highlights) = - state.overlays.picker.entries.with(&state.store, |entries| { - let typed_entry = entries.first().expect("typed ref entry"); - (typed_entry.value.clone(), typed_entry.highlights.clone()) - }); - assert_eq!(typed_value, "HEAD~2"); - assert_eq!(typed_highlights, vec![(0, "HEAD~2".len())]); - - state.apply_action(crate::actions::OverlayAction::ConfirmOverlaySelection); - assert_eq!(state.compare.left_ref.get(&state.store), "HEAD~2"); - } - - #[test] - fn ref_picker_uses_jj_refs_and_change_ids_without_git_workdir() { - let mut state = AppState::default(); - let working_commit = "3e2d7a6e55221e519e3efb86e4f8fbb324980427".to_owned(); - let change_id = "xxyzvpwmsuxytmqltlzwzqpylvlqqyso".to_owned(); - - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: PathBuf::from("/repo"), - store_root: Some(PathBuf::from("/repo/.jj")), - }), - ); - state.repository.refs.set( - &state.store, - vec![ - VcsRef { - name: "@".to_owned(), - kind: RefKind::WorkingCopy, - target: RevisionId { - backend: VcsKind::JJ, - id: working_commit.clone(), - }, - active: true, - upstream: None, - ahead_behind: None, - }, - VcsRef { - name: "main".to_owned(), - kind: RefKind::Bookmark, - target: RevisionId { - backend: VcsKind::JJ, - id: "a4c9f6e8b1d24036a78610a332e12ca25e97c315".to_owned(), - }, - active: false, - upstream: None, - ahead_behind: None, - }, - ], - ); - state.repository.changes.set( - &state.store, - vec![VcsChange { - revision: RevisionId { - backend: VcsKind::JJ, - id: working_commit, - }, - change_id: Some(change_id.clone()), - short_change_id: Some("xsvsonvs".to_owned()), - short_change_id_prefix_len: Some(2), - short_revision: "3e2d7a6e5522".to_owned(), - summary: "Working copy".to_owned(), - author_name: "ro".to_owned(), - timestamp: 0, - flags: ChangeFlags { - current: true, - working_copy: true, - ..ChangeFlags::default() - }, - }], - ); - - state.open_ref_picker(CompareField::Left); - - state.overlays.picker.entries.with(&state.store, |entries| { - assert!(!entries.iter().any(|entry| entry.value == "@workdir")); - - let working_copy = entries - .iter() - .find(|entry| entry.value == "@") - .expect("working copy ref"); - assert_eq!( - working_copy.detail, - "Working copy change \u{2022} current / xsvsonvs 3e2d7a6e5522" - ); - - let bookmark = entries - .iter() - .find(|entry| entry.value == "main") - .expect("bookmark ref"); - assert_eq!(bookmark.detail, "Bookmark"); - - let change = entries - .iter() - .find(|entry| entry.value == change_id) - .expect("change id entry"); - assert_eq!(change.label, "xsvsonvs"); - assert!(change.highlights.is_empty()); - assert_eq!( - change.label_style(), - PickerLabelStyle::JjChangeId { - prefix_len: 2, - working_copy: true, - } - ); + let store = Rc::new(SignalStore::default()); + let sidebar_visible = store.create(true); + let focus = store.create(if repo_path.is_some() { + Some(FocusTarget::TitleBar) + } else { + Some(FocusTarget::WorkspacePrimaryButton) }); - } - - #[test] - fn command_palette_uses_actual_match_indices_for_highlighting() { - let mut state = AppState::default(); - state - .overlays - .command_palette - .query - .set(&state.store, "them".to_owned()); - - state.rebuild_command_palette(); - - let highlights = state - .overlays - .command_palette - .entries - .with(&state.store, |entries| { - entries - .iter() - .find(|entry| entry.label == "Change Theme") - .expect("Change Theme entry") - .highlights - .clone() - }); - assert_eq!(highlights, vec![(7, 11)]); - } - - #[test] - fn command_palette_surfaces_jj_operations_for_jj_repositories() { - let mut state = AppState::default(); - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: PathBuf::from("/repo"), - store_root: Some(PathBuf::from("/repo/.jj")), - }), - ); - state - .overlays - .command_palette - .query - .set(&state.store, "jj".to_owned()); - - state.rebuild_command_palette(); - - let entries = state - .overlays - .command_palette - .entries - .with(&state.store, |entries| entries.clone()); - for operation in JjOperation::ALL { - let label = format!("jj: {}", operation.label()); - let entry = entries - .iter() - .find(|entry| entry.label == label) - .unwrap_or_else(|| panic!("missing {label} command")); - assert!(matches!( - entry.kind, - super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( - VcsOperation::Jj(found) - )) if found == operation - )); - } - - let mut state = AppState::default(); - state - .overlays - .command_palette - .query - .set(&state.store, "jj".to_owned()); - state.rebuild_command_palette(); - let has_jj_operation = - state - .overlays - .command_palette - .entries - .with(&state.store, |entries| { - entries.iter().any(|entry| { - JjOperation::ALL - .into_iter() - .any(|operation| entry.label == format!("jj: {}", operation.label())) - }) - }); - assert!(!has_jj_operation); - } - - #[test] - fn jj_operation_action_emits_repository_effect() { - let mut state = AppState::default(); - let repo_path = PathBuf::from("/repo"); - let operation = VcsOperation::Jj(JjOperation::NewChange); - state - .compare - .repo_path - .set(&state.store, Some(repo_path.clone())); - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: repo_path.clone(), - store_root: Some(repo_path.join(".jj")), - }), - ); - - let effects = state.apply_action(crate::actions::RepositoryAction::RunOperation( - operation.clone(), - )); - - let [Effect::Repository(RepositoryEffect::RunOperation(request))] = effects.as_slice() - else { - panic!("expected RunOperation effect, got {effects:?}"); - }; - assert_eq!(request.repo_path, repo_path); - assert_eq!(request.operation, operation); - } - - #[test] - fn destructive_jj_palette_operation_requires_confirmation() { - let mut state = AppState::default(); - let repo_path = PathBuf::from("/repo"); - let operation = VcsOperation::Jj(JjOperation::AbandonChange); - state - .compare - .repo_path - .set(&state.store, Some(repo_path.clone())); - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: repo_path.clone(), - store_root: Some(repo_path.join(".jj")), - }), - ); - state - .overlays - .command_palette - .query - .set(&state.store, "abandon".to_owned()); - state.overlays.stack.update(&state.store, |stack| { - stack.push(OverlayEntry { - surface: OverlaySurface::CommandPalette, - focus_return: None, - }); + let text_focused = + store.create_memo(move |s| s.read(focus).is_some_and(|t| t.is_text_field())); + let workspace_mode = store.create(if repo_path.is_some() && auto_compare_pending { + WorkspaceMode::Loading + } else { + WorkspaceMode::Empty }); - state.rebuild_command_palette(); - - let effects = state.apply_action(crate::actions::OverlayAction::ConfirmOverlaySelection); - - assert!(effects.is_empty()); - assert_eq!(state.overlays_top(), Some(OverlaySurface::Confirmation)); - assert_eq!( - state.overlays.confirmation.action.get(&state.store), - Some(crate::actions::RepositoryAction::RunOperation(operation.clone()).into()) - ); - - let effects = state.apply_action(crate::actions::OverlayAction::ConfirmOverlaySelection); - - let [Effect::Repository(RepositoryEffect::RunOperation(request))] = effects.as_slice() - else { - panic!("expected RunOperation effect, got {effects:?}"); - }; - assert_eq!(request.repo_path, repo_path); - assert_eq!(request.operation, operation); - assert_eq!(state.overlays_top(), None); - } - - #[test] - fn command_palette_surfaces_jj_rebase_destinations() { - let mut state = AppState::default(); - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: PathBuf::from("/repo"), - store_root: Some(PathBuf::from("/repo/.jj")), - }), - ); - state.repository.refs.set( - &state.store, - vec![ - VcsRef { - name: "@".to_owned(), - kind: RefKind::WorkingCopy, - target: RevisionId { - backend: VcsKind::JJ, - id: "current".to_owned(), - }, - active: true, - upstream: None, - ahead_behind: None, - }, - VcsRef { - name: "main".to_owned(), - kind: RefKind::Bookmark, - target: RevisionId { - backend: VcsKind::JJ, - id: "main-revision".to_owned(), - }, - active: false, - upstream: None, - ahead_behind: None, - }, - ], - ); - state - .overlays - .command_palette - .query - .set(&state.store, "rebase main".to_owned()); - - state.rebuild_command_palette(); - - let entry = state - .overlays - .command_palette - .entries - .with(&state.store, |entries| entries.first().cloned()) - .expect("rebase entry"); - assert_eq!(entry.label, "jj: Rebase @ Onto main"); - assert!(matches!( - entry.kind, - super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( - VcsOperation::JjRebaseCurrentChangeOnto { ref destination } - )) if destination == "main" - )); - } - - #[test] - fn command_palette_surfaces_jj_editable_changes() { - let mut state = AppState::default(); - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: PathBuf::from("/repo"), - store_root: Some(PathBuf::from("/repo/.jj")), - }), - ); - state.repository.changes.set( - &state.store, - vec![ - VcsChange { - revision: RevisionId { - backend: VcsKind::JJ, - id: "current-revision".to_owned(), - }, - change_id: Some("current-change".to_owned()), - short_change_id: Some("cur".to_owned()), - short_change_id_prefix_len: Some(3), - short_revision: "currev".to_owned(), - summary: "current".to_owned(), - author_name: "ro".to_owned(), - timestamp: 0, - flags: ChangeFlags { - current: true, - working_copy: true, - ..ChangeFlags::default() - }, - }, - VcsChange { - revision: RevisionId { - backend: VcsKind::JJ, - id: "target-revision".to_owned(), - }, - change_id: Some("target-change".to_owned()), - short_change_id: Some("tgt".to_owned()), - short_change_id_prefix_len: Some(3), - short_revision: "tgt123".to_owned(), - summary: "target change".to_owned(), - author_name: "ro".to_owned(), - timestamp: 0, - flags: ChangeFlags::default(), - }, - ], + let compare_progress = store.create(None::>); + let app_view = store.create(AppView::default()); + let settings_section = store.create(SettingsSection::default()); + let keymap_capture = store.create(None::); + let keymaps_scroll_top_px = store.create(0.0_f32); + let keymaps_viewport_height_px = store.create(0.0_f32); + let keymaps_content_height_px = store.create(0.0_f32); + let last_error = store.create(None::); + let theme_preview_original = store.create(None::); + let toasts = store.create(Vec::::new()); + let syntax_pack_installs = store.create(Vec::::new()); + let update = store.create(UpdateState::default()); + let debug = DebugStateStore::new(&store, DebugState::default()); + let file_list = FileListStateStore::new_default(&store); + let editor = EditorStateStore::new( + &store, + EditorState { + layout, + wrap_enabled: settings.viewport.wrap_enabled, + wrap_column: settings.viewport.wrap_column, + ..EditorState::default() + }, ); - state - .overlays - .command_palette - .query - .set(&state.store, "edit tgt".to_owned()); - - state.rebuild_command_palette(); - - let entry = state - .overlays - .command_palette - .entries - .with(&state.store, |entries| entries.first().cloned()) - .expect("edit entry"); - assert_eq!(entry.label, "jj: Edit tgt"); - assert!(matches!( - entry.kind, - super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( - VcsOperation::JjEditRevision { - ref revision, - ref label - } - )) if revision == "target-revision" && label == "tgt" - )); - } - - #[test] - fn command_palette_surfaces_jj_operation_log_restore_targets() { - let mut state = AppState::default(); - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: PathBuf::from("/repo"), - store_root: Some(PathBuf::from("/repo/.jj")), - }), + let overlays = OverlayStackStateStore::new_default(&store); + let compare = CompareStateStore::new( + &store, + CompareState { + repo_path: repo_path.clone(), + left_ref, + right_ref, + mode, + layout, + renderer, + resolved_left: None, + resolved_right: None, + }, ); - state.repository.operation_log.set( - &state.store, - vec![ - VcsOperationLogEntry { - operation_id: "current-operation".to_owned(), - short_operation_id: "current".to_owned(), - user: "ro".to_owned(), - time: "later".to_owned(), - description: "snapshot working copy".to_owned(), - }, - VcsOperationLogEntry { - operation_id: "target-operation".to_owned(), - short_operation_id: "target".to_owned(), - user: "ro".to_owned(), - time: "earlier".to_owned(), - description: "describe change".to_owned(), + let repository = RepositoryStateStore::new_default(&store); + let workspace = WorkspaceStateStore::new_default(&store); + let text_edit = TextEditStateStore::new_default(&store); + let initial_token_present = settings.github_user.is_some(); + let github = GitHubStateStore::new( + &store, + GitHubState { + client_id: startup.github_client_id.clone(), + auth: GitHubAuthState { + token_present: initial_token_present, + user: settings.github_user.clone(), + ..GitHubAuthState::default() }, - ], - ); - state - .overlays - .command_palette - .query - .set(&state.store, "restore target".to_owned()); - - state.rebuild_command_palette(); - - let entries = state - .overlays - .command_palette - .entries - .with(&state.store, |entries| entries.clone()); - assert!( - !entries - .iter() - .any(|entry| entry.label == "jj: Restore Operation current") - ); - let entry = entries - .iter() - .find(|entry| entry.label == "jj: Restore Operation target") - .expect("restore entry"); - assert_eq!(entry.detail, "describe change - ro - earlier"); - assert!(matches!( - entry.kind, - super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( - VcsOperation::JjRestoreOperation { - ref operation_id, - ref label - } - )) if operation_id == "target-operation" && label == "target" - )); - } - - #[test] - fn sidebar_width_action_clamps_and_stores_manual_preference() { - let mut state = AppState::default(); - - state.apply_action(crate::actions::SettingsAction::SetSidebarWidthPx(40)); - assert_eq!(state.settings.sidebar_width_px, Some(179)); - - state.apply_action(crate::actions::SettingsAction::SetSidebarWidthPx(420)); - assert_eq!(state.settings.sidebar_width_px, Some(420)); - } - - #[test] - fn ui_scale_actions_step_and_persist_within_bounds() { - let mut state = AppState::default(); - - let effects = state.apply_action(crate::actions::SettingsAction::IncreaseUiScale); - assert_eq!(state.settings.ui_scale_pct, 110); - assert_eq!(effects.len(), 1); - - for _ in 0..20 { - state.apply_action(crate::actions::SettingsAction::IncreaseUiScale); - } - assert_eq!(state.settings.ui_scale_pct, 180); - - for _ in 0..20 { - state.apply_action(crate::actions::SettingsAction::DecreaseUiScale); - } - assert_eq!(state.settings.ui_scale_pct, 70); - } - - #[test] - fn avatar_url_sized_appends_or_replaces_s_param() { - use super::avatar_url_sized; - assert_eq!( - avatar_url_sized("https://avatars.githubusercontent.com/u/1?v=4", 128), - Some("https://avatars.githubusercontent.com/u/1?v=4&s=128".to_owned()) - ); - assert_eq!( - avatar_url_sized("https://avatars.githubusercontent.com/u/1", 64), - Some("https://avatars.githubusercontent.com/u/1?s=64".to_owned()) - ); - assert_eq!( - avatar_url_sized("https://avatars.githubusercontent.com/u/1?s=40&v=4", 128), - Some("https://avatars.githubusercontent.com/u/1?v=4&s=128".to_owned()) - ); - assert_eq!(avatar_url_sized("", 128), None); - } - - #[test] - fn card_text_selection_slices_normalized_range() { - let body = "the quick brown fox".to_owned(); - // Forward selection. - let mut sel = CardTextSelection::new(7, body.clone(), 4); - sel.focus = 9; - assert_eq!(sel.normalized(), (4, 9)); - assert_eq!(sel.selected_text().as_deref(), Some("quick")); - assert!(!sel.is_collapsed()); - - // Reversed drag yields the same substring. - let mut rev = CardTextSelection::new(7, body.clone(), 9); - rev.focus = 4; - assert_eq!(rev.normalized(), (4, 9)); - assert_eq!(rev.selected_text().as_deref(), Some("quick")); - - // Collapsed selection copies nothing. - let collapsed = CardTextSelection::new(7, body.clone(), 4); - assert!(collapsed.is_collapsed()); - assert_eq!(collapsed.selected_text(), None); - - // Out-of-range anchor is clamped at construction (no panic / no copy). - let clamped = CardTextSelection::new(7, body, 999); - assert!(clamped.is_collapsed()); - } - - #[test] - fn command_palette_detects_pr_url_and_emits_peek_effect() { - let mut state = AppState::default(); - state.overlays.command_palette.query.set( - &state.store, - "https://github.com/foo/bar/pull/42".to_owned(), - ); - - let effects = state.rebuild_command_palette(); - - // A peek effect was fired for the parsed key. - assert!(effects.iter().any(|e| matches!( - e, - Effect::GitHub(GitHubEffect::PeekPullRequest { - owner, repo, number, .. - }) if owner == "foo" && repo == "bar" && *number == 42 - ))); - - // Palette has the synthesized PR entry as the top row with key intact. - let top = state - .overlays - .command_palette - .entries - .with(&state.store, |e| e.first().cloned()) - .expect("palette has at least one entry"); - assert!(matches!( - top.kind, - super::PaletteEntryKind::PullRequest((ref o, ref r, n)) - if o == "foo" && r == "bar" && n == 42 - )); - - // Cache entry is initialized to Loading. - let cached = state.github.pull_request.cache.with(&state.store, |c| { - c.get(&("foo".to_owned(), "bar".to_owned(), 42)).cloned() - }); - let cached = cached.expect("cache entry"); - assert!(matches!(cached.meta, super::PrPeekMeta::Loading)); - } - - #[test] - fn pr_peeked_event_transitions_cache_meta_to_ready() { - use crate::core::forge::github::PullRequestInfo; - use crate::events::AppEvent; - - let mut state = AppState::default(); - state - .overlays - .command_palette - .query - .set(&state.store, "https://github.com/foo/bar/pull/7".to_owned()); - let _ = state.rebuild_command_palette(); - - let info = PullRequestInfo { - title: "Fix thing".to_owned(), - state: "open".to_owned(), - author_login: "alice".to_owned(), - number: 7, - additions: 12, - deletions: 3, - changed_files: 1, - base_branch: "main".to_owned(), - head_branch: "fix".to_owned(), - base_sha: "a".to_owned(), - head_sha: "b".to_owned(), - base_repo_url: String::new(), - head_repo_url: String::new(), - }; - state.apply_event(AppEvent::from(GitHubEvent::PullRequestPeeked { - owner: "foo".to_owned(), - repo: "bar".to_owned(), - number: 7, - info: info.clone(), - })); - - let meta = state.github.pull_request.cache.with(&state.store, |c| { - c.get(&("foo".to_owned(), "bar".to_owned(), 7)) - .map(|e| e.meta.clone()) - }); - assert!(matches!(meta, Some(super::PrPeekMeta::Ready(_)))); - } - - // ----------------------------------------------------------------- - // Compare progress — end-to-end through the event lifecycle - // ----------------------------------------------------------------- - - use super::{ComparePhase, CompareProgress, LoadingSubject}; - use crate::events::{CompareFinished, RepositorySyncReason}; - - fn compare_ready_state() -> AppState { - let state = AppState::default(); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - state.compare.left_ref.set(&state.store, "v5.0".to_owned()); - state.compare.right_ref.set(&state.store, "v5.1".to_owned()); - state.compare.mode.set(&state.store, CompareMode::TwoDot); - state - } - - #[test] - fn kickoff_compare_seeds_progress_with_labels_and_started_at() { - let mut state = compare_ready_state(); - state.clock_ms = 1_000; - let _ = state.kickoff_compare(); - - let progress = state - .compare_progress - .with(&state.store, |p| p.clone()) - .expect("progress should be populated"); - match &progress.subject { - LoadingSubject::Compare { - left_label, - right_label, - } => { - assert_eq!(left_label, "v5.0"); - assert_eq!(right_label, "v5.1"); - } - other => panic!("expected Compare subject, got {other:?}"), - } - assert_eq!(progress.started_at_ms, 1_000); - assert_eq!(progress.phase, ComparePhase::OpeningRepo); - assert_eq!(progress.file_count_total, None); - assert_eq!( - state.workspace_mode.get(&state.store), - WorkspaceMode::Loading, - "viewport should flip to loading so the panel actually renders" - ); - } - - #[test] - fn compare_progress_update_applies_only_when_generation_matches() { - let mut state = compare_ready_state(); - let _ = state.kickoff_compare(); - let generation = state.workspace.compare_generation.get(&state.store); - - // Stale reporter — must be ignored. - state.apply_event(AppEvent::from(CompareEvent::CompareProgressUpdate { - generation: generation.wrapping_sub(1), - phase: ComparePhase::EnumeratingChanges, - })); - assert_eq!( - state - .compare_progress - .with(&state.store, |p| p.as_ref().unwrap().phase), - ComparePhase::OpeningRepo, - "stale generation must not advance the phase" - ); - - // Fresh reporter — applies. - state.apply_event(AppEvent::from(CompareEvent::CompareProgressUpdate { - generation, - phase: ComparePhase::EnumeratingChanges, - })); - assert_eq!( - state - .compare_progress - .with(&state.store, |p| p.as_ref().unwrap().phase), - ComparePhase::EnumeratingChanges, - ); - } - - #[test] - fn loading_files_phase_updates_counts_on_struct() { - let mut state = compare_ready_state(); - let _ = state.kickoff_compare(); - let generation = state.workspace.compare_generation.get(&state.store); - - state.apply_event(AppEvent::from(CompareEvent::CompareProgressUpdate { - generation, - phase: ComparePhase::LoadingFiles { - files_seen: 142, - files_total: 3_891, + pull_request: PullRequestState::default(), }, - })); - - let progress = state - .compare_progress - .with(&state.store, |p| p.clone()) - .expect("progress exists"); - assert_eq!(progress.files_loaded, 142); - assert_eq!(progress.file_count_total, Some(3_891)); - assert!(matches!(progress.phase, ComparePhase::LoadingFiles { .. })); - } - - #[test] - fn kickoff_with_prior_state_reveals_loading_immediately() { - let mut state = compare_ready_state(); - // Simulate a previously loaded compare (files present). - state.workspace.files.set( - &state.store, - vec![FileListEntry { - path: "old.rs".into(), - }], - ); - state.clock_ms = 10_000; - - let _ = state.kickoff_compare(); - let progress = state - .compare_progress - .with(&state.store, |p| p.clone()) - .expect("progress populated"); - assert_eq!(progress.started_at_ms, 10_000); - assert_eq!( - progress.reveal_at_ms, 10_000, - "compare loading should be visible immediately" - ); - assert_ne!( - state.workspace_mode.get(&state.store), - WorkspaceMode::Loading - ); - // Prior files are preserved so fast compares don't cause a flash. - assert_eq!(state.workspace.files.with(&state.store, |f| f.len()), 1); - } - - #[test] - fn open_repository_seeds_repo_subject_progress() { - let mut state = AppState::default(); - state.clock_ms = 500; - - let effects = state.open_repository(PathBuf::from("/tmp/linux")); - - let progress = state - .compare_progress - .with(&state.store, |p| p.clone()) - .expect("progress seeded for repo open"); - match &progress.subject { - LoadingSubject::RepoOpen { name } => { - assert_eq!(name, "linux"); - } - other => panic!("expected RepoOpen subject, got {other:?}"), - } - assert_eq!(progress.phase, ComparePhase::OpeningRepo); - assert_eq!( - progress.reveal_at_ms, - 500 + super::COMPARE_REVEAL_DELAY_MS, - "every repo open delays reveal so sub-threshold opens don't flash" - ); - // Reporter generation is threaded through the SyncRepository effect - // so the worker's phase events stamp the matching generation. - let sync_gen = effects.iter().find_map(|eff| match eff { - Effect::Repository(RepositoryEffect::SyncRepository { - reporter_generation, - .. - }) => *reporter_generation, - _ => None, - }); - assert_eq!(sync_gen, Some(progress.generation)); - } - - #[test] - fn open_repository_with_prior_diff_delays_reveal() { - let mut state = AppState::default(); - state.workspace.files.set( - &state.store, - vec![FileListEntry { - path: "old.rs".into(), - }], ); - state.clock_ms = 10_000; - - let _ = state.open_repository(PathBuf::from("/tmp/other")); - - let progress = state - .compare_progress - .with(&state.store, |p| p.clone()) - .expect("progress seeded"); - assert_eq!( - progress.reveal_at_ms, 10_500, - "re-open with prior diff delays reveal by COMPARE_REVEAL_DELAY_MS" - ); - } - - #[test] - fn open_repository_resets_stale_compare_refs_before_snapshot() { - let mut state = AppState::default(); - state.compare.left_ref.set(&state.store, "@-".to_owned()); - state.compare.right_ref.set(&state.store, "@".to_owned()); - state.compare.mode.set(&state.store, CompareMode::TwoDot); - - let path = PathBuf::from("/tmp/git-repo"); - let effects = state.open_repository(path.clone()); - - assert_eq!(state.compare.left_ref.get(&state.store), ""); - assert_eq!(state.compare.right_ref.get(&state.store), ""); - assert_eq!(state.compare.mode.get(&state.store), CompareMode::default()); - let saved = effects.iter().find_map(|effect| match effect { - Effect::Settings(SettingsEffect::SaveSettings(settings)) => { - settings.last_compare.as_ref() - } - _ => None, - }); - let saved = saved.expect("open_repository should persist settings"); - assert_eq!(saved.repo_path.as_ref(), Some(&path)); - assert_eq!(saved.left_ref, ""); - assert_eq!(saved.right_ref, ""); - } + let mut state = Self { + workspace_mode, + compare_progress, + app_view, + settings_section, + keymap_capture, + keymaps_scroll_top_px, + keymaps_viewport_height_px, + keymaps_content_height_px, + compare, + repository, + workspace, + file_list, + overlays, + focus, + text_edit, + editor, + github, + settings, + startup: StartupState { + keyring_enabled: startup.keyring_enabled, + github_token_store: startup.github_token_store, + auto_compare_pending: auto_compare_pending && !bootstrap_compare_started, + bootstrap_compare_started, + pending_pr_url: startup.args.open_pr.clone(), + preferred_file_index: startup.args.file_index, + preferred_file_path: startup.args.file_path.clone(), + }, + last_error, + toasts, + syntax_pack_installs, + update, + context_menu: ContextMenuState::default(), + text_focused, + animation: crate::ui::animation::AnimationState::default(), + commit_editor: Editor::default(), + review_comment_editor: Editor::default(), + steering_prompt_editor: Editor::default(), + text_compare: TextCompareState::default(), + ai_openai_key: String::new(), + ai_anthropic_key: String::new(), + ai_openai_editing: false, + ai_anthropic_editing: false, + ai_generation_id: 0, + ai_generation_active: false, + ai_generation_error: None, + sidebar_visible, + debug, + store, + clock_ms: 0, + next_toast_id: 1, + frecency: crate::core::frecency::open_default_store(), + theme_names: Vec::new(), + theme_variants: Vec::new(), + theme_preview_original, + github_access_token: None, + viewport_document_cache: None, + virtual_diff_document: VirtualDiffDocument::default(), + virtual_scroll: VirtualScrollModel::default(), + file_working_set: FileWorkingSet::default(), + syntax_requests: SyntaxRequestTracker::default(), + last_virtual_scroll_top_px: None, + }; + let seed_prompt = if state.settings.ai_steering_prompt.trim().is_empty() { + crate::ai::DEFAULT_STEERING_PROMPT + } else { + state.settings.ai_steering_prompt.as_str() + }; + state.steering_prompt_editor.set_text(seed_prompt); + state.sync_settings_snapshot(); - #[test] - fn git_snapshot_after_jj_refs_uses_git_defaults() { - let mut state = AppState::default(); - state.compare.left_ref.set(&state.store, "@-".to_owned()); - state.compare.right_ref.set(&state.store, "@".to_owned()); - state.compare.mode.set(&state.store, CompareMode::TwoDot); + let mut effects = Vec::new(); + if let Some(path) = repo_path { + state + .repository + .status + .set(&state.store, AsyncStatus::Loading); - let path = PathBuf::from("/tmp/git-repo"); - let _ = state.open_repository(path.clone()); - state.apply_event(AppEvent::from(RepositoryEvent::RepositorySnapshotReady( - crate::events::RepositorySnapshot::from_vcs_snapshot( - crate::core::vcs::model::VcsSnapshot { - location: RepoLocation { - kind: VcsKind::GIT, - profile: crate::core::vcs::model::VCS_PROFILE_GIT, - workspace_root: path, - store_root: None, + // Bootstrap: seed the loading panel so a slow cold-boot open + // shows staged progress. Reveal is gated by the same 500ms + // threshold as user-initiated opens — if the whole bootstrap + // open completes within the threshold the panel never appears + // and the user lands straight in the ready UI. + let boot_gen = state + .workspace + .compare_generation + .get(&state.store) + .saturating_add(1); + state + .workspace + .compare_generation + .set(&state.store, boot_gen); + effects.push(state.invalidate_syntax_epoch_effect()); + let repo_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("repository") + .to_owned(); + state.compare_progress.set( + &state.store, + Some(Arc::new(CompareProgress { + generation: boot_gen, + phase: ComparePhase::OpeningRepo, + subject: if bootstrap_compare_started { + LoadingSubject::Compare { + left_label: state.vcs_ui_profile().compare_ref_display_label( + &state.compare.left_ref.get(&state.store), + ), + right_label: state.vcs_ui_profile().compare_ref_display_label( + &state.compare.right_ref.get(&state.store), + ), + } + } else { + LoadingSubject::RepoOpen { name: repo_name } }, - reason: RepositorySyncReason::Open, - change_kind: None, - capabilities: RepoCapabilities::git(), - refs: Vec::new(), - changes: Vec::new(), - operation_log: Vec::new(), - file_changes: Vec::new(), - }, - ), - ))); - - let (left, right, mode) = - crate::ui::vcs::profile(state.repository.location.get(&state.store).as_ref()) - .default_compare(); - assert_eq!(state.compare.left_ref.get(&state.store), left); - assert_eq!(state.compare.right_ref.get(&state.store), right); - assert_eq!(state.compare.mode.get(&state.store), mode); - } - - #[test] - fn large_compare_stats_stream_offscreen_background_rows_after_visible_rows() { - let state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - state.file_list.row_height.set(&state.store, 36.0); - state.file_list.gap.set(&state.store, 4.0); - state.file_list.viewport_height.set(&state.store, 80.0); + started_at_ms: 0, + reveal_at_ms: COMPARE_REVEAL_DELAY_MS, + file_count_total: None, + files_loaded: 0, + })), + ); - let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) - .map(|index| { - let path = format!("src/file-{index}.rs"); - let mut summary = CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ); - if index < 128 { - summary.stats_deferred = false; + effects.push( + RepositoryEffect::SyncRepository { + path: path.clone(), + reason: RepositorySyncReason::Open, + reporter_generation: (!bootstrap_compare_started).then_some(boot_gen), } - summary - }) - .collect(); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - - let effect = state - .next_compare_stats_hydration_effect() - .expect("huge compares should keep streaming offscreen stats"); - - match effect { - Effect::Compare(CompareEffect::LoadFileStats(task)) => { - assert_eq!(task.request.priority, CompareWorkPriority::Warmup); - assert_eq!(task.request.files.first().map(|item| item.index), Some(128)); + .into(), + ); + effects.push(RepositoryEffect::WatchRepository { path: Some(path) }.into()); + if bootstrap_compare_started { + effects.push( + CompareEffect::Run(Task { + generation: boot_gen, + request: CompareRequest { + repo_path: state.compare.repo_path.get(&state.store).unwrap(), + request: vcs_compare_request( + state.compare.mode.get(&state.store), + state.compare.left_ref.get(&state.store), + state.compare.right_ref.get(&state.store), + state.compare.layout.get(&state.store), + state.compare.renderer.get(&state.store), + ), + github_token: startup.github_token.clone(), + }, + }) + .into(), + ); } - other => panic!("expected LoadFileStats effect, got {other:?}"), } - } - - #[test] - fn large_compare_still_loads_exact_total_stats() { - let mut state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - - let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) - .map(|index| { - let path = format!("src/file-{index}.rs"); - CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ) - }) - .collect(); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - - let effect = state - .start_compare_total_stats_if_needed() - .expect("large deferred compares should request one bounded total-stats job"); - - match effect { - Effect::Compare(CompareEffect::LoadStats(task)) => { - assert_eq!(task.request.priority, CompareWorkPriority::TotalStats); - assert!( - state - .workspace - .compare_total_stats_loading - .get(&state.store) - ); + if let Some(token) = startup.github_token.clone() { + state.github_access_token = Some(token.clone()); + state.github.auth.token_present.set(&state.store, true); + if startup.github_token_store.is_enabled() { + effects.push(GitHubEffect::SaveGitHubToken(token).into()); } - other => panic!("expected LoadStats effect, got {other:?}"), + } else if startup.github_token_store.is_enabled() { + effects.push(GitHubEffect::LoadGitHubToken.into()); } - } - - #[test] - fn filtered_compare_stats_hydrates_filtered_visible_raw_indices() { - let state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.file_list.row_height.set(&state.store, 36.0); - state.file_list.gap.set(&state.store, 4.0); - state.file_list.viewport_height.set(&state.store, 80.0); - state - .file_list - .filter - .set(&state.store, "target-only".to_owned()); - - let summaries = (0..50) - .map(|index| { - let path = if index == 40 { - "src/target-only.rs".to_owned() - } else { - format!("src/file-{index}.rs") - }; - CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ) - }) - .collect(); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - - let items = state.visible_compare_stats_hydration_items(); - - assert_eq!(items.len(), 1); - assert_eq!(items[0].index, 40); - } - - #[test] - fn tree_compare_stats_hydrates_visible_tree_file_indices() { - let state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state - .file_list - .mode - .set(&state.store, SidebarMode::TreeView); - state - .file_list - .expanded_folders - .set(&state.store, ["a".to_owned()].into_iter().collect()); - state.file_list.row_height.set(&state.store, 36.0); - state.file_list.gap.set(&state.store, 4.0); - state.file_list.viewport_height.set(&state.store, 80.0); - - let summaries = (0..50) - .map(|index| { - let path = if index == 40 { - "a/target-visible.rs".to_owned() - } else { - format!("z/file-{index}.rs") - }; - CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ) - }) - .collect(); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - - let items = state.visible_compare_stats_hydration_items(); - - assert_eq!(items.len(), 1); - assert_eq!(items[0].index, 40); - } - - #[test] - fn loaded_compare_stats_update_sidebar_meta() { - let mut state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state - .file_list - .mode - .set(&state.store, SidebarMode::TreeView); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: vec![CompareFileSummary::from_paths_status( - None, - Some("arch/arm64/boot/dts/mediatek/mt8183-kukui-jacuzzi-kenzo.dts"), - carbon::FileStatus::Added, - true, - )], - ..CompareOutput::default() - }), - ); - let effects = state.handle_compare_file_stats_ready(CompareFileStatsReady { - generation: state.workspace.compare_generation.get(&state.store), - stats: vec![CompareFileStat { - index: 0, - path: "arch/arm64/boot/dts/mediatek/mt8183-kukui-jacuzzi-kenzo.dts".to_owned(), - additions: 13, - deletions: 0, - }], - request_complete: false, - }); - - assert!(effects.is_empty()); - let meta = state.file_list_entry_meta(0); - assert_eq!(meta.additions, 13); - assert_eq!(meta.deletions, 0); - assert!( - !state.workspace.compare_output.with(&state.store, |output| { - output - .as_ref() - .and_then(|output| output.file_summaries.first()) - .is_none_or(|summary| summary.stats_deferred) - }), - "loaded stats must clear the deferred marker used by sidebar rows", - ); - } - - #[test] - fn expanding_tree_folder_starts_visible_stats_hydration() { - let mut state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - state - .file_list - .mode - .set(&state.store, SidebarMode::TreeView); - state.file_list.row_height.set(&state.store, 36.0); - state.file_list.gap.set(&state.store, 4.0); - state.file_list.viewport_height.set(&state.store, 80.0); - - let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) - .map(|index| { - let path = if index == 40 { - "a/target-visible.rs".to_owned() - } else { - format!("z/file-{index}.rs") - }; - CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ) - }) - .collect(); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - state.set_compare_stats_hydration(super::CompareStatsHydrationState::Running); - - let effects = - state.apply_action(crate::actions::FileListAction::ToggleFolder("a".to_owned())); - - assert!(effects.iter().any(|effect| { - matches!( - effect, - Effect::Compare(CompareEffect::LoadFileStats(task)) - if task.request.priority == CompareWorkPriority::VisibleSidebarStats - && task.request.files.iter().any(|item| item.index == 40) - ) - })); - } - - #[test] - fn compare_stats_ready_drains_history_when_hydration_has_no_visible_work() { - let mut state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.file_list.tab.set(&state.store, SidebarTab::Commits); - state.workspace.compare_history_pending.set( - &state.store, - Some(crate::effects::CompareHistoryRequest { - repo_path: PathBuf::from("/repo"), - left_ref: "v5.0".to_owned(), - right_ref: "v5.1".to_owned(), - }), - ); - let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) - .map(|index| { - let path = format!("src/file-{index}.rs"); - CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ) - }) - .collect(); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - - let effects = state.handle_compare_stats_ready(CompareStatsReady { - generation: state.workspace.compare_generation.get(&state.store), - additions: 0, - deletions: 0, - }); - - assert!( - effects - .iter() - .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadHistory(_)))) - ); - assert!( - state - .workspace - .compare_history_pending - .get(&state.store) - .is_none() - ); - } - - #[test] - fn compare_file_stats_failure_does_not_retry_same_chunk() { - let mut state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.set_compare_stats_hydration(super::CompareStatsHydrationState::Running); - state.workspace.compare_history_pending.set( - &state.store, - Some(crate::effects::CompareHistoryRequest { - repo_path: PathBuf::from("/repo"), - left_ref: "v5.0".to_owned(), - right_ref: "v5.1".to_owned(), - }), - ); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: vec![CompareFileSummary::from_paths_status( - Some("src/file.rs"), - Some("src/file.rs"), - carbon::FileStatus::Modified, - true, - )], - ..CompareOutput::default() - }), - ); - - let effects = state.apply_event(AppEvent::from(CompareEvent::CompareFileStatsFailed { - generation: state.workspace.compare_generation.get(&state.store), - message: "backend failed".to_owned(), - })); - - assert!( - !effects - .iter() - .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadFileStats(_)))), - "failed stats hydration should not immediately retry the same deferred chunk" - ); - assert!( - effects - .iter() - .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadHistory(_)))) - ); - assert!( - state - .workspace - .compare_history_pending - .get(&state.store) - .is_none() - ); - assert!(state.compare_stats_hydration_failed()); - } - - #[test] - fn repository_snapshot_ready_clears_repo_open_progress() { - let mut state = AppState::default(); - let path = PathBuf::from("/tmp/linux"); - let _ = state.open_repository(path.clone()); - assert!(state.compare_progress.with(&state.store, |p| p.is_some())); - - state.apply_event(AppEvent::from(RepositoryEvent::RepositorySnapshotReady( - crate::events::RepositorySnapshot::from_vcs_snapshot( - crate::core::vcs::model::VcsSnapshot { - location: RepoLocation { - kind: VcsKind::GIT, - profile: crate::core::vcs::model::VCS_PROFILE_GIT, - workspace_root: path, - store_root: None, - }, - reason: RepositorySyncReason::Open, - change_kind: None, - capabilities: RepoCapabilities::git(), - refs: Vec::new(), - changes: Vec::new(), - operation_log: Vec::new(), - file_changes: Vec::new(), - }, - ), - ))); - - assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), - "snapshot-ready must tear down the repo-open progress panel" - ); - } - - #[test] - fn kickoff_without_prior_state_reveals_loading_immediately() { - let mut state = compare_ready_state(); - state.clock_ms = 5_000; - - let _ = state.kickoff_compare(); - let progress = state - .compare_progress - .with(&state.store, |p| p.clone()) - .expect("progress populated"); - assert_eq!(progress.started_at_ms, 5_000); - assert_eq!( - progress.reveal_at_ms, 5_000, - "compare loading should be visible immediately" - ); - // With no prior state to preserve, workspace_mode flips to Loading - // up front so the editor/ready-hint stops rendering in the background. - assert_eq!( - state.workspace_mode.get(&state.store), - WorkspaceMode::Loading - ); - } - - #[test] - fn cancel_compare_bumps_generation_and_drops_stale_result() { - let mut state = compare_ready_state(); - let _ = state.kickoff_compare(); - let generation = state.workspace.compare_generation.get(&state.store); - - let _ = state.cancel_compare(); - - assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), - "progress should be cleared after cancel" - ); - let new_gen = state.workspace.compare_generation.get(&state.store); - assert!(new_gen > generation, "generation should be bumped"); - assert_eq!( - state.workspace_mode.get(&state.store), - WorkspaceMode::Empty, - "fresh-state cancel should revert the Loading flip" - ); - - // A stale CompareFinished arriving after cancel must be silently dropped. - state.apply_event(AppEvent::from(CompareEvent::CompareFinished( - CompareFinished { - generation, - request: vcs_compare_request( - CompareMode::TwoDot, - "v5.0".to_owned(), - "v5.1".to_owned(), - LayoutMode::Unified, - RendererKind::Builtin, - ), - resolved_left: "deadbeef".to_owned(), - resolved_right: "cafefeed".to_owned(), - output: CompareOutput::default(), - range_commits: Vec::new(), - }, - ))); - assert_eq!( - state.workspace_mode.get(&state.store), - WorkspaceMode::Empty, - "stale finished result must not promote workspace to Ready", - ); - assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), - "stale finished result must not re-seed progress", - ); - } - - #[test] - fn cancel_compare_preserves_previous_diff_on_recompare() { - let mut state = compare_ready_state(); - // Prior state: an existing file in the workspace. - state.workspace.files.set( - &state.store, - vec![FileListEntry { - path: "old.rs".into(), - }], - ); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - - let _ = state.kickoff_compare(); - let _ = state.cancel_compare(); - - assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), - "progress cleared on cancel" - ); - assert_eq!( - state.workspace_mode.get(&state.store), - WorkspaceMode::Ready, - "previous workspace state is preserved on cancel — no blanking" - ); - assert_eq!( - state.workspace.files.with(&state.store, |f| f.len()), - 1, - "prior file list must not be wiped by cancel" - ); - } - - #[test] - fn compare_finished_advances_phase_and_records_file_count() { - let mut state = compare_ready_state(); - let _ = state.kickoff_compare(); - let generation = state.workspace.compare_generation.get(&state.store); - - // Simulate a successful compare with 3 files. - let files = ["a.rs", "b.rs", "c.rs"]; - let output = CompareOutput { - carbon: carbon::DiffDocument { - files: files - .iter() - .enumerate() - .map(|(index, path)| carbon_summary_for_path(index, path)) - .collect(), - }, - ..CompareOutput::default() - }; - - state.apply_event(AppEvent::from(CompareEvent::CompareFinished( - CompareFinished { - generation, - request: vcs_compare_request( - CompareMode::TwoDot, - "v5.0".to_owned(), - "v5.1".to_owned(), - LayoutMode::Unified, - RendererKind::Builtin, - ), - resolved_left: "deadbeef".to_owned(), - resolved_right: "cafefeed".to_owned(), - output, - range_commits: Vec::new(), - }, - ))); + // Show the cached user + avatar optimistically while the token loads. + if let Some(user) = state.settings.github_user.as_ref() + && let Some(url) = avatar_url_sized(&user.avatar_url, 128) + { + state.github.auth.avatar_fetching.set(&state.store, true); + effects.push(GitHubEffect::FetchAvatar { url }.into()); + } - // Small files load synchronously, so progress is already cleared by the - // time handle_compare_finished returns. We at least know the workspace - // is Ready and the compare file view is populated from CompareOutput. - assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Ready,); - assert_eq!(state.workspace_file_count(), 3); + effects.push(SyntaxEffect::InstallCommonSyntaxPacks.into()); + if startup.keyring_enabled { + effects.push(AiEffect::LoadAiKeys.into()); + } + if state.update_polling_enabled() { + effects.push(UpdateEffect::CheckForUpdates { silent: true }.into()); + } + (state, effects) } - #[test] - fn compare_failed_clears_progress_and_marks_workspace_empty() { - let mut state = compare_ready_state(); - let _ = state.kickoff_compare(); - let generation = state.workspace.compare_generation.get(&state.store); - - state.apply_event(AppEvent::from(CompareEvent::CompareFailed { - generation, - message: "boom".to_owned(), - })); - - assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Empty,); - assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), - "progress panel must tear down on compare failure", - ); + pub fn apply_action>(&mut self, action: A) -> Vec { + let action = action.into(); + match action { + Action::App(action) => app::reduce_action(self, action), + Action::Workspace(action) => workspace::reduce_action(self, action), + Action::TextCompare(action) => text_compare::reduce_action(self, action), + Action::Compare(action) => compare::reduce_action(self, action), + Action::Repository(action) => repository::reduce_action(self, action), + Action::FileList(action) => file_list::reduce_action(self, action), + Action::Overlay(action) => overlay::reduce_action(self, action), + Action::Editor(action) => editor::reduce_action(self, action), + Action::TextEdit(action) => text_edit::reduce_action(self, action), + Action::Settings(action) => settings::reduce_action(self, action), + Action::GitHub(action) => github::reduce_action(self, action), + Action::Update(action) => update::reduce_action(self, action), + Action::Window(_) => Vec::new(), + Action::Syntax(action) => syntax::reduce_action(self, action), + Action::Ai(action) => ai::reduce_action(self, action), + Action::Noop => Vec::new(), + } } - #[test] - fn compare_progress_label_does_not_panic_for_all_phases() { - // Non-empty labels matter for the title-bar fallback. Cheap to - // check exhaustively. - let phases = [ - ComparePhase::OpeningRepo, - ComparePhase::ResolvingRefs, - ComparePhase::EnumeratingChanges, - ComparePhase::LoadingFiles { - files_seen: 142, - files_total: 3_891, - }, - ComparePhase::FetchingHistory, - ComparePhase::PopulatingList, - ComparePhase::RenderingFirstFile, - ]; - for phase in phases { - let label = phase.label(); - assert!(!label.is_empty()); + pub fn apply_event(&mut self, event: AppEvent) -> Vec { + match event { + AppEvent::Ui(event) => app::reduce_event(self, event), + AppEvent::Repository(event) => repository::reduce_event(self, event), + AppEvent::Compare(event) => compare::reduce_event(self, event), + AppEvent::GitHub(event) => github::reduce_event(self, event), + AppEvent::Settings(event) => settings::reduce_event(self, event), + AppEvent::Update(event) => update::reduce_event(self, event), + AppEvent::Syntax(event) => syntax::reduce_event(self, event), + AppEvent::Ai(event) => ai::reduce_event(self, event), } - // LoadingFiles label should interpolate counts. - assert!( - ComparePhase::LoadingFiles { - files_seen: 142, - files_total: 3_891, - } - .label() - .contains("142"), - "file counts must appear in the label" - ); - - let _ = CompareProgress { - generation: 0, - phase: ComparePhase::default(), - subject: LoadingSubject::Compare { - left_label: String::new(), - right_label: String::new(), - }, - started_at_ms: 0, - reveal_at_ms: 0, - file_count_total: None, - files_loaded: 0, - }; } } + +#[cfg(test)] +mod tests; diff --git a/src/ui/state/overlay.rs b/src/ui/state/overlay.rs index ae74972f..60f19b86 100644 --- a/src/ui/state/overlay.rs +++ b/src/ui/state/overlay.rs @@ -70,3 +70,2987 @@ impl AppState { } } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OverlayListState { + pub scroll_top_px: u32, + pub viewport_height_px: u32, + pub row_height_px: u32, + pub gap_px: u32, +} + +impl Default for OverlayListState { + fn default() -> Self { + Self { + scroll_top_px: 0, + viewport_height_px: 0, + row_height_px: 36, + gap_px: 0, + } + } +} + +impl OverlayListState { + pub fn stride_px(&self) -> u32 { + self.row_height_px + self.gap_px + } + + pub fn total_content_height_px(&self, entry_count: usize) -> u32 { + if entry_count == 0 { + return 0; + } + self.stride_px() + .saturating_mul(entry_count as u32) + .saturating_sub(self.gap_px) + } + + pub fn viewport_for_max_rows(&self, max_rows: usize, entry_count: usize) -> u32 { + let visible = entry_count.min(max_rows); + if visible == 0 { + return 0; + } + self.stride_px() + .saturating_mul(visible as u32) + .saturating_sub(self.gap_px) + } + + pub fn max_scroll_top_px(&self, entry_count: usize) -> u32 { + self.total_content_height_px(entry_count) + .saturating_sub(self.viewport_height_px) + } + + pub fn clamp_scroll(&mut self, entry_count: usize) { + self.scroll_top_px = self.scroll_top_px.min(self.max_scroll_top_px(entry_count)); + } + + pub fn scroll_px(&mut self, delta_px: i32, entry_count: usize) { + self.scroll_top_px = apply_scroll_delta_px( + self.scroll_top_px, + delta_px, + self.max_scroll_top_px(entry_count), + ); + } + + pub fn reveal_index(&mut self, index: usize, entry_count: usize) { + let stride = self.stride_px().max(1); + let item_top = stride.saturating_mul(index as u32); + let item_bottom = item_top.saturating_add(self.row_height_px); + let viewport_bottom = self.scroll_top_px.saturating_add(self.viewport_height_px); + + if item_top < self.scroll_top_px { + self.scroll_top_px = item_top; + } else if item_bottom > viewport_bottom { + self.scroll_top_px = item_bottom.saturating_sub(self.viewport_height_px); + } + + self.clamp_scroll(entry_count); + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PickerKind { + #[default] + Repository, + LeftRef, + RightRef, + Theme, + UiFont, + MonoFont, +} + +pub trait PickerItem { + fn label(&self) -> &str; + fn detail(&self) -> Option<&str>; + fn label_style(&self) -> PickerLabelStyle { + PickerLabelStyle::Default + } + fn highlight_ranges(&self) -> &[(usize, usize)] { + &[] + } + fn icon_svg(&self) -> Option<&'static str> { + None + } + fn is_section_header(&self) -> bool { + false + } + fn rhs(&self) -> Option<&str> { + None + } + fn is_disabled(&self) -> bool { + false + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PickerLabelStyle { + #[default] + Default, + JjChangeId { + prefix_len: usize, + working_copy: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerEntry { + pub label: String, + pub detail: String, + pub value: String, + pub highlights: Vec<(usize, usize)>, + pub label_style: PickerLabelStyle, + pub icon: Option<&'static str>, + pub section_header: bool, +} + +impl PickerItem for PickerEntry { + fn label(&self) -> &str { + &self.label + } + fn detail(&self) -> Option<&str> { + Some(&self.detail) + } + fn label_style(&self) -> PickerLabelStyle { + self.label_style + } + fn highlight_ranges(&self) -> &[(usize, usize)] { + &self.highlights + } + fn icon_svg(&self) -> Option<&'static str> { + self.icon + } + fn is_section_header(&self) -> bool { + self.section_header + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct PickerState { + pub kind: PickerKind, + pub query: String, + pub entries: Vec, + pub selected_index: usize, + pub hovered_index: Option, + pub list: OverlayListState, + pub browse_path: Option, + pub ref_resolve_generation: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaletteCommand { + OpenRepoPicker, + NewTextCompare, + OpenGitHubAuthModal, + OpenGitHubAccountMenu, + SignOutGitHub, + FocusFileList, + FocusViewport, + ShowWorkingTree, + RefreshRepository, + OpenBaseRefPicker, + OpenHeadRefPicker, + SwapRefs, + StartCompare, + OpenCompareMenu, + ShowKeyboardShortcuts, + RestoreCompare, + ToggleSidebar, + ToggleFileTree, + ExpandAllFolders, + CollapseAllFolders, + ToggleWrap, + ToggleContinuousScroll, + SetSettingsSection(SettingsSection), + SetThemeMode(ThemeMode), + SetUiScalePct(u16), + SetWrapColumn(u32), + SetWheelScrollLines(u8), + ToggleAutoUpdate, + ToggleThemeMode, + ChangeTheme, + SetLayout(LayoutMode), + SetRenderer(RendererKind), + SetTheme(String), + ExpandAllContext, + ClearLineSelection, + GenerateCommitMessage, + OpenReviewComment, + OpenPullRequestInGitHub, + CheckForUpdates, + InstallUpdate, + RestartToUpdate, + RunOperation(VcsOperation), + FetchOrigin, + FetchAllRemotes, + PushCurrentBranch, + PublishOptions, + PushCurrentBranchForceWithLease, + PullCurrentBranch, + OpenSettings, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaletteEntryKind { + Command(PaletteCommand), + File(usize), + Commit(String), + Repo(PathBuf), + Ref(CompareField, String), + PullRequest(PrKey), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaletteEntry { + pub label: String, + pub detail: String, + pub kind: PaletteEntryKind, + pub highlights: Vec<(usize, usize)>, + /// Extra right-aligned summary (e.g. "+12 āˆ’3 Ā· open"). + pub rhs: Option, + /// Disables the entry when set; `detail` usually explains why. + pub disabled: bool, +} + +pub(super) fn palette_command_available( + command: &PaletteCommand, + capabilities: Option, +) -> bool { + match command { + PaletteCommand::FetchOrigin + | PaletteCommand::FetchAllRemotes + | PaletteCommand::PushCurrentBranch + | PaletteCommand::PublishOptions => { + capabilities.is_some_and(|capabilities| capabilities.remotes) + } + PaletteCommand::PushCurrentBranchForceWithLease => { + capabilities.is_some_and(|capabilities| capabilities.remotes && capabilities.branches) + } + PaletteCommand::PullCurrentBranch => { + capabilities.is_some_and(|capabilities| capabilities.pull_fast_forward) + } + _ => true, + } +} + +pub(super) fn vcs_operation_available_for_location( + operation: &VcsOperation, + location: Option<&RepoLocation>, +) -> bool { + match operation { + VcsOperation::Jj(_) => location.is_some_and(|location| location.profile == VCS_PROFILE_JJ), + VcsOperation::JjRebaseCurrentChangeOnto { .. } => { + location.is_some_and(|location| location.profile == VCS_PROFILE_JJ) + } + VcsOperation::JjEditRevision { .. } => { + location.is_some_and(|location| location.profile == VCS_PROFILE_JJ) + } + VcsOperation::JjRestoreOperation { .. } => { + location.is_some_and(|location| location.profile == VCS_PROFILE_JJ) + } + } +} + +pub(super) fn operation_log_entry_detail(entry: &VcsOperationLogEntry) -> String { + match ( + entry.description.is_empty(), + entry.user.is_empty(), + entry.time.is_empty(), + ) { + (false, false, false) => format!("{} - {} - {}", entry.description, entry.user, entry.time), + (false, false, true) => format!("{} - {}", entry.description, entry.user), + (false, true, false) => format!("{} - {}", entry.description, entry.time), + (false, true, true) => entry.description.clone(), + (true, false, false) => format!("{} - {}", entry.user, entry.time), + (true, false, true) => entry.user.clone(), + (true, true, false) => entry.time.clone(), + (true, true, true) => "jj operation log entry".to_owned(), + } +} + +impl PickerItem for PaletteEntry { + fn label(&self) -> &str { + &self.label + } + fn detail(&self) -> Option<&str> { + Some(&self.detail) + } + fn highlight_ranges(&self) -> &[(usize, usize)] { + &self.highlights + } + fn rhs(&self) -> Option<&str> { + self.rhs.as_deref() + } + fn is_disabled(&self) -> bool { + self.disabled + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct CommandPaletteState { + pub query: String, + pub entries: Vec, + pub selected_index: usize, + pub list: OverlayListState, +} + +/// Ephemeral ref-picker overlay state. `active_field` tracks which chip the +/// search input currently drives; `original_*` snapshots the refs at the moment +/// the picker opened so we can revert cleanly on cancel/backdrop. +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct RefPickerState { + pub active_field: CompareField, + pub original_left: String, + pub original_right: String, +} +/// Overlays live as normal elements in the main tree with a z-index above the +/// viewport. Occluding the viewport is the overlay's own responsibility: modal +/// surfaces (pickers, auth, shortcuts) render a full-screen `overlay_scrim` +/// backdrop; anchored dropdowns (AccountMenu, CompareMenu) render a transparent +/// backdrop and let the viewport show through. Do NOT gate viewport rendering +/// on overlay presence — let z-index handle layering. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverlaySurface { + RepoPicker, + RefPicker, + CommandPalette, + Confirmation, + GitHubAuthModal, + KeyboardShortcuts, + ThemePicker, + FontPicker, + CompareMenu, + AccountMenu, + PublishMenu, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OverlayEntry { + pub surface: OverlaySurface, + pub focus_return: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct ConfirmationState { + pub title: String, + pub message: String, + pub confirm_label: String, + pub action: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct OverlayStackState { + pub stack: Vec, + #[store(flatten)] + pub picker: PickerState, + #[store(flatten)] + pub command_palette: CommandPaletteState, + #[store(flatten)] + pub ref_picker: RefPickerState, + #[store(flatten)] + pub confirmation: ConfirmationState, +} + +impl AppState { + pub fn overlays_top(&self) -> Option { + self.overlays + .stack + .with(&self.store, |stack| stack.last().map(|e| e.surface)) + } + + pub fn overlays_active_name(&self) -> Option<&'static str> { + self.overlays_top().map(overlay_name) + } + + /// `(pending, failed)` draft counts for the active pull request's review + /// session, for the submit bar. Zeroes when no PR/session is active. + pub fn active_review_draft_metrics(&self) -> (usize, usize) { + let Some(key) = self.active_pull_request_key() else { + return (0, 0); + }; + self.github + .pull_request + .review_sessions + .with(&self.store, |sessions| { + sessions + .get(&key) + .map(|session| { + let metrics = session.metrics(); + (metrics.pending_drafts, metrics.failed_drafts) + }) + .unwrap_or((0, 0)) + }) + } + + pub fn reset_picker(&mut self) { + let d = PickerState::default(); + self.overlays.picker.kind.set(&self.store, d.kind); + self.overlays.picker.query.set(&self.store, d.query); + self.overlays.picker.entries.set(&self.store, d.entries); + self.overlays + .picker + .selected_index + .set(&self.store, d.selected_index); + self.overlays + .picker + .hovered_index + .set(&self.store, d.hovered_index); + self.overlays.picker.list.set(&self.store, d.list); + self.overlays + .picker + .browse_path + .set(&self.store, d.browse_path); + self.overlays + .picker + .ref_resolve_generation + .set(&self.store, d.ref_resolve_generation); + } + + pub fn reset_command_palette(&mut self) { + let d = CommandPaletteState::default(); + self.overlays + .command_palette + .query + .set(&self.store, d.query); + self.overlays + .command_palette + .entries + .set(&self.store, d.entries); + self.overlays + .command_palette + .selected_index + .set(&self.store, d.selected_index); + self.overlays.command_palette.list.set(&self.store, d.list); + } + + pub fn reset_confirmation(&mut self) { + let d = ConfirmationState::default(); + self.overlays.confirmation.title.set(&self.store, d.title); + self.overlays + .confirmation + .message + .set(&self.store, d.message); + self.overlays + .confirmation + .confirm_label + .set(&self.store, d.confirm_label); + self.overlays.confirmation.action.set(&self.store, d.action); + } + + pub fn clear_overlays(&mut self) { + // The bottom-most entry recorded the focus from before any overlay + // opened; restore it so focus never dangles on a dismissed surface. + let mut focus_return: Option> = None; + self.overlays.stack.update(&self.store, |stack| { + focus_return = stack.first().map(|entry| entry.focus_return); + stack.clear(); + }); + self.reset_picker(); + self.reset_command_palette(); + self.reset_confirmation(); + if let Some(target) = focus_return { + self.set_focus(target); + } + } +} +pub(super) fn overlay_name(surface: OverlaySurface) -> &'static str { + match surface { + OverlaySurface::RepoPicker => "repo-picker", + OverlaySurface::RefPicker => "ref-picker", + OverlaySurface::CommandPalette => "command-palette", + OverlaySurface::Confirmation => "confirmation", + OverlaySurface::GitHubAuthModal => "github-auth-modal", + OverlaySurface::AccountMenu => "account-menu", + OverlaySurface::KeyboardShortcuts => "keyboard-shortcuts", + OverlaySurface::ThemePicker => "theme-picker", + OverlaySurface::FontPicker => "font-picker", + OverlaySurface::CompareMenu => "compare-menu", + OverlaySurface::PublishMenu => "publish-menu", + } +} + +pub(super) fn font_picker_entry( + entry: &FontFamilyEntry, + selected_family: &str, + highlights: Vec<(usize, usize)>, +) -> PickerEntry { + let source = entry.source.label(); + let detail = if entry.family == selected_family { + format!("Selected - {source}") + } else { + source.to_owned() + }; + PickerEntry { + label: entry.label.clone(), + detail, + value: entry.family.clone(), + highlights, + label_style: PickerLabelStyle::Default, + icon: Some(if entry.monospaced { + lucide::TERMINAL + } else { + lucide::FILE + }), + section_header: false, + } +} + +pub(super) fn highlight_ranges_from_match_indices( + text: &str, + indices_rev: &[usize], +) -> Vec<(usize, usize)> { + let len = text.len(); + let mut indices: Vec = indices_rev + .iter() + .copied() + .filter(|&idx| idx < len && text.is_char_boundary(idx)) + .collect(); + indices.sort_unstable(); + + let mut ranges = Vec::new(); + for index in indices { + let mut end = index + 1; + while end < len && !text.is_char_boundary(end) { + end += 1; + } + if let Some((_, last_end)) = ranges.last_mut() { + if index <= *last_end { + *last_end = (*last_end).max(end); + continue; + } + } + ranges.push((index, end)); + } + ranges +} + +pub(super) fn highlight_ranges_for_prefix_match( + text: &str, + indices_rev: &[usize], +) -> Vec<(usize, usize)> { + let prefix_indices: Vec = indices_rev + .iter() + .copied() + .filter(|&idx| idx < text.len()) + .collect(); + highlight_ranges_from_match_indices(text, &prefix_indices) +} + +pub(super) fn highlight_ranges_for_visible_match( + query: &str, + visible_text: &str, + search_indices_rev: &[usize], + config: &neo_frizbee::Config, +) -> Vec<(usize, usize)> { + if query.is_empty() { + return Vec::new(); + } + + let visible_only = [visible_text]; + if let Some(m) = neo_frizbee::match_list_indices(query, &visible_only, config) + .into_iter() + .next() + { + return highlight_ranges_from_match_indices(visible_text, &m.indices); + } + + highlight_ranges_for_prefix_match(visible_text, search_indices_rev) +} + +pub(super) fn query_looks_like_path(query: &str) -> bool { + query.starts_with('/') + || query.starts_with("~/") + || query.starts_with("./") + || (query.len() >= 2 && query.as_bytes()[1] == b':') +} + +pub(super) fn path_looks_like_repository(path: &Path) -> bool { + path.join(".git").exists() || path.join(".jj").exists() +} + +pub(super) fn normalize_repository_open_path(path: PathBuf) -> PathBuf { + crate::core::vcs::discovery::discover_repository(&path) + .ok() + .flatten() + .map(|location| location.workspace_root) + .unwrap_or(path) +} + +pub(super) fn expand_tilde(path: &str) -> String { + if path.starts_with("~/") || path == "~" { + if let Some(home) = dirs::home_dir() { + return format!("{}{}", home.display(), &path[1..]); + } + } + path.to_owned() +} + +pub(super) fn split_browse_query(expanded: &str) -> (String, &str) { + if let Some(pos) = expanded.rfind('/') { + let dir = if pos == 0 { + "/".to_owned() + } else { + expanded[..pos].to_owned() + }; + let filter = &expanded[pos + 1..]; + (dir, filter) + } else if expanded.len() >= 2 && expanded.as_bytes()[1] == b':' { + if let Some(pos) = expanded.rfind('\\') { + let dir = expanded[..pos].to_owned(); + let filter = &expanded[pos + 1..]; + (dir, filter) + } else { + (expanded.to_owned(), "") + } + } else { + (expanded.to_owned(), "") + } +} + +impl AppState { + pub fn active_overlay_name(&self) -> Option<&'static str> { + self.overlays_active_name() + } + + pub(super) fn open_repo_picker(&mut self) { + let scale = self.ui_scale_factor(); + self.overlays + .picker + .kind + .set(&self.store, PickerKind::Repository); + self.overlays.picker.list.update(&self.store, |l| { + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + l.scroll_top_px = 0; + }); + self.overlays.picker.browse_path.set(&self.store, None); + self.overlays.picker.selected_index.set(&self.store, 0); + + let has_recents = crate::core::frecency::recent_repo_paths(self.frecency.as_ref(), 1) + .first() + .is_some(); + + if has_recents { + self.overlays.picker.query.set(&self.store, String::new()); + } else { + let home = dirs::home_dir() + .map(|p| format!("{}/", p.display())) + .unwrap_or_else(|| "/".to_owned()); + let home_len = home.len(); + self.overlays.picker.query.set(&self.store, home); + self.reset_text_edit(home_len); + } + + self.rebuild_repo_picker(); + self.push_overlay(OverlaySurface::RepoPicker, Some(FocusTarget::PickerInput)); + } + + pub(super) fn open_theme_picker(&mut self) { + let scale = self.ui_scale_factor(); + self.theme_preview_original + .set(&self.store, Some(self.settings.theme_name.clone())); + self.overlays + .picker + .kind + .set(&self.store, PickerKind::Theme); + self.overlays.picker.query.set(&self.store, String::new()); + self.overlays.picker.list.update(&self.store, |l| { + l.scroll_top_px = 0; + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + }); + let entries = self.build_theme_entries_grouped(); + let selected = entries.iter().position(|e| !e.section_header).unwrap_or(0); + self.overlays.picker.entries.set(&self.store, entries); + self.overlays + .picker + .selected_index + .set(&self.store, selected); + self.push_overlay(OverlaySurface::ThemePicker, Some(FocusTarget::PickerInput)); + } + + pub(super) fn build_theme_entries_grouped(&self) -> Vec { + use crate::core::themes::ThemeVariant; + + let original = self + .theme_preview_original + .get(&self.store) + .unwrap_or_else(|| self.settings.theme_name.clone()); + let make_entry = |name: &String| PickerEntry { + label: name.clone(), + detail: if *name == original { + "\u{2713}".to_owned() + } else { + String::new() + }, + value: name.clone(), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + icon: None, + section_header: false, + }; + let make_header = |label: &str| PickerEntry { + label: label.to_owned(), + detail: String::new(), + value: String::new(), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + icon: None, + section_header: true, + }; + + let variant_of = |index: usize| { + self.theme_variants + .get(index) + .copied() + .unwrap_or(ThemeVariant::Dark) + }; + let mut ordered: Vec = Vec::with_capacity(self.theme_names.len()); + for group in [ThemeVariant::Dual, ThemeVariant::Dark, ThemeVariant::Light] { + ordered.extend((0..self.theme_names.len()).filter(|&index| variant_of(index) == group)); + } + + build_sectioned_rows( + &ordered, + |index| Some(variant_of(index)), + |variant| { + make_header(match variant { + ThemeVariant::Dual => "Dark & Light", + ThemeVariant::Dark => "Dark", + ThemeVariant::Light => "Light", + }) + }, + |index| self.theme_names.get(index).map(make_entry), + ) + } + + pub(super) fn rebuild_theme_picker(&mut self) { + let query = self + .overlays + .picker + .query + .with(&self.store, |q| q.trim().to_owned()); + let original = self + .theme_preview_original + .get(&self.store) + .unwrap_or_else(|| self.settings.theme_name.clone()); + let (entries, selected) = if query.is_empty() { + let entries = self.build_theme_entries_grouped(); + let selected = entries.iter().position(|e| !e.section_header).unwrap_or(0); + (entries, selected) + } else { + let haystack: Vec<&str> = self.theme_names.iter().map(|s| s.as_str()).collect(); + let config = neo_frizbee::Config { + max_typos: Some(2), + sort: false, + ..Default::default() + }; + let mut matches = neo_frizbee::match_list_indices(&query, &haystack, &config); + matches.sort_by(|a, b| b.score.cmp(&a.score)); + let entries: Vec = matches + .iter() + .map(|m| { + let name = &self.theme_names[m.index as usize]; + PickerEntry { + label: name.clone(), + detail: if *name == *original { + "\u{2713}".to_owned() + } else { + String::new() + }, + value: name.clone(), + highlights: highlight_ranges_from_match_indices(name, &m.indices), + label_style: PickerLabelStyle::Default, + icon: None, + section_header: false, + } + }) + .collect(); + (entries, 0) + }; + if let Some(entry) = entries.get(selected) { + if !entry.section_header { + self.settings.theme_name = entry.value.clone(); + } + } + let entry_count = entries.len(); + self.overlays.picker.entries.set(&self.store, entries); + self.overlays + .picker + .selected_index + .set(&self.store, selected); + self.overlays.picker.list.update(&self.store, |l| { + l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); + l.scroll_top_px = 0; + }); + } + + pub(super) fn open_font_picker(&mut self, role: FontRole) { + let scale = self.ui_scale_factor(); + self.overlays.picker.kind.set( + &self.store, + match role { + FontRole::Ui => PickerKind::UiFont, + FontRole::Mono => PickerKind::MonoFont, + }, + ); + self.overlays.picker.query.set(&self.store, String::new()); + self.overlays.picker.list.update(&self.store, |l| { + l.scroll_top_px = 0; + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + }); + self.rebuild_font_picker(); + self.reset_text_edit(0); + self.push_overlay(OverlaySurface::FontPicker, Some(FocusTarget::PickerInput)); + } + + pub(super) fn rebuild_font_picker(&mut self) { + let Some(role) = self.font_picker_role() else { + return; + }; + let query = self + .overlays + .picker + .query + .with(&self.store, |q| q.trim().to_owned()); + let selected_family = self.selected_font_family(role); + let font_entries = crate::fonts::font_family_entries(role); + let entries: Vec = if query.is_empty() { + font_entries + .iter() + .map(|entry| font_picker_entry(entry, &selected_family, Vec::new())) + .collect() + } else { + let search_texts: Vec = font_entries + .iter() + .map(|entry| { + if entry.label == entry.family { + entry.label.clone() + } else { + format!("{} {}", entry.label, entry.family) + } + }) + .collect(); + let haystack: Vec<&str> = search_texts.iter().map(|s| s.as_str()).collect(); + let config = neo_frizbee::Config { + max_typos: Some(2), + sort: false, + ..Default::default() + }; + let mut matches = neo_frizbee::match_list_indices(&query, &haystack, &config); + matches.sort_by(|a, b| b.score.cmp(&a.score).then(a.index.cmp(&b.index))); + matches + .into_iter() + .map(|m| { + let entry = &font_entries[m.index as usize]; + let highlights = highlight_ranges_for_visible_match( + &query, + &entry.label, + &m.indices, + &config, + ); + font_picker_entry(entry, &selected_family, highlights) + }) + .collect() + }; + + let selected = entries + .iter() + .position(|entry| entry.value == selected_family) + .unwrap_or(0); + let entry_count = entries.len(); + self.overlays.picker.entries.set(&self.store, entries); + self.overlays + .picker + .selected_index + .set(&self.store, selected); + self.overlays.picker.list.update(&self.store, |l| { + l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); + l.scroll_top_px = 0; + }); + } + + pub(super) fn font_picker_role(&self) -> Option { + match self.overlays.picker.kind.get(&self.store) { + PickerKind::UiFont => Some(FontRole::Ui), + PickerKind::MonoFont => Some(FontRole::Mono), + _ => None, + } + } + + pub(super) fn selected_font_family(&self, role: FontRole) -> String { + match role { + FontRole::Ui => { + crate::fonts::normalize_font_selection(role, &self.settings.fonts.ui_family) + } + FontRole::Mono => { + crate::fonts::normalize_font_selection(role, &self.settings.fonts.mono_family) + } + } + } + + pub(super) fn open_ref_picker(&mut self, field: CompareField) -> Vec { + let scale = self.ui_scale_factor(); + let already_open = self.overlays_top() == Some(OverlaySurface::RefPicker); + // Snapshot originals only on first open; switching chips shouldn't + // refresh the revert baseline. + if !already_open { + let left = self.compare.left_ref.get(&self.store); + let right = self.compare.right_ref.get(&self.store); + self.overlays + .ref_picker + .original_left + .set(&self.store, left); + self.overlays + .ref_picker + .original_right + .set(&self.store, right); + } + self.overlays + .ref_picker + .active_field + .set(&self.store, field); + self.overlays.picker.kind.set( + &self.store, + match field { + CompareField::Left => PickerKind::LeftRef, + CompareField::Right => PickerKind::RightRef, + }, + ); + self.overlays.picker.selected_index.set(&self.store, 0); + self.overlays.picker.list.update(&self.store, |l| { + l.scroll_top_px = 0; + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + }); + let effects = self.rebuild_ref_picker(field); + self.push_overlay(OverlaySurface::RefPicker, Some(FocusTarget::PickerInput)); + // Move cursor to end of the active field's current value so typing + // continues from where the label ends. + let len = match field { + CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), + CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), + }; + self.reset_text_edit(len); + effects + } + + pub(super) fn open_command_palette(&mut self) -> Vec { + let scale = self.ui_scale_factor(); + self.overlays.command_palette.list.update(&self.store, |l| { + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + l.scroll_top_px = 0; + }); + let effects = self.rebuild_command_palette(); + self.push_overlay( + OverlaySurface::CommandPalette, + Some(FocusTarget::CommandPaletteInput), + ); + effects + } + + pub(super) fn push_overlay( + &mut self, + surface: OverlaySurface, + focus_target: Option, + ) { + if self.overlays_top() == Some(surface) { + self.set_focus(focus_target); + return; + } + let focus_return = self.focus.get(&self.store); + self.overlays.stack.update(&self.store, |stack| { + stack.push(OverlayEntry { + surface, + focus_return, + }); + }); + self.set_focus(focus_target); + } + + pub(super) fn pop_overlay(&mut self) { + let mut popped: Option = None; + self.overlays.stack.update(&self.store, |stack| { + popped = stack.pop(); + }); + let Some(entry) = popped else { + return; + }; + match entry.surface { + OverlaySurface::ThemePicker => { + let original = self.theme_preview_original.get(&self.store); + self.theme_preview_original.set(&self.store, None); + if let Some(original) = original { + self.settings.theme_name = original; + } + self.reset_picker(); + } + OverlaySurface::RepoPicker | OverlaySurface::RefPicker | OverlaySurface::FontPicker => { + self.reset_picker(); + } + OverlaySurface::CommandPalette => { + self.reset_command_palette(); + } + OverlaySurface::Confirmation => { + self.reset_confirmation(); + } + _ => {} + } + self.set_focus(entry.focus_return); + } + + pub(super) fn open_confirmation( + &mut self, + title: impl Into, + message: impl Into, + confirm_label: impl Into, + action: Action, + ) { + self.overlays + .confirmation + .title + .set(&self.store, title.into()); + self.overlays + .confirmation + .message + .set(&self.store, message.into()); + self.overlays + .confirmation + .confirm_label + .set(&self.store, confirm_label.into()); + self.overlays + .confirmation + .action + .set(&self.store, Some(action)); + // Let push_overlay snapshot the current focus as the restore target + // before it moves focus off the field; closing the confirmation then + // returns focus (and IME state) to wherever the user was. + self.push_overlay(OverlaySurface::Confirmation, None); + } + + pub(super) fn move_overlay_selection(&mut self, delta: i32) { + match self.overlays_top() { + Some(OverlaySurface::ThemePicker) => { + let current = self.overlays.picker.selected_index.get(&self.store); + let (idx, len, value) = self.overlays.picker.entries.with(&self.store, |entries| { + let len = entries.len(); + let idx = step_selection(current, delta, len, |i| entries[i].section_header); + let value = idx.and_then(|idx| { + entries + .get(idx) + .filter(|e| !e.section_header) + .map(|e| e.value.clone()) + }); + (idx, len, value) + }); + let Some(idx) = idx else { + return; + }; + self.overlays.picker.selected_index.set(&self.store, idx); + self.overlays + .picker + .list + .update(&self.store, |l| l.reveal_index(idx, len)); + if let Some(value) = value { + tracing::debug!(theme = %value, "theme preview"); + self.settings.theme_name = value; + } + } + Some( + OverlaySurface::RepoPicker | OverlaySurface::RefPicker | OverlaySurface::FontPicker, + ) => { + let current = self.overlays.picker.selected_index.get(&self.store); + let (idx, len) = self.overlays.picker.entries.with(&self.store, |entries| { + let len = entries.len(); + let idx = step_selection(current, delta, len, |i| entries[i].section_header); + (idx, len) + }); + let Some(idx) = idx else { + return; + }; + self.overlays.picker.selected_index.set(&self.store, idx); + self.overlays + .picker + .list + .update(&self.store, |l| l.reveal_index(idx, len)); + } + Some(OverlaySurface::CommandPalette) => { + let entry_count = self + .overlays + .command_palette + .entries + .with(&self.store, |e| e.len()); + let current = self + .overlays + .command_palette + .selected_index + .get(&self.store); + // Palette entries have no section headers; an empty palette + // still pins the selection to row zero. + let idx = step_selection(current, delta, entry_count, |_| false).unwrap_or(0); + self.overlays + .command_palette + .selected_index + .set(&self.store, idx); + self.overlays + .command_palette + .list + .update(&self.store, |l| l.reveal_index(idx, entry_count)); + } + _ => {} + } + } + + pub(super) fn select_overlay_entry(&mut self, index: usize) { + match self.overlays_top() { + Some(OverlaySurface::ThemePicker) => { + let (clamped, len, value) = + self.overlays.picker.entries.with(&self.store, |entries| { + let len = entries.len(); + let clamped = index.min(len.saturating_sub(1)); + let value = entries.get(clamped).map(|e| e.value.clone()); + (clamped, len, value) + }); + self.overlays + .picker + .selected_index + .set(&self.store, clamped); + if let Some(value) = value { + self.settings.theme_name = value; + } + self.overlays + .picker + .list + .update(&self.store, |l| l.reveal_index(clamped, len)); + } + Some( + OverlaySurface::RepoPicker | OverlaySurface::RefPicker | OverlaySurface::FontPicker, + ) => { + let (clamped, len, is_header) = + self.overlays.picker.entries.with(&self.store, |entries| { + let len = entries.len(); + let clamped = index.min(len.saturating_sub(1)); + let is_header = entries.get(clamped).map_or(false, |e| e.section_header); + (clamped, len, is_header) + }); + if is_header { + return; + } + self.overlays + .picker + .selected_index + .set(&self.store, clamped); + self.overlays + .picker + .list + .update(&self.store, |l| l.reveal_index(clamped, len)); + } + Some(OverlaySurface::CommandPalette) => { + let len = self + .overlays + .command_palette + .entries + .with(&self.store, |e| e.len()); + let clamped = index.min(len.saturating_sub(1)); + self.overlays + .command_palette + .selected_index + .set(&self.store, clamped); + self.overlays + .command_palette + .list + .update(&self.store, |l| l.reveal_index(clamped, len)); + } + _ => {} + } + } + + pub(super) fn confirm_overlay_selection(&mut self) -> Vec { + match self.overlays_top() { + Some(OverlaySurface::ThemePicker) => { + let selected = self.overlays.picker.selected_index.get(&self.store); + let value = self.overlays.picker.entries.with(&self.store, |entries| { + entries.get(selected).map(|e| e.value.clone()) + }); + if let Some(value) = value { + tracing::info!(theme = %value, "theme confirmed"); + self.settings.theme_name = value; + } + self.theme_preview_original.set(&self.store, None); + self.pop_overlay(); + self.persist_settings_effect() + } + Some(OverlaySurface::FontPicker) => self.confirm_font_picker(), + Some(OverlaySurface::RepoPicker) => self.confirm_repo_picker(), + Some(OverlaySurface::RefPicker) => { + let field = self.overlays.ref_picker.active_field.get(&self.store); + self.confirm_ref_picker(field) + } + Some(OverlaySurface::CommandPalette) => self.confirm_command_palette(), + Some(OverlaySurface::Confirmation) => { + let action = self.overlays.confirmation.action.get(&self.store); + self.pop_overlay(); + if let Some(action) = action { + self.apply_action(action) + } else { + Vec::new() + } + } + Some(OverlaySurface::GitHubAuthModal) => { + if self + .github + .auth + .device_flow + .with(&self.store, |opt| opt.is_some()) + { + self.apply_action(crate::actions::GitHubAction::OpenDeviceFlowBrowser) + } else { + self.apply_action(crate::actions::GitHubAction::StartGitHubDeviceFlow) + } + } + Some( + OverlaySurface::KeyboardShortcuts + | OverlaySurface::CompareMenu + | OverlaySurface::AccountMenu + | OverlaySurface::PublishMenu, + ) => Vec::new(), + None => Vec::new(), + } + } + + pub(super) fn confirm_font_picker(&mut self) -> Vec { + let Some(role) = self.font_picker_role() else { + return Vec::new(); + }; + let selected = self.overlays.picker.selected_index.get(&self.store); + let family = self.overlays.picker.entries.with(&self.store, |entries| { + entries.get(selected).map(|entry| entry.value.clone()) + }); + let Some(family) = family else { + return Vec::new(); + }; + let family = crate::fonts::normalize_font_selection(role, &family); + let changed = match role { + FontRole::Ui => { + if self.settings.fonts.ui_family == family { + false + } else { + self.settings.fonts.ui_family = family; + true + } + } + FontRole::Mono => { + if self.settings.fonts.mono_family == family { + false + } else { + self.settings.fonts.mono_family = family; + true + } + } + }; + self.pop_overlay(); + if changed { + self.persist_settings_effect() + } else { + Vec::new() + } + } + + pub(super) fn confirm_repo_picker(&mut self) -> Vec { + let selected = self.overlays.picker.selected_index.get(&self.store); + let entry = self + .overlays + .picker + .entries + .with(&self.store, |entries| entries.get(selected).cloned()); + + let Some(entry) = entry else { + let query = self + .overlays + .picker + .query + .with(&self.store, |q| q.trim().to_owned()); + if !query.is_empty() { + let expanded = expand_tilde(&query); + let path = PathBuf::from(&expanded); + if path.is_dir() && path_looks_like_repository(&path) { + self.pop_overlay(); + return self.open_repository(path); + } + if path.is_dir() { + self.navigate_picker_to_dir(&path); + return Vec::new(); + } + } + return Vec::new(); + }; + + if entry.section_header { + return Vec::new(); + } + + if entry.value.starts_with("open:") { + let path = PathBuf::from(&entry.value[5..]); + self.pop_overlay(); + return self.open_repository(path); + } + + let path = PathBuf::from(&entry.value); + + let browsing = self + .overlays + .picker + .browse_path + .with(&self.store, |p| p.is_some()); + if browsing { + if entry.label == ".." { + self.navigate_picker_to_dir(&path); + return Vec::new(); + } + if path.is_dir() && path_looks_like_repository(&path) { + self.pop_overlay(); + return self.open_repository(path); + } + if path.is_dir() { + self.navigate_picker_to_dir(&path); + return Vec::new(); + } + return Vec::new(); + } + + self.pop_overlay(); + self.open_repository(path) + } + + pub(super) fn tab_complete_picker_dir(&mut self) { + if self.overlays.picker.kind.get(&self.store) != PickerKind::Repository { + return; + } + let selected = self.overlays.picker.selected_index.get(&self.store); + let entry = self + .overlays + .picker + .entries + .with(&self.store, |entries| entries.get(selected).cloned()); + let Some(entry) = entry else { return }; + if entry.section_header || entry.value.is_empty() { + return; + } + let path = PathBuf::from(&entry.value); + if path.is_dir() { + self.navigate_picker_to_dir(&path); + } + } + + pub(super) fn navigate_picker_to_dir(&mut self, path: &Path) { + let display = path.display().to_string(); + let new_query = if display.ends_with('/') || display.ends_with('\\') { + display + } else { + format!("{}/", display) + }; + let new_len = new_query.len(); + self.overlays.picker.query.set(&self.store, new_query); + self.reset_text_edit(new_len); + self.rebuild_repo_picker(); + } + + pub(super) fn confirm_ref_picker(&mut self, field: CompareField) -> Vec { + let selected = self.overlays.picker.selected_index.get(&self.store); + let entry = self + .overlays + .picker + .entries + .with(&self.store, |entries| entries.get(selected).cloned()) + .or_else(|| { + let query = match field { + CompareField::Left => self + .compare + .left_ref + .with(&self.store, |s| s.trim().to_owned()), + CompareField::Right => self + .compare + .right_ref + .with(&self.store, |s| s.trim().to_owned()), + }; + (!query.is_empty()).then(|| PickerEntry { + label: query.clone(), + detail: "Use typed ref".to_owned(), + value: query.clone(), + highlights: vec![(0, query.len())], + label_style: PickerLabelStyle::Default, + icon: None, + section_header: false, + }) + }); + let Some(entry) = entry else { + return Vec::new(); + }; + // Presets apply both refs at once; treat them as an explicit commit. + if let Some(rest) = entry.value.strip_prefix("@preset:") { + return self.apply_compare_preset(rest); + } + if let Some(ref store) = self.frecency { + store.record_access(&format!("ref:{}", entry.value)); + } + let _ = self.update_compare_field(field, entry.value); + // Auto-advance to the other chip if it's still at its snapshot — the + // user is likely changing both refs. Only commit when both chips have + // diverged from their snapshots (or neither, which is a no-op). + let other = match field { + CompareField::Left => CompareField::Right, + CompareField::Right => CompareField::Left, + }; + let other_current = match other { + CompareField::Left => self.compare.left_ref.get(&self.store), + CompareField::Right => self.compare.right_ref.get(&self.store), + }; + let other_original = match other { + CompareField::Left => self.overlays.ref_picker.original_left.get(&self.store), + CompareField::Right => self.overlays.ref_picker.original_right.get(&self.store), + }; + if other_current == other_original { + let scale = self.ui_scale_factor(); + self.overlays + .ref_picker + .active_field + .set(&self.store, other); + self.overlays.picker.kind.set( + &self.store, + match other { + CompareField::Left => PickerKind::LeftRef, + CompareField::Right => PickerKind::RightRef, + }, + ); + self.overlays.picker.selected_index.set(&self.store, 0); + self.overlays.picker.list.update(&self.store, |l| { + l.scroll_top_px = 0; + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + }); + let effects = self.rebuild_ref_picker(other); + let len = match other { + CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), + CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), + }; + self.reset_text_edit(len); + return effects; + } + // Both chips changed — commit. + self.commit_ref_picker() + } + + pub(super) fn commit_ref_picker(&mut self) -> Vec { + let original_left = self.overlays.ref_picker.original_left.get(&self.store); + let original_right = self.overlays.ref_picker.original_right.get(&self.store); + let current_left = self.compare.left_ref.get(&self.store); + let current_right = self.compare.right_ref.get(&self.store); + let changed = current_left != original_left || current_right != original_right; + self.pop_overlay(); + let mut effects = self.persist_settings_effect(); + if !changed { + return effects; + } + let has_repo = self.compare.repo_path.with(&self.store, |p| p.is_some()); + let not_loading = self.workspace.status.get(&self.store) != AsyncStatus::Loading; + let refs_valid = compare_refs_are_valid( + self.compare.mode.get(&self.store), + ¤t_left, + ¤t_right, + ); + if has_repo && not_loading && refs_valid { + effects.extend(self.kickoff_compare()); + } + effects + } + + pub(super) fn cancel_ref_picker(&mut self) -> Vec { + let left = self.overlays.ref_picker.original_left.get(&self.store); + let right = self.overlays.ref_picker.original_right.get(&self.store); + self.compare.left_ref.set(&self.store, left); + self.compare.right_ref.set(&self.store, right); + self.compare.resolved_left.set(&self.store, None); + self.compare.resolved_right.set(&self.store, None); + self.pop_overlay(); + Vec::new() + } + + pub(super) fn set_active_ref_field(&mut self, field: CompareField) -> Vec { + if self.overlays_top() != Some(OverlaySurface::RefPicker) { + return Vec::new(); + } + let scale = self.ui_scale_factor(); + self.overlays + .ref_picker + .active_field + .set(&self.store, field); + self.overlays.picker.kind.set( + &self.store, + match field { + CompareField::Left => PickerKind::LeftRef, + CompareField::Right => PickerKind::RightRef, + }, + ); + self.overlays.picker.selected_index.set(&self.store, 0); + self.overlays.picker.list.update(&self.store, |l| { + l.scroll_top_px = 0; + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + }); + let effects = self.rebuild_ref_picker(field); + let len = match field { + CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), + CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), + }; + self.reset_text_edit(len); + effects + } + + pub(super) fn swap_draft_refs(&mut self) -> Vec { + if self.overlays_top() != Some(OverlaySurface::RefPicker) { + return Vec::new(); + } + let left = self.compare.left_ref.get(&self.store); + let right = self.compare.right_ref.get(&self.store); + self.compare.left_ref.set(&self.store, right); + self.compare.right_ref.set(&self.store, left); + self.compare.resolved_left.set(&self.store, None); + self.compare.resolved_right.set(&self.store, None); + // Re-sync the search input to the active chip's new value. + let field = self.overlays.ref_picker.active_field.get(&self.store); + let len = match field { + CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), + CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), + }; + self.reset_text_edit(len); + self.rebuild_ref_picker(field) + } + + pub(super) fn apply_compare_preset(&mut self, preset: &str) -> Vec { + let parts: Vec<&str> = preset.splitn(3, ':').collect(); + if parts.len() != 3 { + return Vec::new(); + } + let (left, right, mode_str) = (parts[0], parts[1], parts[2]); + let mode = match mode_str { + "commit" => CompareMode::SingleCommit, + "diff" => CompareMode::TwoDot, + _ => CompareMode::ThreeDot, + }; + let profile = self.vcs_ui_profile(); + let mode = if profile.accepts_compare_mode(mode) { + mode + } else { + profile.compare_modes()[0].mode + }; + self.workspace.pre_drill_compare.set(&self.store, None); + self.compare.left_ref.set(&self.store, left.to_owned()); + self.compare.right_ref.set(&self.store, right.to_owned()); + self.compare.resolved_left.set(&self.store, None); + self.compare.resolved_right.set(&self.store, None); + self.compare.mode.set(&self.store, mode); + self.pop_overlay(); + let mut effects = self.persist_settings_effect(); + if self.compare.repo_path.with(&self.store, |p| p.is_some()) { + effects.extend(self.kickoff_compare()); + } + effects + } + + pub(super) fn confirm_command_palette(&mut self) -> Vec { + let selected = self + .overlays + .command_palette + .selected_index + .get(&self.store); + let Some(entry) = self + .overlays + .command_palette + .entries + .with(&self.store, |entries| entries.get(selected).cloned()) + else { + return Vec::new(); + }; + if entry.disabled { + return Vec::new(); + } + self.clear_overlays(); + match entry.kind { + PaletteEntryKind::Command(command) => { + match command { + PaletteCommand::OpenRepoPicker => { + self.open_repo_picker(); + Vec::new() + } + PaletteCommand::NewTextCompare => { + self.apply_action(crate::actions::WorkspaceAction::NewTextCompare) + } + PaletteCommand::OpenGitHubAuthModal => { + self.push_overlay( + OverlaySurface::GitHubAuthModal, + Some(FocusTarget::AuthPrimaryAction), + ); + Vec::new() + } + PaletteCommand::OpenGitHubAccountMenu => { + self.apply_action(crate::actions::GitHubAction::OpenAccountMenu) + } + PaletteCommand::SignOutGitHub => { + self.apply_action(crate::actions::GitHubAction::SignOutGitHub) + } + PaletteCommand::FocusFileList => { + self.set_focus(Some(FocusTarget::FileList)); + Vec::new() + } + PaletteCommand::FocusViewport => { + self.set_focus(Some(FocusTarget::Editor)); + Vec::new() + } + PaletteCommand::ShowWorkingTree => { + self.apply_action(crate::actions::WorkspaceAction::ShowWorkingTree) + } + PaletteCommand::RefreshRepository => { + self.apply_action(crate::actions::WorkspaceAction::RefreshRepository) + } + PaletteCommand::OpenBaseRefPicker => self.apply_action( + crate::actions::OverlayAction::OpenRefPicker(CompareField::Left), + ), + PaletteCommand::OpenHeadRefPicker => self.apply_action( + crate::actions::OverlayAction::OpenRefPicker(CompareField::Right), + ), + PaletteCommand::SwapRefs => { + self.apply_action(crate::actions::CompareAction::SwapRefs) + } + PaletteCommand::StartCompare => { + self.apply_action(crate::actions::CompareAction::StartCompare) + } + PaletteCommand::OpenCompareMenu => { + self.apply_action(crate::actions::CompareAction::OpenCompareMenu) + } + PaletteCommand::ShowKeyboardShortcuts => { + self.apply_action(crate::actions::SettingsAction::OpenKeymaps) + } + PaletteCommand::RestoreCompare => { + self.apply_action(crate::actions::CompareAction::ClearSidebarCommit) + } + PaletteCommand::ToggleSidebar => { + self.apply_action(crate::actions::FileListAction::ToggleSidebar) + } + PaletteCommand::ToggleFileTree => { + self.apply_action(crate::actions::FileListAction::ToggleSidebarMode) + } + PaletteCommand::ExpandAllFolders => { + self.apply_action(crate::actions::FileListAction::ExpandAllFolders) + } + PaletteCommand::CollapseAllFolders => { + self.apply_action(crate::actions::FileListAction::CollapseAllFolders) + } + PaletteCommand::ToggleWrap => { + self.apply_action(crate::actions::SettingsAction::ToggleWrap) + } + PaletteCommand::ToggleContinuousScroll => { + self.apply_action(crate::actions::SettingsAction::ToggleContinuousScroll) + } + PaletteCommand::SetSettingsSection(section) => self + .apply_action(crate::actions::SettingsAction::SetSettingsSection(section)), + PaletteCommand::SetThemeMode(mode) => { + self.apply_action(crate::actions::SettingsAction::SetThemeMode(mode)) + } + PaletteCommand::SetUiScalePct(pct) => { + self.apply_action(crate::actions::SettingsAction::SetUiScalePct(pct)) + } + PaletteCommand::SetWrapColumn(column) => { + self.apply_action(crate::actions::SettingsAction::SetWrapColumn(column)) + } + PaletteCommand::SetWheelScrollLines(lines) => self + .apply_action(crate::actions::SettingsAction::SetWheelScrollLines(lines)), + PaletteCommand::ToggleAutoUpdate => { + self.apply_action(crate::actions::SettingsAction::ToggleAutoUpdate) + } + PaletteCommand::ToggleThemeMode => { + self.apply_action(crate::actions::SettingsAction::ToggleThemeMode) + } + PaletteCommand::SetLayout(layout) => { + self.apply_action(crate::actions::CompareAction::SetLayoutMode(layout)) + } + PaletteCommand::SetRenderer(renderer) => { + self.apply_action(crate::actions::CompareAction::SetRenderer(renderer)) + } + PaletteCommand::ChangeTheme => { + self.apply_action(crate::actions::SettingsAction::OpenThemePicker) + } + PaletteCommand::SetTheme(name) => { + self.apply_action(crate::actions::SettingsAction::SetThemeName(name)) + } + PaletteCommand::ExpandAllContext => { + self.apply_action(crate::actions::EditorAction::ExpandAllContext) + } + PaletteCommand::ClearLineSelection => { + self.apply_action(crate::actions::RepositoryAction::ClearLineSelection) + } + PaletteCommand::GenerateCommitMessage => { + self.apply_action(crate::actions::AiAction::GenerateCommitMessage) + } + PaletteCommand::OpenReviewComment => { + self.apply_action(crate::actions::GitHubAction::OpenReviewCommentComposer) + } + PaletteCommand::OpenPullRequestInGitHub => { + self.apply_action(crate::actions::GitHubAction::OpenPullRequestInBrowser) + } + PaletteCommand::CheckForUpdates => { + self.apply_action(crate::actions::UpdateAction::CheckForUpdates) + } + PaletteCommand::InstallUpdate => { + self.apply_action(crate::actions::UpdateAction::InstallUpdate) + } + PaletteCommand::RestartToUpdate => { + self.apply_action(crate::actions::UpdateAction::RestartToUpdate) + } + PaletteCommand::RunOperation(operation) => { + self.confirm_or_run_vcs_operation(operation) + } + PaletteCommand::FetchOrigin => self.apply_action( + crate::actions::RepositoryAction::FetchRemote("origin".to_owned()), + ), + PaletteCommand::FetchAllRemotes => { + self.apply_action(crate::actions::RepositoryAction::FetchAllRemotes) + } + PaletteCommand::PushCurrentBranch => { + self.apply_action(crate::actions::RepositoryAction::PushCurrentBranch { + force_with_lease: false, + }) + } + PaletteCommand::PublishOptions => { + self.apply_action(crate::actions::RepositoryAction::OpenPublishMenu) + } + PaletteCommand::PushCurrentBranchForceWithLease => { + self.apply_action(crate::actions::RepositoryAction::PushCurrentBranch { + force_with_lease: true, + }) + } + PaletteCommand::PullCurrentBranch => { + self.apply_action(crate::actions::RepositoryAction::PullCurrentBranch) + } + PaletteCommand::OpenSettings => { + self.apply_action(crate::actions::SettingsAction::OpenSettings) + } + } + } + PaletteEntryKind::File(index) => self.select_file(index, true), + PaletteEntryKind::Commit(oid) => { + self.apply_action(crate::actions::CompareAction::SelectSidebarCommit(oid)) + } + PaletteEntryKind::Repo(path) => self.open_repository(path), + PaletteEntryKind::Ref(field, value) => { + let _ = self.update_compare_field(field, value); + self.persist_settings_effect() + } + PaletteEntryKind::PullRequest(key) => self.confirm_pr_entry(key), + } + } + + pub(super) fn confirm_pr_entry(&mut self, key: PrKey) -> Vec { + if self.compare.repo_path.with(&self.store, |p| p.is_none()) { + self.push_error("Open a repository before loading a pull request."); + return Vec::new(); + } + let diff_state = self + .github + .pull_request + .cache + .with(&self.store, |c| c.get(&key).map(|e| e.diff.clone())); + match diff_state { + Some(PrPeekDiff::Ready { + left_ref, + right_ref, + .. + }) => { + self.github + .pull_request + .pending_confirm + .set(&self.store, None); + self.github.pull_request.active.set(&self.store, Some(key)); + self.apply_pr_compare(left_ref, right_ref) + } + Some(PrPeekDiff::Loading) | Some(PrPeekDiff::Idle) => { + self.github + .pull_request + .pending_confirm + .set(&self.store, Some(key.clone())); + self.push_info(&format!("Preparing PR #{}\u{2026}", key.2)); + Vec::new() + } + Some(PrPeekDiff::Failed(message)) => { + self.push_error(&message); + Vec::new() + } + None => { + self.push_error("Pull request not available."); + Vec::new() + } + } + } + + pub(super) fn confirm_or_run_vcs_operation(&mut self, operation: VcsOperation) -> Vec { + let action = crate::actions::RepositoryAction::RunOperation(operation.clone()); + if let Some(message) = operation.confirmation_message() { + self.open_confirmation( + format!("Confirm {}", operation.label()), + message, + operation.label(), + action.into(), + ); + Vec::new() + } else { + self.apply_action(action) + } + } + + pub(super) fn rebuild_repo_picker(&mut self) { + let query = self.overlays.picker.query.with(&self.store, |q| q.clone()); + let trimmed = query.trim(); + + if query_looks_like_path(trimmed) { + self.rebuild_repo_picker_browse(trimmed); + } else { + self.overlays.picker.browse_path.set(&self.store, None); + self.rebuild_repo_picker_recent(trimmed); + } + + let current_selected = self.overlays.picker.selected_index.get(&self.store); + let (entry_count, new_selected) = + self.overlays.picker.entries.with(&self.store, |entries| { + let entry_count = entries.len(); + let new_selected = if entries.is_empty() { + 0 + } else { + let first_selectable = + entries.iter().position(|e| !e.section_header).unwrap_or(0); + current_selected + .max(first_selectable) + .min(entries.len().saturating_sub(1)) + }; + (entry_count, new_selected) + }); + self.overlays + .picker + .selected_index + .set(&self.store, new_selected); + self.overlays.picker.list.update(&self.store, |l| { + l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); + l.clamp_scroll(entry_count); + }); + } + + pub(super) fn rebuild_repo_picker_recent(&mut self, query: &str) { + let mut entries = Vec::new(); + + let all_repos = crate::core::frecency::recent_repo_paths(self.frecency.as_ref(), 20); + + let mut seen = HashSet::new(); + let mut unique_repos = Vec::new(); + for repo in &all_repos { + if seen.insert(repo.clone()) { + unique_repos.push(repo.clone()); + } + } + + if !unique_repos.is_empty() { + entries.push(PickerEntry { + label: "Recent".to_owned(), + detail: String::new(), + value: String::new(), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + icon: None, + section_header: true, + }); + } + + if query.is_empty() { + for repo in &unique_repos { + let display = repo.display().to_string(); + let is_repo = path_looks_like_repository(repo); + entries.push(PickerEntry { + label: repo + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&display) + .to_owned(), + detail: display.clone(), + value: repo.display().to_string(), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + icon: Some(if is_repo { + lucide::FOLDER_GIT + } else { + lucide::FOLDER + }), + section_header: false, + }); + } + } else { + let haystack: Vec = unique_repos + .iter() + .map(|r| r.display().to_string()) + .collect(); + let haystack_refs: Vec<&str> = haystack.iter().map(|s| s.as_str()).collect(); + let config = neo_frizbee::Config { + max_typos: Some(2), + sort: false, + ..Default::default() + }; + let mut matches = neo_frizbee::match_list_indices(query, &haystack_refs, &config); + matches.sort_by(|a, b| b.score.cmp(&a.score)); + if matches.is_empty() { + entries.clear(); + } + for m in matches { + let repo = &unique_repos[m.index as usize]; + let display = &haystack[m.index as usize]; + let label = repo + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(display) + .to_owned(); + let highlights = + highlight_ranges_for_visible_match(query, &label, &m.indices, &config); + let is_repo = path_looks_like_repository(repo); + entries.push(PickerEntry { + label, + detail: display.clone(), + value: repo.display().to_string(), + highlights, + label_style: PickerLabelStyle::Default, + icon: Some(if is_repo { + lucide::FOLDER_GIT + } else { + lucide::FOLDER + }), + section_header: false, + }); + } + } + self.overlays.picker.entries.set(&self.store, entries); + } + + pub(super) fn rebuild_repo_picker_browse(&mut self, query: &str) { + let expanded = expand_tilde(query); + let (dir_path, filter) = split_browse_query(&expanded); + + let dir = PathBuf::from(&dir_path); + if !dir.is_dir() { + self.overlays.picker.browse_path.set(&self.store, None); + self.overlays.picker.entries.set(&self.store, Vec::new()); + return; + } + + self.overlays + .picker + .browse_path + .set(&self.store, Some(dir.clone())); + + let mut entries = Vec::new(); + + if path_looks_like_repository(&dir) { + entries.push(PickerEntry { + label: "open this directory".to_owned(), + detail: String::new(), + value: format!("open:{}", dir.display()), + icon: Some(lucide::CORNER_UP_LEFT), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + section_header: false, + }); + } + + if dir.parent().is_some() { + entries.push(PickerEntry { + label: "..".to_owned(), + detail: String::new(), + value: dir + .parent() + .map(|p| p.display().to_string()) + .unwrap_or_default(), + icon: Some(lucide::CORNER_UP_LEFT), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + section_header: false, + }); + } + + let mut dirs: Vec<(String, PathBuf, bool)> = Vec::new(); + if let Ok(read) = std::fs::read_dir(&dir) { + for entry in read.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_str().unwrap_or_default().to_owned(); + if name.starts_with('.') { + continue; + } + let is_repo = path_looks_like_repository(&path); + dirs.push((name, path, is_repo)); + } + } + + dirs.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + + if filter.is_empty() { + for (name, path, is_repo) in &dirs { + entries.push(PickerEntry { + label: name.clone(), + detail: String::new(), + value: path.display().to_string(), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + icon: Some(if *is_repo { + lucide::FOLDER_GIT + } else { + lucide::FOLDER + }), + section_header: false, + }); + } + } else { + let haystack: Vec<&str> = dirs.iter().map(|(n, _, _)| n.as_str()).collect(); + let config = neo_frizbee::Config { + max_typos: Some(1), + sort: false, + ..Default::default() + }; + let mut matches = neo_frizbee::match_list_indices(filter, &haystack, &config); + matches.sort_by(|a, b| b.score.cmp(&a.score)); + for m in matches { + let (name, path, is_repo) = &dirs[m.index as usize]; + entries.push(PickerEntry { + label: name.clone(), + detail: String::new(), + value: path.display().to_string(), + highlights: highlight_ranges_from_match_indices(name, &m.indices), + label_style: PickerLabelStyle::Default, + icon: Some(if *is_repo { + lucide::FOLDER_GIT + } else { + lucide::FOLDER + }), + section_header: false, + }); + } + } + + self.overlays.picker.entries.set(&self.store, entries); + } + + pub(super) fn rebuild_ref_picker(&mut self, field: CompareField) -> Vec { + let query_owned = match field { + CompareField::Left => self + .compare + .left_ref + .with(&self.store, |s| s.trim().to_owned()), + CompareField::Right => self + .compare + .right_ref + .with(&self.store, |s| s.trim().to_owned()), + }; + let query = query_owned.as_str(); + let mut seen = HashSet::new(); + + struct RefCandidate { + search_text: String, + label: String, + detail: String, + value: String, + icon: Option<&'static str>, + default_highlights: Vec<(usize, usize)>, + label_style: PickerLabelStyle, + ordinal: usize, + } + + let mut all_candidates = Vec::new(); + let mut ordinal = 0_usize; + + let mut push = |search_text: String, + label: String, + detail: String, + value: String, + icon: Option<&'static str>, + default_highlights: Vec<(usize, usize)>, + label_style: PickerLabelStyle| { + if !seen.insert(value.clone()) { + return; + } + all_candidates.push(RefCandidate { + search_text, + label, + detail, + value, + icon, + default_highlights, + label_style, + ordinal, + }); + ordinal += 1; + }; + + let profile = self.vcs_ui_profile(); + let refs = self.repository.refs.get(&self.store); + let changes = self.repository.changes.get(&self.store); + + for reference in &refs { + let value = reference.name.clone(); + let (kind_label, icon) = profile.ref_kind_label_and_icon(reference.kind); + let mut detail = kind_label.to_owned(); + if reference.active { + detail.push_str(" \u{2022} current"); + } + let mut search_text = format!("{} {detail}", reference.name); + if reference.target.id != reference.name { + search_text.push(' '); + search_text.push_str(&reference.target.id); + } + if reference.kind == RefKind::WorkingCopy + && let Some((detail_suffix, search_suffix)) = + profile.working_copy_ref_suffix(&changes) + { + detail.push_str(&detail_suffix); + search_text.push_str(&search_suffix); + } + push( + search_text, + reference.name.clone(), + detail, + value, + icon, + Vec::new(), + PickerLabelStyle::Default, + ); + } + + for change in &changes { + let entry = profile.change_ref_entry(change); + let label_style = entry + .prefix_len + .map(|prefix_len| PickerLabelStyle::JjChangeId { + prefix_len, + working_copy: entry.working_copy, + }) + .unwrap_or_default(); + push( + entry.search_text, + entry.label, + entry.detail, + entry.value, + Some(lucide::HASH), + entry.default_highlights, + label_style, + ); + } + + let mut needs_resolve = false; + + if query.is_empty() { + let entries = all_candidates + .into_iter() + .take(10) + .map(|c| PickerEntry { + label: c.label, + detail: c.detail, + value: c.value, + highlights: c.default_highlights, + label_style: c.label_style, + icon: c.icon, + section_header: false, + }) + .collect(); + self.overlays.picker.entries.set(&self.store, entries); + } else { + let haystack: Vec<&str> = all_candidates + .iter() + .map(|c| c.search_text.as_str()) + .collect(); + let config = neo_frizbee::Config { + max_typos: Some(2), + sort: false, + ..Default::default() + }; + let matches = neo_frizbee::match_list_indices(query, &haystack, &config); + let mut scored: Vec<_> = matches + .into_iter() + .map(|m| { + let c = &all_candidates[m.index as usize]; + ( + m.score, + c.ordinal, + PickerEntry { + label: c.label.clone(), + detail: c.detail.clone(), + value: c.value.clone(), + highlights: highlight_ranges_for_visible_match( + query, &c.label, &m.indices, &config, + ), + label_style: c.label_style, + icon: c.icon, + section_header: false, + }, + ) + }) + .collect(); + scored.sort_by(|a, b| { + b.0.cmp(&a.0) + .then(a.1.cmp(&b.1)) + .then(a.2.label.cmp(&b.2.label)) + }); + let mut entries = Vec::new(); + entries.extend(scored.into_iter().map(|(_, _, entry)| entry).take(10)); + if !entries.iter().any(|entry| entry.value == query) { + entries.insert( + 0, + PickerEntry { + label: query.to_owned(), + detail: "Resolving\u{2026}".to_owned(), + value: query.to_owned(), + highlights: vec![(0, query.len())], + label_style: PickerLabelStyle::Default, + icon: None, + section_header: false, + }, + ); + needs_resolve = true; + } + self.overlays.picker.entries.set(&self.store, entries); + } + + self.overlays.picker.entries.update(&self.store, |e| { + e.truncate(10); + }); + let entry_count = self.overlays.picker.entries.with(&self.store, |e| e.len()); + let current_selected = self.overlays.picker.selected_index.get(&self.store); + self.overlays.picker.selected_index.set( + &self.store, + current_selected.min(entry_count.saturating_sub(1)), + ); + self.overlays.picker.list.update(&self.store, |l| { + l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); + l.clamp_scroll(entry_count); + }); + + if needs_resolve { + if let Some(repo_path) = self.compare.repo_path.get(&self.store) { + let new_gen = self.overlays.picker.ref_resolve_generation.get(&self.store) + 1; + self.overlays + .picker + .ref_resolve_generation + .set(&self.store, new_gen); + return vec![ + CompareEffect::ResolveRef { + repo_path, + query: query.to_owned(), + generation: new_gen, + } + .into(), + ]; + } + } + Vec::new() + } + + pub(super) fn rebuild_command_palette_if_open(&mut self) -> Vec { + if self.overlays_top() == Some(OverlaySurface::CommandPalette) { + self.rebuild_command_palette() + } else { + Vec::new() + } + } + + pub(super) fn rebuild_command_palette(&mut self) -> Vec { + let query_owned = self + .overlays + .command_palette + .query + .with(&self.store, |q| q.trim().to_owned()); + let query = query_owned.as_str(); + + let mut out_effects = Vec::new(); + let mut pr_entry: Option = None; + + if let Some(parsed) = crate::core::forge::github::parse_pr_url(query) { + let key: PrKey = (parsed.owner.clone(), parsed.repo.clone(), parsed.number); + let token = self.github_access_token.clone(); + let repo_path = self.compare.repo_path.get(&self.store); + let supports_github_prs = repo_path.is_some() + && self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.github_pull_requests) + }); + + let already_cached = self + .github + .pull_request + .cache + .with(&self.store, |c| c.contains_key(&key)); + if !already_cached { + self.github.pull_request.cache.update(&self.store, |c| { + c.insert( + key.clone(), + PrCacheEntry { + meta: PrPeekMeta::Loading, + diff: PrPeekDiff::Idle, + last_peek_ms: self.clock_ms, + }, + ); + }); + out_effects.push( + GitHubEffect::PeekPullRequest { + owner: parsed.owner.clone(), + repo: parsed.repo.clone(), + number: parsed.number, + github_token: token.clone(), + } + .into(), + ); + } + + // Speculative diff load — kick off as soon as we know the key, provided + // a repo is open. Dedupe via the cache's diff state. + if supports_github_prs && let Some(repo_path) = repo_path.clone() { + let diff_idle = self.github.pull_request.cache.with(&self.store, |c| { + matches!(c.get(&key).map(|e| &e.diff), Some(PrPeekDiff::Idle) | None) + }); + if diff_idle { + self.github.pull_request.cache.update(&self.store, |c| { + if let Some(e) = c.get_mut(&key) { + e.diff = PrPeekDiff::Loading; + } + }); + let url = format!( + "https://github.com/{}/{}/pull/{}", + parsed.owner, parsed.repo, parsed.number + ); + out_effects.push( + GitHubEffect::LoadPullRequest { + url, + repo_path, + github_token: token, + } + .into(), + ); + } + } + + pr_entry = Some(build_pr_palette_entry( + &self.github.pull_request.cache.get(&self.store), + &key, + supports_github_prs, + )); + } + + struct PaletteCandidate { + search_text: String, + label: String, + detail: String, + kind: PaletteEntryKind, + } + + let mut all_candidates = Vec::new(); + let repo_capabilities = self.repository.capabilities.get(&self.store); + + for (label, detail, command) in [ + ( + "Choose Repository".to_owned(), + "Open repository picker".to_owned(), + PaletteCommand::OpenRepoPicker, + ), + ( + "New Text Compare".to_owned(), + "Compare arbitrary pasted text".to_owned(), + PaletteCommand::NewTextCompare, + ), + ( + "GitHub Sign In".to_owned(), + "Start device flow".to_owned(), + PaletteCommand::OpenGitHubAuthModal, + ), + ( + "GitHub Account Menu".to_owned(), + "Open GitHub account actions".to_owned(), + PaletteCommand::OpenGitHubAccountMenu, + ), + ( + "GitHub Sign Out".to_owned(), + "Remove the saved GitHub session".to_owned(), + PaletteCommand::SignOutGitHub, + ), + ( + "Focus File List".to_owned(), + "Move keyboard focus to sidebar".to_owned(), + PaletteCommand::FocusFileList, + ), + ( + "Focus Diff Viewport".to_owned(), + "Move keyboard focus to editor".to_owned(), + PaletteCommand::FocusViewport, + ), + ( + "Show Working Tree".to_owned(), + "Return to the repository working tree view".to_owned(), + PaletteCommand::ShowWorkingTree, + ), + ( + "Refresh Repository".to_owned(), + "Refresh status or rerun the current compare".to_owned(), + PaletteCommand::RefreshRepository, + ), + ( + "Select Base Ref".to_owned(), + "Open the left-side ref picker".to_owned(), + PaletteCommand::OpenBaseRefPicker, + ), + ( + "Select Head Ref".to_owned(), + "Open the right-side ref picker".to_owned(), + PaletteCommand::OpenHeadRefPicker, + ), + ( + "Swap Compare Refs".to_owned(), + "Swap the current base and head refs".to_owned(), + PaletteCommand::SwapRefs, + ), + ( + "Run Compare".to_owned(), + "Compare the selected refs now".to_owned(), + PaletteCommand::StartCompare, + ), + ( + "Open Compare Menu".to_owned(), + "Change compare mode or preset".to_owned(), + PaletteCommand::OpenCompareMenu, + ), + ( + "Keymaps".to_owned(), + "Review and rebind keyboard shortcuts".to_owned(), + PaletteCommand::ShowKeyboardShortcuts, + ), + ( + "Toggle Sidebar".to_owned(), + "Show or hide the file sidebar".to_owned(), + PaletteCommand::ToggleSidebar, + ), + ( + "Toggle File Tree".to_owned(), + "Switch sidebar between tree and flat list".to_owned(), + PaletteCommand::ToggleFileTree, + ), + ( + "Expand All Folders".to_owned(), + "Expand every folder in the file tree".to_owned(), + PaletteCommand::ExpandAllFolders, + ), + ( + "Collapse All Folders".to_owned(), + "Collapse every folder in the file tree".to_owned(), + PaletteCommand::CollapseAllFolders, + ), + ( + "Toggle Wrap".to_owned(), + "Enable or disable line wrapping".to_owned(), + PaletteCommand::ToggleWrap, + ), + ( + "Toggle Continuous Scroll".to_owned(), + "Switch between continuous and single-file diff navigation".to_owned(), + PaletteCommand::ToggleContinuousScroll, + ), + ( + "Toggle Theme".to_owned(), + "Switch light and dark mode".to_owned(), + PaletteCommand::ToggleThemeMode, + ), + ( + "Change Theme".to_owned(), + "Browse and preview color themes".to_owned(), + PaletteCommand::ChangeTheme, + ), + ( + "Use Unified Layout".to_owned(), + "Set unified diff mode".to_owned(), + PaletteCommand::SetLayout(LayoutMode::Unified), + ), + ( + "Use Split Layout".to_owned(), + "Set side-by-side diff mode".to_owned(), + PaletteCommand::SetLayout(LayoutMode::Split), + ), + ( + "Use Built-in Renderer".to_owned(), + "Render diffs with Diffy's built-in engine".to_owned(), + PaletteCommand::SetRenderer(RendererKind::Builtin), + ), + ( + "Use Difftastic Renderer".to_owned(), + "Render diffs with Difftastic".to_owned(), + PaletteCommand::SetRenderer(RendererKind::Difftastic), + ), + ( + "Expand All Context".to_owned(), + "Show all hidden context in the active diff".to_owned(), + PaletteCommand::ExpandAllContext, + ), + ( + "Clear Line Selection".to_owned(), + "Clear the current partial-line staging selection".to_owned(), + PaletteCommand::ClearLineSelection, + ), + ( + "Generate Commit Message".to_owned(), + "Draft a commit message from the current changes".to_owned(), + PaletteCommand::GenerateCommitMessage, + ), + ( + "Fetch origin".to_owned(), + "Update remote references from origin".to_owned(), + PaletteCommand::FetchOrigin, + ), + ( + "Fetch all remotes".to_owned(), + "Update remote references from every configured remote".to_owned(), + PaletteCommand::FetchAllRemotes, + ), + ( + "Pull current branch".to_owned(), + "Fast-forward the current Git branch from its upstream".to_owned(), + PaletteCommand::PullCurrentBranch, + ), + ( + self.vcs_ui_profile().publish_command_label().to_owned(), + self.vcs_ui_profile().publish_command_detail().to_owned(), + PaletteCommand::PushCurrentBranch, + ), + ( + "Publish options".to_owned(), + "Choose a backend-provided publish action".to_owned(), + PaletteCommand::PublishOptions, + ), + ( + "Push current branch (force with lease)".to_owned(), + "Force-push the current Git branch; refuse if upstream moved".to_owned(), + PaletteCommand::PushCurrentBranchForceWithLease, + ), + ( + "Open Settings".to_owned(), + "Configure appearance, editor, and behavior".to_owned(), + PaletteCommand::OpenSettings, + ), + ] { + if !palette_command_available(&command, repo_capabilities) { + continue; + } + let search_text = format!("{label} {detail}"); + all_candidates.push(PaletteCandidate { + search_text, + label, + detail, + kind: PaletteEntryKind::Command(command), + }); + } + + for section in SettingsSection::ALL { + let label = format!("Settings: {}", section.label()); + let detail = "Switch settings section".to_owned(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::SetSettingsSection(section)), + }); + } + for (label, detail, mode) in [ + ( + "Use Dark Mode", + "Set settings appearance to dark", + ThemeMode::Dark, + ), + ( + "Use Light Mode", + "Set settings appearance to light", + ThemeMode::Light, + ), + ] { + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label: label.to_owned(), + detail: detail.to_owned(), + kind: PaletteEntryKind::Command(PaletteCommand::SetThemeMode(mode)), + }); + } + for pct in [80, 90, 100, 110, 125, 150, 180] { + let label = format!("Set UI Scale {pct}%"); + let detail = "Change interface density".to_owned(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::SetUiScalePct(pct)), + }); + } + for (column, label_suffix) in [(0, "Auto"), (80, "80"), (100, "100"), (120, "120")] { + let label = format!("Set Wrap Column {label_suffix}"); + let detail = "Set line wrapping column".to_owned(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::SetWrapColumn(column)), + }); + } + for lines in [1, 2, 3, 5, 7] { + let label = format!("Set Mouse Wheel Speed {lines}"); + let detail = "Set lines scrolled per wheel notch".to_owned(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::SetWheelScrollLines(lines)), + }); + } + all_candidates.push(PaletteCandidate { + search_text: "Toggle Automatic Updates auto update".to_owned(), + label: "Toggle Automatic Updates".to_owned(), + detail: "Enable or disable hourly update checks".to_owned(), + kind: PaletteEntryKind::Command(PaletteCommand::ToggleAutoUpdate), + }); + all_candidates.push(PaletteCandidate { + search_text: "Check For Updates update release".to_owned(), + label: "Check For Updates".to_owned(), + detail: "Check Diffy's release channel now".to_owned(), + kind: PaletteEntryKind::Command(PaletteCommand::CheckForUpdates), + }); + match self.update.get(&self.store) { + UpdateState::Available(update) => { + let label = format!("Install Update {}", update.version); + let detail = "Download and verify the available update".to_owned(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::InstallUpdate), + }); + } + UpdateState::ReadyToRestart(update) => { + let label = format!("Restart To Update {}", update.update.version); + let detail = "Restart Diffy and apply the staged update".to_owned(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::RestartToUpdate), + }); + } + _ => {} + } + + let repo_location = self.repository.location.get(&self.store); + for operation in JjOperation::ALL.map(VcsOperation::Jj) { + if !vcs_operation_available_for_location(&operation, repo_location.as_ref()) { + continue; + } + let label = format!("jj: {}", operation.label()); + let detail = operation.detail(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), + }); + } + if repo_location + .as_ref() + .is_some_and(|location| location.profile == VCS_PROFILE_JJ) + { + let mut destinations = self.repository.refs.with(&self.store, |refs| { + refs.iter() + .filter(|reference| { + !reference.active + && matches!(reference.kind, RefKind::Bookmark | RefKind::Branch) + }) + .map(|reference| reference.name.clone()) + .collect::>() + }); + destinations.sort(); + destinations.dedup(); + for destination in destinations.into_iter().take(12) { + let operation = VcsOperation::JjRebaseCurrentChangeOnto { + destination: destination.clone(), + }; + let label = format!("jj: {}", operation.label()); + let detail = operation.detail(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), + }); + } + let changes = self.repository.changes.get(&self.store); + for change in changes + .iter() + .filter(|change| { + !change.flags.current && !change.flags.working_copy && !change.flags.immutable + }) + .take(12) + { + let change_label = change + .short_change_id + .as_deref() + .unwrap_or(change.short_revision.as_str()) + .to_owned(); + let operation = VcsOperation::JjEditRevision { + revision: change.revision.id.clone(), + label: change_label.clone(), + }; + let label = format!("jj: {}", operation.label()); + let detail = crate::ui::vcs::change_summary_label(change); + all_candidates.push(PaletteCandidate { + search_text: format!( + "{label} {detail} {} {}", + change.short_revision, change.revision.id + ), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), + }); + } + let operation_log = self.repository.operation_log.get(&self.store); + for entry in operation_log.iter().skip(1).take(12) { + let operation_label = entry.short_operation_id.clone(); + let operation = VcsOperation::JjRestoreOperation { + operation_id: entry.operation_id.clone(), + label: operation_label.clone(), + }; + let label = format!("jj: {}", operation.label()); + let detail = operation_log_entry_detail(entry); + all_candidates.push(PaletteCandidate { + search_text: format!( + "{label} {detail} {} {}", + entry.operation_id, entry.short_operation_id + ), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), + }); + } + } + + if self + .workspace + .pre_drill_compare + .with(&self.store, |pre_drill| pre_drill.is_some()) + { + all_candidates.push(PaletteCandidate { + search_text: "Restore compare return range comparison commit drilldown".to_owned(), + label: "Restore Compare".to_owned(), + detail: "Return from the selected commit to the previous compare".to_owned(), + kind: PaletteEntryKind::Command(PaletteCommand::RestoreCompare), + }); + } + + if self + .editor + .line_selection + .with(&self.store, |selection| !selection.is_empty()) + { + all_candidates.push(PaletteCandidate { + search_text: "Comment on selected lines review pull request".to_owned(), + label: "Comment on Selected Lines".to_owned(), + detail: "Open the pull request review comment composer".to_owned(), + kind: PaletteEntryKind::Command(PaletteCommand::OpenReviewComment), + }); + } + + if self.active_pull_request_web_url().is_some() { + all_candidates.push(PaletteCandidate { + search_text: "Open pull request in GitHub browser web PR".to_owned(), + label: "Open Pull Request in GitHub".to_owned(), + detail: "Open the active pull request on github.com".to_owned(), + kind: PaletteEntryKind::Command(PaletteCommand::OpenPullRequestInGitHub), + }); + } + + let file_count = self.workspace_file_count(); + for index in 0..file_count { + let Some(file) = self.workspace_file_entry_at(index) else { + continue; + }; + let meta = self.file_list_entry_meta(index); + let detail = format!( + "File \u{2022} {} \u{2022} +{} -{}", + meta.status.label(), + meta.additions, + meta.deletions + ); + let search_text = format!("{} {detail}", file.path); + all_candidates.push(PaletteCandidate { + search_text, + label: file.path.to_string(), + detail, + kind: PaletteEntryKind::File(index), + }); + } + + let range_commits = self.workspace.range_commits.get(&self.store); + for change in &range_commits { + let label = crate::ui::vcs::change_summary_label(change); + let detail = format!("Commit {}", change.short_revision); + let search_text = format!("{} {} {}", change.short_revision, change.revision.id, label); + all_candidates.push(PaletteCandidate { + search_text, + label, + detail, + kind: PaletteEntryKind::Commit(change.revision.id.clone()), + }); + } + + let palette_repos = crate::core::frecency::recent_repo_paths(self.frecency.as_ref(), 10); + for repo in &palette_repos { + let repo_name = repo + .file_name() + .and_then(|name| name.to_str()) + .filter(|n| *n != ".") + .map(str::to_owned) + .unwrap_or_else(|| repo.display().to_string()); + let detail = repo.display().to_string(); + let search_text = format!("{repo_name} {detail}"); + all_candidates.push(PaletteCandidate { + search_text, + label: repo_name, + detail, + kind: PaletteEntryKind::Repo(repo.clone()), + }); + } + + let repo_refs = self.repository.refs.get(&self.store); + for reference in repo_refs.iter().filter(|reference| { + matches!( + reference.kind, + RefKind::Branch + | RefKind::RemoteBranch + | RefKind::Bookmark + | RefKind::RemoteBookmark + | RefKind::Tag + ) + }) { + let (detail, _) = self + .vcs_ui_profile() + .ref_kind_label_and_icon(reference.kind); + let search_text = format!("{} {}", reference.name, detail); + all_candidates.push(PaletteCandidate { + search_text, + label: reference.name.clone(), + detail: detail.to_owned(), + kind: PaletteEntryKind::Ref(CompareField::Left, reference.name.clone()), + }); + } + + let mut entries: Vec; + if query.is_empty() { + entries = all_candidates + .into_iter() + .map(|c| PaletteEntry { + label: c.label, + detail: c.detail, + kind: c.kind, + highlights: Vec::new(), + rhs: None, + disabled: false, + }) + .collect(); + } else { + let haystack: Vec<&str> = all_candidates + .iter() + .map(|c| c.search_text.as_str()) + .collect(); + let config = neo_frizbee::Config { + max_typos: Some(2), + sort: false, + ..Default::default() + }; + let matches = neo_frizbee::match_list_indices(query, &haystack, &config); + let mut scored: Vec<_> = matches + .into_iter() + .map(|m| { + let c = &all_candidates[m.index as usize]; + ( + m.score, + PaletteEntry { + label: c.label.clone(), + detail: c.detail.clone(), + kind: c.kind.clone(), + highlights: highlight_ranges_for_visible_match( + query, &c.label, &m.indices, &config, + ), + rhs: None, + disabled: false, + }, + ) + }) + .collect(); + scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.label.cmp(&b.1.label))); + entries = scored.into_iter().map(|(_, e)| e).collect(); + } + if let Some(pr) = pr_entry { + entries.insert(0, pr); + } + entries.truncate(18); + let entry_count = entries.len(); + self.overlays + .command_palette + .entries + .set(&self.store, entries); + let current_selected = self + .overlays + .command_palette + .selected_index + .get(&self.store); + self.overlays.command_palette.selected_index.set( + &self.store, + current_selected.min(entry_count.saturating_sub(1)), + ); + self.overlays.command_palette.list.update(&self.store, |l| { + l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); + l.clamp_scroll(entry_count); + }); + out_effects + } + + pub(super) fn scroll_active_overlay_list_px(&mut self, delta_px: i32) { + match self.overlays_top() { + Some( + OverlaySurface::RepoPicker + | OverlaySurface::RefPicker + | OverlaySurface::ThemePicker + | OverlaySurface::FontPicker, + ) => { + let count = self.overlays.picker.entries.with(&self.store, |e| e.len()); + self.overlays + .picker + .list + .update(&self.store, |l| l.scroll_px(delta_px, count)); + } + Some(OverlaySurface::CommandPalette) => { + let count = self + .overlays + .command_palette + .entries + .with(&self.store, |e| e.len()); + self.overlays + .command_palette + .list + .update(&self.store, |l| l.scroll_px(delta_px, count)); + } + _ => {} + } + } +} diff --git a/src/ui/state/presentation.rs b/src/ui/state/presentation.rs new file mode 100644 index 00000000..c11c41a5 --- /dev/null +++ b/src/ui/state/presentation.rs @@ -0,0 +1,2154 @@ +//! Presentation-layer caches for the diff viewport: the continuous-scroll +//! virtual document model, per-slot render-doc cache, file height index, and +//! scroll anchoring. Pure code motion from `mod.rs`. + +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewportDocumentMode { + Single, + Continuous, +} + +#[derive(Debug, Clone)] +pub struct ViewportDocument { + pub doc: Arc, + pub mode: ViewportDocumentMode, + pub generation: u64, + pub start_index: usize, + pub start_offset_px: u32, + pub scroll_top_px: u32, + pub slot_indices: Vec, + pub slot_item_ids: Vec, + pub stream_items: Vec, + pub slot_loading: Vec, + pub path: String, +} + +impl ViewportDocument { + pub fn single(doc: Arc, generation: u64, file_index: usize, path: String) -> Self { + Self { + doc, + mode: ViewportDocumentMode::Single, + generation, + start_index: file_index, + start_offset_px: 0, + scroll_top_px: 0, + slot_indices: vec![file_index], + slot_item_ids: vec![VirtualDiffItemId::file( + WorkspaceSource::None, + generation, + file_index, + )], + stream_items: Vec::new(), + slot_loading: vec![false], + path, + } + } + + pub fn is_continuous(&self) -> bool { + self.mode == ViewportDocumentMode::Continuous + } + + pub fn insert_stream_item(&mut self, item: VirtualDiffStreamItem) { + let index = self + .stream_items + .partition_point(|existing| existing.sort_key <= item.sort_key); + self.stream_items.insert(index, item); + } +} + +pub(super) fn virtual_stream_item_kind( + slot: &ViewportSlotKey, + line: &RenderLine, +) -> Option { + match line.row_kind() { + RenderRowKind::FileHeader => Some(VirtualDiffItemKind::FileHeader), + RenderRowKind::HunkSeparator + if matches!(slot.kind, ViewportSlotKind::Loading) || line.hunk_index < 0 => + { + Some(VirtualDiffItemKind::LoadingPlaceholder) + } + RenderRowKind::HunkSeparator => Some(VirtualDiffItemKind::Hunk), + RenderRowKind::Context + | RenderRowKind::Added + | RenderRowKind::Removed + | RenderRowKind::Modified => Some(VirtualDiffItemKind::DiffRow), + RenderRowKind::Block => None, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VirtualDiffItemKind { + File, + FileHeader, + Hunk, + DiffRow, + ReviewThread, + ReviewComment, + Composer, + LoadingPlaceholder, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VirtualDiffItemId { + pub source: WorkspaceSource, + pub generation: u64, + pub kind: VirtualDiffItemKind, + pub index: usize, + pub ordinal: u32, + pub stable_key: u64, +} + +impl VirtualDiffItemId { + pub(super) fn file(source: WorkspaceSource, generation: u64, index: usize) -> Self { + Self { + source, + generation, + kind: VirtualDiffItemKind::File, + index, + ordinal: 0, + stable_key: 0, + } + } + + pub fn new( + source: WorkspaceSource, + generation: u64, + kind: VirtualDiffItemKind, + index: usize, + ordinal: u32, + stable_key: u64, + ) -> Self { + Self { + source, + generation, + kind, + index, + ordinal, + stable_key, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VirtualDiffStreamItem { + pub id: VirtualDiffItemId, + pub sort_key: u64, + pub estimated_height_px: u32, + pub measured_height_px: Option, +} + +impl VirtualDiffStreamItem { + pub fn new( + id: VirtualDiffItemId, + sort_key: u64, + estimated_height_px: u32, + measured_height_px: Option, + ) -> Self { + Self { + id, + sort_key, + estimated_height_px, + measured_height_px, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewportAnchorBias { + PreserveTop, + PreserveBottom, + FollowEnd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ViewportAnchor { + pub item_id: VirtualDiffItemId, + pub intra_item_offset_px: u32, + pub bias: ViewportAnchorBias, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ViewportSlotKey { + pub(super) source: WorkspaceSource, + pub(super) index: usize, + pub(super) path: String, + pub(super) left_ref: String, + pub(super) right_ref: String, + pub(super) kind: ViewportSlotKind, +} + +impl ViewportSlotKey { + pub(super) fn working_set_key(&self) -> Option { + if self.source == WorkspaceSource::None { + return None; + } + Some(WorkingSetFileKey::new( + self.index, + self.path.clone(), + self.left_ref.clone(), + self.right_ref.clone(), + )) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ViewportSlotKind { + Text { + line_count: usize, + text_len: usize, + style_run_count: usize, + syntax_covered_count: usize, + }, + Binary, + Loading, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ViewportDocumentKey { + pub(super) source: WorkspaceSource, + pub(super) generation: u64, + pub(super) start_index: usize, + pub(super) slots: Vec, +} + +#[derive(Debug, Clone)] +pub(super) struct ViewportDocumentCache { + pub(super) key: ViewportDocumentKey, + pub(super) doc: Arc, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ScrollDirection { + Backward, + Forward, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SyntaxPendingWindow { + pub(super) request_id: u64, + pub(super) window: SyntaxRowWindow, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SidebarWidthCache { + pub compare_generation: u64, + pub ui_scale_pct: u16, + pub intrinsic_width_px: f32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ViewportScrollbarMetrics { + pub content_height_px: u32, + pub viewport_height_px: u32, + pub scroll_top_px: u32, + pub max_scroll_top_px: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ViewportScrollbarDragState { + pub metrics: ViewportScrollbarMetrics, + pub file_heights_px: Vec, +} +pub(super) const FILE_HEIGHT_SPARSE_MIN_COUNT: usize = 4096; + +#[derive(Debug)] +pub(super) enum FileHeightIndex { + Empty, + Dense { + heights: Vec, + tree: Vec, + }, + Sparse { + count: usize, + default_height: u32, + total: u64, + overrides: BTreeMap, + tree: Vec, + }, +} + +impl Default for FileHeightIndex { + fn default() -> Self { + Self::Empty + } +} + +impl FileHeightIndex { + pub(super) fn rebuild(&mut self, heights: Vec) { + if heights.is_empty() { + self.clear(); + return; + } + + if let Some((default_height, overrides, total)) = sparse_height_index_parts(&heights) { + let mut tree = vec![0; heights.len() + 1]; + for (index, height) in heights.iter().copied().enumerate() { + height_tree_add(&mut tree, index, u64::from(height)); + } + *self = Self::Sparse { + count: heights.len(), + default_height, + total, + overrides, + tree, + }; + return; + } + + let mut tree = vec![0; heights.len() + 1]; + for (index, height) in heights.iter().copied().enumerate() { + dense_tree_add(&mut tree, index, height); + } + *self = Self::Dense { heights, tree }; + } + + pub(super) fn clear(&mut self) { + *self = Self::Empty; + } + + pub(super) fn len(&self) -> usize { + match self { + Self::Empty => 0, + Self::Dense { heights, .. } => heights.len(), + Self::Sparse { count, .. } => *count, + } + } + + pub(super) fn total_u64(&self) -> u64 { + match self { + Self::Empty => 0, + Self::Dense { heights, .. } => self.prefix_u64(heights.len()), + Self::Sparse { total, .. } => *total, + } + } + + pub(super) fn total_u32(&self) -> u32 { + self.total_u64().min(u64::from(u32::MAX)) as u32 + } + + pub(super) fn prefix_u32(&self, index: usize) -> u32 { + self.prefix_u64(index).min(u64::from(u32::MAX)) as u32 + } + + pub(super) fn update(&mut self, index: usize, height: u32) { + match self { + Self::Empty => {} + Self::Dense { heights, tree } => { + if index >= heights.len() { + return; + } + let old = heights[index]; + if old == height { + return; + } + heights[index] = height; + if height >= old { + dense_tree_add(tree, index, height - old); + } else { + dense_tree_sub(tree, index, old - height); + } + } + Self::Sparse { + count, + default_height, + total, + overrides, + tree, + } => { + if index >= *count { + return; + } + let old = overrides.get(&index).copied().unwrap_or(*default_height); + if old == height { + return; + } + if height == *default_height { + overrides.remove(&index); + } else { + overrides.insert(index, height); + } + *total = total + .saturating_sub(u64::from(old)) + .saturating_add(u64::from(height)); + if height >= old { + height_tree_add(tree, index, u64::from(height - old)); + } else { + height_tree_sub(tree, index, u64::from(old - height)); + } + if overrides.len() > *count / 4 { + self.promote_sparse_to_dense(); + } + } + } + } + + pub(super) fn locate(&self, target_px: u32) -> Option<(usize, u32)> { + match self { + Self::Empty => None, + Self::Dense { heights, tree } => locate_dense_height(heights, tree, target_px), + Self::Sparse { + count, total, tree, .. + } => locate_sparse_height(self, *count, *total, tree, target_px), + } + } + + pub(super) fn prefix_u64(&self, index: usize) -> u64 { + match self { + Self::Empty => 0, + Self::Dense { heights, tree } => dense_prefix_u64(heights, tree, index), + Self::Sparse { count, tree, .. } => height_tree_prefix_u64(tree, index.min(*count)), + } + } + + pub(super) fn height_at(&self, index: usize) -> u32 { + match self { + Self::Empty => 0, + Self::Dense { heights, .. } => heights.get(index).copied().unwrap_or(0), + Self::Sparse { + count, + default_height, + overrides, + .. + } => { + if index >= *count { + 0 + } else { + overrides.get(&index).copied().unwrap_or(*default_height) + } + } + } + } + + pub(super) fn promote_sparse_to_dense(&mut self) { + let Self::Sparse { + count, + default_height, + overrides, + .. + } = self + else { + return; + }; + let mut heights = vec![*default_height; *count]; + for (index, height) in overrides.iter() { + if let Some(slot) = heights.get_mut(*index) { + *slot = *height; + } + } + self.rebuild(heights); + } +} + +#[derive(Debug, Default)] +pub(super) struct VirtualDiffDocument { + pub(super) source: WorkspaceSource, + pub(super) generation: u64, + pub(super) file_count: usize, + pub(super) height_index: FileHeightIndex, +} + +impl VirtualDiffDocument { + pub(super) fn sync_identity( + &mut self, + source: WorkspaceSource, + generation: u64, + file_count: usize, + ) -> bool { + let changed = + self.source != source || self.generation != generation || self.file_count != file_count; + if changed { + self.source = source; + self.generation = generation; + self.file_count = file_count; + self.height_index.clear(); + } + changed + } + + pub(super) fn clear(&mut self) { + self.source = WorkspaceSource::None; + self.generation = 0; + self.file_count = 0; + self.height_index.clear(); + } + + pub(super) fn rebuild_heights(&mut self, heights: Vec) { + self.file_count = heights.len(); + self.height_index.rebuild(heights); + } + + pub(super) fn item_id(&self, index: usize) -> Option { + (index < self.file_count) + .then(|| VirtualDiffItemId::file(self.source, self.generation, index)) + } + + pub(super) fn anchor_is_current(&self, anchor: ViewportAnchor) -> bool { + anchor.item_id.source == self.source + && anchor.item_id.generation == self.generation + && anchor.item_id.kind == VirtualDiffItemKind::File + && anchor.item_id.index < self.file_count + } + + pub(super) fn len(&self) -> usize { + self.height_index.len() + } + + pub(super) fn total_u32(&self) -> u32 { + self.height_index.total_u32() + } + + pub(super) fn prefix_u32(&self, index: usize) -> u32 { + self.height_index.prefix_u32(index) + } + + pub(super) fn locate(&self, target_px: u32) -> Option<(usize, u32)> { + self.height_index.locate(target_px) + } + + pub(super) fn height_at(&self, index: usize) -> u32 { + self.height_index.height_at(index) + } + + pub(super) fn update_height(&mut self, index: usize, height: u32) { + self.height_index.update(index, height); + } +} + +#[derive(Debug, Default)] +pub(super) struct VirtualScrollModel { + pub(super) anchor: Option, +} + +impl VirtualScrollModel { + pub(super) fn clear(&mut self) { + self.anchor = None; + } + + pub(super) fn set_anchor(&mut self, anchor: ViewportAnchor) { + self.anchor = Some(anchor); + } +} + +const VIRTUAL_STREAM_SORT_STRIDE: u64 = 1024; +const VIRTUAL_STREAM_ROW_OFFSET: u64 = 512; +const VIRTUAL_STREAM_BLOCK_BELOW_OFFSET: u64 = 768; + +pub(super) fn virtual_row_sort_key(line_index: usize) -> u64 { + (line_index as u64) + .saturating_mul(VIRTUAL_STREAM_SORT_STRIDE) + .saturating_add(VIRTUAL_STREAM_ROW_OFFSET) +} + +pub fn virtual_block_below_sort_key(anchor_line_index: u32, block_order: usize) -> u64 { + u64::from(anchor_line_index) + .saturating_mul(VIRTUAL_STREAM_SORT_STRIDE) + .saturating_add(VIRTUAL_STREAM_BLOCK_BELOW_OFFSET) + .saturating_add(block_order.min(255) as u64) +} + +pub fn stable_virtual_key(text: &str) -> u64 { + let mut key = 0xcbf2_9ce4_8422_2325_u64; + for byte in text.as_bytes() { + key ^= u64::from(*byte); + key = key.wrapping_mul(0x100_0000_01b3); + } + key +} + +pub(super) fn estimated_virtual_item_height_px(kind: VirtualDiffItemKind) -> u32 { + match kind { + VirtualDiffItemKind::File => 192, + VirtualDiffItemKind::FileHeader => 40, + VirtualDiffItemKind::Hunk => 28, + VirtualDiffItemKind::DiffRow => 24, + VirtualDiffItemKind::ReviewThread => 160, + VirtualDiffItemKind::ReviewComment => 96, + VirtualDiffItemKind::Composer => 248, + VirtualDiffItemKind::LoadingPlaceholder => 48, + } +} + +pub(super) fn virtual_row_stable_key(line: &RenderLine, local_ordinal: u32) -> u64 { + let mut key = u64::from(line.kind); + key = key + .wrapping_mul(1_099_511_628_211) + .wrapping_add(line.hunk_index as i64 as u64); + key = key + .wrapping_mul(1_099_511_628_211) + .wrapping_add(u64::from(line.old_line_no)); + key = key + .wrapping_mul(1_099_511_628_211) + .wrapping_add(u64::from(line.new_line_no)); + key = key + .wrapping_mul(1_099_511_628_211) + .wrapping_add(line.line_index as i64 as u64); + key.wrapping_mul(1_099_511_628_211) + .wrapping_add(u64::from(local_ordinal)) +} + +fn sparse_height_index_parts(heights: &[u32]) -> Option<(u32, BTreeMap, u64)> { + if heights.len() < FILE_HEIGHT_SPARSE_MIN_COUNT { + return None; + } + let default_height = most_common_height(heights); + let mut overrides = BTreeMap::new(); + let mut total = 0_u64; + for (index, height) in heights.iter().copied().enumerate() { + total = total.saturating_add(u64::from(height)); + if height != default_height { + overrides.insert(index, height); + } + } + + if overrides.len() <= heights.len() / 4 { + Some((default_height, overrides, total)) + } else { + None + } +} + +fn most_common_height(heights: &[u32]) -> u32 { + let mut counts: HashMap = HashMap::new(); + let mut best_height = heights[0]; + let mut best_count = 0; + for height in heights { + let count = counts + .entry(*height) + .and_modify(|count| *count += 1) + .or_insert(1); + if *count > best_count { + best_height = *height; + best_count = *count; + } + } + best_height +} + +fn dense_tree_add(tree: &mut [u32], index: usize, delta: u32) { + let mut idx = index + 1; + while idx < tree.len() { + tree[idx] = tree[idx].saturating_add(delta); + idx += idx & idx.wrapping_neg(); + } +} + +fn dense_tree_sub(tree: &mut [u32], index: usize, delta: u32) { + let mut idx = index + 1; + while idx < tree.len() { + tree[idx] = tree[idx].saturating_sub(delta); + idx += idx & idx.wrapping_neg(); + } +} + +fn height_tree_add(tree: &mut [u64], index: usize, delta: u64) { + let mut idx = index + 1; + while idx < tree.len() { + tree[idx] = tree[idx].saturating_add(delta); + idx += idx & idx.wrapping_neg(); + } +} + +fn height_tree_sub(tree: &mut [u64], index: usize, delta: u64) { + let mut idx = index + 1; + while idx < tree.len() { + tree[idx] = tree[idx].saturating_sub(delta); + idx += idx & idx.wrapping_neg(); + } +} + +fn dense_prefix_u64(heights: &[u32], tree: &[u32], index: usize) -> u64 { + let mut idx = index.min(heights.len()); + let mut sum = 0_u64; + while idx > 0 { + sum = sum.saturating_add(u64::from(tree[idx])); + idx &= idx - 1; + } + sum +} + +fn height_tree_prefix_u64(tree: &[u64], index: usize) -> u64 { + let mut idx = index.min(tree.len().saturating_sub(1)); + let mut sum = 0_u64; + while idx > 0 { + sum = sum.saturating_add(tree[idx]); + idx &= idx - 1; + } + sum +} + +fn locate_dense_height(heights: &[u32], tree: &[u32], target_px: u32) -> Option<(usize, u32)> { + if heights.is_empty() { + return None; + } + let target = u64::from(target_px); + let total = dense_prefix_u64(heights, tree, heights.len()); + if target >= total { + let index = heights.len() - 1; + return Some((index, heights[index].saturating_sub(1))); + } + + let mut idx = 0_usize; + let mut bit = 1_usize; + while bit < tree.len() { + bit <<= 1; + } + let mut sum = 0_u64; + while bit > 0 { + let next = idx + bit; + if next < tree.len() { + let next_sum = sum.saturating_add(u64::from(tree[next])); + if next_sum <= target { + idx = next; + sum = next_sum; + } + } + bit >>= 1; + } + let index = idx.min(heights.len().saturating_sub(1)); + Some(( + index, + target.saturating_sub(sum).min(u64::from(u32::MAX)) as u32, + )) +} + +fn locate_sparse_height( + index: &FileHeightIndex, + count: usize, + total: u64, + tree: &[u64], + target_px: u32, +) -> Option<(usize, u32)> { + if count == 0 { + return None; + } + let target = u64::from(target_px); + if target >= total { + let slot = count - 1; + return Some((slot, index.height_at(slot).saturating_sub(1))); + } + + let mut slot = 0_usize; + let mut bit = 1_usize; + while bit < tree.len() { + bit <<= 1; + } + let mut sum = 0_u64; + while bit > 0 { + let next = slot + bit; + if next < tree.len() { + let next_sum = sum.saturating_add(tree[next]); + if next_sum <= target { + slot = next; + sum = next_sum; + } + } + bit >>= 1; + } + let slot = slot.min(count.saturating_sub(1)); + Some(( + slot, + target.saturating_sub(sum).min(u64::from(u32::MAX)) as u32, + )) +} + +pub(super) const CONTINUOUS_BOTTOM_ANCHOR_TOLERANCE_PX: u32 = 2; + +pub(super) fn apply_scroll_delta_px(current: u32, delta: i32, max: u32) -> u32 { + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as u32) + }; + next.min(max) +} + +impl AppState { + pub(super) fn active_file_slot_key( + &self, + source: WorkspaceSource, + active: &ActiveFile, + ) -> ViewportSlotKey { + let kind = if active.carbon_file.is_binary { + ViewportSlotKind::Binary + } else { + ViewportSlotKind::Text { + line_count: active.render_doc.lines.len(), + text_len: active.render_doc.text_bytes.len(), + style_run_count: active.render_doc.style_runs.len(), + syntax_covered_count: active.syntax_covered.len(), + } + }; + ViewportSlotKey { + source, + index: active.index, + path: active.path.clone(), + left_ref: active.left_ref.clone(), + right_ref: active.right_ref.clone(), + kind, + } + } + + pub(super) fn loading_slot_key( + &self, + source: WorkspaceSource, + index: usize, + path: &str, + left_ref: String, + right_ref: String, + ) -> ViewportSlotKey { + ViewportSlotKey { + source, + index, + path: path.to_owned(), + left_ref, + right_ref, + kind: ViewportSlotKind::Loading, + } + } + + pub(super) fn compare_slot_key_at(&self, index: usize, path: &str) -> ViewportSlotKey { + let source = match self.workspace.source.get(&self.store) { + WorkspaceSource::TextCompare => WorkspaceSource::TextCompare, + _ => WorkspaceSource::Compare, + }; + let (left_ref, right_ref) = self.compare_refs(); + if let Some(key) = self.workspace.active_file.with(&self.store, |file| { + file.as_ref() + .filter(|file| { + file.index == index + && file.path == path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + .map(|file| self.active_file_slot_key(source, file)) + }) { + return key; + } + if let Some(key) = self.workspace.file_cache.with(&self.store, |files| { + files + .get(&index) + .filter(|file| { + file.index == index + && file.path == path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + .map(|file| self.active_file_slot_key(source, file)) + }) { + return key; + } + self.loading_slot_key(source, index, path, left_ref, right_ref) + } + + pub(super) fn status_slot_key_at(&self, index: usize, change: &FileChange) -> ViewportSlotKey { + let (left_ref, right_ref) = self.status_refs_for_bucket(change.bucket); + if let Some(key) = self.workspace.active_file.with(&self.store, |file| { + file.as_ref() + .filter(|file| { + file.index == index + && file.path == change.path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + .map(|file| self.active_file_slot_key(WorkspaceSource::Status, file)) + }) { + return key; + } + if let Some(key) = self.workspace.file_cache.with(&self.store, |files| { + files + .get(&index) + .filter(|file| { + file.index == index + && file.path == change.path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + .map(|file| self.active_file_slot_key(WorkspaceSource::Status, file)) + }) { + return key; + } + self.loading_slot_key( + WorkspaceSource::Status, + index, + &change.path, + left_ref, + right_ref, + ) + } + + pub(super) fn append_viewport_slot_doc( + &self, + out: &mut RenderDoc, + key: &ViewportSlotKey, + loading_message: &str, + ) { + if let ViewportSlotKind::Loading = key.kind { + out.append_doc(&build_placeholder_render_doc(&key.path, loading_message)); + return; + } + + let mut appended = false; + self.workspace.active_file.with(&self.store, |file| { + let Some(active) = file.as_ref() else { + return; + }; + if active.index == key.index + && active.path == key.path + && active.left_ref == key.left_ref + && active.right_ref == key.right_ref + { + append_active_file_doc(out, active); + appended = true; + } + }); + if appended { + return; + } + + self.workspace.file_cache.with(&self.store, |files| { + let Some(active) = files.get(&key.index).filter(|active| { + active.index == key.index + && active.path == key.path + && active.left_ref == key.left_ref + && active.right_ref == key.right_ref + }) else { + return; + }; + append_active_file_doc(out, active); + appended = true; + }); + + if !appended { + out.append_doc(&build_placeholder_render_doc(&key.path, loading_message)); + } + } + + pub(super) fn viewport_slot_syntax_window( + &self, + key: &ViewportSlotKey, + slot_top_px: u32, + slot_height_px: u32, + viewport_top_px: u32, + viewport_height_px: u32, + ) -> Option { + let ViewportSlotKind::Text { line_count, .. } = key.kind else { + return None; + }; + if line_count == 0 { + return None; + } + + let slot_bottom_px = slot_top_px.saturating_add(slot_height_px.max(1)); + let viewport_bottom_px = viewport_top_px.saturating_add(viewport_height_px.max(1)); + let visible_top_px = slot_top_px.max(viewport_top_px); + let visible_bottom_px = slot_bottom_px.min(viewport_bottom_px); + if visible_bottom_px <= visible_top_px { + return None; + } + + let row_height_q16 = self.workspace.measured_px_per_row_q16.get(&self.store); + let row_height_q16 = if row_height_q16 == 0 { + 24_u32 << 16 + } else { + row_height_q16 + }; + let row_height_q16 = u64::from(row_height_q16.max(1)); + let start_px = visible_top_px.saturating_sub(slot_top_px); + let end_px = visible_bottom_px.saturating_sub(slot_top_px); + let row_floor = |px: u32| ((u64::from(px) << 16) / row_height_q16) as usize; + let row_ceil = |px: u32| { + (((u64::from(px) << 16).saturating_add(row_height_q16 - 1)) / row_height_q16) as usize + }; + + let start = row_floor(start_px) + .saturating_sub(SYNTAX_OVERSCAN_ROWS) + .min(line_count); + let mut end = row_ceil(end_px) + .saturating_add(SYNTAX_OVERSCAN_ROWS) + .min(line_count); + if end <= start { + end = start.saturating_add(SYNTAX_INITIAL_ROWS).min(line_count); + } + Some(SyntaxRowWindow { start, end }) + } + + pub(super) fn request_viewport_slot_syntax_window( + &mut self, + key: &ViewportSlotKey, + window: SyntaxRowWindow, + ) -> Option { + if window.end <= window.start { + return None; + } + if !self.syntax_request_budget_available() { + return None; + } + let repo_path = self.compare.repo_path.get(&self.store)?; + let generation = self.active_syntax_generation(); + let syntax_epoch = self.syntax_requests.epoch(); + let mut request = None; + let request_id = self.syntax_requests.next_request_id(); + let mut matched_active = false; + let mut active_to_cache = None; + + self.workspace.active_file.update(&self.store, |slot| { + let Some(active) = slot.as_mut() else { + return; + }; + if active.index != key.index + || active.path != key.path + || active.left_ref != key.left_ref + || active.right_ref != key.right_ref + { + return; + } + matched_active = true; + if let Some(next_request) = request_syntax_for_active_file( + active, + repo_path.clone(), + generation, + syntax_epoch, + window, + request_id, + ) { + active_to_cache = Some(active.clone()); + request = Some(next_request); + } + }); + if let Some(active_file) = active_to_cache { + self.cache_active_file(active_file); + } + if matched_active { + if let Some(request) = request { + self.track_syntax_request(&request); + return Some( + SyntaxEffect::LoadFileSyntax(Task { + generation, + request, + }) + .into(), + ); + } + return None; + } + + let request_id = self.syntax_requests.next_request_id(); + self.workspace.file_cache.update(&self.store, |files| { + let Some(active) = files.get_mut(&key.index).filter(|active| { + active.index == key.index + && active.path == key.path + && active.left_ref == key.left_ref + && active.right_ref == key.right_ref + }) else { + return; + }; + request = request_syntax_for_active_file( + active, + repo_path, + generation, + syntax_epoch, + window, + request_id, + ); + }); + + request.map(|request| { + self.track_syntax_request(&request); + SyntaxEffect::LoadFileSyntax(Task { + generation, + request, + }) + .into() + }) + } + + pub(super) fn ensure_compare_file_cached_for_viewport( + &mut self, + index: usize, + path: &str, + priority: CompareWorkPriority, + ) -> Vec { + if self.cached_compare_file_at(index, path).is_some() { + return Vec::new(); + } + if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { + if self.cache_compare_file_from_output(index, path).is_some() { + return vec![ + SyntaxEffect::EnsureSyntaxPackForPath { + path: path.to_owned(), + } + .into(), + ]; + } + return Vec::new(); + } + if !self.compare_file_is_large(index) + && self.cache_compare_file_from_output(index, path).is_some() + { + return vec![ + SyntaxEffect::EnsureSyntaxPackForPath { + path: path.to_owned(), + } + .into(), + ]; + } + if !self.should_enqueue_file_load(index, path, priority) { + return Vec::new(); + } + + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + let deferred_file = self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .and_then(|output| compare_output_deferred_summary(output, index)) + .filter(|summary| summary.path() == path) + }); + self.mark_file_cache_loading(index, path.to_owned(), priority); + vec![ + SyntaxEffect::EnsureSyntaxPackForPath { + path: path.to_owned(), + } + .into(), + CompareEffect::LoadFile(Task { + generation: self.workspace.compare_generation.get(&self.store), + request: CompareFileRequest { + repo_path, + request: vcs_compare_request( + self.compare.mode.get(&self.store), + self.compare.left_ref.get(&self.store), + self.compare.right_ref.get(&self.store), + self.compare.layout.get(&self.store), + self.compare.renderer.get(&self.store), + ), + path: path.to_owned(), + index, + deferred_file, + priority, + }, + }) + .into(), + ] + } + + pub(super) fn ensure_status_file_cached_for_viewport(&mut self, index: usize) -> Vec { + let Some(file_change) = self + .workspace + .status_file_changes + .with(&self.store, |changes| changes.get(index).cloned()) + else { + return Vec::new(); + }; + if self.cached_status_file_at(index, &file_change).is_some() { + return Vec::new(); + } + if !self.should_enqueue_file_load( + index, + &file_change.path, + CompareWorkPriority::VisibleViewportDiff, + ) { + return Vec::new(); + } + + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + self.mark_file_cache_loading( + index, + file_change.path.clone(), + CompareWorkPriority::VisibleViewportDiff, + ); + let generation = self.workspace.status_generation.get(&self.store); + let renderer = self.compare.renderer.get(&self.store); + vec![ + ensure_syntax_packs_for_file_change_effect(&file_change), + RepositoryEffect::LoadStatusDiff { + task: Task { + generation, + request: StatusDiffRequest { + repo_path, + file_change, + renderer, + }, + }, + index, + } + .into(), + ] + } +} + +impl AppState { + pub(super) fn scroll_viewport_lines(&mut self, delta_lines: i32) -> Vec { + let step_px = 20_i32; + let delta_px = delta_lines.saturating_mul(step_px); + self.scroll_viewport_px(delta_px) + } + + pub(super) fn scroll_viewport_px(&mut self, delta_px: i32) -> Vec { + if !self.settings.continuous_scroll { + let current = self.editor.scroll_top_px.get(&self.store); + let max = self.editor_max_scroll_top_px(); + let next = apply_scroll_delta_px(current, delta_px, max); + self.editor.scroll_top_px.set(&self.store, next); + return Vec::new(); + } + + if delta_px == 0 { + return Vec::new(); + } + + let current = self.workspace.global_scroll_top_px.get(&self.store); + let target = apply_scroll_delta_px(current, delta_px, self.global_max_scroll_top_px()); + self.scroll_viewport_to_global(target) + } + + pub(super) fn clear_file_scroll_layout(&mut self) { + self.workspace + .file_content_heights + .set(&self.store, Vec::new()); + self.workspace + .file_scroll_total_height_px + .set(&self.store, 0); + self.workspace + .pending_file_content_heights + .set(&self.store, HashMap::new()); + self.workspace + .file_scroll_recompute_pending + .set(&self.store, false); + self.workspace + .viewport_scrollbar_drag + .set(&self.store, None); + self.virtual_diff_document.clear(); + self.virtual_scroll.clear(); + self.last_virtual_scroll_top_px = None; + } + + pub(super) fn reset_file_scroll_layout(&mut self) { + self.workspace + .file_content_heights + .set(&self.store, Vec::new()); + self.workspace + .pending_file_content_heights + .set(&self.store, HashMap::new()); + self.workspace + .file_scroll_recompute_pending + .set(&self.store, false); + self.workspace + .viewport_scrollbar_drag + .set(&self.store, None); + self.virtual_scroll.clear(); + self.last_virtual_scroll_top_px = None; + self.recompute_file_scroll_total_height_px(); + } + + pub fn recompute_file_scroll_total_height_px(&mut self) { + let count = self.workspace_file_count(); + let source = self.workspace.source.get(&self.store); + let generation = self.workspace_render_generation(); + if self + .virtual_diff_document + .sync_identity(source, generation, count) + { + self.virtual_scroll.clear(); + self.last_virtual_scroll_top_px = None; + } + self.workspace + .file_content_heights + .update(&self.store, |heights| { + if heights.len() > count { + heights.truncate(count); + } + }); + + let heights = (0..count) + .map(|index| self.file_scroll_height_px(index).max(1)) + .collect::>(); + self.virtual_diff_document.rebuild_heights(heights); + let total = self.virtual_diff_document.total_u32(); + self.workspace + .file_scroll_total_height_px + .set(&self.store, total); + } + + pub(super) fn update_file_scroll_heights(&mut self, old_heights: Vec<(usize, u32)>) { + let count = self.workspace_file_count(); + if self.virtual_diff_document.len() != count { + self.recompute_file_scroll_total_height_px(); + return; + } + + let mut total = self.workspace.file_scroll_total_height_px.get(&self.store); + for (index, old_height) in old_heights { + if index >= count { + continue; + } + let new_height = self.file_scroll_height_px(index).max(1); + total = total.saturating_sub(old_height).saturating_add(new_height); + self.virtual_diff_document.update_height(index, new_height); + } + self.workspace + .file_scroll_total_height_px + .set(&self.store, total); + } + + pub fn update_file_content_height_px(&mut self, index: usize, height: u32) -> bool { + let count = self.workspace_file_count(); + if index >= count || height == 0 { + return false; + } + if self.settings.continuous_scroll + && self + .workspace + .viewport_scrollbar_drag + .get(&self.store) + .is_some() + { + self.workspace + .pending_file_content_heights + .update(&self.store, |pending| { + pending.insert(index, height); + }); + return false; + } + if self.virtual_diff_document.len() != count { + self.recompute_file_scroll_total_height_px(); + } + + let old_slot_height = self.file_scroll_height_px(index); + let old_total = self.total_diff_height_px(); + let anchor = self + .settings + .continuous_scroll + .then(|| self.current_or_derived_viewport_anchor()) + .flatten(); + let row_count = self.workspace_file_row_count(index); + let mut recorded_changed = false; + self.workspace + .file_content_heights + .update(&self.store, |heights| { + if heights.len() < count { + heights.resize(count, None); + } + if heights[index] != Some(height) { + heights[index] = Some(height); + recorded_changed = true; + } + }); + + let mut calibration_initialized = false; + if let Some(rows) = row_count + && rows > 0 + { + let sample_q16 = (u64::from(height) << 16) / u64::from(rows); + let prev = self.workspace.measured_px_per_row_q16.get(&self.store); + let next = if prev == 0 { + calibration_initialized = true; + sample_q16 as u32 + } else { + (((u64::from(prev) * 7) + sample_q16) / 8) as u32 + }; + self.workspace + .measured_px_per_row_q16 + .set(&self.store, next); + } + + if calibration_initialized { + self.recompute_file_scroll_total_height_px(); + } + + if recorded_changed { + let new_slot_height = self.file_scroll_height_px(index); + let slot_height_changed = new_slot_height != old_slot_height; + if calibration_initialized { + self.workspace + .file_scroll_total_height_px + .set(&self.store, self.virtual_diff_document.total_u32()); + } else { + let next_total = old_total + .saturating_sub(old_slot_height) + .saturating_add(new_slot_height); + self.workspace + .file_scroll_total_height_px + .set(&self.store, next_total); + self.virtual_diff_document + .update_height(index, new_slot_height.max(1)); + } + + if self.settings.continuous_scroll + && slot_height_changed + && let Some(anchor) = anchor + { + self.rebase_viewport_anchor(anchor); + } + } + + recorded_changed && old_slot_height != self.file_scroll_height_px(index) + } + + pub fn update_virtual_diff_item_height_px( + &mut self, + item_id: VirtualDiffItemId, + height: u32, + ) -> bool { + if item_id.kind != VirtualDiffItemKind::File + || item_id.source != self.workspace.source.get(&self.store) + || item_id.generation != self.workspace_render_generation() + { + return false; + } + self.update_file_content_height_px(item_id.index, height) + } + + pub fn virtual_stream_item( + &self, + file_index: usize, + kind: VirtualDiffItemKind, + ordinal: u32, + stable_key: u64, + sort_key: u64, + measured_height_px: Option, + ) -> VirtualDiffStreamItem { + VirtualDiffStreamItem::new( + VirtualDiffItemId::new( + self.workspace.source.get(&self.store), + self.workspace_render_generation(), + kind, + file_index, + ordinal, + stable_key, + ), + sort_key, + measured_height_px.unwrap_or_else(|| estimated_virtual_item_height_px(kind)), + measured_height_px, + ) + } + + pub(super) fn virtual_stream_items_for_viewport_doc( + &self, + source: WorkspaceSource, + generation: u64, + slots: &[ViewportSlotKey], + doc: &RenderDoc, + ) -> Vec { + let mut items = Vec::new(); + let mut slot_pos = None::; + let mut local_ordinal = 0_u32; + + for (line_index, line) in doc.lines.iter().enumerate() { + if line.row_kind() == RenderRowKind::FileHeader { + slot_pos = Some(slot_pos.map_or(0, |pos| pos.saturating_add(1))); + local_ordinal = 0; + } + + let Some(slot) = slot_pos.and_then(|pos| slots.get(pos)) else { + continue; + }; + let Some(kind) = virtual_stream_item_kind(slot, line) else { + continue; + }; + let ordinal = match kind { + VirtualDiffItemKind::FileHeader => 0, + VirtualDiffItemKind::Hunk if line.hunk_index >= 0 => line.hunk_index as u32, + _ => local_ordinal, + }; + + items.push(VirtualDiffStreamItem::new( + VirtualDiffItemId::new( + source, + generation, + kind, + slot.index, + ordinal, + virtual_row_stable_key(line, ordinal), + ), + virtual_row_sort_key(line_index), + estimated_virtual_item_height_px(kind), + None, + )); + local_ordinal = local_ordinal.saturating_add(1); + } + + items + } + + pub(super) fn file_scroll_height_px(&self, index: usize) -> u32 { + self.workspace + .file_content_heights + .with(&self.store, |heights| heights.get(index).copied().flatten()) + .unwrap_or_else(|| self.estimated_file_height_px(index)) + } + + pub(super) fn viewport_file_scroll_height_px(&self, index: usize) -> u32 { + if let Some(height) = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| { + drag.as_ref() + .and_then(|drag| drag.file_heights_px.get(index).copied()) + }) + { + return height; + } + self.file_scroll_height_px(index) + } + + pub fn estimated_file_height_px(&self, index: usize) -> u32 { + const BASELINE_ROWS: u32 = 8; + let row_height_q16 = { + let cal = self.workspace.measured_px_per_row_q16.get(&self.store); + if cal == 0 { 24_u32 << 16 } else { cal } + }; + let row_height_px = + |rows: u32| ((u64::from(rows) * u64::from(row_height_q16)) >> 16) as u32; + + if matches!( + self.workspace.source.get(&self.store), + WorkspaceSource::Compare | WorkspaceSource::TextCompare + ) && let Some(rows) = self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .and_then(|output| output.carbon.files.get(index)) + .map(estimated_carbon_file_rows_with_overhead) + }) { + return row_height_px(rows); + } + + let line_count = match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + if index < self.workspace_file_count() { + let meta = self.file_list_entry_meta(index); + meta.additions.saturating_add(meta.deletions).max(1) as u32 + BASELINE_ROWS + } else { + BASELINE_ROWS + } + } + WorkspaceSource::Status => BASELINE_ROWS, + WorkspaceSource::None => BASELINE_ROWS, + }; + row_height_px(line_count) + } + + pub(super) fn workspace_file_row_count(&self, index: usize) -> Option { + if !matches!( + self.workspace.source.get(&self.store), + WorkspaceSource::Compare | WorkspaceSource::TextCompare + ) { + return None; + } + self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .and_then(|output| output.carbon.files.get(index)) + .map(estimated_carbon_file_rows_with_overhead) + }) + } + + pub fn total_diff_height_px(&self) -> u32 { + if let Some(total) = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| { + drag.as_ref().map(|drag| drag.metrics.content_height_px) + }) + { + return total; + } + let cached = self.workspace.file_scroll_total_height_px.get(&self.store); + if cached > 0 || self.workspace_file_count() == 0 { + return cached; + } + + self.virtual_diff_document.total_u32() + } + + pub fn file_start_offset_px(&self, index: usize) -> u32 { + if self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| drag.is_none()) + && self.virtual_diff_document.len() == self.workspace_file_count() + { + return self.virtual_diff_document.prefix_u32(index); + } + let mut total: u32 = 0; + for slot in 0..index.min(self.workspace_file_count()) { + total = total.saturating_add(self.viewport_file_scroll_height_px(slot)); + } + total + } + + pub fn global_max_scroll_top_px(&self) -> u32 { + if let Some(max) = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| { + drag.as_ref().map(|drag| drag.metrics.max_scroll_top_px) + }) + { + return max; + } + let viewport = self.editor.viewport_height_px.get(&self.store); + self.total_diff_height_px().saturating_sub(viewport.max(1)) + } + + pub(super) fn viewport_anchor_bias_for_global(&self, scroll_top_px: u32) -> ViewportAnchorBias { + let max = self.global_max_scroll_top_px(); + if max > 0 && scroll_top_px.saturating_add(CONTINUOUS_BOTTOM_ANCHOR_TOLERANCE_PX) >= max { + ViewportAnchorBias::FollowEnd + } else { + ViewportAnchorBias::PreserveTop + } + } + + pub(super) fn viewport_anchor_for_file_offset( + &self, + index: usize, + local_offset_px: u32, + bias: ViewportAnchorBias, + ) -> Option { + let item_id = self.virtual_diff_document.item_id(index)?; + Some(ViewportAnchor { + item_id, + intra_item_offset_px: local_offset_px, + bias, + }) + } + + pub(super) fn viewport_anchor_for_global( + &self, + scroll_top_px: u32, + bias: ViewportAnchorBias, + ) -> Option { + let target_px = match bias { + ViewportAnchorBias::PreserveBottom => { + scroll_top_px.saturating_add(self.editor.viewport_height_px.get(&self.store).max(1)) + } + ViewportAnchorBias::PreserveTop | ViewportAnchorBias::FollowEnd => scroll_top_px, + }; + let (index, local_offset_px) = self.locate_global_scroll_px(target_px)?; + self.viewport_anchor_for_file_offset(index, local_offset_px, bias) + } + + pub(super) fn current_or_derived_viewport_anchor(&self) -> Option { + if let Some(anchor) = self.virtual_scroll.anchor + && self.virtual_diff_document.anchor_is_current(anchor) + { + return Some(anchor); + } + let scroll_top_px = self.workspace.global_scroll_top_px.get(&self.store); + let bias = self.viewport_anchor_bias_for_global(scroll_top_px); + self.viewport_anchor_for_global(scroll_top_px, bias) + } + + pub(super) fn scroll_top_for_viewport_anchor(&self, anchor: ViewportAnchor) -> Option { + if !self.virtual_diff_document.anchor_is_current(anchor) { + return None; + } + if anchor.bias == ViewportAnchorBias::FollowEnd { + return Some(self.global_max_scroll_top_px()); + } + + let index = anchor.item_id.index; + let item_height = self + .viewport_file_scroll_height_px(index) + .max(self.virtual_diff_document.height_at(index)) + .max(1); + let local_offset = anchor + .intra_item_offset_px + .min(item_height.saturating_sub(1)); + let item_top = self.file_start_offset_px(index); + let target = match anchor.bias { + ViewportAnchorBias::PreserveTop => item_top.saturating_add(local_offset), + ViewportAnchorBias::PreserveBottom => item_top + .saturating_add(local_offset) + .saturating_sub(self.editor.viewport_height_px.get(&self.store).max(1)), + ViewportAnchorBias::FollowEnd => unreachable!(), + }; + Some(target.min(self.global_max_scroll_top_px())) + } + + pub(super) fn set_viewport_anchor(&mut self, anchor: ViewportAnchor) { + if let Some(scroll_top_px) = self.scroll_top_for_viewport_anchor(anchor) { + self.workspace + .global_scroll_top_px + .set(&self.store, scroll_top_px); + self.virtual_scroll.set_anchor(anchor); + } else { + self.virtual_scroll.clear(); + self.clamp_global_scroll_top_px(); + } + } + + pub(super) fn set_viewport_anchor_for_global( + &mut self, + scroll_top_px: u32, + bias: ViewportAnchorBias, + ) { + if let Some(anchor) = self.viewport_anchor_for_global(scroll_top_px, bias) { + self.set_viewport_anchor(anchor); + } else { + self.virtual_scroll.clear(); + self.workspace.global_scroll_top_px.set(&self.store, 0); + } + } + + pub(super) fn rebase_viewport_anchor(&mut self, anchor: ViewportAnchor) { + self.set_viewport_anchor(anchor); + } + + pub(super) fn clamp_global_scroll_top_px(&mut self) { + if let Some(anchor) = self.virtual_scroll.anchor + && let Some(scroll_top_px) = self.scroll_top_for_viewport_anchor(anchor) + { + self.workspace + .global_scroll_top_px + .set(&self.store, scroll_top_px); + return; + } + let max = self.global_max_scroll_top_px(); + let current = self.workspace.global_scroll_top_px.get(&self.store); + self.workspace + .global_scroll_top_px + .set(&self.store, current.min(max)); + } + + pub(super) fn locate_global_scroll_px(&self, target_px: u32) -> Option<(usize, u32)> { + let count = self.workspace_file_count(); + if count == 0 { + return None; + } + if self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| drag.is_none()) + && self.virtual_diff_document.len() == count + { + return self.virtual_diff_document.locate(target_px); + } + let mut prior: u32 = 0; + for index in 0..count { + let height = self.viewport_file_scroll_height_px(index).max(1); + let next_prior = prior.saturating_add(height); + if target_px < next_prior || index + 1 == count { + return Some((index, target_px.saturating_sub(prior))); + } + prior = next_prior; + } + Some((count - 1, 0)) + } + + pub(super) fn scroll_viewport_to_global(&mut self, target_px: u32) -> Vec { + if self.virtual_diff_document.len() != self.workspace_file_count() { + self.recompute_file_scroll_total_height_px(); + } + let target_px = target_px.min(self.global_max_scroll_top_px()); + let bias = self.viewport_anchor_bias_for_global(target_px); + self.set_viewport_anchor_for_global(target_px, bias); + let target_px = self.workspace.global_scroll_top_px.get(&self.store); + let Some((target_index, local_offset)) = self.locate_global_scroll_px(target_px) else { + self.workspace.global_scroll_top_px.set(&self.store, 0); + self.virtual_scroll.clear(); + return Vec::new(); + }; + self.workspace + .global_scroll_top_px + .set(&self.store, target_px); + self.workspace + .viewport_scrollbar_drag + .update(&self.store, |drag| { + if let Some(drag) = drag.as_mut() { + drag.metrics.scroll_top_px = target_px.min(drag.metrics.max_scroll_top_px); + } + }); + + let dragging_scrollbar = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| drag.is_some()); + let mut effects = if dragging_scrollbar { + Vec::new() + } else if self.active_file_matches_workspace_file(target_index) { + Vec::new() + } else { + self.select_file_inner(target_index, true) + }; + + let local_max = self.editor_max_scroll_top_px(); + self.editor + .scroll_top_px + .set(&self.store, local_offset.min(local_max)); + if !dragging_scrollbar { + effects.extend(self.request_active_file_syntax_effect()); + } + effects + } + + pub fn global_scroll_position_px(&self) -> u32 { + self.workspace.global_scroll_top_px.get(&self.store) + } + + pub fn continuous_viewport_scrollbar_metrics(&self) -> ViewportScrollbarMetrics { + if let Some(metrics) = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| drag.as_ref().map(|drag| drag.metrics)) + { + return metrics; + } + let viewport_height_px = self.editor.viewport_height_px.get(&self.store); + let content_height_px = self.total_diff_height_px(); + ViewportScrollbarMetrics { + content_height_px, + viewport_height_px, + scroll_top_px: self.global_scroll_position_px(), + max_scroll_top_px: content_height_px.saturating_sub(viewport_height_px.max(1)), + } + } + + pub fn begin_viewport_scrollbar_drag( + &mut self, + content_height_px: u32, + viewport_height_px: u32, + scroll_top_px: u32, + max_scroll_top_px: u32, + ) { + if !self.settings.continuous_scroll { + return; + } + let file_heights_px = (0..self.workspace_file_count()) + .map(|index| self.file_scroll_height_px(index).max(1)) + .collect(); + self.workspace.viewport_scrollbar_drag.set( + &self.store, + Some(ViewportScrollbarDragState { + metrics: ViewportScrollbarMetrics { + content_height_px, + viewport_height_px, + scroll_top_px: scroll_top_px.min(max_scroll_top_px), + max_scroll_top_px, + }, + file_heights_px, + }), + ); + } + + pub fn end_viewport_scrollbar_drag(&mut self) { + self.workspace + .viewport_scrollbar_drag + .set(&self.store, None); + self.apply_pending_file_scroll_updates(); + } + + pub(super) fn apply_pending_file_scroll_updates(&mut self) { + let pending_heights = self + .workspace + .pending_file_content_heights + .with(&self.store, |pending| pending.clone()); + self.workspace + .pending_file_content_heights + .set(&self.store, HashMap::new()); + for (index, height) in pending_heights { + self.update_file_content_height_px(index, height); + } + if self + .workspace + .file_scroll_recompute_pending + .get(&self.store) + { + self.workspace + .file_scroll_recompute_pending + .set(&self.store, false); + self.recompute_file_scroll_total_height_px(); + self.clamp_global_scroll_top_px(); + } + } + + pub fn sync_editor_scroll_from_global(&mut self) -> Vec { + if !self.settings.continuous_scroll { + return Vec::new(); + } + self.clamp_global_scroll_top_px(); + let target = self.workspace.global_scroll_top_px.get(&self.store); + let Some((_, local_offset)) = self.locate_global_scroll_px(target) else { + self.workspace.global_scroll_top_px.set(&self.store, 0); + self.virtual_scroll.clear(); + return Vec::new(); + }; + let max = self.editor_max_scroll_top_px(); + self.editor + .scroll_top_px + .set(&self.store, local_offset.min(max)); + Vec::new() + } + + pub fn sync_global_scroll_from_editor(&mut self) { + let Some(selected_index) = self.reconcile_selected_file_index_from_path() else { + self.workspace.global_scroll_top_px.set(&self.store, 0); + self.virtual_scroll.clear(); + return; + }; + let start = self.file_start_offset_px(selected_index); + let local = self.editor.scroll_top_px.get(&self.store); + let target = start + .saturating_add(local) + .min(self.global_max_scroll_top_px()); + self.workspace.global_scroll_top_px.set(&self.store, target); + if self.settings.continuous_scroll { + if let Some(anchor) = self.viewport_anchor_for_file_offset( + selected_index, + local, + self.viewport_anchor_bias_for_global(target), + ) { + self.virtual_scroll.set_anchor(anchor); + } else { + self.virtual_scroll.clear(); + } + } + } + + pub fn build_continuous_viewport_document( + &mut self, + ) -> (Option, Vec) { + if !self.settings.continuous_scroll { + return (None, Vec::new()); + } + if self.virtual_diff_document.len() != self.workspace_file_count() { + self.recompute_file_scroll_total_height_px(); + } + self.clamp_global_scroll_top_px(); + let scroll_top_px = self.workspace.global_scroll_top_px.get(&self.store); + let scroll_direction = match self.last_virtual_scroll_top_px { + Some(previous) if scroll_top_px < previous => ScrollDirection::Backward, + _ => ScrollDirection::Forward, + }; + self.last_virtual_scroll_top_px = Some(scroll_top_px); + let Some((anchor_index, _)) = self.locate_global_scroll_px(scroll_top_px) else { + return (None, Vec::new()); + }; + + let source = self.workspace.source.get(&self.store); + if source == WorkspaceSource::None { + return (None, Vec::new()); + } + let dragging_scrollbar = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| drag.is_some()); + + let count = self.workspace_file_count(); + let viewport = self.editor.viewport_height_px.get(&self.store).max(1); + let follow_end = self.virtual_scroll.anchor.is_some_and(|anchor| { + anchor.bias == ViewportAnchorBias::FollowEnd + && self.virtual_diff_document.anchor_is_current(anchor) + }) || self.viewport_anchor_bias_for_global(scroll_top_px) + == ViewportAnchorBias::FollowEnd; + let (start_index, start_offset, local_top, target_height) = if follow_end { + let mut start_index = count.saturating_sub(1); + let mut tail_height = self.viewport_file_scroll_height_px(start_index).max(1); + let target_tail_height = viewport.saturating_mul(2).max(viewport); + while start_index > 0 && tail_height < target_tail_height { + start_index -= 1; + tail_height = tail_height + .saturating_add(self.viewport_file_scroll_height_px(start_index).max(1)); + } + ( + start_index, + self.file_start_offset_px(start_index), + tail_height.saturating_sub(viewport), + tail_height.max(1), + ) + } else { + let mut start_index = anchor_index; + let mut before_viewport_px = 0_u32; + while start_index > 0 && before_viewport_px < viewport { + start_index -= 1; + before_viewport_px = before_viewport_px + .saturating_add(self.viewport_file_scroll_height_px(start_index).max(1)); + } + let start_offset = self.file_start_offset_px(start_index); + let local_top = self + .workspace + .global_scroll_top_px + .get(&self.store) + .saturating_sub(start_offset); + let target_height = local_top + .saturating_add(viewport) + .saturating_add(viewport / 2) + .max(1); + (start_index, start_offset, local_top, target_height) + }; + + let mut effects = Vec::new(); + let mut slot_keys = Vec::new(); + let mut slot_loading = Vec::new(); + let mut accumulated = 0_u32; + let mut index = start_index; + while index < count && (slot_keys.is_empty() || accumulated < target_height) { + let path = self + .workspace_file_path_at(index) + .unwrap_or_else(|| format!("File {}", index + 1)); + let slot_key = match source { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + effects.extend(self.ensure_compare_file_cached_for_viewport( + index, + &path, + CompareWorkPriority::VisibleViewportDiff, + )); + self.compare_slot_key_at(index, &path) + } + WorkspaceSource::Status => { + effects.extend(self.ensure_status_file_cached_for_viewport(index)); + let file_change = self + .workspace + .status_file_changes + .with(&self.store, |changes| changes.get(index).cloned()); + file_change.as_ref().map_or_else( + || { + self.loading_slot_key( + WorkspaceSource::Status, + index, + &path, + String::new(), + String::new(), + ) + }, + |change| self.status_slot_key_at(index, change), + ) + } + WorkspaceSource::None => self.loading_slot_key( + WorkspaceSource::None, + index, + &path, + String::new(), + String::new(), + ), + }; + let slot_height = self.viewport_file_scroll_height_px(index).max(1); + if let Some(window) = self.viewport_slot_syntax_window( + &slot_key, + accumulated, + slot_height, + local_top, + viewport, + ) { + effects.extend(self.request_viewport_slot_syntax_window(&slot_key, window)); + } + let slot_is_loading = matches!(&slot_key.kind, ViewportSlotKind::Loading); + if !slot_is_loading { + self.touch_viewport_slot(&slot_key); + } + slot_loading.push(slot_is_loading); + slot_keys.push(slot_key); + accumulated = accumulated.saturating_add(slot_height); + index += 1; + } + let render_end_index = index; + self.protect_working_set_slots(&slot_keys); + self.trim_file_working_set(); + effects.extend(self.prefetch_compare_working_set( + start_index, + render_end_index, + scroll_direction, + viewport, + )); + + let key = ViewportDocumentKey { + source, + generation: self.workspace_render_generation(), + start_index, + slots: slot_keys, + }; + let doc = if let Some(cache) = self.viewport_document_cache.as_ref() + && cache.key == key + { + cache.doc.clone() + } else { + let mut doc = RenderDoc::default(); + let loading_message = if dragging_scrollbar { + "" + } else { + "Loading diff..." + }; + for slot in &key.slots { + self.append_viewport_slot_doc(&mut doc, slot, loading_message); + } + let doc = Arc::new(doc); + self.viewport_document_cache = Some(ViewportDocumentCache { + key: key.clone(), + doc: doc.clone(), + }); + doc + }; + let slot_indices = key.slots.iter().map(|slot| slot.index).collect(); + let slot_item_ids = key + .slots + .iter() + .map(|slot| { + self.virtual_diff_document + .item_id(slot.index) + .unwrap_or_else(|| { + VirtualDiffItemId::file( + source, + self.workspace_render_generation(), + slot.index, + ) + }) + }) + .collect(); + let stream_items = self.virtual_stream_items_for_viewport_doc( + source, + self.workspace_render_generation(), + &key.slots, + doc.as_ref(), + ); + + ( + Some(ViewportDocument { + doc, + mode: ViewportDocumentMode::Continuous, + generation: self.workspace_render_generation(), + start_index, + start_offset_px: start_offset, + scroll_top_px: local_top, + slot_indices, + slot_item_ids, + stream_items, + slot_loading, + path: String::new(), + }), + effects, + ) + } + + pub(super) fn scroll_viewport_pages(&mut self, delta_pages: i32) -> Vec { + let viewport = self.editor.viewport_height_px.get(&self.store); + let page_px = ((viewport as f32) * 0.85).round().max(1.0) as i32; + let delta_px = delta_pages.saturating_mul(page_px); + if self.settings.continuous_scroll { + return self.scroll_viewport_px(delta_px); + } + let current = self.editor.scroll_top_px.get(&self.store); + let max = self.editor_max_scroll_top_px(); + let next = apply_scroll_delta_px(current, delta_px, max); + self.editor.scroll_top_px.set(&self.store, next); + Vec::new() + } + + pub(super) fn scroll_viewport_half_page(&mut self, direction: i32) -> Vec { + let viewport = self.editor.viewport_height_px.get(&self.store); + let half_px = ((viewport as f32) * 0.5).round().max(1.0) as i32; + let delta_px = direction.saturating_mul(half_px); + if self.settings.continuous_scroll { + return self.scroll_viewport_px(delta_px); + } + let current = self.editor.scroll_top_px.get(&self.store); + let max = self.editor_max_scroll_top_px(); + let next = apply_scroll_delta_px(current, delta_px, max); + self.editor.scroll_top_px.set(&self.store, next); + Vec::new() + } +} diff --git a/src/ui/state/repository.rs b/src/ui/state/repository.rs index a10856eb..5d958798 100644 --- a/src/ui/state/repository.rs +++ b/src/ui/state/repository.rs @@ -410,3 +410,1086 @@ impl AppState { ] } } + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct RepositoryState { + pub status: AsyncStatus, + pub location: Option, + pub capabilities: Option, + pub refs: Vec, + pub changes: Vec, + pub operation_log: Vec, + pub file_changes: Vec, + pub publish_plan: Option, +} + +pub(super) fn active_publish_ref(refs: &[VcsRef]) -> Option { + refs.iter() + .find(|reference| { + reference.active && matches!(reference.kind, RefKind::Branch | RefKind::Bookmark) + }) + .cloned() +} + +pub(super) fn upstream_pair(upstream: &str) -> Option<(String, String)> { + upstream + .split_once('/') + .map(|(remote, branch)| (remote.to_owned(), branch.to_owned())) +} + +pub(super) fn remote_names_from_refs(refs: &[VcsRef]) -> std::collections::BTreeSet { + let mut remotes = std::collections::BTreeSet::new(); + for reference in refs { + if let Some((remote, _)) = reference + .upstream + .as_deref() + .and_then(|upstream| upstream.split_once('/')) + { + remotes.insert(remote.to_owned()); + } + if matches!( + reference.kind, + RefKind::RemoteBranch | RefKind::RemoteBookmark + ) && let Some((remote, _)) = reference.name.split_once('/') + { + remotes.insert(remote.to_owned()); + } + } + remotes +} + +impl AppState { + pub(super) fn status_refs_for_bucket(&self, bucket: ChangeBucket) -> (String, String) { + self.vcs_ui_profile().status_compare_refs(bucket) + } + + pub(super) fn vcs_ui_profile(&self) -> crate::ui::vcs::VcsUiProfile { + self.repository.location.with(&self.store, |location| { + crate::ui::vcs::profile(location.as_ref()) + }) + } +} + +impl AppState { + pub(super) fn open_repository(&mut self, path: PathBuf) -> Vec { + let path = normalize_repository_open_path(path); + self.workspace_mode.set(&self.store, WorkspaceMode::Loading); + self.compare.repo_path.set(&self.store, Some(path.clone())); + self.compare.left_ref.set(&self.store, String::new()); + self.compare.right_ref.set(&self.store, String::new()); + self.compare.mode.set(&self.store, CompareMode::default()); + self.compare.resolved_left.set(&self.store, None); + self.compare.resolved_right.set(&self.store, None); + self.repository + .status + .set(&self.store, AsyncStatus::Loading); + self.repository.location.set(&self.store, None); + self.repository.capabilities.set(&self.store, None); + self.repository.refs.set(&self.store, Vec::new()); + self.repository.changes.set(&self.store, Vec::new()); + self.repository.operation_log.set(&self.store, Vec::new()); + self.repository.file_changes.set(&self.store, Vec::new()); + self.repository.publish_plan.set(&self.store, None); + self.workspace_clear_compare(); + self.reset_file_list(); + self.editor_clear_document(); + self.editor.focused.set(&self.store, false); + self.last_error.set(&self.store, None); + self.github.pull_request.cache.update(&self.store, |c| { + c.clear(); + }); + self.github + .pull_request + .pending_confirm + .set(&self.store, None); + self.clear_overlays(); + self.focus.set(&self.store, Some(FocusTarget::TitleBar)); + self.sync_settings_snapshot(); + + // Seed the progress panel with a repo-open subject. We piggy-back + // on `compare_generation` as the loading generation — any in-flight + // compare is invalidated when the user opens a new repo anyway, + // and `handle_compare_progress_update` just matches on whatever + // generation the panel records. + let next_gen = self + .workspace + .compare_generation + .get(&self.store) + .saturating_add(1); + self.workspace.compare_generation.set(&self.store, next_gen); + let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); + let repo_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("repository") + .to_owned(); + // Always delay the panel reveal — a tiny repo that opens in under + // the threshold should finish without ever flashing a loading UI. + // Unlike re-compare (which can preserve the old diff during the + // grace window), repo-open has nothing to fall back to visually; + // the empty background / previous workspace is what the user sees + // for 500ms, which is a cheap price for zero flash on fast ops. + let started_at_ms = self.clock_ms; + let reveal_at_ms = started_at_ms.saturating_add(COMPARE_REVEAL_DELAY_MS); + self.compare_progress.set( + &self.store, + Some(Arc::new(CompareProgress { + generation: next_gen, + phase: ComparePhase::OpeningRepo, + subject: LoadingSubject::RepoOpen { name: repo_name }, + started_at_ms, + reveal_at_ms, + file_count_total: None, + files_loaded: 0, + })), + ); + + vec![ + syntax_epoch_effect, + SettingsEffect::SaveSettings(self.settings.clone()).into(), + RepositoryEffect::SyncRepository { + path: path.clone(), + reason: RepositorySyncReason::Open, + reporter_generation: Some(next_gen), + } + .into(), + RepositoryEffect::WatchRepository { path: Some(path) }.into(), + ] + } + + pub(super) fn handle_repository_snapshot( + &mut self, + payload: RepositorySnapshot, + ) -> Vec { + tracing::debug!( + path = %payload.path.display(), + reason = ?payload.reason, + change_kind = ?payload.change_kind, + pending = self.workspace.status_operation_pending.get(&self.store), + status_gen = self.workspace.status_generation.get(&self.store), + "handle_repository_snapshot: entered" + ); + if self + .compare + .repo_path + .with(&self.store, |p| p.as_ref() != Some(&payload.path)) + { + tracing::warn!("handle_repository_snapshot: path mismatch, ignored"); + return Vec::new(); + } + + self.repository.status.set(&self.store, AsyncStatus::Ready); + self.repository + .location + .set(&self.store, Some(payload.location.clone())); + self.repository + .capabilities + .set(&self.store, Some(payload.capabilities)); + let file_changes = payload.file_changes; + self.repository.refs.set(&self.store, payload.refs); + self.repository.changes.set(&self.store, payload.changes); + self.repository + .operation_log + .set(&self.store, payload.operation_log); + self.repository + .file_changes + .set(&self.store, file_changes.clone()); + self.repository.publish_plan.set(&self.store, None); + self.workspace + .status_file_changes + .set(&self.store, file_changes); + + // Tear down a repo-open progress panel. Compare-subject progress + // survives — a kickoff_compare may be queued below and will + // replace it atomically via its own seeding path. + self.compare_progress.update(&self.store, |slot| { + if let Some(p) = slot.as_ref() + && matches!(p.subject, LoadingSubject::RepoOpen { .. }) + { + *slot = None; + } + }); + + match payload.reason { + RepositorySyncReason::Open => { + if let Some(ref store) = self.frecency { + store.record_access(&format!("repo:{}", payload.path.display())); + } + let mut effects = self.persist_settings_effect(); + if let Some(url) = self.startup.pending_pr_url.clone() { + self.startup.pending_pr_url = None; + self.startup.auto_compare_pending = false; + self.github + .pull_request + .status + .set(&self.store, AsyncStatus::Loading); + if let Some(parsed) = crate::core::forge::github::parse_pr_url(&url) { + let key: PrKey = (parsed.owner, parsed.repo, parsed.number); + self.github.pull_request.cache.update(&self.store, |c| { + c.entry(key.clone()).or_insert_with(|| PrCacheEntry { + meta: PrPeekMeta::Loading, + diff: PrPeekDiff::Loading, + last_peek_ms: 0, + }); + }); + self.github + .pull_request + .pending_confirm + .set(&self.store, Some(key)); + } + effects.push( + GitHubEffect::LoadPullRequest { + url, + repo_path: payload.path, + github_token: self.github_access_token.clone(), + } + .into(), + ); + } else if self.startup.auto_compare_pending { + self.startup.auto_compare_pending = false; + effects.extend(self.kickoff_compare()); + } else if self.startup.bootstrap_compare_started { + self.startup.bootstrap_compare_started = false; + } else if let Some(persisted) = self.settings.last_compare.as_ref().filter(|c| { + c.repo_path.as_ref() == Some(&payload.path) + && compare_refs_are_valid(c.mode, &c.left_ref, &c.right_ref) + }) { + self.compare + .left_ref + .set(&self.store, persisted.left_ref.clone()); + self.compare + .right_ref + .set(&self.store, persisted.right_ref.clone()); + self.compare.mode.set(&self.store, persisted.mode); + effects.extend(self.kickoff_compare()); + } else { + let profile = crate::ui::vcs::profile(Some(&payload.location)); + let (left, right, mode) = profile.default_compare(); + self.compare.left_ref.set(&self.store, left.to_owned()); + self.compare.right_ref.set(&self.store, right.to_owned()); + self.compare.mode.set(&self.store, mode); + effects.extend(self.activate_status_view(true)); + } + effects + } + RepositorySyncReason::Dirty | RepositorySyncReason::Rescan => { + if self.workspace.source.get(&self.store) == WorkspaceSource::Status { + return self.activate_status_view(false); + } + + let (mode, left_ref, right_ref) = ( + self.compare.mode.get(&self.store), + self.compare.left_ref.get(&self.store), + self.compare.right_ref.get(&self.store), + ); + if !compare_refs_are_valid(mode, &left_ref, &right_ref) { + return Vec::new(); + } + + match payload.change_kind { + Some(RepositoryChangeKind::Metadata | RepositoryChangeKind::Both) => { + self.kickoff_compare() + } + Some(RepositoryChangeKind::Worktree) + if self.vcs_ui_profile().is_working_copy_ref(&right_ref) => + { + self.kickoff_compare() + } + _ => Vec::new(), + } + } + } + } + + pub(super) fn handle_status_diff_finished( + &mut self, + payload: StatusDiffFinished, + ) -> Vec { + let current_gen = self.workspace.status_generation.get(&self.store); + tracing::debug!( + payload_gen = payload.generation, + current_gen, + payload_index = payload.index, + payload_path = %payload.file_change.path, + payload_bucket = ?payload.file_change.bucket, + "handle_status_diff_finished: entered" + ); + if payload.generation != current_gen { + tracing::debug!( + "handle_status_diff_finished: generation mismatch, discarding (pending NOT cleared)" + ); + return Vec::new(); + } + let matches = self.repository.file_changes.with(&self.store, |changes| { + match changes.get(payload.index) { + Some(current) => current == &payload.file_change, + None => false, + } + }); + if !matches { + let current_change_at_idx = self.repository.file_changes.with(&self.store, |changes| { + changes + .get(payload.index) + .map(|change| format!("{}:{:?}", change.path, change.bucket)) + .unwrap_or_else(|| "".to_owned()) + }); + tracing::debug!( + current_change_at_idx, + "handle_status_diff_finished: file change mismatch, discarding (pending NOT cleared)" + ); + return Vec::new(); + } + let matches_selection = self.workspace.selected_file_index.get(&self.store) + == Some(payload.index) + && self + .workspace + .selected_file_path + .get(&self.store) + .as_deref() + == Some(payload.file_change.path.as_str()) + && self.workspace.selected_change_bucket.get(&self.store) + == Some(payload.file_change.bucket); + let output = payload.output; + + let Some(carbon_file) = output.carbon.files.first() else { + self.clear_file_cache_loading(payload.index); + if matches_selection { + self.workspace.active_file.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + self.editor_clear_document(); + } + return Vec::new(); + }; + let prepared = prepare_active_file(payload.index, carbon_file); + let bucket = payload.file_change.bucket; + let (left_ref, right_ref) = self.status_refs_for_bucket(bucket); + let active_file = self.build_active_file( + payload.index, + payload.file_change.path.clone(), + prepared, + left_ref, + right_ref, + ); + let active_file = self.cache_active_file(active_file); + if !matches_selection { + return Vec::new(); + } + + tracing::debug!("handle_status_diff_finished: clearing status_operation_pending"); + self.workspace + .source + .set(&self.store, WorkspaceSource::Status); + self.workspace + .status_operation_pending + .set(&self.store, false); + self.workspace.status.set(&self.store, AsyncStatus::Ready); + self.workspace_mode.set(&self.store, WorkspaceMode::Ready); + self.workspace + .used_fallback + .set(&self.store, output.used_fallback); + self.workspace + .fallback_message + .set(&self.store, output.fallback_message.clone()); + self.workspace + .raw_diff_len + .set(&self.store, output.raw_diff_len); + self.workspace.compare_output.set(&self.store, None); + self.workspace.compare_total_stats.set(&self.store, None); + self.workspace.compare_hydrated_stats.set(&self.store, None); + self.workspace + .compare_deferred_stats_remaining + .set(&self.store, None); + self.workspace + .compare_deferred_stats_cursor + .set(&self.store, 0); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); + self.workspace.active_file_loading.set(&self.store, None); + + self.workspace + .selected_file_index + .set(&self.store, Some(payload.index)); + self.workspace + .selected_file_path + .set(&self.store, Some(payload.file_change.path.clone())); + self.workspace + .selected_change_bucket + .set(&self.store, Some(bucket)); + // Preserve scroll/hover/positional editor state when refreshing the + // same file (e.g. after staging a hunk). Only reset when the path + // changed (navigating to a different file). + let same_file = self.workspace.active_file.with(&self.store, |af| { + af.as_ref().is_some_and(|a| { + a.path == payload.file_change.path + && a.left_ref == active_file.left_ref + && a.right_ref == active_file.right_ref + }) + }); + self.workspace + .active_file + .set(&self.store, Some(active_file)); + if !same_file { + self.editor_clear_document(); + self.editor + .line_selection + .update(&self.store, |ls| ls.clear()); + } + if self.editor.search.open.get(&self.store) { + self.recompute_search_matches(); + } + let mut effects = self.sync_editor_scroll_from_global(); + effects.extend(self.request_active_file_syntax_effect()); + effects + } + + pub(super) fn activate_status_view(&mut self, reset_scroll: bool) -> Vec { + tracing::debug!( + reset_scroll, + pending = self.workspace.status_operation_pending.get(&self.store), + status_gen = self.workspace.status_generation.get(&self.store), + status_file_changes_count = self + .workspace + .status_file_changes + .with(&self.store, |i| i.len()), + "activate_status_view: entered" + ); + self.workspace + .source + .set(&self.store, WorkspaceSource::Status); + self.workspace.status.set(&self.store, AsyncStatus::Ready); + self.workspace_mode.set(&self.store, WorkspaceMode::Ready); + self.workspace.compare_output.set(&self.store, None); + self.workspace.compare_total_stats.set(&self.store, None); + self.workspace.compare_hydrated_stats.set(&self.store, None); + self.workspace + .compare_deferred_stats_remaining + .set(&self.store, None); + self.workspace + .compare_deferred_stats_cursor + .set(&self.store, 0); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); + self.workspace.active_file_loading.set(&self.store, None); + let new_files = self + .workspace + .status_file_changes + .with(&self.store, |changes| build_status_file_entries(changes)); + self.workspace.files.set(&self.store, new_files); + let next_status_gen = self + .workspace + .status_generation + .get(&self.store) + .saturating_add(1); + self.workspace + .status_generation + .set(&self.store, next_status_gen); + let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); + self.clear_file_cache(); + self.workspace.sidebar_auto_width.set(&self.store, None); + self.workspace.used_fallback.set(&self.store, false); + self.workspace + .fallback_message + .set(&self.store, String::new()); + self.workspace.raw_diff_len.set(&self.store, 0); + self.reset_file_scroll_layout(); + if reset_scroll { + self.file_list.scroll_offset_px.set(&self.store, 0.0); + self.workspace.global_scroll_top_px.set(&self.store, 0); + } else if self.settings.continuous_scroll { + self.clamp_global_scroll_top_px(); + } + + let current_path = self.workspace.selected_file_path.get(&self.store); + let current_bucket = self.workspace.selected_change_bucket.get(&self.store); + let (status_syntax_paths, selected_index, selected_syntax_paths) = self + .workspace + .status_file_changes + .with(&self.store, |changes| { + let paths = changes + .iter() + .flat_map(file_change_syntax_paths) + .collect::>(); + let selected_index = + if let Some((path, bucket)) = current_path.clone().zip(current_bucket) { + if let Some(idx) = changes + .iter() + .position(|change| change.path == path && change.bucket == bucket) + { + Some(idx) + } else { + None + } + } else if let Some(path) = current_path.as_deref() { + if let Some(idx) = changes.iter().position(|change| change.path == path) { + Some(idx) + } else { + None + } + } else { + None + } + .or_else(|| (!changes.is_empty()).then_some(0)); + let selected_paths = selected_index + .and_then(|index| changes.get(index)) + .map(file_change_syntax_paths) + .unwrap_or_default(); + (paths, selected_index, selected_paths) + }); + + tracing::debug!( + ?selected_index, + "activate_status_view: resolved selected_index" + ); + match selected_index { + Some(index) => { + let mut effects = self.select_status_item(index, false); + effects.insert(0, syntax_epoch_effect); + if let Some(effect) = self.syntax_pack_warmup_effect_for_paths( + &status_syntax_paths, + &selected_syntax_paths, + ) { + effects.insert(0, effect); + } + effects + } + None => { + tracing::debug!("activate_status_view: no selection, clearing pending"); + self.workspace + .status_operation_pending + .set(&self.store, false); + self.workspace.selected_file_index.set(&self.store, None); + self.workspace.selected_file_path.set(&self.store, None); + self.workspace.selected_change_bucket.set(&self.store, None); + self.workspace.active_file.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + self.editor_clear_document(); + vec![syntax_epoch_effect] + } + } + } + + pub(super) fn show_working_tree(&mut self) -> Vec { + let (left, right, mode) = self.vcs_ui_profile().working_copy_compare(); + self.compare.left_ref.set(&self.store, left.to_owned()); + self.compare.right_ref.set(&self.store, right.to_owned()); + self.compare.mode.set(&self.store, mode); + let mut effects = self.persist_settings_effect(); + effects.extend(self.activate_status_view(true)); + effects + } + + pub(super) fn select_status_item(&mut self, index: usize, reveal: bool) -> Vec { + let Some(file_change) = self + .workspace + .status_file_changes + .with(&self.store, |changes| changes.get(index).cloned()) + else { + tracing::warn!( + index, + "select_status_item: index out of range, returning empty" + ); + return Vec::new(); + }; + tracing::debug!( + index, + path = %file_change.path, + bucket = ?file_change.bucket, + status_gen = self.workspace.status_generation.get(&self.store), + "select_status_item: dispatching LoadStatusDiff" + ); + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + tracing::warn!("select_status_item: no repo_path"); + return Vec::new(); + }; + + self.workspace + .source + .set(&self.store, WorkspaceSource::Status); + // Keep the current document visible while the new diff loads — no + // Loading state, no tear-down. handle_status_diff_finished swaps the + // ActiveFile atomically when the fresh diff arrives. + self.workspace + .selected_file_index + .set(&self.store, Some(index)); + self.workspace + .selected_file_path + .set(&self.store, Some(file_change.path.clone())); + self.workspace + .selected_change_bucket + .set(&self.store, Some(file_change.bucket)); + let (left_ref, right_ref) = self.status_refs_for_bucket(file_change.bucket); + let active_matches_selection = self.workspace.active_file.with(&self.store, |af| { + af.as_ref().is_some_and(|active| { + active.index == index + && active.path == file_change.path + && active.left_ref == left_ref + && active.right_ref == right_ref + }) + }); + if active_matches_selection { + self.workspace.active_file_loading.set(&self.store, None); + self.clear_file_cache_loading(index); + self.file_list.hovered_index.set(&self.store, Some(index)); + if reveal { + self.reveal_file_list_row(index); + } + let mut effects = self.sync_editor_scroll_from_global(); + effects.push(ensure_syntax_packs_for_file_change_effect(&file_change)); + effects.extend(self.request_active_file_syntax_effect()); + return effects; + } else if let Some(mut active_file) = self.cached_status_file_at(index, &file_change) { + active_file.last_used_tick = self.next_file_working_set_tick(); + self.workspace.active_file_loading.set(&self.store, None); + self.workspace + .active_file + .set(&self.store, Some(active_file.clone())); + self.cache_active_file(active_file); + self.editor_clear_document(); + self.file_list.hovered_index.set(&self.store, Some(index)); + if reveal { + self.reveal_file_list_row(index); + } + let mut effects = self.sync_editor_scroll_from_global(); + effects.push(ensure_syntax_packs_for_file_change_effect(&file_change)); + effects.extend(self.request_active_file_syntax_effect()); + return effects; + } else { + let should_load = self.should_enqueue_file_load( + index, + &file_change.path, + CompareWorkPriority::InteractiveSelectedFile, + ); + self.workspace.active_file_loading.set( + &self.store, + Some(ActiveFileLoading { + index, + path: file_change.path.clone(), + priority: CompareWorkPriority::InteractiveSelectedFile, + }), + ); + self.mark_file_cache_loading( + index, + file_change.path.clone(), + CompareWorkPriority::InteractiveSelectedFile, + ); + self.file_list.hovered_index.set(&self.store, Some(index)); + if reveal { + self.reveal_file_list_row(index); + } + + let mut effects = vec![ensure_syntax_packs_for_file_change_effect(&file_change)]; + if should_load { + let generation = self.workspace.status_generation.get(&self.store); + let renderer = self.compare.renderer.get(&self.store); + effects.push( + RepositoryEffect::LoadStatusDiff { + task: Task { + generation, + request: StatusDiffRequest { + repo_path, + file_change, + renderer, + }, + }, + index, + } + .into(), + ); + } + return effects; + } + } + + pub(super) fn apply_selected_status_operation( + &mut self, + operation: FileOperation, + ) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.staging_area) + }) + { + self.push_error("This repository backend does not support staging operations."); + return Vec::new(); + } + if self.workspace.source.get(&self.store) != WorkspaceSource::Status { + return Vec::new(); + } + if self.workspace.status_operation_pending.get(&self.store) { + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + let Some(index) = self.workspace.selected_file_index.get(&self.store) else { + return Vec::new(); + }; + let Some(file_change) = self + .workspace + .status_file_changes + .with(&self.store, |changes| changes.get(index).cloned()) + else { + return Vec::new(); + }; + + self.workspace + .status_operation_pending + .set(&self.store, true); + vec![ + RepositoryEffect::ApplyFileOperation(FileOperationRequest { + repo_path, + file_change, + operation, + }) + .into(), + ] + } + + pub(super) fn apply_file_status_operation( + &mut self, + index: usize, + operation: FileOperation, + ) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.staging_area) + }) + { + self.push_error("This repository backend does not support staging operations."); + return Vec::new(); + } + if self.workspace.source.get(&self.store) != WorkspaceSource::Status { + return Vec::new(); + } + if self.workspace.status_operation_pending.get(&self.store) { + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + let Some(file_change) = self + .workspace + .status_file_changes + .with(&self.store, |changes| changes.get(index).cloned()) + else { + return Vec::new(); + }; + + self.workspace + .status_operation_pending + .set(&self.store, true); + vec![ + RepositoryEffect::ApplyFileOperation(FileOperationRequest { + repo_path, + file_change, + operation, + }) + .into(), + ] + } + + pub(super) fn apply_batch_scope_operation( + &mut self, + buckets: &[ChangeBucket], + operation: FileOperation, + ) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.staging_area) + }) + { + self.push_error("This repository backend does not support staging operations."); + return Vec::new(); + } + if self.workspace.source.get(&self.store) != WorkspaceSource::Status { + return Vec::new(); + } + if self.workspace.status_operation_pending.get(&self.store) { + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + let file_changes: Vec = + self.workspace + .status_file_changes + .with(&self.store, |changes| { + changes + .iter() + .filter(|change| buckets.contains(&change.bucket)) + .cloned() + .collect() + }); + if file_changes.is_empty() { + return Vec::new(); + } + + self.workspace + .status_operation_pending + .set(&self.store, true); + vec![ + RepositoryEffect::ApplyBatchFileOperation(BatchFileOperationRequest { + repo_path, + file_changes, + operation, + }) + .into(), + ] + } + + pub(super) fn start_fetch_remote(&mut self, remote: String) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.remotes) + }) + { + self.push_error("This repository backend does not support remotes."); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before fetching."); + return Vec::new(); + }; + let toast_id = self.push_progress_toast(&format!("Fetching {remote}\u{2026}")); + vec![ + RepositoryEffect::FetchRemote(FetchRemoteRequest { + repo_path, + remote, + toast_id, + }) + .into(), + ] + } + + pub(super) fn start_fetch_all_remotes(&mut self) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.remotes) + }) + { + self.push_error("This repository backend does not support remotes."); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before fetching."); + return Vec::new(); + }; + let remotes = self.repository.refs.with(&self.store, |refs| { + remote_names_from_refs(refs).into_iter().collect::>() + }); + if remotes.is_empty() { + self.push_error("No remotes are configured for this repository."); + return Vec::new(); + } + remotes + .into_iter() + .flat_map(|remote| { + let toast_id = self.push_progress_toast(&format!("Fetching {remote}\u{2026}")); + std::iter::once( + RepositoryEffect::FetchRemote(FetchRemoteRequest { + repo_path: repo_path.clone(), + remote, + toast_id, + }) + .into(), + ) + }) + .collect() + } + + pub(super) fn start_publish_default(&mut self) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.remotes) + }) + { + self.push_error("This repository backend does not support publishing."); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before publishing."); + return Vec::new(); + }; + let toast_id = self.push_progress_toast(&format!( + "{}\u{2026}", + self.vcs_ui_profile().publish_command_label() + )); + vec![ + RepositoryEffect::PublishDefault(PublishRequest { + repo_path, + action: None, + toast_id, + }) + .into(), + ] + } + + pub(super) fn start_open_publish_menu(&mut self) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.remotes) + }) + { + self.push_error("This repository backend does not support publishing."); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before publishing."); + return Vec::new(); + }; + self.push_overlay(OverlaySurface::PublishMenu, None); + vec![ + RepositoryEffect::LoadPublishPlan(PublishPlanRequest { + repo_path, + toast_id: None, + }) + .into(), + ] + } + + pub(super) fn start_publish_action(&mut self, action: PublishAction) -> Vec { + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before publishing."); + return Vec::new(); + }; + if self.overlays_top() == Some(OverlaySurface::PublishMenu) { + self.pop_overlay(); + } + let toast_id = self.push_progress_toast(&format!("{}\u{2026}", action.label)); + vec![ + RepositoryEffect::PublishDefault(PublishRequest { + repo_path, + action: Some(action), + toast_id, + }) + .into(), + ] + } + + pub(super) fn start_push_current_branch(&mut self, force_with_lease: bool) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.remotes) + }) + { + self.push_error("This repository backend does not support push."); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before pushing."); + return Vec::new(); + }; + let Some(branch_ref) = self + .repository + .refs + .with(&self.store, |refs| active_publish_ref(refs)) + else { + self.push_error("No active branch or bookmark to push."); + return Vec::new(); + }; + let branch = branch_ref.name; + let (remote, refspec) = match branch_ref.upstream.as_deref().and_then(upstream_pair) { + Some((remote, upstream_branch)) => ( + remote, + format!("refs/heads/{branch}:refs/heads/{upstream_branch}"), + ), + None => { + // No upstream configured yet — default to `origin/`. + let remotes = self.repository.refs.with(&self.store, |refs| { + remote_names_from_refs(refs).into_iter().collect::>() + }); + let remote = if remotes.iter().any(|n| n == "origin") { + "origin".to_owned() + } else if let Some(first) = remotes.first() { + first.clone() + } else { + self.push_error("No remotes are configured for this repository."); + return Vec::new(); + }; + (remote, format!("refs/heads/{branch}:refs/heads/{branch}")) + } + }; + let label = if force_with_lease { + format!("Force-pushing {branch} to {remote}\u{2026}") + } else { + format!("Pushing {branch} to {remote}\u{2026}") + }; + let toast_id = self.push_progress_toast(&label); + vec![ + RepositoryEffect::Push(PushRequest { + repo_path, + remote, + refspec, + force_with_lease, + toast_id, + }) + .into(), + ] + } + + pub(super) fn start_pull_current_branch(&mut self) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.pull_fast_forward) + }) + { + self.push_error("This repository backend does not support fast-forward pull."); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before pulling."); + return Vec::new(); + }; + let Some(branch_ref) = self + .repository + .refs + .with(&self.store, |refs| active_publish_ref(refs)) + else { + self.push_error("No active branch or bookmark to pull into."); + return Vec::new(); + }; + let branch = branch_ref.name; + let (remote, upstream_branch) = match branch_ref.upstream.as_deref().and_then(upstream_pair) + { + Some(pair) => pair, + None => { + self.push_error(&format!( + "No upstream configured for {branch}. Push once to set one." + )); + return Vec::new(); + } + }; + let toast_id = self.push_progress_toast(&format!("Pulling {branch} from {remote}\u{2026}")); + vec![ + RepositoryEffect::PullFf(PullFfRequest { + repo_path, + remote, + branch: upstream_branch, + toast_id, + }) + .into(), + ] + } +} diff --git a/src/ui/state/settings.rs b/src/ui/state/settings.rs index 2e5dc003..7a94f445 100644 --- a/src/ui/state/settings.rs +++ b/src/ui/state/settings.rs @@ -180,3 +180,65 @@ impl AppState { } } } + +impl AppState { + pub fn keymaps_max_scroll_px(&self) -> f32 { + let content = self.keymaps_content_height_px.get(&self.store); + let viewport = self.keymaps_viewport_height_px.get(&self.store); + (content - viewport).max(0.0) + } + + pub fn clamp_keymaps_scroll(&mut self) { + let max = self.keymaps_max_scroll_px(); + let cur = self.keymaps_scroll_top_px.get(&self.store); + self.keymaps_scroll_top_px + .set(&self.store, cur.clamp(0.0, max)); + } +} + +impl AppState { + pub(super) fn persist_settings_effect(&mut self) -> Vec { + self.sync_settings_snapshot(); + vec![SettingsEffect::SaveSettings(self.settings.clone()).into()] + } + + pub(super) fn sync_settings_snapshot(&mut self) { + self.settings.ui_scale_pct = self.clamp_ui_scale_pct(self.settings.ui_scale_pct); + self.settings.fonts = self.settings.fonts.normalized(); + self.settings.sidebar_width_px = self + .settings + .sidebar_width_px + .map(|width| self.clamp_sidebar_width_px(width)); + self.settings.viewport.wrap_enabled = self.editor.wrap_enabled.get(&self.store); + self.settings.viewport.wrap_column = self.editor.wrap_column.get(&self.store); + self.settings.viewport.layout = self.compare.layout.get(&self.store); + self.settings.last_compare = Some(PersistedCompare { + repo_path: self.compare.repo_path.get(&self.store), + left_ref: self.compare.left_ref.get(&self.store), + right_ref: self.compare.right_ref.get(&self.store), + mode: self.compare.mode.get(&self.store), + layout: self.compare.layout.get(&self.store), + renderer: self.compare.renderer.get(&self.store), + }); + } + + pub fn ui_scale_factor(&self) -> f32 { + self.clamp_ui_scale_pct(self.settings.ui_scale_pct) as f32 / DEFAULT_UI_SCALE_PCT as f32 + } + + pub(super) fn clamp_ui_scale_pct(&self, scale_pct: u16) -> u16 { + scale_pct.clamp(MIN_UI_SCALE_PCT, MAX_UI_SCALE_PCT) + } + + pub(super) fn adjust_ui_scale(&mut self, delta_pct: i16) -> Vec { + let current = i32::from(self.clamp_ui_scale_pct(self.settings.ui_scale_pct)); + let updated = (current + i32::from(delta_pct)) + .clamp(i32::from(MIN_UI_SCALE_PCT), i32::from(MAX_UI_SCALE_PCT)) + as u16; + if updated == self.settings.ui_scale_pct { + return Vec::new(); + } + self.settings.ui_scale_pct = updated; + self.persist_settings_effect() + } +} diff --git a/src/ui/state/syntax.rs b/src/ui/state/syntax.rs index 4c92491c..fdafaf5c 100644 --- a/src/ui/state/syntax.rs +++ b/src/ui/state/syntax.rs @@ -4,7 +4,7 @@ use crate::actions::SyntaxAction; use crate::effects::{Effect, LoadFileSyntaxRequest}; use crate::events::SyntaxEvent; -use super::{AppState, MAX_PENDING_SYNTAX_WINDOWS}; +use super::*; #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct SyntaxInflightKey { @@ -99,3 +99,671 @@ pub(super) fn reduce_event(state: &mut AppState, event: SyntaxEvent) -> Vec Option { + let window = next_missing_syntax_tile(active, window)?; + if active + .syntax_pending + .iter() + .any(|pending| pending.window.contains(window)) + || active + .syntax_covered + .iter() + .any(|covered| covered.contains(window)) + { + return None; + } + + active + .syntax_pending + .push(SyntaxPendingWindow { request_id, window }); + Some(LoadFileSyntaxRequest { + repo_path, + file_index: active.index, + path: active.path.clone(), + carbon_file: active.carbon_file.clone(), + carbon_expansion: active.carbon_expansion.clone(), + left_ref: active.left_ref.clone(), + right_ref: active.right_ref.clone(), + window, + request_id, + cache_generation: generation, + syntax_epoch, + }) +} + +pub(super) fn next_missing_syntax_tile( + active: &ActiveFile, + requested: SyntaxRowWindow, +) -> Option { + let line_count = active.render_doc.lines.len(); + let start = requested.start.min(line_count); + let end = requested.end.min(line_count); + if line_count == 0 || end <= start { + return None; + } + + let tile_rows = SYNTAX_INITIAL_ROWS.max(1); + let mut tile_start = (start / tile_rows) * tile_rows; + while tile_start < end { + let tile_end = tile_start.saturating_add(tile_rows).min(line_count); + let candidate = SyntaxRowWindow { + start: tile_start, + end: tile_end, + }; + let already_requested = active + .syntax_pending + .iter() + .any(|pending| pending.window.contains(candidate)) + || active + .syntax_covered + .iter() + .any(|covered| covered.contains(candidate)); + if !already_requested { + return Some(candidate); + } + if tile_end == line_count { + break; + } + tile_start = tile_end; + } + None +} + +pub(super) fn apply_syntax_tokens_to_file( + carbon_overlays: &mut CarbonStyleOverlays, + token_buffer: &mut TokenBuffer, + updates: &[SyntaxLineTokens], +) { + for update in updates { + if let (Some(side), Some(source_index)) = (update.side, update.source_index) { + if update.tokens.is_empty() { + continue; + } + let range = token_buffer.append(&update.tokens); + carbon_overlays.insert_syntax(update.hunk_index as u32, side, source_index, range); + } + } +} + +pub(super) fn active_file_matches_language( + active: &ActiveFile, + highlighter: &Highlighter, + language: &str, +) -> bool { + !active.carbon_file.is_binary + && [ + Some(active.path.as_str()), + active.carbon_file.old_path.as_deref(), + active.carbon_file.new_path.as_deref(), + ] + .into_iter() + .flatten() + .any(|path| { + highlighter + .resolve_language(path) + .is_some_and(|resolved| resolved.name() == language) + }) +} + +pub(super) fn file_change_syntax_paths(change: &FileChange) -> Vec { + let mut paths = Vec::with_capacity(2); + if let Some(old_path) = change.old_path.as_ref() { + paths.push(old_path.clone()); + } + if !paths.iter().any(|path| path == &change.path) { + paths.push(change.path.clone()); + } + paths +} + +pub(super) fn ensure_syntax_packs_for_file_change_effect(change: &FileChange) -> Effect { + let mut paths = file_change_syntax_paths(change); + if paths.len() == 1 { + return SyntaxEffect::EnsureSyntaxPackForPath { + path: paths.pop().unwrap_or_else(|| change.path.clone()), + } + .into(); + } + SyntaxEffect::EnsureSyntaxPacksForPaths { paths }.into() +} + +pub(super) fn reset_active_file_syntax(active: &mut ActiveFile) { + active.syntax_pending.clear(); + active.syntax_covered.clear(); + let preserve_change_tokens = active.carbon_overlays.has_change_tokens(); + active.carbon_overlays.clear_syntax(); + if !preserve_change_tokens { + active.token_buffer.clear(); + } + active.render_doc = Arc::new(build_render_doc_from_carbon( + &active.carbon_file, + active.index, + &active.carbon_expansion, + &active.carbon_overlays, + &active.token_buffer, + )); +} + +pub(super) fn push_syntax_covered_window( + windows: &mut Vec, + window: SyntaxRowWindow, +) { + if window.end <= window.start { + return; + } + windows.push(window); + windows.sort_by_key(|window| window.start); + let mut merged: Vec = Vec::with_capacity(windows.len()); + for window in windows.drain(..) { + if let Some(last) = merged.last_mut() + && window.start <= last.end + { + last.end = last.end.max(window.end); + continue; + } + merged.push(window); + } + *windows = merged; +} + +pub(super) fn remove_pending_syntax_window( + pending: &mut Vec, + request_id: u64, + window: SyntaxRowWindow, +) -> bool { + let Some(index) = pending + .iter() + .position(|pending| pending.request_id == request_id && pending.window == window) + else { + return false; + }; + pending.swap_remove(index); + true +} + +impl AppState { + pub(super) fn syntax_pending_window_count(&self) -> usize { + let active_count = self.workspace.active_file.with(&self.store, |active| { + active + .as_ref() + .map_or(0, |active| active.syntax_pending.len()) + }); + let cache_count = self.workspace.file_cache.with(&self.store, |files| { + files + .values() + .map(|file| file.syntax_pending.len()) + .sum::() + }); + active_count.saturating_add(cache_count) + } + + pub(super) fn syntax_outstanding_window_count(&self) -> usize { + self.syntax_requests + .outstanding_count(self.syntax_pending_window_count()) + } + + pub(super) fn syntax_request_budget_available(&self) -> bool { + self.syntax_requests + .budget_available(self.syntax_pending_window_count()) + } + + pub(super) fn track_syntax_request(&mut self, request: &LoadFileSyntaxRequest) { + self.syntax_requests.track(request); + } + + pub(super) fn finish_syntax_request(&mut self, generation: u64, request_id: u64) { + self.syntax_requests.finish(generation, request_id); + } + + pub(super) fn clear_syntax_pending_windows(&mut self) { + self.workspace.active_file.update(&self.store, |active| { + if let Some(active) = active.as_mut() { + active.syntax_pending.clear(); + } + }); + self.workspace.file_cache.update(&self.store, |files| { + for active in files.values_mut() { + active.syntax_pending.clear(); + } + }); + } + + pub(super) fn clear_syntax_inflight(&mut self) { + self.clear_syntax_pending_windows(); + self.syntax_requests.invalidate(); + } + + pub(super) fn syntax_epoch_effect(&self) -> Effect { + SyntaxEffect::SetFileSyntaxEpoch { + epoch: self.syntax_requests.epoch(), + } + .into() + } + + pub(super) fn invalidate_syntax_epoch_effect(&mut self) -> Effect { + self.clear_syntax_inflight(); + self.syntax_epoch_effect() + } +} + +impl AppState { + pub(super) fn handle_file_syntax_ready(&mut self, payload: FileSyntaxReady) -> Vec { + self.finish_syntax_request(payload.generation, payload.request_id); + if payload.generation != self.active_syntax_generation() { + return Vec::new(); + } + + let mut applied_file = None; + let mut applied_active = false; + let mut matched_active = false; + self.workspace.active_file.update(&self.store, |slot| { + let Some(active) = slot.as_mut() else { + return; + }; + if active.index != payload.file_index || active.path != payload.path { + return; + } + matched_active = true; + + if !remove_pending_syntax_window( + &mut active.syntax_pending, + payload.request_id, + payload.window, + ) { + return; + } + if active + .syntax_covered + .iter() + .any(|covered| covered.contains(payload.window)) + { + return; + } + push_syntax_covered_window(&mut active.syntax_covered, payload.window); + apply_syntax_tokens_to_file( + &mut active.carbon_overlays, + &mut active.token_buffer, + &payload.tokens, + ); + active.render_doc = Arc::new(build_render_doc_from_carbon( + &active.carbon_file, + active.index, + &active.carbon_expansion, + &active.carbon_overlays, + &active.token_buffer, + )); + applied_file = Some(active.clone()); + applied_active = true; + }); + if matched_active && applied_file.is_none() { + tracing::debug!( + file_index = payload.file_index, + path = %payload.path, + request_id = payload.request_id, + "stale active syntax response dropped" + ); + return Vec::new(); + } + + if applied_file.is_none() { + self.workspace.file_cache.update(&self.store, |files| { + let Some(active) = files.get_mut(&payload.file_index) else { + return; + }; + if active.index != payload.file_index || active.path != payload.path { + return; + } + + if !remove_pending_syntax_window( + &mut active.syntax_pending, + payload.request_id, + payload.window, + ) { + return; + } + if active + .syntax_covered + .iter() + .any(|covered| covered.contains(payload.window)) + { + return; + } + push_syntax_covered_window(&mut active.syntax_covered, payload.window); + apply_syntax_tokens_to_file( + &mut active.carbon_overlays, + &mut active.token_buffer, + &payload.tokens, + ); + active.render_doc = Arc::new(build_render_doc_from_carbon( + &active.carbon_file, + active.index, + &active.carbon_expansion, + &active.carbon_overlays, + &active.token_buffer, + )); + applied_file = Some(active.clone()); + }); + } + + let Some(active_file) = applied_file else { + return Vec::new(); + }; + self.cache_active_file(active_file); + self.viewport_document_cache = None; + + if applied_active { + self.request_active_file_syntax_effect() + .into_iter() + .collect() + } else { + Vec::new() + } + } + + pub(super) fn handle_syntax_pack_install_started(&mut self, language: &str) { + self.syntax_pack_installs.update(&self.store, |active| { + if !active.iter().any(|item| item == language) { + active.push(language.to_owned()); + } + }); + } + + pub(super) fn handle_syntax_pack_install_finished(&mut self, language: &str) { + self.syntax_pack_installs + .update(&self.store, |active| active.retain(|item| item != language)); + } + + pub fn syntax_pack_install_active(&self) -> bool { + self.syntax_pack_installs + .with(&self.store, |active| !active.is_empty()) + } + + pub(super) fn syntax_pack_warmup_effect_for_compare( + &self, + exclude_paths: &[String], + ) -> Option { + let highlighter = phosphor::Highlighter::new(); + let excluded_languages = exclude_paths + .iter() + .filter_map(|path| highlighter.guess_language(Path::new(path))) + .collect::>(); + let active_languages = self.syntax_pack_installs.with(&self.store, |active| { + active.iter().cloned().collect::>() + }); + + self.workspace.compare_output.with(&self.store, |output| { + let output = output.as_ref()?; + let mut seen = HashSet::new(); + let mut warmup_paths = Vec::new(); + output.for_each_summary(|_, summary| { + for path in [summary.paths.old_path(), summary.paths.new_path()] + .into_iter() + .flatten() + { + let Some(language) = highlighter.guess_language(Path::new(path.as_ref())) + else { + continue; + }; + if excluded_languages.contains(&language) + || active_languages.contains(language.name()) + || highlighter.is_parser_available(language) + { + continue; + } + if seen.insert(language) { + warmup_paths.push(path.into_owned()); + } + } + }); + + (!warmup_paths.is_empty()).then_some( + SyntaxEffect::EnsureSyntaxPacksForPaths { + paths: warmup_paths, + } + .into(), + ) + }) + } + + pub(super) fn syntax_pack_warmup_effect_for_paths( + &self, + paths: &[String], + exclude_paths: &[String], + ) -> Option { + let highlighter = phosphor::Highlighter::new(); + let excluded_languages = exclude_paths + .iter() + .filter_map(|path| highlighter.guess_language(Path::new(path))) + .collect::>(); + let active_languages = self.syntax_pack_installs.with(&self.store, |active| { + active.iter().cloned().collect::>() + }); + + let mut seen = HashSet::new(); + let mut warmup_paths = Vec::new(); + for path in paths { + let Some(language) = highlighter.guess_language(Path::new(path)) else { + continue; + }; + if excluded_languages.contains(&language) + || active_languages.contains(language.name()) + || highlighter.is_parser_available(language) + { + continue; + } + if seen.insert(language) { + warmup_paths.push(path.clone()); + } + } + + (!warmup_paths.is_empty()).then_some( + SyntaxEffect::EnsureSyntaxPacksForPaths { + paths: warmup_paths, + } + .into(), + ) + } + + pub(super) fn handle_syntax_packs_installed(&mut self, languages: &[String]) -> Vec { + if languages.is_empty() { + return Vec::new(); + } + let mut effects = vec![self.invalidate_syntax_epoch_effect()]; + for language in languages { + effects.extend(self.refresh_active_file_syntax_for_language(language)); + effects.extend(self.request_cached_file_syntax_effects_for_language(language)); + } + effects + } + + pub(super) fn refresh_active_file_syntax_for_language( + &mut self, + language: &str, + ) -> Vec { + let highlighter = Highlighter::new(); + let mut refreshed = false; + self.workspace.active_file.update(&self.store, |slot| { + let Some(active) = slot.as_mut() else { + return; + }; + if !active_file_matches_language(active, &highlighter, language) { + return; + } + reset_active_file_syntax(active); + refreshed = true; + }); + if !refreshed { + return Vec::new(); + } + self.viewport_document_cache = None; + self.request_active_file_syntax_effect() + .into_iter() + .collect() + } + + pub(super) fn request_cached_file_syntax_effects_for_language( + &mut self, + language: &str, + ) -> Vec { + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + let generation = self.active_syntax_generation(); + let syntax_epoch = self.syntax_requests.epoch(); + let mut remaining_budget = + MAX_PENDING_SYNTAX_WINDOWS.saturating_sub(self.syntax_outstanding_window_count()); + if remaining_budget == 0 { + return Vec::new(); + } + let active_key = self.workspace.active_file.with(&self.store, |active| { + active.as_ref().map(ActiveFile::working_set_key) + }); + let highlighter = Highlighter::new(); + let mut requests = Vec::new(); + let mut next_request_id = self.syntax_requests.last_request_id(); + + self.workspace.file_cache.update(&self.store, |files| { + for active in files.values_mut() { + if remaining_budget == 0 { + break; + } + if active_key + .as_ref() + .is_some_and(|key| key == &active.working_set_key()) + { + continue; + } + if !active_file_matches_language(active, &highlighter, language) { + continue; + } + let line_count = active.render_doc.lines.len(); + if line_count == 0 { + continue; + } + reset_active_file_syntax(active); + let window = SyntaxRowWindow { + start: 0, + end: line_count.min(SYNTAX_INITIAL_ROWS), + }; + next_request_id = next_request_id.saturating_add(1); + if let Some(request) = request_syntax_for_active_file( + active, + repo_path.clone(), + generation, + syntax_epoch, + window, + next_request_id, + ) { + requests.push(request); + remaining_budget = remaining_budget.saturating_sub(1); + } + } + }); + self.syntax_requests.set_last_request_id(next_request_id); + + requests + .into_iter() + .map(|request| { + self.track_syntax_request(&request); + SyntaxEffect::LoadFileSyntax(Task { + generation, + request, + }) + .into() + }) + .collect() + } + + pub(super) fn request_active_file_syntax_effect(&mut self) -> Option { + if !self.syntax_request_budget_available() { + return None; + } + let repo_path = self.compare.repo_path.get(&self.store)?; + let window = self.desired_syntax_window()?; + let generation = self.active_syntax_generation(); + let syntax_epoch = self.syntax_requests.epoch(); + let mut request = None; + let request_id = self.syntax_requests.next_request_id(); + let mut active_to_cache = None; + + self.workspace.active_file.update(&self.store, |active| { + let Some(active) = active.as_mut() else { + return; + }; + if let Some(next_request) = request_syntax_for_active_file( + active, + repo_path, + generation, + syntax_epoch, + window, + request_id, + ) { + active_to_cache = Some(active.clone()); + request = Some(next_request); + } + }); + if let Some(active_file) = active_to_cache { + self.cache_active_file(active_file); + } + + request.map(|request| { + self.track_syntax_request(&request); + SyntaxEffect::LoadFileSyntax(Task { + generation, + request, + }) + .into() + }) + } + + pub(super) fn active_syntax_generation(&self) -> u64 { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Status => self.workspace.status_generation.get(&self.store), + _ => self.workspace.compare_generation.get(&self.store), + } + } + + pub(super) fn desired_syntax_window(&self) -> Option { + let line_count = self.workspace.active_file.with(&self.store, |active| { + active.as_ref().map(|active| active.render_doc.lines.len()) + })?; + if line_count == 0 { + return None; + } + + if let (Some(start), Some(end)) = ( + self.editor.visible_row_start.get(&self.store), + self.editor.visible_row_end.get(&self.store), + ) && end > start + { + return Some(SyntaxRowWindow { + start: start.saturating_sub(SYNTAX_OVERSCAN_ROWS), + end: end.saturating_add(SYNTAX_OVERSCAN_ROWS).min(line_count), + }); + } + + let scroll = self.editor.scroll_top_px.get(&self.store) as usize; + let viewport = self.editor.viewport_height_px.get(&self.store) as usize; + let approx_row_height = 20usize; + let start = scroll / approx_row_height; + let visible = (viewport / approx_row_height).saturating_add(SYNTAX_INITIAL_ROWS); + Some(SyntaxRowWindow { + start: start.saturating_sub(SYNTAX_OVERSCAN_ROWS), + end: start.saturating_add(visible).min(line_count), + }) + } +} diff --git a/src/ui/state/tests.rs b/src/ui/state/tests.rs new file mode 100644 index 00000000..915fa07a --- /dev/null +++ b/src/ui/state/tests.rs @@ -0,0 +1,3291 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use clap::Parser; + +use super::{ + ActiveFile, ActiveFileLoading, AppState, AsyncStatus, CarbonStyleOverlays, CardTextSelection, + CompareField, FILE_HEIGHT_SPARSE_MIN_COUNT, FileHeightIndex, FileListEntry, FocusTarget, + OverlayEntry, OverlaySurface, PickerItem, PickerLabelStyle, PreparedActiveFile, SidebarMode, + SidebarTab, TextCompareLanguage, TextCompareView, ViewportAnchorBias, VirtualDiffItemKind, + WorkspaceMode, WorkspaceSource, prepare_active_file, vcs_compare_request, +}; +use crate::core::compare::{ + CompareFileSummary, CompareMode, CompareOutput, LayoutMode, RendererKind, +}; +use crate::core::text::TokenBuffer; +use crate::core::vcs::model::{ + ChangeBucket, ChangeFlags, FileChange, FileChangeStatus, JjOperation, RefKind, + RepoCapabilities, RepoLocation, RevisionId, VcsChange, VcsKind, VcsOperation, + VcsOperationLogEntry, VcsRef, +}; +use crate::editor::EditorMode; +use crate::editor::diff::render_doc::{RenderDoc, build_render_doc_from_carbon}; +use crate::effects::{ + AiEffect, CompareEffect, CompareWorkPriority, Effect, GitHubEffect, RepositoryEffect, + SettingsEffect, SyntaxEffect, +}; +use crate::events::{ + AppEvent, CompareEvent, CompareFileFinished, CompareFileStat, CompareFileStatsReady, + CompareStatsReady, GitHubEvent, RepositoryEvent, TextCompareFinished, +}; +use crate::platform::persistence::Settings; +use crate::platform::startup::{Args, StartupOptions}; + +fn carbon_summary_for_path(index: usize, path: &str) -> carbon::FileDiff { + carbon::FileDiff { + id: carbon::FileId(index as u32), + old_path: Some(path.to_owned()), + new_path: Some(path.to_owned()), + is_partial: true, + ..carbon::FileDiff::default() + } +} + +fn carbon_context_file(index: usize, path: &str, text: &str) -> carbon::FileDiff { + carbon::parse_unified_patch(&format!( + "diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1 +1 @@\n {text}\n" + )) + .unwrap() + .files + .into_iter() + .next() + .map(|mut file| { + file.id = carbon::FileId(index as u32); + file + }) + .unwrap() +} + +#[test] +fn new_text_compare_enters_text_workspace_with_left_focus() { + let mut state = AppState::default(); + + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + + assert_eq!( + state.workspace.source.get(&state.store), + WorkspaceSource::TextCompare + ); + assert_eq!(state.text_compare.view, TextCompareView::Edit); + assert_eq!(state.text_compare.left_editor.mode(), EditorMode::CodeInput); + assert_eq!( + state.text_compare.right_editor.mode(), + EditorMode::CodeInput + ); + assert_eq!(state.text_compare.language, TextCompareLanguage::Auto); + assert_eq!(state.text_compare.path_hint, "text.txt"); + assert_eq!( + state.focus.get(&state.store), + Some(FocusTarget::TextCompareLeft) + ); +} + +#[test] +fn text_compare_paste_routes_to_focused_side() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + + state.apply_action(crate::actions::TextEditAction::Paste("left".to_owned())); + state.apply_action(crate::actions::AppAction::SetFocus(Some( + FocusTarget::TextCompareRight, + ))); + state.apply_action(crate::actions::TextEditAction::Paste("right".to_owned())); + + assert_eq!(state.text_compare.left_editor.text(), "left"); + assert_eq!(state.text_compare.right_editor.text(), "right"); + assert_eq!(state.text_compare.left_editor.line_count(), 1); + assert_eq!(state.text_compare.right_editor.line_count(), 1); +} + +#[test] +fn text_compare_auto_language_detects_pasted_rust() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + + state.apply_action(crate::actions::TextEditAction::Paste( + "pub fn main() {\n println!(\"hi\");\n}\n".to_owned(), + )); + + assert_eq!( + state.text_compare.detected_language, + Some(TextCompareLanguage::Rust) + ); + assert_eq!(state.text_compare.path_hint, "scratch.rs"); +} + +#[test] +fn text_compare_auto_language_detects_pasted_typescript() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + + state.apply_action(crate::actions::TextEditAction::Paste( + "const answer: number = 42;\nexport { answer };\n".to_owned(), + )); + + assert_eq!( + state.text_compare.detected_language, + Some(TextCompareLanguage::TypeScript) + ); + assert_eq!(state.text_compare.path_hint, "scratch.ts"); +} + +#[test] +fn text_compare_language_override_sets_compare_path() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + + state.apply_action(crate::actions::TextCompareAction::SetLanguage( + TextCompareLanguage::TypeScript, + )); + state.apply_action(crate::actions::TextEditAction::Paste( + "pub fn main() {}\n".to_owned(), + )); + let effects = state.apply_action(crate::actions::TextCompareAction::CompareNow); + let request_path = effects + .iter() + .find_map(|effect| match effect { + Effect::Compare(CompareEffect::RunText(task)) => { + Some(task.request.display_path.as_str()) + } + _ => None, + }) + .unwrap(); + + assert_eq!(state.text_compare.language, TextCompareLanguage::TypeScript); + assert_eq!(state.text_compare.path_hint, "scratch.ts"); + assert_eq!(request_path, "scratch.ts"); +} + +#[test] +fn text_compare_swap_sides_preserves_text_and_marks_stale() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + state.text_compare.left_editor.set_text("old"); + state.text_compare.right_editor.set_text("new"); + let generation = state.text_compare.generation; + + state.apply_action(crate::actions::TextCompareAction::SwapSides); + + assert_eq!(state.text_compare.left_editor.text(), "new"); + assert_eq!(state.text_compare.right_editor.text(), "old"); + assert!(state.text_compare.generation > generation); + assert!(state.text_compare_is_stale()); +} + +#[test] +fn stale_text_compare_finished_event_is_ignored() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + let effects = state.apply_action(crate::actions::TextCompareAction::CompareNow); + let generation = effects + .iter() + .find_map(|effect| match effect { + Effect::Compare(CompareEffect::RunText(task)) => Some(task.generation), + _ => None, + }) + .unwrap(); + state.apply_action(crate::actions::TextEditAction::Paste("newer".to_owned())); + + state.apply_event(AppEvent::from(CompareEvent::TextCompareFinished( + TextCompareFinished { + generation, + display_path: "text.txt".to_owned(), + renderer: RendererKind::Builtin, + layout: LayoutMode::Unified, + output: CompareOutput::default(), + }, + ))); + + assert!(state.workspace.compare_output.get(&state.store).is_none()); + assert_eq!(state.text_compare.view, TextCompareView::Edit); +} + +#[test] +fn text_compare_finished_installs_diff_view() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + let generation = state.text_compare.generation.saturating_add(1); + state.text_compare.generation = generation; + let output = crate::core::compare::compare_text( + "old\n", + "new\n", + "text.txt", + RendererKind::Builtin, + LayoutMode::Unified, + ) + .unwrap(); + + state.apply_event(AppEvent::from(CompareEvent::TextCompareFinished( + TextCompareFinished { + generation, + display_path: "text.txt".to_owned(), + renderer: RendererKind::Builtin, + layout: LayoutMode::Unified, + output, + }, + ))); + + assert_eq!(state.text_compare.view, TextCompareView::Diff); + assert_eq!( + state.workspace.source.get(&state.store), + WorkspaceSource::TextCompare + ); + assert!(state.workspace.active_file.get(&state.store).is_some()); +} + +fn status_state_with_two_hunks() -> AppState { + let state = AppState::default(); + let repo_path = PathBuf::from("/repo"); + let path = "src/lib.rs".to_owned(); + let token_buffer = TokenBuffer::default(); + let carbon_file = carbon::parse_unified_patch( + "\ +diff --git a/src/lib.rs b/src/lib.rs +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,3 +1,2 @@ + fn one() { +- old_first(); + } +@@ -8,3 +7,2 @@ + fn two() { +- old_second(); + } +", + ) + .unwrap() + .files + .into_iter() + .next() + .unwrap(); + let carbon_expansion = carbon::ExpansionState::default(); + let render_doc = build_render_doc_from_carbon( + &carbon_file, + 0, + &carbon_expansion, + &CarbonStyleOverlays::default(), + &token_buffer, + ); + let (left_ref, right_ref) = + crate::ui::vcs::profile(None).status_compare_refs(ChangeBucket::Unstaged); + + state.compare.repo_path.set(&state.store, Some(repo_path)); + state + .repository + .capabilities + .set(&state.store, Some(RepoCapabilities::git())); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Status); + state.workspace.status.set(&state.store, AsyncStatus::Ready); + state + .workspace + .status_operation_pending + .set(&state.store, false); + state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.files.set( + &state.store, + vec![FileListEntry { + path: path.as_str().into(), + }], + ); + state.workspace.status_file_changes.set( + &state.store, + vec![FileChange { + path: path.clone(), + old_path: None, + status: FileChangeStatus::Modified, + bucket: ChangeBucket::Unstaged, + }], + ); + state + .workspace + .selected_file_index + .set(&state.store, Some(0)); + state + .workspace + .selected_file_path + .set(&state.store, Some(path.clone())); + state + .workspace + .selected_change_bucket + .set(&state.store, Some(ChangeBucket::Unstaged)); + state.workspace.active_file.set( + &state.store, + Some(ActiveFile { + index: 0, + path, + carbon_file: Arc::new(carbon_file.clone()), + carbon_expansion, + carbon_overlays: CarbonStyleOverlays::default(), + render_doc: Arc::new(render_doc), + token_buffer, + left_ref, + right_ref, + file_line_count: None, + old_file_lines: None, + file_lines: None, + syntax_pending: Vec::new(), + syntax_covered: Vec::new(), + last_used_tick: 0, + }), + ); + + state +} + +fn loaded_state_with_files(paths: &[&str]) -> AppState { + let state = AppState::default(); + let carbon_files: Vec = paths + .iter() + .enumerate() + .map(|(index, path)| carbon_context_file(index, path, "loaded")) + .collect(); + let entries: Vec = carbon_files + .iter() + .map(|file| FileListEntry { + path: file.path().into(), + }) + .collect(); + + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + carbon: carbon::DiffDocument { + files: carbon_files, + }, + ..CompareOutput::default() + }), + ); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.workspace.files.set(&state.store, entries); + state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.file_list.row_height.set(&state.store, 36.0); + state.file_list.gap.set(&state.store, 4.0); + state.file_list.viewport_height.set(&state.store, 80.0); + state +} + +#[test] +fn bootstrap_with_no_repo_starts_empty_workspace() { + let startup = StartupOptions::from_parts( + Args::parse_from(["diffy"]), + None, + "client".to_owned(), + false, + ); + + let (state, effects) = AppState::bootstrap(startup, Settings::default()); + assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Empty); + assert_eq!( + state.focus.get(&state.store), + Some(FocusTarget::WorkspacePrimaryButton) + ); + assert!(effects.iter().all(|e| matches!( + e, + Effect::GitHub(GitHubEffect::LoadGitHubToken) + | Effect::Ai(AiEffect::LoadAiKeys) + | Effect::Syntax(SyntaxEffect::InstallCommonSyntaxPacks) + ))); +} + +#[test] +fn bootstrap_with_repo_starts_repo_sync() { + let startup = StartupOptions::from_parts( + Args { + repo: Some("C:\\repo".into()), + left: Some("main".to_owned()), + right: None, + compare_mode: Some(CompareMode::TwoDot), + layout: Some(LayoutMode::Unified), + renderer: Some(RendererKind::Builtin), + file_index: None, + file_path: None, + open_pr: None, + }, + None, + "client".to_owned(), + false, + ); + + let (state, effects) = AppState::bootstrap(startup, Settings::default()); + assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Empty); + assert_eq!(state.active_overlay_name(), None); + assert_eq!( + effects + .iter() + .filter(|e| matches!( + e, + Effect::Repository(RepositoryEffect::SyncRepository { .. }) + | Effect::Repository(RepositoryEffect::WatchRepository { .. }) + )) + .count(), + 2 + ); +} + +#[test] +fn overlay_close_restores_prior_focus() { + let startup = StartupOptions::from_parts( + Args::parse_from(["diffy"]), + None, + "client".to_owned(), + false, + ); + let (mut state, _) = AppState::bootstrap(startup, Settings::default()); + state.apply_action(crate::actions::AppAction::SetFocus(Some( + FocusTarget::TitleBar, + ))); + state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); + assert_eq!(state.overlays_top(), Some(OverlaySurface::CommandPalette)); + state.apply_action(crate::actions::OverlayAction::CloseOverlay); + assert_eq!(state.focus.get(&state.store), Some(FocusTarget::TitleBar)); +} + +#[test] +fn pixel_scroll_actions_clamp_file_list_and_viewport() { + let mut state = AppState::default(); + + state.workspace.files.set( + &state.store, + vec![ + FileListEntry { + path: "a.rs".into(), + }, + FileListEntry { + path: "b.rs".into(), + }, + FileListEntry { + path: "c.rs".into(), + }, + FileListEntry { + path: "d.rs".into(), + }, + FileListEntry { + path: "e.rs".into(), + }, + ], + ); + state.file_list.row_height.set(&state.store, 36.0); + state.file_list.gap.set(&state.store, 4.0); + state.file_list.viewport_height.set(&state.store, 80.0); + + state.apply_action(crate::actions::FileListAction::ScrollFileListPx(50)); + assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 50.0); + + state.apply_action(crate::actions::FileListAction::ScrollFileListPx(500)); + assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 116.0); + + state.apply_action(crate::actions::FileListAction::ScrollFileListPx(-500)); + assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 0.0); + + state.editor.content_height_px.set(&state.store, 600); + state.editor.viewport_height_px.set(&state.store, 200); + + state.apply_action(crate::actions::EditorAction::ScrollViewportPx(75)); + assert_eq!(state.editor.scroll_top_px.get(&state.store), 75); + + state.apply_action(crate::actions::EditorAction::ScrollViewportPx(500)); + assert_eq!(state.editor.scroll_top_px.get(&state.store), 400); + + state.apply_action(crate::actions::EditorAction::ScrollViewportPx(-500)); + assert_eq!(state.editor.scroll_top_px.get(&state.store), 0); +} + +#[test] +fn file_height_index_keeps_uniform_large_lists_sparse() { + let mut index = FileHeightIndex::default(); + index.rebuild(vec![192; FILE_HEIGHT_SPARSE_MIN_COUNT + 1]); + + assert_eq!(index.len(), FILE_HEIGHT_SPARSE_MIN_COUNT + 1); + assert_eq!( + index.total_u32(), + ((FILE_HEIGHT_SPARSE_MIN_COUNT + 1) as u32) * 192 + ); + assert!(matches!(index, FileHeightIndex::Sparse { .. })); + assert_eq!(index.locate(192 * 7 + 12), Some((7, 12))); +} + +#[test] +fn sparse_file_height_index_updates_prefix_and_locate() { + let mut index = FileHeightIndex::default(); + index.rebuild(vec![100; FILE_HEIGHT_SPARSE_MIN_COUNT + 2]); + index.update(3, 250); + index.update(7, 40); + + assert_eq!(index.prefix_u32(4), 550); + assert_eq!(index.prefix_u32(8), 890); + assert_eq!(index.locate(549), Some((3, 249))); + assert_eq!(index.locate(550), Some((4, 0))); + assert_eq!(index.locate(849), Some((6, 99))); + assert_eq!(index.locate(850), Some((7, 0))); + assert_eq!(index.locate(889), Some((7, 39))); + assert_eq!(index.locate(890), Some((8, 0))); +} + +#[test] +fn clicking_a_visible_file_does_not_force_sidebar_reveal() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.file_list.scroll_offset_px.set(&state.store, 10.0); + + state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + assert_eq!( + state.workspace.selected_file_index.get(&state.store), + Some(0) + ); + assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 10.0); +} + +#[test] +fn keyboard_file_navigation_still_reveals_selection() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs", "d.rs"]); + state + .workspace + .selected_file_index + .set(&state.store, Some(0)); + state + .workspace + .selected_file_path + .set(&state.store, Some("a.rs".into())); + state.file_list.scroll_offset_px.set(&state.store, 50.0); + + state.apply_action(crate::actions::FileListAction::SelectNextFile); + + assert_eq!( + state.workspace.selected_file_index.get(&state.store), + Some(1) + ); + assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 40.0); +} + +#[test] +fn next_file_action_selects_adjacent_file_in_single_file_mode() { + let mut state = loaded_state_with_files(&["src/ui/state/mod.rs", "src/ui/state/text_edit.rs"]); + state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + state.apply_action(crate::actions::EditorAction::GoToNextFile); + state.sync_editor_scroll_from_global(); + + assert_eq!( + state.workspace.selected_file_index.get(&state.store), + Some(1) + ); + assert_eq!( + state + .workspace + .selected_file_path + .get(&state.store) + .as_deref(), + Some("src/ui/state/text_edit.rs") + ); + assert_eq!( + state + .workspace + .active_file + .get(&state.store) + .as_ref() + .map(|file| file.path.as_str()), + Some("src/ui/state/text_edit.rs") + ); + assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 0); +} + +#[test] +fn next_file_action_selects_next_file_when_tail_is_short() { + let mut state = loaded_state_with_files(&["src/ui/state/mod.rs", "src/ui/state/text_edit.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 10_000); + state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + state.apply_action(crate::actions::EditorAction::GoToNextFile); + + assert_eq!( + state.workspace.selected_file_index.get(&state.store), + Some(1) + ); + assert_eq!( + state + .workspace + .selected_file_path + .get(&state.store) + .as_deref(), + Some("src/ui/state/text_edit.rs") + ); + assert_eq!( + state + .workspace + .active_file + .get(&state.store) + .as_ref() + .map(|file| file.path.as_str()), + Some("src/ui/state/text_edit.rs") + ); +} + +#[test] +fn continuous_scroll_keeps_short_tail_at_natural_bottom() { + let state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.editor.viewport_height_px.set(&state.store, 10_000); + + assert_eq!(state.global_max_scroll_top_px(), 0); +} + +#[test] +fn continuous_scroll_first_height_measurement_keeps_total_cache_in_sync_with_index() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.recompute_file_scroll_total_height_px(); + + assert_eq!(state.workspace.measured_px_per_row_q16.get(&state.store), 0); + + assert!(state.update_file_content_height_px(0, 1_200)); + + assert_eq!( + state + .workspace + .file_scroll_total_height_px + .get(&state.store), + state.virtual_diff_document.total_u32() + ); +} + +#[test] +fn continuous_scroll_keeps_bottom_anchor_when_visible_file_height_grows() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 100); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(200), Some(200), Some(200)]); + state.recompute_file_scroll_total_height_px(); + + let old_max = state.global_max_scroll_top_px(); + assert_eq!(old_max, 500); + state + .workspace + .global_scroll_top_px + .set(&state.store, old_max); + + assert!(state.update_file_content_height_px(2, 350)); + + assert_eq!(state.global_max_scroll_top_px(), 650); + assert_eq!( + state.workspace.global_scroll_top_px.get(&state.store), + state.global_max_scroll_top_px() + ); +} + +#[test] +fn continuous_scroll_follow_end_anchor_is_explicit_after_scrolling_to_bottom() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 100); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(200), Some(200), Some(200)]); + state.recompute_file_scroll_total_height_px(); + + let old_max = state.global_max_scroll_top_px(); + state.scroll_viewport_to_global(old_max); + + let anchor = state.virtual_scroll.anchor.expect("bottom anchor"); + assert_eq!(anchor.bias, ViewportAnchorBias::FollowEnd); + + assert!(state.update_file_content_height_px(2, 350)); + + assert_eq!( + state.workspace.global_scroll_top_px.get(&state.store), + state.global_max_scroll_top_px() + ); +} + +#[test] +fn continuous_scroll_preserves_top_anchor_when_prior_file_height_changes() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 100); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(200), Some(200), Some(200)]); + state.recompute_file_scroll_total_height_px(); + + state.scroll_viewport_to_global(250); + let anchor = state.virtual_scroll.anchor.expect("top anchor"); + assert_eq!(anchor.item_id.index, 1); + assert_eq!(anchor.intra_item_offset_px, 50); + assert_eq!(anchor.bias, ViewportAnchorBias::PreserveTop); + + assert!(state.update_file_content_height_px(0, 300)); + + assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 350); + let anchor = state.virtual_scroll.anchor.expect("rebased anchor"); + assert_eq!(anchor.item_id.index, 1); + assert_eq!(anchor.intra_item_offset_px, 50); +} + +#[test] +fn continuous_scroll_preserves_bottom_anchor_when_prior_file_height_changes() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 100); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(200), Some(200), Some(200)]); + state.recompute_file_scroll_total_height_px(); + + state.set_viewport_anchor_for_global(350, ViewportAnchorBias::PreserveBottom); + let anchor = state.virtual_scroll.anchor.expect("bottom-edge anchor"); + assert_eq!(anchor.item_id.index, 2); + assert_eq!(anchor.intra_item_offset_px, 50); + + assert!(state.update_file_content_height_px(0, 300)); + + assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 450); + let anchor = state.virtual_scroll.anchor.expect("rebased anchor"); + assert_eq!(anchor.bias, ViewportAnchorBias::PreserveBottom); + assert_eq!(anchor.item_id.index, 2); + assert_eq!(anchor.intra_item_offset_px, 50); +} + +#[test] +fn continuous_scroll_keeps_bottom_anchor_after_pending_scrollbar_drag_height_update() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 100); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(200), Some(200), Some(200)]); + state.recompute_file_scroll_total_height_px(); + + let old_max = state.global_max_scroll_top_px(); + assert_eq!(old_max, 500); + state + .workspace + .global_scroll_top_px + .set(&state.store, old_max); + state.begin_viewport_scrollbar_drag(600, 100, old_max, old_max); + + assert!(!state.update_file_content_height_px(2, 350)); + state.end_viewport_scrollbar_drag(); + + assert_eq!(state.global_max_scroll_top_px(), 650); + assert_eq!( + state.workspace.global_scroll_top_px.get(&state.store), + state.global_max_scroll_top_px() + ); +} + +#[test] +fn continuous_scroll_does_not_treat_zero_max_as_bottom_anchor() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 1_000); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(200), Some(200), Some(200)]); + state.recompute_file_scroll_total_height_px(); + + assert_eq!(state.global_max_scroll_top_px(), 0); + assert!(state.update_file_content_height_px(2, 700)); + + assert_eq!(state.global_max_scroll_top_px(), 100); + assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 0); +} + +#[test] +fn virtual_diff_document_keeps_large_compare_ranges_sparse_and_anchorable() { + let count = FILE_HEIGHT_SPARSE_MIN_COUNT + 32; + let summaries = (0..count) + .map(|index| { + let path = format!("kernel/file_{index}.c"); + CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ) + }) + .collect::>(); + let mut state = AppState::default(); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 900); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + state.recompute_file_scroll_total_height_px(); + + assert!(matches!( + state.virtual_diff_document.height_index, + FileHeightIndex::Sparse { .. } + )); + + let target = state.global_max_scroll_top_px() / 2; + state.scroll_viewport_to_global(target); + let anchor = state.virtual_scroll.anchor.expect("compare anchor"); + + assert_eq!(anchor.item_id.source, WorkspaceSource::Compare); + assert_eq!( + anchor.item_id.generation, + state.workspace_render_generation() + ); + assert!(anchor.item_id.index < count); +} + +#[test] +fn virtual_diff_document_rejects_stale_measurement_item_ids() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs"]); + state.settings.continuous_scroll = true; + state.recompute_file_scroll_total_height_px(); + let item_id = state.virtual_diff_document.item_id(1).expect("item id"); + + assert!(state.update_virtual_diff_item_height_px(item_id, 300)); + state.workspace.compare_generation.set(&state.store, 1); + + assert!(!state.update_virtual_diff_item_height_px(item_id, 500)); + assert_eq!( + state + .workspace + .file_content_heights + .with(&state.store, |heights| heights.get(1).copied().flatten()), + Some(300) + ); +} + +#[test] +fn continuous_compare_count_keeps_sidebar_files_when_output_is_partially_hydrated() { + let mut state = AppState::default(); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 10_000); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + carbon: carbon::DiffDocument { + files: vec![carbon_context_file(0, "a.rs", "loaded")], + }, + ..CompareOutput::default() + }), + ); + state.workspace.files.set( + &state.store, + vec![ + FileListEntry { + path: "a.rs".into(), + }, + FileListEntry { + path: "b.rs".into(), + }, + FileListEntry { + path: "c.rs".into(), + }, + ], + ); + + assert_eq!(state.workspace_file_count(), 3); + + let (doc, _effects) = state.build_continuous_viewport_document(); + let doc = doc.expect("viewport doc"); + + assert_eq!(doc.slot_indices, vec![0, 1, 2]); + assert_eq!(doc.slot_loading, vec![false, true, true]); +} + +#[test] +fn continuous_viewport_document_exposes_virtual_stream_rows() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 900); + state + .cache_compare_file_from_output(0, "a.rs") + .expect("cached first file"); + state + .cache_compare_file_from_output(1, "b.rs") + .expect("cached second file"); + + let (doc, _effects) = state.build_continuous_viewport_document(); + let doc = doc.expect("viewport doc"); + + assert!( + doc.stream_items + .iter() + .any(|item| item.id.kind == VirtualDiffItemKind::FileHeader) + ); + assert!( + doc.stream_items + .iter() + .any(|item| item.id.kind == VirtualDiffItemKind::Hunk) + ); + assert!( + doc.stream_items + .iter() + .any(|item| item.id.kind == VirtualDiffItemKind::DiffRow) + ); + assert!( + doc.stream_items + .windows(2) + .all(|items| items[0].sort_key <= items[1].sort_key) + ); + assert!( + doc.stream_items + .iter() + .all(|item| item.estimated_height_px > 0) + ); +} + +#[test] +fn continuous_viewport_document_backfills_before_tail_file() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 500); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(800), Some(800), Some(800)]); + state.recompute_file_scroll_total_height_px(); + state + .workspace + .global_scroll_top_px + .set(&state.store, 1_700); + + let (doc, _effects) = state.build_continuous_viewport_document(); + let doc = doc.expect("viewport doc"); + + assert_eq!(doc.slot_indices, vec![1, 2]); + assert_eq!(doc.start_index, 1); + assert_eq!(doc.start_offset_px, 800); + assert_eq!(doc.scroll_top_px, 900); +} + +#[test] +fn continuous_viewport_document_follow_end_builds_from_tail() { + let mut state = + loaded_state_with_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs", "tail.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 500); + state.workspace.file_content_heights.set( + &state.store, + vec![ + Some(1_000), + Some(1_000), + Some(1_000), + Some(1_000), + Some(1_000), + Some(1_000), + Some(100), + ], + ); + state.recompute_file_scroll_total_height_px(); + + state.scroll_viewport_to_global(state.global_max_scroll_top_px()); + + let (doc, _effects) = state.build_continuous_viewport_document(); + let doc = doc.expect("viewport doc"); + + assert_eq!(doc.slot_indices, vec![5, 6]); + assert_eq!(doc.start_index, 5); + assert_eq!(doc.start_offset_px, 5_000); + assert_eq!(doc.scroll_top_px, 600); +} + +#[test] +fn next_file_action_resolves_current_file_from_selected_path() { + let mut state = loaded_state_with_files(&[ + "src/core/compare/backends/git_diff.rs", + "src/core/compare/mod.rs", + "src/core/compare/service.rs", + "src/core/compare/stats.rs", + "src/core/frecency.rs", + "src/ui/state/mod.rs", + "src/ui/state/text_edit.rs", + "src/ui/toolbar.rs", + ]); + state.settings.continuous_scroll = true; + state + .workspace + .selected_file_index + .set(&state.store, Some(0)); + state + .workspace + .selected_file_path + .set(&state.store, Some("src/ui/state/mod.rs".to_owned())); + + state.apply_action(crate::actions::EditorAction::GoToNextFile); + + assert_eq!( + state.workspace.selected_file_index.get(&state.store), + Some(6) + ); + assert_eq!( + state + .workspace + .selected_file_path + .get(&state.store) + .as_deref(), + Some("src/ui/state/text_edit.rs") + ); +} + +#[test] +fn selecting_a_file_requests_async_syntax_without_mutating_compare_output() { + let mut state = AppState::default(); + let mut output = CompareOutput::default(); + output.carbon.files = vec![carbon_context_file( + 0, + "src/lib.rs", + "fn answer() -> i32 { 42 }", + )]; + state + .workspace + .compare_output + .set(&state.store, Some(output)); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.workspace.files.set( + &state.store, + vec![FileListEntry { + path: "src/lib.rs".into(), + }], + ); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/tmp/repo"))); + state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + + let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + assert!(effects.iter().any(|effect| { + matches!( + effect, + Effect::Syntax(SyntaxEffect::LoadFileSyntax(task)) + if task.request.path == "src/lib.rs" + && task.request.window.start == 0 + && task.request.window.end > 0 + ) + })); + state.workspace.compare_output.with(&state.store, |co| { + let output = co.as_ref().expect("compare output"); + assert_eq!(output.carbon.files[0].path(), "src/lib.rs"); + assert_eq!(output.carbon.files[0].hunks.len(), 1); + }); +} + +#[test] +fn prepare_active_file_builds_from_carbon_text() { + let carbon_file = carbon::parse_unified_patch( + "\ +diff --git a/src/lib.rs b/src/lib.rs +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1 +1 @@ + fn answer() -> i32 { 42 } +", + ) + .unwrap() + .files + .into_iter() + .next() + .unwrap(); + + let prepared = prepare_active_file(0, &carbon_file); + + assert_eq!(prepared.carbon_file.path(), "src/lib.rs"); + assert!(prepared.render_doc.lines.iter().any(|render_line| { + prepared.render_doc.line_text(render_line.left_text) == "fn answer() -> i32 { 42 }" + || prepared.render_doc.line_text(render_line.right_text) == "fn answer() -> i32 { 42 }" + })); +} + +#[test] +fn small_compare_file_selection_stays_synchronous() { + let mut state = AppState::default(); + let mut output = CompareOutput::default(); + let mut carbon_file = carbon_context_file(0, "src/lib.rs", "fn answer() -> i32 { 42 }"); + carbon_file.additions = 10; + carbon_file.deletions = 5; + output.carbon.files = vec![carbon_file]; + + state + .workspace + .compare_output + .set(&state.store, Some(output)); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.workspace.files.set( + &state.store, + vec![FileListEntry { + path: "src/lib.rs".into(), + }], + ); + state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + + let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + assert!(effects.iter().any(|effect| { + matches!(effect, Effect::Syntax(SyntaxEffect::EnsureSyntaxPackForPath { path }) if path == "src/lib.rs") + })); + assert!( + !effects + .iter() + .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadFile(_)))) + ); + assert!( + state + .workspace + .active_file_loading + .get(&state.store) + .is_none() + ); + assert_eq!( + state + .workspace + .active_file + .get(&state.store) + .as_ref() + .map(|file| file.path.as_str()), + Some("src/lib.rs") + ); +} + +#[test] +fn selecting_large_compare_file_dispatches_async_load() { + let mut state = loaded_state_with_files(&["src/big.rs"]); + state + .workspace + .compare_output + .update(&state.store, |output| { + let file = &mut output.as_mut().expect("compare output").carbon.files[0]; + file.additions = 1_500; + }); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + state.compare.left_ref.set(&state.store, "v5.5".to_owned()); + state.compare.right_ref.set(&state.store, "v5.6".to_owned()); + state + .compare + .renderer + .set(&state.store, RendererKind::Builtin); + state.compare.layout.set(&state.store, LayoutMode::Unified); + state.compare.mode.set(&state.store, CompareMode::TwoDot); + + let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + assert!(matches!( + effects.as_slice(), + [ + Effect::Syntax(SyntaxEffect::EnsureSyntaxPackForPath { path }), + Effect::Compare(CompareEffect::LoadFile(task)) + ] + if path == "src/big.rs" + && task.request.index == 0 + && task.request.path == "src/big.rs" + )); + assert_eq!( + state.workspace.active_file_loading.get(&state.store), + Some(ActiveFileLoading { + index: 0, + path: "src/big.rs".to_owned(), + priority: CompareWorkPriority::InteractiveSelectedFile, + }) + ); + assert!(state.workspace.active_file.get(&state.store).is_none()); +} + +#[test] +fn selecting_deferred_compare_file_dispatches_async_load() { + let mut state = loaded_state_with_files(&["src/kernel.c"]); + state + .workspace + .compare_output + .update(&state.store, |output| { + let file = &mut output.as_mut().expect("compare output").carbon.files[0]; + file.is_partial = true; + file.hunks.clear(); + file.blocks.clear(); + }); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + + let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + assert!(effects.iter().any(|effect| { + matches!( + effect, + Effect::Compare(CompareEffect::LoadFile(task)) + if task.request.index == 0 + && task.request.path == "src/kernel.c" + && task.request.deferred_file.as_ref().is_some_and(|file| file.is_partial) + ) + })); + assert_eq!( + state.workspace.active_file_loading.get(&state.store), + Some(ActiveFileLoading { + index: 0, + path: "src/kernel.c".to_owned(), + priority: CompareWorkPriority::InteractiveSelectedFile, + }) + ); + assert!(state.workspace.active_file.get(&state.store).is_none()); +} + +#[test] +fn scrollbar_drag_loads_visible_compare_files_without_selecting_them() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 240); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + state + .workspace + .compare_output + .update(&state.store, |output| { + for file in &mut output.as_mut().expect("compare output").carbon.files { + file.is_partial = true; + file.hunks.clear(); + file.blocks.clear(); + } + }); + state.begin_viewport_scrollbar_drag(900, 240, 300, 660); + + let (_doc, effects) = state.build_continuous_viewport_document(); + + assert!( + effects + .iter() + .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadFile(_)))) + ); + assert!( + state + .workspace + .active_file_loading + .get(&state.store) + .is_none() + ); +} + +#[test] +fn overscan_prefetch_does_not_enqueue_syntax_work() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + + let effects = state.prefetch_compare_files_forward(0, 1_000); + + assert!( + !effects + .iter() + .any(|effect| matches!(effect, Effect::Syntax(SyntaxEffect::LoadFileSyntax(_)))), + "overscan should warm file diffs without adding syntax windows" + ); + state.workspace.file_cache.with(&state.store, |files| { + assert!(files.values().all(|file| file.syntax_pending.is_empty())); + }); +} + +#[test] +fn offscreen_viewport_slots_do_not_enqueue_syntax_work() { + let mut state = loaded_state_with_files(&["a.rs"]); + state + .cache_compare_file_from_output(0, "a.rs") + .expect("cached file"); + let key = state.compare_slot_key_at(0, "a.rs"); + + let window = state.viewport_slot_syntax_window(&key, 1_000, 120, 0, 240); + + assert_eq!(window, None); +} + +#[test] +fn syntax_budget_counts_inflight_requests_after_cache_eviction() { + let mut state = loaded_state_with_files(&["a.rs"]); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + state + .cache_compare_file_from_output(0, "a.rs") + .expect("cached file"); + let key = state.compare_slot_key_at(0, "a.rs"); + + let effect = state.request_viewport_slot_syntax_window( + &key, + crate::core::syntax::annotator::SyntaxRowWindow { start: 0, end: 32 }, + ); + + assert!(matches!( + effect, + Some(Effect::Syntax(SyntaxEffect::LoadFileSyntax(_))) + )); + state.workspace.file_cache.update(&state.store, |files| { + files.clear(); + }); + assert_eq!(state.syntax_pending_window_count(), 0); + assert_eq!(state.syntax_requests.inflight_len(), 1); +} + +#[test] +fn syntax_epoch_invalidation_clears_attached_pending_windows() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs"]); + state + .cache_compare_file_from_output(0, "a.rs") + .expect("cached file"); + state + .cache_compare_file_from_output(1, "b.rs") + .expect("cached file"); + let active = state + .workspace + .file_cache + .with(&state.store, |files| files.get(&0).cloned()) + .expect("cached active"); + state.workspace.active_file.set(&state.store, Some(active)); + let pending = super::SyntaxPendingWindow { + request_id: 1, + window: crate::core::syntax::annotator::SyntaxRowWindow { start: 0, end: 32 }, + }; + state.workspace.active_file.update(&state.store, |active| { + active + .as_mut() + .expect("active file") + .syntax_pending + .push(pending); + }); + state.workspace.file_cache.update(&state.store, |files| { + for file in files.values_mut() { + file.syntax_pending.push(pending); + } + }); + state.syntax_requests.insert_inflight(0, 1); + + let effect = state.invalidate_syntax_epoch_effect(); + + assert!(matches!( + effect, + Effect::Syntax(SyntaxEffect::SetFileSyntaxEpoch { .. }) + )); + assert_eq!(state.syntax_pending_window_count(), 0); + assert_eq!(state.syntax_requests.inflight_len(), 0); +} + +#[test] +fn context_expansion_invalidates_existing_syntax_windows() { + let mut state = status_state_with_two_hunks(); + let stale_window = crate::core::syntax::annotator::SyntaxRowWindow { start: 0, end: 8 }; + + state.workspace.active_file.update(&state.store, |active| { + let active = active.as_mut().expect("active file"); + active.syntax_pending.push(super::SyntaxPendingWindow { + request_id: 7, + window: stale_window, + }); + active.syntax_covered.push(stale_window); + let range = active + .token_buffer + .append(&[crate::core::text::DiffTokenSpan { + offset: 0, + length: 2, + kind: Default::default(), + intensity: Default::default(), + }]); + active + .carbon_overlays + .insert_syntax(0, carbon::DiffSide::Old, 0, range); + }); + + state.apply_context_expansion( + crate::events::ContextDirection::All, + 0, + 0, + Arc::new((0..12).map(|index| format!("old {index}")).collect()), + Arc::new((0..12).map(|index| format!("new {index}")).collect()), + ); + + state.workspace.active_file.with(&state.store, |active| { + let active = active.as_ref().expect("active file"); + assert!(active.syntax_pending.is_empty()); + assert!(active.syntax_covered.is_empty()); + assert_eq!(active.token_buffer.len(), 0); + }); +} + +#[test] +fn context_expansion_retires_old_syntax_epoch_before_requeue() { + let mut state = status_state_with_two_hunks(); + state.workspace.active_file.update(&state.store, |active| { + let active = active.as_mut().expect("active file"); + active.old_file_lines = Some(Arc::new( + (0..12).map(|index| format!("old {index}")).collect(), + )); + active.file_lines = Some(Arc::new( + (0..12).map(|index| format!("new {index}")).collect(), + )); + }); + state.workspace.compare_generation.set(&state.store, 1); + for request_id in 0..super::MAX_PENDING_SYNTAX_WINDOWS as u64 { + state.syntax_requests.insert_inflight(0, request_id); + } + + let effects = state.dispatch_context_expansion(0, crate::events::ContextDirection::All, 0); + + assert!(matches!( + effects.first(), + Some(Effect::Syntax(SyntaxEffect::SetFileSyntaxEpoch { .. })) + )); + assert!(effects.iter().any(|effect| { + matches!( + effect, + Effect::Syntax(SyntaxEffect::LoadFileSyntax(task)) + if task.request.syntax_epoch == state.syntax_requests.epoch() + ) + })); + assert_eq!(state.syntax_requests.inflight_len(), 1); +} + +#[test] +fn syntax_pack_install_retires_old_epoch_before_refresh() { + let mut state = status_state_with_two_hunks(); + for request_id in 0..super::MAX_PENDING_SYNTAX_WINDOWS as u64 { + state.syntax_requests.insert_inflight(0, request_id); + } + + let effects = state.handle_syntax_packs_installed(&["rust".to_owned()]); + + assert!(matches!( + effects.first(), + Some(Effect::Syntax(SyntaxEffect::SetFileSyntaxEpoch { .. })) + )); + assert!(effects.iter().any(|effect| { + matches!( + effect, + Effect::Syntax(SyntaxEffect::LoadFileSyntax(task)) + if task.request.syntax_epoch == state.syntax_requests.epoch() + ) + })); + assert_eq!(state.syntax_requests.inflight_len(), 1); +} + +#[test] +fn compare_file_finished_ignores_stale_path() { + let mut state = loaded_state_with_files(&["src/lib.rs"]); + state.workspace.compare_generation.set(&state.store, 7); + state + .workspace + .selected_file_index + .set(&state.store, Some(0)); + state + .workspace + .selected_file_path + .set(&state.store, Some("src/lib.rs".to_owned())); + state.workspace.active_file_loading.set( + &state.store, + Some(ActiveFileLoading { + index: 0, + path: "src/lib.rs".to_owned(), + priority: CompareWorkPriority::InteractiveSelectedFile, + }), + ); + + state.apply_event(AppEvent::from(CompareEvent::CompareFileFinished( + CompareFileFinished { + generation: 7, + index: 0, + path: "src/other.rs".to_owned(), + prepared: PreparedActiveFile { + carbon_file: carbon::FileDiff::default(), + carbon_expansion: carbon::ExpansionState::default(), + carbon_overlays: CarbonStyleOverlays::default(), + render_doc: Arc::new(RenderDoc::default()), + token_buffer: TokenBuffer::default(), + }, + }, + ))); + + assert!(state.workspace.active_file.get(&state.store).is_none()); + assert_eq!( + state.workspace.active_file_loading.get(&state.store), + Some(ActiveFileLoading { + index: 0, + path: "src/lib.rs".to_owned(), + priority: CompareWorkPriority::InteractiveSelectedFile, + }) + ); +} + +#[test] +fn overlay_list_pixel_scroll_action_clamps_active_overlay() { + let mut state = AppState::default(); + state.overlays.stack.update(&state.store, |stack| { + stack.push(super::OverlayEntry { + surface: OverlaySurface::RepoPicker, + focus_return: None, + }); + }); + let picker_entries: Vec = (0..12) + .map(|index| super::PickerEntry { + label: format!("repo-{index}"), + detail: format!("C:\\repo-{index}"), + value: format!("C:\\repo-{index}"), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + icon: None, + section_header: false, + }) + .collect(); + state + .overlays + .picker + .entries + .set(&state.store, picker_entries); + state + .overlays + .picker + .list + .update(&state.store, |l| l.viewport_height_px = 120); + + state.apply_action(crate::actions::OverlayAction::ScrollActiveOverlayListPx(50)); + assert_eq!( + state + .overlays + .picker + .list + .with(&state.store, |l| l.scroll_top_px), + 50 + ); + + state.apply_action(crate::actions::OverlayAction::ScrollActiveOverlayListPx( + 1_000, + )); + assert_eq!( + state + .overlays + .picker + .list + .with(&state.store, |l| l.scroll_top_px), + 312 + ); + + state.apply_action(crate::actions::OverlayAction::ScrollActiveOverlayListPx( + -1_000, + )); + assert_eq!( + state + .overlays + .picker + .list + .with(&state.store, |l| l.scroll_top_px), + 0 + ); +} + +#[test] +fn closing_overlays_restores_previous_focus() { + let mut state = AppState::default(); + state.apply_action(crate::actions::AppAction::SetFocus(Some( + FocusTarget::FileList, + ))); + + state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); + assert_eq!( + state.focus.get(&state.store), + Some(FocusTarget::CommandPaletteInput) + ); + + // Each nested overlay records its own restore target. + state.apply_action(crate::actions::OverlayAction::OpenGitHubAuthModal); + assert_eq!( + state.focus.get(&state.store), + Some(FocusTarget::AuthPrimaryAction) + ); + + state.apply_action(crate::actions::OverlayAction::CloseOverlay); + assert_eq!(state.overlays_top(), Some(OverlaySurface::CommandPalette)); + assert_eq!( + state.focus.get(&state.store), + Some(FocusTarget::CommandPaletteInput) + ); + + state.apply_action(crate::actions::OverlayAction::CloseOverlay); + assert_eq!(state.overlays_top(), None); + assert_eq!(state.focus.get(&state.store), Some(FocusTarget::FileList)); +} + +#[test] +fn clearing_overlay_stack_restores_pre_overlay_focus() { + let mut state = AppState::default(); + state.apply_action(crate::actions::AppAction::SetFocus(Some( + FocusTarget::FileList, + ))); + state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); + state.apply_action(crate::actions::OverlayAction::OpenGitHubAuthModal); + + state.clear_overlays(); + + assert_eq!(state.overlays_top(), None); + assert_eq!(state.focus.get(&state.store), Some(FocusTarget::FileList)); +} + +#[test] +fn stage_hunk_at_stages_the_given_index() { + let mut state = status_state_with_two_hunks(); + + let effects = state.apply_action(crate::actions::RepositoryAction::StageHunkAt(1)); + + let [Effect::Repository(RepositoryEffect::ApplyPatchOperation(request))] = effects.as_slice() + else { + panic!("expected one patch effect, got {:?}", effects); + }; + assert!(request.patch.contains("old_second();")); + assert!(!request.patch.contains("old_first();")); +} + +#[test] +fn stage_hunk_reads_the_hovered_hunk_index() { + let mut state = status_state_with_two_hunks(); + state.editor.hovered_hunk_index.set(&state.store, Some(1)); + + let effects = state.apply_action(crate::actions::RepositoryAction::StageHunk); + + let [Effect::Repository(RepositoryEffect::ApplyPatchOperation(request))] = effects.as_slice() + else { + panic!("expected one patch effect"); + }; + assert!(request.patch.contains("old_second();")); +} + +#[test] +fn stage_hunk_without_partial_hunk_capability_is_ignored() { + let mut state = status_state_with_two_hunks(); + let mut capabilities = RepoCapabilities::git(); + capabilities.staging_area = false; + capabilities.partial_hunk_mutation = false; + state + .repository + .capabilities + .set(&state.store, Some(capabilities)); + + let effects = state.apply_action(crate::actions::RepositoryAction::StageHunkAt(0)); + + assert!(effects.is_empty()); +} + +#[test] +fn status_operation_failure_clears_the_pending_flag() { + let mut state = status_state_with_two_hunks(); + let _ = state.apply_action(crate::actions::RepositoryAction::StageHunkAt(0)); + assert!(state.workspace.status_operation_pending.get(&state.store)); + + let _ = state.apply_event(AppEvent::from(RepositoryEvent::FileOperationFailed { + path: PathBuf::from("/repo"), + message: "patch failed".to_owned(), + })); + + assert!(!state.workspace.status_operation_pending.get(&state.store)); +} + +#[test] +fn ref_picker_rebuilds_matches_while_typing_and_keeps_raw_git_revisions_selectable() { + let mut state = AppState::default(); + state.repository.refs.set( + &state.store, + vec![VcsRef { + name: "main".to_owned(), + kind: RefKind::Branch, + target: RevisionId::git("0000000000000000000000000000000000000000"), + active: true, + upstream: None, + ahead_behind: None, + }], + ); + + state.open_ref_picker(CompareField::Left); + state.apply_action(crate::actions::TextEditAction::InsertText("mai".to_owned())); + + let branch_highlights = state.overlays.picker.entries.with(&state.store, |entries| { + entries + .iter() + .find(|entry| entry.value == "main") + .expect("main branch entry") + .highlights + .clone() + }); + assert_eq!(branch_highlights, vec![(0, 3)]); + + let mut state = AppState::default(); + state.open_ref_picker(CompareField::Left); + state.apply_action(crate::actions::TextEditAction::InsertText( + "HEAD~2".to_owned(), + )); + + let (typed_value, typed_highlights) = + state.overlays.picker.entries.with(&state.store, |entries| { + let typed_entry = entries.first().expect("typed ref entry"); + (typed_entry.value.clone(), typed_entry.highlights.clone()) + }); + assert_eq!(typed_value, "HEAD~2"); + assert_eq!(typed_highlights, vec![(0, "HEAD~2".len())]); + + state.apply_action(crate::actions::OverlayAction::ConfirmOverlaySelection); + assert_eq!(state.compare.left_ref.get(&state.store), "HEAD~2"); +} + +#[test] +fn ref_picker_uses_jj_refs_and_change_ids_without_git_workdir() { + let mut state = AppState::default(); + let working_commit = "3e2d7a6e55221e519e3efb86e4f8fbb324980427".to_owned(); + let change_id = "xxyzvpwmsuxytmqltlzwzqpylvlqqyso".to_owned(); + + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: PathBuf::from("/repo"), + store_root: Some(PathBuf::from("/repo/.jj")), + }), + ); + state.repository.refs.set( + &state.store, + vec![ + VcsRef { + name: "@".to_owned(), + kind: RefKind::WorkingCopy, + target: RevisionId { + backend: VcsKind::JJ, + id: working_commit.clone(), + }, + active: true, + upstream: None, + ahead_behind: None, + }, + VcsRef { + name: "main".to_owned(), + kind: RefKind::Bookmark, + target: RevisionId { + backend: VcsKind::JJ, + id: "a4c9f6e8b1d24036a78610a332e12ca25e97c315".to_owned(), + }, + active: false, + upstream: None, + ahead_behind: None, + }, + ], + ); + state.repository.changes.set( + &state.store, + vec![VcsChange { + revision: RevisionId { + backend: VcsKind::JJ, + id: working_commit, + }, + change_id: Some(change_id.clone()), + short_change_id: Some("xsvsonvs".to_owned()), + short_change_id_prefix_len: Some(2), + short_revision: "3e2d7a6e5522".to_owned(), + summary: "Working copy".to_owned(), + author_name: "ro".to_owned(), + timestamp: 0, + flags: ChangeFlags { + current: true, + working_copy: true, + ..ChangeFlags::default() + }, + }], + ); + + state.open_ref_picker(CompareField::Left); + + state.overlays.picker.entries.with(&state.store, |entries| { + assert!(!entries.iter().any(|entry| entry.value == "@workdir")); + + let working_copy = entries + .iter() + .find(|entry| entry.value == "@") + .expect("working copy ref"); + assert_eq!( + working_copy.detail, + "Working copy change \u{2022} current / xsvsonvs 3e2d7a6e5522" + ); + + let bookmark = entries + .iter() + .find(|entry| entry.value == "main") + .expect("bookmark ref"); + assert_eq!(bookmark.detail, "Bookmark"); + + let change = entries + .iter() + .find(|entry| entry.value == change_id) + .expect("change id entry"); + assert_eq!(change.label, "xsvsonvs"); + assert!(change.highlights.is_empty()); + assert_eq!( + change.label_style(), + PickerLabelStyle::JjChangeId { + prefix_len: 2, + working_copy: true, + } + ); + }); +} + +#[test] +fn command_palette_uses_actual_match_indices_for_highlighting() { + let mut state = AppState::default(); + state + .overlays + .command_palette + .query + .set(&state.store, "them".to_owned()); + + state.rebuild_command_palette(); + + let highlights = state + .overlays + .command_palette + .entries + .with(&state.store, |entries| { + entries + .iter() + .find(|entry| entry.label == "Change Theme") + .expect("Change Theme entry") + .highlights + .clone() + }); + assert_eq!(highlights, vec![(7, 11)]); +} + +#[test] +fn command_palette_surfaces_jj_operations_for_jj_repositories() { + let mut state = AppState::default(); + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: PathBuf::from("/repo"), + store_root: Some(PathBuf::from("/repo/.jj")), + }), + ); + state + .overlays + .command_palette + .query + .set(&state.store, "jj".to_owned()); + + state.rebuild_command_palette(); + + let entries = state + .overlays + .command_palette + .entries + .with(&state.store, |entries| entries.clone()); + for operation in JjOperation::ALL { + let label = format!("jj: {}", operation.label()); + let entry = entries + .iter() + .find(|entry| entry.label == label) + .unwrap_or_else(|| panic!("missing {label} command")); + assert!(matches!( + entry.kind, + super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( + VcsOperation::Jj(found) + )) if found == operation + )); + } + + let mut state = AppState::default(); + state + .overlays + .command_palette + .query + .set(&state.store, "jj".to_owned()); + state.rebuild_command_palette(); + let has_jj_operation = state + .overlays + .command_palette + .entries + .with(&state.store, |entries| { + entries.iter().any(|entry| { + JjOperation::ALL + .into_iter() + .any(|operation| entry.label == format!("jj: {}", operation.label())) + }) + }); + assert!(!has_jj_operation); +} + +#[test] +fn jj_operation_action_emits_repository_effect() { + let mut state = AppState::default(); + let repo_path = PathBuf::from("/repo"); + let operation = VcsOperation::Jj(JjOperation::NewChange); + state + .compare + .repo_path + .set(&state.store, Some(repo_path.clone())); + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: repo_path.clone(), + store_root: Some(repo_path.join(".jj")), + }), + ); + + let effects = state.apply_action(crate::actions::RepositoryAction::RunOperation( + operation.clone(), + )); + + let [Effect::Repository(RepositoryEffect::RunOperation(request))] = effects.as_slice() else { + panic!("expected RunOperation effect, got {effects:?}"); + }; + assert_eq!(request.repo_path, repo_path); + assert_eq!(request.operation, operation); +} + +#[test] +fn destructive_jj_palette_operation_requires_confirmation() { + let mut state = AppState::default(); + let repo_path = PathBuf::from("/repo"); + let operation = VcsOperation::Jj(JjOperation::AbandonChange); + state + .compare + .repo_path + .set(&state.store, Some(repo_path.clone())); + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: repo_path.clone(), + store_root: Some(repo_path.join(".jj")), + }), + ); + state + .overlays + .command_palette + .query + .set(&state.store, "abandon".to_owned()); + state.overlays.stack.update(&state.store, |stack| { + stack.push(OverlayEntry { + surface: OverlaySurface::CommandPalette, + focus_return: None, + }); + }); + state.rebuild_command_palette(); + + let effects = state.apply_action(crate::actions::OverlayAction::ConfirmOverlaySelection); + + assert!(effects.is_empty()); + assert_eq!(state.overlays_top(), Some(OverlaySurface::Confirmation)); + assert_eq!( + state.overlays.confirmation.action.get(&state.store), + Some(crate::actions::RepositoryAction::RunOperation(operation.clone()).into()) + ); + + let effects = state.apply_action(crate::actions::OverlayAction::ConfirmOverlaySelection); + + let [Effect::Repository(RepositoryEffect::RunOperation(request))] = effects.as_slice() else { + panic!("expected RunOperation effect, got {effects:?}"); + }; + assert_eq!(request.repo_path, repo_path); + assert_eq!(request.operation, operation); + assert_eq!(state.overlays_top(), None); +} + +#[test] +fn command_palette_surfaces_jj_rebase_destinations() { + let mut state = AppState::default(); + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: PathBuf::from("/repo"), + store_root: Some(PathBuf::from("/repo/.jj")), + }), + ); + state.repository.refs.set( + &state.store, + vec![ + VcsRef { + name: "@".to_owned(), + kind: RefKind::WorkingCopy, + target: RevisionId { + backend: VcsKind::JJ, + id: "current".to_owned(), + }, + active: true, + upstream: None, + ahead_behind: None, + }, + VcsRef { + name: "main".to_owned(), + kind: RefKind::Bookmark, + target: RevisionId { + backend: VcsKind::JJ, + id: "main-revision".to_owned(), + }, + active: false, + upstream: None, + ahead_behind: None, + }, + ], + ); + state + .overlays + .command_palette + .query + .set(&state.store, "rebase main".to_owned()); + + state.rebuild_command_palette(); + + let entry = state + .overlays + .command_palette + .entries + .with(&state.store, |entries| entries.first().cloned()) + .expect("rebase entry"); + assert_eq!(entry.label, "jj: Rebase @ Onto main"); + assert!(matches!( + entry.kind, + super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( + VcsOperation::JjRebaseCurrentChangeOnto { ref destination } + )) if destination == "main" + )); +} + +#[test] +fn command_palette_surfaces_jj_editable_changes() { + let mut state = AppState::default(); + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: PathBuf::from("/repo"), + store_root: Some(PathBuf::from("/repo/.jj")), + }), + ); + state.repository.changes.set( + &state.store, + vec![ + VcsChange { + revision: RevisionId { + backend: VcsKind::JJ, + id: "current-revision".to_owned(), + }, + change_id: Some("current-change".to_owned()), + short_change_id: Some("cur".to_owned()), + short_change_id_prefix_len: Some(3), + short_revision: "currev".to_owned(), + summary: "current".to_owned(), + author_name: "ro".to_owned(), + timestamp: 0, + flags: ChangeFlags { + current: true, + working_copy: true, + ..ChangeFlags::default() + }, + }, + VcsChange { + revision: RevisionId { + backend: VcsKind::JJ, + id: "target-revision".to_owned(), + }, + change_id: Some("target-change".to_owned()), + short_change_id: Some("tgt".to_owned()), + short_change_id_prefix_len: Some(3), + short_revision: "tgt123".to_owned(), + summary: "target change".to_owned(), + author_name: "ro".to_owned(), + timestamp: 0, + flags: ChangeFlags::default(), + }, + ], + ); + state + .overlays + .command_palette + .query + .set(&state.store, "edit tgt".to_owned()); + + state.rebuild_command_palette(); + + let entry = state + .overlays + .command_palette + .entries + .with(&state.store, |entries| entries.first().cloned()) + .expect("edit entry"); + assert_eq!(entry.label, "jj: Edit tgt"); + assert!(matches!( + entry.kind, + super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( + VcsOperation::JjEditRevision { + ref revision, + ref label + } + )) if revision == "target-revision" && label == "tgt" + )); +} + +#[test] +fn command_palette_surfaces_jj_operation_log_restore_targets() { + let mut state = AppState::default(); + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: PathBuf::from("/repo"), + store_root: Some(PathBuf::from("/repo/.jj")), + }), + ); + state.repository.operation_log.set( + &state.store, + vec![ + VcsOperationLogEntry { + operation_id: "current-operation".to_owned(), + short_operation_id: "current".to_owned(), + user: "ro".to_owned(), + time: "later".to_owned(), + description: "snapshot working copy".to_owned(), + }, + VcsOperationLogEntry { + operation_id: "target-operation".to_owned(), + short_operation_id: "target".to_owned(), + user: "ro".to_owned(), + time: "earlier".to_owned(), + description: "describe change".to_owned(), + }, + ], + ); + state + .overlays + .command_palette + .query + .set(&state.store, "restore target".to_owned()); + + state.rebuild_command_palette(); + + let entries = state + .overlays + .command_palette + .entries + .with(&state.store, |entries| entries.clone()); + assert!( + !entries + .iter() + .any(|entry| entry.label == "jj: Restore Operation current") + ); + let entry = entries + .iter() + .find(|entry| entry.label == "jj: Restore Operation target") + .expect("restore entry"); + assert_eq!(entry.detail, "describe change - ro - earlier"); + assert!(matches!( + entry.kind, + super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( + VcsOperation::JjRestoreOperation { + ref operation_id, + ref label + } + )) if operation_id == "target-operation" && label == "target" + )); +} + +#[test] +fn sidebar_width_action_clamps_and_stores_manual_preference() { + let mut state = AppState::default(); + + state.apply_action(crate::actions::SettingsAction::SetSidebarWidthPx(40)); + assert_eq!(state.settings.sidebar_width_px, Some(179)); + + state.apply_action(crate::actions::SettingsAction::SetSidebarWidthPx(420)); + assert_eq!(state.settings.sidebar_width_px, Some(420)); +} + +#[test] +fn ui_scale_actions_step_and_persist_within_bounds() { + let mut state = AppState::default(); + + let effects = state.apply_action(crate::actions::SettingsAction::IncreaseUiScale); + assert_eq!(state.settings.ui_scale_pct, 110); + assert_eq!(effects.len(), 1); + + for _ in 0..20 { + state.apply_action(crate::actions::SettingsAction::IncreaseUiScale); + } + assert_eq!(state.settings.ui_scale_pct, 180); + + for _ in 0..20 { + state.apply_action(crate::actions::SettingsAction::DecreaseUiScale); + } + assert_eq!(state.settings.ui_scale_pct, 70); +} + +#[test] +fn avatar_url_sized_appends_or_replaces_s_param() { + use super::avatar_url_sized; + assert_eq!( + avatar_url_sized("https://avatars.githubusercontent.com/u/1?v=4", 128), + Some("https://avatars.githubusercontent.com/u/1?v=4&s=128".to_owned()) + ); + assert_eq!( + avatar_url_sized("https://avatars.githubusercontent.com/u/1", 64), + Some("https://avatars.githubusercontent.com/u/1?s=64".to_owned()) + ); + assert_eq!( + avatar_url_sized("https://avatars.githubusercontent.com/u/1?s=40&v=4", 128), + Some("https://avatars.githubusercontent.com/u/1?v=4&s=128".to_owned()) + ); + assert_eq!(avatar_url_sized("", 128), None); +} + +#[test] +fn card_text_selection_slices_normalized_range() { + let body = "the quick brown fox".to_owned(); + // Forward selection. + let mut sel = CardTextSelection::new(7, body.clone(), 4); + sel.focus = 9; + assert_eq!(sel.normalized(), (4, 9)); + assert_eq!(sel.selected_text().as_deref(), Some("quick")); + assert!(!sel.is_collapsed()); + + // Reversed drag yields the same substring. + let mut rev = CardTextSelection::new(7, body.clone(), 9); + rev.focus = 4; + assert_eq!(rev.normalized(), (4, 9)); + assert_eq!(rev.selected_text().as_deref(), Some("quick")); + + // Collapsed selection copies nothing. + let collapsed = CardTextSelection::new(7, body.clone(), 4); + assert!(collapsed.is_collapsed()); + assert_eq!(collapsed.selected_text(), None); + + // Out-of-range anchor is clamped at construction (no panic / no copy). + let clamped = CardTextSelection::new(7, body, 999); + assert!(clamped.is_collapsed()); +} + +#[test] +fn command_palette_detects_pr_url_and_emits_peek_effect() { + let mut state = AppState::default(); + state.overlays.command_palette.query.set( + &state.store, + "https://github.com/foo/bar/pull/42".to_owned(), + ); + + let effects = state.rebuild_command_palette(); + + // A peek effect was fired for the parsed key. + assert!(effects.iter().any(|e| matches!( + e, + Effect::GitHub(GitHubEffect::PeekPullRequest { + owner, repo, number, .. + }) if owner == "foo" && repo == "bar" && *number == 42 + ))); + + // Palette has the synthesized PR entry as the top row with key intact. + let top = state + .overlays + .command_palette + .entries + .with(&state.store, |e| e.first().cloned()) + .expect("palette has at least one entry"); + assert!(matches!( + top.kind, + super::PaletteEntryKind::PullRequest((ref o, ref r, n)) + if o == "foo" && r == "bar" && n == 42 + )); + + // Cache entry is initialized to Loading. + let cached = state.github.pull_request.cache.with(&state.store, |c| { + c.get(&("foo".to_owned(), "bar".to_owned(), 42)).cloned() + }); + let cached = cached.expect("cache entry"); + assert!(matches!(cached.meta, super::PrPeekMeta::Loading)); +} + +#[test] +fn pr_peeked_event_transitions_cache_meta_to_ready() { + use crate::core::forge::github::PullRequestInfo; + use crate::events::AppEvent; + + let mut state = AppState::default(); + state + .overlays + .command_palette + .query + .set(&state.store, "https://github.com/foo/bar/pull/7".to_owned()); + let _ = state.rebuild_command_palette(); + + let info = PullRequestInfo { + title: "Fix thing".to_owned(), + state: "open".to_owned(), + author_login: "alice".to_owned(), + number: 7, + additions: 12, + deletions: 3, + changed_files: 1, + base_branch: "main".to_owned(), + head_branch: "fix".to_owned(), + base_sha: "a".to_owned(), + head_sha: "b".to_owned(), + base_repo_url: String::new(), + head_repo_url: String::new(), + }; + state.apply_event(AppEvent::from(GitHubEvent::PullRequestPeeked { + owner: "foo".to_owned(), + repo: "bar".to_owned(), + number: 7, + info: info.clone(), + })); + + let meta = state.github.pull_request.cache.with(&state.store, |c| { + c.get(&("foo".to_owned(), "bar".to_owned(), 7)) + .map(|e| e.meta.clone()) + }); + assert!(matches!(meta, Some(super::PrPeekMeta::Ready(_)))); +} + +// ----------------------------------------------------------------- +// Compare progress — end-to-end through the event lifecycle +// ----------------------------------------------------------------- + +use super::{ComparePhase, CompareProgress, LoadingSubject}; +use crate::events::{CompareFinished, RepositorySyncReason}; + +fn compare_ready_state() -> AppState { + let state = AppState::default(); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + state.compare.left_ref.set(&state.store, "v5.0".to_owned()); + state.compare.right_ref.set(&state.store, "v5.1".to_owned()); + state.compare.mode.set(&state.store, CompareMode::TwoDot); + state +} + +#[test] +fn kickoff_compare_seeds_progress_with_labels_and_started_at() { + let mut state = compare_ready_state(); + state.clock_ms = 1_000; + let _ = state.kickoff_compare(); + + let progress = state + .compare_progress + .with(&state.store, |p| p.clone()) + .expect("progress should be populated"); + match &progress.subject { + LoadingSubject::Compare { + left_label, + right_label, + } => { + assert_eq!(left_label, "v5.0"); + assert_eq!(right_label, "v5.1"); + } + other => panic!("expected Compare subject, got {other:?}"), + } + assert_eq!(progress.started_at_ms, 1_000); + assert_eq!(progress.phase, ComparePhase::OpeningRepo); + assert_eq!(progress.file_count_total, None); + assert_eq!( + state.workspace_mode.get(&state.store), + WorkspaceMode::Loading, + "viewport should flip to loading so the panel actually renders" + ); +} + +#[test] +fn compare_progress_update_applies_only_when_generation_matches() { + let mut state = compare_ready_state(); + let _ = state.kickoff_compare(); + let generation = state.workspace.compare_generation.get(&state.store); + + // Stale reporter — must be ignored. + state.apply_event(AppEvent::from(CompareEvent::CompareProgressUpdate { + generation: generation.wrapping_sub(1), + phase: ComparePhase::EnumeratingChanges, + })); + assert_eq!( + state + .compare_progress + .with(&state.store, |p| p.as_ref().unwrap().phase), + ComparePhase::OpeningRepo, + "stale generation must not advance the phase" + ); + + // Fresh reporter — applies. + state.apply_event(AppEvent::from(CompareEvent::CompareProgressUpdate { + generation, + phase: ComparePhase::EnumeratingChanges, + })); + assert_eq!( + state + .compare_progress + .with(&state.store, |p| p.as_ref().unwrap().phase), + ComparePhase::EnumeratingChanges, + ); +} + +#[test] +fn loading_files_phase_updates_counts_on_struct() { + let mut state = compare_ready_state(); + let _ = state.kickoff_compare(); + let generation = state.workspace.compare_generation.get(&state.store); + + state.apply_event(AppEvent::from(CompareEvent::CompareProgressUpdate { + generation, + phase: ComparePhase::LoadingFiles { + files_seen: 142, + files_total: 3_891, + }, + })); + + let progress = state + .compare_progress + .with(&state.store, |p| p.clone()) + .expect("progress exists"); + assert_eq!(progress.files_loaded, 142); + assert_eq!(progress.file_count_total, Some(3_891)); + assert!(matches!(progress.phase, ComparePhase::LoadingFiles { .. })); +} + +#[test] +fn kickoff_with_prior_state_reveals_loading_immediately() { + let mut state = compare_ready_state(); + // Simulate a previously loaded compare (files present). + state.workspace.files.set( + &state.store, + vec![FileListEntry { + path: "old.rs".into(), + }], + ); + state.clock_ms = 10_000; + + let _ = state.kickoff_compare(); + let progress = state + .compare_progress + .with(&state.store, |p| p.clone()) + .expect("progress populated"); + assert_eq!(progress.started_at_ms, 10_000); + assert_eq!( + progress.reveal_at_ms, 10_000, + "compare loading should be visible immediately" + ); + assert_ne!( + state.workspace_mode.get(&state.store), + WorkspaceMode::Loading + ); + // Prior files are preserved so fast compares don't cause a flash. + assert_eq!(state.workspace.files.with(&state.store, |f| f.len()), 1); +} + +#[test] +fn open_repository_seeds_repo_subject_progress() { + let mut state = AppState::default(); + state.clock_ms = 500; + + let effects = state.open_repository(PathBuf::from("/tmp/linux")); + + let progress = state + .compare_progress + .with(&state.store, |p| p.clone()) + .expect("progress seeded for repo open"); + match &progress.subject { + LoadingSubject::RepoOpen { name } => { + assert_eq!(name, "linux"); + } + other => panic!("expected RepoOpen subject, got {other:?}"), + } + assert_eq!(progress.phase, ComparePhase::OpeningRepo); + assert_eq!( + progress.reveal_at_ms, + 500 + super::COMPARE_REVEAL_DELAY_MS, + "every repo open delays reveal so sub-threshold opens don't flash" + ); + // Reporter generation is threaded through the SyncRepository effect + // so the worker's phase events stamp the matching generation. + let sync_gen = effects.iter().find_map(|eff| match eff { + Effect::Repository(RepositoryEffect::SyncRepository { + reporter_generation, + .. + }) => *reporter_generation, + _ => None, + }); + assert_eq!(sync_gen, Some(progress.generation)); +} + +#[test] +fn open_repository_with_prior_diff_delays_reveal() { + let mut state = AppState::default(); + state.workspace.files.set( + &state.store, + vec![FileListEntry { + path: "old.rs".into(), + }], + ); + state.clock_ms = 10_000; + + let _ = state.open_repository(PathBuf::from("/tmp/other")); + + let progress = state + .compare_progress + .with(&state.store, |p| p.clone()) + .expect("progress seeded"); + assert_eq!( + progress.reveal_at_ms, 10_500, + "re-open with prior diff delays reveal by COMPARE_REVEAL_DELAY_MS" + ); +} + +#[test] +fn open_repository_resets_stale_compare_refs_before_snapshot() { + let mut state = AppState::default(); + state.compare.left_ref.set(&state.store, "@-".to_owned()); + state.compare.right_ref.set(&state.store, "@".to_owned()); + state.compare.mode.set(&state.store, CompareMode::TwoDot); + + let path = PathBuf::from("/tmp/git-repo"); + let effects = state.open_repository(path.clone()); + + assert_eq!(state.compare.left_ref.get(&state.store), ""); + assert_eq!(state.compare.right_ref.get(&state.store), ""); + assert_eq!(state.compare.mode.get(&state.store), CompareMode::default()); + let saved = effects.iter().find_map(|effect| match effect { + Effect::Settings(SettingsEffect::SaveSettings(settings)) => settings.last_compare.as_ref(), + _ => None, + }); + let saved = saved.expect("open_repository should persist settings"); + assert_eq!(saved.repo_path.as_ref(), Some(&path)); + assert_eq!(saved.left_ref, ""); + assert_eq!(saved.right_ref, ""); +} + +#[test] +fn git_snapshot_after_jj_refs_uses_git_defaults() { + let mut state = AppState::default(); + state.compare.left_ref.set(&state.store, "@-".to_owned()); + state.compare.right_ref.set(&state.store, "@".to_owned()); + state.compare.mode.set(&state.store, CompareMode::TwoDot); + + let path = PathBuf::from("/tmp/git-repo"); + let _ = state.open_repository(path.clone()); + state.apply_event(AppEvent::from(RepositoryEvent::RepositorySnapshotReady( + crate::events::RepositorySnapshot::from_vcs_snapshot( + crate::core::vcs::model::VcsSnapshot { + location: RepoLocation { + kind: VcsKind::GIT, + profile: crate::core::vcs::model::VCS_PROFILE_GIT, + workspace_root: path, + store_root: None, + }, + reason: RepositorySyncReason::Open, + change_kind: None, + capabilities: RepoCapabilities::git(), + refs: Vec::new(), + changes: Vec::new(), + operation_log: Vec::new(), + file_changes: Vec::new(), + }, + ), + ))); + + let (left, right, mode) = + crate::ui::vcs::profile(state.repository.location.get(&state.store).as_ref()) + .default_compare(); + assert_eq!(state.compare.left_ref.get(&state.store), left); + assert_eq!(state.compare.right_ref.get(&state.store), right); + assert_eq!(state.compare.mode.get(&state.store), mode); +} + +#[test] +fn large_compare_stats_stream_offscreen_background_rows_after_visible_rows() { + let state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + state.file_list.row_height.set(&state.store, 36.0); + state.file_list.gap.set(&state.store, 4.0); + state.file_list.viewport_height.set(&state.store, 80.0); + + let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) + .map(|index| { + let path = format!("src/file-{index}.rs"); + let mut summary = CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ); + if index < 128 { + summary.stats_deferred = false; + } + summary + }) + .collect(); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + + let effect = state + .next_compare_stats_hydration_effect() + .expect("huge compares should keep streaming offscreen stats"); + + match effect { + Effect::Compare(CompareEffect::LoadFileStats(task)) => { + assert_eq!(task.request.priority, CompareWorkPriority::Warmup); + assert_eq!(task.request.files.first().map(|item| item.index), Some(128)); + } + other => panic!("expected LoadFileStats effect, got {other:?}"), + } +} + +#[test] +fn large_compare_still_loads_exact_total_stats() { + let mut state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + + let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) + .map(|index| { + let path = format!("src/file-{index}.rs"); + CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ) + }) + .collect(); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + + let effect = state + .start_compare_total_stats_if_needed() + .expect("large deferred compares should request one bounded total-stats job"); + + match effect { + Effect::Compare(CompareEffect::LoadStats(task)) => { + assert_eq!(task.request.priority, CompareWorkPriority::TotalStats); + assert!( + state + .workspace + .compare_total_stats_loading + .get(&state.store) + ); + } + other => panic!("expected LoadStats effect, got {other:?}"), + } +} + +#[test] +fn filtered_compare_stats_hydrates_filtered_visible_raw_indices() { + let state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.file_list.row_height.set(&state.store, 36.0); + state.file_list.gap.set(&state.store, 4.0); + state.file_list.viewport_height.set(&state.store, 80.0); + state + .file_list + .filter + .set(&state.store, "target-only".to_owned()); + + let summaries = (0..50) + .map(|index| { + let path = if index == 40 { + "src/target-only.rs".to_owned() + } else { + format!("src/file-{index}.rs") + }; + CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ) + }) + .collect(); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + + let items = state.visible_compare_stats_hydration_items(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].index, 40); +} + +#[test] +fn tree_compare_stats_hydrates_visible_tree_file_indices() { + let state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state + .file_list + .mode + .set(&state.store, SidebarMode::TreeView); + state + .file_list + .expanded_folders + .set(&state.store, ["a".to_owned()].into_iter().collect()); + state.file_list.row_height.set(&state.store, 36.0); + state.file_list.gap.set(&state.store, 4.0); + state.file_list.viewport_height.set(&state.store, 80.0); + + let summaries = (0..50) + .map(|index| { + let path = if index == 40 { + "a/target-visible.rs".to_owned() + } else { + format!("z/file-{index}.rs") + }; + CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ) + }) + .collect(); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + + let items = state.visible_compare_stats_hydration_items(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].index, 40); +} + +#[test] +fn loaded_compare_stats_update_sidebar_meta() { + let mut state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state + .file_list + .mode + .set(&state.store, SidebarMode::TreeView); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: vec![CompareFileSummary::from_paths_status( + None, + Some("arch/arm64/boot/dts/mediatek/mt8183-kukui-jacuzzi-kenzo.dts"), + carbon::FileStatus::Added, + true, + )], + ..CompareOutput::default() + }), + ); + + let effects = state.handle_compare_file_stats_ready(CompareFileStatsReady { + generation: state.workspace.compare_generation.get(&state.store), + stats: vec![CompareFileStat { + index: 0, + path: "arch/arm64/boot/dts/mediatek/mt8183-kukui-jacuzzi-kenzo.dts".to_owned(), + additions: 13, + deletions: 0, + }], + request_complete: false, + }); + + assert!(effects.is_empty()); + let meta = state.file_list_entry_meta(0); + assert_eq!(meta.additions, 13); + assert_eq!(meta.deletions, 0); + assert!( + !state.workspace.compare_output.with(&state.store, |output| { + output + .as_ref() + .and_then(|output| output.file_summaries.first()) + .is_none_or(|summary| summary.stats_deferred) + }), + "loaded stats must clear the deferred marker used by sidebar rows", + ); +} + +#[test] +fn expanding_tree_folder_starts_visible_stats_hydration() { + let mut state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + state + .file_list + .mode + .set(&state.store, SidebarMode::TreeView); + state.file_list.row_height.set(&state.store, 36.0); + state.file_list.gap.set(&state.store, 4.0); + state.file_list.viewport_height.set(&state.store, 80.0); + + let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) + .map(|index| { + let path = if index == 40 { + "a/target-visible.rs".to_owned() + } else { + format!("z/file-{index}.rs") + }; + CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ) + }) + .collect(); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + state.set_compare_stats_hydration(super::CompareStatsHydrationState::Running); + + let effects = state.apply_action(crate::actions::FileListAction::ToggleFolder("a".to_owned())); + + assert!(effects.iter().any(|effect| { + matches!( + effect, + Effect::Compare(CompareEffect::LoadFileStats(task)) + if task.request.priority == CompareWorkPriority::VisibleSidebarStats + && task.request.files.iter().any(|item| item.index == 40) + ) + })); +} + +#[test] +fn compare_stats_ready_drains_history_when_hydration_has_no_visible_work() { + let mut state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.file_list.tab.set(&state.store, SidebarTab::Commits); + state.workspace.compare_history_pending.set( + &state.store, + Some(crate::effects::CompareHistoryRequest { + repo_path: PathBuf::from("/repo"), + left_ref: "v5.0".to_owned(), + right_ref: "v5.1".to_owned(), + }), + ); + let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) + .map(|index| { + let path = format!("src/file-{index}.rs"); + CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ) + }) + .collect(); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + + let effects = state.handle_compare_stats_ready(CompareStatsReady { + generation: state.workspace.compare_generation.get(&state.store), + additions: 0, + deletions: 0, + }); + + assert!( + effects + .iter() + .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadHistory(_)))) + ); + assert!( + state + .workspace + .compare_history_pending + .get(&state.store) + .is_none() + ); +} + +#[test] +fn compare_file_stats_failure_does_not_retry_same_chunk() { + let mut state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.set_compare_stats_hydration(super::CompareStatsHydrationState::Running); + state.workspace.compare_history_pending.set( + &state.store, + Some(crate::effects::CompareHistoryRequest { + repo_path: PathBuf::from("/repo"), + left_ref: "v5.0".to_owned(), + right_ref: "v5.1".to_owned(), + }), + ); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: vec![CompareFileSummary::from_paths_status( + Some("src/file.rs"), + Some("src/file.rs"), + carbon::FileStatus::Modified, + true, + )], + ..CompareOutput::default() + }), + ); + + let effects = state.apply_event(AppEvent::from(CompareEvent::CompareFileStatsFailed { + generation: state.workspace.compare_generation.get(&state.store), + message: "backend failed".to_owned(), + })); + + assert!( + !effects + .iter() + .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadFileStats(_)))), + "failed stats hydration should not immediately retry the same deferred chunk" + ); + assert!( + effects + .iter() + .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadHistory(_)))) + ); + assert!( + state + .workspace + .compare_history_pending + .get(&state.store) + .is_none() + ); + assert!(state.compare_stats_hydration_failed()); +} + +#[test] +fn repository_snapshot_ready_clears_repo_open_progress() { + let mut state = AppState::default(); + let path = PathBuf::from("/tmp/linux"); + let _ = state.open_repository(path.clone()); + assert!(state.compare_progress.with(&state.store, |p| p.is_some())); + + state.apply_event(AppEvent::from(RepositoryEvent::RepositorySnapshotReady( + crate::events::RepositorySnapshot::from_vcs_snapshot( + crate::core::vcs::model::VcsSnapshot { + location: RepoLocation { + kind: VcsKind::GIT, + profile: crate::core::vcs::model::VCS_PROFILE_GIT, + workspace_root: path, + store_root: None, + }, + reason: RepositorySyncReason::Open, + change_kind: None, + capabilities: RepoCapabilities::git(), + refs: Vec::new(), + changes: Vec::new(), + operation_log: Vec::new(), + file_changes: Vec::new(), + }, + ), + ))); + + assert!( + state.compare_progress.with(&state.store, |p| p.is_none()), + "snapshot-ready must tear down the repo-open progress panel" + ); +} + +#[test] +fn kickoff_without_prior_state_reveals_loading_immediately() { + let mut state = compare_ready_state(); + state.clock_ms = 5_000; + + let _ = state.kickoff_compare(); + let progress = state + .compare_progress + .with(&state.store, |p| p.clone()) + .expect("progress populated"); + assert_eq!(progress.started_at_ms, 5_000); + assert_eq!( + progress.reveal_at_ms, 5_000, + "compare loading should be visible immediately" + ); + // With no prior state to preserve, workspace_mode flips to Loading + // up front so the editor/ready-hint stops rendering in the background. + assert_eq!( + state.workspace_mode.get(&state.store), + WorkspaceMode::Loading + ); +} + +#[test] +fn cancel_compare_bumps_generation_and_drops_stale_result() { + let mut state = compare_ready_state(); + let _ = state.kickoff_compare(); + let generation = state.workspace.compare_generation.get(&state.store); + + let _ = state.cancel_compare(); + + assert!( + state.compare_progress.with(&state.store, |p| p.is_none()), + "progress should be cleared after cancel" + ); + let new_gen = state.workspace.compare_generation.get(&state.store); + assert!(new_gen > generation, "generation should be bumped"); + assert_eq!( + state.workspace_mode.get(&state.store), + WorkspaceMode::Empty, + "fresh-state cancel should revert the Loading flip" + ); + + // A stale CompareFinished arriving after cancel must be silently dropped. + state.apply_event(AppEvent::from(CompareEvent::CompareFinished( + CompareFinished { + generation, + request: vcs_compare_request( + CompareMode::TwoDot, + "v5.0".to_owned(), + "v5.1".to_owned(), + LayoutMode::Unified, + RendererKind::Builtin, + ), + resolved_left: "deadbeef".to_owned(), + resolved_right: "cafefeed".to_owned(), + output: CompareOutput::default(), + range_commits: Vec::new(), + }, + ))); + assert_eq!( + state.workspace_mode.get(&state.store), + WorkspaceMode::Empty, + "stale finished result must not promote workspace to Ready", + ); + assert!( + state.compare_progress.with(&state.store, |p| p.is_none()), + "stale finished result must not re-seed progress", + ); +} + +#[test] +fn cancel_compare_preserves_previous_diff_on_recompare() { + let mut state = compare_ready_state(); + // Prior state: an existing file in the workspace. + state.workspace.files.set( + &state.store, + vec![FileListEntry { + path: "old.rs".into(), + }], + ); + state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + + let _ = state.kickoff_compare(); + let _ = state.cancel_compare(); + + assert!( + state.compare_progress.with(&state.store, |p| p.is_none()), + "progress cleared on cancel" + ); + assert_eq!( + state.workspace_mode.get(&state.store), + WorkspaceMode::Ready, + "previous workspace state is preserved on cancel — no blanking" + ); + assert_eq!( + state.workspace.files.with(&state.store, |f| f.len()), + 1, + "prior file list must not be wiped by cancel" + ); +} + +#[test] +fn compare_finished_advances_phase_and_records_file_count() { + let mut state = compare_ready_state(); + let _ = state.kickoff_compare(); + let generation = state.workspace.compare_generation.get(&state.store); + + // Simulate a successful compare with 3 files. + let files = ["a.rs", "b.rs", "c.rs"]; + let output = CompareOutput { + carbon: carbon::DiffDocument { + files: files + .iter() + .enumerate() + .map(|(index, path)| carbon_summary_for_path(index, path)) + .collect(), + }, + ..CompareOutput::default() + }; + + state.apply_event(AppEvent::from(CompareEvent::CompareFinished( + CompareFinished { + generation, + request: vcs_compare_request( + CompareMode::TwoDot, + "v5.0".to_owned(), + "v5.1".to_owned(), + LayoutMode::Unified, + RendererKind::Builtin, + ), + resolved_left: "deadbeef".to_owned(), + resolved_right: "cafefeed".to_owned(), + output, + range_commits: Vec::new(), + }, + ))); + + // Small files load synchronously, so progress is already cleared by the + // time handle_compare_finished returns. We at least know the workspace + // is Ready and the compare file view is populated from CompareOutput. + assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Ready,); + assert_eq!(state.workspace_file_count(), 3); +} + +#[test] +fn compare_failed_clears_progress_and_marks_workspace_empty() { + let mut state = compare_ready_state(); + let _ = state.kickoff_compare(); + let generation = state.workspace.compare_generation.get(&state.store); + + state.apply_event(AppEvent::from(CompareEvent::CompareFailed { + generation, + message: "boom".to_owned(), + })); + + assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Empty,); + assert!( + state.compare_progress.with(&state.store, |p| p.is_none()), + "progress panel must tear down on compare failure", + ); +} + +#[test] +fn compare_progress_label_does_not_panic_for_all_phases() { + // Non-empty labels matter for the title-bar fallback. Cheap to + // check exhaustively. + let phases = [ + ComparePhase::OpeningRepo, + ComparePhase::ResolvingRefs, + ComparePhase::EnumeratingChanges, + ComparePhase::LoadingFiles { + files_seen: 142, + files_total: 3_891, + }, + ComparePhase::FetchingHistory, + ComparePhase::PopulatingList, + ComparePhase::RenderingFirstFile, + ]; + for phase in phases { + let label = phase.label(); + assert!(!label.is_empty()); + } + // LoadingFiles label should interpolate counts. + assert!( + ComparePhase::LoadingFiles { + files_seen: 142, + files_total: 3_891, + } + .label() + .contains("142"), + "file counts must appear in the label" + ); + + let _ = CompareProgress { + generation: 0, + phase: ComparePhase::default(), + subject: LoadingSubject::Compare { + left_label: String::new(), + right_label: String::new(), + }, + started_at_ms: 0, + reveal_at_ms: 0, + file_count_total: None, + files_loaded: 0, + }; +} diff --git a/src/ui/state/text_compare.rs b/src/ui/state/text_compare.rs index a889eafd..df072ddd 100644 --- a/src/ui/state/text_compare.rs +++ b/src/ui/state/text_compare.rs @@ -309,3 +309,143 @@ fn looks_like_json(source: &str) -> bool { && trimmed.contains(':') && trimmed.contains('"') } + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TextCompareView { + #[default] + Edit, + Diff, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextCompareSide { + Left, + Right, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TextCompareLanguage { + #[default] + Auto, + PlainText, + Rust, + TypeScript, + JavaScript, + Python, + Go, + Json, + Toml, + Shell, + Nix, + C, + Cpp, + Zig, +} + +impl TextCompareLanguage { + pub const OPTIONS: &'static [Self] = &[ + Self::Auto, + Self::PlainText, + Self::Rust, + Self::TypeScript, + Self::JavaScript, + Self::Python, + Self::Go, + Self::Json, + Self::Toml, + Self::Shell, + Self::Nix, + Self::C, + Self::Cpp, + Self::Zig, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Auto => "Auto", + Self::PlainText => "Plain text", + Self::Rust => "Rust", + Self::TypeScript => "TypeScript", + Self::JavaScript => "JavaScript", + Self::Python => "Python", + Self::Go => "Go", + Self::Json => "JSON", + Self::Toml => "TOML", + Self::Shell => "Shell", + Self::Nix => "Nix", + Self::C => "C", + Self::Cpp => "C++", + Self::Zig => "Zig", + } + } + + pub fn short_label(self) -> &'static str { + match self { + Self::Auto => "Auto", + Self::PlainText => "Text", + Self::Rust => "Rust", + Self::TypeScript => "TS", + Self::JavaScript => "JS", + Self::Python => "Py", + Self::Go => "Go", + Self::Json => "JSON", + Self::Toml => "TOML", + Self::Shell => "Sh", + Self::Nix => "Nix", + Self::C => "C", + Self::Cpp => "C++", + Self::Zig => "Zig", + } + } + + pub fn scratch_path(self) -> &'static str { + match self { + Self::Auto | Self::PlainText => "text.txt", + Self::Rust => "scratch.rs", + Self::TypeScript => "scratch.ts", + Self::JavaScript => "scratch.js", + Self::Python => "scratch.py", + Self::Go => "scratch.go", + Self::Json => "scratch.json", + Self::Toml => "scratch.toml", + Self::Shell => "scratch.sh", + Self::Nix => "scratch.nix", + Self::C => "scratch.c", + Self::Cpp => "scratch.cpp", + Self::Zig => "scratch.zig", + } + } +} + +#[derive(Debug, Clone)] +pub struct TextCompareState { + pub left_editor: Editor, + pub right_editor: Editor, + pub language: TextCompareLanguage, + pub detected_language: Option, + pub path_hint: String, + pub view: TextCompareView, + pub generation: u64, + pub last_compared_generation: Option, + pub status: AsyncStatus, +} + +impl Default for TextCompareState { + fn default() -> Self { + let mut left_editor = Editor::new(EditorMode::CodeInput); + let mut right_editor = Editor::new(EditorMode::CodeInput); + left_editor.set_syntax_path("text.txt"); + right_editor.set_syntax_path("text.txt"); + Self { + left_editor, + right_editor, + language: TextCompareLanguage::Auto, + detected_language: None, + path_hint: "text.txt".to_owned(), + view: TextCompareView::default(), + generation: 0, + last_compared_generation: None, + status: AsyncStatus::Idle, + } + } +} diff --git a/src/ui/state/text_edit.rs b/src/ui/state/text_edit.rs index 66d6e326..7eab76a7 100644 --- a/src/ui/state/text_edit.rs +++ b/src/ui/state/text_edit.rs @@ -4,7 +4,7 @@ use crate::actions::TextEditAction; use crate::effects::{AiEffect, Effect, UiEffect}; use crate::platform::secrets::AiKeyKind; -use super::{AppState, CompareField, FocusTarget, PickerKind}; +use super::*; pub(super) fn reduce_action(state: &mut AppState, action: TextEditAction) -> Vec { state.apply_text_edit_action(action) @@ -800,3 +800,163 @@ impl AppState { Vec::new() } } + +/// Cursor/selection state for the currently focused text field. +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct TextEditState { + /// Byte offset of the caret. + pub cursor: usize, + /// Byte offset of the selection anchor. Equal to `cursor` when nothing is selected. + pub anchor: usize, + /// Timestamp (clock_ms) when the cursor last moved — used to reset blink phase. + pub cursor_moved_at_ms: u64, +} + +impl AppState { + /// Set cursor and anchor to the same offset and refresh the blink timestamp. + pub(super) fn reset_text_edit(&mut self, offset: usize) { + self.text_edit.cursor.set(&self.store, offset); + self.text_edit.anchor.set(&self.store, offset); + self.text_edit + .cursor_moved_at_ms + .set(&self.store, self.clock_ms); + } + + /// Run `f` against the text string for the given focus target, if it's a text field. + pub(super) fn with_text_for_focus( + &self, + target: FocusTarget, + f: impl FnOnce(&str) -> R, + ) -> Option { + match target { + FocusTarget::PickerInput => match self.overlays.picker.kind.get(&self.store) { + PickerKind::Repository + | PickerKind::Theme + | PickerKind::UiFont + | PickerKind::MonoFont => { + Some(self.overlays.picker.query.with(&self.store, |s| f(s))) + } + PickerKind::LeftRef => Some(self.compare.left_ref.with(&self.store, |s| f(s))), + PickerKind::RightRef => Some(self.compare.right_ref.with(&self.store, |s| f(s))), + }, + FocusTarget::CommandPaletteInput => Some( + self.overlays + .command_palette + .query + .with(&self.store, |s| f(s)), + ), + FocusTarget::SidebarSearch => Some(self.file_list.filter.with(&self.store, |s| f(s))), + FocusTarget::SearchInput => Some(self.editor.search.query.with(&self.store, |s| f(s))), + FocusTarget::CommitEditor => None, + FocusTarget::SettingsOpenAiKey => Some(f(&self.ai_openai_key)), + FocusTarget::SettingsAnthropicKey => Some(f(&self.ai_anthropic_key)), + FocusTarget::SettingsSteeringPrompt => None, + FocusTarget::TextCompareLeft | FocusTarget::TextCompareRight => None, + _ => None, + } + } + + pub(super) fn with_focused_text(&self, f: impl FnOnce(&str) -> R) -> Option { + let target = self.focus.get(&self.store)?; + self.with_text_for_focus(target, f) + } + + pub(super) fn update_focused_text(&mut self, f: impl FnOnce(&mut String) -> R) -> Option { + match self.focus.get(&self.store) { + Some(FocusTarget::PickerInput) => match self.overlays.picker.kind.get(&self.store) { + PickerKind::Repository + | PickerKind::Theme + | PickerKind::UiFont + | PickerKind::MonoFont => { + let mut out = None; + self.overlays + .picker + .query + .update(&self.store, |s| out = Some(f(s))); + out + } + PickerKind::LeftRef => { + let mut out = None; + self.compare + .left_ref + .update(&self.store, |s| out = Some(f(s))); + out + } + PickerKind::RightRef => { + let mut out = None; + self.compare + .right_ref + .update(&self.store, |s| out = Some(f(s))); + out + } + }, + Some(FocusTarget::CommandPaletteInput) => { + let mut out = None; + self.overlays + .command_palette + .query + .update(&self.store, |s| out = Some(f(s))); + out + } + Some(FocusTarget::SidebarSearch) => { + let mut out = None; + self.file_list + .filter + .update(&self.store, |s| out = Some(f(s))); + out + } + Some(FocusTarget::SearchInput) => { + let mut out = None; + self.editor + .search + .query + .update(&self.store, |s| out = Some(f(s))); + out + } + Some(FocusTarget::CommitEditor) => None, + Some(FocusTarget::SettingsOpenAiKey) => { + if !self.ai_key_editable(AiKeyKind::OpenAi) { + return None; + } + let result = f(&mut self.ai_openai_key); + Some(result) + } + Some(FocusTarget::SettingsAnthropicKey) => { + if !self.ai_key_editable(AiKeyKind::Anthropic) { + return None; + } + let result = f(&mut self.ai_anthropic_key); + Some(result) + } + Some(FocusTarget::SettingsSteeringPrompt) => None, + _ => None, + } + } + + pub(super) fn touch_cursor(&mut self) { + self.text_edit + .cursor_moved_at_ms + .set(&self.store, self.clock_ms); + } + + pub(super) fn clamp_cursor(&mut self) { + let cursor_now = self.text_edit.cursor.get(&self.store); + let anchor_now = self.text_edit.anchor.get(&self.store); + let Some((cursor, anchor)) = self.with_focused_text(|text| { + let len = text.len(); + let mut cursor = cursor_now.min(len); + while cursor > 0 && !text.is_char_boundary(cursor) { + cursor -= 1; + } + let mut anchor = anchor_now.min(len); + while anchor > 0 && !text.is_char_boundary(anchor) { + anchor -= 1; + } + (cursor, anchor) + }) else { + return; + }; + self.text_edit.cursor.set(&self.store, cursor); + self.text_edit.anchor.set(&self.store, anchor); + } +} diff --git a/src/ui/state/ui.rs b/src/ui/state/ui.rs new file mode 100644 index 00000000..12eef48a --- /dev/null +++ b/src/ui/state/ui.rs @@ -0,0 +1,362 @@ +//! App-chrome state: view routing, settings sections, focus targets, and +//! toasts. Pure code motion from `mod.rs`. + +use super::*; + +pub(super) const MAX_VISIBLE_TOASTS: usize = 5; + +pub(super) const TOAST_LIFETIME_MS: u64 = 5_000; + +pub(super) const TOAST_ANIM_MS: u64 = 150; + +pub(super) const CURSOR_BLINK_INTERVAL_MS: u64 = 530; + +pub(super) const DEFAULT_UI_SCALE_PCT: u16 = 100; + +pub(super) const MIN_UI_SCALE_PCT: u16 = 70; + +pub(super) const MAX_UI_SCALE_PCT: u16 = 180; + +pub(super) const UI_SCALE_STEP_PCT: u16 = 10; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AppView { + #[default] + Workspace, + Settings, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SettingsSection { + #[default] + Appearance, + Editor, + Behavior, + Keymaps, + Clankers, + About, +} + +impl SettingsSection { + pub fn label(self) -> &'static str { + match self { + Self::Appearance => "Appearance", + Self::Editor => "Editor", + Self::Behavior => "Behavior", + Self::Keymaps => "Keymaps", + Self::Clankers => "Clankers", + Self::About => "About", + } + } + + pub fn icon(self) -> &'static str { + match self { + Self::Appearance => lucide::SUN, + Self::Editor => lucide::FILE_CODE, + Self::Behavior => lucide::SETTINGS, + Self::Keymaps => lucide::KEY, + Self::Clankers => lucide::SPARKLES, + Self::About => lucide::INFO, + } + } + + pub const ALL: [Self; 6] = [ + Self::Appearance, + Self::Editor, + Self::Behavior, + Self::Keymaps, + Self::Clankers, + Self::About, + ]; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusTarget { + WorkspacePrimaryButton, + TitleBar, + ThemeToggle, + FileList, + Editor, + PickerInput, + PickerList, + CommandPaletteInput, + CommandPaletteList, + AuthPrimaryAction, + SidebarSearch, + SearchInput, + CommitEditor, + ReviewCommentEditor, + TextCompareLeft, + TextCompareRight, + SettingsOpenAiKey, + SettingsAnthropicKey, + SettingsSteeringPrompt, +} + +impl FocusTarget { + pub fn is_text_field(self) -> bool { + matches!( + self, + Self::PickerInput + | Self::CommandPaletteInput + | Self::SidebarSearch + | Self::SearchInput + | Self::CommitEditor + | Self::ReviewCommentEditor + | Self::TextCompareLeft + | Self::TextCompareRight + | Self::SettingsOpenAiKey + | Self::SettingsAnthropicKey + | Self::SettingsSteeringPrompt + ) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Toast { + pub id: u64, + pub kind: ToastKind, + pub message: String, + pub description: Option, + pub created_at_ms: u64, + pub hovered: bool, + /// When `Some`, the toast renders an externally-driven progress bar in + /// place of the time-based one and is pinned (not auto-dismissed). + pub progress: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + Info, + Error, +} + +impl AppState { + pub fn window_title(&self) -> String { + let workspace_mode = if self.compare_progress.with(&self.store, |p| p.is_some()) { + "loading" + } else { + workspace_mode_name(self.workspace_mode.get(&self.store)) + }; + let title_prefix = crate::platform::startup::window_title_prefix(); + if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { + return format!("{title_prefix} - Text Compare [{workspace_mode}]"); + } + let repo = self.compare.repo_path.with(&self.store, |p| { + p.as_deref() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .unwrap_or("native") + .to_owned() + }); + let selected_path = self.workspace.selected_file_path.get(&self.store); + if let Some(path) = selected_path.as_deref() { + format!("{title_prefix} - {repo} [{workspace_mode}] {path}") + } else { + format!("{title_prefix} - {repo} [{workspace_mode}]") + } + } + + pub fn update_time(&mut self, now_ms: u64) { + self.clock_ms = now_ms; + self.animation.tick(now_ms); + let has_expired_toast = self.toasts.with(&self.store, |toasts| { + toasts.iter().any(|toast| { + !toast.hovered + && toast.progress.is_none() + && now_ms.saturating_sub(toast.created_at_ms) >= TOAST_LIFETIME_MS + }) + }); + if has_expired_toast { + self.toasts.update(&self.store, |toasts| { + toasts.retain(|toast| { + toast.hovered + || toast.progress.is_some() + || now_ms.saturating_sub(toast.created_at_ms) < TOAST_LIFETIME_MS + }); + }); + } + } + + pub fn update_polling_enabled(&self) -> bool { + self.settings.auto_update + && crate::core::update::updates_configured() + && !cfg!(debug_assertions) + && !matches!( + self.update.get(&self.store), + UpdateState::Downloading(_) + | UpdateState::ReadyToRestart(_) + | UpdateState::Restarting(_) + ) + } + + pub fn cursor_blink_epoch(&self) -> Option { + self.is_text_focused().then(|| { + self.clock_ms + .saturating_sub(self.text_edit.cursor_moved_at_ms.get(&self.store)) + / CURSOR_BLINK_INTERVAL_MS + }) + } + + pub fn next_cursor_blink_at_ms(&self) -> Option { + self.is_text_focused().then(|| { + let moved_at = self.text_edit.cursor_moved_at_ms.get(&self.store); + let elapsed = self.clock_ms.saturating_sub(moved_at); + let next_epoch = elapsed / CURSOR_BLINK_INTERVAL_MS + 1; + moved_at.saturating_add(next_epoch.saturating_mul(CURSOR_BLINK_INTERVAL_MS)) + }) + } + + pub fn next_toast_expiry_at_ms(&self) -> Option { + self.toasts.with(&self.store, |toasts| { + toasts + .iter() + .filter(|toast| !toast.hovered && toast.progress.is_none()) + .map(|toast| toast.created_at_ms.saturating_add(TOAST_LIFETIME_MS)) + .min() + }) + } + + pub(super) fn set_focus(&mut self, target: Option) { + if target != self.focus.get(&self.store) { + // Reset cursor to end of the new field + let len = target + .and_then(|t| self.with_text_for_focus(t, |s| s.len())) + .unwrap_or(0); + self.reset_text_edit(len); + } + self.focus.set(&self.store, target); + self.editor + .focused + .set(&self.store, target == Some(FocusTarget::Editor)); + } + + /// Returns true if the current focus target is a text editing field. + /// Backed by a memo; `focus` writes invalidate it automatically. + pub fn is_text_focused(&self) -> bool { + self.text_focused.get(&self.store) + } + + pub(super) fn push_error(&mut self, message: &str) -> u64 { + self.last_error.set(&self.store, Some(message.to_owned())); + self.push_toast(ToastKind::Error, message, None, None) + } + + pub(super) fn push_info(&mut self, message: &str) -> u64 { + self.push_toast(ToastKind::Info, message, None, None) + } + + #[allow(dead_code)] + pub(super) fn push_error_with_description(&mut self, message: &str, description: &str) -> u64 { + self.last_error.set(&self.store, Some(message.to_owned())); + self.push_toast( + ToastKind::Error, + message, + Some(description.to_owned()), + None, + ) + } + + #[allow(dead_code)] + pub(super) fn push_info_with_description(&mut self, message: &str, description: &str) -> u64 { + self.push_toast(ToastKind::Info, message, Some(description.to_owned()), None) + } + + /// Create an info toast with an externally-driven progress bar (0.0-1.0). + /// The toast is pinned until `finish_progress_toast` or `fail_progress_toast` + /// is called — it does not auto-dismiss based on time. + pub(super) fn push_progress_toast(&mut self, message: &str) -> u64 { + self.push_toast(ToastKind::Info, message, None, Some(0.0)) + } + + /// Convert a pinned progress toast into a normal info toast and let it + /// auto-dismiss. Also updates its message and description. + pub(super) fn finish_progress_toast( + &mut self, + toast_id: u64, + message: &str, + description: Option, + ) { + let now = self.clock_ms; + self.toasts.update(&self.store, |toasts| { + if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { + toast.kind = ToastKind::Info; + toast.message = message.to_owned(); + toast.description = description; + toast.created_at_ms = now; + toast.progress = None; + } + }); + } + + /// Convert a pinned progress toast into an error toast. + pub(super) fn fail_progress_toast( + &mut self, + toast_id: u64, + message: &str, + description: Option, + ) { + let now = self.clock_ms; + self.last_error.set(&self.store, Some(message.to_owned())); + self.toasts.update(&self.store, |toasts| { + if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { + toast.kind = ToastKind::Error; + toast.message = message.to_owned(); + toast.description = description; + toast.created_at_ms = now; + toast.progress = None; + } + }); + } + + pub(super) fn update_toast_progress(&mut self, toast_id: u64, fraction: f32) { + let clamped = fraction.clamp(0.0, 1.0); + self.toasts.update(&self.store, |toasts| { + if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { + toast.progress = Some(clamped); + } + }); + } + + pub(super) fn update_toast_message(&mut self, toast_id: u64, message: &str) { + self.toasts.update(&self.store, |toasts| { + if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { + toast.message = message.to_owned(); + } + }); + } + + pub(super) fn push_toast( + &mut self, + kind: ToastKind, + message: &str, + description: Option, + progress: Option, + ) -> u64 { + use crate::ui::animation::AnimationKey; + let id = self.next_toast_id; + self.next_toast_id = self.next_toast_id.saturating_add(1); + self.animation.set_target( + AnimationKey::ToastEntrance(id), + 1.0, + TOAST_ANIM_MS, + self.clock_ms, + ); + let now = self.clock_ms; + self.toasts.update(&self.store, |toasts| { + toasts.push(Toast { + id, + kind, + message: message.to_owned(), + description, + created_at_ms: now, + hovered: false, + progress, + }); + if toasts.len() > MAX_VISIBLE_TOASTS { + toasts.remove(0); + } + }); + id + } +} diff --git a/src/ui/state/update.rs b/src/ui/state/update.rs index 53b81410..ab4ccb2e 100644 --- a/src/ui/state/update.rs +++ b/src/ui/state/update.rs @@ -95,3 +95,15 @@ impl AppState { } } } + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum UpdateState { + #[default] + Idle, + Checking, + Available(AvailableUpdate), + Downloading(AvailableUpdate), + ReadyToRestart(StagedUpdate), + Restarting(StagedUpdate), + Failed(String), +} diff --git a/src/ui/state/working_set.rs b/src/ui/state/working_set.rs index cdecfc2e..2c663cac 100644 --- a/src/ui/state/working_set.rs +++ b/src/ui/state/working_set.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use super::ViewportSlotKey; +use super::*; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(super) struct WorkingSetFileKey { @@ -48,3 +48,671 @@ impl FileWorkingSet { self.protected.clone() } } + +pub(super) const COMPARE_WORKING_SET_MAX_FILES: usize = 96; + +pub(super) const COMPARE_WORKING_SET_MIN_FILES: usize = 24; + +pub(super) const COMPARE_WORKING_SET_BYTE_BUDGET: usize = 64 * 1024 * 1024; + +pub(super) const COMPARE_WORKING_SET_PREFETCH_PAGES: u32 = 3; + +pub(super) const COMPARE_WORKING_SET_TRAILING_PAGES: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveFileLoading { + pub index: usize, + pub path: String, + pub priority: CompareWorkPriority, +} + +#[derive(Debug, Clone)] +pub struct PreparedActiveFile { + pub carbon_file: carbon::FileDiff, + pub carbon_expansion: carbon::ExpansionState, + pub carbon_overlays: CarbonStyleOverlays, + pub render_doc: Arc, + pub token_buffer: TokenBuffer, +} + +pub(super) fn append_active_file_doc(out: &mut RenderDoc, active: &ActiveFile) { + if active.carbon_file.is_binary { + out.append_doc(&build_placeholder_render_doc( + &active.path, + "Binary file. Diffy only shows text diffs here.", + )); + } else { + out.append_doc(&active.render_doc); + } +} + +pub(super) fn apply_compare_stat_to_active_file( + active: &mut ActiveFile, + stat: &CompareFileStat, +) -> bool { + if active.index != stat.index || active.path != stat.path { + return false; + } + + let additions = i32_to_u32_nonnegative(stat.additions); + let deletions = i32_to_u32_nonnegative(stat.deletions); + let carbon_file = Arc::make_mut(&mut active.carbon_file); + if carbon_file.additions == additions + && carbon_file.deletions == deletions + && !carbon_file.stats_deferred + { + return false; + } + + carbon_file.additions = additions; + carbon_file.deletions = deletions; + carbon_file.stats_deferred = false; + active.render_doc = Arc::new(build_render_doc_from_carbon( + &active.carbon_file, + active.index, + &active.carbon_expansion, + &active.carbon_overlays, + &active.token_buffer, + )); + true +} + +pub(super) fn hydrate_carbon_full_text( + file: &mut carbon::FileDiff, + old_lines: &[String], + new_lines: &[String], +) { + if !old_lines.is_empty() { + file.old_text = Some(carbon::TextStore::from_text(lines_to_text(old_lines))); + } + if !new_lines.is_empty() { + file.new_text = Some(carbon::TextStore::from_text(lines_to_text(new_lines))); + } + for block in &mut file.blocks { + block.old.start = block.old_line_start.saturating_sub(1); + block.new.start = block.new_line_start.saturating_sub(1); + } + file.is_partial = false; +} + +pub(super) fn lines_to_text(lines: &[String]) -> String { + if lines.is_empty() { + return String::new(); + } + let mut text = + String::with_capacity(lines.iter().map(|line| line.len().saturating_add(1)).sum()); + for line in lines { + text.push_str(line); + text.push('\n'); + } + text +} + +pub(super) fn text_store_estimated_bytes(text: &carbon::TextStore) -> usize { + text.as_bytes() + .len() + .saturating_add(text.line_count() as usize * std::mem::size_of::()) +} + +pub(super) fn render_doc_estimated_bytes(doc: &RenderDoc) -> usize { + doc.text_bytes + .len() + .saturating_add( + doc.style_runs.len() * std::mem::size_of::(), + ) + .saturating_add( + doc.lines.len() * std::mem::size_of::(), + ) + .saturating_add( + doc.file_metadata + .iter() + .map(|meta| { + meta.path + .len() + .saturating_add(meta.old_path.as_ref().map_or(0, String::len)) + }) + .sum::(), + ) +} + +pub(super) fn carbon_file_estimated_bytes(file: &carbon::FileDiff) -> usize { + file.old_path + .as_ref() + .map_or(0, String::len) + .saturating_add(file.new_path.as_ref().map_or(0, String::len)) + .saturating_add(file.old_oid.as_ref().map_or(0, |oid| oid.0.len())) + .saturating_add(file.new_oid.as_ref().map_or(0, |oid| oid.0.len())) + .saturating_add(file.old_mode.as_ref().map_or(0, |mode| mode.0.len())) + .saturating_add(file.new_mode.as_ref().map_or(0, |mode| mode.0.len())) + .saturating_add(file.old_text.as_ref().map_or(0, text_store_estimated_bytes)) + .saturating_add(file.new_text.as_ref().map_or(0, text_store_estimated_bytes)) + .saturating_add(file.hunks.len() * std::mem::size_of::()) + .saturating_add( + file.hunks + .iter() + .map(|hunk| hunk.header.len()) + .sum::(), + ) + .saturating_add(file.blocks.len() * std::mem::size_of::()) + .saturating_add( + file.blocks + .iter() + .map(|block| { + block.old_inline.len() * std::mem::size_of::() + + block.new_inline.len() * std::mem::size_of::() + }) + .sum::(), + ) +} + +pub(super) fn line_vec_estimated_bytes(lines: &Arc>) -> usize { + lines + .iter() + .map(|line| { + std::mem::size_of::() + .saturating_add(line.len()) + .saturating_add(1) + }) + .fold(0usize, usize::saturating_add) +} + +pub(super) fn i32_to_u32_nonnegative(value: i32) -> u32 { + u32::try_from(value).unwrap_or_default() +} + +#[derive(Debug, Clone)] +pub struct ActiveFile { + pub index: usize, + pub path: String, + pub carbon_file: Arc, + pub carbon_expansion: carbon::ExpansionState, + pub carbon_overlays: CarbonStyleOverlays, + pub render_doc: Arc, + pub token_buffer: TokenBuffer, + pub left_ref: String, + pub right_ref: String, + pub file_line_count: Option, + pub old_file_lines: Option>>, + pub file_lines: Option>>, + pub syntax_pending: Vec, + pub syntax_covered: Vec, + pub last_used_tick: u64, +} + +impl ActiveFile { + pub(super) fn working_set_key(&self) -> WorkingSetFileKey { + WorkingSetFileKey::new( + self.index, + self.path.clone(), + self.left_ref.clone(), + self.right_ref.clone(), + ) + } + + pub(super) fn working_set_bytes(&self) -> usize { + self.path + .len() + .saturating_add(self.left_ref.len()) + .saturating_add(self.right_ref.len()) + .saturating_add(render_doc_estimated_bytes(&self.render_doc)) + .saturating_add( + self.token_buffer + .len() + .saturating_mul(std::mem::size_of::()), + ) + .saturating_add(carbon_file_estimated_bytes(&self.carbon_file)) + .saturating_add( + self.old_file_lines + .as_ref() + .map_or(0, line_vec_estimated_bytes), + ) + .saturating_add(self.file_lines.as_ref().map_or(0, line_vec_estimated_bytes)) + } +} + +pub(crate) fn prepare_active_file( + file_index: usize, + carbon_file: &carbon::FileDiff, +) -> PreparedActiveFile { + let token_buffer = TokenBuffer::default(); + let carbon_overlays = CarbonStyleOverlays::default(); + + let carbon_expansion = carbon::ExpansionState::default(); + let render_doc = build_render_doc_from_carbon( + carbon_file, + file_index, + &carbon_expansion, + &carbon_overlays, + &token_buffer, + ); + PreparedActiveFile { + carbon_file: carbon_file.clone(), + carbon_expansion, + carbon_overlays, + render_doc: Arc::new(render_doc), + token_buffer, + } +} + +impl AppState { + pub(super) fn build_active_file( + &self, + index: usize, + path: String, + prepared: PreparedActiveFile, + left_ref: String, + right_ref: String, + ) -> ActiveFile { + ActiveFile { + index, + path, + carbon_file: Arc::new(prepared.carbon_file), + carbon_expansion: prepared.carbon_expansion.clone(), + carbon_overlays: prepared.carbon_overlays, + render_doc: prepared.render_doc, + token_buffer: prepared.token_buffer, + left_ref, + right_ref, + file_line_count: None, + old_file_lines: None, + file_lines: None, + syntax_pending: Vec::new(), + syntax_covered: Vec::new(), + last_used_tick: 0, + } + } + + pub(super) fn clear_file_cache(&mut self) { + self.workspace.file_cache.set(&self.store, HashMap::new()); + self.workspace + .file_cache_loading + .set(&self.store, HashMap::new()); + self.viewport_document_cache = None; + self.last_virtual_scroll_top_px = None; + self.file_working_set.reset(); + } + + pub(super) fn next_file_working_set_tick(&mut self) -> u64 { + self.file_working_set.next_tick() + } + + pub(super) fn protect_working_set_slots(&mut self, slots: &[ViewportSlotKey]) { + self.file_working_set.protect_slots(slots); + } + + pub(super) fn cache_active_file(&mut self, mut active_file: ActiveFile) -> ActiveFile { + let index = active_file.index; + active_file.last_used_tick = self.next_file_working_set_tick(); + let cached = active_file.clone(); + self.workspace.file_cache.update(&self.store, |files| { + files.insert(index, cached); + }); + self.workspace + .file_cache_loading + .update(&self.store, |files| { + files.remove(&index); + }); + self.trim_file_working_set(); + active_file + } + + pub(super) fn touch_viewport_slot(&mut self, key: &ViewportSlotKey) { + let tick = self.next_file_working_set_tick(); + self.workspace.active_file.update(&self.store, |slot| { + if let Some(active) = slot.as_mut() + && active.index == key.index + && active.path == key.path + && active.left_ref == key.left_ref + && active.right_ref == key.right_ref + { + active.last_used_tick = tick; + } + }); + self.workspace.file_cache.update(&self.store, |files| { + if let Some(active) = files.get_mut(&key.index) + && active.index == key.index + && active.path == key.path + && active.left_ref == key.left_ref + && active.right_ref == key.right_ref + { + active.last_used_tick = tick; + } + }); + } + + pub(super) fn trim_file_working_set(&mut self) { + let mut keep = self.file_working_set.protected_snapshot(); + if let Some(active) = self.workspace.active_file.with(&self.store, |active| { + active.as_ref().map(ActiveFile::working_set_key) + }) { + keep.insert(active); + } + if let Some(cache) = self.viewport_document_cache.as_ref() { + keep.extend( + cache + .key + .slots + .iter() + .filter_map(ViewportSlotKey::working_set_key), + ); + } + + self.workspace.file_cache.update(&self.store, |files| { + let mut bytes = files + .values() + .map(ActiveFile::working_set_bytes) + .fold(0usize, usize::saturating_add); + if files.len() <= COMPARE_WORKING_SET_MAX_FILES + && bytes <= COMPARE_WORKING_SET_BYTE_BUDGET + { + return; + } + + let mut victims = files + .iter() + .filter(|(_, file)| !keep.contains(&file.working_set_key())) + .map(|(index, file)| (*index, file.last_used_tick)) + .collect::>(); + victims.sort_by_key(|(_, last_used)| *last_used); + + for (index, _) in victims { + if files.len() <= COMPARE_WORKING_SET_MAX_FILES + && (files.len() <= COMPARE_WORKING_SET_MIN_FILES + || bytes <= COMPARE_WORKING_SET_BYTE_BUDGET) + { + break; + } + if let Some(file) = files.remove(&index) { + bytes = bytes.saturating_sub(file.working_set_bytes()); + } + } + }); + } + + pub(super) fn cached_file_at(&self, index: usize) -> Option { + self.workspace + .file_cache + .with(&self.store, |files| files.get(&index).cloned()) + } + + pub(crate) fn viewport_file_snapshot(&self, index: usize) -> Option { + if let Some(active) = self.workspace.active_file.with(&self.store, |file| { + file.as_ref() + .filter(|active| active.index == index) + .cloned() + }) { + return Some(active); + } + self.cached_file_at(index) + } + + pub(super) fn file_load_pending_priority( + &self, + index: usize, + path: &str, + ) -> Option { + self.workspace + .active_file_loading + .with(&self.store, |loading| { + loading + .as_ref() + .filter(|loading| loading.index == index && loading.path == path) + .map(|loading| loading.priority) + }) + .or_else(|| { + self.workspace + .file_cache_loading + .with(&self.store, |loading| { + loading + .get(&index) + .filter(|loading| loading.path == path) + .map(|loading| loading.priority) + }) + }) + } + + pub(super) fn should_enqueue_file_load( + &self, + index: usize, + path: &str, + priority: CompareWorkPriority, + ) -> bool { + self.file_load_pending_priority(index, path) + .is_none_or(|pending| priority.rank() > pending.rank()) + } + + pub(super) fn mark_file_cache_loading( + &mut self, + index: usize, + path: String, + priority: CompareWorkPriority, + ) { + self.workspace + .file_cache_loading + .update(&self.store, |loading| { + loading.insert( + index, + ActiveFileLoading { + index, + path, + priority, + }, + ); + }); + } + + pub(super) fn clear_file_cache_loading(&mut self, index: usize) { + self.workspace + .file_cache_loading + .update(&self.store, |loading| { + loading.remove(&index); + }); + } + + pub(super) fn cached_compare_file_at(&self, index: usize, path: &str) -> Option { + let (left_ref, right_ref) = self.compare_refs(); + if let Some(active_file) = self.workspace.active_file.with(&self.store, |file| { + file.as_ref() + .filter(|file| { + file.index == index + && file.path == path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + .cloned() + }) { + return Some(active_file); + } + self.cached_file_at(index).filter(|file| { + file.index == index + && file.path == path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + } + + pub(super) fn cached_status_file_at( + &self, + index: usize, + change: &FileChange, + ) -> Option { + let (left_ref, right_ref) = self.status_refs_for_bucket(change.bucket); + if let Some(active_file) = self.workspace.active_file.with(&self.store, |file| { + file.as_ref() + .filter(|file| { + file.index == index + && file.path == change.path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + .cloned() + }) { + return Some(active_file); + } + self.cached_file_at(index).filter(|file| { + file.index == index + && file.path == change.path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + } + + pub(super) fn cache_compare_file_from_output( + &mut self, + index: usize, + path: &str, + ) -> Option { + let carbon_file = self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .and_then(|output| output.carbon.files.get(index)) + .filter(|file| file.path() == path) + .filter(|file| !(file.is_partial && file.hunks.is_empty())) + .cloned() + })?; + let prepared = prepare_active_file(index, &carbon_file); + let (left_ref, right_ref) = self.compare_refs(); + let active_file = + self.build_active_file(index, path.to_owned(), prepared, left_ref, right_ref); + let active_file = self.cache_active_file(active_file); + Some(active_file) + } + + pub(super) fn install_compare_active_file( + &mut self, + index: usize, + path: String, + prepared: PreparedActiveFile, + ) { + let left_ref = self + .compare + .resolved_left + .get(&self.store) + .unwrap_or_else(|| self.compare.left_ref.get(&self.store)); + let right_ref = self + .compare + .resolved_right + .get(&self.store) + .unwrap_or_else(|| self.compare.right_ref.get(&self.store)); + let active_file = + self.build_active_file(index, path.clone(), prepared, left_ref, right_ref); + let active_file = self.cache_active_file(active_file); + let stats = CompareFileStat { + index, + path: path.clone(), + additions: u32_to_i32_saturating(active_file.carbon_file.additions), + deletions: u32_to_i32_saturating(active_file.carbon_file.deletions), + }; + + self.workspace + .selected_file_index + .set(&self.store, Some(index)); + self.workspace + .selected_file_path + .set(&self.store, Some(path)); + self.workspace.selected_change_bucket.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + self.workspace + .active_file + .set(&self.store, Some(active_file)); + self.apply_compare_file_stats(&[stats]); + // The first real file has landed — tear down the progress panel. + // Subsequent file loads use the sidebar row spinner, not this. + self.compare_progress.set(&self.store, None); + self.editor_clear_document(); + self.editor + .line_selection + .update(&self.store, |ls| ls.clear()); + if self.editor.search.open.get(&self.store) { + self.recompute_search_matches(); + } + self.file_list.hovered_index.set(&self.store, Some(index)); + } +} + +impl AppState { + pub(super) fn prefetch_compare_working_set( + &mut self, + render_start_index: usize, + render_end_index: usize, + direction: ScrollDirection, + viewport_height_px: u32, + ) -> Vec { + if self.workspace.source.get(&self.store) != WorkspaceSource::Compare { + return Vec::new(); + } + let count = self.workspace_file_count(); + if count == 0 { + return Vec::new(); + } + + let forward_pages = if direction == ScrollDirection::Forward { + COMPARE_WORKING_SET_PREFETCH_PAGES + } else { + COMPARE_WORKING_SET_TRAILING_PAGES + }; + let backward_pages = if direction == ScrollDirection::Backward { + COMPARE_WORKING_SET_PREFETCH_PAGES + } else { + COMPARE_WORKING_SET_TRAILING_PAGES + }; + + let mut effects = Vec::new(); + effects.extend(self.prefetch_compare_files_forward( + render_end_index, + viewport_height_px.saturating_mul(forward_pages).max(1), + )); + effects.extend(self.prefetch_compare_files_backward( + render_start_index, + viewport_height_px.saturating_mul(backward_pages).max(1), + )); + effects + } + + pub(super) fn prefetch_compare_files_forward( + &mut self, + start_index: usize, + target_height: u32, + ) -> Vec { + let count = self.workspace_file_count(); + let mut effects = Vec::new(); + let mut accumulated = 0_u32; + let mut index = start_index; + while index < count && accumulated < target_height { + if let Some(path) = self.workspace_file_path_at(index) { + effects.extend(self.ensure_compare_file_cached_for_viewport( + index, + &path, + CompareWorkPriority::Overscan, + )); + } + accumulated = + accumulated.saturating_add(self.viewport_file_scroll_height_px(index).max(1)); + index += 1; + } + effects + } + + pub(super) fn prefetch_compare_files_backward( + &mut self, + start_index: usize, + target_height: u32, + ) -> Vec { + let mut effects = Vec::new(); + let mut accumulated = 0_u32; + let mut index = start_index; + while index > 0 && accumulated < target_height { + index -= 1; + if let Some(path) = self.workspace_file_path_at(index) { + effects.extend(self.ensure_compare_file_cached_for_viewport( + index, + &path, + CompareWorkPriority::Overscan, + )); + } + accumulated = + accumulated.saturating_add(self.viewport_file_scroll_height_px(index).max(1)); + } + effects + } +} diff --git a/src/ui/state/workspace.rs b/src/ui/state/workspace.rs index 153da76c..9e1f9044 100644 --- a/src/ui/state/workspace.rs +++ b/src/ui/state/workspace.rs @@ -37,3 +37,75 @@ impl AppState { } } } + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum WorkspaceMode { + #[default] + Empty, + Loading, + Ready, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum WorkspaceSource { + #[default] + None, + Status, + Compare, + TextCompare, +} + +#[derive(Debug, Clone, Default, Store)] +pub struct WorkspaceState { + pub source: WorkspaceSource, + pub status: AsyncStatus, + pub status_operation_pending: bool, + pub compare_generation: u64, + pub status_generation: u64, + pub files: Vec, + pub status_file_changes: Vec, + pub selected_file_index: Option, + pub selected_file_path: Option, + pub selected_change_bucket: Option, + pub compare_output: Option, + pub compare_total_stats: Option<(i32, i32)>, + pub compare_hydrated_stats: Option<(i32, i32)>, + pub compare_deferred_stats_remaining: Option, + pub compare_deferred_stats_cursor: usize, + pub compare_total_stats_loading: bool, + pub compare_stats_hydration: CompareStatsHydrationState, + pub active_file: Option, + pub active_file_loading: Option, + pub file_cache: HashMap, + pub file_cache_loading: HashMap, + pub raw_diff_len: usize, + pub used_fallback: bool, + pub fallback_message: String, + pub sidebar_auto_width: Option, + pub range_commits: Vec, + pub compare_history_pending: Option, + pub pre_drill_compare: Option<(String, String, CompareMode)>, + pub expansions: HashMap, + pub file_content_heights: Vec>, + pub file_scroll_total_height_px: u32, + pub pending_file_content_heights: HashMap, + pub file_scroll_recompute_pending: bool, + pub global_scroll_top_px: u32, + pub measured_px_per_row_q16: u32, + pub viewport_scrollbar_drag: Option, +} + +pub fn workspace_mode_name(mode: WorkspaceMode) -> &'static str { + match mode { + WorkspaceMode::Empty => "empty", + WorkspaceMode::Loading => "loading", + WorkspaceMode::Ready => "ready", + } +} + +impl AppState { + /// Returns true when the workspace is in `Ready` mode. + pub fn is_workspace_ready(&self) -> bool { + self.workspace_mode.get(&self.store) == WorkspaceMode::Ready + } +} From 8b24bef376725b259986be9698608f43d4e47a08 Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 21:46:10 +0000 Subject: [PATCH 13/25] refactor(editor): split diff editor element into focused modules Pure code motion: src/editor/diff/element.rs (~4.8k lines) becomes a module directory with the public API unchanged (all pub types stay in element/mod.rs, pub methods move with their impl blocks): - element/mod.rs: EditorElement struct, EditorLayout/EditorLayoutKey, EditorDocument, scrollbar/mouse state setters, and the wrapped-text, text-layout, and gutter-text caches. - element/layout.rs: prepare, spatial layout, display-row rebuilds, navigation positions, visible ranges, and scrollbar geometry. - element/hit_test.rs: row/text-point hit testing, selection byte mapping, review/block/hunk hit targets, and header path lookup. - element/paint.rs: scene painting, accessibility emission, rich-text span building, and syntax style mapping. - element/tests.rs: existing unit tests, unchanged behavior. Cross-module helpers are pub(super); no behavior change. element/input.rs was not needed: keyboard/mouse dispatch already lives in src/input/. --- src/editor/diff/element/hit_test.rs | 703 +++++ src/editor/diff/element/layout.rs | 605 ++++ src/editor/diff/element/mod.rs | 487 ++++ .../diff/{element.rs => element/paint.rs} | 2585 +---------------- src/editor/diff/element/tests.rs | 724 +++++ 5 files changed, 2574 insertions(+), 2530 deletions(-) create mode 100644 src/editor/diff/element/hit_test.rs create mode 100644 src/editor/diff/element/layout.rs create mode 100644 src/editor/diff/element/mod.rs rename src/editor/diff/{element.rs => element/paint.rs} (50%) create mode 100644 src/editor/diff/element/tests.rs diff --git a/src/editor/diff/element/hit_test.rs b/src/editor/diff/element/hit_test.rs new file mode 100644 index 00000000..c00987b1 --- /dev/null +++ b/src/editor/diff/element/hit_test.rs @@ -0,0 +1,703 @@ +use crate::actions::{Action, ContextMenuEntry}; +use crate::editor::diff::anchor::{EditorOverlayKind, ResolvedEditorOverlay}; +use crate::editor::diff::decoration::BlockActionCtx; +use crate::editor::diff::render_doc::{ + ByteRange, DisplayRow, RenderDoc, RenderLine, RenderRowKind, +}; +use crate::editor::diff::state::{ + EditorState, LineSelection, ViewportTextPoint, ViewportTextSelection, ViewportTextSide, +}; +use crate::render::Rect; + +use super::layout::{editor_scale, scaled}; +use super::paint::unified_body_side_with_side; +use super::{CachedTextLayout, EditorElement, EditorLayout, TextBlock}; + +impl EditorElement { + pub fn hit_test_row(&self, state: &EditorState, x: f32, y: f32) -> Option { + if !self.layout.content_bounds.contains(x, y) { + return None; + } + let content_y = (y - self.layout.content_bounds.y).max(0.0) + state.scroll_top_px as f32; + let index = self + .rows + .partition_point(|row| row.bottom_px() as f32 <= content_y); + self.rows.get(index).and_then(|row| { + (content_y >= row.y_px as f32 && content_y < row.bottom_px() as f32).then_some(index) + }) + } + + pub fn is_gutter_hit(&self, x: f32, _y: f32) -> bool { + if self.layout.split_mode { + self.layout + .left_gutter_rect + .contains(x, self.layout.left_gutter_rect.y) + } else { + self.layout + .unified_gutter_rect + .contains(x, self.layout.unified_gutter_rect.y) + } + } + + pub fn review_comment_line_for_row( + &self, + doc: &RenderDoc, + display_row_index: usize, + ) -> Option { + let row = self.rows.get(display_row_index)?; + if row.is_block() { + return None; + } + let line = doc.lines.get(row.line_index as usize)?; + review_comment_gutter_rect(&self.layout, line)?; + Some(row.line_index as usize) + } + + pub fn review_add_button_overlay( + &self, + state: &EditorState, + doc: &RenderDoc, + clip: Rect, + ) -> Option { + if !state.review_enabled { + return None; + } + let row_index = self.layout.highlighted_row?; + self.review_add_button_overlay_for_row(state, doc, row_index, clip) + } + + pub fn review_add_button_overlay_at( + &self, + state: &EditorState, + doc: &RenderDoc, + x: f32, + y: f32, + clip: Rect, + ) -> Option { + if !state.review_enabled { + return None; + } + let row_index = self.hit_test_row(state, x, y)?; + let overlay = self.review_add_button_overlay_for_row(state, doc, row_index, clip)?; + overlay.contains(x, y).then_some(overlay) + } + + pub fn block_action_for_row_at( + &self, + display_row_index: usize, + x: f32, + y: f32, + ) -> Option { + let row = self.rows.get(display_row_index)?; + if !row.is_block() { + return None; + } + let block = self.blocks.get(row.block_index as usize)?; + block.on_click_at( + &BlockActionCtx { + layout: &self.layout, + row_rect: self.row_rect_for(row), + }, + x, + y, + ) + } + + pub fn block_context_menu_for_row( + &self, + display_row_index: usize, + ) -> Option> { + let row = self.rows.get(display_row_index)?; + if !row.is_block() { + return None; + } + self.blocks + .get(row.block_index as usize)? + .context_menu_entries() + } + + pub fn hunk_action_bar_rect(&self, doc: &RenderDoc) -> Option<(Rect, i16)> { + let idx = self.layout.highlighted_row?; + let display_row = self.rows.get(idx)?; + if display_row.is_block() { + return None; + } + let line = doc.lines.get(display_row.line_index as usize)?; + if line.hunk_index < 0 { + return None; + } + let hunk_index = line.hunk_index; + + let mut first_idx = idx; + while first_idx > 0 { + let prev = self.rows.get(first_idx - 1)?; + if prev.is_block() { + break; + } + let prev_line = doc.lines.get(prev.line_index as usize)?; + if prev_line.hunk_index != hunk_index { + break; + } + first_idx -= 1; + } + + let mut last_idx = idx; + while last_idx + 1 < self.rows.len() { + let next = &self.rows[last_idx + 1]; + if next.is_block() { + break; + } + let Some(next_line) = doc.lines.get(next.line_index as usize) else { + break; + }; + if next_line.hunk_index != hunk_index { + break; + } + last_idx += 1; + } + + let first_rect = self.row_rect_for(&self.rows[first_idx]); + let last_rect = self.row_rect_for(&self.rows[last_idx]); + let row_h = first_rect.height; + let viewport_top = self.layout.content_bounds.y; + let viewport_bottom = self.layout.content_bounds.bottom(); + + let max_y = (last_rect.y + last_rect.height - row_h).max(first_rect.y); + let y = first_rect.y.max(viewport_top).min(max_y); + if y + row_h <= viewport_top || y >= viewport_bottom { + return None; + } + + // The bar floats on the hunk separator row, which spans the full + // content width in both split and unified modes. Use the full text span + // so the buttons right-align against the editor edge, not a column. + let (x, width) = if self.layout.split_mode { + let left = self.layout.left_text_rect.x; + let right = self.layout.right_text_rect.x + self.layout.right_text_rect.width; + (left, right - left) + } else { + ( + self.layout.unified_text_rect.x, + self.layout.unified_text_rect.width, + ) + }; + Some(( + Rect { + x, + y, + width, + height: row_h, + }, + hunk_index, + )) + } + + pub fn line_selection_bar_rect(&self, doc: &RenderDoc, state: &EditorState) -> Option { + if state.line_selection.is_empty() { + return None; + } + + let is_selected = |row: &DisplayRow| -> bool { + if row.is_block() { + return false; + } + let Some(line) = doc.lines.get(row.line_index as usize) else { + return false; + }; + if !matches!( + line.row_kind(), + RenderRowKind::Added | RenderRowKind::Removed | RenderRowKind::Modified + ) { + return false; + } + line_selection_contains_line( + &state.line_selection, + file_path_for_line(doc, row.line_index as usize), + line, + ) + }; + + let first = self.rows.iter().find(|r| is_selected(r))?; + let last = self.rows.iter().rev().find(|r| is_selected(r))?; + + let first_rect = self.row_rect_for(first); + let last_rect = self.row_rect_for(last); + let last_bottom = last_rect.y + last_rect.height; + let viewport_top = self.layout.content_bounds.y; + let viewport_bottom = self.layout.content_bounds.bottom(); + + // Hide entirely when the selection is fully outside the viewport. + if last_bottom <= viewport_top || first_rect.y >= viewport_bottom { + return None; + } + + let bar_h = first_rect.height; + // Float the bar above the first selected row. Once the user scrolls + // past that row the bar stays pinned to the viewport top, acting like + // a sticky header over the selection — no jumps, no disappearing. + let above_y = first_rect.y - bar_h; + let y = above_y.max(viewport_top); + // If even the sticky position would sit past the last selected row, + // the selection no longer covers enough area to anchor the bar. + if y >= last_bottom { + return None; + } + + // Span the full content width in both modes so the buttons right-align + // against the editor edge, never pinned to a narrow column. + let (x, width) = if self.layout.split_mode { + let left = self.layout.left_text_rect.x; + let right = self.layout.right_text_rect.x + self.layout.right_text_rect.width; + (left, right - left) + } else { + ( + self.layout.unified_text_rect.x, + self.layout.unified_text_rect.width, + ) + }; + Some(Rect { + x, + y, + width, + height: bar_h, + }) + } + + fn review_add_comment_button_rect_for_row( + &self, + line: &RenderLine, + display_row: &DisplayRow, + ) -> Option { + let gutter = review_comment_gutter_rect(&self.layout, line)?; + let row_rect = self.row_rect_for(display_row); + if !self.row_in_viewport(&row_rect) { + return None; + } + let s = editor_scale(self.text_metrics); + let size = (self.layout.line_height * 0.72).clamp(scaled(14.0, s), scaled(20.0, s)); + // Straddle the gutter→code divider like GitHub: centered on the boundary, + // but never reaching left past the line number's right edge. + let x = (gutter.right() - size * 0.5).max(gutter.right() - self.layout.gutter_padding); + Some(Rect { + x, + y: row_rect.y + (self.layout.line_height - size).max(0.0) * 0.5, + width: size, + height: size, + }) + } + + fn review_add_button_overlay_for_row( + &self, + state: &EditorState, + doc: &RenderDoc, + row_index: usize, + clip: Rect, + ) -> Option { + let display_row = self.rows.get(row_index).copied()?; + if display_row.is_block() { + return None; + } + let line = doc.lines.get(display_row.line_index as usize)?; + let rect = self.review_add_comment_button_rect_for_row(line, &display_row)?; + let emphasised = state.review_add_hovered + || (!state.line_selection.is_empty() + && line_selection_contains_line( + &state.line_selection, + file_path_for_line(doc, display_row.line_index as usize), + line, + )); + ResolvedEditorOverlay::new( + EditorOverlayKind::ReviewAddButton { + line_index: display_row.line_index as usize, + emphasised, + }, + rect, + clip, + ) + } + + pub fn file_header_path_at(&self, x: f32, y: f32) -> Option { + if let Some((rect, path)) = self.sticky_header_hit.as_ref() + && rect.contains(x, y) + { + return Some(path.clone()); + } + if !self.layout.content_bounds.contains(x, y) { + return None; + } + let content_y = (y - self.layout.content_bounds.y).max(0.0) + self.layout.scroll_top_px; + for hit in &self.file_header_hits { + let top = hit.y_px as f32; + let bottom = top + hit.h_px as f32; + if content_y >= top && content_y < bottom { + return Some(hit.path.clone()); + } + } + None + } + + pub fn hit_test_text_point( + &self, + state: &EditorState, + doc: &RenderDoc, + x: f32, + y: f32, + ) -> Option { + let row_index = self.hit_test_row(state, x, y)?; + let display_row = self.rows.get(row_index).copied()?; + if display_row.is_block() { + return None; + } + let line = doc.lines.get(display_row.line_index as usize)?; + if !line.row_kind().is_body() { + return None; + } + let row_rect = self.row_rect_for(&display_row); + let blocks = self.text_blocks_for_line(line, &display_row, row_rect); + blocks + .into_iter() + .flatten() + .filter(|block| { + let bottom = block.y + block.segment_count.max(1) as f32 * self.layout.line_height; + y >= block.y && y < bottom + }) + .min_by(|a, b| { + distance_to_text_block_x(*a, x) + .partial_cmp(&distance_to_text_block_x(*b, x)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|block| { + let text = doc.line_text(block.text_range); + let layout = CachedTextLayout::new(text); + let segment = ((y - block.y) / self.layout.line_height.max(1.0)) + .floor() + .max(0.0) as u32; + let segment = segment.min(u32::from(block.segment_count.max(1).saturating_sub(1))); + let local_col = + ((x - block.text_x) / self.text_metrics.mono_char_width_px.max(1.0)).max(0.0); + let col = local_col + segment.saturating_mul(u32::from(block.segment_cols)) as f32; + ViewportTextPoint { + line_index: block.line_index, + side: block.side, + byte_offset: layout.byte_for_col_nearest(col), + } + }) + } + + pub fn viewport_selection_text( + &self, + doc: &RenderDoc, + selection: &ViewportTextSelection, + ) -> Option { + if selection.is_collapsed() { + return None; + } + let mut copied = String::new(); + let (start, end) = selection.normalized(); + for line_index in start.line_index..=end.line_index { + let Some(line) = doc.lines.get(line_index as usize) else { + continue; + }; + for (side, range) in text_side_ranges_for_line(self.layout.split_mode, line) + .into_iter() + .flatten() + { + let text = doc.line_text(range); + let Some((byte_start, byte_end)) = selection_byte_range_for_side( + selection, + self.layout.split_mode, + line_index, + side, + text, + ) else { + continue; + }; + if byte_end <= byte_start { + continue; + } + if !copied.is_empty() { + copied.push('\n'); + } + copied.push_str(&text[byte_start..byte_end]); + } + } + (!copied.is_empty()).then_some(copied) + } + + pub fn viewport_line_text_at_point( + &self, + doc: &RenderDoc, + point: ViewportTextPoint, + ) -> Option { + let line = doc.lines.get(point.line_index as usize)?; + let range = match point.side { + ViewportTextSide::Left => line.left_text, + ViewportTextSide::Right => line.right_text, + }; + range.is_valid().then(|| doc.line_text(range).to_owned()) + } + + pub(super) fn text_blocks_for_line( + &self, + line: &RenderLine, + display_row: &DisplayRow, + row_rect: Rect, + ) -> [Option; 2] { + let mut blocks = [None, None]; + let mut next = 0_usize; + let mut push_block = |block: TextBlock| { + if next < blocks.len() { + blocks[next] = Some(block); + next += 1; + } + }; + + let line_height = self.layout.line_height; + if self.layout.split_mode { + let segment_cols = self.render_cols_split(); + if line.left_text.is_valid() { + push_block(TextBlock { + line_index: display_row.line_index, + side: ViewportTextSide::Left, + text_range: line.left_text, + text_x: self.layout.left_text_rect.x, + text_width: self.layout.left_text_rect.width, + y: row_rect.y, + segment_count: if self.config.wrap_enabled { + display_row.wrap_left.max(1) + } else { + 1 + }, + segment_cols, + }); + } + if line.right_text.is_valid() { + push_block(TextBlock { + line_index: display_row.line_index, + side: ViewportTextSide::Right, + text_range: line.right_text, + text_x: self.layout.right_text_rect.x, + text_width: self.layout.right_text_rect.width, + y: row_rect.y, + segment_count: if self.config.wrap_enabled { + display_row.wrap_right.max(1) + } else { + 1 + }, + segment_cols, + }); + } + return blocks; + } + + let segment_cols = self.render_cols_unified(); + if line.row_kind() == RenderRowKind::Modified + && line.left_text.is_valid() + && line.right_text.is_valid() + { + let left_segments = if self.config.wrap_enabled { + display_row.wrap_left.max(1) + } else { + 1 + }; + push_block(TextBlock { + line_index: display_row.line_index, + side: ViewportTextSide::Left, + text_range: line.left_text, + text_x: self.layout.unified_text_rect.x, + text_width: self.layout.unified_text_rect.width, + y: row_rect.y, + segment_count: left_segments, + segment_cols, + }); + push_block(TextBlock { + line_index: display_row.line_index, + side: ViewportTextSide::Right, + text_range: line.right_text, + text_x: self.layout.unified_text_rect.x, + text_width: self.layout.unified_text_rect.width, + y: row_rect.y + left_segments as f32 * line_height, + segment_count: if self.config.wrap_enabled { + display_row.wrap_right.max(1) + } else { + 1 + }, + segment_cols, + }); + } else if let Some((side, text_range, _, _)) = unified_body_side_with_side(line) { + push_block(TextBlock { + line_index: display_row.line_index, + side, + text_range, + text_x: self.layout.unified_text_rect.x, + text_width: self.layout.unified_text_rect.width, + y: row_rect.y, + segment_count: if self.config.wrap_enabled { + display_row.wrap_left.max(1) + } else { + 1 + }, + segment_cols, + }); + } + blocks + } +} + +pub(super) fn line_selection_contains_line( + selection: &LineSelection, + file_path: Option<&str>, + line: &RenderLine, +) -> bool { + let Ok(hunk_id) = u32::try_from(line.hunk_index) else { + return false; + }; + (line.old_line_index >= 0 + && selection.contains_in_file( + file_path, + hunk_id, + carbon::DiffSide::Old, + line.old_line_index as u32, + )) + || (line.new_line_index >= 0 + && selection.contains_in_file( + file_path, + hunk_id, + carbon::DiffSide::New, + line.new_line_index as u32, + )) +} + +pub(super) fn file_path_for_line(doc: &RenderDoc, line_index: usize) -> Option<&str> { + doc.lines.get(..=line_index)?.iter().rev().find_map(|line| { + if line.row_kind() == RenderRowKind::FileHeader { + doc.file_meta(line).map(|meta| meta.path.as_str()) + } else { + None + } + }) +} + +fn review_comment_gutter_rect(layout: &EditorLayout, line: &RenderLine) -> Option { + // Any body line is commentable (incl. context) — selected_review_range maps it + // to the new side, or the old side for removed-only lines. + if line.hunk_index < 0 || !line.row_kind().is_body() { + return None; + } + match line.row_kind() { + RenderRowKind::Removed if layout.split_mode => Some(layout.left_gutter_rect), + _ if layout.split_mode => Some(layout.right_gutter_rect), + _ => Some(layout.unified_gutter_rect), + } +} + +fn distance_to_text_block_x(block: TextBlock, x: f32) -> f32 { + if x < block.text_x { + block.text_x - x + } else if x > block.text_x + block.text_width { + x - (block.text_x + block.text_width) + } else { + 0.0 + } +} + +fn text_side_ranges_for_line( + split_mode: bool, + line: &RenderLine, +) -> [Option<(ViewportTextSide, ByteRange)>; 2] { + let mut ranges = [None, None]; + if !line.row_kind().is_body() { + return ranges; + } + let mut next = 0_usize; + let mut push = |side, range: ByteRange| { + if range.is_valid() && next < ranges.len() { + ranges[next] = Some((side, range)); + next += 1; + } + }; + + if split_mode { + push(ViewportTextSide::Left, line.left_text); + push(ViewportTextSide::Right, line.right_text); + } else if line.row_kind() == RenderRowKind::Modified + && line.left_text.is_valid() + && line.right_text.is_valid() + { + push(ViewportTextSide::Left, line.left_text); + push(ViewportTextSide::Right, line.right_text); + } else if let Some((side, range, _, _)) = unified_body_side_with_side(line) { + push(side, range); + } + ranges +} + +pub(super) fn selection_byte_range_for_side( + selection: &ViewportTextSelection, + split_mode: bool, + line_index: u32, + side: ViewportTextSide, + text: &str, +) -> Option<(usize, usize)> { + let (start, end) = selection_bounds_for_side(selection, split_mode, side)?; + let text_len = text.len(); + let text_len_u32 = text_len.min(u32::MAX as usize) as u32; + let side_start = ViewportTextPoint { + line_index, + side, + byte_offset: 0, + }; + let side_end = ViewportTextPoint { + line_index, + side, + byte_offset: text_len_u32, + }; + if side_end <= start || side_start >= end { + return None; + } + + let byte_start = if start.line_index == line_index && start.side == side { + start.byte_offset.min(text_len_u32) + } else { + 0 + }; + let byte_end = if end.line_index == line_index && end.side == side { + end.byte_offset.min(text_len_u32) + } else { + text_len_u32 + }; + let byte_start = previous_char_boundary(text, byte_start as usize); + let byte_end = previous_char_boundary(text, byte_end as usize); + (byte_end > byte_start).then_some((byte_start, byte_end)) +} + +fn selection_bounds_for_side( + selection: &ViewportTextSelection, + split_mode: bool, + side: ViewportTextSide, +) -> Option<(ViewportTextPoint, ViewportTextPoint)> { + if !split_mode { + return Some(selection.normalized()); + } + if selection.anchor.side != side { + return None; + } + let anchor = selection.anchor; + let focus = ViewportTextPoint { + side, + ..selection.focus + }; + Some(if anchor <= focus { + (anchor, focus) + } else { + (focus, anchor) + }) +} + +fn previous_char_boundary(text: &str, byte: usize) -> usize { + let mut byte = byte.min(text.len()); + while byte > 0 && !text.is_char_boundary(byte) { + byte -= 1; + } + byte +} diff --git a/src/editor/diff/element/layout.rs b/src/editor/diff/element/layout.rs new file mode 100644 index 00000000..e9ec04de --- /dev/null +++ b/src/editor/diff/element/layout.rs @@ -0,0 +1,605 @@ +use std::ops::Range; +use std::sync::Arc; + +use crate::core::compare::LayoutMode; +use crate::editor::diff::anchor::{EditorOverlayKind, ResolvedEditorOverlay}; +use crate::editor::diff::display_layout::{ + DisplayLayoutConfig, DisplayLayoutMetrics, compute_gutter_digits, rebuild_display_rows, +}; +use crate::editor::diff::render_doc::{DisplayRow, RenderDoc, RenderRowKind}; +use crate::editor::diff::state::EditorState; +use crate::editor::diff::strip_layout::{build_strip_layouts, visible_strip_range}; +use crate::render::{Rect, TextMetrics}; + +use super::{ + BASE_MONO_FONT_SIZE, EditorDocument, EditorElement, EditorLayout, EditorLayoutKey, + FileHeaderHit, ScrollbarLayout, ScrollbarOverride, VisibleRange, +}; + +const BASE_VIEWPORT_PADDING: f32 = 14.0; +const BASE_COLUMN_GAP: f32 = 18.0; +const BASE_GUTTER_PADDING: f32 = 8.0; +const BASE_SCROLLBAR_WIDTH: f32 = 8.0; +const BASE_SCROLLBAR_MARGIN: f32 = 6.0; +const FILE_HEADER_ROW_MULTIPLE: u16 = 1; +const HUNK_ROW_MULTIPLE: u16 = 1; +const BASE_SCROLLBAR_THUMB_MIN: f32 = 32.0; + +const STRIP_TARGET_HEIGHT_PX: u32 = 480; +const STRIP_OVERSCAN: usize = 1; +const UNWRAPPED_RENDER_OVERSCAN_COLS: u16 = 16; + +pub(super) fn editor_scale(text_metrics: TextMetrics) -> f32 { + (text_metrics.mono_font_size_px / BASE_MONO_FONT_SIZE).max(0.5) +} + +fn display_layout_metrics(text_metrics: TextMetrics) -> DisplayLayoutMetrics { + let body_h = text_metrics.mono_line_height_px.round().max(1.0) as u16; + DisplayLayoutMetrics { + body_row_height_px: body_h, + file_header_height_px: body_h * FILE_HEADER_ROW_MULTIPLE, + hunk_height_px: body_h * HUNK_ROW_MULTIPLE, + } +} + +pub(super) fn scaled(base: f32, scale: f32) -> f32 { + base * scale +} + +fn content_bounds_for_viewport(bounds: Rect, text_metrics: TextMetrics) -> Rect { + bounds.inset(scaled(BASE_VIEWPORT_PADDING, editor_scale(text_metrics))) +} + +pub(super) fn editor_bottom_padding_px(metrics: DisplayLayoutMetrics) -> u32 { + u32::from(metrics.body_row_height_px) +} + +impl EditorElement { + pub fn prepare( + &mut self, + state: &mut EditorState, + document: EditorDocument<'_>, + bounds: Rect, + text_metrics: TextMetrics, + ) -> EditorLayout { + self.text_metrics = text_metrics; + let gutter_digits = match document { + EditorDocument::Text { doc, .. } => compute_gutter_digits(doc), + _ => 3, + }; + self.layout = build_spatial_layout(bounds, state.layout, gutter_digits, text_metrics); + state.viewport_width_px = self.layout.content_bounds.width.max(0.0).round() as u32; + state.viewport_height_px = self.layout.content_bounds.height.max(0.0).round() as u32; + + let s = editor_scale(text_metrics); + self.layout.font_size = text_metrics.mono_font_size_px; + self.layout.line_height = self.metrics.body_row_height_px as f32; + let glyphon_line_h = text_metrics.mono_font_size_px * 1.35; + self.layout.text_y_offset = ((self.layout.line_height - glyphon_line_h) * 0.5).max(0.0); + self.layout.gutter_padding = scaled(BASE_GUTTER_PADDING, s); + self.layout.column_gap = scaled(BASE_COLUMN_GAP, s); + + match document { + EditorDocument::Text { + compare_generation, + file_index, + doc, + show_file_headers, + .. + } => { + let key = EditorLayoutKey { + compare_generation, + file_index, + show_file_headers, + split_mode: state.layout == LayoutMode::Split, + wrap_enabled: state.wrap_enabled, + wrap_column: state.wrap_column, + viewport_width_bits: self.layout.content_bounds.width.to_bits(), + viewport_height_bits: self.layout.content_bounds.height.to_bits(), + mono_char_width_bits: text_metrics.mono_char_width_px.to_bits(), + mono_line_height_bits: text_metrics.mono_line_height_px.to_bits(), + doc_line_count: doc.line_count() as u32, + doc_text_len: doc.text_bytes.len().min(u32::MAX as usize) as u32, + block_layout_signature: self + .blocks + .layout_signature(display_layout_metrics(text_metrics)), + }; + + if self.layout_key != Some(key) { + self.rebuild_rows(doc, state, text_metrics, show_file_headers); + self.clear_document_caches(); + self.layout_key = Some(key); + } + + // Stamp the generation this geometry belongs to. When a new + // compare generation replaces the document, the carried-over + // scroll offset may point past geometry that no longer + // exists; the `clamp_scroll` below re-clamps it against the + // freshly built layout. Scroll is intentionally not reset + // here: per-file resets (and continuous-scroll restore) are + // owned by the reducer so a recompare of the same file keeps + // the user's place. + state.doc_generation = compare_generation; + + state.content_height_px = self + .summary + .content_height_px + .saturating_add(editor_bottom_padding_px(self.metrics)); + self.rebuild_navigation_positions(state); + state.clamp_scroll(); + self.update_visible_ranges(state); + + self.layout.line_height = self.metrics.body_row_height_px as f32; + } + _ => { + self.layout_key = None; + self.rows.clear(); + self.strips.clear(); + self.clear_document_caches(); + state.clear_document(); + } + } + + self.layout.scroll_top_px = state.scroll_top_px as f32; + self.layout.highlighted_row = state.hovered_row; + self.layout.scrollbar = + compute_scrollbar_layout(&self.layout, state, self.scrollbar_override); + + self.layout + } + + pub fn body_bounds(&self) -> Rect { + self.layout.content_bounds + } + + /// Pins the single card-width formula used by the review-thread overlay. Mirrors + /// the inset `build_spatial_layout` applies to produce `content_bounds.width`, so a + /// caller can derive the width BEFORE `prepare` runs (it needs the width to measure + /// card heights before blocks are populated). `text_metrics` is passed explicitly + /// rather than read from `self` so it does not depend on prepare-ordering. + pub fn content_width_for_bounds(&self, bounds: Rect, text_metrics: TextMetrics) -> f32 { + content_bounds_for_viewport(bounds, text_metrics).width + } + + pub fn content_height_for_bounds(&self, bounds: Rect, text_metrics: TextMetrics) -> f32 { + content_bounds_for_viewport(bounds, text_metrics).height + } + + /// Top edge band occupied by the sticky file header, if any, so overlays can avoid + /// painting/clicking over it. + pub fn sticky_header_rect(&self) -> Option { + self.sticky_header_hit.as_ref().map(|(rect, _)| *rect) + } + + pub fn overlay_clip_rect(&self, viewport_bounds: Rect) -> Rect { + match self.sticky_header_rect() { + Some(header) => Rect { + x: viewport_bounds.x, + y: header.bottom(), + width: viewport_bounds.width, + height: (viewport_bounds.bottom() - header.bottom()).max(0.0), + }, + None => viewport_bounds, + } + } + + /// Visible review block rows resolved into the same overlay rects used for both + /// rendering and clipping their element hit regions. + pub fn visible_review_block_overlays( + &self, + overlay_width: f32, + clip: Rect, + ) -> Vec { + let mut out = Vec::new(); + for row in &self.rows { + if !row.is_block() { + continue; + } + let rect = self.row_rect_for(row); + if !self.row_in_viewport(&rect) { + continue; + } + let block_index = row.block_index as usize; + let Some(block) = self.blocks.get(block_index) else { + continue; + }; + let kind = if block.is_composer() { + EditorOverlayKind::ReviewComposerBlock { block_index } + } else if block.review_card().is_some() { + EditorOverlayKind::ReviewThreadBlock { block_index } + } else { + continue; + }; + // Start the card/composer at the code column so the line-number gutter + // stays visible beside it (like GitHub), rather than covering it. + let code_x = if self.layout.split_mode { + self.layout.left_text_rect.x + } else { + self.layout.unified_text_rect.x + }; + let overlay_rect = Rect { + x: code_x.max(rect.x), + y: rect.y, + width: overlay_width, + height: rect.height, + }; + if let Some(overlay) = ResolvedEditorOverlay::new(kind, overlay_rect, clip) { + out.push(overlay); + } + } + out + } + + pub fn render_line_index_for_row(&self, display_row_index: usize) -> Option { + let row = self.rows.get(display_row_index)?; + if row.is_block() { + return None; + } + Some(row.line_index) + } + + pub fn is_block_row(&self, display_row_index: usize) -> bool { + self.rows + .get(display_row_index) + .is_some_and(|row| row.is_block()) + } + + fn rebuild_rows( + &mut self, + doc: &RenderDoc, + state: &EditorState, + text_metrics: TextMetrics, + show_file_headers: bool, + ) { + self.metrics = display_layout_metrics(text_metrics); + self.config = DisplayLayoutConfig { + split_mode: state.layout == LayoutMode::Split, + wrap_enabled: state.wrap_enabled, + wrap_column: state.wrap_column, + show_file_headers, + char_width_px: text_metrics.mono_char_width_px as f64, + unified_text_width_px: self.layout.unified_text_rect.width as f64, + split_text_width_px: self.layout.left_text_rect.width as f64, + }; + self.summary = + rebuild_display_rows(doc, self.config, self.metrics, &self.blocks, &mut self.rows); + build_strip_layouts(&self.rows, STRIP_TARGET_HEIGHT_PX, &mut self.strips); + self.file_header_hits.clear(); + if show_file_headers { + for row in &self.rows { + if row.kind != RenderRowKind::FileHeader as u8 { + continue; + } + let Some(line) = doc.lines.get(row.line_index as usize) else { + continue; + }; + if let Some(meta) = doc.file_meta(line) { + self.file_header_hits.push(FileHeaderHit { + y_px: row.y_px, + h_px: row.h_px, + path: meta.path.clone(), + }); + } + } + } + } + + fn rebuild_navigation_positions(&mut self, state: &mut EditorState) { + if !self.nav_positions_valid { + let mut hunk_positions = Vec::new(); + let mut file_positions = Vec::new(); + for row in &self.rows { + if row.kind == RenderRowKind::HunkSeparator as u8 { + hunk_positions.push(row.y_px); + } else if row.kind == RenderRowKind::FileHeader as u8 { + file_positions.push(row.y_px); + } + } + self.nav_hunk_positions = Arc::new(hunk_positions); + self.nav_file_positions = Arc::new(file_positions); + // Search Y positions are derived from row geometry too. + self.nav_search_matches = None; + self.nav_positions_valid = true; + } + state.hunk_positions = Arc::clone(&self.nav_hunk_positions); + state.file_positions = Arc::clone(&self.nav_file_positions); + + if state.search.open && !state.search.matches.is_empty() { + let cached = self + .nav_search_matches + .as_ref() + .is_some_and(|matches| Arc::ptr_eq(matches, &state.search.matches)); + if !cached { + let mut y_positions = Vec::with_capacity(state.search.matches.len()); + for m in state.search.matches.iter() { + let y = self + .rows + .iter() + .find(|r| !r.is_block() && r.line_index == m.line_index) + .map(|r| r.y_px) + .unwrap_or(0); + y_positions.push(y); + } + self.nav_search_y_positions = Arc::new(y_positions); + self.nav_search_matches = Some(Arc::clone(&state.search.matches)); + } + state.search_match_y_positions = Arc::clone(&self.nav_search_y_positions); + } else { + self.nav_search_matches = None; + if !state.search_match_y_positions.is_empty() { + state.search_match_y_positions = Arc::default(); + } + } + } + + fn update_visible_ranges(&mut self, state: &mut EditorState) { + let viewport_top_px = state.scroll_top_px; + let viewport_height_px = state.viewport_height_px.max(1); + let strip_range = visible_strip_range( + &self.strips, + viewport_top_px, + viewport_height_px, + STRIP_OVERSCAN, + ); + self.layout.visible_row_range = if strip_range.is_empty() { + VisibleRange::default() + } else { + let first = self.strips[strip_range.start].row_start; + let last = self.strips[strip_range.end - 1].row_end; + VisibleRange { + start: first, + end: last, + } + }; + + let visible_bottom_px = viewport_top_px.saturating_add(viewport_height_px); + let visible_start = self + .rows + .partition_point(|row| row.bottom_px() <= viewport_top_px); + let visible_end = self + .rows + .partition_point(|row| row.y_px < visible_bottom_px); + if visible_start < visible_end { + state.visible_row_start = Some(visible_start); + state.visible_row_end = Some(visible_end); + } else { + state.visible_row_start = None; + state.visible_row_end = None; + } + } + + pub(super) fn row_rect_for(&self, display_row: &DisplayRow) -> Rect { + Rect { + x: self.layout.content_bounds.x, + y: self.layout.content_bounds.y + display_row.y_px as f32 - self.layout.scroll_top_px, + width: self.layout.content_bounds.width, + height: display_row.h_px as f32, + } + } + + pub(super) fn row_in_viewport(&self, row_rect: &Rect) -> bool { + row_rect.bottom() >= self.layout.content_bounds.y + && row_rect.y <= self.layout.content_bounds.bottom() + } + + pub(super) fn render_cols_unified(&self) -> u16 { + render_cols_for_width( + self.config.wrap_enabled, + self.config.wrap_column, + self.config.char_width_px as f32, + self.layout.unified_text_rect.width, + ) + } + + pub(super) fn render_cols_split(&self) -> u16 { + render_cols_for_width( + self.config.wrap_enabled, + self.config.wrap_column, + self.config.char_width_px as f32, + self.layout.left_text_rect.width, + ) + } + + pub(super) fn visible_segment_range(&self, block_y: f32, segment_count: u16) -> Range { + visible_segment_range_for_block( + block_y, + segment_count.max(1), + self.layout.line_height, + self.layout.content_bounds.y, + self.layout.content_bounds.bottom(), + ) + } +} + +fn compute_scrollbar_layout( + layout: &EditorLayout, + state: &EditorState, + override_metrics: Option, +) -> Option { + if state.viewport_height_px == 0 { + return None; + } + let (content_height_px, scroll_top_px, max_scroll_top_px) = match override_metrics { + Some(o) => (o.total_height_px, o.scroll_top_px, o.max_scroll_top_px), + None => ( + state.content_height_px, + state.scroll_top_px, + state.max_scroll_top_px(), + ), + }; + if content_height_px <= state.viewport_height_px { + return None; + } + let s = layout.font_size / BASE_MONO_FONT_SIZE; + let sb_width = scaled(BASE_SCROLLBAR_WIDTH, s); + let sb_margin = scaled(BASE_SCROLLBAR_MARGIN, s); + let cb = layout.content_bounds; + let track = Rect { + x: cb.right() - sb_width, + y: cb.y + sb_margin, + width: sb_width, + height: (cb.height - sb_margin * 2.0).max(0.0), + }; + let ratio = state.viewport_height_px as f32 / content_height_px as f32; + let thumb_min = scaled(BASE_SCROLLBAR_THUMB_MIN, s); + let thumb_height = (track.height * ratio).max(thumb_min).min(track.height); + let scroll_range = max_scroll_top_px.max(1) as f32; + let top_ratio = (scroll_top_px as f32 / scroll_range).clamp(0.0, 1.0); + let thumb_y = track.y + (track.height - thumb_height) * top_ratio; + Some(ScrollbarLayout { + track, + thumb_top: thumb_y, + thumb_height, + thumb: Rect { + x: track.x + 1.0, + y: thumb_y + 1.0, + width: track.width - 2.0, + height: thumb_height - 2.0, + }, + }) +} + +fn build_spatial_layout( + bounds: Rect, + layout: LayoutMode, + gutter_digits: u32, + text_metrics: TextMetrics, +) -> EditorLayout { + let s = editor_scale(text_metrics); + let column_gap = scaled(BASE_COLUMN_GAP, s); + let gutter_padding = scaled(BASE_GUTTER_PADDING, s); + let scrollbar_width = scaled(BASE_SCROLLBAR_WIDTH, s); + let scrollbar_margin = scaled(BASE_SCROLLBAR_MARGIN, s); + + let content_bounds = content_bounds_for_viewport(bounds, text_metrics); + let usable_width = (content_bounds.width - scrollbar_width - scrollbar_margin).max(0.0); + let gutter_width = + gutter_digits as f32 * text_metrics.mono_char_width_px + gutter_padding * 2.0; + let unified_gutter_width = gutter_digits as f32 * text_metrics.mono_char_width_px * 2.0 + + text_metrics.mono_char_width_px + + gutter_padding * 2.0; + + if layout == LayoutMode::Split { + let col_width = ((usable_width - gutter_width * 2.0 - column_gap) / 2.0).max(60.0); + let left_gutter_rect = Rect { + x: content_bounds.x, + y: content_bounds.y, + width: gutter_width, + height: content_bounds.height, + }; + let text_left_pad = gutter_padding; + let left_text_rect = Rect { + x: left_gutter_rect.right() + text_left_pad, + y: content_bounds.y, + width: (col_width - text_left_pad).max(60.0), + height: content_bounds.height, + }; + let right_gutter_rect = Rect { + x: left_gutter_rect.right() + col_width + column_gap, + y: content_bounds.y, + width: gutter_width, + height: content_bounds.height, + }; + let right_text_rect = Rect { + x: right_gutter_rect.right() + text_left_pad, + y: content_bounds.y, + width: (content_bounds.right() + - scrollbar_width + - scrollbar_margin + - right_gutter_rect.right() + - text_left_pad) + .max(60.0), + height: content_bounds.height, + }; + EditorLayout { + outer_bounds: bounds, + content_bounds, + split_mode: true, + gutter_digits, + unified_gutter_rect: Rect::default(), + unified_text_rect: Rect::default(), + left_gutter_rect, + left_text_rect, + right_gutter_rect, + right_text_rect, + ..EditorLayout::default() + } + } else { + let unified_gutter_rect = Rect { + x: content_bounds.x, + y: content_bounds.y, + width: unified_gutter_width, + height: content_bounds.height, + }; + let text_left_pad = gutter_padding; + let unified_text_rect = Rect { + x: unified_gutter_rect.right() + text_left_pad, + y: content_bounds.y, + width: (usable_width - unified_gutter_width - text_left_pad).max(60.0), + height: content_bounds.height, + }; + EditorLayout { + outer_bounds: bounds, + content_bounds, + split_mode: false, + gutter_digits, + unified_gutter_rect, + unified_text_rect, + ..EditorLayout::default() + } + } +} + +fn wrap_cols_for_width( + wrap_enabled: bool, + wrap_column: u32, + char_width_px: f32, + width_px: f32, +) -> u16 { + if !wrap_enabled { + return u16::MAX; + } + let width_cols = (width_px / char_width_px.max(1.0)).floor() as u32; + let cols = if wrap_column > 0 { + width_cols.min(wrap_column) + } else { + width_cols + }; + cols.max(1).min(u16::MAX as u32) as u16 +} + +pub(super) fn render_cols_for_width( + wrap_enabled: bool, + wrap_column: u32, + char_width_px: f32, + width_px: f32, +) -> u16 { + if wrap_enabled { + return wrap_cols_for_width(true, wrap_column, char_width_px, width_px); + } + + let visible_cols = (width_px / char_width_px.max(1.0)).ceil() as u32; + visible_cols + .saturating_add(u32::from(UNWRAPPED_RENDER_OVERSCAN_COLS)) + .max(1) + .min(u16::MAX as u32) as u16 +} + +pub(super) fn visible_segment_range_for_block( + block_y: f32, + segment_count: u16, + line_height: f32, + viewport_top: f32, + viewport_bottom: f32, +) -> Range { + if segment_count == 0 || line_height <= 0.0 { + return 0..0; + } + + let max_segments = u32::from(segment_count); + let start = ((viewport_top - block_y) / line_height).floor().max(0.0) as u32; + let end = ((viewport_bottom - block_y) / line_height).ceil().max(0.0) as u32; + let start = start.min(max_segments); + let end = end.max(start).min(max_segments); + start as u16..end as u16 +} diff --git a/src/editor/diff/element/mod.rs b/src/editor/diff/element/mod.rs new file mode 100644 index 00000000..2898f37b --- /dev/null +++ b/src/editor/diff/element/mod.rs @@ -0,0 +1,487 @@ +use std::{collections::HashMap, ops::Range, sync::Arc}; + +use crate::render::{Rect, RichTextSpan, TextMetrics}; +use crate::ui::theme::{Color, Theme}; + +use super::decoration::BlockRegistry; +use super::display_layout::{DisplayLayoutConfig, DisplayLayoutMetrics, DisplayLayoutSummary}; +use super::render_doc::{ + ByteRange, DisplayRow, INVALID_U32, RenderDoc, RunRange, advance_display_col, +}; +use super::state::{SearchMatch, ViewportTextSide}; +use super::strip_layout::StripLayout; + +mod hit_test; +mod layout; +mod paint; +#[cfg(test)] +mod tests; + +use paint::{RowTone, build_wrapped_rich_text}; + +pub(crate) const BASE_MONO_FONT_SIZE: f32 = 13.0; + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct EditorLayout { + pub outer_bounds: Rect, + pub content_bounds: Rect, + pub split_mode: bool, + pub gutter_digits: u32, + pub unified_gutter_rect: Rect, + pub unified_text_rect: Rect, + pub left_gutter_rect: Rect, + pub left_text_rect: Rect, + pub right_gutter_rect: Rect, + pub right_text_rect: Rect, + + pub line_height: f32, + pub font_size: f32, + pub text_y_offset: f32, + pub gutter_padding: f32, + pub column_gap: f32, + pub scroll_top_px: f32, + pub visible_row_range: VisibleRange, + pub highlighted_row: Option, + pub scrollbar: Option, + pub show_staging_controls: bool, + pub file_is_staged: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct VisibleRange { + pub start: usize, + pub end: usize, +} + +impl VisibleRange { + pub fn iter(&self) -> Range { + self.start..self.end + } + + pub fn is_empty(&self) -> bool { + self.start >= self.end + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct ScrollbarLayout { + pub track: Rect, + pub thumb: Rect, + pub thumb_top: f32, + pub thumb_height: f32, +} + +#[derive(Debug, Clone, Copy)] +pub enum EditorDocument<'a> { + Empty, + Loading { + path: &'a str, + }, + Binary { + path: &'a str, + }, + Text { + compare_generation: u64, + file_index: usize, + path: &'a str, + doc: &'a RenderDoc, + show_file_headers: bool, + }, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct EditorLayoutKey { + compare_generation: u64, + file_index: usize, + show_file_headers: bool, + split_mode: bool, + wrap_enabled: bool, + wrap_column: u32, + viewport_width_bits: u32, + viewport_height_bits: u32, + mono_char_width_bits: u32, + mono_line_height_bits: u32, + doc_line_count: u32, + doc_text_len: u32, + block_layout_signature: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EditorThemeKey { + text_strong: Color, + text_muted: Color, + accent: Color, + line_add_text: Color, + line_del_text: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct WrappedTextCacheKey { + text_start: u32, + text_len: u32, + runs_start: u32, + runs_len: u32, + segment_index: u16, + wrap_cols: u16, + tone: RowTone, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct GutterTextCacheKey { + old_line_no: u32, + new_line_no: u32, + digits: u32, + kind: GutterTextKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum GutterTextKind { + SplitLeft, + SplitRight, + Unified, + UnifiedOldOnly, + UnifiedNewOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct TextLayoutCacheKey { + text_start: u32, + text_len: u32, +} + +#[derive(Debug, Clone)] +struct CachedTextLayout { + char_boundaries: Arc<[u32]>, + col_boundaries: Arc<[u32]>, +} + +impl CachedTextLayout { + fn new(text: &str) -> Self { + let mut char_boundaries = Vec::with_capacity(text.chars().count().saturating_add(1)); + let mut col_boundaries = Vec::with_capacity(text.chars().count().saturating_add(1)); + let mut cols = 0_u32; + + for (idx, ch) in text.char_indices() { + char_boundaries.push(idx as u32); + col_boundaries.push(cols); + cols = advance_display_col(cols, ch); + } + char_boundaries.push(text.len() as u32); + col_boundaries.push(cols); + Self { + char_boundaries: Arc::from(char_boundaries), + col_boundaries: Arc::from(col_boundaries), + } + } + + fn char_count(&self) -> u32 { + self.char_boundaries.len().saturating_sub(1) as u32 + } + + fn total_cols(&self) -> u32 { + self.col_boundaries.last().copied().unwrap_or(0) + } + + fn char_range_for_cols(&self, start_col: u32, end_col: u32) -> (usize, usize) { + let total_cols = self.total_cols(); + let start_col = start_col.min(total_cols); + let end_col = end_col.min(total_cols).max(start_col); + let char_count = self.char_count() as usize; + let start = self + .col_boundaries + .partition_point(|boundary| *boundary <= start_col) + .saturating_sub(1) + .min(char_count); + let end = self + .col_boundaries + .partition_point(|boundary| *boundary < end_col) + .min(char_count); + (start, end.max(start)) + } + + #[cfg(test)] + fn byte_range_for_cols(&self, start_col: u32, end_col: u32) -> (usize, usize) { + let (start, end) = self.char_range_for_cols(start_col, end_col); + ( + self.char_boundaries[start] as usize, + self.char_boundaries[end] as usize, + ) + } + + fn col_for_byte(&self, byte: usize) -> u32 { + let byte = (byte as u32).min(self.char_boundaries.last().copied().unwrap_or(0)); + let idx = self + .char_boundaries + .partition_point(|boundary| *boundary <= byte) + .saturating_sub(1); + self.col_boundaries.get(idx).copied().unwrap_or(0) + } + + fn byte_for_col_nearest(&self, col: f32) -> u32 { + let Some(&last_byte) = self.char_boundaries.last() else { + return 0; + }; + let Some(&last_col) = self.col_boundaries.last() else { + return 0; + }; + if col <= 0.0 { + return 0; + } + if col >= last_col as f32 { + return last_byte; + } + + let upper = self + .col_boundaries + .partition_point(|boundary| (*boundary as f32) < col) + .min(self.col_boundaries.len().saturating_sub(1)); + let lower = upper.saturating_sub(1); + let lower_col = self.col_boundaries[lower] as f32; + let upper_col = self.col_boundaries[upper] as f32; + if (col - lower_col).abs() <= (upper_col - col).abs() { + self.char_boundaries[lower] + } else { + self.char_boundaries[upper] + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScrollbarOverride { + pub total_height_px: u32, + pub scroll_top_px: u32, + pub max_scroll_top_px: u32, +} + +#[derive(Debug)] +pub struct EditorElement { + layout_key: Option, + pub layout: EditorLayout, + config: DisplayLayoutConfig, + metrics: DisplayLayoutMetrics, + summary: DisplayLayoutSummary, + rows: Vec, + strips: Vec, + blocks: BlockRegistry, + hunk_expand_caps: Vec, + theme_cache_key: Option, + wrapped_text_cache: HashMap>, + text_layout_cache: HashMap>, + gutter_text_cache: HashMap>, + text_metrics: TextMetrics, + scrollbar_override: Option, + sticky_header_hit: Option<(Rect, String)>, + file_header_hits: Vec, + mouse_pos: Option<(f32, f32)>, + /// Memoized navigation positions. Hunk/file positions depend only on + /// `rows` (rebuilt when `layout_key` changes); search Y positions + /// additionally depend on the search match set. Recomputing these every + /// frame meant iterating every row per frame, so cache and hand out + /// shared Arcs instead. + nav_positions_valid: bool, + nav_hunk_positions: Arc>, + nav_file_positions: Arc>, + nav_search_matches: Option>>, + nav_search_y_positions: Arc>, +} + +#[derive(Debug, Clone)] +struct FileHeaderHit { + y_px: u32, + h_px: u16, + path: String, +} + +#[derive(Debug, Clone, Copy)] +struct TextBlock { + line_index: u32, + side: ViewportTextSide, + text_range: ByteRange, + text_x: f32, + text_width: f32, + y: f32, + segment_count: u16, + segment_cols: u16, +} + +impl Default for EditorElement { + fn default() -> Self { + Self { + layout_key: None, + layout: EditorLayout::default(), + config: DisplayLayoutConfig::default(), + metrics: DisplayLayoutMetrics::default(), + summary: DisplayLayoutSummary::default(), + rows: Vec::new(), + strips: Vec::new(), + blocks: BlockRegistry::new(), + hunk_expand_caps: Vec::new(), + theme_cache_key: None, + wrapped_text_cache: HashMap::new(), + text_layout_cache: HashMap::new(), + gutter_text_cache: HashMap::new(), + text_metrics: TextMetrics::default(), + scrollbar_override: None, + sticky_header_hit: None, + file_header_hits: Vec::new(), + mouse_pos: None, + nav_positions_valid: false, + nav_hunk_positions: Arc::default(), + nav_file_positions: Arc::default(), + nav_search_matches: None, + nav_search_y_positions: Arc::default(), + } + } +} + +impl EditorElement { + pub fn set_scrollbar_override(&mut self, value: Option) { + self.scrollbar_override = value; + } + + pub fn set_mouse_pos(&mut self, pos: Option<(f32, f32)>) { + self.mouse_pos = pos; + } + + pub fn scrollbar_layout(&self) -> Option { + self.layout.scrollbar + } + + pub fn scroll_line_height_px(&self) -> f32 { + let lh = self.layout.line_height; + if lh > 0.0 { lh } else { 20.0 } + } + + pub fn blocks_mut(&mut self) -> &mut BlockRegistry { + &mut self.blocks + } + + pub fn blocks(&self) -> &BlockRegistry { + &self.blocks + } + + pub fn set_hunk_expand_caps(&mut self, caps: Vec) { + self.hunk_expand_caps = caps; + } + + fn clear_document_caches(&mut self) { + self.wrapped_text_cache.clear(); + self.text_layout_cache.clear(); + self.gutter_text_cache.clear(); + // Rows changed (or went away) — navigation positions must be + // recomputed from the new row geometry. + self.nav_positions_valid = false; + self.nav_search_matches = None; + } + + fn sync_theme_cache(&mut self, theme: &Theme) { + let key = EditorThemeKey { + text_strong: theme.colors.text_strong, + text_muted: theme.colors.text_muted, + accent: theme.colors.accent, + line_add_text: theme.colors.line_add_text, + line_del_text: theme.colors.line_del_text, + }; + if self.theme_cache_key != Some(key) { + self.wrapped_text_cache.clear(); + self.theme_cache_key = Some(key); + } + } + + fn cached_wrapped_rich_text( + &mut self, + doc: &RenderDoc, + text_range: ByteRange, + runs: RunRange, + segment_index: u16, + wrap_cols: u16, + tone: RowTone, + theme: &Theme, + ) -> Option> { + if !text_range.is_valid() { + return None; + } + let key = WrappedTextCacheKey { + text_start: text_range.start, + text_len: text_range.len, + runs_start: runs.start, + runs_len: runs.len, + segment_index, + wrap_cols, + tone, + }; + if let Some(cached) = self.wrapped_text_cache.get(&key) { + return Some(cached.clone()); + } + + let text_layout = self.cached_text_layout(doc, text_range); + let spans = build_wrapped_rich_text( + doc, + text_layout.as_ref(), + text_range, + runs, + segment_index, + wrap_cols, + tone, + theme, + )?; + self.wrapped_text_cache.insert(key, spans.clone()); + Some(spans) + } + + fn cached_text_layout( + &mut self, + doc: &RenderDoc, + text_range: ByteRange, + ) -> Arc { + let key = TextLayoutCacheKey { + text_start: text_range.start, + text_len: text_range.len, + }; + if let Some(cached) = self.text_layout_cache.get(&key) { + return cached.clone(); + } + + let layout = Arc::new(CachedTextLayout::new(doc.line_text(text_range))); + self.text_layout_cache.insert(key, layout.clone()); + layout + } + + fn cached_gutter_text(&mut self, key: GutterTextCacheKey) -> Arc { + if let Some(cached) = self.gutter_text_cache.get(&key) { + return cached.clone(); + } + + let spaces = " ".repeat(key.digits as usize); + let text: Arc = match key.kind { + GutterTextKind::SplitLeft => format_line_number_string(key.old_line_no, key.digits), + GutterTextKind::SplitRight => format_line_number_string(key.new_line_no, key.digits), + GutterTextKind::Unified => format!( + "{} {}", + format_line_number_string(key.old_line_no, key.digits), + format_line_number_string(key.new_line_no, key.digits) + ), + GutterTextKind::UnifiedOldOnly => format!( + "{} {}", + format_line_number_string(key.old_line_no, key.digits), + spaces + ), + GutterTextKind::UnifiedNewOnly => format!( + "{} {}", + spaces, + format_line_number_string(key.new_line_no, key.digits) + ), + } + .into(); + self.gutter_text_cache.insert(key, text.clone()); + text + } +} + +fn format_line_number_string(line_no: u32, digits: u32) -> String { + if line_no == INVALID_U32 { + " ".repeat(digits as usize) + } else { + format!("{line_no:>width$}", width = digits as usize) + } +} diff --git a/src/editor/diff/element.rs b/src/editor/diff/element/paint.rs similarity index 50% rename from src/editor/diff/element.rs rename to src/editor/diff/element/paint.rs index b7a61a91..114581e7 100644 --- a/src/editor/diff/element.rs +++ b/src/editor/diff/element/paint.rs @@ -1,986 +1,38 @@ -use std::{collections::HashMap, ops::Range, sync::Arc}; +use std::ops::Range; +use std::sync::Arc; -use crate::actions::{Action, ContextMenuEntry}; -use crate::core::compare::LayoutMode; -use crate::core::text::SyntaxTokenKind; -use crate::render::{ - FontKind, FontStyle, FontWeight, Rect, RectPrimitive, RichTextPrimitive, RichTextSpan, - RoundedRectPrimitive, Scene, TextMetrics, TextPrimitive, -}; -use crate::ui::accessibility::{AccessibilityAction, AccessibilityFrame, AccessibilityNode}; -use crate::ui::design::{Alpha, Sz}; -use crate::ui::element::ScrollActionBuilder; -use crate::ui::state::FocusTarget; -use crate::ui::theme::{Color, Theme}; -use accesskit::Role; - -use super::anchor::{EditorOverlayKind, ResolvedEditorOverlay}; -use super::decoration::{ - BlockActionCtx, BlockPaintCtx, BlockRegistry, FileHeaderDecoration, RowDecoration, RowPaintCtx, - decoration_for_kind, -}; -use super::display_layout::{ - DisplayLayoutConfig, DisplayLayoutMetrics, DisplayLayoutSummary, compute_gutter_digits, - rebuild_display_rows, -}; -use super::render_doc::{ - ByteRange, DisplayRow, FileHeaderMeta, INVALID_U32, RENDER_FLAG_STRUCTURAL, RenderDoc, - RenderLine, RenderRowKind, RunRange, STYLE_FLAG_CHANGE, STYLE_FLAG_UNCHANGED_CTX, StyleRun, - advance_display_col, -}; -use super::state::{ - EditorState, SearchMatch, ViewportTextPoint, ViewportTextSelection, ViewportTextSide, -}; -use super::strip_layout::{StripLayout, build_strip_layouts, visible_strip_range}; - -const BASE_VIEWPORT_PADDING: f32 = 14.0; -const BASE_COLUMN_GAP: f32 = 18.0; -const BASE_GUTTER_PADDING: f32 = 8.0; -const BASE_SCROLLBAR_WIDTH: f32 = 8.0; -const BASE_SCROLLBAR_MARGIN: f32 = 6.0; -const FILE_HEADER_ROW_MULTIPLE: u16 = 1; -const HUNK_ROW_MULTIPLE: u16 = 1; -const BASE_SCROLLBAR_THUMB_MIN: f32 = 32.0; -pub(crate) const BASE_MONO_FONT_SIZE: f32 = 13.0; -const STRIP_TARGET_HEIGHT_PX: u32 = 480; -const STRIP_OVERSCAN: usize = 1; -const UNWRAPPED_RENDER_OVERSCAN_COLS: u16 = 16; -const STICKY_HEADER_Z: i32 = 10; -const INLINE_CHANGE_BG_MERGE_GAP_COLS: u32 = 2; -const INLINE_CHANGE_BG_Y_INSET_RATIO: f32 = 0.10; - -fn editor_scale(text_metrics: TextMetrics) -> f32 { - (text_metrics.mono_font_size_px / BASE_MONO_FONT_SIZE).max(0.5) -} - -fn display_layout_metrics(text_metrics: TextMetrics) -> DisplayLayoutMetrics { - let body_h = text_metrics.mono_line_height_px.round().max(1.0) as u16; - DisplayLayoutMetrics { - body_row_height_px: body_h, - file_header_height_px: body_h * FILE_HEADER_ROW_MULTIPLE, - hunk_height_px: body_h * HUNK_ROW_MULTIPLE, - } -} - -fn scaled(base: f32, scale: f32) -> f32 { - base * scale -} - -fn content_bounds_for_viewport(bounds: Rect, text_metrics: TextMetrics) -> Rect { - bounds.inset(scaled(BASE_VIEWPORT_PADDING, editor_scale(text_metrics))) -} - -fn editor_bottom_padding_px(metrics: DisplayLayoutMetrics) -> u32 { - u32::from(metrics.body_row_height_px) -} - -#[derive(Debug, Clone, Copy, Default, PartialEq)] -pub struct EditorLayout { - pub outer_bounds: Rect, - pub content_bounds: Rect, - pub split_mode: bool, - pub gutter_digits: u32, - pub unified_gutter_rect: Rect, - pub unified_text_rect: Rect, - pub left_gutter_rect: Rect, - pub left_text_rect: Rect, - pub right_gutter_rect: Rect, - pub right_text_rect: Rect, - - pub line_height: f32, - pub font_size: f32, - pub text_y_offset: f32, - pub gutter_padding: f32, - pub column_gap: f32, - pub scroll_top_px: f32, - pub visible_row_range: VisibleRange, - pub highlighted_row: Option, - pub scrollbar: Option, - pub show_staging_controls: bool, - pub file_is_staged: bool, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq)] -pub struct VisibleRange { - pub start: usize, - pub end: usize, -} - -impl VisibleRange { - pub fn iter(&self) -> Range { - self.start..self.end - } - - pub fn is_empty(&self) -> bool { - self.start >= self.end - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq)] -pub struct ScrollbarLayout { - pub track: Rect, - pub thumb: Rect, - pub thumb_top: f32, - pub thumb_height: f32, -} - -#[derive(Debug, Clone, Copy)] -pub enum EditorDocument<'a> { - Empty, - Loading { - path: &'a str, - }, - Binary { - path: &'a str, - }, - Text { - compare_generation: u64, - file_index: usize, - path: &'a str, - doc: &'a RenderDoc, - show_file_headers: bool, - }, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct EditorLayoutKey { - compare_generation: u64, - file_index: usize, - show_file_headers: bool, - split_mode: bool, - wrap_enabled: bool, - wrap_column: u32, - viewport_width_bits: u32, - viewport_height_bits: u32, - mono_char_width_bits: u32, - mono_line_height_bits: u32, - doc_line_count: u32, - doc_text_len: u32, - block_layout_signature: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct EditorThemeKey { - text_strong: Color, - text_muted: Color, - accent: Color, - line_add_text: Color, - line_del_text: Color, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -struct WrappedTextCacheKey { - text_start: u32, - text_len: u32, - runs_start: u32, - runs_len: u32, - segment_index: u16, - wrap_cols: u16, - tone: RowTone, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -struct GutterTextCacheKey { - old_line_no: u32, - new_line_no: u32, - digits: u32, - kind: GutterTextKind, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum GutterTextKind { - SplitLeft, - SplitRight, - Unified, - UnifiedOldOnly, - UnifiedNewOnly, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -struct TextLayoutCacheKey { - text_start: u32, - text_len: u32, -} - -#[derive(Debug, Clone)] -struct CachedTextLayout { - char_boundaries: Arc<[u32]>, - col_boundaries: Arc<[u32]>, -} - -impl CachedTextLayout { - fn new(text: &str) -> Self { - let mut char_boundaries = Vec::with_capacity(text.chars().count().saturating_add(1)); - let mut col_boundaries = Vec::with_capacity(text.chars().count().saturating_add(1)); - let mut cols = 0_u32; - - for (idx, ch) in text.char_indices() { - char_boundaries.push(idx as u32); - col_boundaries.push(cols); - cols = advance_display_col(cols, ch); - } - char_boundaries.push(text.len() as u32); - col_boundaries.push(cols); - Self { - char_boundaries: Arc::from(char_boundaries), - col_boundaries: Arc::from(col_boundaries), - } - } - - fn char_count(&self) -> u32 { - self.char_boundaries.len().saturating_sub(1) as u32 - } - - fn total_cols(&self) -> u32 { - self.col_boundaries.last().copied().unwrap_or(0) - } - - fn char_range_for_cols(&self, start_col: u32, end_col: u32) -> (usize, usize) { - let total_cols = self.total_cols(); - let start_col = start_col.min(total_cols); - let end_col = end_col.min(total_cols).max(start_col); - let char_count = self.char_count() as usize; - let start = self - .col_boundaries - .partition_point(|boundary| *boundary <= start_col) - .saturating_sub(1) - .min(char_count); - let end = self - .col_boundaries - .partition_point(|boundary| *boundary < end_col) - .min(char_count); - (start, end.max(start)) - } - - #[cfg(test)] - fn byte_range_for_cols(&self, start_col: u32, end_col: u32) -> (usize, usize) { - let (start, end) = self.char_range_for_cols(start_col, end_col); - ( - self.char_boundaries[start] as usize, - self.char_boundaries[end] as usize, - ) - } - - fn col_for_byte(&self, byte: usize) -> u32 { - let byte = (byte as u32).min(self.char_boundaries.last().copied().unwrap_or(0)); - let idx = self - .char_boundaries - .partition_point(|boundary| *boundary <= byte) - .saturating_sub(1); - self.col_boundaries.get(idx).copied().unwrap_or(0) - } - - fn byte_for_col_nearest(&self, col: f32) -> u32 { - let Some(&last_byte) = self.char_boundaries.last() else { - return 0; - }; - let Some(&last_col) = self.col_boundaries.last() else { - return 0; - }; - if col <= 0.0 { - return 0; - } - if col >= last_col as f32 { - return last_byte; - } - - let upper = self - .col_boundaries - .partition_point(|boundary| (*boundary as f32) < col) - .min(self.col_boundaries.len().saturating_sub(1)); - let lower = upper.saturating_sub(1); - let lower_col = self.col_boundaries[lower] as f32; - let upper_col = self.col_boundaries[upper] as f32; - if (col - lower_col).abs() <= (upper_col - col).abs() { - self.char_boundaries[lower] - } else { - self.char_boundaries[upper] - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ScrollbarOverride { - pub total_height_px: u32, - pub scroll_top_px: u32, - pub max_scroll_top_px: u32, -} - -#[derive(Debug)] -pub struct EditorElement { - layout_key: Option, - pub layout: EditorLayout, - config: DisplayLayoutConfig, - metrics: DisplayLayoutMetrics, - summary: DisplayLayoutSummary, - rows: Vec, - strips: Vec, - blocks: BlockRegistry, - hunk_expand_caps: Vec, - theme_cache_key: Option, - wrapped_text_cache: HashMap>, - text_layout_cache: HashMap>, - gutter_text_cache: HashMap>, - text_metrics: TextMetrics, - scrollbar_override: Option, - sticky_header_hit: Option<(Rect, String)>, - file_header_hits: Vec, - mouse_pos: Option<(f32, f32)>, - /// Memoized navigation positions. Hunk/file positions depend only on - /// `rows` (rebuilt when `layout_key` changes); search Y positions - /// additionally depend on the search match set. Recomputing these every - /// frame meant iterating every row per frame, so cache and hand out - /// shared Arcs instead. - nav_positions_valid: bool, - nav_hunk_positions: Arc>, - nav_file_positions: Arc>, - nav_search_matches: Option>>, - nav_search_y_positions: Arc>, -} - -#[derive(Debug, Clone)] -struct FileHeaderHit { - y_px: u32, - h_px: u16, - path: String, -} - -#[derive(Debug, Clone, Copy)] -struct TextBlock { - line_index: u32, - side: ViewportTextSide, - text_range: ByteRange, - text_x: f32, - text_width: f32, - y: f32, - segment_count: u16, - segment_cols: u16, -} - -impl Default for EditorElement { - fn default() -> Self { - Self { - layout_key: None, - layout: EditorLayout::default(), - config: DisplayLayoutConfig::default(), - metrics: DisplayLayoutMetrics::default(), - summary: DisplayLayoutSummary::default(), - rows: Vec::new(), - strips: Vec::new(), - blocks: BlockRegistry::new(), - hunk_expand_caps: Vec::new(), - theme_cache_key: None, - wrapped_text_cache: HashMap::new(), - text_layout_cache: HashMap::new(), - gutter_text_cache: HashMap::new(), - text_metrics: TextMetrics::default(), - scrollbar_override: None, - sticky_header_hit: None, - file_header_hits: Vec::new(), - mouse_pos: None, - nav_positions_valid: false, - nav_hunk_positions: Arc::default(), - nav_file_positions: Arc::default(), - nav_search_matches: None, - nav_search_y_positions: Arc::default(), - } - } -} - -impl EditorElement { - pub fn set_scrollbar_override(&mut self, value: Option) { - self.scrollbar_override = value; - } - - pub fn set_mouse_pos(&mut self, pos: Option<(f32, f32)>) { - self.mouse_pos = pos; - } - - pub fn scrollbar_layout(&self) -> Option { - self.layout.scrollbar - } - - pub fn scroll_line_height_px(&self) -> f32 { - let lh = self.layout.line_height; - if lh > 0.0 { lh } else { 20.0 } - } - - pub fn prepare( - &mut self, - state: &mut EditorState, - document: EditorDocument<'_>, - bounds: Rect, - text_metrics: TextMetrics, - ) -> EditorLayout { - self.text_metrics = text_metrics; - let gutter_digits = match document { - EditorDocument::Text { doc, .. } => compute_gutter_digits(doc), - _ => 3, - }; - self.layout = build_spatial_layout(bounds, state.layout, gutter_digits, text_metrics); - state.viewport_width_px = self.layout.content_bounds.width.max(0.0).round() as u32; - state.viewport_height_px = self.layout.content_bounds.height.max(0.0).round() as u32; - - let s = editor_scale(text_metrics); - self.layout.font_size = text_metrics.mono_font_size_px; - self.layout.line_height = self.metrics.body_row_height_px as f32; - let glyphon_line_h = text_metrics.mono_font_size_px * 1.35; - self.layout.text_y_offset = ((self.layout.line_height - glyphon_line_h) * 0.5).max(0.0); - self.layout.gutter_padding = scaled(BASE_GUTTER_PADDING, s); - self.layout.column_gap = scaled(BASE_COLUMN_GAP, s); - - match document { - EditorDocument::Text { - compare_generation, - file_index, - doc, - show_file_headers, - .. - } => { - let key = EditorLayoutKey { - compare_generation, - file_index, - show_file_headers, - split_mode: state.layout == LayoutMode::Split, - wrap_enabled: state.wrap_enabled, - wrap_column: state.wrap_column, - viewport_width_bits: self.layout.content_bounds.width.to_bits(), - viewport_height_bits: self.layout.content_bounds.height.to_bits(), - mono_char_width_bits: text_metrics.mono_char_width_px.to_bits(), - mono_line_height_bits: text_metrics.mono_line_height_px.to_bits(), - doc_line_count: doc.line_count() as u32, - doc_text_len: doc.text_bytes.len().min(u32::MAX as usize) as u32, - block_layout_signature: self - .blocks - .layout_signature(display_layout_metrics(text_metrics)), - }; - - if self.layout_key != Some(key) { - self.rebuild_rows(doc, state, text_metrics, show_file_headers); - self.clear_document_caches(); - self.layout_key = Some(key); - } - - // Stamp the generation this geometry belongs to. When a new - // compare generation replaces the document, the carried-over - // scroll offset may point past geometry that no longer - // exists; the `clamp_scroll` below re-clamps it against the - // freshly built layout. Scroll is intentionally not reset - // here: per-file resets (and continuous-scroll restore) are - // owned by the reducer so a recompare of the same file keeps - // the user's place. - state.doc_generation = compare_generation; - - state.content_height_px = self - .summary - .content_height_px - .saturating_add(editor_bottom_padding_px(self.metrics)); - self.rebuild_navigation_positions(state); - state.clamp_scroll(); - self.update_visible_ranges(state); - - self.layout.line_height = self.metrics.body_row_height_px as f32; - } - _ => { - self.layout_key = None; - self.rows.clear(); - self.strips.clear(); - self.clear_document_caches(); - state.clear_document(); - } - } - - self.layout.scroll_top_px = state.scroll_top_px as f32; - self.layout.highlighted_row = state.hovered_row; - self.layout.scrollbar = - compute_scrollbar_layout(&self.layout, state, self.scrollbar_override); - - self.layout - } - - pub fn body_bounds(&self) -> Rect { - self.layout.content_bounds - } - - pub fn hit_test_row(&self, state: &EditorState, x: f32, y: f32) -> Option { - if !self.layout.content_bounds.contains(x, y) { - return None; - } - let content_y = (y - self.layout.content_bounds.y).max(0.0) + state.scroll_top_px as f32; - let index = self - .rows - .partition_point(|row| row.bottom_px() as f32 <= content_y); - self.rows.get(index).and_then(|row| { - (content_y >= row.y_px as f32 && content_y < row.bottom_px() as f32).then_some(index) - }) - } - - pub fn is_gutter_hit(&self, x: f32, _y: f32) -> bool { - if self.layout.split_mode { - self.layout - .left_gutter_rect - .contains(x, self.layout.left_gutter_rect.y) - } else { - self.layout - .unified_gutter_rect - .contains(x, self.layout.unified_gutter_rect.y) - } - } - - pub fn blocks_mut(&mut self) -> &mut BlockRegistry { - &mut self.blocks - } - - pub fn blocks(&self) -> &BlockRegistry { - &self.blocks - } - - /// Pins the single card-width formula used by the review-thread overlay. Mirrors - /// the inset `build_spatial_layout` applies to produce `content_bounds.width`, so a - /// caller can derive the width BEFORE `prepare` runs (it needs the width to measure - /// card heights before blocks are populated). `text_metrics` is passed explicitly - /// rather than read from `self` so it does not depend on prepare-ordering. - pub fn content_width_for_bounds(&self, bounds: Rect, text_metrics: TextMetrics) -> f32 { - content_bounds_for_viewport(bounds, text_metrics).width - } - - pub fn content_height_for_bounds(&self, bounds: Rect, text_metrics: TextMetrics) -> f32 { - content_bounds_for_viewport(bounds, text_metrics).height - } - - /// Top edge band occupied by the sticky file header, if any, so overlays can avoid - /// painting/clicking over it. - pub fn sticky_header_rect(&self) -> Option { - self.sticky_header_hit.as_ref().map(|(rect, _)| *rect) - } - - pub fn overlay_clip_rect(&self, viewport_bounds: Rect) -> Rect { - match self.sticky_header_rect() { - Some(header) => Rect { - x: viewport_bounds.x, - y: header.bottom(), - width: viewport_bounds.width, - height: (viewport_bounds.bottom() - header.bottom()).max(0.0), - }, - None => viewport_bounds, - } - } - - /// Visible review block rows resolved into the same overlay rects used for both - /// rendering and clipping their element hit regions. - pub fn visible_review_block_overlays( - &self, - overlay_width: f32, - clip: Rect, - ) -> Vec { - let mut out = Vec::new(); - for row in &self.rows { - if !row.is_block() { - continue; - } - let rect = self.row_rect_for(row); - if !self.row_in_viewport(&rect) { - continue; - } - let block_index = row.block_index as usize; - let Some(block) = self.blocks.get(block_index) else { - continue; - }; - let kind = if block.is_composer() { - EditorOverlayKind::ReviewComposerBlock { block_index } - } else if block.review_card().is_some() { - EditorOverlayKind::ReviewThreadBlock { block_index } - } else { - continue; - }; - // Start the card/composer at the code column so the line-number gutter - // stays visible beside it (like GitHub), rather than covering it. - let code_x = if self.layout.split_mode { - self.layout.left_text_rect.x - } else { - self.layout.unified_text_rect.x - }; - let overlay_rect = Rect { - x: code_x.max(rect.x), - y: rect.y, - width: overlay_width, - height: rect.height, - }; - if let Some(overlay) = ResolvedEditorOverlay::new(kind, overlay_rect, clip) { - out.push(overlay); - } - } - out - } - - pub fn set_hunk_expand_caps(&mut self, caps: Vec) { - self.hunk_expand_caps = caps; - } - - pub fn render_line_index_for_row(&self, display_row_index: usize) -> Option { - let row = self.rows.get(display_row_index)?; - if row.is_block() { - return None; - } - Some(row.line_index) - } - - pub fn is_block_row(&self, display_row_index: usize) -> bool { - self.rows - .get(display_row_index) - .is_some_and(|row| row.is_block()) - } - - pub fn review_comment_line_for_row( - &self, - doc: &RenderDoc, - display_row_index: usize, - ) -> Option { - let row = self.rows.get(display_row_index)?; - if row.is_block() { - return None; - } - let line = doc.lines.get(row.line_index as usize)?; - review_comment_gutter_rect(&self.layout, line)?; - Some(row.line_index as usize) - } - - pub fn review_add_button_overlay( - &self, - state: &EditorState, - doc: &RenderDoc, - clip: Rect, - ) -> Option { - if !state.review_enabled { - return None; - } - let row_index = self.layout.highlighted_row?; - self.review_add_button_overlay_for_row(state, doc, row_index, clip) - } - - pub fn review_add_button_overlay_at( - &self, - state: &EditorState, - doc: &RenderDoc, - x: f32, - y: f32, - clip: Rect, - ) -> Option { - if !state.review_enabled { - return None; - } - let row_index = self.hit_test_row(state, x, y)?; - let overlay = self.review_add_button_overlay_for_row(state, doc, row_index, clip)?; - overlay.contains(x, y).then_some(overlay) - } - - pub fn block_action_for_row_at( - &self, - display_row_index: usize, - x: f32, - y: f32, - ) -> Option { - let row = self.rows.get(display_row_index)?; - if !row.is_block() { - return None; - } - let block = self.blocks.get(row.block_index as usize)?; - block.on_click_at( - &BlockActionCtx { - layout: &self.layout, - row_rect: self.row_rect_for(row), - }, - x, - y, - ) - } - - pub fn block_context_menu_for_row( - &self, - display_row_index: usize, - ) -> Option> { - let row = self.rows.get(display_row_index)?; - if !row.is_block() { - return None; - } - self.blocks - .get(row.block_index as usize)? - .context_menu_entries() - } - - pub fn hunk_action_bar_rect(&self, doc: &RenderDoc) -> Option<(Rect, i16)> { - let idx = self.layout.highlighted_row?; - let display_row = self.rows.get(idx)?; - if display_row.is_block() { - return None; - } - let line = doc.lines.get(display_row.line_index as usize)?; - if line.hunk_index < 0 { - return None; - } - let hunk_index = line.hunk_index; - - let mut first_idx = idx; - while first_idx > 0 { - let prev = self.rows.get(first_idx - 1)?; - if prev.is_block() { - break; - } - let prev_line = doc.lines.get(prev.line_index as usize)?; - if prev_line.hunk_index != hunk_index { - break; - } - first_idx -= 1; - } - - let mut last_idx = idx; - while last_idx + 1 < self.rows.len() { - let next = &self.rows[last_idx + 1]; - if next.is_block() { - break; - } - let Some(next_line) = doc.lines.get(next.line_index as usize) else { - break; - }; - if next_line.hunk_index != hunk_index { - break; - } - last_idx += 1; - } - - let first_rect = self.row_rect_for(&self.rows[first_idx]); - let last_rect = self.row_rect_for(&self.rows[last_idx]); - let row_h = first_rect.height; - let viewport_top = self.layout.content_bounds.y; - let viewport_bottom = self.layout.content_bounds.bottom(); - - let max_y = (last_rect.y + last_rect.height - row_h).max(first_rect.y); - let y = first_rect.y.max(viewport_top).min(max_y); - if y + row_h <= viewport_top || y >= viewport_bottom { - return None; - } - - // The bar floats on the hunk separator row, which spans the full - // content width in both split and unified modes. Use the full text span - // so the buttons right-align against the editor edge, not a column. - let (x, width) = if self.layout.split_mode { - let left = self.layout.left_text_rect.x; - let right = self.layout.right_text_rect.x + self.layout.right_text_rect.width; - (left, right - left) - } else { - ( - self.layout.unified_text_rect.x, - self.layout.unified_text_rect.width, - ) - }; - Some(( - Rect { - x, - y, - width, - height: row_h, - }, - hunk_index, - )) - } - - pub fn line_selection_bar_rect(&self, doc: &RenderDoc, state: &EditorState) -> Option { - use super::render_doc::RenderRowKind; - - if state.line_selection.is_empty() { - return None; - } - - let is_selected = |row: &DisplayRow| -> bool { - if row.is_block() { - return false; - } - let Some(line) = doc.lines.get(row.line_index as usize) else { - return false; - }; - if !matches!( - line.row_kind(), - RenderRowKind::Added | RenderRowKind::Removed | RenderRowKind::Modified - ) { - return false; - } - line_selection_contains_line( - &state.line_selection, - file_path_for_line(doc, row.line_index as usize), - line, - ) - }; - - let first = self.rows.iter().find(|r| is_selected(r))?; - let last = self.rows.iter().rev().find(|r| is_selected(r))?; - - let first_rect = self.row_rect_for(first); - let last_rect = self.row_rect_for(last); - let last_bottom = last_rect.y + last_rect.height; - let viewport_top = self.layout.content_bounds.y; - let viewport_bottom = self.layout.content_bounds.bottom(); - - // Hide entirely when the selection is fully outside the viewport. - if last_bottom <= viewport_top || first_rect.y >= viewport_bottom { - return None; - } - - let bar_h = first_rect.height; - // Float the bar above the first selected row. Once the user scrolls - // past that row the bar stays pinned to the viewport top, acting like - // a sticky header over the selection — no jumps, no disappearing. - let above_y = first_rect.y - bar_h; - let y = above_y.max(viewport_top); - // If even the sticky position would sit past the last selected row, - // the selection no longer covers enough area to anchor the bar. - if y >= last_bottom { - return None; - } - - // Span the full content width in both modes so the buttons right-align - // against the editor edge, never pinned to a narrow column. - let (x, width) = if self.layout.split_mode { - let left = self.layout.left_text_rect.x; - let right = self.layout.right_text_rect.x + self.layout.right_text_rect.width; - (left, right - left) - } else { - ( - self.layout.unified_text_rect.x, - self.layout.unified_text_rect.width, - ) - }; - Some(Rect { - x, - y, - width, - height: bar_h, - }) - } - - fn rebuild_rows( - &mut self, - doc: &RenderDoc, - state: &EditorState, - text_metrics: TextMetrics, - show_file_headers: bool, - ) { - self.metrics = display_layout_metrics(text_metrics); - self.config = DisplayLayoutConfig { - split_mode: state.layout == LayoutMode::Split, - wrap_enabled: state.wrap_enabled, - wrap_column: state.wrap_column, - show_file_headers, - char_width_px: text_metrics.mono_char_width_px as f64, - unified_text_width_px: self.layout.unified_text_rect.width as f64, - split_text_width_px: self.layout.left_text_rect.width as f64, - }; - self.summary = - rebuild_display_rows(doc, self.config, self.metrics, &self.blocks, &mut self.rows); - build_strip_layouts(&self.rows, STRIP_TARGET_HEIGHT_PX, &mut self.strips); - self.file_header_hits.clear(); - if show_file_headers { - for row in &self.rows { - if row.kind != RenderRowKind::FileHeader as u8 { - continue; - } - let Some(line) = doc.lines.get(row.line_index as usize) else { - continue; - }; - if let Some(meta) = doc.file_meta(line) { - self.file_header_hits.push(FileHeaderHit { - y_px: row.y_px, - h_px: row.h_px, - path: meta.path.clone(), - }); - } - } - } - } - - fn rebuild_navigation_positions(&mut self, state: &mut EditorState) { - if !self.nav_positions_valid { - let mut hunk_positions = Vec::new(); - let mut file_positions = Vec::new(); - for row in &self.rows { - if row.kind == RenderRowKind::HunkSeparator as u8 { - hunk_positions.push(row.y_px); - } else if row.kind == RenderRowKind::FileHeader as u8 { - file_positions.push(row.y_px); - } - } - self.nav_hunk_positions = Arc::new(hunk_positions); - self.nav_file_positions = Arc::new(file_positions); - // Search Y positions are derived from row geometry too. - self.nav_search_matches = None; - self.nav_positions_valid = true; - } - state.hunk_positions = Arc::clone(&self.nav_hunk_positions); - state.file_positions = Arc::clone(&self.nav_file_positions); +use accesskit::Role; - if state.search.open && !state.search.matches.is_empty() { - let cached = self - .nav_search_matches - .as_ref() - .is_some_and(|matches| Arc::ptr_eq(matches, &state.search.matches)); - if !cached { - let mut y_positions = Vec::with_capacity(state.search.matches.len()); - for m in state.search.matches.iter() { - let y = self - .rows - .iter() - .find(|r| !r.is_block() && r.line_index == m.line_index) - .map(|r| r.y_px) - .unwrap_or(0); - y_positions.push(y); - } - self.nav_search_y_positions = Arc::new(y_positions); - self.nav_search_matches = Some(Arc::clone(&state.search.matches)); - } - state.search_match_y_positions = Arc::clone(&self.nav_search_y_positions); - } else { - self.nav_search_matches = None; - if !state.search_match_y_positions.is_empty() { - state.search_match_y_positions = Arc::default(); - } - } - } +use crate::core::text::SyntaxTokenKind; +use crate::editor::diff::decoration::{ + BlockPaintCtx, FileHeaderDecoration, RowDecoration, RowPaintCtx, decoration_for_kind, +}; +use crate::editor::diff::render_doc::{ + ByteRange, DisplayRow, FileHeaderMeta, INVALID_U32, RENDER_FLAG_STRUCTURAL, RenderDoc, + RenderLine, RenderRowKind, RunRange, STYLE_FLAG_CHANGE, STYLE_FLAG_UNCHANGED_CTX, StyleRun, +}; +use crate::editor::diff::state::{EditorState, ViewportTextSide}; +use crate::render::{ + FontKind, FontStyle, FontWeight, Rect, RectPrimitive, RichTextPrimitive, RichTextSpan, + RoundedRectPrimitive, Scene, TextPrimitive, +}; +use crate::ui::accessibility::{AccessibilityAction, AccessibilityFrame, AccessibilityNode}; +use crate::ui::design::{Alpha, Sz}; +use crate::ui::element::ScrollActionBuilder; +use crate::ui::state::FocusTarget; +use crate::ui::theme::{Color, Theme}; - fn update_visible_ranges(&mut self, state: &mut EditorState) { - let viewport_top_px = state.scroll_top_px; - let viewport_height_px = state.viewport_height_px.max(1); - let strip_range = visible_strip_range( - &self.strips, - viewport_top_px, - viewport_height_px, - STRIP_OVERSCAN, - ); - self.layout.visible_row_range = if strip_range.is_empty() { - VisibleRange::default() - } else { - let first = self.strips[strip_range.start].row_start; - let last = self.strips[strip_range.end - 1].row_end; - VisibleRange { - start: first, - end: last, - } - }; +use super::hit_test::{ + file_path_for_line, line_selection_contains_line, selection_byte_range_for_side, +}; +use super::layout::{editor_scale, scaled}; +use super::{CachedTextLayout, EditorDocument, EditorElement, GutterTextCacheKey, GutterTextKind}; - let visible_bottom_px = viewport_top_px.saturating_add(viewport_height_px); - let visible_start = self - .rows - .partition_point(|row| row.bottom_px() <= viewport_top_px); - let visible_end = self - .rows - .partition_point(|row| row.y_px < visible_bottom_px); - if visible_start < visible_end { - state.visible_row_start = Some(visible_start); - state.visible_row_end = Some(visible_end); - } else { - state.visible_row_start = None; - state.visible_row_end = None; - } - } +const STICKY_HEADER_Z: i32 = 10; +const INLINE_CHANGE_BG_MERGE_GAP_COLS: u32 = 2; +const INLINE_CHANGE_BG_Y_INSET_RATIO: f32 = 0.10; +impl EditorElement { pub fn paint( &mut self, scene: &mut Scene, @@ -1353,73 +405,6 @@ impl EditorElement { }); } - fn row_rect_for(&self, display_row: &DisplayRow) -> Rect { - Rect { - x: self.layout.content_bounds.x, - y: self.layout.content_bounds.y + display_row.y_px as f32 - self.layout.scroll_top_px, - width: self.layout.content_bounds.width, - height: display_row.h_px as f32, - } - } - - fn row_in_viewport(&self, row_rect: &Rect) -> bool { - row_rect.bottom() >= self.layout.content_bounds.y - && row_rect.y <= self.layout.content_bounds.bottom() - } - - fn review_add_comment_button_rect_for_row( - &self, - line: &RenderLine, - display_row: &DisplayRow, - ) -> Option { - let gutter = review_comment_gutter_rect(&self.layout, line)?; - let row_rect = self.row_rect_for(display_row); - if !self.row_in_viewport(&row_rect) { - return None; - } - let s = editor_scale(self.text_metrics); - let size = (self.layout.line_height * 0.72).clamp(scaled(14.0, s), scaled(20.0, s)); - // Straddle the gutter→code divider like GitHub: centered on the boundary, - // but never reaching left past the line number's right edge. - let x = (gutter.right() - size * 0.5).max(gutter.right() - self.layout.gutter_padding); - Some(Rect { - x, - y: row_rect.y + (self.layout.line_height - size).max(0.0) * 0.5, - width: size, - height: size, - }) - } - - fn review_add_button_overlay_for_row( - &self, - state: &EditorState, - doc: &RenderDoc, - row_index: usize, - clip: Rect, - ) -> Option { - let display_row = self.rows.get(row_index).copied()?; - if display_row.is_block() { - return None; - } - let line = doc.lines.get(display_row.line_index as usize)?; - let rect = self.review_add_comment_button_rect_for_row(line, &display_row)?; - let emphasised = state.review_add_hovered - || (!state.line_selection.is_empty() - && line_selection_contains_line( - &state.line_selection, - file_path_for_line(doc, display_row.line_index as usize), - line, - )); - ResolvedEditorOverlay::new( - EditorOverlayKind::ReviewAddButton { - line_index: display_row.line_index as usize, - emphasised, - }, - rect, - clip, - ) - } - fn paint_sticky_file_header( &mut self, scene: &mut Scene, @@ -1496,233 +481,6 @@ impl EditorElement { scene.pop_z_index(); } - pub fn file_header_path_at(&self, x: f32, y: f32) -> Option { - if let Some((rect, path)) = self.sticky_header_hit.as_ref() - && rect.contains(x, y) - { - return Some(path.clone()); - } - if !self.layout.content_bounds.contains(x, y) { - return None; - } - let content_y = (y - self.layout.content_bounds.y).max(0.0) + self.layout.scroll_top_px; - for hit in &self.file_header_hits { - let top = hit.y_px as f32; - let bottom = top + hit.h_px as f32; - if content_y >= top && content_y < bottom { - return Some(hit.path.clone()); - } - } - None - } - - pub fn hit_test_text_point( - &self, - state: &EditorState, - doc: &RenderDoc, - x: f32, - y: f32, - ) -> Option { - let row_index = self.hit_test_row(state, x, y)?; - let display_row = self.rows.get(row_index).copied()?; - if display_row.is_block() { - return None; - } - let line = doc.lines.get(display_row.line_index as usize)?; - if !line.row_kind().is_body() { - return None; - } - let row_rect = self.row_rect_for(&display_row); - let blocks = self.text_blocks_for_line(line, &display_row, row_rect); - blocks - .into_iter() - .flatten() - .filter(|block| { - let bottom = block.y + block.segment_count.max(1) as f32 * self.layout.line_height; - y >= block.y && y < bottom - }) - .min_by(|a, b| { - distance_to_text_block_x(*a, x) - .partial_cmp(&distance_to_text_block_x(*b, x)) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .map(|block| { - let text = doc.line_text(block.text_range); - let layout = CachedTextLayout::new(text); - let segment = ((y - block.y) / self.layout.line_height.max(1.0)) - .floor() - .max(0.0) as u32; - let segment = segment.min(u32::from(block.segment_count.max(1).saturating_sub(1))); - let local_col = - ((x - block.text_x) / self.text_metrics.mono_char_width_px.max(1.0)).max(0.0); - let col = local_col + segment.saturating_mul(u32::from(block.segment_cols)) as f32; - ViewportTextPoint { - line_index: block.line_index, - side: block.side, - byte_offset: layout.byte_for_col_nearest(col), - } - }) - } - - pub fn viewport_selection_text( - &self, - doc: &RenderDoc, - selection: &ViewportTextSelection, - ) -> Option { - if selection.is_collapsed() { - return None; - } - let mut copied = String::new(); - let (start, end) = selection.normalized(); - for line_index in start.line_index..=end.line_index { - let Some(line) = doc.lines.get(line_index as usize) else { - continue; - }; - for (side, range) in text_side_ranges_for_line(self.layout.split_mode, line) - .into_iter() - .flatten() - { - let text = doc.line_text(range); - let Some((byte_start, byte_end)) = selection_byte_range_for_side( - selection, - self.layout.split_mode, - line_index, - side, - text, - ) else { - continue; - }; - if byte_end <= byte_start { - continue; - } - if !copied.is_empty() { - copied.push('\n'); - } - copied.push_str(&text[byte_start..byte_end]); - } - } - (!copied.is_empty()).then_some(copied) - } - - pub fn viewport_line_text_at_point( - &self, - doc: &RenderDoc, - point: ViewportTextPoint, - ) -> Option { - let line = doc.lines.get(point.line_index as usize)?; - let range = match point.side { - ViewportTextSide::Left => line.left_text, - ViewportTextSide::Right => line.right_text, - }; - range.is_valid().then(|| doc.line_text(range).to_owned()) - } - - fn text_blocks_for_line( - &self, - line: &RenderLine, - display_row: &DisplayRow, - row_rect: Rect, - ) -> [Option; 2] { - let mut blocks = [None, None]; - let mut next = 0_usize; - let mut push_block = |block: TextBlock| { - if next < blocks.len() { - blocks[next] = Some(block); - next += 1; - } - }; - - let line_height = self.layout.line_height; - if self.layout.split_mode { - let segment_cols = self.render_cols_split(); - if line.left_text.is_valid() { - push_block(TextBlock { - line_index: display_row.line_index, - side: ViewportTextSide::Left, - text_range: line.left_text, - text_x: self.layout.left_text_rect.x, - text_width: self.layout.left_text_rect.width, - y: row_rect.y, - segment_count: if self.config.wrap_enabled { - display_row.wrap_left.max(1) - } else { - 1 - }, - segment_cols, - }); - } - if line.right_text.is_valid() { - push_block(TextBlock { - line_index: display_row.line_index, - side: ViewportTextSide::Right, - text_range: line.right_text, - text_x: self.layout.right_text_rect.x, - text_width: self.layout.right_text_rect.width, - y: row_rect.y, - segment_count: if self.config.wrap_enabled { - display_row.wrap_right.max(1) - } else { - 1 - }, - segment_cols, - }); - } - return blocks; - } - - let segment_cols = self.render_cols_unified(); - if line.row_kind() == RenderRowKind::Modified - && line.left_text.is_valid() - && line.right_text.is_valid() - { - let left_segments = if self.config.wrap_enabled { - display_row.wrap_left.max(1) - } else { - 1 - }; - push_block(TextBlock { - line_index: display_row.line_index, - side: ViewportTextSide::Left, - text_range: line.left_text, - text_x: self.layout.unified_text_rect.x, - text_width: self.layout.unified_text_rect.width, - y: row_rect.y, - segment_count: left_segments, - segment_cols, - }); - push_block(TextBlock { - line_index: display_row.line_index, - side: ViewportTextSide::Right, - text_range: line.right_text, - text_x: self.layout.unified_text_rect.x, - text_width: self.layout.unified_text_rect.width, - y: row_rect.y + left_segments as f32 * line_height, - segment_count: if self.config.wrap_enabled { - display_row.wrap_right.max(1) - } else { - 1 - }, - segment_cols, - }); - } else if let Some((side, text_range, _, _)) = unified_body_side_with_side(line) { - push_block(TextBlock { - line_index: display_row.line_index, - side, - text_range, - text_x: self.layout.unified_text_rect.x, - text_width: self.layout.unified_text_rect.width, - y: row_rect.y, - segment_count: if self.config.wrap_enabled { - display_row.wrap_left.max(1) - } else { - 1 - }, - segment_cols, - }); - } - blocks - } - fn paint_gutter_backgrounds(&self, scene: &mut Scene, theme: &Theme) { if self.layout.split_mode { scene.rect(RectPrimitive { @@ -2119,8 +877,6 @@ impl EditorElement { state: &EditorState, doc: &RenderDoc, ) { - use super::render_doc::RenderRowKind; - if state.line_selection.is_empty() { return; } @@ -2964,332 +1720,33 @@ impl EditorElement { let rect = Rect { x: self.layout.unified_text_rect.x, y, - width: self.layout.unified_text_rect.width, - height: line_height, - }; - if let Some(spans) = self.cached_wrapped_rich_text( - doc, - line.right_text, - line.right_runs, - seg, - render_cols, - tone_for_right_side(line), - theme, - ) { - scene.rich_text(RichTextPrimitive { - rect, - spans, - default_color: tone_for_right_side(line).default_text(theme), - font_size, - font_kind: FontKind::Mono, - font_weight: FontWeight::Normal, - }); - } - } - } - - fn clear_document_caches(&mut self) { - self.wrapped_text_cache.clear(); - self.text_layout_cache.clear(); - self.gutter_text_cache.clear(); - // Rows changed (or went away) — navigation positions must be - // recomputed from the new row geometry. - self.nav_positions_valid = false; - self.nav_search_matches = None; - } - - fn sync_theme_cache(&mut self, theme: &Theme) { - let key = EditorThemeKey { - text_strong: theme.colors.text_strong, - text_muted: theme.colors.text_muted, - accent: theme.colors.accent, - line_add_text: theme.colors.line_add_text, - line_del_text: theme.colors.line_del_text, - }; - if self.theme_cache_key != Some(key) { - self.wrapped_text_cache.clear(); - self.theme_cache_key = Some(key); - } - } - - fn cached_wrapped_rich_text( - &mut self, - doc: &RenderDoc, - text_range: ByteRange, - runs: RunRange, - segment_index: u16, - wrap_cols: u16, - tone: RowTone, - theme: &Theme, - ) -> Option> { - if !text_range.is_valid() { - return None; - } - let key = WrappedTextCacheKey { - text_start: text_range.start, - text_len: text_range.len, - runs_start: runs.start, - runs_len: runs.len, - segment_index, - wrap_cols, - tone, - }; - if let Some(cached) = self.wrapped_text_cache.get(&key) { - return Some(cached.clone()); - } - - let text_layout = self.cached_text_layout(doc, text_range); - let spans = build_wrapped_rich_text( - doc, - text_layout.as_ref(), - text_range, - runs, - segment_index, - wrap_cols, - tone, - theme, - )?; - self.wrapped_text_cache.insert(key, spans.clone()); - Some(spans) - } - - fn cached_text_layout( - &mut self, - doc: &RenderDoc, - text_range: ByteRange, - ) -> Arc { - let key = TextLayoutCacheKey { - text_start: text_range.start, - text_len: text_range.len, - }; - if let Some(cached) = self.text_layout_cache.get(&key) { - return cached.clone(); - } - - let layout = Arc::new(CachedTextLayout::new(doc.line_text(text_range))); - self.text_layout_cache.insert(key, layout.clone()); - layout - } - - fn cached_gutter_text(&mut self, key: GutterTextCacheKey) -> Arc { - if let Some(cached) = self.gutter_text_cache.get(&key) { - return cached.clone(); - } - - let spaces = " ".repeat(key.digits as usize); - let text: Arc = match key.kind { - GutterTextKind::SplitLeft => format_line_number_string(key.old_line_no, key.digits), - GutterTextKind::SplitRight => format_line_number_string(key.new_line_no, key.digits), - GutterTextKind::Unified => format!( - "{} {}", - format_line_number_string(key.old_line_no, key.digits), - format_line_number_string(key.new_line_no, key.digits) - ), - GutterTextKind::UnifiedOldOnly => format!( - "{} {}", - format_line_number_string(key.old_line_no, key.digits), - spaces - ), - GutterTextKind::UnifiedNewOnly => format!( - "{} {}", - spaces, - format_line_number_string(key.new_line_no, key.digits) - ), - } - .into(); - self.gutter_text_cache.insert(key, text.clone()); - text - } - - fn render_cols_unified(&self) -> u16 { - render_cols_for_width( - self.config.wrap_enabled, - self.config.wrap_column, - self.config.char_width_px as f32, - self.layout.unified_text_rect.width, - ) - } - - fn render_cols_split(&self) -> u16 { - render_cols_for_width( - self.config.wrap_enabled, - self.config.wrap_column, - self.config.char_width_px as f32, - self.layout.left_text_rect.width, - ) - } - - fn visible_segment_range(&self, block_y: f32, segment_count: u16) -> Range { - visible_segment_range_for_block( - block_y, - segment_count.max(1), - self.layout.line_height, - self.layout.content_bounds.y, - self.layout.content_bounds.bottom(), - ) - } -} - -fn line_selection_contains_line( - selection: &super::state::LineSelection, - file_path: Option<&str>, - line: &RenderLine, -) -> bool { - let Ok(hunk_id) = u32::try_from(line.hunk_index) else { - return false; - }; - (line.old_line_index >= 0 - && selection.contains_in_file( - file_path, - hunk_id, - carbon::DiffSide::Old, - line.old_line_index as u32, - )) - || (line.new_line_index >= 0 - && selection.contains_in_file( - file_path, - hunk_id, - carbon::DiffSide::New, - line.new_line_index as u32, - )) -} - -fn file_path_for_line(doc: &RenderDoc, line_index: usize) -> Option<&str> { - doc.lines.get(..=line_index)?.iter().rev().find_map(|line| { - if line.row_kind() == RenderRowKind::FileHeader { - doc.file_meta(line).map(|meta| meta.path.as_str()) - } else { - None - } - }) -} - -fn review_comment_gutter_rect(layout: &EditorLayout, line: &RenderLine) -> Option { - // Any body line is commentable (incl. context) — selected_review_range maps it - // to the new side, or the old side for removed-only lines. - if line.hunk_index < 0 || !line.row_kind().is_body() { - return None; - } - match line.row_kind() { - RenderRowKind::Removed if layout.split_mode => Some(layout.left_gutter_rect), - _ if layout.split_mode => Some(layout.right_gutter_rect), - _ => Some(layout.unified_gutter_rect), - } -} - -fn distance_to_text_block_x(block: TextBlock, x: f32) -> f32 { - if x < block.text_x { - block.text_x - x - } else if x > block.text_x + block.text_width { - x - (block.text_x + block.text_width) - } else { - 0.0 - } -} - -fn text_side_ranges_for_line( - split_mode: bool, - line: &RenderLine, -) -> [Option<(ViewportTextSide, ByteRange)>; 2] { - let mut ranges = [None, None]; - if !line.row_kind().is_body() { - return ranges; - } - let mut next = 0_usize; - let mut push = |side, range: ByteRange| { - if range.is_valid() && next < ranges.len() { - ranges[next] = Some((side, range)); - next += 1; + width: self.layout.unified_text_rect.width, + height: line_height, + }; + if let Some(spans) = self.cached_wrapped_rich_text( + doc, + line.right_text, + line.right_runs, + seg, + render_cols, + tone_for_right_side(line), + theme, + ) { + scene.rich_text(RichTextPrimitive { + rect, + spans, + default_color: tone_for_right_side(line).default_text(theme), + font_size, + font_kind: FontKind::Mono, + font_weight: FontWeight::Normal, + }); + } } - }; - - if split_mode { - push(ViewportTextSide::Left, line.left_text); - push(ViewportTextSide::Right, line.right_text); - } else if line.row_kind() == RenderRowKind::Modified - && line.left_text.is_valid() - && line.right_text.is_valid() - { - push(ViewportTextSide::Left, line.left_text); - push(ViewportTextSide::Right, line.right_text); - } else if let Some((side, range, _, _)) = unified_body_side_with_side(line) { - push(side, range); - } - ranges -} - -fn selection_byte_range_for_side( - selection: &ViewportTextSelection, - split_mode: bool, - line_index: u32, - side: ViewportTextSide, - text: &str, -) -> Option<(usize, usize)> { - let (start, end) = selection_bounds_for_side(selection, split_mode, side)?; - let text_len = text.len(); - let text_len_u32 = text_len.min(u32::MAX as usize) as u32; - let side_start = ViewportTextPoint { - line_index, - side, - byte_offset: 0, - }; - let side_end = ViewportTextPoint { - line_index, - side, - byte_offset: text_len_u32, - }; - if side_end <= start || side_start >= end { - return None; - } - - let byte_start = if start.line_index == line_index && start.side == side { - start.byte_offset.min(text_len_u32) - } else { - 0 - }; - let byte_end = if end.line_index == line_index && end.side == side { - end.byte_offset.min(text_len_u32) - } else { - text_len_u32 - }; - let byte_start = previous_char_boundary(text, byte_start as usize); - let byte_end = previous_char_boundary(text, byte_end as usize); - (byte_end > byte_start).then_some((byte_start, byte_end)) -} - -fn selection_bounds_for_side( - selection: &ViewportTextSelection, - split_mode: bool, - side: ViewportTextSide, -) -> Option<(ViewportTextPoint, ViewportTextPoint)> { - if !split_mode { - return Some(selection.normalized()); - } - if selection.anchor.side != side { - return None; - } - let anchor = selection.anchor; - let focus = ViewportTextPoint { - side, - ..selection.focus - }; - Some(if anchor <= focus { - (anchor, focus) - } else { - (focus, anchor) - }) -} - -fn previous_char_boundary(text: &str, byte: usize) -> usize { - let mut byte = byte.min(text.len()); - while byte > 0 && !text.is_char_boundary(byte) { - byte -= 1; } - byte } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum RowTone { +pub(super) enum RowTone { Neutral, Added, Removed, @@ -3309,145 +1766,6 @@ impl RowTone { } } -fn compute_scrollbar_layout( - layout: &EditorLayout, - state: &EditorState, - override_metrics: Option, -) -> Option { - if state.viewport_height_px == 0 { - return None; - } - let (content_height_px, scroll_top_px, max_scroll_top_px) = match override_metrics { - Some(o) => (o.total_height_px, o.scroll_top_px, o.max_scroll_top_px), - None => ( - state.content_height_px, - state.scroll_top_px, - state.max_scroll_top_px(), - ), - }; - if content_height_px <= state.viewport_height_px { - return None; - } - let s = layout.font_size / BASE_MONO_FONT_SIZE; - let sb_width = scaled(BASE_SCROLLBAR_WIDTH, s); - let sb_margin = scaled(BASE_SCROLLBAR_MARGIN, s); - let cb = layout.content_bounds; - let track = Rect { - x: cb.right() - sb_width, - y: cb.y + sb_margin, - width: sb_width, - height: (cb.height - sb_margin * 2.0).max(0.0), - }; - let ratio = state.viewport_height_px as f32 / content_height_px as f32; - let thumb_min = scaled(BASE_SCROLLBAR_THUMB_MIN, s); - let thumb_height = (track.height * ratio).max(thumb_min).min(track.height); - let scroll_range = max_scroll_top_px.max(1) as f32; - let top_ratio = (scroll_top_px as f32 / scroll_range).clamp(0.0, 1.0); - let thumb_y = track.y + (track.height - thumb_height) * top_ratio; - Some(ScrollbarLayout { - track, - thumb_top: thumb_y, - thumb_height, - thumb: Rect { - x: track.x + 1.0, - y: thumb_y + 1.0, - width: track.width - 2.0, - height: thumb_height - 2.0, - }, - }) -} - -fn build_spatial_layout( - bounds: Rect, - layout: LayoutMode, - gutter_digits: u32, - text_metrics: TextMetrics, -) -> EditorLayout { - let s = editor_scale(text_metrics); - let column_gap = scaled(BASE_COLUMN_GAP, s); - let gutter_padding = scaled(BASE_GUTTER_PADDING, s); - let scrollbar_width = scaled(BASE_SCROLLBAR_WIDTH, s); - let scrollbar_margin = scaled(BASE_SCROLLBAR_MARGIN, s); - - let content_bounds = content_bounds_for_viewport(bounds, text_metrics); - let usable_width = (content_bounds.width - scrollbar_width - scrollbar_margin).max(0.0); - let gutter_width = - gutter_digits as f32 * text_metrics.mono_char_width_px + gutter_padding * 2.0; - let unified_gutter_width = gutter_digits as f32 * text_metrics.mono_char_width_px * 2.0 - + text_metrics.mono_char_width_px - + gutter_padding * 2.0; - - if layout == LayoutMode::Split { - let col_width = ((usable_width - gutter_width * 2.0 - column_gap) / 2.0).max(60.0); - let left_gutter_rect = Rect { - x: content_bounds.x, - y: content_bounds.y, - width: gutter_width, - height: content_bounds.height, - }; - let text_left_pad = gutter_padding; - let left_text_rect = Rect { - x: left_gutter_rect.right() + text_left_pad, - y: content_bounds.y, - width: (col_width - text_left_pad).max(60.0), - height: content_bounds.height, - }; - let right_gutter_rect = Rect { - x: left_gutter_rect.right() + col_width + column_gap, - y: content_bounds.y, - width: gutter_width, - height: content_bounds.height, - }; - let right_text_rect = Rect { - x: right_gutter_rect.right() + text_left_pad, - y: content_bounds.y, - width: (content_bounds.right() - - scrollbar_width - - scrollbar_margin - - right_gutter_rect.right() - - text_left_pad) - .max(60.0), - height: content_bounds.height, - }; - EditorLayout { - outer_bounds: bounds, - content_bounds, - split_mode: true, - gutter_digits, - unified_gutter_rect: Rect::default(), - unified_text_rect: Rect::default(), - left_gutter_rect, - left_text_rect, - right_gutter_rect, - right_text_rect, - ..EditorLayout::default() - } - } else { - let unified_gutter_rect = Rect { - x: content_bounds.x, - y: content_bounds.y, - width: unified_gutter_width, - height: content_bounds.height, - }; - let text_left_pad = gutter_padding; - let unified_text_rect = Rect { - x: unified_gutter_rect.right() + text_left_pad, - y: content_bounds.y, - width: (usable_width - unified_gutter_width - text_left_pad).max(60.0), - height: content_bounds.height, - }; - EditorLayout { - outer_bounds: bounds, - content_bounds, - split_mode: false, - gutter_digits, - unified_gutter_rect, - unified_text_rect, - ..EditorLayout::default() - } - } -} - fn dim_bg(c: Color) -> Color { Color { a: ((c.a as u16 * 200) / 255) as u8, @@ -3475,15 +1793,7 @@ fn paint_row_background(scene: &mut Scene, theme: &Theme, row_rect: Rect, line: }); } -fn format_line_number_string(line_no: u32, digits: u32) -> String { - if line_no == INVALID_U32 { - " ".repeat(digits as usize) - } else { - format!("{line_no:>width$}", width = digits as usize) - } -} - -fn unified_body_side_with_side( +pub(super) fn unified_body_side_with_side( line: &RenderLine, ) -> Option<(ViewportTextSide, ByteRange, RunRange, RowTone)> { let structural = line.flags & RENDER_FLAG_STRUCTURAL != 0; @@ -3686,60 +1996,6 @@ fn accessibility_side_key(side: ViewportTextSide) -> &'static str { } } -fn wrap_cols_for_width( - wrap_enabled: bool, - wrap_column: u32, - char_width_px: f32, - width_px: f32, -) -> u16 { - if !wrap_enabled { - return u16::MAX; - } - let width_cols = (width_px / char_width_px.max(1.0)).floor() as u32; - let cols = if wrap_column > 0 { - width_cols.min(wrap_column) - } else { - width_cols - }; - cols.max(1).min(u16::MAX as u32) as u16 -} - -fn render_cols_for_width( - wrap_enabled: bool, - wrap_column: u32, - char_width_px: f32, - width_px: f32, -) -> u16 { - if wrap_enabled { - return wrap_cols_for_width(true, wrap_column, char_width_px, width_px); - } - - let visible_cols = (width_px / char_width_px.max(1.0)).ceil() as u32; - visible_cols - .saturating_add(u32::from(UNWRAPPED_RENDER_OVERSCAN_COLS)) - .max(1) - .min(u16::MAX as u32) as u16 -} - -fn visible_segment_range_for_block( - block_y: f32, - segment_count: u16, - line_height: f32, - viewport_top: f32, - viewport_bottom: f32, -) -> Range { - if segment_count == 0 || line_height <= 0.0 { - return 0..0; - } - - let max_segments = u32::from(segment_count); - let start = ((viewport_top - block_y) / line_height).floor().max(0.0) as u32; - let end = ((viewport_bottom - block_y) / line_height).ceil().max(0.0) as u32; - let start = start.min(max_segments); - let end = end.max(start).min(max_segments); - start as u16..end as u16 -} - fn paint_column_range_rects_with_vertical_inset( scene: &mut Scene, col_start: u32, @@ -3850,7 +2106,7 @@ fn paint_column_range_rects( } } -fn build_wrapped_rich_text( +pub(super) fn build_wrapped_rich_text( doc: &RenderDoc, text_layout: &CachedTextLayout, text_range: ByteRange, @@ -3901,7 +2157,7 @@ fn wrapped_col_slice( } #[cfg(test)] -fn wrapped_byte_slice( +pub(super) fn wrapped_byte_slice( text_layout: &CachedTextLayout, wrap_cols: u16, segment_index: u16, @@ -4086,734 +2342,3 @@ fn syntax_kind_from_style_id(style_id: u16) -> SyntaxTokenKind { _ => SyntaxTokenKind::Normal, } } - -#[cfg(test)] -mod tests { - use super::{ - CachedTextLayout, EditorDocument, EditorElement, build_wrapped_rich_text, - editor_bottom_padding_px, render_cols_for_width, visible_segment_range_for_block, - wrapped_byte_slice, - }; - use crate::core::compare::LayoutMode; - use crate::editor::diff::render_doc::{ - ByteRange, RenderDoc, RenderLine, RenderRowKind, RunRange, - }; - use crate::editor::diff::state::{ - EditorState, ViewportTextPoint, ViewportTextSelection, ViewportTextSide, - }; - use crate::render::{FontStyle, FontWeight, Rect, TextMetrics}; - use crate::ui::theme::Theme; - - #[test] - fn wrapped_byte_slice_breaks_monospaced_text_by_columns() { - let layout = CachedTextLayout::new("abcdefghij"); - assert_eq!(wrapped_byte_slice(&layout, 4, 0), Some((0, 4))); - assert_eq!(wrapped_byte_slice(&layout, 4, 1), Some((4, 8))); - assert_eq!(wrapped_byte_slice(&layout, 4, 2), Some((8, 10))); - assert_eq!(wrapped_byte_slice(&layout, 4, 3), None); - } - - #[test] - fn cached_text_layout_tracks_visual_columns_for_tabs() { - let layout = CachedTextLayout::new("\ta\t"); - assert_eq!(layout.total_cols(), 16); - assert_eq!(layout.col_for_byte(0), 0); - assert_eq!(layout.col_for_byte(1), 8); - assert_eq!(layout.col_for_byte(2), 9); - assert_eq!(layout.col_for_byte(3), 16); - } - - #[test] - fn rich_text_builder_returns_spans_for_requested_segment() { - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"keyword // comment".to_vec(), - style_runs: vec![ - crate::editor::diff::render_doc::StyleRun { - byte_start: 0, - byte_len: 7, - style_id: 1, - flags: 0, - }, - crate::editor::diff::render_doc::StyleRun { - byte_start: 7, - byte_len: 1, - style_id: 0, - flags: 0, - }, - crate::editor::diff::render_doc::StyleRun { - byte_start: 8, - byte_len: 10, - style_id: 3, - flags: 0, - }, - ], - lines: vec![RenderLine { - kind: RenderRowKind::Context as u8, - right_text: ByteRange { start: 0, len: 18 }, - right_runs: RunRange { start: 0, len: 3 }, - right_cols: 18, - ..RenderLine::default() - }], - }; - - let text_layout = CachedTextLayout::new("keyword // comment"); - let spans = build_wrapped_rich_text( - &doc, - &text_layout, - doc.lines[0].right_text, - doc.lines[0].right_runs, - 0, - u16::MAX, - super::RowTone::Neutral, - &Theme::default_dark(), - ) - .expect("spans"); - - assert!(!spans.is_empty()); - assert_eq!( - spans - .iter() - .map(|span| span.text.as_ref()) - .collect::(), - "keyword // comment" - ); - assert_eq!(spans[0].text.as_ref(), "keyword"); - assert_eq!(spans[0].font_weight, Some(FontWeight::Semibold)); - let comment = spans - .iter() - .find(|span| span.text.as_ref() == "// comment") - .expect("comment span"); - assert_eq!(comment.font_style, Some(FontStyle::Italic)); - } - - #[test] - fn rich_text_builder_expands_tabs_across_wrapped_segments() { - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"\tabc".to_vec(), - style_runs: vec![crate::editor::diff::render_doc::StyleRun { - byte_start: 0, - byte_len: 4, - style_id: 0, - flags: 0, - }], - lines: vec![RenderLine { - kind: RenderRowKind::Context as u8, - right_text: ByteRange { start: 0, len: 4 }, - right_runs: RunRange { start: 0, len: 1 }, - right_cols: 11, - ..RenderLine::default() - }], - }; - - let text_layout = CachedTextLayout::new("\tabc"); - let theme = Theme::default_dark(); - - let seg0 = build_wrapped_rich_text( - &doc, - &text_layout, - doc.lines[0].right_text, - doc.lines[0].right_runs, - 0, - 4, - super::RowTone::Neutral, - &theme, - ) - .expect("segment 0"); - let seg1 = build_wrapped_rich_text( - &doc, - &text_layout, - doc.lines[0].right_text, - doc.lines[0].right_runs, - 1, - 4, - super::RowTone::Neutral, - &theme, - ) - .expect("segment 1"); - let seg2 = build_wrapped_rich_text( - &doc, - &text_layout, - doc.lines[0].right_text, - doc.lines[0].right_runs, - 2, - 4, - super::RowTone::Neutral, - &theme, - ) - .expect("segment 2"); - - assert_eq!( - seg0.iter() - .map(|span| span.text.as_ref()) - .collect::(), - " " - ); - assert_eq!( - seg1.iter() - .map(|span| span.text.as_ref()) - .collect::(), - " " - ); - assert_eq!( - seg2.iter() - .map(|span| span.text.as_ref()) - .collect::(), - "abc" - ); - } - - #[test] - fn render_cols_cap_unwrapped_rows_to_viewport_budget() { - assert_eq!(render_cols_for_width(false, 0, 8.0, 80.0), 26); - assert_eq!(render_cols_for_width(true, 0, 8.0, 80.0), 10); - } - - #[test] - fn visible_segment_range_limits_wrapped_blocks_to_viewport() { - assert_eq!( - visible_segment_range_for_block(100.0, 10, 20.0, 120.0, 170.0), - 1..4 - ); - assert_eq!( - visible_segment_range_for_block(100.0, 10, 20.0, 0.0, 50.0), - 0..0 - ); - } - - #[test] - fn prepare_populates_visible_range_and_hit_testing() { - let mut state = EditorState { - layout: LayoutMode::Unified, - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"demo.txt@@ -1 +1 @@line".to_vec(), - style_runs: Vec::new(), - lines: vec![ - RenderLine { - kind: RenderRowKind::FileHeader as u8, - left_text: ByteRange { start: 0, len: 8 }, - left_cols: 8, - ..RenderLine::default() - }, - RenderLine { - kind: RenderRowKind::HunkSeparator as u8, - left_text: ByteRange { start: 8, len: 11 }, - left_cols: 11, - ..RenderLine::default() - }, - RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 1, - new_line_no: 1, - right_text: ByteRange { start: 19, len: 4 }, - right_cols: 4, - ..RenderLine::default() - }, - ], - }; - - let mut runtime = EditorElement::default(); - runtime.prepare( - &mut state, - EditorDocument::Text { - compare_generation: 1, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }, - Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }, - TextMetrics::default(), - ); - - assert_eq!(state.visible_row_start, Some(0)); - // FileHeader lines are skipped in layout, so only 2 display rows exist. - assert!(state.visible_row_end.expect("visible end") >= 2); - let body = runtime.body_bounds(); - assert_eq!( - runtime.hit_test_row(&state, body.x + 20.0, body.y + 5.0), - Some(0) - ); - } - - #[test] - fn prepare_adds_bottom_padding_to_keep_last_row_above_viewport_clip() { - let mut state = EditorState { - layout: LayoutMode::Unified, - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"last".to_vec(), - style_runs: Vec::new(), - lines: vec![RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 1, - new_line_no: 1, - right_text: ByteRange { start: 0, len: 4 }, - right_cols: 4, - ..RenderLine::default() - }], - }; - let mut runtime = EditorElement::default(); - runtime.prepare( - &mut state, - EditorDocument::Text { - compare_generation: 1, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }, - Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 16.0, - }, - TextMetrics::default(), - ); - - let bottom_padding = editor_bottom_padding_px(runtime.metrics); - assert_eq!( - state.content_height_px, - runtime.summary.content_height_px + bottom_padding - ); - let unpadded_max = runtime - .summary - .content_height_px - .saturating_sub(state.viewport_height_px.max(1)); - assert_eq!(state.max_scroll_top_px(), unpadded_max + bottom_padding); - } - - #[test] - fn preprepare_content_height_matches_prepared_viewport_height() { - let mut state = EditorState { - layout: LayoutMode::Unified, - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"last".to_vec(), - style_runs: Vec::new(), - lines: vec![RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 1, - new_line_no: 1, - right_text: ByteRange { start: 0, len: 4 }, - right_cols: 4, - ..RenderLine::default() - }], - }; - let mut runtime = EditorElement::default(); - let bounds = Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }; - let text_metrics = TextMetrics::default(); - let expected_height = runtime - .content_height_for_bounds(bounds, text_metrics) - .max(0.0) - .round() as u32; - - runtime.prepare( - &mut state, - EditorDocument::Text { - compare_generation: 1, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }, - bounds, - text_metrics, - ); - - assert_eq!(state.viewport_height_px, expected_height); - } - - #[test] - fn hit_test_text_point_maps_viewport_columns_to_line_bytes() { - let mut state = EditorState { - layout: LayoutMode::Unified, - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"hello".to_vec(), - style_runs: Vec::new(), - lines: vec![RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 1, - new_line_no: 1, - right_text: ByteRange { start: 0, len: 5 }, - right_cols: 5, - ..RenderLine::default() - }], - }; - let mut runtime = EditorElement::default(); - runtime.prepare( - &mut state, - EditorDocument::Text { - compare_generation: 1, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }, - Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }, - TextMetrics::default(), - ); - - let x = - runtime.layout.unified_text_rect.x + TextMetrics::default().mono_char_width_px * 3.1; - let y = runtime.body_bounds().y + runtime.layout.line_height * 0.5; - let point = runtime - .hit_test_text_point(&state, &doc, x, y) - .expect("text point"); - - assert_eq!( - point, - ViewportTextPoint { - line_index: 0, - side: ViewportTextSide::Right, - byte_offset: 3, - } - ); - } - - #[test] - fn viewport_selection_text_copies_visible_line_segments() { - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"alphaBRAVO".to_vec(), - style_runs: Vec::new(), - lines: vec![ - RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 1, - new_line_no: 1, - right_text: ByteRange { start: 0, len: 5 }, - right_cols: 5, - ..RenderLine::default() - }, - RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 2, - new_line_no: 2, - right_text: ByteRange { start: 5, len: 5 }, - right_cols: 5, - ..RenderLine::default() - }, - ], - }; - let selection = ViewportTextSelection { - generation: 7, - anchor: ViewportTextPoint { - line_index: 0, - side: ViewportTextSide::Right, - byte_offset: 1, - }, - focus: ViewportTextPoint { - line_index: 1, - side: ViewportTextSide::Right, - byte_offset: 3, - }, - }; - let runtime = EditorElement::default(); - - assert_eq!( - runtime.viewport_selection_text(&doc, &selection).as_deref(), - Some("lpha\nBRA") - ); - } - - #[test] - fn split_viewport_selection_text_stays_on_selected_side() { - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"old-aNEW-Aold-bNEW-B".to_vec(), - style_runs: Vec::new(), - lines: vec![ - RenderLine { - kind: RenderRowKind::Modified as u8, - old_line_no: 1, - new_line_no: 1, - left_text: ByteRange { start: 0, len: 5 }, - right_text: ByteRange { start: 5, len: 5 }, - left_cols: 5, - right_cols: 5, - ..RenderLine::default() - }, - RenderLine { - kind: RenderRowKind::Modified as u8, - old_line_no: 2, - new_line_no: 2, - left_text: ByteRange { start: 10, len: 5 }, - right_text: ByteRange { start: 15, len: 5 }, - left_cols: 5, - right_cols: 5, - ..RenderLine::default() - }, - ], - }; - let selection = ViewportTextSelection { - generation: 7, - anchor: ViewportTextPoint { - line_index: 0, - side: ViewportTextSide::Left, - byte_offset: 1, - }, - focus: ViewportTextPoint { - line_index: 1, - side: ViewportTextSide::Left, - byte_offset: 4, - }, - }; - let mut runtime = EditorElement::default(); - runtime.layout.split_mode = true; - - assert_eq!( - runtime.viewport_selection_text(&doc, &selection).as_deref(), - Some("ld-a\nold-") - ); - } - - #[test] - fn viewport_text_selection_paints_square_rectangles() { - use crate::render::{Primitive, Scene}; - - let mut state = EditorState { - layout: LayoutMode::Unified, - text_selection: Some(ViewportTextSelection { - generation: 1, - anchor: ViewportTextPoint { - line_index: 0, - side: ViewportTextSide::Right, - byte_offset: 1, - }, - focus: ViewportTextPoint { - line_index: 1, - side: ViewportTextSide::Right, - byte_offset: 4, - }, - }), - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"alphabravo".to_vec(), - style_runs: Vec::new(), - lines: vec![ - RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 1, - new_line_no: 1, - right_text: ByteRange { start: 0, len: 5 }, - right_cols: 5, - ..RenderLine::default() - }, - RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 2, - new_line_no: 2, - right_text: ByteRange { start: 5, len: 5 }, - right_cols: 5, - ..RenderLine::default() - }, - ], - }; - let mut runtime = EditorElement::default(); - let document = EditorDocument::Text { - compare_generation: 1, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }; - runtime.prepare( - &mut state, - document, - Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }, - TextMetrics::default(), - ); - - let theme = Theme::default_dark(); - let selection_bg = theme.colors.selection_bg; - let mut scene = Scene::default(); - runtime.paint(&mut scene, &theme, &state, document); - - assert!( - scene - .primitives - .iter() - .any(|p| matches!(p, Primitive::Rect(r) if r.color == selection_bg)) - ); - assert!( - !scene - .primitives - .iter() - .any(|p| matches!(p, Primitive::RoundedRect(r) if r.color == selection_bg)) - ); - } - - #[test] - fn block_paint_emits_primitive_for_registered_decoration() { - use super::super::decoration::{BlockDecoration, BlockPaintCtx, BlockPlacement}; - use crate::render::{Primitive, RectPrimitive, Scene}; - use crate::ui::theme::Color; - - #[derive(Debug)] - struct StubBlock { - color: Color, - } - - impl BlockDecoration for StubBlock { - fn height(&self, _metrics: &super::super::display_layout::DisplayLayoutMetrics) -> u16 { - 20 - } - - fn paint(&self, ctx: &mut BlockPaintCtx) { - let _ = ctx.hovered; - ctx.scene.rect(RectPrimitive { - rect: ctx.row_rect, - color: self.color, - }); - } - } - - let mut state = EditorState { - layout: LayoutMode::Unified, - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"@@ hdr @@".to_vec(), - style_runs: Vec::new(), - lines: vec![RenderLine { - kind: RenderRowKind::HunkSeparator as u8, - left_text: ByteRange { start: 0, len: 9 }, - left_cols: 9, - ..RenderLine::default() - }], - }; - - let marker = Color { - r: 11, - g: 22, - b: 33, - a: 255, - }; - - let mut runtime = EditorElement::default(); - runtime.blocks_mut().push( - BlockPlacement::Above(0), - Box::new(StubBlock { color: marker }), - ); - - let document = EditorDocument::Text { - compare_generation: 7, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }; - runtime.prepare( - &mut state, - document, - Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }, - TextMetrics::default(), - ); - - let theme = Theme::default_dark(); - let mut scene = Scene::default(); - runtime.paint(&mut scene, &theme, &state, document); - - let has_marker = scene.primitives.iter().any(|p| match p { - Primitive::Rect(r) => r.color == marker, - _ => false, - }); - assert!(has_marker, "block paint() should emit its own primitives"); - } - - #[test] - fn hunk_separator_decoration_emits_background_rect() { - use crate::render::{Primitive, Scene}; - - let mut state = EditorState { - layout: LayoutMode::Unified, - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"@@ hdr @@".to_vec(), - style_runs: Vec::new(), - lines: vec![RenderLine { - kind: RenderRowKind::HunkSeparator as u8, - left_text: ByteRange { start: 0, len: 9 }, - left_cols: 9, - ..RenderLine::default() - }], - }; - - let mut runtime = EditorElement::default(); - let document = EditorDocument::Text { - compare_generation: 1, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }; - runtime.prepare( - &mut state, - document, - Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }, - TextMetrics::default(), - ); - - let theme = Theme::default_dark(); - let mut scene = Scene::default(); - runtime.paint(&mut scene, &theme, &state, document); - - let hunk_bg = theme.colors.hunk_header_bg; - let has_hunk_bg = scene.primitives.iter().any(|p| match p { - Primitive::Rect(r) => r.color == hunk_bg, - _ => false, - }); - assert!( - has_hunk_bg, - "expected a rect with hunk_header_bg color to be emitted" - ); - } -} diff --git a/src/editor/diff/element/tests.rs b/src/editor/diff/element/tests.rs new file mode 100644 index 00000000..d32c87ee --- /dev/null +++ b/src/editor/diff/element/tests.rs @@ -0,0 +1,724 @@ +use super::layout::{ + editor_bottom_padding_px, render_cols_for_width, visible_segment_range_for_block, +}; +use super::paint::{RowTone, build_wrapped_rich_text, wrapped_byte_slice}; +use super::{CachedTextLayout, EditorDocument, EditorElement}; +use crate::core::compare::LayoutMode; +use crate::editor::diff::render_doc::{ByteRange, RenderDoc, RenderLine, RenderRowKind, RunRange}; +use crate::editor::diff::state::{ + EditorState, ViewportTextPoint, ViewportTextSelection, ViewportTextSide, +}; +use crate::render::{FontStyle, FontWeight, Rect, TextMetrics}; +use crate::ui::theme::Theme; + +#[test] +fn wrapped_byte_slice_breaks_monospaced_text_by_columns() { + let layout = CachedTextLayout::new("abcdefghij"); + assert_eq!(wrapped_byte_slice(&layout, 4, 0), Some((0, 4))); + assert_eq!(wrapped_byte_slice(&layout, 4, 1), Some((4, 8))); + assert_eq!(wrapped_byte_slice(&layout, 4, 2), Some((8, 10))); + assert_eq!(wrapped_byte_slice(&layout, 4, 3), None); +} + +#[test] +fn cached_text_layout_tracks_visual_columns_for_tabs() { + let layout = CachedTextLayout::new("\ta\t"); + assert_eq!(layout.total_cols(), 16); + assert_eq!(layout.col_for_byte(0), 0); + assert_eq!(layout.col_for_byte(1), 8); + assert_eq!(layout.col_for_byte(2), 9); + assert_eq!(layout.col_for_byte(3), 16); +} + +#[test] +fn rich_text_builder_returns_spans_for_requested_segment() { + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"keyword // comment".to_vec(), + style_runs: vec![ + crate::editor::diff::render_doc::StyleRun { + byte_start: 0, + byte_len: 7, + style_id: 1, + flags: 0, + }, + crate::editor::diff::render_doc::StyleRun { + byte_start: 7, + byte_len: 1, + style_id: 0, + flags: 0, + }, + crate::editor::diff::render_doc::StyleRun { + byte_start: 8, + byte_len: 10, + style_id: 3, + flags: 0, + }, + ], + lines: vec![RenderLine { + kind: RenderRowKind::Context as u8, + right_text: ByteRange { start: 0, len: 18 }, + right_runs: RunRange { start: 0, len: 3 }, + right_cols: 18, + ..RenderLine::default() + }], + }; + + let text_layout = CachedTextLayout::new("keyword // comment"); + let spans = build_wrapped_rich_text( + &doc, + &text_layout, + doc.lines[0].right_text, + doc.lines[0].right_runs, + 0, + u16::MAX, + RowTone::Neutral, + &Theme::default_dark(), + ) + .expect("spans"); + + assert!(!spans.is_empty()); + assert_eq!( + spans + .iter() + .map(|span| span.text.as_ref()) + .collect::(), + "keyword // comment" + ); + assert_eq!(spans[0].text.as_ref(), "keyword"); + assert_eq!(spans[0].font_weight, Some(FontWeight::Semibold)); + let comment = spans + .iter() + .find(|span| span.text.as_ref() == "// comment") + .expect("comment span"); + assert_eq!(comment.font_style, Some(FontStyle::Italic)); +} + +#[test] +fn rich_text_builder_expands_tabs_across_wrapped_segments() { + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"\tabc".to_vec(), + style_runs: vec![crate::editor::diff::render_doc::StyleRun { + byte_start: 0, + byte_len: 4, + style_id: 0, + flags: 0, + }], + lines: vec![RenderLine { + kind: RenderRowKind::Context as u8, + right_text: ByteRange { start: 0, len: 4 }, + right_runs: RunRange { start: 0, len: 1 }, + right_cols: 11, + ..RenderLine::default() + }], + }; + + let text_layout = CachedTextLayout::new("\tabc"); + let theme = Theme::default_dark(); + + let seg0 = build_wrapped_rich_text( + &doc, + &text_layout, + doc.lines[0].right_text, + doc.lines[0].right_runs, + 0, + 4, + RowTone::Neutral, + &theme, + ) + .expect("segment 0"); + let seg1 = build_wrapped_rich_text( + &doc, + &text_layout, + doc.lines[0].right_text, + doc.lines[0].right_runs, + 1, + 4, + RowTone::Neutral, + &theme, + ) + .expect("segment 1"); + let seg2 = build_wrapped_rich_text( + &doc, + &text_layout, + doc.lines[0].right_text, + doc.lines[0].right_runs, + 2, + 4, + RowTone::Neutral, + &theme, + ) + .expect("segment 2"); + + assert_eq!( + seg0.iter() + .map(|span| span.text.as_ref()) + .collect::(), + " " + ); + assert_eq!( + seg1.iter() + .map(|span| span.text.as_ref()) + .collect::(), + " " + ); + assert_eq!( + seg2.iter() + .map(|span| span.text.as_ref()) + .collect::(), + "abc" + ); +} + +#[test] +fn render_cols_cap_unwrapped_rows_to_viewport_budget() { + assert_eq!(render_cols_for_width(false, 0, 8.0, 80.0), 26); + assert_eq!(render_cols_for_width(true, 0, 8.0, 80.0), 10); +} + +#[test] +fn visible_segment_range_limits_wrapped_blocks_to_viewport() { + assert_eq!( + visible_segment_range_for_block(100.0, 10, 20.0, 120.0, 170.0), + 1..4 + ); + assert_eq!( + visible_segment_range_for_block(100.0, 10, 20.0, 0.0, 50.0), + 0..0 + ); +} + +#[test] +fn prepare_populates_visible_range_and_hit_testing() { + let mut state = EditorState { + layout: LayoutMode::Unified, + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"demo.txt@@ -1 +1 @@line".to_vec(), + style_runs: Vec::new(), + lines: vec![ + RenderLine { + kind: RenderRowKind::FileHeader as u8, + left_text: ByteRange { start: 0, len: 8 }, + left_cols: 8, + ..RenderLine::default() + }, + RenderLine { + kind: RenderRowKind::HunkSeparator as u8, + left_text: ByteRange { start: 8, len: 11 }, + left_cols: 11, + ..RenderLine::default() + }, + RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 1, + new_line_no: 1, + right_text: ByteRange { start: 19, len: 4 }, + right_cols: 4, + ..RenderLine::default() + }, + ], + }; + + let mut runtime = EditorElement::default(); + runtime.prepare( + &mut state, + EditorDocument::Text { + compare_generation: 1, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }, + Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }, + TextMetrics::default(), + ); + + assert_eq!(state.visible_row_start, Some(0)); + // FileHeader lines are skipped in layout, so only 2 display rows exist. + assert!(state.visible_row_end.expect("visible end") >= 2); + let body = runtime.body_bounds(); + assert_eq!( + runtime.hit_test_row(&state, body.x + 20.0, body.y + 5.0), + Some(0) + ); +} + +#[test] +fn prepare_adds_bottom_padding_to_keep_last_row_above_viewport_clip() { + let mut state = EditorState { + layout: LayoutMode::Unified, + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"last".to_vec(), + style_runs: Vec::new(), + lines: vec![RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 1, + new_line_no: 1, + right_text: ByteRange { start: 0, len: 4 }, + right_cols: 4, + ..RenderLine::default() + }], + }; + let mut runtime = EditorElement::default(); + runtime.prepare( + &mut state, + EditorDocument::Text { + compare_generation: 1, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }, + Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 16.0, + }, + TextMetrics::default(), + ); + + let bottom_padding = editor_bottom_padding_px(runtime.metrics); + assert_eq!( + state.content_height_px, + runtime.summary.content_height_px + bottom_padding + ); + let unpadded_max = runtime + .summary + .content_height_px + .saturating_sub(state.viewport_height_px.max(1)); + assert_eq!(state.max_scroll_top_px(), unpadded_max + bottom_padding); +} + +#[test] +fn preprepare_content_height_matches_prepared_viewport_height() { + let mut state = EditorState { + layout: LayoutMode::Unified, + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"last".to_vec(), + style_runs: Vec::new(), + lines: vec![RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 1, + new_line_no: 1, + right_text: ByteRange { start: 0, len: 4 }, + right_cols: 4, + ..RenderLine::default() + }], + }; + let mut runtime = EditorElement::default(); + let bounds = Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }; + let text_metrics = TextMetrics::default(); + let expected_height = runtime + .content_height_for_bounds(bounds, text_metrics) + .max(0.0) + .round() as u32; + + runtime.prepare( + &mut state, + EditorDocument::Text { + compare_generation: 1, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }, + bounds, + text_metrics, + ); + + assert_eq!(state.viewport_height_px, expected_height); +} + +#[test] +fn hit_test_text_point_maps_viewport_columns_to_line_bytes() { + let mut state = EditorState { + layout: LayoutMode::Unified, + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"hello".to_vec(), + style_runs: Vec::new(), + lines: vec![RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 1, + new_line_no: 1, + right_text: ByteRange { start: 0, len: 5 }, + right_cols: 5, + ..RenderLine::default() + }], + }; + let mut runtime = EditorElement::default(); + runtime.prepare( + &mut state, + EditorDocument::Text { + compare_generation: 1, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }, + Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }, + TextMetrics::default(), + ); + + let x = runtime.layout.unified_text_rect.x + TextMetrics::default().mono_char_width_px * 3.1; + let y = runtime.body_bounds().y + runtime.layout.line_height * 0.5; + let point = runtime + .hit_test_text_point(&state, &doc, x, y) + .expect("text point"); + + assert_eq!( + point, + ViewportTextPoint { + line_index: 0, + side: ViewportTextSide::Right, + byte_offset: 3, + } + ); +} + +#[test] +fn viewport_selection_text_copies_visible_line_segments() { + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"alphaBRAVO".to_vec(), + style_runs: Vec::new(), + lines: vec![ + RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 1, + new_line_no: 1, + right_text: ByteRange { start: 0, len: 5 }, + right_cols: 5, + ..RenderLine::default() + }, + RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 2, + new_line_no: 2, + right_text: ByteRange { start: 5, len: 5 }, + right_cols: 5, + ..RenderLine::default() + }, + ], + }; + let selection = ViewportTextSelection { + generation: 7, + anchor: ViewportTextPoint { + line_index: 0, + side: ViewportTextSide::Right, + byte_offset: 1, + }, + focus: ViewportTextPoint { + line_index: 1, + side: ViewportTextSide::Right, + byte_offset: 3, + }, + }; + let runtime = EditorElement::default(); + + assert_eq!( + runtime.viewport_selection_text(&doc, &selection).as_deref(), + Some("lpha\nBRA") + ); +} + +#[test] +fn split_viewport_selection_text_stays_on_selected_side() { + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"old-aNEW-Aold-bNEW-B".to_vec(), + style_runs: Vec::new(), + lines: vec![ + RenderLine { + kind: RenderRowKind::Modified as u8, + old_line_no: 1, + new_line_no: 1, + left_text: ByteRange { start: 0, len: 5 }, + right_text: ByteRange { start: 5, len: 5 }, + left_cols: 5, + right_cols: 5, + ..RenderLine::default() + }, + RenderLine { + kind: RenderRowKind::Modified as u8, + old_line_no: 2, + new_line_no: 2, + left_text: ByteRange { start: 10, len: 5 }, + right_text: ByteRange { start: 15, len: 5 }, + left_cols: 5, + right_cols: 5, + ..RenderLine::default() + }, + ], + }; + let selection = ViewportTextSelection { + generation: 7, + anchor: ViewportTextPoint { + line_index: 0, + side: ViewportTextSide::Left, + byte_offset: 1, + }, + focus: ViewportTextPoint { + line_index: 1, + side: ViewportTextSide::Left, + byte_offset: 4, + }, + }; + let mut runtime = EditorElement::default(); + runtime.layout.split_mode = true; + + assert_eq!( + runtime.viewport_selection_text(&doc, &selection).as_deref(), + Some("ld-a\nold-") + ); +} + +#[test] +fn viewport_text_selection_paints_square_rectangles() { + use crate::render::{Primitive, Scene}; + + let mut state = EditorState { + layout: LayoutMode::Unified, + text_selection: Some(ViewportTextSelection { + generation: 1, + anchor: ViewportTextPoint { + line_index: 0, + side: ViewportTextSide::Right, + byte_offset: 1, + }, + focus: ViewportTextPoint { + line_index: 1, + side: ViewportTextSide::Right, + byte_offset: 4, + }, + }), + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"alphabravo".to_vec(), + style_runs: Vec::new(), + lines: vec![ + RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 1, + new_line_no: 1, + right_text: ByteRange { start: 0, len: 5 }, + right_cols: 5, + ..RenderLine::default() + }, + RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 2, + new_line_no: 2, + right_text: ByteRange { start: 5, len: 5 }, + right_cols: 5, + ..RenderLine::default() + }, + ], + }; + let mut runtime = EditorElement::default(); + let document = EditorDocument::Text { + compare_generation: 1, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }; + runtime.prepare( + &mut state, + document, + Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }, + TextMetrics::default(), + ); + + let theme = Theme::default_dark(); + let selection_bg = theme.colors.selection_bg; + let mut scene = Scene::default(); + runtime.paint(&mut scene, &theme, &state, document); + + assert!( + scene + .primitives + .iter() + .any(|p| matches!(p, Primitive::Rect(r) if r.color == selection_bg)) + ); + assert!( + !scene + .primitives + .iter() + .any(|p| matches!(p, Primitive::RoundedRect(r) if r.color == selection_bg)) + ); +} + +#[test] +fn block_paint_emits_primitive_for_registered_decoration() { + use super::super::decoration::{BlockDecoration, BlockPaintCtx, BlockPlacement}; + use crate::render::{Primitive, RectPrimitive, Scene}; + use crate::ui::theme::Color; + + #[derive(Debug)] + struct StubBlock { + color: Color, + } + + impl BlockDecoration for StubBlock { + fn height(&self, _metrics: &super::super::display_layout::DisplayLayoutMetrics) -> u16 { + 20 + } + + fn paint(&self, ctx: &mut BlockPaintCtx) { + let _ = ctx.hovered; + ctx.scene.rect(RectPrimitive { + rect: ctx.row_rect, + color: self.color, + }); + } + } + + let mut state = EditorState { + layout: LayoutMode::Unified, + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"@@ hdr @@".to_vec(), + style_runs: Vec::new(), + lines: vec![RenderLine { + kind: RenderRowKind::HunkSeparator as u8, + left_text: ByteRange { start: 0, len: 9 }, + left_cols: 9, + ..RenderLine::default() + }], + }; + + let marker = Color { + r: 11, + g: 22, + b: 33, + a: 255, + }; + + let mut runtime = EditorElement::default(); + runtime.blocks_mut().push( + BlockPlacement::Above(0), + Box::new(StubBlock { color: marker }), + ); + + let document = EditorDocument::Text { + compare_generation: 7, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }; + runtime.prepare( + &mut state, + document, + Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }, + TextMetrics::default(), + ); + + let theme = Theme::default_dark(); + let mut scene = Scene::default(); + runtime.paint(&mut scene, &theme, &state, document); + + let has_marker = scene.primitives.iter().any(|p| match p { + Primitive::Rect(r) => r.color == marker, + _ => false, + }); + assert!(has_marker, "block paint() should emit its own primitives"); +} + +#[test] +fn hunk_separator_decoration_emits_background_rect() { + use crate::render::{Primitive, Scene}; + + let mut state = EditorState { + layout: LayoutMode::Unified, + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"@@ hdr @@".to_vec(), + style_runs: Vec::new(), + lines: vec![RenderLine { + kind: RenderRowKind::HunkSeparator as u8, + left_text: ByteRange { start: 0, len: 9 }, + left_cols: 9, + ..RenderLine::default() + }], + }; + + let mut runtime = EditorElement::default(); + let document = EditorDocument::Text { + compare_generation: 1, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }; + runtime.prepare( + &mut state, + document, + Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }, + TextMetrics::default(), + ); + + let theme = Theme::default_dark(); + let mut scene = Scene::default(); + runtime.paint(&mut scene, &theme, &state, document); + + let hunk_bg = theme.colors.hunk_header_bg; + let has_hunk_bg = scene.primitives.iter().any(|p| match p { + Primitive::Rect(r) => r.color == hunk_bg, + _ => false, + }); + assert!( + has_hunk_bg, + "expected a rect with hunk_header_bg color to be emitted" + ); +} From eed9842b94d342f5c3d5f25307c892aca645c5c1 Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 21:56:30 +0000 Subject: [PATCH 14/25] refactor(vcs): extract shared caching layer for git and jj services Move the operation-id (epoch) keyed diff and file-text caches out of the jj service into a shared src/core/vcs/cache.rs (VcsReadCache) so any VCS backend service can reuse them. Invalidation semantics are unchanged: exact epoch match on lookup, FIFO eviction at the same caps (8 diffs, 16 file texts), and full clears on operation-id change or after writes. The git service performs no diff/file-text caching today, so it gains no cache wiring (adding one would change staleness behavior). The publish state machines, ref resolution, and snapshot scaffolding of the two backends are not isomorphic (gix vs jj CLI, PushRef vs bookmark/change actions) and are intentionally left separate. --- src/core/vcs/cache.rs | 253 +++++++++++++++++++++++++++++++++++++ src/core/vcs/jj/service.rs | 138 +++++--------------- src/core/vcs/mod.rs | 1 + 3 files changed, 287 insertions(+), 105 deletions(-) create mode 100644 src/core/vcs/cache.rs diff --git a/src/core/vcs/cache.rs b/src/core/vcs/cache.rs new file mode 100644 index 00000000..38b9ae0b --- /dev/null +++ b/src/core/vcs/cache.rs @@ -0,0 +1,253 @@ +//! Shared epoch-keyed read caches for VCS backend services. +//! +//! Backends that re-run expensive reads (whole-compare diffs, per-path +//! diffs, file text at a revision) can memoize them here, keyed on an opaque +//! read epoch. The epoch identifies the repository state the read was +//! produced under — for jj this is the operation id — so cache hits require +//! an exact epoch match and a `None` epoch only matches entries inserted +//! with a `None` epoch. Callers are responsible for calling [`clear`] after +//! writes or whenever the epoch changes; the caches never invalidate +//! entries on their own. Eviction is FIFO with small fixed caps so memory +//! stays bounded without tracking recency. +//! +//! [`clear`]: VcsReadCache::clear + +use carbon::TextStore; + +use crate::core::compare::CompareOutput; +use crate::core::vcs::model::{RevisionId, VcsCompareRequest}; + +const MAX_DIFF_CACHE_ENTRIES: usize = 8; +const MAX_FILE_TEXT_CACHE_ENTRIES: usize = 16; + +#[derive(Clone)] +struct DiffCacheEntry { + epoch: Option, + request: VcsCompareRequest, + path: Option, + output: CompareOutput, +} + +#[derive(Clone)] +struct FileTextCacheEntry { + epoch: Option, + revision: RevisionId, + path: String, + text: TextStore, +} + +/// Bounded diff and file-text caches for a VCS repository service. +#[derive(Default)] +pub struct VcsReadCache { + diffs: Vec, + file_texts: Vec, +} + +impl VcsReadCache { + pub fn new() -> Self { + Self::default() + } + + pub fn cached_diff( + &self, + epoch: Option<&str>, + request: &VcsCompareRequest, + path: Option<&str>, + ) -> Option { + self.diffs + .iter() + .find(|entry| { + entry.epoch.as_deref() == epoch + && entry.request == *request + && entry.path.as_deref() == path + }) + .map(|entry| entry.output.clone()) + } + + pub fn insert_diff( + &mut self, + epoch: Option, + request: VcsCompareRequest, + path: Option, + output: CompareOutput, + ) { + if self.diffs.len() >= MAX_DIFF_CACHE_ENTRIES { + self.diffs.remove(0); + } + self.diffs.push(DiffCacheEntry { + epoch, + request, + path, + output, + }); + } + + pub fn cached_file_text( + &self, + epoch: Option<&str>, + revision: &RevisionId, + path: &str, + ) -> Option { + self.file_texts + .iter() + .find(|entry| { + entry.epoch.as_deref() == epoch && entry.revision == *revision && entry.path == path + }) + .map(|entry| entry.text.clone()) + } + + pub fn insert_file_text( + &mut self, + epoch: Option, + revision: RevisionId, + path: String, + text: TextStore, + ) { + if self.file_texts.len() >= MAX_FILE_TEXT_CACHE_ENTRIES { + self.file_texts.remove(0); + } + self.file_texts.push(FileTextCacheEntry { + epoch, + revision, + path, + text, + }); + } + + pub fn clear(&mut self) { + self.diffs.clear(); + self.file_texts.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::compare::{LayoutMode, RendererKind}; + use crate::core::vcs::model::{VcsCompareSpec, VcsKind}; + + fn request(revision: &str) -> VcsCompareRequest { + VcsCompareRequest { + spec: VcsCompareSpec::Change { + revision: revision.to_owned(), + }, + layout: LayoutMode::Unified, + renderer: RendererKind::Builtin, + } + } + + fn revision(id: &str) -> RevisionId { + RevisionId { + backend: VcsKind::JJ, + id: id.to_owned(), + } + } + + #[test] + fn diff_hits_require_matching_epoch_request_and_path() { + let mut cache = VcsReadCache::new(); + cache.insert_diff( + Some("op-1".to_owned()), + request("abc"), + Some("src/lib.rs".to_owned()), + CompareOutput::default(), + ); + + assert!( + cache + .cached_diff(Some("op-1"), &request("abc"), Some("src/lib.rs")) + .is_some() + ); + assert!( + cache + .cached_diff(Some("op-2"), &request("abc"), Some("src/lib.rs")) + .is_none() + ); + assert!( + cache + .cached_diff(None, &request("abc"), Some("src/lib.rs")) + .is_none() + ); + assert!( + cache + .cached_diff(Some("op-1"), &request("def"), Some("src/lib.rs")) + .is_none() + ); + assert!( + cache + .cached_diff(Some("op-1"), &request("abc"), None) + .is_none() + ); + } + + #[test] + fn file_text_hits_require_matching_epoch_revision_and_path() { + let mut cache = VcsReadCache::new(); + cache.insert_file_text( + Some("op-1".to_owned()), + revision("abc"), + "src/lib.rs".to_owned(), + TextStore::from_text("hello\n".to_owned()), + ); + + assert!( + cache + .cached_file_text(Some("op-1"), &revision("abc"), "src/lib.rs") + .is_some() + ); + assert!( + cache + .cached_file_text(Some("op-2"), &revision("abc"), "src/lib.rs") + .is_none() + ); + assert!( + cache + .cached_file_text(Some("op-1"), &revision("def"), "src/lib.rs") + .is_none() + ); + } + + #[test] + fn diff_cache_evicts_oldest_entry_at_capacity() { + let mut cache = VcsReadCache::new(); + for index in 0..=MAX_DIFF_CACHE_ENTRIES { + cache.insert_diff( + Some("op-1".to_owned()), + request(&format!("rev-{index}")), + None, + CompareOutput::default(), + ); + } + + assert!( + cache + .cached_diff(Some("op-1"), &request("rev-0"), None) + .is_none() + ); + assert!( + cache + .cached_diff(Some("op-1"), &request("rev-1"), None) + .is_some() + ); + assert_eq!(cache.diffs.len(), MAX_DIFF_CACHE_ENTRIES); + } + + #[test] + fn clear_drops_both_caches() { + let mut cache = VcsReadCache::new(); + cache.insert_diff(None, request("abc"), None, CompareOutput::default()); + cache.insert_file_text( + None, + revision("abc"), + "src/lib.rs".to_owned(), + TextStore::from_text(String::new()), + ); + cache.clear(); + assert!(cache.cached_diff(None, &request("abc"), None).is_none()); + assert!( + cache + .cached_file_text(None, &revision("abc"), "src/lib.rs") + .is_none() + ); + } +} diff --git a/src/core/vcs/jj/service.rs b/src/core/vcs/jj/service.rs index 8006621e..b69221c3 100644 --- a/src/core/vcs/jj/service.rs +++ b/src/core/vcs/jj/service.rs @@ -13,6 +13,7 @@ use crate::core::compare::{ }; use crate::core::error::{DiffyError, Result, VcsBackendKind}; use crate::core::vcs::backend::{VcsBackend, VcsRepository, VcsWatchPaths}; +use crate::core::vcs::cache::VcsReadCache; use crate::core::vcs::jj::cli::JjCli; use crate::core::vcs::jj::parse::{ parse_bookmark_list, parse_change_log, parse_conflict_list, parse_diff_summary, @@ -70,24 +71,8 @@ pub struct JjRepository { location: RepoLocation, last_operation_id: Option, last_snapshot: Option, - diff_cache: Vec, - file_text_cache: Vec, -} - -#[derive(Clone)] -struct DiffCacheEntry { - operation_id: Option, - request: VcsCompareRequest, - path: Option, - output: CompareOutput, -} - -#[derive(Clone)] -struct FileTextCacheEntry { - operation_id: Option, - revision: RevisionId, - path: String, - text: TextStore, + /// Reads cached per operation id; see [`VcsReadCache`]. + read_cache: VcsReadCache, } #[derive(Debug, Clone)] @@ -115,8 +100,7 @@ impl JjRepository { location, last_operation_id: None, last_snapshot: None, - diff_cache: Vec::new(), - file_text_cache: Vec::new(), + read_cache: VcsReadCache::new(), } } @@ -267,8 +251,7 @@ impl JjRepository { fn set_operation_id(&mut self, operation_id: String) { if self.last_operation_id.as_deref() != Some(operation_id.as_str()) { - self.diff_cache.clear(); - self.file_text_cache.clear(); + self.read_cache.clear(); self.last_snapshot = None; } self.last_operation_id = Some(operation_id); @@ -283,76 +266,6 @@ impl JjRepository { Ok(self.last_operation_id.clone()) } - fn cached_diff( - &self, - operation_id: Option<&str>, - request: &VcsCompareRequest, - path: Option<&str>, - ) -> Option { - self.diff_cache - .iter() - .find(|entry| { - entry.operation_id.as_deref() == operation_id - && entry.request == *request - && entry.path.as_deref() == path - }) - .map(|entry| entry.output.clone()) - } - - fn insert_diff_cache( - &mut self, - operation_id: Option, - request: VcsCompareRequest, - path: Option, - output: CompareOutput, - ) { - const MAX_DIFF_CACHE_ENTRIES: usize = 8; - if self.diff_cache.len() >= MAX_DIFF_CACHE_ENTRIES { - self.diff_cache.remove(0); - } - self.diff_cache.push(DiffCacheEntry { - operation_id, - request, - path, - output, - }); - } - - fn cached_file_text( - &self, - operation_id: Option<&str>, - revision: &RevisionId, - path: &str, - ) -> Option { - self.file_text_cache - .iter() - .find(|entry| { - entry.operation_id.as_deref() == operation_id - && entry.revision == *revision - && entry.path == path - }) - .map(|entry| entry.text.clone()) - } - - fn insert_file_text_cache( - &mut self, - operation_id: Option, - revision: RevisionId, - path: String, - text: TextStore, - ) { - const MAX_FILE_TEXT_CACHE_ENTRIES: usize = 16; - if self.file_text_cache.len() >= MAX_FILE_TEXT_CACHE_ENTRIES { - self.file_text_cache.remove(0); - } - self.file_text_cache.push(FileTextCacheEntry { - operation_id, - revision, - path, - text, - }); - } - fn conflict_list(&self) -> Result { match self.cli.run_ignored_wc(&[ OsString::from("resolve"), @@ -369,8 +282,7 @@ impl JjRepository { fn clear_after_write(&mut self) { self.last_operation_id = None; self.last_snapshot = None; - self.diff_cache.clear(); - self.file_text_cache.clear(); + self.read_cache.clear(); } fn remote_names(&self) -> Result> { @@ -715,13 +627,17 @@ impl VcsRepository for JjRepository { _reporter: Option<&dyn ProgressSink>, ) -> Result { let operation_id = self.ensure_read_epoch()?; - if let Some(output) = self.cached_diff(operation_id.as_deref(), request, None) { + if let Some(output) = self + .read_cache + .cached_diff(operation_id.as_deref(), request, None) + { return Ok(output); } #[cfg(feature = "difftastic")] if request.renderer == RendererKind::Difftastic { let output = self.compare_difftastic(request, _reporter, None)?; - self.insert_diff_cache(operation_id, request.clone(), None, output.clone()); + self.read_cache + .insert_diff(operation_id, request.clone(), None, output.clone()); return Ok(output); } @@ -734,13 +650,15 @@ impl VcsRepository for JjRepository { ..CompareOutput::default() }; output.compact_file_summaries(); - self.insert_diff_cache(operation_id, request.clone(), None, output.clone()); + self.read_cache + .insert_diff(operation_id, request.clone(), None, output.clone()); return Ok(output); } let args = self.diff_args_for_spec(&request.spec)?; let raw_diff = self.cli.run_ignored_wc(&args)?; let output = compare_output_from_raw_patch(&raw_diff)?; - self.insert_diff_cache(operation_id, request.clone(), None, output.clone()); + self.read_cache + .insert_diff(operation_id, request.clone(), None, output.clone()); Ok(output) } @@ -815,13 +733,16 @@ impl VcsRepository for JjRepository { _deferred_file: Option<&CompareFileSummary>, ) -> Result { let operation_id = self.ensure_read_epoch()?; - if let Some(output) = self.cached_diff(operation_id.as_deref(), request, Some(path)) { + if let Some(output) = + self.read_cache + .cached_diff(operation_id.as_deref(), request, Some(path)) + { return Ok(output); } #[cfg(feature = "difftastic")] if request.renderer == RendererKind::Difftastic { let output = self.compare_difftastic(request, None, Some(path))?; - self.insert_diff_cache( + self.read_cache.insert_diff( operation_id, request.clone(), Some(path.to_owned()), @@ -834,7 +755,7 @@ impl VcsRepository for JjRepository { args.push(jj_root_pathspec(path)); let raw_diff = self.cli.run_ignored_wc(&args)?; let output = compare_output_from_raw_patch(&raw_diff)?; - self.insert_diff_cache( + self.read_cache.insert_diff( operation_id, request.clone(), Some(path.to_owned()), @@ -1223,7 +1144,10 @@ impl VcsRepository for JjRepository { layout: crate::core::compare::LayoutMode::Unified, renderer: RendererKind::Builtin, }; - if let Some(output) = self.cached_diff(operation_id.as_deref(), &request, Some(path)) { + if let Some(output) = + self.read_cache + .cached_diff(operation_id.as_deref(), &request, Some(path)) + { return Ok(output); } let raw_diff = self.cli.run_ignored_wc(&[ @@ -1234,13 +1158,17 @@ impl VcsRepository for JjRepository { jj_root_pathspec(path), ])?; let output = compare_output_from_raw_patch(&raw_diff)?; - self.insert_diff_cache(operation_id, request, Some(path.to_owned()), output.clone()); + self.read_cache + .insert_diff(operation_id, request, Some(path.to_owned()), output.clone()); Ok(output) } fn read_file_text(&mut self, revision: &RevisionId, path: &str) -> Result { let operation_id = self.ensure_read_epoch()?; - if let Some(text) = self.cached_file_text(operation_id.as_deref(), revision, path) { + if let Some(text) = + self.read_cache + .cached_file_text(operation_id.as_deref(), revision, path) + { return Ok(text); } let output = self.cli.run_ignored_wc(&[ @@ -1251,7 +1179,7 @@ impl VcsRepository for JjRepository { jj_root_pathspec(path), ])?; let text = TextStore::from_text(output); - self.insert_file_text_cache( + self.read_cache.insert_file_text( operation_id, revision.clone(), path.to_owned(), diff --git a/src/core/vcs/mod.rs b/src/core/vcs/mod.rs index 0eb715cf..4486111a 100644 --- a/src/core/vcs/mod.rs +++ b/src/core/vcs/mod.rs @@ -1,4 +1,5 @@ pub mod backend; +pub mod cache; pub mod discovery; pub mod git; pub mod jj; From 503d53d12c33a447818a39ae2eda34a681cdf852 Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 22:15:00 +0000 Subject: [PATCH 15/25] perf(vcs): cache and batch non-auth git subprocess calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce per-session git subprocess spawn count on read-only paths; all auth-requiring operations (fetch/push/pull, credential and SSH handling) remain on system git untouched. - Snapshot ref listing: merge branches() and tags() into a single branches_and_tags() for-each-ref invocation (one subprocess per watcher-driven refresh instead of two). branches()/tags() keep their exact output as thin wrappers; the combined format adds %(*objectname) so annotated-tag peeling matches the old tags() parse. - resolve_ref summary: replace the `git log -n1` subprocess with a gix object-store read (GitService::commit_summary). gix's MessageRef:: summary() folds title whitespace exactly like `git log --format=%s`. - Immutable read cache: share one process-wide VcsReadCache across the short-lived GitRepository instances the runtime opens per operation, keyed by workspace root in the epoch slot. Only content-addressed reads are cached — compares whose request revisions are all full hex OIDs (parents, trees, and merge bases of fixed commits are fixed) and file text at a full-hex revision — so entries can never go stale and no invalidation is required. Workdir/index/symbolic-ref reads bypass the cache, preserving the staleness guarantees called out when the cache layer was extracted. This eliminates repeated `git diff [--shortstat] [-- path]` spawns when revisiting the same commit/range compare or re-opening files within it. - VcsReadCache grows a small FIFO stats cache (epoch + request -> (additions, deletions)) used by compare_stats; clear() drops it too. Verified with cargo check --workspace and the vcs/compare lib tests (141 tests passing). --- src/core/vcs/cache.rs | 60 +++++++++- src/core/vcs/git/adapter.rs | 230 +++++++++++++++++++++++++----------- src/core/vcs/git/service.rs | 88 +++++++------- 3 files changed, 265 insertions(+), 113 deletions(-) diff --git a/src/core/vcs/cache.rs b/src/core/vcs/cache.rs index 38b9ae0b..6e70c68b 100644 --- a/src/core/vcs/cache.rs +++ b/src/core/vcs/cache.rs @@ -1,7 +1,7 @@ //! Shared epoch-keyed read caches for VCS backend services. //! //! Backends that re-run expensive reads (whole-compare diffs, per-path -//! diffs, file text at a revision) can memoize them here, keyed on an opaque +//! diffs, diff stats, file text at a revision) can memoize them here, keyed on an opaque //! read epoch. The epoch identifies the repository state the read was //! produced under — for jj this is the operation id — so cache hits require //! an exact epoch match and a `None` epoch only matches entries inserted @@ -19,6 +19,7 @@ use crate::core::vcs::model::{RevisionId, VcsCompareRequest}; const MAX_DIFF_CACHE_ENTRIES: usize = 8; const MAX_FILE_TEXT_CACHE_ENTRIES: usize = 16; +const MAX_STATS_CACHE_ENTRIES: usize = 16; #[derive(Clone)] struct DiffCacheEntry { @@ -28,6 +29,13 @@ struct DiffCacheEntry { output: CompareOutput, } +#[derive(Clone)] +struct StatsCacheEntry { + epoch: Option, + request: VcsCompareRequest, + stats: (i32, i32), +} + #[derive(Clone)] struct FileTextCacheEntry { epoch: Option, @@ -36,11 +44,13 @@ struct FileTextCacheEntry { text: TextStore, } -/// Bounded diff and file-text caches for a VCS repository service. +/// Bounded diff, diff-stat, and file-text caches for a VCS repository +/// service. #[derive(Default)] pub struct VcsReadCache { diffs: Vec, file_texts: Vec, + stats: Vec, } impl VcsReadCache { @@ -82,6 +92,33 @@ impl VcsReadCache { }); } + pub fn cached_stats( + &self, + epoch: Option<&str>, + request: &VcsCompareRequest, + ) -> Option<(i32, i32)> { + self.stats + .iter() + .find(|entry| entry.epoch.as_deref() == epoch && entry.request == *request) + .map(|entry| entry.stats) + } + + pub fn insert_stats( + &mut self, + epoch: Option, + request: VcsCompareRequest, + stats: (i32, i32), + ) { + if self.stats.len() >= MAX_STATS_CACHE_ENTRIES { + self.stats.remove(0); + } + self.stats.push(StatsCacheEntry { + epoch, + request, + stats, + }); + } + pub fn cached_file_text( &self, epoch: Option<&str>, @@ -117,6 +154,7 @@ impl VcsReadCache { pub fn clear(&mut self) { self.diffs.clear(); self.file_texts.clear(); + self.stats.clear(); } } @@ -233,7 +271,21 @@ mod tests { } #[test] - fn clear_drops_both_caches() { + fn stats_hits_require_matching_epoch_and_request() { + let mut cache = VcsReadCache::new(); + cache.insert_stats(Some("op-1".to_owned()), request("abc"), (3, 1)); + + assert_eq!( + cache.cached_stats(Some("op-1"), &request("abc")), + Some((3, 1)) + ); + assert!(cache.cached_stats(Some("op-2"), &request("abc")).is_none()); + assert!(cache.cached_stats(None, &request("abc")).is_none()); + assert!(cache.cached_stats(Some("op-1"), &request("def")).is_none()); + } + + #[test] + fn clear_drops_all_caches() { let mut cache = VcsReadCache::new(); cache.insert_diff(None, request("abc"), None, CompareOutput::default()); cache.insert_file_text( @@ -242,6 +294,7 @@ mod tests { "src/lib.rs".to_owned(), TextStore::from_text(String::new()), ); + cache.insert_stats(None, request("abc"), (1, 2)); cache.clear(); assert!(cache.cached_diff(None, &request("abc"), None).is_none()); assert!( @@ -249,5 +302,6 @@ mod tests { .cached_file_text(None, &revision("abc"), "src/lib.rs") .is_none() ); + assert!(cache.cached_stats(None, &request("abc")).is_none()); } } diff --git a/src/core/vcs/git/adapter.rs b/src/core/vcs/git/adapter.rs index b14d0a8e..765be862 100644 --- a/src/core/vcs/git/adapter.rs +++ b/src/core/vcs/git/adapter.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; use carbon::TextStore; @@ -9,6 +10,8 @@ use crate::core::compare::{ }; use crate::core::error::{DiffyError, Result, VcsBackendKind}; use crate::core::vcs::backend::{VcsBackend, VcsRepository, VcsWatchPaths}; +use crate::core::vcs::cache::VcsReadCache; +use crate::core::vcs::git::service::is_full_hex_oid; use crate::core::vcs::git::status::StatusBits; use crate::core::vcs::git::{ BranchInfo, CommitInfo, GitService, PatchApplyTarget, PullOutcome, StatusItem, StatusOperation, @@ -74,6 +77,32 @@ impl VcsBackend for GitBackend { } } +/// Process-wide cache for Git reads whose results are immutable: compares +/// and file reads addressed entirely by full commit OIDs. Such results are +/// content-addressed by the object store and can never go stale — not even +/// across writes, fetches, or ref updates — so no invalidation is needed and +/// it is safe to share across the short-lived `GitRepository` instances the +/// runtime opens per operation. The cache epoch slot carries the workspace +/// root so entries never leak across repositories. Anything involving +/// movable refs, the index, or the working tree is deliberately not cached +/// (see the staleness note in `VcsReadCache`). +static IMMUTABLE_READ_CACHE: LazyLock> = + LazyLock::new(|| Mutex::new(VcsReadCache::new())); + +/// True when every revision in the request is a full hex OID, making the +/// compare result content-addressed: parents, trees, and merge bases of +/// fixed commits are themselves fixed. +fn compare_request_is_immutable(request: &VcsCompareRequest) -> bool { + match &request.spec { + VcsCompareSpec::WorkingCopy => false, + VcsCompareSpec::Change { revision } => is_full_hex_oid(revision), + VcsCompareSpec::Range { from, to } => is_full_hex_oid(from) && is_full_hex_oid(to), + VcsCompareSpec::MergeBaseRange { base, head } => { + is_full_hex_oid(base) && is_full_hex_oid(head) + } + } +} + pub struct GitRepository { service: GitService, location: RepoLocation, @@ -85,6 +114,75 @@ impl GitRepository { service.open(location.workspace_root.to_string_lossy().as_ref())?; Ok(Self { service, location }) } + + /// Epoch for [`IMMUTABLE_READ_CACHE`]: scopes entries to this repository. + fn immutable_cache_epoch(&self) -> String { + self.location.workspace_root.to_string_lossy().into_owned() + } + + fn compare_path_uncached( + &mut self, + request: &VcsCompareRequest, + path: &str, + deferred_file: Option<&CompareFileSummary>, + ) -> Result { + let spec = git_compare_spec(request); + let deferred_file = deferred_file.map(CompareFileSummary::to_file_diff); + let summary_fallback = deferred_file.is_some(); + match request.renderer { + crate::core::compare::RendererKind::Builtin => { + let output = deferred_file + .as_ref() + .map(|file| GitDiffBackend.compare_deferred_file(file, &self.service)) + .transpose()? + .flatten(); + match output { + Some(output) => Ok(output), + None if summary_fallback => GitDiffBackend + .compare_path_no_renames(&spec, path, &self.service)? + .ok_or_else(|| { + DiffyError::General("compare file returned no result".to_owned()) + }), + None => GitDiffBackend + .compare_path(&spec, path, &self.service)? + .ok_or_else(|| { + DiffyError::General("compare file returned no result".to_owned()) + }), + } + } + crate::core::compare::RendererKind::Difftastic if DifftasticBackend::is_available() => { + DifftasticBackend + .compare_path(&spec, path, &self.service)? + .ok_or_else(|| { + DiffyError::General("compare file returned no result".to_owned()) + }) + } + crate::core::compare::RendererKind::Difftastic => { + let output = deferred_file + .as_ref() + .map(|file| GitDiffBackend.compare_deferred_file(file, &self.service)) + .transpose()? + .flatten(); + match output { + Some(output) => Ok(output), + None => { + let path_output = if summary_fallback { + GitDiffBackend.compare_path_no_renames(&spec, path, &self.service)? + } else { + GitDiffBackend.compare_path(&spec, path, &self.service)? + }; + let mut output = path_output.ok_or_else(|| { + DiffyError::General("compare file returned no result".to_owned()) + })?; + output.used_fallback = true; + output.fallback_message = + "difftastic not compiled in, used built-in backend".to_owned(); + Ok(output) + } + } + } + } + } } impl VcsRepository for GitRepository { @@ -110,13 +208,7 @@ impl VcsRepository for GitRepository { .service .abbreviate_oid(&oid) .unwrap_or_else(|_| oid[..7].to_owned()); - let summary = self - .service - .commits(&oid, 1) - .ok() - .and_then(|mut commits| commits.pop()) - .map(|commit| commit.summary) - .unwrap_or_default(); + let summary = self.service.commit_summary(&oid).unwrap_or_default(); Ok((short_oid, summary)) } @@ -128,8 +220,7 @@ impl VcsRepository for GitRepository { if let Some(reporter) = reporter { reporter.phase(ComparePhase::ResolvingRefs); } - let branches = self.service.branches()?; - let tags = self.service.tags()?; + let (branches, tags) = self.service.branches_and_tags()?; if let Some(reporter) = reporter { reporter.phase(ComparePhase::FetchingHistory); } @@ -157,15 +248,39 @@ impl VcsRepository for GitRepository { request: &VcsCompareRequest, reporter: Option<&dyn ProgressSink>, ) -> Result { + let cacheable = compare_request_is_immutable(request); + let epoch = self.immutable_cache_epoch(); + if cacheable + && let Ok(cache) = IMMUTABLE_READ_CACHE.lock() + && let Some(output) = cache.cached_diff(Some(&epoch), request, None) + { + return Ok(output); + } let spec = git_compare_spec(request); - CompareService::default().compare(&spec, &self.service, reporter) + let output = CompareService::default().compare(&spec, &self.service, reporter)?; + if cacheable && let Ok(mut cache) = IMMUTABLE_READ_CACHE.lock() { + cache.insert_diff(Some(epoch), request.clone(), None, output.clone()); + } + Ok(output) } fn compare_stats(&mut self, request: &VcsCompareRequest) -> Result<(i32, i32)> { + let cacheable = compare_request_is_immutable(request); + let epoch = self.immutable_cache_epoch(); + if cacheable + && let Ok(cache) = IMMUTABLE_READ_CACHE.lock() + && let Some(stats) = cache.cached_stats(Some(&epoch), request) + { + return Ok(stats); + } let spec = git_compare_spec(request); - GitDiffBackend + let stats = GitDiffBackend .compare_stats(&spec, &self.service)? - .ok_or_else(|| DiffyError::General("compare stats returned no result".to_owned())) + .ok_or_else(|| DiffyError::General("compare stats returned no result".to_owned()))?; + if cacheable && let Ok(mut cache) = IMMUTABLE_READ_CACHE.lock() { + cache.insert_stats(Some(epoch), request.clone(), stats); + } + Ok(stats) } fn compare_history( @@ -202,62 +317,27 @@ impl VcsRepository for GitRepository { path: &str, deferred_file: Option<&CompareFileSummary>, ) -> Result { - let spec = git_compare_spec(request); - let deferred_file = deferred_file.map(CompareFileSummary::to_file_diff); - let summary_fallback = deferred_file.is_some(); - match request.renderer { - crate::core::compare::RendererKind::Builtin => { - let output = deferred_file - .as_ref() - .map(|file| GitDiffBackend.compare_deferred_file(file, &self.service)) - .transpose()? - .flatten(); - match output { - Some(output) => Ok(output), - None if summary_fallback => GitDiffBackend - .compare_path_no_renames(&spec, path, &self.service)? - .ok_or_else(|| { - DiffyError::General("compare file returned no result".to_owned()) - }), - None => GitDiffBackend - .compare_path(&spec, path, &self.service)? - .ok_or_else(|| { - DiffyError::General("compare file returned no result".to_owned()) - }), - } - } - crate::core::compare::RendererKind::Difftastic if DifftasticBackend::is_available() => { - DifftasticBackend - .compare_path(&spec, path, &self.service)? - .ok_or_else(|| { - DiffyError::General("compare file returned no result".to_owned()) - }) - } - crate::core::compare::RendererKind::Difftastic => { - let output = deferred_file - .as_ref() - .map(|file| GitDiffBackend.compare_deferred_file(file, &self.service)) - .transpose()? - .flatten(); - match output { - Some(output) => Ok(output), - None => { - let path_output = if summary_fallback { - GitDiffBackend.compare_path_no_renames(&spec, path, &self.service)? - } else { - GitDiffBackend.compare_path(&spec, path, &self.service)? - }; - let mut output = path_output.ok_or_else(|| { - DiffyError::General("compare file returned no result".to_owned()) - })?; - output.used_fallback = true; - output.fallback_message = - "difftastic not compiled in, used built-in backend".to_owned(); - Ok(output) - } - } - } + // Only the summary-less path shells out to `git diff`; deferred-file + // compares already run in-process on gix blobs, and their rename + // handling differs, so they are not folded into the same cache key. + let cacheable = deferred_file.is_none() && compare_request_is_immutable(request); + let epoch = self.immutable_cache_epoch(); + if cacheable + && let Ok(cache) = IMMUTABLE_READ_CACHE.lock() + && let Some(output) = cache.cached_diff(Some(&epoch), request, Some(path)) + { + return Ok(output); } + let output = self.compare_path_uncached(request, path, deferred_file)?; + if cacheable && let Ok(mut cache) = IMMUTABLE_READ_CACHE.lock() { + cache.insert_diff( + Some(epoch), + request.clone(), + Some(path.to_owned()), + output.clone(), + ); + } + Ok(output) } fn file_change_diff( @@ -410,7 +490,21 @@ impl VcsRepository for GitRepository { } fn read_file_text(&mut self, revision: &RevisionId, path: &str) -> Result { - self.service.read_file_text_store_at(&revision.id, path) + // Blob content at a fixed commit OID is immutable; workdir, index, + // and symbolic refs are not and bypass the cache. + let cacheable = is_full_hex_oid(&revision.id); + let epoch = self.immutable_cache_epoch(); + if cacheable + && let Ok(cache) = IMMUTABLE_READ_CACHE.lock() + && let Some(text) = cache.cached_file_text(Some(&epoch), revision, path) + { + return Ok(text); + } + let text = self.service.read_file_text_store_at(&revision.id, path)?; + if cacheable && let Ok(mut cache) = IMMUTABLE_READ_CACHE.lock() { + cache.insert_file_text(Some(epoch), revision.clone(), path.to_owned(), text.clone()); + } + Ok(text) } } diff --git a/src/core/vcs/git/service.rs b/src/core/vcs/git/service.rs index 6b3b2fab..1bf817c0 100644 --- a/src/core/vcs/git/service.rs +++ b/src/core/vcs/git/service.rs @@ -482,41 +482,68 @@ impl GitService { } pub fn branches(&self) -> Result> { + Ok(self.branches_and_tags()?.0) + } + + pub fn tags(&self) -> Result> { + Ok(self.branches_and_tags()?.1) + } + + /// List branches and tags with a single `for-each-ref` invocation. The + /// snapshot path needs both, and one subprocess per refresh is cheaper + /// than two on repositories with many refs. + pub fn branches_and_tags(&self) -> Result<(Vec, Vec)> { let output = run_system_git_capture( self.repo_path_ref()?, &[ OsString::from("for-each-ref"), OsString::from( - "--format=%(refname)%00%(refname:short)%00%(objectname)%00%(upstream:short)%00%(upstream:track)%00%(HEAD)", + "--format=%(refname)%00%(refname:short)%00%(objectname)%00%(*objectname)%00%(upstream:short)%00%(upstream:track)%00%(HEAD)", ), OsString::from("refs/heads"), OsString::from("refs/remotes"), + OsString::from("refs/tags"), ], )?; let mut branches = Vec::new(); + let mut tags = Vec::new(); for line in output.stdout.split(|byte| *byte == b'\n') { if line.is_empty() { continue; } let fields = line.split(|byte| *byte == 0).collect::>(); - if fields.len() < 6 { + if fields.len() < 7 { continue; } let full_name = String::from_utf8_lossy(fields[0]); let name = String::from_utf8_lossy(fields[1]).to_string(); - let target_oid = String::from_utf8_lossy(fields[2]).to_string(); + if full_name.starts_with("refs/tags/") { + // Annotated tags carry the peeled commit in `%(*objectname)`; + // lightweight tags only fill `%(objectname)`. + let peeled = if fields[3].is_empty() { + fields[2] + } else { + fields[3] + }; + tags.push(TagInfo { + name, + target_oid: String::from_utf8_lossy(peeled).to_string(), + }); + continue; + } if name.ends_with("/HEAD") { continue; } let is_remote = full_name.starts_with("refs/remotes/"); + let target_oid = String::from_utf8_lossy(fields[2]).to_string(); let upstream = - (!fields[3].is_empty()).then(|| String::from_utf8_lossy(fields[3]).to_string()); + (!fields[4].is_empty()).then(|| String::from_utf8_lossy(fields[4]).to_string()); let ahead_behind = if is_remote { None } else { - parse_upstream_track(upstream.as_ref(), &String::from_utf8_lossy(fields[4])) + parse_upstream_track(upstream.as_ref(), &String::from_utf8_lossy(fields[5])) }; - let is_head = !is_remote && fields[5] == b"*"; + let is_head = !is_remote && fields[6] == b"*"; branches.push(BranchInfo { name, is_remote, @@ -533,42 +560,8 @@ impl GitService { }, other => other, }); - Ok(branches) - } - - pub fn tags(&self) -> Result> { - let output = run_system_git_capture( - self.repo_path_ref()?, - &[ - OsString::from("for-each-ref"), - OsString::from("--format=%(refname:short)%00%(*objectname)%00%(objectname)"), - OsString::from("refs/tags"), - ], - )?; - let mut tags = output - .stdout - .split(|byte| *byte == b'\n') - .filter_map(|line| { - if line.is_empty() { - return None; - } - let fields = line.split(|byte| *byte == 0).collect::>(); - if fields.len() < 3 { - return None; - } - let peeled = if fields[1].is_empty() { - fields[2] - } else { - fields[1] - }; - Some(TagInfo { - name: String::from_utf8_lossy(fields[0]).to_string(), - target_oid: String::from_utf8_lossy(peeled).to_string(), - }) - }) - .collect::>(); tags.sort_by(|left, right| left.name.cmp(&right.name)); - Ok(tags) + Ok((branches, tags)) } pub fn commits(&self, reference: &str, max_count: usize) -> Result> { @@ -1378,6 +1371,17 @@ impl GitService { Ok(id) } + /// Subject line of the commit at `oid`, equivalent to + /// `git log -n1 --format=%s` (whitespace in the title folded to single + /// spaces) but answered from the gix object store without a subprocess. + pub fn commit_summary(&self, oid: &str) -> Result { + let commit = self + .repo()? + .find_commit(gix_object_id(oid)?) + .map_err(gix_error)?; + Ok(commit.message().map_err(gix_error)?.summary().to_string()) + } + pub fn repo(&self) -> Result<&gix::Repository> { self.repo .as_ref() @@ -1839,7 +1843,7 @@ fn fixed_short_oid(oid: &str) -> &str { oid.get(..8).unwrap_or(oid) } -fn is_full_hex_oid(value: &str) -> bool { +pub(crate) fn is_full_hex_oid(value: &str) -> bool { value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) } From e7fc1a478d3ca9b750425405f7318384f8f316ed Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 22:35:54 +0000 Subject: [PATCH 16/25] refactor(ui): adopt #[derive(Store)] for app state Fold the 16 loose hand-created signals on AppState into derived stores: - New UiState (#[derive(Store)]) in src/ui/state/ui.rs holds the app-chrome leaves: app_view, settings_section, keymap_capture, keymaps_* scroll metrics, focus, last_error, toasts, syntax_pack_installs, update, sidebar_visible, and theme_preview_original. AppState now carries a single ui: UiStateStore field instead of 13 Signal fields plus the duplicated store.create(...) boilerplate in Default and bootstrap. - workspace_mode and compare_progress move into the existing derived WorkspaceState as workspace.mode / workspace.compare_progress. - Call sites updated mechanically (state.focus -> state.ui.focus etc.); read/write style is unchanged because derived stores expose the same Signal handles. - text_focused stays a hand-created memo (derive cannot express memos), now built from ui.focus after the store is created. UpdateState remains a single enum-valued signal (enums are not derivable) and plain non-reactive fields (text_compare, ai_*, startup) are intentionally left alone. --- src/input/keyboard.rs | 46 ++++++------- src/input/mod.rs | 7 +- src/input/pointer.rs | 1 + src/ui/app.rs | 66 +++++++++++-------- src/ui/harness.rs | 4 +- src/ui/overlays/auth.rs | 2 +- src/ui/overlays/picker.rs | 2 +- src/ui/settings_page.rs | 13 ++-- src/ui/shell.rs | 18 ++--- src/ui/state/ai.rs | 2 +- src/ui/state/app.rs | 4 +- src/ui/state/compare.rs | 30 +++++---- src/ui/state/editor.rs | 10 +-- src/ui/state/file_list.rs | 6 +- src/ui/state/mod.rs | 123 ++++++++++------------------------- src/ui/state/overlay.rs | 20 +++--- src/ui/state/repository.rs | 35 +++++----- src/ui/state/settings.rs | 45 +++++++------ src/ui/state/syntax.rs | 12 ++-- src/ui/state/tests.rs | 97 ++++++++++++++++++--------- src/ui/state/text_compare.rs | 10 +-- src/ui/state/text_edit.rs | 25 +++---- src/ui/state/ui.rs | 83 ++++++++++++++++++----- src/ui/state/update.rs | 20 ++++-- src/ui/state/working_set.rs | 2 +- src/ui/state/workspace.rs | 6 +- src/ui/status_bar.rs | 1 + src/ui/title_bar.rs | 4 +- src/ui/toolbar.rs | 11 ++-- src/ui/window_chrome.rs | 2 +- 30 files changed, 397 insertions(+), 310 deletions(-) diff --git a/src/input/keyboard.rs b/src/input/keyboard.rs index e2598024..6f60d13a 100644 --- a/src/input/keyboard.rs +++ b/src/input/keyboard.rs @@ -145,7 +145,7 @@ fn global_shortcut_action(state: &AppState, chord: &KeyChord) -> Option } fn keymap_capture_actions(state: &AppState, chord: &KeyChord) -> Option> { - let command = state.keymap_capture.get(&state.store)?; + let command = state.ui.keymap_capture.get(&state.store)?; if chord.named() == Some(NamedKey::Escape) { return Some(vec![SettingsAction::CancelKeymapRebind.into()]); } @@ -552,11 +552,11 @@ fn workspace_key_actions_inner( Some(NamedKey::Escape) => { if state.overlays_top().is_some() { Some(vec![OverlayAction::CloseOverlay.into()]) - } else if state.app_view.get(&state.store) == AppView::Settings { + } else if state.ui.app_view.get(&state.store) == AppView::Settings { Some(vec![SettingsAction::CloseSettings.into()]) } else if state.editor.search.open.get(&state.store) { Some(vec![EditorAction::CloseSearch.into()]) - } else if state.focus.get(&state.store) == Some(FocusTarget::SidebarSearch) { + } else if state.ui.focus.get(&state.store) == Some(FocusTarget::SidebarSearch) { Some(vec![ FileListAction::ClearSidebarFilter.into(), AppAction::SetFocus(None).into(), @@ -579,7 +579,7 @@ fn workspace_key_actions_inner( } Some(NamedKey::Tab) => Some(vec![AppAction::SetFocus(cycle_focus_target(state)).into()]), Some(NamedKey::Enter) => { - if state.focus.get(&state.store) == Some(FocusTarget::SearchInput) { + if state.ui.focus.get(&state.store) == Some(FocusTarget::SearchInput) { Some(vec![if chord.shift() { EditorAction::SearchPrevious.into() } else { @@ -590,11 +590,11 @@ fn workspace_key_actions_inner( } } Some(NamedKey::ArrowDown) => { - if state.app_view.get(&state.store) == AppView::Settings { + if state.ui.app_view.get(&state.store) == AppView::Settings { Some(vec![ SettingsAction::SetSettingsSection(adjacent_settings_section(state, 1)).into(), ]) - } else if state.focus.get(&state.store) == Some(FocusTarget::Editor) { + } else if state.ui.focus.get(&state.store) == Some(FocusTarget::Editor) { Some(vec![EditorAction::ScrollViewportLines(1).into()]) } else if state.is_workspace_ready() { Some(vec![FileListAction::SelectNextFile.into()]) @@ -603,11 +603,11 @@ fn workspace_key_actions_inner( } } Some(NamedKey::ArrowUp) => { - if state.app_view.get(&state.store) == AppView::Settings { + if state.ui.app_view.get(&state.store) == AppView::Settings { Some(vec![ SettingsAction::SetSettingsSection(adjacent_settings_section(state, -1)).into(), ]) - } else if state.focus.get(&state.store) == Some(FocusTarget::Editor) { + } else if state.ui.focus.get(&state.store) == Some(FocusTarget::Editor) { Some(vec![EditorAction::ScrollViewportLines(-1).into()]) } else if state.is_workspace_ready() { Some(vec![FileListAction::SelectPreviousFile.into()]) @@ -616,14 +616,14 @@ fn workspace_key_actions_inner( } } Some(NamedKey::PageDown) if state.is_workspace_ready() => { - if state.focus.get(&state.store) == Some(FocusTarget::Editor) { + if state.ui.focus.get(&state.store) == Some(FocusTarget::Editor) { Some(vec![EditorAction::ScrollViewportPages(1).into()]) } else { Some(vec![FileListAction::ScrollFileList(10).into()]) } } Some(NamedKey::PageUp) if state.is_workspace_ready() => { - if state.focus.get(&state.store) == Some(FocusTarget::Editor) { + if state.ui.focus.get(&state.store) == Some(FocusTarget::Editor) { Some(vec![EditorAction::ScrollViewportPages(-1).into()]) } else { Some(vec![FileListAction::ScrollFileList(-10).into()]) @@ -648,11 +648,11 @@ fn workspace_key_actions_inner( _ => { let ch = chord.logical_char()?; let binding = chord.binding_string()?; - if state.app_view.get(&state.store) == AppView::Settings { + if state.ui.app_view.get(&state.store) == AppView::Settings { return settings_key_actions(state, &binding); } if state.overlays_top().is_some() - || state.workspace_mode.get(&state.store) != WorkspaceMode::Ready + || state.workspace.mode.get(&state.store) != WorkspaceMode::Ready { return None; } @@ -676,11 +676,11 @@ fn workspace_key_actions_inner( } else if matches_binding(overrides, ShortcutCommand::PreviousFile, &binding) { Some(vec![EditorAction::GoToPreviousFile.into()]) } else if matches_binding(overrides, ShortcutCommand::MoveDown, &binding) - && state.focus.get(&state.store) == Some(FocusTarget::FileList) + && state.ui.focus.get(&state.store) == Some(FocusTarget::FileList) { Some(vec![FileListAction::SelectNextFile.into()]) } else if matches_binding(overrides, ShortcutCommand::MoveUp, &binding) - && state.focus.get(&state.store) == Some(FocusTarget::FileList) + && state.ui.focus.get(&state.store) == Some(FocusTarget::FileList) { Some(vec![FileListAction::SelectPreviousFile.into()]) } else if matches_binding(overrides, ShortcutCommand::MoveDown, &binding) { @@ -695,7 +695,7 @@ fn workspace_key_actions_inner( Some(vec![EditorAction::ScrollViewportHalfPage(1).into()]) } else if matches_binding(overrides, ShortcutCommand::Unstage, &binding) && state.workspace.source.get(&state.store) == WorkspaceSource::Status - && state.focus.get(&state.store) == Some(FocusTarget::FileList) + && state.ui.focus.get(&state.store) == Some(FocusTarget::FileList) { Some(vec![RepositoryAction::UnstageSelectedFile.into()]) } else if matches_binding(overrides, ShortcutCommand::ScrollHalfPageUp, &binding) { @@ -871,7 +871,7 @@ fn settings_key_actions(state: &AppState, binding: &str) -> Option> } fn adjacent_settings_section(state: &AppState, delta: i32) -> SettingsSection { - let current = state.settings_section.get(&state.store); + let current = state.ui.settings_section.get(&state.store); let sections = SettingsSection::ALL; let current_index = sections .iter() @@ -926,7 +926,7 @@ fn status_operation_actions( if state.workspace.source.get(&state.store) != WorkspaceSource::Status { return None; } - if state.focus.get(&state.store) == Some(FocusTarget::FileList) { + if state.ui.focus.get(&state.store) == Some(FocusTarget::FileList) { return Some(vec![file_action.into()]); } if state @@ -943,17 +943,17 @@ fn status_operation_actions( fn cycle_focus_target(state: &AppState) -> Option { match state.overlays_top() { Some(OverlaySurface::RepoPicker | OverlaySurface::RefPicker) => { - match state.focus.get(&state.store) { + match state.ui.focus.get(&state.store) { Some(FocusTarget::PickerInput) => Some(FocusTarget::PickerList), _ => Some(FocusTarget::PickerInput), } } - Some(OverlaySurface::CommandPalette) => match state.focus.get(&state.store) { + Some(OverlaySurface::CommandPalette) => match state.ui.focus.get(&state.store) { Some(FocusTarget::CommandPaletteInput) => Some(FocusTarget::CommandPaletteList), _ => Some(FocusTarget::CommandPaletteInput), }, Some(OverlaySurface::ThemePicker | OverlaySurface::FontPicker) => { - match state.focus.get(&state.store) { + match state.ui.focus.get(&state.store) { Some(FocusTarget::PickerInput) => Some(FocusTarget::PickerList), _ => Some(FocusTarget::PickerInput), } @@ -966,7 +966,7 @@ fn cycle_focus_target(state: &AppState) -> Option { | OverlaySurface::PublishMenu | OverlaySurface::Confirmation, ) => None, - None => match state.focus.get(&state.store) { + None => match state.ui.focus.get(&state.store) { Some(FocusTarget::FileList) => Some(FocusTarget::Editor), Some(FocusTarget::Editor) => Some(FocusTarget::FileList), Some(FocusTarget::WorkspacePrimaryButton) => Some(FocusTarget::TitleBar), @@ -1007,7 +1007,7 @@ fn activate_current_focus_actions(state: &AppState) -> Option> { | OverlaySurface::AccountMenu | OverlaySurface::PublishMenu, ) => Some(Vec::new()), - None => match state.focus.get(&state.store) { + None => match state.ui.focus.get(&state.store) { Some(FocusTarget::WorkspacePrimaryButton) => { Some(vec![OverlayAction::OpenRepoPicker.into()]) } @@ -1049,7 +1049,7 @@ mod tests { #[test] fn viewport_copy_shortcut_copies_current_text_selection() { let state = AppState::default(); - state.focus.set(&state.store, Some(FocusTarget::Editor)); + state.ui.focus.set(&state.store, Some(FocusTarget::Editor)); state.editor.text_selection.set( &state.store, Some(ViewportTextSelection { diff --git a/src/input/mod.rs b/src/input/mod.rs index f9807c21..7dde56d9 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -538,6 +538,7 @@ impl InputSystem { pub fn resolve_input_context(state: &AppState, ime_active: bool) -> InputContext { let owner = if let Some(target) = state + .ui .focus .get(&state.store) .filter(|_| state.is_text_focused()) @@ -545,7 +546,7 @@ pub fn resolve_input_context(state: &AppState, ime_active: bool) -> InputContext InputOwner::TextField(target) } else if let Some(overlay) = state.overlays_top() { InputOwner::Overlay(overlay) - } else if state.focus.get(&state.store) == Some(FocusTarget::Editor) { + } else if state.ui.focus.get(&state.store) == Some(FocusTarget::Editor) { InputOwner::Editor } else { InputOwner::Workspace @@ -553,8 +554,8 @@ pub fn resolve_input_context(state: &AppState, ime_active: bool) -> InputContext InputContext { owner, overlay: state.overlays_top(), - focus: state.focus.get(&state.store), - workspace_mode: state.workspace_mode.get(&state.store), + focus: state.ui.focus.get(&state.store), + workspace_mode: state.workspace.mode.get(&state.store), ime_active, } } diff --git a/src/input/pointer.rs b/src/input/pointer.rs index 6d4b8610..98dc3f19 100644 --- a/src/input/pointer.rs +++ b/src/input/pointer.rs @@ -610,6 +610,7 @@ impl InputSystem { actions.push(OverlayAction::HoverOverlayEntry(hovered_overlay_entry).into()); } let current_hovered_toast = state + .ui .toasts .with(&state.store, |toasts| toasts.iter().position(|t| t.hovered)); if hovered_toast != current_hovered_toast { diff --git a/src/ui/app.rs b/src/ui/app.rs index 4d74f2e3..b4e51e19 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -636,7 +636,7 @@ impl NativeApp { let update = self .ui_frame .accessibility - .tree_update(self.state.focus.get(&self.state.store)); + .tree_update(self.state.ui.focus.get(&self.state.store)); if let Ok(mut latest) = self.accessibility_latest_tree.lock() { *latest = update.clone(); } @@ -803,7 +803,7 @@ impl NativeApp { &store, ) .with_text_measure_cache(&mut self.text_measure_cache) - .with_focus(store.read(self.state.focus)) + .with_focus(store.read(self.state.ui.focus)) .with_clock(self.state.clock_ms); cx.debug_wireframe = std::env::var("DIFFY_DEBUG_WIREFRAME").is_ok(); @@ -1164,6 +1164,7 @@ impl ApplicationHandler for NativeApp { Err(error) => { eprintln!("render failed: {error}"); self.state + .ui .last_error .set(&self.state.store, Some(error.to_string())); } @@ -1243,6 +1244,7 @@ impl ApplicationHandler for NativeApp { .map(|ms| self.launch_at + std::time::Duration::from_millis(ms)); let next_compare_progress_reveal = self.state + .workspace .compare_progress .with(&self.state.store, |progress| { progress.as_ref().and_then(|progress| { @@ -1450,7 +1452,7 @@ mod tests { #[test] fn file_list_scroll_region_wins_over_viewport_fallback() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state.workspace.files.set( &state.store, (0..32) @@ -1501,7 +1503,7 @@ mod tests { #[test] fn file_list_wheel_scroll_moves_sidebar_contents() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state.workspace.files.set( &state.store, (0..32) @@ -1550,7 +1552,7 @@ mod tests { #[test] fn overlay_blocks_viewport_scroll_fallback() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state.workspace.files.set( &state.store, vec![FileListEntry { @@ -1656,7 +1658,7 @@ mod tests { #[test] fn clicking_file_row_selects_exact_file() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state .workspace .source @@ -1719,7 +1721,7 @@ mod tests { Some("src/ui/state/text_edit.rs".to_owned()) ); assert_eq!( - app.state.focus.get(&app.state.store), + app.state.ui.focus.get(&app.state.store), Some(FocusTarget::FileList) ); } @@ -1728,7 +1730,7 @@ mod tests { fn clicking_continuous_file_header_selects_exact_file() { let mut state = AppState::default(); state.settings.continuous_scroll = true; - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state .workspace .source @@ -1783,7 +1785,7 @@ mod tests { Some("src/ui/state/text_edit.rs".to_owned()) ); assert_eq!( - app.state.focus.get(&app.state.store), + app.state.ui.focus.get(&app.state.store), Some(FocusTarget::Editor) ); @@ -1807,7 +1809,7 @@ mod tests { assert!(!app.state.editor.search.open.get(&app.state.store)); assert_eq!( - app.state.focus.get(&app.state.store), + app.state.ui.focus.get(&app.state.store), Some(FocusTarget::Editor) ); } @@ -1817,9 +1819,12 @@ mod tests { let mut app = test_app(AppState::default()); dispatch_input_event(&mut app, keypress("?", ModifiersState::empty())); - assert_eq!(app.state.app_view.get(&app.state.store), AppView::Settings); assert_eq!( - app.state.settings_section.get(&app.state.store), + app.state.ui.app_view.get(&app.state.store), + AppView::Settings + ); + assert_eq!( + app.state.ui.settings_section.get(&app.state.store), SettingsSection::Keymaps ); } @@ -1848,7 +1853,7 @@ mod tests { fn sidebar_tab_keys_switch_files_and_commits() { let repo_dir = TempDir::new().unwrap(); let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state .workspace .source @@ -2044,8 +2049,8 @@ mod tests { #[test] fn row_cursor_keys_move_visible_editor_cursor() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - state.focus.set(&state.store, Some(FocusTarget::Editor)); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); + state.ui.focus.set(&state.store, Some(FocusTarget::Editor)); state.editor.visible_row_start.set(&state.store, Some(4)); state.editor.visible_row_end.set(&state.store, Some(8)); let mut app = test_app(state); @@ -2063,12 +2068,12 @@ mod tests { #[test] fn line_selection_keys_dispatch_current_line_actions() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state .workspace .source .set(&state.store, crate::ui::state::WorkspaceSource::Status); - state.focus.set(&state.store, Some(FocusTarget::Editor)); + state.ui.focus.set(&state.store, Some(FocusTarget::Editor)); let mut app = test_app(state); let toggle = route_input_event(&mut app, keypress("v", ModifiersState::empty())); @@ -2088,6 +2093,7 @@ mod tests { fn review_comment_editor_keyboard_submit_and_cancel() { let state = AppState::default(); state + .ui .focus .set(&state.store, Some(FocusTarget::ReviewCommentEditor)); let mut app = test_app(state); @@ -2114,18 +2120,18 @@ mod tests { #[test] fn settings_number_and_navigation_keys_switch_sections() { let state = AppState::default(); - state.app_view.set(&state.store, AppView::Settings); + state.ui.app_view.set(&state.store, AppView::Settings); let mut app = test_app(state); dispatch_input_event(&mut app, keypress("3", ModifiersState::empty())); assert_eq!( - app.state.settings_section.get(&app.state.store), + app.state.ui.settings_section.get(&app.state.store), SettingsSection::Behavior ); dispatch_input_event(&mut app, keypress("j", ModifiersState::empty())); assert_eq!( - app.state.settings_section.get(&app.state.store), + app.state.ui.settings_section.get(&app.state.store), SettingsSection::Keymaps ); @@ -2134,7 +2140,7 @@ mod tests { named_keypress(NamedKey::ArrowUp, ModifiersState::empty()), ); assert_eq!( - app.state.settings_section.get(&app.state.store), + app.state.ui.settings_section.get(&app.state.store), SettingsSection::Behavior ); } @@ -2142,7 +2148,7 @@ mod tests { #[test] fn settings_control_keys_dispatch_existing_actions() { let state = AppState::default(); - state.app_view.set(&state.store, AppView::Settings); + state.ui.app_view.set(&state.store, AppView::Settings); let mut app = test_app(state); dispatch_input_event(&mut app, keypress("w", ModifiersState::empty())); @@ -2161,11 +2167,12 @@ mod tests { #[test] fn keymap_rebind_overrides_default_shortcut() { let state = AppState::default(); - state.app_view.set(&state.store, AppView::Settings); + state.ui.app_view.set(&state.store, AppView::Settings); state + .ui .settings_section .set(&state.store, SettingsSection::Keymaps); - state.keymap_capture.set( + state.ui.keymap_capture.set( &state.store, Some(crate::input::ShortcutCommand::ToggleWrap), ); @@ -2184,19 +2191,22 @@ mod tests { #[test] fn vim_focus_keys_switch_file_list_and_editor_focus() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - state.focus.set(&state.store, Some(FocusTarget::FileList)); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); + state + .ui + .focus + .set(&state.store, Some(FocusTarget::FileList)); let mut app = test_app(state); dispatch_input_event(&mut app, keypress("l", ModifiersState::empty())); assert_eq!( - app.state.focus.get(&app.state.store), + app.state.ui.focus.get(&app.state.store), Some(FocusTarget::Editor) ); dispatch_input_event(&mut app, keypress("h", ModifiersState::empty())); assert_eq!( - app.state.focus.get(&app.state.store), + app.state.ui.focus.get(&app.state.store), Some(FocusTarget::FileList) ); } diff --git a/src/ui/harness.rs b/src/ui/harness.rs index 25946d52..512b118c 100644 --- a/src/ui/harness.rs +++ b/src/ui/harness.rs @@ -404,6 +404,7 @@ pub fn render_review_composer(width: f32, scale: f32, preview: bool) -> Rendered }, ); state + .ui .focus .set(&state.store, Some(FocusTarget::ReviewCommentEditor)); @@ -1261,7 +1262,7 @@ mod tests { // The card mousedown emitted FocusViewport (applied by the harness), so // input owner resolves to Editor and Cmd+C takes the card-copy branch. assert_eq!( - harness.state.focus.get(&harness.state.store), + harness.state.ui.focus.get(&harness.state.store), Some(FocusTarget::Editor), "card mousedown must focus the viewport/editor" ); @@ -1326,6 +1327,7 @@ mod tests { }, ); state + .ui .focus .set(&state.store, Some(FocusTarget::ReviewCommentEditor)); let small = theme.metrics.ui_small_font_size; diff --git a/src/ui/overlays/auth.rs b/src/ui/overlays/auth.rs index 481925cc..3bdf42c9 100644 --- a/src/ui/overlays/auth.rs +++ b/src/ui/overlays/auth.rs @@ -20,7 +20,7 @@ pub fn auth_modal( let token_present = state.github.auth.token_present.get(&state.store); let device_flow = state.github.auth.device_flow.get(&state.store); let status = state.github.auth.status.get(&state.store); - let last_error = state.last_error.get(&state.store); + let last_error = state.ui.last_error.get(&state.store); let phase = if token_present { AuthPhase::Success diff --git a/src/ui/overlays/picker.rs b/src/ui/overlays/picker.rs index e076ce07..c98d545c 100644 --- a/src/ui/overlays/picker.rs +++ b/src/ui/overlays/picker.rs @@ -107,7 +107,7 @@ pub fn picker_with_header(
{text_input("", query) .placeholder(placeholder) - .focused(state.focus.get(&state.store) == Some(focus_target)) + .focused(state.ui.focus.get(&state.store) == Some(focus_target)) .on_click( crate::actions::AppAction::SetFocus(Some(focus_target)).into(), ) diff --git a/src/ui/settings_page.rs b/src/ui/settings_page.rs index 96a12ba9..802de248 100644 --- a/src/ui/settings_page.rs +++ b/src/ui/settings_page.rs @@ -24,7 +24,7 @@ use crate::ui::theme::{Color, Theme, ThemeMode}; pub fn settings_page(state: &AppState, theme: &Theme) -> AnyElement { let tc = &theme.colors; - let active = state.settings_section.get(&state.store); + let active = state.ui.settings_section.get(&state.store); let nav = nav_panel(state, theme, active); let content = section_content(state, theme, active); @@ -166,9 +166,9 @@ fn keymaps_layout(state: &AppState, theme: &Theme) -> AnyElement { let tc = &theme.colors; let scale = theme.metrics.ui_scale(); let inner_max_w = (Sz::SETTINGS_KEYMAPS_MAX_W * scale).round(); - let capture = state.keymap_capture.get(&state.store); - let scroll_px = state.keymaps_scroll_top_px.get(&state.store); - let total_h = state.keymaps_content_height_px.get(&state.store); + let capture = state.ui.keymap_capture.get(&state.store); + let scroll_px = state.ui.keymaps_scroll_top_px.get(&state.store); + let total_h = state.ui.keymaps_content_height_px.get(&state.store); let groups: Vec = shortcut_groups() .iter() @@ -732,14 +732,17 @@ fn clankers_section(state: &AppState, theme: &Theme) -> AnyElement { let scale = theme.metrics.ui_scale(); let openai_focused = state + .ui .focus .get(&state.store) .is_some_and(|t| t == FocusTarget::SettingsOpenAiKey); let anthropic_focused = state + .ui .focus .get(&state.store) .is_some_and(|t| t == FocusTarget::SettingsAnthropicKey); let prompt_focused = state + .ui .focus .get(&state.store) .is_some_and(|t| t == FocusTarget::SettingsSteeringPrompt); @@ -937,7 +940,7 @@ fn about_section(state: &AppState, theme: &Theme) -> AnyElement { let tc = &theme.colors; let scale = theme.metrics.ui_scale(); let version = crate::APP_VERSION; - let update_state = state.update.get(&state.store); + let update_state = state.ui.update.get(&state.store); let auto_update_toggle = toggle(state.settings.auto_update) .on_toggle(crate::actions::SettingsAction::ToggleAutoUpdate.into()) .into_any(); diff --git a/src/ui/shell.rs b/src/ui/shell.rs index 4cd4de6c..fc9452b6 100644 --- a/src/ui/shell.rs +++ b/src/ui/shell.rs @@ -140,13 +140,15 @@ pub fn build_ui_frame( - Sp::LG * ui_scale) .max(0.0); state + .ui .keymaps_viewport_height_px .set(&state.store, keymaps_viewport_h); state + .ui .keymaps_content_height_px .set(&state.store, settings_page::keymaps_content_height(theme)); state.clamp_keymaps_scroll(); - let sidebar_width_factor = if state.sidebar_visible.get(&state.store) { + let sidebar_width_factor = if state.ui.sidebar_visible.get(&state.store) { 1.0 } else { 0.0 @@ -154,14 +156,14 @@ pub fn build_ui_frame( let sidebar_width = sidebar_mod::preferred_sidebar_width(state, theme, cx, width) * sidebar_width_factor; - let in_settings = state.app_view.get(&state.store) == AppView::Settings; + let in_settings = state.ui.app_view.get(&state.store) == AppView::Settings; // Once the reveal delay has elapsed we want the skeleton to take the // sidebar slot even if `workspace_mode` is still Ready — a re-compare // keeps the old file list around as scaffolding during the grace // window, but after the grace window we're committed to showing the // loading view, so blow the old sidebar away. - let progress_visible = state.compare_progress.with(&state.store, |p| { + let progress_visible = state.workspace.compare_progress.with(&state.store, |p| { p.as_ref().is_some_and(|p| state.clock_ms >= p.reveal_at_ms) }); let text_compare_source = @@ -240,7 +242,7 @@ pub fn build_ui_frame( root = root.child(edges); } - let toast_stack = state.toasts.with(&state.store, |toasts| { + let toast_stack = state.ui.toasts.with(&state.store, |toasts| { if toasts.is_empty() { None } else { @@ -1312,8 +1314,8 @@ fn build_review_add_button(theme: &Theme, ui_scale: f32, rect: Rect, strong: boo /// from `state.review_comment_editor`). Caller sizes it (`.flex_1()`) and converts. fn composer_text_editor(state: &AppState, theme: &Theme) -> TextEditorElement { let tc = &theme.colors; - let focused = - state.focus.get(&state.store) == Some(crate::ui::state::FocusTarget::ReviewCommentEditor); + let focused = state.ui.focus.get(&state.store) + == Some(crate::ui::state::FocusTarget::ReviewCommentEditor); text_editor_element() .placeholder("Leave a review comment") .editor_snapshot(&state.review_comment_editor) @@ -1419,8 +1421,8 @@ fn composer_editor_box( body_height: Option, ) -> AnyElement { let tc = &theme.colors; - let focused = - state.focus.get(&state.store) == Some(crate::ui::state::FocusTarget::ReviewCommentEditor); + let focused = state.ui.focus.get(&state.store) + == Some(crate::ui::state::FocusTarget::ReviewCommentEditor); let group_border = if focused { tc.accent } else { diff --git a/src/ui/state/ai.rs b/src/ui/state/ai.rs index 3cc6371e..24e7dc2e 100644 --- a/src/ui/state/ai.rs +++ b/src/ui/state/ai.rs @@ -111,7 +111,7 @@ impl AppState { }; if editing { self.set_focus(Some(target)); - } else if self.focus.get(&self.store) == Some(target) { + } else if self.ui.focus.get(&self.store) == Some(target) { self.set_focus(None); } Vec::new() diff --git a/src/ui/state/app.rs b/src/ui/state/app.rs index f34729a1..7dfcf011 100644 --- a/src/ui/state/app.rs +++ b/src/ui/state/app.rs @@ -42,7 +42,7 @@ impl AppState { Vec::new() } AppAction::DismissToast(index) => { - self.toasts.update(&self.store, |toasts| { + self.ui.toasts.update(&self.store, |toasts| { if index < toasts.len() { toasts.remove(index); } @@ -52,7 +52,7 @@ impl AppState { AppAction::HoverToast(index) => { let mut was_any_hovered = false; let mut is_any_hovered = false; - self.toasts.update(&self.store, |toasts| { + self.ui.toasts.update(&self.store, |toasts| { was_any_hovered = toasts.iter().any(|t| t.hovered); let hovered_id = index.and_then(|i| toasts.get(i)).map(|t| t.id); for toast in toasts.iter_mut() { diff --git a/src/ui/state/compare.rs b/src/ui/state/compare.rs index 88eb784b..5fd76719 100644 --- a/src/ui/state/compare.rs +++ b/src/ui/state/compare.rs @@ -26,8 +26,8 @@ pub(super) fn reduce_event(state: &mut AppState, event: CompareEvent) -> Vec Vec Vec { - if self.compare_progress.with(&self.store, |p| p.is_none()) { + if self + .workspace + .compare_progress + .with(&self.store, |p| p.is_none()) + { return Vec::new(); } let next_gen = self @@ -1660,13 +1664,13 @@ impl AppState { .saturating_add(1); self.workspace.compare_generation.set(&self.store, next_gen); let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); - self.compare_progress.set(&self.store, None); + self.workspace.compare_progress.set(&self.store, None); self.workspace.active_file_loading.set(&self.store, None); // Only revert the workspace mode if kickoff flipped it to Loading // (i.e. no prior state was preserved). When the user cancels a // re-compare, the old diff is still mounted and should stay visible. - if self.workspace_mode.get(&self.store) == WorkspaceMode::Loading { - self.workspace_mode.set(&self.store, WorkspaceMode::Empty); + if self.workspace.mode.get(&self.store) == WorkspaceMode::Loading { + self.workspace.mode.set(&self.store, WorkspaceMode::Empty); self.workspace.status.set(&self.store, AsyncStatus::Idle); } vec![syntax_epoch_effect] @@ -1675,7 +1679,7 @@ impl AppState { pub(super) fn handle_compare_progress_update(&mut self, generation: u64, phase: ComparePhase) { // Only apply when the progress slot matches the reporter's // generation — stale workers silently lose their updates. - self.compare_progress.update(&self.store, |slot| { + self.workspace.compare_progress.update(&self.store, |slot| { if let Some(p) = slot.as_mut() && p.generation == generation { diff --git a/src/ui/state/editor.rs b/src/ui/state/editor.rs index 7d8e13c8..3ffa43b7 100644 --- a/src/ui/state/editor.rs +++ b/src/ui/state/editor.rs @@ -127,7 +127,7 @@ impl AppState { Vec::new() } EditorClick(x, y) => { - match self.focus.get(&self.store) { + match self.ui.focus.get(&self.store) { Some(FocusTarget::SettingsSteeringPrompt) => { self.steering_prompt_editor.click(x, y); } @@ -147,7 +147,7 @@ impl AppState { Vec::new() } EditorDrag(x, y) => { - match self.focus.get(&self.store) { + match self.ui.focus.get(&self.store) { Some(FocusTarget::SettingsSteeringPrompt) => { self.steering_prompt_editor.drag(x, y); } @@ -167,7 +167,7 @@ impl AppState { Vec::new() } EditorScrollPx(delta) => { - match self.focus.get(&self.store) { + match self.ui.focus.get(&self.store) { Some(FocusTarget::SettingsSteeringPrompt) => { self.steering_prompt_editor.scroll(delta as f32); } @@ -843,7 +843,9 @@ impl AppState { self.text_edit .cursor_moved_at_ms .set(&self.store, self.clock_ms); - self.focus.set(&self.store, Some(FocusTarget::SearchInput)); + self.ui + .focus + .set(&self.store, Some(FocusTarget::SearchInput)); self.editor.focused.set(&self.store, false); self.recompute_search_matches(); } diff --git a/src/ui/state/file_list.rs b/src/ui/state/file_list.rs index 163c5979..7ab65c36 100644 --- a/src/ui/state/file_list.rs +++ b/src/ui/state/file_list.rs @@ -112,7 +112,7 @@ impl AppState { .collect() } ToggleSidebar => { - self.store.update(self.sidebar_visible, |v| *v = !*v); + self.store.update(self.ui.sidebar_visible, |v| *v = !*v); Vec::new() } ToggleSidebarMode => { @@ -817,7 +817,7 @@ impl AppState { .active_file .set(&self.store, Some(active_file.clone())); self.cache_active_file(active_file); - self.compare_progress.set(&self.store, None); + self.workspace.compare_progress.set(&self.store, None); self.editor_clear_document(); self.file_list.hovered_index.set(&self.store, Some(index)); if reveal { @@ -838,7 +838,7 @@ impl AppState { // If we're mid-compare (first file selection post-CompareFinished), // flip the phase so the progress panel reports "Preparing first // file…". Subsequent selections don't touch compare_progress. - self.compare_progress.update(&self.store, |slot| { + self.workspace.compare_progress.update(&self.store, |slot| { if let Some(p) = slot.as_mut() { Arc::make_mut(p).phase = ComparePhase::RenderingFirstFile; } diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index e7c2cf80..9219ed88 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -99,37 +99,24 @@ pub enum AsyncStatus { Failed, } -// Focus is stored directly as a Signal on AppState — no wrapper struct. +// App-chrome signals (focus, toasts, view routing, ...) live in the +// derived `UiStateStore` at `AppState::ui`. #[derive(Debug)] pub struct AppState { - pub workspace_mode: Signal, - /// Arc-wrapped so per-frame UI snapshots clone a pointer, not the - /// label strings inside. - pub compare_progress: Signal>>, - pub app_view: Signal, - pub settings_section: Signal, - pub keymap_capture: Signal>, - pub keymaps_scroll_top_px: Signal, - pub keymaps_viewport_height_px: Signal, - pub keymaps_content_height_px: Signal, + pub ui: UiStateStore, pub compare: CompareStateStore, pub repository: RepositoryStateStore, pub workspace: WorkspaceStateStore, pub file_list: FileListStateStore, pub overlays: OverlayStackStateStore, - pub focus: Signal>, pub text_edit: TextEditStateStore, pub editor: EditorStateStore, pub github: GitHubStateStore, pub settings: Settings, pub startup: StartupState, - pub last_error: Signal>, - pub toasts: Signal>, - pub syntax_pack_installs: Signal>, - pub update: Signal, pub context_menu: ContextMenuState, - /// Memoized: `true` when `focus` targets a text-editing field. + /// Memoized: `true` when `ui.focus` targets a text-editing field. pub text_focused: Signal, pub animation: crate::ui::animation::AnimationState, pub commit_editor: Editor, @@ -143,18 +130,16 @@ pub struct AppState { pub ai_generation_id: u64, pub ai_generation_active: bool, pub ai_generation_error: Option, - /// Shared reactive store. Signals (like `sidebar_visible`) are handles + /// Shared reactive store. Signals (like `ui.sidebar_visible`) are handles /// into this store. Kept in `AppState` so state methods (apply_action etc.) /// can freely read/write signals without threading a store parameter. pub store: Rc, - pub sidebar_visible: Signal, pub debug: DebugStateStore, pub clock_ms: u64, pub next_toast_id: u64, pub frecency: Option, pub theme_names: Vec, pub theme_variants: Vec, - pub theme_preview_original: Signal>, pub github_access_token: Option, viewport_document_cache: Option, virtual_diff_document: VirtualDiffDocument, @@ -167,23 +152,10 @@ pub struct AppState { impl Default for AppState { fn default() -> Self { let store = Rc::new(SignalStore::default()); - let sidebar_visible = store.create(true); - let focus = store.create(None::); + let ui = UiStateStore::new_default(&store); + let focus = ui.focus; let text_focused = store.create_memo(move |s| s.read(focus).is_some_and(|t| t.is_text_field())); - let workspace_mode = store.create(WorkspaceMode::default()); - let compare_progress = store.create(None::>); - let app_view = store.create(AppView::default()); - let settings_section = store.create(SettingsSection::default()); - let keymap_capture = store.create(None::); - let keymaps_scroll_top_px = store.create(0.0_f32); - let keymaps_viewport_height_px = store.create(0.0_f32); - let keymaps_content_height_px = store.create(0.0_f32); - let last_error = store.create(None::); - let theme_preview_original = store.create(None::); - let toasts = store.create(Vec::::new()); - let syntax_pack_installs = store.create(Vec::::new()); - let update = store.create(UpdateState::default()); let debug = DebugStateStore::new(&store, DebugState::default()); let file_list = FileListStateStore::new_default(&store); let editor = EditorStateStore::new_default(&store); @@ -194,29 +166,17 @@ impl Default for AppState { let text_edit = TextEditStateStore::new_default(&store); let github = GitHubStateStore::new_default(&store); Self { - workspace_mode, - compare_progress, - app_view, - settings_section, - keymap_capture, - keymaps_scroll_top_px, - keymaps_viewport_height_px, - keymaps_content_height_px, + ui, compare, repository, workspace, file_list, overlays, - focus, text_edit, editor, github, settings: Settings::default(), startup: StartupState::default(), - last_error, - toasts, - syntax_pack_installs, - update, context_menu: ContextMenuState::default(), text_focused, animation: crate::ui::animation::AnimationState::default(), @@ -231,7 +191,6 @@ impl Default for AppState { ai_generation_id: 0, ai_generation_active: false, ai_generation_error: None, - sidebar_visible, debug, store, clock_ms: 0, @@ -239,7 +198,6 @@ impl Default for AppState { frecency: None, theme_names: Vec::new(), theme_variants: Vec::new(), - theme_preview_original, github_access_token: None, viewport_document_cache: None, virtual_diff_document: VirtualDiffDocument::default(), @@ -291,31 +249,20 @@ impl AppState { || startup.args.compare_mode.is_some()); let store = Rc::new(SignalStore::default()); - let sidebar_visible = store.create(true); - let focus = store.create(if repo_path.is_some() { - Some(FocusTarget::TitleBar) - } else { - Some(FocusTarget::WorkspacePrimaryButton) - }); + let ui = UiStateStore::new( + &store, + UiState { + focus: Some(if repo_path.is_some() { + FocusTarget::TitleBar + } else { + FocusTarget::WorkspacePrimaryButton + }), + ..UiState::default() + }, + ); + let focus = ui.focus; let text_focused = store.create_memo(move |s| s.read(focus).is_some_and(|t| t.is_text_field())); - let workspace_mode = store.create(if repo_path.is_some() && auto_compare_pending { - WorkspaceMode::Loading - } else { - WorkspaceMode::Empty - }); - let compare_progress = store.create(None::>); - let app_view = store.create(AppView::default()); - let settings_section = store.create(SettingsSection::default()); - let keymap_capture = store.create(None::); - let keymaps_scroll_top_px = store.create(0.0_f32); - let keymaps_viewport_height_px = store.create(0.0_f32); - let keymaps_content_height_px = store.create(0.0_f32); - let last_error = store.create(None::); - let theme_preview_original = store.create(None::); - let toasts = store.create(Vec::::new()); - let syntax_pack_installs = store.create(Vec::::new()); - let update = store.create(UpdateState::default()); let debug = DebugStateStore::new(&store, DebugState::default()); let file_list = FileListStateStore::new_default(&store); let editor = EditorStateStore::new( @@ -342,7 +289,17 @@ impl AppState { }, ); let repository = RepositoryStateStore::new_default(&store); - let workspace = WorkspaceStateStore::new_default(&store); + let workspace = WorkspaceStateStore::new( + &store, + WorkspaceState { + mode: if repo_path.is_some() && auto_compare_pending { + WorkspaceMode::Loading + } else { + WorkspaceMode::Empty + }, + ..WorkspaceState::default() + }, + ); let text_edit = TextEditStateStore::new_default(&store); let initial_token_present = settings.github_user.is_some(); let github = GitHubStateStore::new( @@ -358,20 +315,12 @@ impl AppState { }, ); let mut state = Self { - workspace_mode, - compare_progress, - app_view, - settings_section, - keymap_capture, - keymaps_scroll_top_px, - keymaps_viewport_height_px, - keymaps_content_height_px, + ui, compare, repository, workspace, file_list, overlays, - focus, text_edit, editor, github, @@ -385,10 +334,6 @@ impl AppState { preferred_file_index: startup.args.file_index, preferred_file_path: startup.args.file_path.clone(), }, - last_error, - toasts, - syntax_pack_installs, - update, context_menu: ContextMenuState::default(), text_focused, animation: crate::ui::animation::AnimationState::default(), @@ -403,7 +348,6 @@ impl AppState { ai_generation_id: 0, ai_generation_active: false, ai_generation_error: None, - sidebar_visible, debug, store, clock_ms: 0, @@ -411,7 +355,6 @@ impl AppState { frecency: crate::core::frecency::open_default_store(), theme_names: Vec::new(), theme_variants: Vec::new(), - theme_preview_original, github_access_token: None, viewport_document_cache: None, virtual_diff_document: VirtualDiffDocument::default(), @@ -455,7 +398,7 @@ impl AppState { .and_then(|n| n.to_str()) .unwrap_or("repository") .to_owned(); - state.compare_progress.set( + state.workspace.compare_progress.set( &state.store, Some(Arc::new(CompareProgress { generation: boot_gen, diff --git a/src/ui/state/overlay.rs b/src/ui/state/overlay.rs index 60f19b86..8b7e97fa 100644 --- a/src/ui/state/overlay.rs +++ b/src/ui/state/overlay.rs @@ -62,8 +62,9 @@ impl AppState { } ShowKeyboardShortcuts => { self.clear_overlays(); - self.app_view.set(&self.store, AppView::Settings); - self.settings_section + self.ui.app_view.set(&self.store, AppView::Settings); + self.ui + .settings_section .set(&self.store, SettingsSection::Keymaps); Vec::new() } @@ -745,7 +746,8 @@ impl AppState { pub(super) fn open_theme_picker(&mut self) { let scale = self.ui_scale_factor(); - self.theme_preview_original + self.ui + .theme_preview_original .set(&self.store, Some(self.settings.theme_name.clone())); self.overlays .picker @@ -771,6 +773,7 @@ impl AppState { use crate::core::themes::ThemeVariant; let original = self + .ui .theme_preview_original .get(&self.store) .unwrap_or_else(|| self.settings.theme_name.clone()); @@ -829,6 +832,7 @@ impl AppState { .query .with(&self.store, |q| q.trim().to_owned()); let original = self + .ui .theme_preview_original .get(&self.store) .unwrap_or_else(|| self.settings.theme_name.clone()); @@ -1058,7 +1062,7 @@ impl AppState { self.set_focus(focus_target); return; } - let focus_return = self.focus.get(&self.store); + let focus_return = self.ui.focus.get(&self.store); self.overlays.stack.update(&self.store, |stack| { stack.push(OverlayEntry { surface, @@ -1078,8 +1082,8 @@ impl AppState { }; match entry.surface { OverlaySurface::ThemePicker => { - let original = self.theme_preview_original.get(&self.store); - self.theme_preview_original.set(&self.store, None); + let original = self.ui.theme_preview_original.get(&self.store); + self.ui.theme_preview_original.set(&self.store, None); if let Some(original) = original { self.settings.theme_name = original; } @@ -1276,7 +1280,7 @@ impl AppState { tracing::info!(theme = %value, "theme confirmed"); self.settings.theme_name = value; } - self.theme_preview_original.set(&self.store, None); + self.ui.theme_preview_original.set(&self.store, None); self.pop_overlay(); self.persist_settings_effect() } @@ -2728,7 +2732,7 @@ impl AppState { detail: "Check Diffy's release channel now".to_owned(), kind: PaletteEntryKind::Command(PaletteCommand::CheckForUpdates), }); - match self.update.get(&self.store) { + match self.ui.update.get(&self.store) { UpdateState::Available(update) => { let label = format!("Install Update {}", update.version); let detail = "Download and verify the available update".to_owned(); diff --git a/src/ui/state/repository.rs b/src/ui/state/repository.rs index 5d958798..4342a7a6 100644 --- a/src/ui/state/repository.rs +++ b/src/ui/state/repository.rs @@ -28,17 +28,20 @@ pub(super) fn reduce_event(state: &mut AppState, event: RepositoryEvent) -> Vec< .repository .status .set(&state.store, AsyncStatus::Failed); - state.workspace_mode.set(&state.store, WorkspaceMode::Empty); - state.compare_progress.update(&state.store, |slot| { - if let Some(p) = slot.as_ref() - && matches!(p.subject, LoadingSubject::RepoOpen { .. }) - { - *slot = None; - } - }); + state.workspace.mode.set(&state.store, WorkspaceMode::Empty); + state + .workspace + .compare_progress + .update(&state.store, |slot| { + if let Some(p) = slot.as_ref() + && matches!(p.subject, LoadingSubject::RepoOpen { .. }) + { + *slot = None; + } + }); state.push_error(&message); } else { - state.last_error.set(&state.store, Some(message)); + state.ui.last_error.set(&state.store, Some(message)); } } Vec::new() @@ -473,7 +476,7 @@ impl AppState { impl AppState { pub(super) fn open_repository(&mut self, path: PathBuf) -> Vec { let path = normalize_repository_open_path(path); - self.workspace_mode.set(&self.store, WorkspaceMode::Loading); + self.workspace.mode.set(&self.store, WorkspaceMode::Loading); self.compare.repo_path.set(&self.store, Some(path.clone())); self.compare.left_ref.set(&self.store, String::new()); self.compare.right_ref.set(&self.store, String::new()); @@ -494,7 +497,7 @@ impl AppState { self.reset_file_list(); self.editor_clear_document(); self.editor.focused.set(&self.store, false); - self.last_error.set(&self.store, None); + self.ui.last_error.set(&self.store, None); self.github.pull_request.cache.update(&self.store, |c| { c.clear(); }); @@ -503,7 +506,7 @@ impl AppState { .pending_confirm .set(&self.store, None); self.clear_overlays(); - self.focus.set(&self.store, Some(FocusTarget::TitleBar)); + self.ui.focus.set(&self.store, Some(FocusTarget::TitleBar)); self.sync_settings_snapshot(); // Seed the progress panel with a repo-open subject. We piggy-back @@ -531,7 +534,7 @@ impl AppState { // for 500ms, which is a cheap price for zero flash on fast ops. let started_at_ms = self.clock_ms; let reveal_at_ms = started_at_ms.saturating_add(COMPARE_REVEAL_DELAY_MS); - self.compare_progress.set( + self.workspace.compare_progress.set( &self.store, Some(Arc::new(CompareProgress { generation: next_gen, @@ -602,7 +605,7 @@ impl AppState { // Tear down a repo-open progress panel. Compare-subject progress // survives — a kickoff_compare may be queued below and will // replace it atomically via its own seeding path. - self.compare_progress.update(&self.store, |slot| { + self.workspace.compare_progress.update(&self.store, |slot| { if let Some(p) = slot.as_ref() && matches!(p.subject, LoadingSubject::RepoOpen { .. }) { @@ -783,7 +786,7 @@ impl AppState { .status_operation_pending .set(&self.store, false); self.workspace.status.set(&self.store, AsyncStatus::Ready); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); + self.workspace.mode.set(&self.store, WorkspaceMode::Ready); self.workspace .used_fallback .set(&self.store, output.used_fallback); @@ -859,7 +862,7 @@ impl AppState { .source .set(&self.store, WorkspaceSource::Status); self.workspace.status.set(&self.store, AsyncStatus::Ready); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); + self.workspace.mode.set(&self.store, WorkspaceMode::Ready); self.workspace.compare_output.set(&self.store, None); self.workspace.compare_total_stats.set(&self.store, None); self.workspace.compare_hydrated_stats.set(&self.store, None); diff --git a/src/ui/state/settings.rs b/src/ui/state/settings.rs index 7a94f445..d3a67142 100644 --- a/src/ui/state/settings.rs +++ b/src/ui/state/settings.rs @@ -117,20 +117,21 @@ impl AppState { } OpenSettings => { self.clear_overlays(); - self.app_view.set(&self.store, AppView::Settings); + self.ui.app_view.set(&self.store, AppView::Settings); Vec::new() } OpenKeymaps => { self.clear_overlays(); - self.app_view.set(&self.store, AppView::Settings); - self.settings_section + self.ui.app_view.set(&self.store, AppView::Settings); + self.ui + .settings_section .set(&self.store, SettingsSection::Keymaps); - self.keymaps_scroll_top_px.set(&self.store, 0.0); + self.ui.keymaps_scroll_top_px.set(&self.store, 0.0); Vec::new() } CloseSettings => { - self.keymap_capture.set(&self.store, None); - self.app_view.set(&self.store, AppView::Workspace); + self.ui.keymap_capture.set(&self.store, None); + self.ui.app_view.set(&self.store, AppView::Workspace); Vec::new() } ToggleAutoUpdate => { @@ -142,38 +143,41 @@ impl AppState { effects } SetSettingsSection(section) => { - self.keymap_capture.set(&self.store, None); - self.settings_section.set(&self.store, section); - self.keymaps_scroll_top_px.set(&self.store, 0.0); + self.ui.keymap_capture.set(&self.store, None); + self.ui.settings_section.set(&self.store, section); + self.ui.keymaps_scroll_top_px.set(&self.store, 0.0); Vec::new() } BeginKeymapRebind(command) => { - self.keymap_capture.set(&self.store, Some(command)); + self.ui.keymap_capture.set(&self.store, Some(command)); Vec::new() } ApplyKeymapBinding { command, binding } => { crate::input::set_override(&mut self.settings.keymap_overrides, command, binding); - self.keymap_capture.set(&self.store, None); + self.ui.keymap_capture.set(&self.store, None); self.persist_settings_effect() } ResetKeymapBinding(command) => { crate::input::reset_override(&mut self.settings.keymap_overrides, command); - self.keymap_capture.set(&self.store, None); + self.ui.keymap_capture.set(&self.store, None); self.persist_settings_effect() } CancelKeymapRebind => { - self.keymap_capture.set(&self.store, None); + self.ui.keymap_capture.set(&self.store, None); Vec::new() } ScrollKeymapsPx(delta) => { - let cur = self.keymaps_scroll_top_px.get(&self.store); - self.keymaps_scroll_top_px + let cur = self.ui.keymaps_scroll_top_px.get(&self.store); + self.ui + .keymaps_scroll_top_px .set(&self.store, cur + delta as f32); self.clamp_keymaps_scroll(); Vec::new() } ScrollKeymapsToPx(target) => { - self.keymaps_scroll_top_px.set(&self.store, target as f32); + self.ui + .keymaps_scroll_top_px + .set(&self.store, target as f32); self.clamp_keymaps_scroll(); Vec::new() } @@ -183,15 +187,16 @@ impl AppState { impl AppState { pub fn keymaps_max_scroll_px(&self) -> f32 { - let content = self.keymaps_content_height_px.get(&self.store); - let viewport = self.keymaps_viewport_height_px.get(&self.store); + let content = self.ui.keymaps_content_height_px.get(&self.store); + let viewport = self.ui.keymaps_viewport_height_px.get(&self.store); (content - viewport).max(0.0) } pub fn clamp_keymaps_scroll(&mut self) { let max = self.keymaps_max_scroll_px(); - let cur = self.keymaps_scroll_top_px.get(&self.store); - self.keymaps_scroll_top_px + let cur = self.ui.keymaps_scroll_top_px.get(&self.store); + self.ui + .keymaps_scroll_top_px .set(&self.store, cur.clamp(0.0, max)); } } diff --git a/src/ui/state/syntax.rs b/src/ui/state/syntax.rs index fdafaf5c..db4cce55 100644 --- a/src/ui/state/syntax.rs +++ b/src/ui/state/syntax.rs @@ -475,7 +475,7 @@ impl AppState { } pub(super) fn handle_syntax_pack_install_started(&mut self, language: &str) { - self.syntax_pack_installs.update(&self.store, |active| { + self.ui.syntax_pack_installs.update(&self.store, |active| { if !active.iter().any(|item| item == language) { active.push(language.to_owned()); } @@ -483,12 +483,14 @@ impl AppState { } pub(super) fn handle_syntax_pack_install_finished(&mut self, language: &str) { - self.syntax_pack_installs + self.ui + .syntax_pack_installs .update(&self.store, |active| active.retain(|item| item != language)); } pub fn syntax_pack_install_active(&self) -> bool { - self.syntax_pack_installs + self.ui + .syntax_pack_installs .with(&self.store, |active| !active.is_empty()) } @@ -501,7 +503,7 @@ impl AppState { .iter() .filter_map(|path| highlighter.guess_language(Path::new(path))) .collect::>(); - let active_languages = self.syntax_pack_installs.with(&self.store, |active| { + let active_languages = self.ui.syntax_pack_installs.with(&self.store, |active| { active.iter().cloned().collect::>() }); @@ -549,7 +551,7 @@ impl AppState { .iter() .filter_map(|path| highlighter.guess_language(Path::new(path))) .collect::>(); - let active_languages = self.syntax_pack_installs.with(&self.store, |active| { + let active_languages = self.ui.syntax_pack_installs.with(&self.store, |active| { active.iter().cloned().collect::>() }); diff --git a/src/ui/state/tests.rs b/src/ui/state/tests.rs index 915fa07a..93d10b94 100644 --- a/src/ui/state/tests.rs +++ b/src/ui/state/tests.rs @@ -76,7 +76,7 @@ fn new_text_compare_enters_text_workspace_with_left_focus() { assert_eq!(state.text_compare.language, TextCompareLanguage::Auto); assert_eq!(state.text_compare.path_hint, "text.txt"); assert_eq!( - state.focus.get(&state.store), + state.ui.focus.get(&state.store), Some(FocusTarget::TextCompareLeft) ); } @@ -284,7 +284,7 @@ diff --git a/src/lib.rs b/src/lib.rs .workspace .status_operation_pending .set(&state.store, false); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state.workspace.files.set( &state.store, vec![FileListEntry { @@ -364,7 +364,7 @@ fn loaded_state_with_files(paths: &[&str]) -> AppState { .source .set(&state.store, WorkspaceSource::Compare); state.workspace.files.set(&state.store, entries); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state.file_list.row_height.set(&state.store, 36.0); state.file_list.gap.set(&state.store, 4.0); state.file_list.viewport_height.set(&state.store, 80.0); @@ -381,9 +381,9 @@ fn bootstrap_with_no_repo_starts_empty_workspace() { ); let (state, effects) = AppState::bootstrap(startup, Settings::default()); - assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Empty); + assert_eq!(state.workspace.mode.get(&state.store), WorkspaceMode::Empty); assert_eq!( - state.focus.get(&state.store), + state.ui.focus.get(&state.store), Some(FocusTarget::WorkspacePrimaryButton) ); assert!(effects.iter().all(|e| matches!( @@ -414,7 +414,7 @@ fn bootstrap_with_repo_starts_repo_sync() { ); let (state, effects) = AppState::bootstrap(startup, Settings::default()); - assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Empty); + assert_eq!(state.workspace.mode.get(&state.store), WorkspaceMode::Empty); assert_eq!(state.active_overlay_name(), None); assert_eq!( effects @@ -444,7 +444,10 @@ fn overlay_close_restores_prior_focus() { state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); assert_eq!(state.overlays_top(), Some(OverlaySurface::CommandPalette)); state.apply_action(crate::actions::OverlayAction::CloseOverlay); - assert_eq!(state.focus.get(&state.store), Some(FocusTarget::TitleBar)); + assert_eq!( + state.ui.focus.get(&state.store), + Some(FocusTarget::TitleBar) + ); } #[test] @@ -1075,7 +1078,7 @@ fn selecting_a_file_requests_async_syntax_without_mutating_compare_output() { .compare .repo_path .set(&state.store, Some(PathBuf::from("/tmp/repo"))); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); @@ -1144,7 +1147,7 @@ fn small_compare_file_selection_stays_synchronous() { path: "src/lib.rs".into(), }], ); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state .compare .repo_path @@ -1624,27 +1627,30 @@ fn closing_overlays_restores_previous_focus() { state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); assert_eq!( - state.focus.get(&state.store), + state.ui.focus.get(&state.store), Some(FocusTarget::CommandPaletteInput) ); // Each nested overlay records its own restore target. state.apply_action(crate::actions::OverlayAction::OpenGitHubAuthModal); assert_eq!( - state.focus.get(&state.store), + state.ui.focus.get(&state.store), Some(FocusTarget::AuthPrimaryAction) ); state.apply_action(crate::actions::OverlayAction::CloseOverlay); assert_eq!(state.overlays_top(), Some(OverlaySurface::CommandPalette)); assert_eq!( - state.focus.get(&state.store), + state.ui.focus.get(&state.store), Some(FocusTarget::CommandPaletteInput) ); state.apply_action(crate::actions::OverlayAction::CloseOverlay); assert_eq!(state.overlays_top(), None); - assert_eq!(state.focus.get(&state.store), Some(FocusTarget::FileList)); + assert_eq!( + state.ui.focus.get(&state.store), + Some(FocusTarget::FileList) + ); } #[test] @@ -1659,7 +1665,10 @@ fn clearing_overlay_stack_restores_pre_overlay_focus() { state.clear_overlays(); assert_eq!(state.overlays_top(), None); - assert_eq!(state.focus.get(&state.store), Some(FocusTarget::FileList)); + assert_eq!( + state.ui.focus.get(&state.store), + Some(FocusTarget::FileList) + ); } #[test] @@ -2415,6 +2424,7 @@ fn kickoff_compare_seeds_progress_with_labels_and_started_at() { let _ = state.kickoff_compare(); let progress = state + .workspace .compare_progress .with(&state.store, |p| p.clone()) .expect("progress should be populated"); @@ -2432,7 +2442,7 @@ fn kickoff_compare_seeds_progress_with_labels_and_started_at() { assert_eq!(progress.phase, ComparePhase::OpeningRepo); assert_eq!(progress.file_count_total, None); assert_eq!( - state.workspace_mode.get(&state.store), + state.workspace.mode.get(&state.store), WorkspaceMode::Loading, "viewport should flip to loading so the panel actually renders" ); @@ -2451,6 +2461,7 @@ fn compare_progress_update_applies_only_when_generation_matches() { })); assert_eq!( state + .workspace .compare_progress .with(&state.store, |p| p.as_ref().unwrap().phase), ComparePhase::OpeningRepo, @@ -2464,6 +2475,7 @@ fn compare_progress_update_applies_only_when_generation_matches() { })); assert_eq!( state + .workspace .compare_progress .with(&state.store, |p| p.as_ref().unwrap().phase), ComparePhase::EnumeratingChanges, @@ -2485,6 +2497,7 @@ fn loading_files_phase_updates_counts_on_struct() { })); let progress = state + .workspace .compare_progress .with(&state.store, |p| p.clone()) .expect("progress exists"); @@ -2507,6 +2520,7 @@ fn kickoff_with_prior_state_reveals_loading_immediately() { let _ = state.kickoff_compare(); let progress = state + .workspace .compare_progress .with(&state.store, |p| p.clone()) .expect("progress populated"); @@ -2516,7 +2530,7 @@ fn kickoff_with_prior_state_reveals_loading_immediately() { "compare loading should be visible immediately" ); assert_ne!( - state.workspace_mode.get(&state.store), + state.workspace.mode.get(&state.store), WorkspaceMode::Loading ); // Prior files are preserved so fast compares don't cause a flash. @@ -2531,6 +2545,7 @@ fn open_repository_seeds_repo_subject_progress() { let effects = state.open_repository(PathBuf::from("/tmp/linux")); let progress = state + .workspace .compare_progress .with(&state.store, |p| p.clone()) .expect("progress seeded for repo open"); @@ -2572,6 +2587,7 @@ fn open_repository_with_prior_diff_delays_reveal() { let _ = state.open_repository(PathBuf::from("/tmp/other")); let progress = state + .workspace .compare_progress .with(&state.store, |p| p.clone()) .expect("progress seeded"); @@ -2648,7 +2664,7 @@ fn large_compare_stats_stream_offscreen_background_rows_after_visible_rows() { .workspace .source .set(&state.store, WorkspaceSource::Compare); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state .compare .repo_path @@ -3054,7 +3070,12 @@ fn repository_snapshot_ready_clears_repo_open_progress() { let mut state = AppState::default(); let path = PathBuf::from("/tmp/linux"); let _ = state.open_repository(path.clone()); - assert!(state.compare_progress.with(&state.store, |p| p.is_some())); + assert!( + state + .workspace + .compare_progress + .with(&state.store, |p| p.is_some()) + ); state.apply_event(AppEvent::from(RepositoryEvent::RepositorySnapshotReady( crate::events::RepositorySnapshot::from_vcs_snapshot( @@ -3077,7 +3098,10 @@ fn repository_snapshot_ready_clears_repo_open_progress() { ))); assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), + state + .workspace + .compare_progress + .with(&state.store, |p| p.is_none()), "snapshot-ready must tear down the repo-open progress panel" ); } @@ -3089,6 +3113,7 @@ fn kickoff_without_prior_state_reveals_loading_immediately() { let _ = state.kickoff_compare(); let progress = state + .workspace .compare_progress .with(&state.store, |p| p.clone()) .expect("progress populated"); @@ -3100,7 +3125,7 @@ fn kickoff_without_prior_state_reveals_loading_immediately() { // With no prior state to preserve, workspace_mode flips to Loading // up front so the editor/ready-hint stops rendering in the background. assert_eq!( - state.workspace_mode.get(&state.store), + state.workspace.mode.get(&state.store), WorkspaceMode::Loading ); } @@ -3114,13 +3139,16 @@ fn cancel_compare_bumps_generation_and_drops_stale_result() { let _ = state.cancel_compare(); assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), + state + .workspace + .compare_progress + .with(&state.store, |p| p.is_none()), "progress should be cleared after cancel" ); let new_gen = state.workspace.compare_generation.get(&state.store); assert!(new_gen > generation, "generation should be bumped"); assert_eq!( - state.workspace_mode.get(&state.store), + state.workspace.mode.get(&state.store), WorkspaceMode::Empty, "fresh-state cancel should revert the Loading flip" ); @@ -3143,12 +3171,15 @@ fn cancel_compare_bumps_generation_and_drops_stale_result() { }, ))); assert_eq!( - state.workspace_mode.get(&state.store), + state.workspace.mode.get(&state.store), WorkspaceMode::Empty, "stale finished result must not promote workspace to Ready", ); assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), + state + .workspace + .compare_progress + .with(&state.store, |p| p.is_none()), "stale finished result must not re-seed progress", ); } @@ -3163,17 +3194,20 @@ fn cancel_compare_preserves_previous_diff_on_recompare() { path: "old.rs".into(), }], ); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); let _ = state.kickoff_compare(); let _ = state.cancel_compare(); assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), + state + .workspace + .compare_progress + .with(&state.store, |p| p.is_none()), "progress cleared on cancel" ); assert_eq!( - state.workspace_mode.get(&state.store), + state.workspace.mode.get(&state.store), WorkspaceMode::Ready, "previous workspace state is preserved on cancel — no blanking" ); @@ -3223,7 +3257,7 @@ fn compare_finished_advances_phase_and_records_file_count() { // Small files load synchronously, so progress is already cleared by the // time handle_compare_finished returns. We at least know the workspace // is Ready and the compare file view is populated from CompareOutput. - assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Ready,); + assert_eq!(state.workspace.mode.get(&state.store), WorkspaceMode::Ready,); assert_eq!(state.workspace_file_count(), 3); } @@ -3238,9 +3272,12 @@ fn compare_failed_clears_progress_and_marks_workspace_empty() { message: "boom".to_owned(), })); - assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Empty,); + assert_eq!(state.workspace.mode.get(&state.store), WorkspaceMode::Empty,); assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), + state + .workspace + .compare_progress + .with(&state.store, |p| p.is_none()), "progress panel must tear down on compare failure", ); } diff --git a/src/ui/state/text_compare.rs b/src/ui/state/text_compare.rs index df072ddd..2efd8f49 100644 --- a/src/ui/state/text_compare.rs +++ b/src/ui/state/text_compare.rs @@ -57,8 +57,8 @@ impl AppState { .source .set(&self.store, WorkspaceSource::TextCompare); self.workspace.status.set(&self.store, AsyncStatus::Ready); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); - self.compare_progress.set(&self.store, None); + self.workspace.mode.set(&self.store, WorkspaceMode::Ready); + self.workspace.compare_progress.set(&self.store, None); self.github.pull_request.active.set(&self.store, None); self.github .pull_request @@ -109,9 +109,9 @@ impl AppState { .compare_generation .set(&self.store, generation); self.workspace.status.set(&self.store, AsyncStatus::Loading); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); + self.workspace.mode.set(&self.store, WorkspaceMode::Ready); self.workspace.active_file_loading.set(&self.store, None); - self.compare_progress.set(&self.store, None); + self.workspace.compare_progress.set(&self.store, None); self.clear_overlays(); self.sync_text_compare_syntax_paths(); @@ -149,7 +149,7 @@ impl AppState { .source .set(&self.store, WorkspaceSource::TextCompare); self.workspace.status.set(&self.store, AsyncStatus::Ready); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); + self.workspace.mode.set(&self.store, WorkspaceMode::Ready); self.workspace .compare_generation .set(&self.store, payload.generation); diff --git a/src/ui/state/text_edit.rs b/src/ui/state/text_edit.rs index 7eab76a7..0c69128e 100644 --- a/src/ui/state/text_edit.rs +++ b/src/ui/state/text_edit.rs @@ -38,7 +38,7 @@ impl AppState { /// Called after text mutation to sync compare fields and rebuild pickers. pub(super) fn after_text_mutation(&mut self) -> Vec { - match self.focus.get(&self.store) { + match self.ui.focus.get(&self.store) { Some(FocusTarget::PickerInput) => match self.overlays.picker.kind.get(&self.store) { PickerKind::Repository => self.rebuild_repo_picker(), PickerKind::LeftRef => { @@ -77,7 +77,7 @@ impl AppState { /// Should we persist settings after editing the current field? pub(super) fn needs_persist(&self) -> bool { matches!( - self.focus.get(&self.store), + self.ui.focus.get(&self.store), Some(FocusTarget::PickerInput) if matches!(self.overlays.picker.kind.get(&self.store), PickerKind::LeftRef | PickerKind::RightRef) ) @@ -264,7 +264,10 @@ impl AppState { } } // No text selection — copy the selected picker/palette entry's value. - if matches!(self.focus.get(&self.store), Some(FocusTarget::PickerInput)) { + if matches!( + self.ui.focus.get(&self.store), + Some(FocusTarget::PickerInput) + ) { let selected = self.overlays.picker.selected_index.get(&self.store); let value = self.overlays.picker.entries.with(&self.store, |entries| { entries.get(selected).map(|e| e.value.clone()) @@ -277,7 +280,7 @@ impl AppState { } } if matches!( - self.focus.get(&self.store), + self.ui.focus.get(&self.store), Some(FocusTarget::CommandPaletteInput) ) { let selected = self @@ -389,17 +392,17 @@ fn ai_key_save_effect(kind: AiKeyKind, value: &str) -> Effect { impl AppState { pub(super) fn apply_text_edit_action(&mut self, action: TextEditAction) -> Vec { use TextEditAction::*; - if self.focus.get(&self.store) == Some(FocusTarget::CommitEditor) { + if self.ui.focus.get(&self.store) == Some(FocusTarget::CommitEditor) { return self.apply_commit_editor_action(action); } - if self.focus.get(&self.store) == Some(FocusTarget::ReviewCommentEditor) { + if self.ui.focus.get(&self.store) == Some(FocusTarget::ReviewCommentEditor) { return self.apply_review_comment_editor_action(action); } - if self.focus.get(&self.store) == Some(FocusTarget::SettingsSteeringPrompt) { + if self.ui.focus.get(&self.store) == Some(FocusTarget::SettingsSteeringPrompt) { return self.apply_steering_prompt_action(action); } if matches!( - self.focus.get(&self.store), + self.ui.focus.get(&self.store), Some(FocusTarget::TextCompareLeft | FocusTarget::TextCompareRight) ) { return self.apply_text_compare_editor_action(action); @@ -712,7 +715,7 @@ impl AppState { } fn apply_text_compare_editor_action(&mut self, action: TextEditAction) -> Vec { - let target = self.focus.get(&self.store); + let target = self.ui.focus.get(&self.store); let changed = { let Some(editor) = (match target { Some(FocusTarget::TextCompareLeft) => Some(&mut self.text_compare.left_editor), @@ -857,12 +860,12 @@ impl AppState { } pub(super) fn with_focused_text(&self, f: impl FnOnce(&str) -> R) -> Option { - let target = self.focus.get(&self.store)?; + let target = self.ui.focus.get(&self.store)?; self.with_text_for_focus(target, f) } pub(super) fn update_focused_text(&mut self, f: impl FnOnce(&mut String) -> R) -> Option { - match self.focus.get(&self.store) { + match self.ui.focus.get(&self.store) { Some(FocusTarget::PickerInput) => match self.overlays.picker.kind.get(&self.store) { PickerKind::Repository | PickerKind::Theme diff --git a/src/ui/state/ui.rs b/src/ui/state/ui.rs index 12eef48a..07627bd8 100644 --- a/src/ui/state/ui.rs +++ b/src/ui/state/ui.rs @@ -131,12 +131,57 @@ pub enum ToastKind { Error, } +/// App-chrome reactive state: view routing, focus, toasts, errors, +/// settings-page scroll metrics, theme preview, and the update lifecycle. +/// `#[derive(Store)]` turns every field into a `Signal` in the generated +/// `UiStateStore` held by `AppState`. +#[derive(Debug, Clone, Store)] +pub struct UiState { + pub app_view: AppView, + pub settings_section: SettingsSection, + pub keymap_capture: Option, + pub keymaps_scroll_top_px: f32, + pub keymaps_viewport_height_px: f32, + pub keymaps_content_height_px: f32, + pub focus: Option, + pub last_error: Option, + pub toasts: Vec, + pub syntax_pack_installs: Vec, + pub update: UpdateState, + pub sidebar_visible: bool, + pub theme_preview_original: Option, +} + +impl Default for UiState { + fn default() -> Self { + Self { + app_view: AppView::default(), + settings_section: SettingsSection::default(), + keymap_capture: None, + keymaps_scroll_top_px: 0.0, + keymaps_viewport_height_px: 0.0, + keymaps_content_height_px: 0.0, + focus: None, + last_error: None, + toasts: Vec::new(), + syntax_pack_installs: Vec::new(), + update: UpdateState::default(), + sidebar_visible: true, + theme_preview_original: None, + } + } +} + impl AppState { pub fn window_title(&self) -> String { - let workspace_mode = if self.compare_progress.with(&self.store, |p| p.is_some()) { + let workspace_mode = if self + .workspace + .compare_progress + .with(&self.store, |p| p.is_some()) + { "loading" } else { - workspace_mode_name(self.workspace_mode.get(&self.store)) + workspace_mode_name(self.workspace.mode.get(&self.store)) }; let title_prefix = crate::platform::startup::window_title_prefix(); if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { @@ -160,7 +205,7 @@ impl AppState { pub fn update_time(&mut self, now_ms: u64) { self.clock_ms = now_ms; self.animation.tick(now_ms); - let has_expired_toast = self.toasts.with(&self.store, |toasts| { + let has_expired_toast = self.ui.toasts.with(&self.store, |toasts| { toasts.iter().any(|toast| { !toast.hovered && toast.progress.is_none() @@ -168,7 +213,7 @@ impl AppState { }) }); if has_expired_toast { - self.toasts.update(&self.store, |toasts| { + self.ui.toasts.update(&self.store, |toasts| { toasts.retain(|toast| { toast.hovered || toast.progress.is_some() @@ -183,7 +228,7 @@ impl AppState { && crate::core::update::updates_configured() && !cfg!(debug_assertions) && !matches!( - self.update.get(&self.store), + self.ui.update.get(&self.store), UpdateState::Downloading(_) | UpdateState::ReadyToRestart(_) | UpdateState::Restarting(_) @@ -208,7 +253,7 @@ impl AppState { } pub fn next_toast_expiry_at_ms(&self) -> Option { - self.toasts.with(&self.store, |toasts| { + self.ui.toasts.with(&self.store, |toasts| { toasts .iter() .filter(|toast| !toast.hovered && toast.progress.is_none()) @@ -218,14 +263,14 @@ impl AppState { } pub(super) fn set_focus(&mut self, target: Option) { - if target != self.focus.get(&self.store) { + if target != self.ui.focus.get(&self.store) { // Reset cursor to end of the new field let len = target .and_then(|t| self.with_text_for_focus(t, |s| s.len())) .unwrap_or(0); self.reset_text_edit(len); } - self.focus.set(&self.store, target); + self.ui.focus.set(&self.store, target); self.editor .focused .set(&self.store, target == Some(FocusTarget::Editor)); @@ -238,7 +283,9 @@ impl AppState { } pub(super) fn push_error(&mut self, message: &str) -> u64 { - self.last_error.set(&self.store, Some(message.to_owned())); + self.ui + .last_error + .set(&self.store, Some(message.to_owned())); self.push_toast(ToastKind::Error, message, None, None) } @@ -248,7 +295,9 @@ impl AppState { #[allow(dead_code)] pub(super) fn push_error_with_description(&mut self, message: &str, description: &str) -> u64 { - self.last_error.set(&self.store, Some(message.to_owned())); + self.ui + .last_error + .set(&self.store, Some(message.to_owned())); self.push_toast( ToastKind::Error, message, @@ -278,7 +327,7 @@ impl AppState { description: Option, ) { let now = self.clock_ms; - self.toasts.update(&self.store, |toasts| { + self.ui.toasts.update(&self.store, |toasts| { if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { toast.kind = ToastKind::Info; toast.message = message.to_owned(); @@ -297,8 +346,10 @@ impl AppState { description: Option, ) { let now = self.clock_ms; - self.last_error.set(&self.store, Some(message.to_owned())); - self.toasts.update(&self.store, |toasts| { + self.ui + .last_error + .set(&self.store, Some(message.to_owned())); + self.ui.toasts.update(&self.store, |toasts| { if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { toast.kind = ToastKind::Error; toast.message = message.to_owned(); @@ -311,7 +362,7 @@ impl AppState { pub(super) fn update_toast_progress(&mut self, toast_id: u64, fraction: f32) { let clamped = fraction.clamp(0.0, 1.0); - self.toasts.update(&self.store, |toasts| { + self.ui.toasts.update(&self.store, |toasts| { if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { toast.progress = Some(clamped); } @@ -319,7 +370,7 @@ impl AppState { } pub(super) fn update_toast_message(&mut self, toast_id: u64, message: &str) { - self.toasts.update(&self.store, |toasts| { + self.ui.toasts.update(&self.store, |toasts| { if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { toast.message = message.to_owned(); } @@ -343,7 +394,7 @@ impl AppState { self.clock_ms, ); let now = self.clock_ms; - self.toasts.update(&self.store, |toasts| { + self.ui.toasts.update(&self.store, |toasts| { toasts.push(Toast { id, kind, diff --git a/src/ui/state/update.rs b/src/ui/state/update.rs index ab4ccb2e..00a44d67 100644 --- a/src/ui/state/update.rs +++ b/src/ui/state/update.rs @@ -12,6 +12,7 @@ pub(super) fn reduce_event(state: &mut AppState, event: UpdateEvent) -> Vec { state + .ui .update .set(&state.store, UpdateState::Downloading(update.clone())); if !silent { @@ -20,7 +21,7 @@ pub(super) fn reduce_event(state: &mut AppState, event: UpdateEvent) -> Vec { - state.update.set(&state.store, UpdateState::Idle); + state.ui.update.set(&state.store, UpdateState::Idle); if !silent { state.push_info("Diffy is up to date."); } @@ -29,6 +30,7 @@ pub(super) fn reduce_event(state: &mut AppState, event: UpdateEvent) -> Vec { if !silent { state + .ui .update .set(&state.store, UpdateState::Failed(message.clone())); state.push_error(&message); @@ -38,6 +40,7 @@ pub(super) fn reduce_event(state: &mut AppState, event: UpdateEvent) -> Vec { let version = staged.update.version.clone(); state + .ui .update .set(&state.store, UpdateState::ReadyToRestart(staged)); if !silent { @@ -47,9 +50,10 @@ pub(super) fn reduce_event(state: &mut AppState, event: UpdateEvent) -> Vec { if silent { - state.update.set(&state.store, UpdateState::Idle); + state.ui.update.set(&state.store, UpdateState::Idle); } else { state + .ui .update .set(&state.store, UpdateState::Failed(message.clone())); state.push_error(&message); @@ -63,13 +67,14 @@ impl AppState { pub(super) fn apply_update_action(&mut self, action: UpdateAction) -> Vec { match action { UpdateAction::CheckForUpdates => { - self.update.set(&self.store, UpdateState::Checking); + self.ui.update.set(&self.store, UpdateState::Checking); vec![UpdateEffect::CheckForUpdates { silent: false }.into()] } UpdateAction::InstallUpdate => { - let update = self.update.get(&self.store); + let update = self.ui.update.get(&self.store); if let UpdateState::Available(update) = update { - self.update + self.ui + .update .set(&self.store, UpdateState::Downloading(update.clone())); vec![ UpdateEffect::StageUpdate { @@ -83,9 +88,10 @@ impl AppState { } } UpdateAction::RestartToUpdate => { - let update = self.update.get(&self.store); + let update = self.ui.update.get(&self.store); if let UpdateState::ReadyToRestart(staged) = update { - self.update + self.ui + .update .set(&self.store, UpdateState::Restarting(staged.clone())); vec![UpdateEffect::ApplyStagedUpdate(staged).into()] } else { diff --git a/src/ui/state/working_set.rs b/src/ui/state/working_set.rs index 2c663cac..54504490 100644 --- a/src/ui/state/working_set.rs +++ b/src/ui/state/working_set.rs @@ -618,7 +618,7 @@ impl AppState { self.apply_compare_file_stats(&[stats]); // The first real file has landed — tear down the progress panel. // Subsequent file loads use the sidebar row spinner, not this. - self.compare_progress.set(&self.store, None); + self.workspace.compare_progress.set(&self.store, None); self.editor_clear_document(); self.editor .line_selection diff --git a/src/ui/state/workspace.rs b/src/ui/state/workspace.rs index 9e1f9044..89782aef 100644 --- a/src/ui/state/workspace.rs +++ b/src/ui/state/workspace.rs @@ -57,6 +57,10 @@ pub enum WorkspaceSource { #[derive(Debug, Clone, Default, Store)] pub struct WorkspaceState { + pub mode: WorkspaceMode, + /// Arc-wrapped so per-frame UI snapshots clone a pointer, not the + /// label strings inside. + pub compare_progress: Option>, pub source: WorkspaceSource, pub status: AsyncStatus, pub status_operation_pending: bool, @@ -106,6 +110,6 @@ pub fn workspace_mode_name(mode: WorkspaceMode) -> &'static str { impl AppState { /// Returns true when the workspace is in `Ready` mode. pub fn is_workspace_ready(&self) -> bool { - self.workspace_mode.get(&self.store) == WorkspaceMode::Ready + self.workspace.mode.get(&self.store) == WorkspaceMode::Ready } } diff --git a/src/ui/status_bar.rs b/src/ui/status_bar.rs index c3164e4e..ba2b0a5b 100644 --- a/src/ui/status_bar.rs +++ b/src/ui/status_bar.rs @@ -105,6 +105,7 @@ pub(crate) fn status_bar(state: &AppState, theme: &Theme) -> AnyElement { .active_pr_review_status() .map(|summary| review_status(summary, theme, scale)); let syntax_pack_child = state + .ui .syntax_pack_installs .with(&state.store, |active| !active.is_empty()) .then(|| syntax_pack_status(state.clock_ms, theme, scale)); diff --git a/src/ui/title_bar.rs b/src/ui/title_bar.rs index 8e201f98..6f8c20fb 100644 --- a/src/ui/title_bar.rs +++ b/src/ui/title_bar.rs @@ -60,8 +60,8 @@ pub(crate) fn compare_cluster_view(state: &AppState, theme: &Theme) -> Option= p.reveal_at_ms); @@ -91,7 +94,7 @@ pub(crate) fn main_surface( progress, state, theme, )) } else { - match state.workspace_mode.get(&state.store) { + match state.workspace.mode.get(&state.store) { // Loading mode is now always accompanied by a `compare_progress` // entry (either compare or repo-open). Reaching this arm means // the reveal delay hasn't elapsed — preserve the current view @@ -551,7 +554,7 @@ fn text_compare_editor_pane( ) -> AnyElement { let tc = &theme.colors; let scale = theme.metrics.ui_scale(); - let focused = state.focus.get(&state.store) == Some(focus_target); + let focused = state.ui.focus.get(&state.store) == Some(focus_target); let editor = match side { TextCompareSide::Left => &state.text_compare.left_editor, TextCompareSide::Right => &state.text_compare.right_editor, @@ -626,7 +629,7 @@ fn search_bar(state: &AppState, theme: &Theme) -> AnyElement { let search_query = state.editor.search.query.with(&state.store, |s| s.clone()); let match_count = state.editor.search.matches.with(&state.store, |m| m.len()); let active_index = state.editor.search.active_index.get(&state.store); - let search_focused = state.focus.get(&state.store) == Some(FocusTarget::SearchInput); + let search_focused = state.ui.focus.get(&state.store) == Some(FocusTarget::SearchInput); let input = text_input("", &search_query) .placeholder("Find in diff\u{2026}") diff --git a/src/ui/window_chrome.rs b/src/ui/window_chrome.rs index 4715d832..35a087ce 100644 --- a/src/ui/window_chrome.rs +++ b/src/ui/window_chrome.rs @@ -198,7 +198,7 @@ fn repo_chip(label: &str, tc: &ThemeColors, scale: f32) -> AnyElement { } fn update_chip(state: &AppState) -> Option { - match state.update.get(&state.store) { + match state.ui.update.get(&state.store) { UpdateState::Available(update) => Some( Button::new(crate::actions::UpdateAction::InstallUpdate.into()) .icon(lucide::ARROW_DOWN) From 0a36dc9270c1d3c48e54ad26bff056df7661b4ec Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 22:47:37 +0000 Subject: [PATCH 17/25] ci(fuzz): seed corpus, add fuzz targets and a CI smoke job Add seed corpora for all carbon fuzz targets (small unified diffs covering no-newline-at-eof, renames, binary markers, empty/invalid hunk headers, multi-hunk/multi-file, CRLF and non-UTF-8 bytes), two new fuzz targets (patch_parse: parser error paths on arbitrary bytes with structural invariants; review_anchor: anchor-to-projection round-trips and row byte range bounds), and a GitHub Actions smoke job that builds the fuzz targets and runs each for a bounded time on PRs touching crates/carbon or fuzz/. --- .github/workflows/fuzz.yml | 54 +++++++++++++++++ fuzz/.gitattributes | 1 + fuzz/.gitignore | 3 + fuzz/Cargo.lock | 2 +- fuzz/Cargo.toml | 14 +++++ fuzz/corpus/inline_diff/punct.txt | 2 + fuzz/corpus/inline_diff/unicode.txt | 2 + fuzz/corpus/inline_diff/words.txt | 2 + fuzz/corpus/patch_parse/basic.diff | 10 ++++ fuzz/corpus/patch_parse/binary.diff | 3 + fuzz/corpus/patch_parse/deleted_file.diff | 8 +++ fuzz/corpus/patch_parse/empty_hunk.diff | 8 +++ .../patch_parse/invalid_hunk_header.diff | 6 ++ fuzz/corpus/patch_parse/multi_hunk.diff | 21 +++++++ fuzz/corpus/patch_parse/new_file.diff | 8 +++ fuzz/corpus/patch_parse/no_newline.diff | 10 ++++ fuzz/corpus/patch_parse/rename.diff | 12 ++++ fuzz/corpus/patch_projection/basic.diff | 10 ++++ fuzz/corpus/patch_projection/binary.diff | 3 + .../corpus/patch_projection/deleted_file.diff | 8 +++ fuzz/corpus/patch_projection/multi_hunk.diff | 21 +++++++ fuzz/corpus/patch_projection/new_file.diff | 8 +++ fuzz/corpus/patch_projection/no_newline.diff | 10 ++++ fuzz/corpus/patch_projection/rename.diff | 12 ++++ fuzz/corpus/review_anchor/basic.diff | 10 ++++ fuzz/corpus/review_anchor/multi_hunk.diff | 21 +++++++ fuzz/corpus/text_store/binary_bytes.bin | Bin 0 -> 29 bytes fuzz/corpus/text_store/blank_lines.txt | 3 + fuzz/corpus/text_store/crlf_no_trailing.txt | 3 + fuzz/fuzz_targets/patch_parse.rs | 38 ++++++++++++ fuzz/fuzz_targets/review_anchor.rs | 56 ++++++++++++++++++ 31 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 fuzz/.gitattributes create mode 100644 fuzz/.gitignore create mode 100644 fuzz/corpus/inline_diff/punct.txt create mode 100644 fuzz/corpus/inline_diff/unicode.txt create mode 100644 fuzz/corpus/inline_diff/words.txt create mode 100644 fuzz/corpus/patch_parse/basic.diff create mode 100644 fuzz/corpus/patch_parse/binary.diff create mode 100644 fuzz/corpus/patch_parse/deleted_file.diff create mode 100644 fuzz/corpus/patch_parse/empty_hunk.diff create mode 100644 fuzz/corpus/patch_parse/invalid_hunk_header.diff create mode 100644 fuzz/corpus/patch_parse/multi_hunk.diff create mode 100644 fuzz/corpus/patch_parse/new_file.diff create mode 100644 fuzz/corpus/patch_parse/no_newline.diff create mode 100644 fuzz/corpus/patch_parse/rename.diff create mode 100644 fuzz/corpus/patch_projection/basic.diff create mode 100644 fuzz/corpus/patch_projection/binary.diff create mode 100644 fuzz/corpus/patch_projection/deleted_file.diff create mode 100644 fuzz/corpus/patch_projection/multi_hunk.diff create mode 100644 fuzz/corpus/patch_projection/new_file.diff create mode 100644 fuzz/corpus/patch_projection/no_newline.diff create mode 100644 fuzz/corpus/patch_projection/rename.diff create mode 100644 fuzz/corpus/review_anchor/basic.diff create mode 100644 fuzz/corpus/review_anchor/multi_hunk.diff create mode 100644 fuzz/corpus/text_store/binary_bytes.bin create mode 100644 fuzz/corpus/text_store/blank_lines.txt create mode 100644 fuzz/corpus/text_store/crlf_no_trailing.txt create mode 100644 fuzz/fuzz_targets/patch_parse.rs create mode 100644 fuzz/fuzz_targets/review_anchor.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..960bcaa3 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,54 @@ +name: Fuzz + +on: + workflow_dispatch: + pull_request: + paths: + - "crates/carbon/**" + - "fuzz/**" + - ".github/workflows/fuzz.yml" + +permissions: + contents: read + +concurrency: + group: fuzz-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + fuzz-smoke: + name: Carbon fuzz smoke + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + # cargo-fuzz needs nightly for -Z sanitizer flags; rust-toolchain.toml + # already pins nightly, so rustup resolves it from the checkout. + - name: Install Rust toolchain + run: rustup show active-toolchain + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + + - name: Build fuzz targets + run: cargo fuzz build + + - name: Run each fuzz target briefly + run: | + for target in $(cargo fuzz list); do + echo "::group::fuzz $target" + cargo fuzz run "$target" -- -max_total_time=60 -rss_limit_mb=4096 + echo "::endgroup::" + done + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-artifacts + path: fuzz/artifacts/ + if-no-files-found: ignore diff --git a/fuzz/.gitattributes b/fuzz/.gitattributes new file mode 100644 index 00000000..74d338b3 --- /dev/null +++ b/fuzz/.gitattributes @@ -0,0 +1 @@ +corpus/** -text diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 00000000..5208f22a --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,3 @@ +artifacts/ +coverage/ +target/ diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index b9465c08..df5d8207 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -10,7 +10,7 @@ checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "carbon" -version = "0.1.4" +version = "0.1.11" [[package]] name = "carbon-fuzz" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 19bc2957..097bba34 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -33,3 +33,17 @@ path = "fuzz_targets/inline_diff.rs" test = false doc = false bench = false + +[[bin]] +name = "patch_parse" +path = "fuzz_targets/patch_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "review_anchor" +path = "fuzz_targets/review_anchor.rs" +test = false +doc = false +bench = false diff --git a/fuzz/corpus/inline_diff/punct.txt b/fuzz/corpus/inline_diff/punct.txt new file mode 100644 index 00000000..374fddef --- /dev/null +++ b/fuzz/corpus/inline_diff/punct.txt @@ -0,0 +1,2 @@ +a,b;c.d(e)f[g]h +a,B;c.D(e)F[g]H diff --git a/fuzz/corpus/inline_diff/unicode.txt b/fuzz/corpus/inline_diff/unicode.txt new file mode 100644 index 00000000..ba751320 --- /dev/null +++ b/fuzz/corpus/inline_diff/unicode.txt @@ -0,0 +1,2 @@ +naive cafe resume +naĆÆve cafĆ© rĆ©sumĆ© ☃ diff --git a/fuzz/corpus/inline_diff/words.txt b/fuzz/corpus/inline_diff/words.txt new file mode 100644 index 00000000..629d8910 --- /dev/null +++ b/fuzz/corpus/inline_diff/words.txt @@ -0,0 +1,2 @@ +fn render(frame: &mut Frame) -> Result<(), Error> +fn render(scene: &mut Scene) -> Result<(), RenderError> diff --git a/fuzz/corpus/patch_parse/basic.diff b/fuzz/corpus/patch_parse/basic.diff new file mode 100644 index 00000000..c1b5dbf0 --- /dev/null +++ b/fuzz/corpus/patch_parse/basic.diff @@ -0,0 +1,10 @@ +diff --git a/src/lib.rs b/src/lib.rs +index 1111111..2222222 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,4 +1,4 @@ + fn main() { +- println!("old"); ++ println!("new"); + } + diff --git a/fuzz/corpus/patch_parse/binary.diff b/fuzz/corpus/patch_parse/binary.diff new file mode 100644 index 00000000..1b9d3acf --- /dev/null +++ b/fuzz/corpus/patch_parse/binary.diff @@ -0,0 +1,3 @@ +diff --git a/icon.png b/icon.png +index 7777777..8888888 100644 +Binary files a/icon.png and b/icon.png differ diff --git a/fuzz/corpus/patch_parse/deleted_file.diff b/fuzz/corpus/patch_parse/deleted_file.diff new file mode 100644 index 00000000..845f7cc8 --- /dev/null +++ b/fuzz/corpus/patch_parse/deleted_file.diff @@ -0,0 +1,8 @@ +diff --git a/gone.txt b/gone.txt +deleted file mode 100644 +index aaaaaaa..0000000 +--- a/gone.txt ++++ /dev/null +@@ -1,2 +0,0 @@ +-hello +-world diff --git a/fuzz/corpus/patch_parse/empty_hunk.diff b/fuzz/corpus/patch_parse/empty_hunk.diff new file mode 100644 index 00000000..1be6f266 --- /dev/null +++ b/fuzz/corpus/patch_parse/empty_hunk.diff @@ -0,0 +1,8 @@ +diff --git a/x b/x +index 1234567..89abcde 100644 +--- a/x ++++ b/x +@@ -3,0 +4,0 @@ empty counts +@@ -5 +6 @@ implicit counts +-five ++six diff --git a/fuzz/corpus/patch_parse/invalid_hunk_header.diff b/fuzz/corpus/patch_parse/invalid_hunk_header.diff new file mode 100644 index 00000000..991892e6 --- /dev/null +++ b/fuzz/corpus/patch_parse/invalid_hunk_header.diff @@ -0,0 +1,6 @@ +diff --git a/x b/x +--- a/x ++++ b/x +@@ -1,oops +1 @@ +-bad ++good diff --git a/fuzz/corpus/patch_parse/multi_hunk.diff b/fuzz/corpus/patch_parse/multi_hunk.diff new file mode 100644 index 00000000..997b5f58 --- /dev/null +++ b/fuzz/corpus/patch_parse/multi_hunk.diff @@ -0,0 +1,21 @@ +diff --git a/multi.txt b/multi.txt +index bbbbbbb..ccccccc 100644 +--- a/multi.txt ++++ b/multi.txt +@@ -1,3 +1,3 @@ fn top() + a +-b ++B + c +@@ -10,3 +10,4 @@ fn bottom() + x + y ++y2 + z +diff --git a/other.txt b/other.txt +index ddddddd..eeeeeee 100644 +--- a/other.txt ++++ b/other.txt +@@ -1 +1 @@ +-one ++uno diff --git a/fuzz/corpus/patch_parse/new_file.diff b/fuzz/corpus/patch_parse/new_file.diff new file mode 100644 index 00000000..3e38bc3e --- /dev/null +++ b/fuzz/corpus/patch_parse/new_file.diff @@ -0,0 +1,8 @@ +diff --git a/added.txt b/added.txt +new file mode 100644 +index 0000000..9999999 +--- /dev/null ++++ b/added.txt +@@ -0,0 +1,2 @@ ++hello ++world diff --git a/fuzz/corpus/patch_parse/no_newline.diff b/fuzz/corpus/patch_parse/no_newline.diff new file mode 100644 index 00000000..fa641069 --- /dev/null +++ b/fuzz/corpus/patch_parse/no_newline.diff @@ -0,0 +1,10 @@ +diff --git a/a.txt b/a.txt +index 3333333..4444444 100644 +--- a/a.txt ++++ b/a.txt +@@ -1,2 +1,2 @@ + first +-old end +\ No newline at end of file ++new end +\ No newline at end of file diff --git a/fuzz/corpus/patch_parse/rename.diff b/fuzz/corpus/patch_parse/rename.diff new file mode 100644 index 00000000..fb4457be --- /dev/null +++ b/fuzz/corpus/patch_parse/rename.diff @@ -0,0 +1,12 @@ +diff --git a/old_name.rs b/new_name.rs +similarity index 95% +rename from old_name.rs +rename to new_name.rs +index 5555555..6666666 100644 +--- a/old_name.rs ++++ b/new_name.rs +@@ -2,3 +2,3 @@ + line one +-line two ++line 2 + line three diff --git a/fuzz/corpus/patch_projection/basic.diff b/fuzz/corpus/patch_projection/basic.diff new file mode 100644 index 00000000..c1b5dbf0 --- /dev/null +++ b/fuzz/corpus/patch_projection/basic.diff @@ -0,0 +1,10 @@ +diff --git a/src/lib.rs b/src/lib.rs +index 1111111..2222222 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,4 +1,4 @@ + fn main() { +- println!("old"); ++ println!("new"); + } + diff --git a/fuzz/corpus/patch_projection/binary.diff b/fuzz/corpus/patch_projection/binary.diff new file mode 100644 index 00000000..1b9d3acf --- /dev/null +++ b/fuzz/corpus/patch_projection/binary.diff @@ -0,0 +1,3 @@ +diff --git a/icon.png b/icon.png +index 7777777..8888888 100644 +Binary files a/icon.png and b/icon.png differ diff --git a/fuzz/corpus/patch_projection/deleted_file.diff b/fuzz/corpus/patch_projection/deleted_file.diff new file mode 100644 index 00000000..845f7cc8 --- /dev/null +++ b/fuzz/corpus/patch_projection/deleted_file.diff @@ -0,0 +1,8 @@ +diff --git a/gone.txt b/gone.txt +deleted file mode 100644 +index aaaaaaa..0000000 +--- a/gone.txt ++++ /dev/null +@@ -1,2 +0,0 @@ +-hello +-world diff --git a/fuzz/corpus/patch_projection/multi_hunk.diff b/fuzz/corpus/patch_projection/multi_hunk.diff new file mode 100644 index 00000000..997b5f58 --- /dev/null +++ b/fuzz/corpus/patch_projection/multi_hunk.diff @@ -0,0 +1,21 @@ +diff --git a/multi.txt b/multi.txt +index bbbbbbb..ccccccc 100644 +--- a/multi.txt ++++ b/multi.txt +@@ -1,3 +1,3 @@ fn top() + a +-b ++B + c +@@ -10,3 +10,4 @@ fn bottom() + x + y ++y2 + z +diff --git a/other.txt b/other.txt +index ddddddd..eeeeeee 100644 +--- a/other.txt ++++ b/other.txt +@@ -1 +1 @@ +-one ++uno diff --git a/fuzz/corpus/patch_projection/new_file.diff b/fuzz/corpus/patch_projection/new_file.diff new file mode 100644 index 00000000..3e38bc3e --- /dev/null +++ b/fuzz/corpus/patch_projection/new_file.diff @@ -0,0 +1,8 @@ +diff --git a/added.txt b/added.txt +new file mode 100644 +index 0000000..9999999 +--- /dev/null ++++ b/added.txt +@@ -0,0 +1,2 @@ ++hello ++world diff --git a/fuzz/corpus/patch_projection/no_newline.diff b/fuzz/corpus/patch_projection/no_newline.diff new file mode 100644 index 00000000..fa641069 --- /dev/null +++ b/fuzz/corpus/patch_projection/no_newline.diff @@ -0,0 +1,10 @@ +diff --git a/a.txt b/a.txt +index 3333333..4444444 100644 +--- a/a.txt ++++ b/a.txt +@@ -1,2 +1,2 @@ + first +-old end +\ No newline at end of file ++new end +\ No newline at end of file diff --git a/fuzz/corpus/patch_projection/rename.diff b/fuzz/corpus/patch_projection/rename.diff new file mode 100644 index 00000000..fb4457be --- /dev/null +++ b/fuzz/corpus/patch_projection/rename.diff @@ -0,0 +1,12 @@ +diff --git a/old_name.rs b/new_name.rs +similarity index 95% +rename from old_name.rs +rename to new_name.rs +index 5555555..6666666 100644 +--- a/old_name.rs ++++ b/new_name.rs +@@ -2,3 +2,3 @@ + line one +-line two ++line 2 + line three diff --git a/fuzz/corpus/review_anchor/basic.diff b/fuzz/corpus/review_anchor/basic.diff new file mode 100644 index 00000000..c1b5dbf0 --- /dev/null +++ b/fuzz/corpus/review_anchor/basic.diff @@ -0,0 +1,10 @@ +diff --git a/src/lib.rs b/src/lib.rs +index 1111111..2222222 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,4 +1,4 @@ + fn main() { +- println!("old"); ++ println!("new"); + } + diff --git a/fuzz/corpus/review_anchor/multi_hunk.diff b/fuzz/corpus/review_anchor/multi_hunk.diff new file mode 100644 index 00000000..997b5f58 --- /dev/null +++ b/fuzz/corpus/review_anchor/multi_hunk.diff @@ -0,0 +1,21 @@ +diff --git a/multi.txt b/multi.txt +index bbbbbbb..ccccccc 100644 +--- a/multi.txt ++++ b/multi.txt +@@ -1,3 +1,3 @@ fn top() + a +-b ++B + c +@@ -10,3 +10,4 @@ fn bottom() + x + y ++y2 + z +diff --git a/other.txt b/other.txt +index ddddddd..eeeeeee 100644 +--- a/other.txt ++++ b/other.txt +@@ -1 +1 @@ +-one ++uno diff --git a/fuzz/corpus/text_store/binary_bytes.bin b/fuzz/corpus/text_store/binary_bytes.bin new file mode 100644 index 0000000000000000000000000000000000000000..1adcef9857f073039e196399813edef380260e70 GIT binary patch literal 29 kcmd1JtVm7aV)*}0At^I2v8Ym^K_RKKB(<0;H7|t=0In_y_5c6? literal 0 HcmV?d00001 diff --git a/fuzz/corpus/text_store/blank_lines.txt b/fuzz/corpus/text_store/blank_lines.txt new file mode 100644 index 00000000..b28b04f6 --- /dev/null +++ b/fuzz/corpus/text_store/blank_lines.txt @@ -0,0 +1,3 @@ + + + diff --git a/fuzz/corpus/text_store/crlf_no_trailing.txt b/fuzz/corpus/text_store/crlf_no_trailing.txt new file mode 100644 index 00000000..69cea06d --- /dev/null +++ b/fuzz/corpus/text_store/crlf_no_trailing.txt @@ -0,0 +1,3 @@ +a +b +no trailing newline \ No newline at end of file diff --git a/fuzz/fuzz_targets/patch_parse.rs b/fuzz/fuzz_targets/patch_parse.rs new file mode 100644 index 00000000..2f92a73e --- /dev/null +++ b/fuzz/fuzz_targets/patch_parse.rs @@ -0,0 +1,38 @@ +#![no_main] + +use carbon::{BlockId, FileId, HunkId, parse_unified_patch, usize_to_u32_saturating}; +use libfuzzer_sys::fuzz_target; + +// Exercises the parser's error paths on arbitrary (possibly non-UTF-8) bytes +// and checks structural invariants of any document it accepts. +fuzz_target!(|bytes: &[u8]| { + let input = String::from_utf8_lossy(bytes); + let Ok(document) = parse_unified_patch(&input) else { + return; + }; + + assert!(!document.files.is_empty()); + for (file_index, file) in document.files.iter().enumerate() { + assert_eq!(file.id, FileId(usize_to_u32_saturating(file_index))); + for (hunk_index, hunk) in file.hunks.iter().enumerate() { + assert_eq!(hunk.id, HunkId(usize_to_u32_saturating(hunk_index))); + assert!(file.hunk(hunk.id).is_some()); + assert!(hunk.blocks.end() as usize <= file.blocks.len()); + assert_eq!(file.hunk_blocks(hunk).len() as u32, hunk.blocks.len); + assert!(hunk.old_start_index() <= hunk.old_end_index()); + assert!(hunk.new_start_index() <= hunk.new_end_index()); + } + for (block_index, block) in file.blocks.iter().enumerate() { + assert_eq!(block.id, BlockId(usize_to_u32_saturating(block_index))); + assert!(file.block(block.id).is_some()); + if block.old.len > 0 { + let text = file.old_text.as_ref(); + assert!(text.is_some_and(|text| block.old.end() <= text.line_count())); + } + if block.new.len > 0 { + let text = file.new_text.as_ref(); + assert!(text.is_some_and(|text| block.new.end() <= text.line_count())); + } + } + } +}); diff --git a/fuzz/fuzz_targets/review_anchor.rs b/fuzz/fuzz_targets/review_anchor.rs new file mode 100644 index 00000000..0c198312 --- /dev/null +++ b/fuzz/fuzz_targets/review_anchor.rs @@ -0,0 +1,56 @@ +#![no_main] + +use carbon::{ + Anchor, DiffSide, ExpansionState, LineRange, ProjectionOptions, map_anchor_to_projection, + parse_unified_patch, project_file, projected_row_byte_range, +}; +use libfuzzer_sys::fuzz_target; + +// Round-trips arbitrary review anchors against projected rows and checks that +// row-to-text byte ranges stay inside the side text stores. +fuzz_target!(|data: (&str, u8, u32, u32)| { + let (input, side_byte, start, len) = data; + let Ok(document) = parse_unified_patch(input) else { + return; + }; + + let side = match side_byte % 3 { + 0 => Some(DiffSide::Old), + 1 => Some(DiffSide::New), + _ => None, + }; + + for file in &document.files { + let mut rows = Vec::new(); + project_file( + file, + ProjectionOptions::default(), + &ExpansionState::default(), + |row| rows.push(row), + ); + + let anchor = Anchor { + side, + line_range: LineRange::new(start, len), + ..Anchor::file(file.id) + }; + let touched = map_anchor_to_projection(&anchor, &rows); + let expected = rows.iter().filter(|row| anchor.touches_row(row)).count(); + assert_eq!(touched.len(), expected); + for row in &touched { + assert!(anchor.touches_row(row)); + } + + for row in &rows { + for side in [DiffSide::Old, DiffSide::New] { + let Some(range) = projected_row_byte_range(file, row, side) else { + continue; + }; + let text = file + .side_text(side) + .expect("a projected byte range implies side text exists"); + assert!(range.start.saturating_add(range.len) <= text.len()); + } + } + } +}); From dfdec1c37d38bb116dffed91b00b98621c94c8b2 Mon Sep 17 00:00:00 2001 From: rohit Date: Tue, 9 Jun 2026 23:02:39 +0000 Subject: [PATCH 18/25] refactor(ui): adopt {@sig} reactive attributes in view! call sites --- src/ui/components/sidebar.rs | 4 ++-- src/ui/settings_page.rs | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/ui/components/sidebar.rs b/src/ui/components/sidebar.rs index f2701a0b..349b1676 100644 --- a/src/ui/components/sidebar.rs +++ b/src/ui/components/sidebar.rs @@ -99,7 +99,7 @@ impl<'a> Sidebar<'a> { }; let total_height = state.file_list_total_content_height(file_count); - let scroll_px = state.file_list.scroll_offset_px.get(&state.store); + let cx = &*state.store; view! { scale,
Sidebar<'a> { semantic_role={SemanticRole::ScrollArea} px={Sp::LG / Sp::XXS} gap={Sp::XS} - scroll_y={scroll_px} + scroll_y={@state.file_list.scroll_offset_px} scroll_total={total_height} on_scroll={ScrollActionBuilder::FileList}> for index in 0..file_count { diff --git a/src/ui/settings_page.rs b/src/ui/settings_page.rs index 802de248..19c633ea 100644 --- a/src/ui/settings_page.rs +++ b/src/ui/settings_page.rs @@ -167,8 +167,7 @@ fn keymaps_layout(state: &AppState, theme: &Theme) -> AnyElement { let scale = theme.metrics.ui_scale(); let inner_max_w = (Sz::SETTINGS_KEYMAPS_MAX_W * scale).round(); let capture = state.ui.keymap_capture.get(&state.store); - let scroll_px = state.ui.keymaps_scroll_top_px.get(&state.store); - let total_h = state.ui.keymaps_content_height_px.get(&state.store); + let cx = &*state.store; let groups: Vec = shortcut_groups() .iter() @@ -189,8 +188,8 @@ fn keymaps_layout(state: &AppState, theme: &Theme) -> AnyElement {
Date: Tue, 9 Jun 2026 23:30:10 +0000 Subject: [PATCH 19/25] fix: address self-review findings Text compares stamped jobs from their own generation counter and overwrote workspace.compare_generation with it, rewinding the shared counter below CompareScheduler's monotonic epoch high-water mark. After that, every scheduler-routed repo job (LoadFile, LoadStats, LoadFileStats) was silently dropped, leaving selected files stuck on 'Loading diff...' with no stats until enough new compares climbed back past the stale epoch. Fix: text and repo compares now share one monotonic generation space. kickoff_text_compare seeds its bump from whichever counter is ahead, and handle_text_compare_finished drops results superseded by any newer workspace compare instead of rewinding the counter. Documented the forward-only invariant on WorkspaceState::compare_generation and added a regression test pinning it. --- src/ui/state/tests.rs | 32 ++++++++++++++++++++++++++++++++ src/ui/state/text_compare.rs | 24 +++++++++++++++++++----- src/ui/state/workspace.rs | 5 +++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/ui/state/tests.rs b/src/ui/state/tests.rs index 93d10b94..7bf762df 100644 --- a/src/ui/state/tests.rs +++ b/src/ui/state/tests.rs @@ -201,12 +201,44 @@ fn stale_text_compare_finished_event_is_ignored() { assert_eq!(state.text_compare.view, TextCompareView::Edit); } +// Regression test: `CompareScheduler` keeps a monotonic epoch high-water +// mark, so a text compare that rewinds `workspace.compare_generation` below +// it makes every later repo file/stats job get dropped silently (perpetual +// "Loading diff..."). Text compares must bump the shared counter forward. +#[test] +fn text_compare_generation_never_rewinds_workspace_generation() { + let mut state = AppState::default(); + // Simulate prior repo compares having advanced the shared counter (and + // with it the scheduler epoch). + state.workspace.compare_generation.set(&state.store, 5); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + let effects = state.apply_action(crate::actions::TextCompareAction::CompareNow); + let generation = effects + .iter() + .find_map(|effect| match effect { + Effect::Compare(CompareEffect::RunText(task)) => Some(task.generation), + _ => None, + }) + .unwrap(); + + assert!(generation > 5); + assert_eq!( + state.workspace.compare_generation.get(&state.store), + generation + ); + assert_eq!(state.text_compare.generation, generation); +} + #[test] fn text_compare_finished_installs_diff_view() { let mut state = AppState::default(); state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); let generation = state.text_compare.generation.saturating_add(1); state.text_compare.generation = generation; + state + .workspace + .compare_generation + .set(&state.store, generation); let output = crate::core::compare::compare_text( "old\n", "new\n", diff --git a/src/ui/state/text_compare.rs b/src/ui/state/text_compare.rs index 2efd8f49..38f19e18 100644 --- a/src/ui/state/text_compare.rs +++ b/src/ui/state/text_compare.rs @@ -102,7 +102,17 @@ impl AppState { .source .set(&self.store, WorkspaceSource::TextCompare); } - let generation = self.text_compare.generation.saturating_add(1); + // Text and repo compares share one workspace-wide generation space: + // `CompareScheduler`'s epoch is a monotonic high-water mark, so seed + // the bump from whichever counter is ahead. Deriving it from + // `text_compare.generation` alone would rewind + // `workspace.compare_generation` below the scheduler epoch and every + // later repo file/stats job would be silently dropped as stale. + let generation = self + .text_compare + .generation + .max(self.workspace.compare_generation.get(&self.store)) + .saturating_add(1); self.text_compare.generation = generation; self.text_compare.status = AsyncStatus::Loading; self.workspace @@ -137,7 +147,14 @@ impl AppState { &mut self, payload: TextCompareFinished, ) -> Vec { - if payload.generation != self.text_compare.generation { + // Drop results superseded by a newer text compare (text generation + // moved on) or by any newer workspace compare (repo compare, cancel, + // or repo open bumped `compare_generation` past us). Rewinding the + // workspace generation here would strand it below the scheduler's + // monotonic epoch. + if payload.generation != self.text_compare.generation + || payload.generation != self.workspace.compare_generation.get(&self.store) + { return Vec::new(); } @@ -150,9 +167,6 @@ impl AppState { .set(&self.store, WorkspaceSource::TextCompare); self.workspace.status.set(&self.store, AsyncStatus::Ready); self.workspace.mode.set(&self.store, WorkspaceMode::Ready); - self.workspace - .compare_generation - .set(&self.store, payload.generation); self.compare.layout.set(&self.store, payload.layout); self.compare.renderer.set(&self.store, payload.renderer); self.compare diff --git a/src/ui/state/workspace.rs b/src/ui/state/workspace.rs index 89782aef..da83397b 100644 --- a/src/ui/state/workspace.rs +++ b/src/ui/state/workspace.rs @@ -64,6 +64,11 @@ pub struct WorkspaceState { pub source: WorkspaceSource, pub status: AsyncStatus, pub status_operation_pending: bool, + /// Shared generation counter for repo *and* text compares. Must only move + /// forward within a session: `CompareScheduler` keeps a monotonic epoch + /// high-water mark and silently drops jobs stamped below it, so any path + /// that writes this signal must bump from the current value (or take a + /// max), never assign an independent counter. pub compare_generation: u64, pub status_generation: u64, pub files: Vec, From f0e67d180a0fb849b6bde79d888945a50669d073 Mon Sep 17 00:00:00 2001 From: rohit Date: Wed, 10 Jun 2026 00:05:09 +0000 Subject: [PATCH 20/25] fix(carbon): treat any backslash hunk line as a no-newline marker The patch parser only recognized the exact English "\ No newline at end of file" text. diff/git localize that message, so any other marker text fell through and was pushed as a context line into a text store whose trailing separator had already been trimmed, leaving block ranges pointing one line past the stored text (CI fuzz finding on the patch_parse target). Only the backslash prefix is structural, so match on that, and make push_text_line restore the separator defensively so stored line counts can never desync from block ranges on malformed input. Adds the fuzz crash as a corpus seed and two regression tests. --- crates/carbon/src/patch.rs | 65 ++++++++++++++++++- .../crash-no-newline-marker-mutation | 10 +++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 fuzz/corpus/patch_parse/crash-no-newline-marker-mutation diff --git a/crates/carbon/src/patch.rs b/crates/carbon/src/patch.rs index a8e56f4f..dfbc2aa0 100644 --- a/crates/carbon/src/patch.rs +++ b/crates/carbon/src/patch.rs @@ -203,7 +203,10 @@ impl FileBuilder { return; }; - if line == r"\ No newline at end of file" { + // Any `\`-prefixed hunk line is a "no newline at end of file" marker; + // the message text is localized by diff/git, so only the prefix is + // structural. + if line.starts_with('\\') { match hunk.last_side { Some(DiffSide::Old) => { hunk.mark_old_no_newline(); @@ -483,6 +486,12 @@ fn strip_patch_path(path: &str) -> Option<&str> { } fn push_text_line(text: &mut String, content: &str) { + // Malformed input can append lines after a no-newline marker already + // trimmed the trailing separator; restore it so stored line counts stay + // in sync with the block ranges counted by the hunk builder. + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } text.push_str(content); text.push('\n'); } @@ -557,4 +566,58 @@ diff --git a/a.txt b/a.txt assert!(file.old_text.as_ref().unwrap().no_newline_at_eof()); assert!(file.new_text.as_ref().unwrap().no_newline_at_eof()); } + + #[test] + fn parses_localized_no_newline_marker() { + let patch = "\ +diff --git a/a.txt b/a.txt +--- a/a.txt ++++ b/a.txt +@@ -1 +1 @@ +-old +\\ Pas de fin de ligne a la fin du fichier ++new +\\ Kein Zeilenumbruch am Dateiende +"; + let document = parse_unified_patch(patch).unwrap(); + let file = &document.files[0]; + let block = &file.blocks[0]; + + assert!(block.old_no_newline_at_end); + assert!(block.new_no_newline_at_end); + assert!(file.old_text.as_ref().unwrap().no_newline_at_eof()); + assert!(file.new_text.as_ref().unwrap().no_newline_at_eof()); + } + + // Regression for a fuzz-found inconsistency: a malformed `\` line after a + // no-newline marker was pushed as a context line into a store whose + // trailing separator had been trimmed, so block ranges pointed one line + // past the stored text. + #[test] + fn block_ranges_stay_within_text_stores_for_malformed_marker() { + let patch = "\ +diff --git a/a.txt b/a.txt +index 3333333..4444444 100644 +--- a/a.txt ++++ b/a.txt +@@ -1,2 +1,2 @@ + first +-old end +\\ No newline at end of file ++new end +\\ N[ newline at end of file +"; + let document = parse_unified_patch(patch).unwrap(); + let file = &document.files[0]; + for block in &file.blocks { + if block.old.len > 0 { + let text = file.old_text.as_ref().unwrap(); + assert!(block.old.end() <= text.line_count()); + } + if block.new.len > 0 { + let text = file.new_text.as_ref().unwrap(); + assert!(block.new.end() <= text.line_count()); + } + } + } } diff --git a/fuzz/corpus/patch_parse/crash-no-newline-marker-mutation b/fuzz/corpus/patch_parse/crash-no-newline-marker-mutation new file mode 100644 index 00000000..3f60cbf0 --- /dev/null +++ b/fuzz/corpus/patch_parse/crash-no-newline-marker-mutation @@ -0,0 +1,10 @@ +diff --git a/a.txt b/a.txt +index 3333333..4444444 100644 +--- a/a.txt ++++ b/a.txt +@@ -1,2 +1,2 @@ + first +-old end +\ No newline at end of file ++new end +\ N[ newline at end of file From be0b5665592450dc2329cd6347d34e486f77f4fb Mon Sep 17 00:00:00 2001 From: rohit Date: Wed, 10 Jun 2026 00:12:22 +0000 Subject: [PATCH 21/25] fix(carbon): keep no-newline trim from erasing empty final lines Second fuzz finding on the patch_parse target: when a no-newline marker followed an empty line, trim_trailing_newline popped the separator and erased the empty line from the text store entirely, leaving the hunk's block ranges one line past the stored text. trim_trailing_newline now trims only when the final stored line is non-empty (an empty line without its separator is not representable) and reports whether it trimmed so the block's no-newline flag stays in sync with the store. Adds the crash as a corpus seed and a minimized regression test. --- crates/carbon/src/patch.rs | 50 ++++++++++++++++--- .../patch_parse/crash-marker-after-empty-line | 11 ++++ 2 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 fuzz/corpus/patch_parse/crash-marker-after-empty-line diff --git a/crates/carbon/src/patch.rs b/crates/carbon/src/patch.rs index dfbc2aa0..daa6de52 100644 --- a/crates/carbon/src/patch.rs +++ b/crates/carbon/src/patch.rs @@ -209,12 +209,14 @@ impl FileBuilder { if line.starts_with('\\') { match hunk.last_side { Some(DiffSide::Old) => { - hunk.mark_old_no_newline(); - trim_trailing_newline(&mut self.old_text); + if trim_trailing_newline(&mut self.old_text) { + hunk.mark_old_no_newline(); + } } Some(DiffSide::New) => { - hunk.mark_new_no_newline(); - trim_trailing_newline(&mut self.new_text); + if trim_trailing_newline(&mut self.new_text) { + hunk.mark_new_no_newline(); + } } None => {} } @@ -496,9 +498,17 @@ fn push_text_line(text: &mut String, content: &str) { text.push('\n'); } -fn trim_trailing_newline(text: &mut String) { - if text.ends_with('\n') { +/// Drops the trailing separator so the final line is stored without a +/// newline. Leaves the text untouched and returns false when the final line +/// is empty: popping its separator would erase the line entirely and desync +/// stored line counts from the hunk's block ranges. +fn trim_trailing_newline(text: &mut String) -> bool { + let bytes = text.as_bytes(); + if bytes.len() >= 2 && bytes[bytes.len() - 1] == b'\n' && bytes[bytes.len() - 2] != b'\n' { text.pop(); + true + } else { + false } } @@ -606,6 +616,34 @@ index 3333333..4444444 100644 \\ No newline at end of file +new end \\ N[ newline at end of file +"; + let document = parse_unified_patch(patch).unwrap(); + let file = &document.files[0]; + for block in &file.blocks { + if block.old.len > 0 { + let text = file.old_text.as_ref().unwrap(); + assert!(block.old.end() <= text.line_count()); + } + if block.new.len > 0 { + let text = file.new_text.as_ref().unwrap(); + assert!(block.new.end() <= text.line_count()); + } + } + } + + // Regression for a fuzz-found inconsistency: a no-newline marker after an + // empty line trimmed the separator and erased the line from the text + // store, leaving block ranges one line past the stored text. + #[test] + fn no_newline_marker_after_empty_line_keeps_counts_in_sync() { + let patch = "\ +diff --git a/a.txt b/a.txt +--- a/a.txt ++++ b/a.txt +@@ -1,2 +1,2 @@ + first + +\\ No newline at end of file "; let document = parse_unified_patch(patch).unwrap(); let file = &document.files[0]; diff --git a/fuzz/corpus/patch_parse/crash-marker-after-empty-line b/fuzz/corpus/patch_parse/crash-marker-after-empty-line new file mode 100644 index 00000000..1f359f22 --- /dev/null +++ b/fuzz/corpus/patch_parse/crash-marker-after-empty-line @@ -0,0 +1,11 @@ +diff txt ++++ b/a.txt +@@ -1,2 +1,2 @@ + first +-o1,2 @@ + first + +\ N at en a/a.’’’’ rst +-old end +\ No newline at end of --nd +\ No newlf file From 432681a9d08c161fc7a102c90669c76353d7f04c Mon Sep 17 00:00:00 2001 From: rohit Date: Wed, 10 Jun 2026 00:16:45 +0000 Subject: [PATCH 22/25] fix(carbon): cap speculative hunk reservations from untrusted headers Third fuzz finding on the patch_parse target: start_hunk pre-reserved old/new text capacity as header_count * 32 bytes, so a hostile hunk header like @@ -0,3333333330 +1,2 @@ forced a ~100 GB allocation (libFuzzer OOM at the 4 GiB rss cap). Cap the warm-up reservation at 1 MiB per side; real content still grows the buffers as it is pushed. Adds the crash as a corpus seed. --- crates/carbon/src/patch.rs | 18 ++++++++++++++---- fuzz/corpus/patch_parse/crash-huge-hunk-count | Bin 0 -> 134 bytes 2 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 fuzz/corpus/patch_parse/crash-huge-hunk-count diff --git a/crates/carbon/src/patch.rs b/crates/carbon/src/patch.rs index daa6de52..744782ed 100644 --- a/crates/carbon/src/patch.rs +++ b/crates/carbon/src/patch.rs @@ -178,10 +178,20 @@ impl FileBuilder { fn start_hunk(&mut self, line: &str) -> Result<(), PatchError> { self.finish_hunk(); let (old_start, old_count, new_start, new_count) = parse_hunk_header(line)?; - self.old_text - .reserve(u32_to_usize_saturating(old_count).saturating_mul(32)); - self.new_text - .reserve(u32_to_usize_saturating(new_count).saturating_mul(32)); + // Header counts are untrusted input; the reservation is only a + // warm-up, so cap it to keep a hostile count from forcing a huge + // allocation. Real content still grows the buffers as it is pushed. + const MAX_HUNK_RESERVE_BYTES: usize = 1 << 20; + self.old_text.reserve( + u32_to_usize_saturating(old_count) + .saturating_mul(32) + .min(MAX_HUNK_RESERVE_BYTES), + ); + self.new_text.reserve( + u32_to_usize_saturating(new_count) + .saturating_mul(32) + .min(MAX_HUNK_RESERVE_BYTES), + ); self.file.hunks.reserve(1); self.file.blocks.reserve(3); self.hunk = Some(HunkBuilder::new( diff --git a/fuzz/corpus/patch_parse/crash-huge-hunk-count b/fuzz/corpus/patch_parse/crash-huge-hunk-count new file mode 100644 index 0000000000000000000000000000000000000000..41d62387e6e0599ddba5737ddbe5f545ac08b962 GIT binary patch literal 134 zcmbR7)qsKF3S&xUTAG5cZhB^kLPZIegM)&ufsQd07$|5P>KG|FIB+RgaDf$;0M$TL xfh1tEy1GcFBgsYaDCno8mg(n}azQ{ol;%n-NmVG}0RZnOA1D9- literal 0 HcmV?d00001 From f34f677479e4b7759aa3786067575661fdb18b62 Mon Sep 17 00:00:00 2001 From: ro Date: Tue, 9 Jun 2026 22:14:46 -0700 Subject: [PATCH 23/25] fix(jj): pr view now works in jj repos --- src/apprt/services.rs | 46 ++++++++++++++++++++++++++++++------- src/core/vcs/git/mod.rs | 2 +- src/core/vcs/git/service.rs | 9 +++++++- src/core/vcs/jj/service.rs | 45 +++++++++++++++++++++++++++++++----- src/core/vcs/model.rs | 12 ++++++++++ 5 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/apprt/services.rs b/src/apprt/services.rs index 8d5c0e4e..de149791 100644 --- a/src/apprt/services.rs +++ b/src/apprt/services.rs @@ -22,10 +22,11 @@ use crate::core::review::{ReviewDecision, ReviewSession, ReviewSessionKey, Revie use crate::core::syntax::annotator::{ FullFileSyntax, SourceLineWindow, carbon_window_source_line_bounds, }; +use crate::core::vcs::backend::VcsRepository; use crate::core::vcs::discovery; -use crate::core::vcs::git::pr_ref_path; +use crate::core::vcs::git::{is_pr_ref, pr_ref_path}; use crate::core::vcs::model::RevisionId; -use crate::core::vcs::model::{VcsCompareRequest, VcsCompareSpec}; +use crate::core::vcs::model::{VcsCompareRequest, VcsCompareSpec, VcsKind}; use crate::effects::{ CompareFileRequest, CompareFileStatsRequest, CompareHistoryRequest, CompareRequest, CompareStatsRequest, GenerateCommitMessageRequest, LoadFileSyntaxRequest, StatusDiffRequest, @@ -50,6 +51,20 @@ const SYNTAX_FULL_HIGHLIGHT_BYTE_LIMIT: usize = 512 * 1024; /// large relative to viewport tiles so scrolling reuses cached windows. const SYNTAX_WINDOW_BUCKET_LINES: usize = 2048; +/// PR comparison refs live in the git ref namespace (`refs/diffy/pr/...`), +/// which jj revsets cannot resolve. Route those comparisons through the git +/// backend, which in colocated jj checkouts operates on the same `.git` store. +fn open_compare_repository<'a>( + repo_path: &Path, + refs: impl IntoIterator, +) -> Result> { + if refs.into_iter().any(is_pr_ref) { + discovery::open_git_repository(repo_path) + } else { + discovery::open_repository(repo_path) + } +} + #[derive(Debug, Clone)] pub struct AppServices { settings_store: SettingsStore, @@ -304,7 +319,7 @@ impl AppServices { r.phase(ComparePhase::OpeningRepo); } let stage_started = Instant::now(); - let mut repo = discovery::open_repository(&request.repo_path)?; + let mut repo = open_compare_repository(&request.repo_path, request.request.spec.refs())?; tracing::info!( generation, elapsed_ms = stage_started.elapsed().as_millis(), @@ -389,7 +404,7 @@ impl AppServices { generation: u64, request: CompareFileRequest, ) -> Result { - let mut repo = discovery::open_repository(&request.repo_path)?; + let mut repo = open_compare_repository(&request.repo_path, request.request.spec.refs())?; let mut output = repo.compare_path( &request.request, &request.path, @@ -413,7 +428,7 @@ impl AppServices { generation: u64, request: CompareStatsRequest, ) -> Result { - let mut repo = discovery::open_repository(&request.repo_path)?; + let mut repo = open_compare_repository(&request.repo_path, request.request.spec.refs())?; let (additions, deletions) = repo.compare_stats(&request.request)?; Ok(CompareStatsReady { @@ -428,7 +443,10 @@ impl AppServices { generation: u64, request: CompareHistoryRequest, ) -> Result { - let mut repo = discovery::open_repository(&request.repo_path)?; + let mut repo = open_compare_repository( + &request.repo_path, + [request.left_ref.as_str(), request.right_ref.as_str()], + )?; let range_commits = repo.compare_history(&request.left_ref, &request.right_ref, 500)?; Ok(CompareHistoryReady { @@ -442,7 +460,7 @@ impl AppServices { generation: u64, request: CompareFileStatsRequest, ) -> Result { - let mut repo = discovery::open_repository(&request.repo_path)?; + let mut repo = open_compare_repository(&request.repo_path, request.request.spec.refs())?; let files = request .files .iter() @@ -478,7 +496,10 @@ impl AppServices { if !is_current() { return Vec::new(); } - let Ok(mut repo) = discovery::open_repository(&request.repo_path) else { + let Ok(mut repo) = open_compare_repository( + &request.repo_path, + [request.left_ref.as_str(), request.right_ref.as_str()], + ) else { return Vec::new(); }; @@ -698,6 +719,9 @@ impl AppServices { .to_owned(), )); } + if repo.location().kind != VcsKind::GIT { + repo = discovery::open_git_repository(repo_path)?; + } let stage_started = Instant::now(); let (info, left_ref, right_ref) = repo.resolve_pull_request_comparison(url, &token)?; tracing::info!( @@ -729,6 +753,12 @@ impl AppServices { if !repo.capabilities().github_pull_requests { return Ok(None); } + if repo.location().kind != VcsKind::GIT { + let Ok(git_repo) = discovery::open_git_repository(repo_path) else { + return Ok(None); + }; + repo = git_repo; + } for session in sessions.into_iter().rev() { let info = session.pull_request; diff --git a/src/core/vcs/git/mod.rs b/src/core/vcs/git/mod.rs index 5b7ff9ac..84ac875b 100644 --- a/src/core/vcs/git/mod.rs +++ b/src/core/vcs/git/mod.rs @@ -5,6 +5,6 @@ pub mod status; pub use adapter::GitBackend; pub use service::{ BranchInfo, CommitInfo, GitService, INDEX_REF, PatchApplyTarget, PullError, PullOutcome, - TagInfo, WORKDIR_REF, pr_ref_path, + TagInfo, WORKDIR_REF, is_pr_ref, pr_ref_path, }; pub use status::{StatusItem, StatusOperation, StatusScope}; diff --git a/src/core/vcs/git/service.rs b/src/core/vcs/git/service.rs index 1bf817c0..0eed8d39 100644 --- a/src/core/vcs/git/service.rs +++ b/src/core/vcs/git/service.rs @@ -104,6 +104,10 @@ pub fn pr_ref_path(pr_number: i32, branch: &str) -> String { format!("{PR_REF_PREFIX}{pr_number}/{branch}") } +pub fn is_pr_ref(reference: &str) -> bool { + reference.starts_with(PR_REF_PREFIX) +} + /// Remove stale refs from prior fetches for this PR. Keeps only the targets /// the latest fetch wrote, and also cleans up the old `refs/diffy/pull/{N}/*` /// scheme we used to use. Uses a prefix filter so branch names with slashes are @@ -1856,7 +1860,7 @@ mod tests { use tempfile::TempDir; use super::{ - INDEX_REF, PR_REF_PREFIX, WORKDIR_REF, github_fetch_source_for_repo, + INDEX_REF, PR_REF_PREFIX, WORKDIR_REF, github_fetch_source_for_repo, is_pr_ref, github_repo_key_from_remote_url, github_repo_url_from_remote_transport, local_remote_for_github_repo, parse_porcelain_status, parse_shortstat, pr_ref_path, }; @@ -1956,6 +1960,9 @@ mod tests { "refs/diffy/pr/77/feat/new-thing" ); assert!(pr_ref_path(1, "x").starts_with(PR_REF_PREFIX)); + assert!(is_pr_ref(&pr_ref_path(12, "main"))); + assert!(!is_pr_ref("refs/heads/main")); + assert!(!is_pr_ref("@workdir")); } #[test] diff --git a/src/core/vcs/jj/service.rs b/src/core/vcs/jj/service.rs index b69221c3..5edad763 100644 --- a/src/core/vcs/jj/service.rs +++ b/src/core/vcs/jj/service.rs @@ -104,6 +104,10 @@ impl JjRepository { } } + fn is_colocated(&self) -> bool { + self.location.workspace_root.join(".git").exists() + } + fn diff_args_for_spec(&self, spec: &VcsCompareSpec) -> Result> { let mut args = vec![OsString::from("diff")]; match spec { @@ -464,7 +468,7 @@ impl VcsRepository for JjRepository { } fn capabilities(&self) -> RepoCapabilities { - jj_capabilities() + jj_capabilities(self.is_colocated()) } fn resolve_ref(&mut self, reference: &str) -> Result<(String, String)> { @@ -602,7 +606,7 @@ impl VcsRepository for JjRepository { location: self.location.clone(), reason, change_kind: None, - capabilities: jj_capabilities(), + capabilities: jj_capabilities(self.is_colocated()), refs, changes, operation_log: parse_operation_log(&operation_log), @@ -1253,7 +1257,7 @@ fn parse_stat_count_before(line: &str, label: &str) -> Option { prefix[digits_start..].parse().ok() } -pub fn jj_capabilities() -> RepoCapabilities { +pub fn jj_capabilities(colocated: bool) -> RepoCapabilities { RepoCapabilities { staging_area: false, branches: false, @@ -1265,7 +1269,9 @@ pub fn jj_capabilities() -> RepoCapabilities { partial_file_restore: true, partial_hunk_mutation: false, operation_log: true, - github_pull_requests: false, + // PR comparisons run through the git backend against the colocated + // .git store, so they are only available in colocated workspaces. + github_pull_requests: colocated, } } @@ -1450,6 +1456,22 @@ mod tests { }; use crate::events::RepositorySyncReason; + #[test] + fn jj_pr_support_follows_colocation() { + let Some(colocated) = init_jj_repo_with(true) else { + return; + }; + let Some(plain) = init_jj_repo_with(false) else { + return; + }; + let backend = JjBackend; + for (dir, expected) in [(&colocated, true), (&plain, false)] { + let location = backend.detect(dir.path()).unwrap().unwrap(); + let repo = backend.open(location).unwrap(); + assert_eq!(repo.capabilities().github_pull_requests, expected); + } + } + #[test] fn jj_merge_base_revset_uses_fork_point() { assert_eq!( @@ -1522,6 +1544,7 @@ mod tests { .expect("jj snapshot"); assert!(snapshot.capabilities.bookmarks); assert!(!snapshot.capabilities.staging_area); + assert!(snapshot.capabilities.github_pull_requests); assert!(snapshot.file_changes.iter().any(|file| { file.path == "README.md" && file.status == FileChangeStatus::Added @@ -1738,12 +1761,22 @@ mod tests { } fn init_jj_repo() -> Option { + init_jj_repo_with(true) + } + + fn init_jj_repo_with(colocate: bool) -> Option { if Command::new("jj").arg("--version").output().is_err() { return None; } let repo_dir = TempDir::new().unwrap(); - let status = Command::new("jj") - .arg("--quiet") + let mut init = Command::new("jj"); + init.arg("--quiet"); + if colocate { + init.arg("--config").arg("git.colocate=true"); + } else { + init.arg("--config").arg("git.colocate=false"); + } + let status = init .arg("git") .arg("init") .arg(repo_dir.path()) diff --git a/src/core/vcs/model.rs b/src/core/vcs/model.rs index 0d58f281..84895d39 100644 --- a/src/core/vcs/model.rs +++ b/src/core/vcs/model.rs @@ -446,6 +446,18 @@ pub enum VcsCompareSpec { MergeBaseRange { base: String, head: String }, } +impl VcsCompareSpec { + pub fn refs(&self) -> impl Iterator { + let (left, right) = match self { + Self::WorkingCopy => (None, None), + Self::Change { revision } => (Some(revision.as_str()), None), + Self::Range { from, to } => (Some(from.as_str()), Some(to.as_str())), + Self::MergeBaseRange { base, head } => (Some(base.as_str()), Some(head.as_str())), + }; + left.into_iter().chain(right) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct VcsCompareRequest { pub spec: VcsCompareSpec, From 419ec96e959057b2eed1a798603f865b87f20b0f Mon Sep 17 00:00:00 2001 From: ro Date: Tue, 9 Jun 2026 22:35:01 -0700 Subject: [PATCH 24/25] fix(jj): simplify push semantics --- src/core/vcs/jj/service.rs | 299 +++++++++++++++++++++++++++++++++---- src/core/vcs/model.rs | 3 + src/ui/vcs.rs | 30 +++- 3 files changed, 299 insertions(+), 33 deletions(-) diff --git a/src/core/vcs/jj/service.rs b/src/core/vcs/jj/service.rs index 5edad763..61fa3ed2 100644 --- a/src/core/vcs/jj/service.rs +++ b/src/core/vcs/jj/service.rs @@ -91,6 +91,9 @@ struct MovableBookmark { name: String, target: String, allow_backwards: bool, + /// Set when the bookmark only exists on this remote (untracked); moving it + /// requires tracking it first to create the local bookmark. + track_remote: Option, } impl JjRepository { @@ -400,23 +403,20 @@ impl JjRepository { .collect()) } - fn movable_bookmarks(&self, revision: &str) -> Result> { - let revset_after = format!("{revision}::"); - let revset = format!("::{revision} | {revset_after}"); + fn movable_bookmarks(&self, revision: &str, remote: &str) -> Result> { + // `bookmark list -r` only matches local bookmark targets, which would + // hide remote-only bookmarks entirely; list everything and let the + // template report ancestor/descendant containment instead. let output = self.cli.run_ignored_wc(&[ OsString::from("bookmark"), OsString::from("list"), - OsString::from("-r"), - OsString::from(revset), + OsString::from("--all-remotes"), OsString::from("-T"), OsString::from(format!( - "name ++ \"\\t\" ++ normal_target.commit_id() ++ \"\\t\" ++ normal_target.contained_in(\"{revset_after}\") ++ \"\\n\"" + "name ++ \"\\t\" ++ if(self.remote(), self.remote(), \"\") ++ \"\\t\" ++ if(self.tracked(), \"true\", \"false\") ++ \"\\t\" ++ normal_target.commit_id() ++ \"\\t\" ++ normal_target.contained_in(\"::({revision})\") ++ \"\\t\" ++ normal_target.contained_in(\"({revision})::\") ++ \"\\n\"" )), ])?; - let mut bookmarks = output - .lines() - .filter_map(parse_movable_bookmark_line) - .collect::>(); + let mut bookmarks = parse_movable_bookmark_list(&output, remote); bookmarks.sort_by(|left, right| { bookmark_priority(&left.name) .cmp(&bookmark_priority(&right.name)) @@ -425,6 +425,28 @@ impl JjRepository { Ok(bookmarks) } + /// Commit ids of the closest ancestor commits of `revision` that carry a + /// bookmark — jj's analog of "the branch you are on" in git. + fn nearest_bookmark_targets(&self, revision: &str, remote: &str) -> Result> { + let escaped_remote = remote.replace('\\', "\\\\").replace('"', "\\\""); + let output = self.cli.run_ignored_wc(&[ + OsString::from("log"), + OsString::from("--no-graph"), + OsString::from("-r"), + OsString::from(format!( + "heads(::(({revision})-) & (bookmarks() | remote_bookmarks(remote=\"{escaped_remote}\")))" + )), + OsString::from("-T"), + OsString::from("commit_id ++ \"\\n\""), + ])?; + Ok(output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_owned) + .collect()) + } + fn generated_bookmark_name(target: &JjPublishTarget) -> String { let suffix = if target.short_change_id.is_empty() { target.short_commit_id.as_str() @@ -448,6 +470,23 @@ impl JjRepository { } } + fn move_bookmark_description( + bookmark: &MovableBookmark, + target: &JjPublishTarget, + remote: &str, + ) -> String { + match &bookmark.track_remote { + Some(tracked) => format!( + "Track {}@{tracked}, move it to {}, and push it to {remote}", + bookmark.name, target.short_commit_id + ), + None => format!( + "Move jj bookmark {} to {} and push it to {remote}", + bookmark.name, target.short_commit_id + ), + } + } + fn push_change_label(target: &JjPublishTarget) -> String { let id = if target.short_change_id.is_empty() { target.short_commit_id.as_str() @@ -945,12 +984,34 @@ impl VcsRepository for JjRepository { } }); let mut movable_bookmarks = self - .movable_bookmarks(&target.revision) + .movable_bookmarks(&target.revision, &remote) .unwrap_or_default() .into_iter() .filter(|bookmark| bookmark.target != target.commit_id) - .take(6) .collect::>(); + // Mirror git's "push the branch you are on": when no bookmark sits on + // the target itself, advance the nearest ancestor bookmark forward. + let advance_bookmark = if bookmarks.is_empty() { + let nearest_targets = self + .nearest_bookmark_targets(&target.revision, &remote) + .unwrap_or_default(); + movable_bookmarks + .iter() + .enumerate() + .filter(|(_, bookmark)| { + !bookmark.allow_backwards && nearest_targets.contains(&bookmark.target) + }) + // Prefer feature bookmarks over main/master when both sit on + // nearest ancestors (e.g. right after merging main in). + .min_by_key(|(_, bookmark)| { + (bookmark_priority(&bookmark.name) == 0, bookmark.name.clone()) + }) + .map(|(index, _)| index) + .map(|index| movable_bookmarks.remove(index)) + } else { + None + }; + movable_bookmarks.truncate(6); let change_id_token = Self::change_id_token(&target); let primary = if let Some(bookmark) = bookmarks.first() { PublishAction { @@ -966,6 +1027,20 @@ impl VcsRepository for JjRepository { disabled_reason: target_disabled_reason.clone(), change_id_token: None, } + } else if let Some(bookmark) = &advance_bookmark { + PublishAction { + label: format!("Push bookmark {}", bookmark.name), + description: Self::move_bookmark_description(bookmark, &target, &remote), + kind: PublishActionKind::MoveBookmarkAndPush { + remote: remote.clone(), + bookmark: bookmark.name.clone(), + revision: target.revision.clone(), + allow_backwards: false, + track_remote: bookmark.track_remote.clone(), + }, + disabled_reason: target_disabled_reason.clone(), + change_id_token: None, + } } else { PublishAction { label: Self::push_change_label(&target), @@ -1017,15 +1092,13 @@ impl VcsRepository for JjRepository { for bookmark in movable_bookmarks.drain(..) { alternatives.push(PublishAction { label: format!("Move bookmark {} here and push", bookmark.name), - description: format!( - "Move jj bookmark {} to {} and push it to {remote}", - bookmark.name, target.short_commit_id - ), + description: Self::move_bookmark_description(&bookmark, &target, &remote), kind: PublishActionKind::MoveBookmarkAndPush { remote: remote.clone(), bookmark: bookmark.name, revision: target.revision.clone(), allow_backwards: bookmark.allow_backwards, + track_remote: bookmark.track_remote, }, disabled_reason: target_disabled_reason.clone(), change_id_token: None, @@ -1084,7 +1157,17 @@ impl VcsRepository for JjRepository { bookmark, revision, allow_backwards, + track_remote, } => { + if let Some(track_remote) = track_remote { + self.cli.run(&[ + OsString::from("bookmark"), + OsString::from("track"), + OsString::from(bookmark), + OsString::from("--remote"), + OsString::from(track_remote), + ])?; + } let mut move_args = vec![ OsString::from("bookmark"), OsString::from("move"), @@ -1375,19 +1458,52 @@ fn looks_binary(bytes: &[u8]) -> bool { bytes.iter().take(1024).any(|byte| *byte == 0) } -fn parse_movable_bookmark_line(line: &str) -> Option { - let mut fields = line.splitn(3, '\t'); - let name = fields.next()?.trim(); - let target = fields.next()?.trim(); - let allow_backwards = fields.next()?.trim() == "true"; - if name.is_empty() || target.is_empty() { - return None; - } - Some(MovableBookmark { - name: name.to_owned(), - target: target.to_owned(), - allow_backwards, - }) +/// Local bookmarks are always movable. Remote bookmarks are movable only when +/// they live on the preferred remote and are untracked with no local +/// counterpart — moving them means track + move. Tracked remote bookmarks +/// without a local are deliberate deletions pending a push, so they are +/// skipped. +fn parse_movable_bookmark_list(output: &str, preferred_remote: &str) -> Vec { + let mut locals = Vec::new(); + let mut remote_only = Vec::new(); + for line in output.lines() { + let mut fields = line.splitn(6, '\t'); + let Some(name) = fields.next().map(str::trim) else { + continue; + }; + let Some(remote) = fields.next().map(str::trim) else { + continue; + }; + let Some(tracked) = fields.next().map(|field| field.trim() == "true") else { + continue; + }; + let Some(target) = fields.next().map(str::trim) else { + continue; + }; + let Some(is_ancestor) = fields.next().map(|field| field.trim() == "true") else { + continue; + }; + let Some(is_descendant) = fields.next().map(|field| field.trim() == "true") else { + continue; + }; + if name.is_empty() || target.is_empty() || (!is_ancestor && !is_descendant) { + continue; + } + let bookmark = MovableBookmark { + name: name.to_owned(), + target: target.to_owned(), + allow_backwards: is_descendant, + track_remote: (!remote.is_empty()).then(|| remote.to_owned()), + }; + if remote.is_empty() { + locals.push(bookmark); + } else if remote == preferred_remote && !tracked { + remote_only.push(bookmark); + } + } + remote_only.retain(|remote| !locals.iter().any(|local| local.name == remote.name)); + locals.extend(remote_only); + locals } fn bookmark_priority(name: &str) -> usize { @@ -1446,7 +1562,7 @@ mod tests { use super::{ JjBackend, compare_summaries_from_jj_diff_summary, jj_fork_point_revset, - parse_jj_diff_stat_total, + parse_jj_diff_stat_total, parse_movable_bookmark_list, }; use crate::core::compare::{CompareFileStatsTarget, LayoutMode, RendererKind}; use crate::core::vcs::backend::VcsBackend; @@ -1760,6 +1876,129 @@ mod tests { })); } + #[test] + fn movable_bookmark_list_includes_untracked_remote_only_bookmarks() { + let output = "feat\t\tfalse\taaa\ttrue\tfalse\n\ + feat\torigin\ttrue\taaa\ttrue\tfalse\n\ + solo\torigin\tfalse\tbbb\ttrue\tfalse\n\ + gone\torigin\ttrue\tccc\ttrue\tfalse\n\ + other\tupstream\tfalse\tddd\ttrue\tfalse\n\ + desc\t\tfalse\teee\tfalse\ttrue\n\ + stray\t\tfalse\tfff\tfalse\tfalse\n"; + let bookmarks = parse_movable_bookmark_list(output, "origin"); + let names: Vec<&str> = bookmarks + .iter() + .map(|bookmark| bookmark.name.as_str()) + .collect(); + assert_eq!(names, ["feat", "desc", "solo"]); + assert_eq!(bookmarks[0].track_remote, None); + assert!(!bookmarks[0].allow_backwards); + assert!(bookmarks[1].allow_backwards); + assert_eq!(bookmarks[2].track_remote.as_deref(), Some("origin")); + assert!(!bookmarks[2].allow_backwards); + } + + #[test] + fn jj_publish_plan_advances_nearest_remote_bookmark() { + let Some(repo_dir) = init_jj_repo() else { + return; + }; + let remote_dir = TempDir::new().unwrap(); + let status = Command::new("git") + .arg("init") + .arg("--bare") + .arg(remote_dir.path()) + .status() + .unwrap(); + assert!(status.success()); + let status = Command::new("jj") + .arg("--quiet") + .arg("git") + .arg("remote") + .arg("add") + .arg("origin") + .arg(remote_dir.path()) + .current_dir(repo_dir.path()) + .status() + .unwrap(); + assert!(status.success()); + + let backend = JjBackend; + let location = backend.detect(repo_dir.path()).unwrap().unwrap(); + let mut repo = backend.open(location).unwrap(); + fs::write(repo_dir.path().join("BASE.md"), "base\n").unwrap(); + repo.create_commit("base").unwrap(); + + // Leave `feat` as a remote-only untracked bookmark on the parent — the + // state a fresh fetch of someone's branch (or a fetched PR head) is in. + for args in [ + ["--quiet", "bookmark", "create", "feat", "-r", "@-"].as_slice(), + &[ + "--quiet", + "git", + "push", + "--remote", + "origin", + "--bookmark", + "feat", + "--allow-new", + ], + &["--quiet", "bookmark", "untrack", "feat@origin"], + &["--quiet", "bookmark", "delete", "feat"], + ] { + let status = Command::new("jj") + .args(args) + .current_dir(repo_dir.path()) + .status() + .unwrap(); + assert!(status.success(), "jj {args:?} failed"); + } + + fs::write(repo_dir.path().join("NEXT.md"), "next\n").unwrap(); + repo.create_commit("next").unwrap(); + + let plan = repo.publish_plan().unwrap(); + match &plan.primary.kind { + PublishActionKind::MoveBookmarkAndPush { + remote, + bookmark, + revision, + allow_backwards, + track_remote, + } => { + assert_eq!(remote, "origin"); + assert_eq!(bookmark, "feat"); + assert_eq!(revision, "@-"); + assert!(!allow_backwards); + assert_eq!(track_remote.as_deref(), Some("origin")); + } + other => panic!("expected move-bookmark publish, got {other:?}"), + } + assert!(plan.primary.disabled_reason.is_none()); + assert_eq!(plan.primary.label, "Push bookmark feat"); + + repo.publish(&plan.primary).unwrap(); + + let remote_head = Command::new("git") + .arg("-C") + .arg(remote_dir.path()) + .args(["rev-parse", "refs/heads/feat"]) + .output() + .unwrap(); + assert!(remote_head.status.success()); + let local_head = Command::new("jj") + .args(["log", "--no-graph", "-r", "@-", "-T", "commit_id"]) + .current_dir(repo_dir.path()) + .output() + .unwrap(); + assert!(local_head.status.success()); + assert_eq!( + String::from_utf8_lossy(&remote_head.stdout).trim(), + String::from_utf8_lossy(&local_head.stdout).trim(), + "remote feat should fast-forward to the published commit" + ); + } + fn init_jj_repo() -> Option { init_jj_repo_with(true) } diff --git a/src/core/vcs/model.rs b/src/core/vcs/model.rs index 84895d39..3d09f1d1 100644 --- a/src/core/vcs/model.rs +++ b/src/core/vcs/model.rs @@ -415,6 +415,9 @@ pub enum PublishActionKind { bookmark: String, revision: String, allow_backwards: bool, + /// When the bookmark only exists on this remote, track it first so a + /// local bookmark exists to move (jj's analog of a checked-out branch). + track_remote: Option, }, CreateBookmarkAndPush { remote: String, diff --git a/src/ui/vcs.rs b/src/ui/vcs.rs index 83d99944..4d38dd54 100644 --- a/src/ui/vcs.rs +++ b/src/ui/vcs.rs @@ -480,10 +480,10 @@ fn jj_publish_target_hint(changes: &[VcsChange], refs: &[VcsRef]) -> Option Option Date: Wed, 10 Jun 2026 09:41:05 -0700 Subject: [PATCH 25/25] refactor(ui): drive publish status UI from snapshot publish plan for atomic plan/ref updates --- src/apprt/vcs_worker.rs | 11 +- src/events.rs | 5 + src/ui/overlays/publish_menu.rs | 10 +- src/ui/state/repository.rs | 4 +- src/ui/status_bar.rs | 10 +- src/ui/vcs.rs | 218 +++++++++++++++----------------- 6 files changed, 124 insertions(+), 134 deletions(-) diff --git a/src/apprt/vcs_worker.rs b/src/apprt/vcs_worker.rs index 7bd092ca..8e0b8aff 100644 --- a/src/apprt/vcs_worker.rs +++ b/src/apprt/vcs_worker.rs @@ -877,9 +877,14 @@ fn sync_vcs_repository( None }) }; - event_sender.send(RepositoryEvent::RepositorySnapshotReady( - RepositorySnapshot::from_vcs_snapshot(snapshot.clone()), - )); + let mut payload = RepositorySnapshot::from_vcs_snapshot(snapshot.clone()); + payload.publish_plan = repo + .publish_plan() + .map_err(|error| { + tracing::debug!(path = %path.display(), %error, "vcs: no publish plan for snapshot"); + }) + .ok(); + event_sender.send(RepositoryEvent::RepositorySnapshotReady(payload)); } state.last_snapshot = Some(snapshot); } diff --git a/src/events.rs b/src/events.rs index 40669189..3a37f454 100644 --- a/src/events.rs +++ b/src/events.rs @@ -51,6 +51,10 @@ pub struct RepositorySnapshot { pub changes: Vec, pub operation_log: Vec, pub file_changes: Vec, + /// The publish plan computed against this exact snapshot, so plan and + /// refs update atomically. `None` when the backend has nothing + /// publishable (no remotes, nothing described). + pub publish_plan: Option, } impl RepositorySnapshot { @@ -66,6 +70,7 @@ impl RepositorySnapshot { changes: snapshot.changes, operation_log: snapshot.operation_log, file_changes: snapshot.file_changes, + publish_plan: None, } } } diff --git a/src/ui/overlays/publish_menu.rs b/src/ui/overlays/publish_menu.rs index 3e26dee5..6861ca57 100644 --- a/src/ui/overlays/publish_menu.rs +++ b/src/ui/overlays/publish_menu.rs @@ -366,13 +366,5 @@ fn publish_ref_chips(state: &AppState) -> Vec { .repository .refs .with(&state.store, |refs| refs.clone()); - let has_remotes = state - .repository - .capabilities - .with(&state.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }); - profile - .publish_status_ui(&changes, &refs, has_remotes) - .ref_chips + profile.publish_status_ui(&changes, &refs, None).ref_chips } diff --git a/src/ui/state/repository.rs b/src/ui/state/repository.rs index 4342a7a6..dbbebc44 100644 --- a/src/ui/state/repository.rs +++ b/src/ui/state/repository.rs @@ -597,7 +597,9 @@ impl AppState { self.repository .file_changes .set(&self.store, file_changes.clone()); - self.repository.publish_plan.set(&self.store, None); + self.repository + .publish_plan + .set(&self.store, payload.publish_plan); self.workspace .status_file_changes .set(&self.store, file_changes); diff --git a/src/ui/status_bar.rs b/src/ui/status_bar.rs index ba2b0a5b..216f8772 100644 --- a/src/ui/status_bar.rs +++ b/src/ui/status_bar.rs @@ -51,13 +51,9 @@ pub(crate) fn status_bar(state: &AppState, theme: &Theme) -> AnyElement { ) }); let vcs_identity = profile.repository_identity_from_changes(&changes); - let has_remotes = state - .repository - .capabilities - .with(&state.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }); - let publish_status = profile.publish_status_ui(&changes, &refs, has_remotes); + let publish_status = state.repository.publish_plan.with(&state.store, |plan| { + profile.publish_status_ui(&changes, &refs, plan.as_ref()) + }); let branch_children = if workspace_source == WorkspaceSource::TextCompare { None diff --git a/src/ui/vcs.rs b/src/ui/vcs.rs index 4d38dd54..f8e4120a 100644 --- a/src/ui/vcs.rs +++ b/src/ui/vcs.rs @@ -1,7 +1,7 @@ use crate::core::compare::CompareMode; use crate::core::vcs::model::{ - ChangeBucket, ChangeIdToken, RefKind, RepoLocation, VCS_PROFILE_GIT, VCS_PROFILE_JJ, VcsChange, - VcsRef, + ChangeBucket, ChangeIdToken, PublishActionKind, PublishPlan, RefKind, RepoLocation, + VCS_PROFILE_GIT, VCS_PROFILE_JJ, VcsChange, VcsRef, }; use crate::ui::icons::lucide; @@ -49,7 +49,7 @@ struct VcsUiDescriptor { status_view_label: fn(Option) -> String, current_change_preset_label: fn(&VcsChange) -> Option, repository_identity_from_changes: fn(&[VcsChange]) -> Option, - publish_status_ui: fn(&[VcsChange], &[VcsRef], bool) -> PublishStatusUi, + publish_status_ui: fn(&[VcsChange], &[VcsRef], Option<&PublishPlan>) -> PublishStatusUi, working_copy_ref_suffix: fn(&[VcsChange]) -> Option<(String, String)>, change_ref_entry: fn(&VcsChange) -> ChangeRefUi, } @@ -317,9 +317,9 @@ impl VcsUiProfile { self, changes: &[VcsChange], refs: &[VcsRef], - has_remotes: bool, + plan: Option<&PublishPlan>, ) -> PublishStatusUi { - (self.descriptor.publish_status_ui)(changes, refs, has_remotes) + (self.descriptor.publish_status_ui)(changes, refs, plan) } pub fn working_copy_ref_suffix(self, changes: &[VcsChange]) -> Option<(String, String)> { @@ -414,7 +414,7 @@ fn git_repository_identity_from_changes(_changes: &[VcsChange]) -> Option, ) -> PublishStatusUi { PublishStatusUi::default() } @@ -459,11 +459,9 @@ fn jj_repository_identity_from_changes(changes: &[VcsChange]) -> Option, ) -> PublishStatusUi { - let hint = has_remotes - .then(|| jj_publish_target_hint(changes, refs)) - .flatten(); + let hint = plan.and_then(publish_hint_from_plan); PublishStatusUi { show_menu: hint.is_some(), hint, @@ -471,115 +469,35 @@ fn jj_publish_status_ui( } } -fn jj_publish_target_hint(changes: &[VcsChange], refs: &[VcsRef]) -> Option { - let wc_idx = changes - .iter() - .position(|change| change.flags.working_copy || change.flags.current)?; - // Mirror backend publish targeting: undescribed working-copy changes are - // not pushed directly, so `@-` becomes the target when possible. - let head_described = changes - .get(wc_idx) - .is_some_and(|change| !change.summary.trim().is_empty()); - let (target, target_revision, target_idx) = if head_described { - (changes.get(wc_idx)?, "@", wc_idx) - } else if let Some(parent) = changes.get(wc_idx + 1) { - (parent, "@-", wc_idx + 1) - } else { - return None; - }; - if target.summary.trim().is_empty() { - return None; - } - let remote = default_remote_from_refs(refs).unwrap_or_else(|| "origin".to_owned()); - - let bookmark_name = refs - .iter() - .filter(|reference| matches!(reference.kind, RefKind::Bookmark)) - .find(|reference| reference.target.id == target.revision.id) - .map(|reference| reference.name.clone()); - - if let Some(name) = bookmark_name { - return Some(PublishHintUi { - label: name.clone(), - change_id_token: None, - tooltip: format!("Push bookmark {name} at {target_revision} to {remote}"), - }); - } - - // Mirror the publish plan's git-like default: with no bookmark on the - // target itself, the nearest ancestor bookmark is what gets advanced. - let ancestor_bookmark = changes[target_idx + 1..].iter().find_map(|change| { - refs.iter() - .filter(|reference| { - matches!(reference.kind, RefKind::Bookmark | RefKind::RemoteBookmark) - }) - .find(|reference| reference.target.id == change.revision.id) - .map(|reference| { - reference - .name - .rsplit_once('@') - .map_or(reference.name.as_str(), |(name, _)| name) - .to_owned() - }) - }); - if let Some(name) = ancestor_bookmark { - return Some(PublishHintUi { - label: name.clone(), - change_id_token: None, - tooltip: format!("Move bookmark {name} to {target_revision} and push to {remote}"), - }); - } - - let short_id = target - .short_change_id - .clone() - .unwrap_or_else(|| target.short_revision.clone()); - if short_id.is_empty() { +/// Formats the backend's publish plan for the status bar. The plan is the +/// single source of truth for what a push would do; this only shortens its +/// primary action to a label. A disabled primary (e.g. already on the +/// remote) hides the button. +fn publish_hint_from_plan(plan: &PublishPlan) -> Option { + let primary = &plan.primary; + if primary.disabled_reason.is_some() { return None; } - let prefix_len = target.short_change_id_prefix_len.unwrap_or(1).max(1); + let label = match &primary.kind { + PublishActionKind::PushBookmark { bookmark, .. } + | PublishActionKind::MoveBookmarkAndPush { bookmark, .. } + | PublishActionKind::CreateBookmarkAndPush { bookmark, .. } => bookmark.clone(), + PublishActionKind::PushChange { revision, .. } => primary + .change_id_token + .as_ref() + .map(|token| token.text.clone()) + .unwrap_or_else(|| revision.clone()), + PublishActionKind::PushRef { .. } | PublishActionKind::PushTracked { .. } => { + primary.label.clone() + } + }; Some(PublishHintUi { - label: short_id.clone(), - change_id_token: Some(ChangeIdToken { - text: short_id.clone(), - prefix_len, - }), - tooltip: format!("Push change {short_id} at {target_revision} to {remote}"), + label, + change_id_token: primary.change_id_token.clone(), + tooltip: primary.description.clone(), }) } -fn default_remote_from_refs(refs: &[VcsRef]) -> Option { - let mut remotes: Vec = refs - .iter() - .filter_map(|reference| { - reference - .upstream - .as_deref() - .and_then(|u| u.split_once('/').map(|(remote, _)| remote.to_owned())) - .or_else(|| { - matches!( - reference.kind, - RefKind::RemoteBranch | RefKind::RemoteBookmark - ) - .then(|| { - reference - .name - .split_once('/') - .map(|(remote, _)| remote.to_owned()) - }) - .flatten() - }) - }) - .collect(); - remotes.sort(); - remotes.dedup(); - remotes - .iter() - .find(|remote| remote.as_str() == "origin") - .cloned() - .or_else(|| remotes.into_iter().next()) -} - fn publish_ref_chips(changes: &[VcsChange], refs: &[VcsRef]) -> Vec { let publish_targets: Vec<&str> = changes .iter() @@ -667,7 +585,79 @@ fn jj_change_ref_entry(change: &VcsChange) -> ChangeRefUi { #[cfg(test)] mod tests { - use super::pretty_ref_label; + use super::{publish_hint_from_plan, pretty_ref_label}; + use crate::core::vcs::model::{ + ChangeIdToken, PublishAction, PublishActionKind, PublishPlan, + }; + + fn plan(kind: PublishActionKind, disabled: bool, token: Option<&str>) -> PublishPlan { + PublishPlan { + primary: PublishAction { + label: "Push bookmark feat".to_owned(), + description: "Move jj bookmark feat to abc123 and push it to origin".to_owned(), + kind, + disabled_reason: disabled.then(|| "feat is already on origin.".to_owned()), + change_id_token: token.map(|text| ChangeIdToken { + text: text.to_owned(), + prefix_len: 2, + }), + }, + alternatives: Vec::new(), + } + } + + #[test] + fn publish_hint_hides_when_primary_is_disabled() { + let plan = plan( + PublishActionKind::PushBookmark { + remote: "origin".to_owned(), + bookmark: "feat".to_owned(), + }, + true, + None, + ); + assert!(publish_hint_from_plan(&plan).is_none()); + } + + #[test] + fn publish_hint_labels_bookmark_actions_with_the_bookmark() { + let plan = plan( + PublishActionKind::MoveBookmarkAndPush { + remote: "origin".to_owned(), + bookmark: "feat".to_owned(), + revision: "@-".to_owned(), + allow_backwards: false, + track_remote: Some("origin".to_owned()), + }, + false, + None, + ); + let hint = publish_hint_from_plan(&plan).expect("hint"); + assert_eq!(hint.label, "feat"); + assert!(hint.change_id_token.is_none()); + assert_eq!( + hint.tooltip, + "Move jj bookmark feat to abc123 and push it to origin" + ); + } + + #[test] + fn publish_hint_labels_change_push_with_the_change_id() { + let plan = plan( + PublishActionKind::PushChange { + remote: "origin".to_owned(), + revision: "@-".to_owned(), + }, + false, + Some("zuwkussw"), + ); + let hint = publish_hint_from_plan(&plan).expect("hint"); + assert_eq!(hint.label, "zuwkussw"); + assert_eq!( + hint.change_id_token.expect("token").text, + "zuwkussw" + ); + } #[test] fn pr_ref_collapses_to_branch() {