use std::{ collections::{HashMap, HashSet}, mem, ops::Range, path::{Path, PathBuf}, rc::Rc, sync::Arc, }; use crate::{ ai::{ agent::{AgentReviewCommentBatch, DiffSetHunk}, blocklist::agent_view::AgentViewEntryOrigin, }, code::editor::comment_editor::DEFAULT_COMMENT_MAX_WIDTH, code_review::diff_state::InvalidationSource, coding_panel_enablement_state::CodingPanelEnablementState, }; #[cfg(feature = "local_fs")] use crate::code_review::context::{ create_attachment_reference_and_key, register_diffset_attachment, }; use crate::{ ai::agent::CurrentHead, code::editor::view::CodeEditorRenderOptions, code::editor::{CommentEditor, CommentEditorEvent, EditorCommentsModel, EditorReviewComment}, code_review::{comments::ReviewCommentBatch, DiffSetScope}, }; use crate::{ ai::agent::{AIAgentAttachment, DiffBase}, code::{ editor::{ view::{CodeEditorEvent, CodeEditorView}, GutterHoverTarget, }, editor_management::CodeEditorStatus, local_code_editor::{ render_unsaved_circle_with_tooltip, LocalCodeEditorEvent, LocalCodeEditorView, }, view::PendingSaveIntent, }, code_review::{ comments::AttachedReviewCommentTarget, context::convert_file_diffs_to_diffset_hunks, diff_state::{ DiffHunk, DiffLineType, DiffMode, DiffState, DiffStateModel, DiffStateModelEvent, DiffStats, FileDiff, FileDiffAndContent, FileStatusInfo, GitDiffWithBaseContent, GitFileStatus, InvalidationBehavior, }, editor_state::CodeReviewEditorState, hidden_lines::calculate_hidden_lines, telemetry_event::{ AddToContextOrigin, CodeReviewContextDestination, CodeReviewTelemetryEvent, PaneStateChange, }, }, }; #[cfg(feature = "local_fs")] use crate::code_review::telemetry_event::DiffSetContextScope; use crate::{ code::editor::line::EditorLineLocation, ui_components::dialog::{dialog_styles, Dialog}, }; use crate::{ code::global_buffer_model::GlobalBufferModel, code_review::comments::ReviewCommentBatchEvent, }; use crate::{ menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}, pane_group::{ focus_state::{PaneFocusHandle, PaneGroupFocusEvent}, PaneId, }, quit_warning::UnsavedStateSummary, terminal::input::MenuPositioning, terminal::view::{CliAgentRouting, InitProjectModel, TerminalAction, TerminalView}, util::bindings::{custom_tag_to_keystroke, CustomAction}, view_components::{ action_button::{ ActionButton, ActionButtonTheme, AdjoinedSide, ButtonSize, DangerPrimaryTheme, KeystrokeSource, NakedTheme, PaneHeaderTheme, SecondaryTheme, }, DismissibleToast, }, workspace::{ToastStack, Workspace, WorkspaceAction}, }; use crate::code_review::find_model::CodeReviewFindModel; #[cfg(feature = "local_fs")] use crate::server::telemetry::CodePanelsFileOpenEntrypoint; use crate::terminal::cli_agent::{ build_selection_line_range_prompt, build_selection_substring_prompt, }; #[cfg(feature = "local_fs")] use crate::util::file::external_editor::EditorSettings; #[cfg(feature = "local_fs")] use crate::util::openable_file_type::resolve_file_target_with_editor_choice; #[cfg(feature = "local_fs")] use crate::util::openable_file_type::FileTarget; use crate::view_components::find::{Event as FindViewEvent, Find, FindEvent, FindWithinBlockState}; use ai::project_context::model::ProjectContextModel; #[cfg(feature = "local_fs")] use num_traits::SaturatingSub; use string_offset::CharOffset; use galaxy_core::{ channel::{Channel, ChannelState}, features::FeatureFlag, safe_error, safe_info, sync_queue::SyncQueue, ui::theme::color::internal_colors, }; use galaxyui::{ clipboard::ClipboardContent, elements::{ new_scrollable::{ NewScrollable, NewScrollableElement, ScrollableAppearance, SingleAxisConfig, }, resizable_state_handle, Align, Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, DragBarSide, Element, Empty, EventHandler, Flex, List, ListState, MainAxisAlignment, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Percentage, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, Rect, Resizable, ResizableStateHandle, ScrollOffset, ScrollStateHandle, ScrollbarWidth, Stack, Text, DEFAULT_UI_LINE_HEIGHT_RATIO, }, keymap::Keystroke, ui_components::{ button::{ButtonVariant, TextAndIcon, TextAndIconAlignment}, components::{Coords, UiComponentStyles}, }, units::Pixels, AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, WindowId, }; use galaxyui::{ elements::{Clipped, MainAxisSize, Shrinkable}, text_layout::{default_compute_baseline_position, ClipConfig}, }; use galaxyui::{ elements::{Hoverable, SavePosition}, platform::Cursor, ui_components::components::UiComponent, }; use galaxyui::{ fonts::{Properties, Weight}, r#async::SpawnedFutureHandle, ModelHandle, WeakViewHandle, }; use indexmap::IndexMap; use itertools::Itertools; use pathfinder_geometry::rect::RectF; use pathfinder_geometry::vector::{vec2f, Vector2F}; use rand::{distributions::Alphanumeric, Rng}; use crate::code::footer::{CodeFooterView, CodeFooterViewEvent}; use crate::settings::AISettings; use crate::settings_view::SettingsSection; use crate::ui_components::{ blended_colors::{neutral_2, neutral_3}, buttons::icon_button_with_color, icons::Icon, }; use crate::view_components::action_button::TooltipAlignment; #[cfg(feature = "local_fs")] use crate::TelemetryEvent; use crate::{ appearance::Appearance, code::editor::{add_color, remove_color}, code_review::diff_selector::{DiffSelector, DiffSelectorEvent, DiffTarget}, editor::InteractionState, pane_group::pane::{view, BackingView, PaneEvent}, send_telemetry_from_ctx, themes::theme::WarpTheme, }; use vec1::Vec1; use super::{ code_review_header::CodeReviewHeader, comment_list_view::{CommentListDebugState, CommentListEvent, CommentListView}, comments::{attach_pending_imported_comments, AttachedReviewComment, CommentOrigin}, diff_size_limits::DiffSize, file_invalidation_queue::FileInvalidationTask, git_dialog::{GitDialog, GitDialogEvent, GitDialogKind}, GlobalCodeReviewEvent, GlobalCodeReviewModel, }; use crate::code::ShowCommentEditorProvider; #[cfg(not(target_family = "wasm"))] use crate::code::ShowFindReferencesCard; use crate::code_review::comments::CommentId; use crate::ui_components::render_file_search_row::{render_file_search_row, FileSearchRowOptions}; use crate::workspace::view::right_panel::{ReviewDestination, ReviewSubmissionResult}; use galaxy_editor::model::CoreEditorModel; #[cfg(not(target_family = "wasm"))] use galaxy_editor::render::model::AutoScrollMode; use galaxy_editor::{ content::buffer::{AutoScrollBehavior, InitialBufferState, SelectionOffsets}, render::{element::VerticalExpansionBehavior, model::LineCount}, }; use galaxy_util::{ content_version::ContentVersion, file::{FileLoadError, FileSaveError}, path::LineAndColumnArg, }; pub struct CodeReviewHeaderFields { pub is_in_split_pane: bool, pub diff_state_model: ModelHandle, pub maximize_button: ViewHandle, pub diff_selector: ViewHandle, pub header_menu: ViewHandle>, pub header_menu_open: bool, pub header_dropdown_button: ViewHandle, pub has_header_menu_items: bool, pub file_nav_button: Option>, pub primary_git_action_mode: PrimaryGitActionMode, pub git_primary_action_button: ViewHandle, pub git_operations_chevron: ViewHandle, pub git_operations_menu: ViewHandle>, pub git_operations_menu_open: bool, } #[derive(Clone, Debug, PartialEq)] pub struct CodeReviewCommentDebugState { pub repo_path: Option, pub has_active_comment_model: bool, pub comment_list: CommentListDebugState, } /// Renders a file navigation button (sidebar toggle) that can be reused across views. pub fn render_file_navigation_button( appearance: &Appearance, is_sidebar_expanded: bool, mouse_state: MouseStateHandle, on_click: F, ) -> Box where F: Fn(&mut galaxyui::EventContext<'_>) + 'static, { let ui_builder = appearance.ui_builder().clone(); let icon_color = appearance .theme() .sub_text_color(appearance.theme().background()); let button = icon_button_with_color( appearance, if is_sidebar_expanded { Icon::LeftSidebarClose } else { Icon::LeftSidebarOpen }, false, mouse_state, icon_color, ) .with_tooltip(move || { ui_builder .tool_tip(if is_sidebar_expanded { "Hide file navigation".to_owned() } else { "Show file navigation".to_owned() }) .build() .finish() }) .with_tooltip_position(galaxyui::ui_components::button::ButtonTooltipPosition::BelowLeft) .build() .on_click(move |ctx: &mut galaxyui::EventContext<'_>, _, _| { on_click(ctx); }); Container::new( ConstrainedBox::new(button.finish()) .with_height(24.) .with_width(24.) .finish(), ) .with_margin_right(4.) .finish() } /// Determines which primary git action the code review header should present. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum PrimaryGitActionMode { /// There are uncommitted changes. Primary = Commit, dropdown shows /// Commit / Push-or-Publish / Create PR with per-item disabled states. Commit, /// Branch has an upstream and unpushed local commits. Primary = Push, /// dropdown shows Commit (greyed) / Push / Create PR. Push, /// Nothing to commit or push, and no existing PR. Primary = Create PR, chevron hidden. CreatePr, /// Nothing to commit or push, and a PR exists for this branch. Primary = PR #N, chevron hidden. ViewPr, /// No upstream tracking branch, but local commits exist. Primary = Publish, chevron hidden. Publish, } const DEFAULT_FILE_SIDEBAR_WIDTH: f32 = 250.; const FILE_SIDEBAR_MIN_WIDTH: f32 = 150.; const FILE_SIDEBAR_MAX_WIDTH: f32 = 800.; /// This is a best guess effort at getting the height of the file header. /// If the file header is changed, we should update this value. const FILE_HEADER_HEIGHT: f32 = 41.; /// The gap between editors in the viewported list. const EDITOR_GAP: f32 = 12.; const FILE_SIDEBAR_PANE_WIDTH_PERCENTAGE: f32 = 0.25; /// Vertical gap between the right panel header row and the code review content below it /// (sub-header in loaded state, loading text in loading state). pub(super) const CONTENT_TOP_MARGIN: f32 = 4.; /// Horizontal margins for the code review content area. The right panel header /// uses these same values so its edges align with the content below. pub(crate) const CONTENT_LEFT_MARGIN: f32 = 16.; pub(crate) const CONTENT_RIGHT_MARGIN: f32 = 4.; const CODE_REVIEW_EDITOR_LINE_HEIGHT_RATIO: f32 = 1.4; /// Extra scroll buffer (in pixels) added when scrolling to a line that has a comment editor below it. const COMMENT_EDITOR_SCROLL_BUFFER: f32 = 200.0; pub const CODE_REVIEW_TOOLTIP_TEXT: &str = "View changes"; const REMOTE_TEXT: &str = "Diffs only work for local workspaces."; const DISABLED_TEXT: &str = "Diffs only work for git repositories."; const WSL_TEXT: &str = "Diffs don't currently work in WSL."; #[cfg(not(target_family = "wasm"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[allow(dead_code)] enum InitButtons { OpenRepository, InitProject, None, } pub fn get_discard_button_disabled_tooltip(git_operation_blocked: bool) -> String { if git_operation_blocked { "Cannot discard changes while a git operation (merge, rebase, etc.) is in progress" .to_string() } else { "No changes to discard".to_string() } } /// Returns true if the file status changed between Deleted and non-Deleted states, /// which requires rebuilding the editor state because we can't use global buffer /// for files that don't exist on the file system. #[cfg(not(target_family = "wasm"))] fn file_status_changed_deleted_state( current_status: &GitFileStatus, new_status: &GitFileStatus, ) -> bool { matches!(current_status, GitFileStatus::Deleted) != matches!(new_status, GitFileStatus::Deleted) } #[cfg(target_family = "wasm")] fn file_status_changed_deleted_state( _current_status: &GitFileStatus, _new_status: &GitFileStatus, ) -> bool { false } #[derive(Clone, Debug, PartialEq)] pub enum CodeReviewAction { OpenInNewTab { path: PathBuf, line_and_column: Option, }, ToggleFileExpanded(PathBuf), OpenHeaderMenu, SetDiffMode(DiffMode), ToggleFileSidebar, FileSelected(usize), ToggleMaximize, SaveAllUnsavedFiles, SaveAllFiles { paths: Vec, }, RefreshGitState, UndoRevert, Close, EmitPaneEvent(PaneEvent), ShowDiscardConfirmDialog(Option), ConfirmDiscardFile, CancelDiscardFile, ToggleStashChanges, ToggleFileSelection(PathBuf), AddDiffSetAsContext(DiffSetScope), CopyFilePath(PathBuf), OpenCommentComposerFromHeader, ShowFindBar, FocusView, InitProjectForCurrentDirectory, OpenRepository, OpenCommitDialog, ToggleGitOperationsMenu, OpenPushDialog, OpenCreatePrDialog, ViewPr(String), PublishBranch, } pub struct FileState { pub file_diff: FileDiff, pub editor_state: Option, pub is_expanded: bool, sidebar_mouse_state: MouseStateHandle, header_mouse_state: MouseStateHandle, chevron_button: ViewHandle, open_in_tab_button: ViewHandle, discard_button: ViewHandle, add_context_button: ViewHandle, copy_path_button: ViewHandle, } pub(crate) struct LoadedState { pub(crate) file_states: IndexMap, pub(crate) total_additions: usize, pub(crate) total_deletions: usize, pub(crate) files_changed: usize, } impl LoadedState { pub(crate) fn to_diff_stats(&self) -> DiffStats { DiffStats { files_changed: self.files_changed, total_additions: self.total_additions, total_deletions: self.total_deletions, } } /// Returns a list of pairs of code editor views and the absolute paths of their underlying files. fn editor_absolute_file_paths( &self, repo_path: &Path, ) -> Vec<(ViewHandle, PathBuf)> { self.file_states .values() .filter_map(|file_state| { let editor = file_state.editor_state.as_ref()?.editor().clone(); let file_path = repo_path.join(&file_state.file_diff.file_path); Some((editor, file_path)) }) .collect() } } /// State of the code review view enum CodeReviewViewState { None, Loaded(LoadedState), Error(String), NoRepoFound, } struct UiStateHandles { sidebar_scroll_state: ClippedScrollStateHandle, sidebar_resizable_state: ResizableStateHandle, retry_button_mouse_state: MouseStateHandle, } impl Default for UiStateHandles { fn default() -> Self { Self { sidebar_scroll_state: Default::default(), sidebar_resizable_state: resizable_state_handle(DEFAULT_FILE_SIDEBAR_WIDTH), retry_button_mouse_state: Default::default(), } } } struct PendingFileUpdate { repo_path: PathBuf, pending_file_edits: HashSet, } impl PendingFileUpdate { fn update_with_file_invalidation( &mut self, repo_path: PathBuf, invalidated_files: Vec, ) { if self.repo_path != repo_path { self.repo_path = repo_path; self.pending_file_edits = HashSet::from_iter(invalidated_files); } else { self.pending_file_edits.extend(invalidated_files); } } } #[cfg_attr(target_family = "wasm", allow(dead_code))] struct GitSessionState { enablement: CodingPanelEnablementState, } #[derive(Clone, Debug)] pub enum CodeReviewViewEvent { Pane(PaneEvent), FileEdited { path: PathBuf, }, FileSaved { path: PathBuf, }, FileLoadError { path: PathBuf, error: Rc, }, FileSaveError { path: PathBuf, error: Rc, }, #[cfg(feature = "local_fs")] OpenFileWithTarget { path: PathBuf, target: FileTarget, line_col: Option, }, ReviewSubmitted, /// Emitted when review comments are ready to be submitted. /// A higher-level view (RightPanelView) handles routing to an available terminal. SubmitReviewComments { comments: AgentReviewCommentBatch, repo_path: PathBuf, }, /// Request to open a file in a new tab (e.g. goto-definition). OpenFileInNewTab { path: PathBuf, line_and_column: Option, }, /// Request to open LSP logs for the given log file path. #[cfg(not(target_family = "wasm"))] OpenLspLogs { log_path: PathBuf, }, } #[derive(Clone, Debug, PartialEq)] pub enum DiscardOperationType { AllUncommittedChanges, FileUncommittedChanges, AllChangesAgainstBranch(Option), FileChangesAgainstBranch(Option), } impl DiscardOperationType { pub fn title(&self) -> String { match self { DiscardOperationType::AllUncommittedChanges => { "Discard uncommitted changes?".to_string() } DiscardOperationType::FileUncommittedChanges => { "Discard all uncommitted changes to file?".to_string() } DiscardOperationType::AllChangesAgainstBranch(_) => "Discard all changes?".to_string(), DiscardOperationType::FileChangesAgainstBranch(_) => { "Discard all changes to file?".to_string() } } } pub fn description(&self) -> Option { match self { DiscardOperationType::AllUncommittedChanges => Some("You're about to discard all local changes that haven't been committed.".to_string()), DiscardOperationType::FileUncommittedChanges => Some("This will restore this file to the last committed version and discard local edits.".to_string()), DiscardOperationType::AllChangesAgainstBranch(None) => Some("You're about to discard all committed and uncommitted changes.".to_string()), DiscardOperationType::FileChangesAgainstBranch(None) => Some("This will restore this file to the main branch version and discard all committed and uncommitted edits.".to_string()), DiscardOperationType::AllChangesAgainstBranch(Some(_)) => Some("You're about to discard all committed and uncommitted changes.".to_string()), DiscardOperationType::FileChangesAgainstBranch(Some(branch)) => Some(format!("This will reset this file to the {branch} branch version and discard all committed and uncommitted edits.")), } } fn is_uncommitted_changes(&self) -> bool { matches!( self, DiscardOperationType::AllUncommittedChanges | DiscardOperationType::FileUncommittedChanges ) } } pub struct DiscardDialogState { show_discard_confirm_dialog: bool, discard_file_paths: Vec, selected_files: HashMap, file_checkbox_mouse_states: HashMap, discard_confirm_button: ViewHandle, discard_cancel_button: ViewHandle, stash_changes_enabled: bool, stash_changes_checkbox_mouse_state: MouseStateHandle, operation_type: DiscardOperationType, file_list_scroll_state: ClippedScrollStateHandle, } #[cfg_attr(target_family = "wasm", allow(dead_code))] struct PendingPreciseScroll { editor_index: usize, /// Starting character offset of the target range to scroll to. start_offset: CharOffset, /// Ending character offset of the target range to scroll to. end_offset: CharOffset, /// Extra scroll buffer (in pixels) to scroll past the target line. buffer: f32, } /// Tracks state for in-flight file invalidation tasks and full-reload coordination. struct FileInvalidationState { /// Whether a full invalidation (`invalidate_all`) is in-flight. /// When true, new `invalidate_files` requests are deferred to `pending_file_updates`. invalidate_all_pending: bool, /// Merge base commit for the current diff mode, computed eagerly during /// full invalidation. merge_base: Option, /// Handle for the in-flight merge base computation spawned during full /// invalidation. Aborted when a new full invalidation starts. merge_base_handle: Option, /// Queue for per-file invalidation tasks. queue: SyncQueue, } impl FileInvalidationState { fn new(queue: SyncQueue) -> Self { Self { invalidate_all_pending: false, merge_base: None, merge_base_handle: None, queue, } } /// Aborts all in-flight file invalidation tasks, cancels queued /// tasks, and clears the merge base. fn cancel_all(&mut self) { self.queue.cancel_all(); self.merge_base = None; if let Some(handle) = self.merge_base_handle.take() { handle.abort(); } } } /// Per-repository state container. struct RepositoryState { repo_path: PathBuf, state: CodeReviewViewState, available_branches: Vec<(String, bool)>, // (branch_name, is_main_branch) /// Whether a file has been explicitly expanded (true) or collapsed (false). file_expanded: HashMap, // TODO: Remove pending file invalidations — pause the queue instead. /// Files that have been invalidated but not yet processed when diff is still loading. pending_file_updates: Option, /// State for tracking in-flight file invalidation tasks. file_invalidation: FileInvalidationState, } impl RepositoryState { fn new(repo_path: PathBuf, queue: SyncQueue) -> Self { Self { repo_path, state: CodeReviewViewState::None, available_branches: Vec::new(), file_expanded: HashMap::new(), pending_file_updates: None, file_invalidation: FileInvalidationState::new(queue), } } /// If the current state is Loaded, replace it with None and return the LoadedState. /// Otherwise leave the state unchanged and return None. fn pop_loaded_state(&mut self) -> Option { if !matches!(self.state, CodeReviewViewState::Loaded(_)) { return None; } let CodeReviewViewState::Loaded(loaded_state) = mem::replace(&mut self.state, CodeReviewViewState::None) else { unreachable!("state was verified as Loaded"); }; Some(loaded_state) } fn should_auto_expand_file(&self, file: &FileDiff) -> bool { if let Some(manually_expanded) = self.file_expanded.get(&file.file_path) { return *manually_expanded; } if file.is_binary { return false; } if file.size != DiffSize::Normal { return false; } !file.is_autogenerated } } struct RelocateCommentsResult { comments: Vec, fallback_count: usize, } /// State shared among the entire code review view. pub struct CodeReviewView { active_repo: Option, focus_handle: Option, maximize_button: ViewHandle, header_dropdown_button: ViewHandle, file_nav_button: ViewHandle, git_primary_action_button: ViewHandle, git_operations_chevron: ViewHandle, git_operations_menu: ViewHandle>, git_operations_menu_open: bool, file_sidebar_expanded: bool, /// The file sidebar state from before a code review panel is maximized. file_sidebar_expanded_before_maximize: Option, scroll_state: ScrollStateHandle, viewported_list_state: ListState, window_id: WindowId, undo_action_button: ViewHandle, last_revert: Option<(ViewHandle, ContentVersion)>, containing_pane_id: Option, // Header-specific dropdown menu ("Add diff set as context" / "Add comment") header_menu: ViewHandle>, header_menu_open: bool, view_position_id: String, /// Position ID of the code review list within this view. code_review_list_position_id: String, /// Position ID of the header (used for anchoring overlays like the comment composer). header_position_id: String, discard_dialog_state: DiscardDialogState, find_model: ModelHandle, find_bar: ViewHandle>, comment_list_view: ViewHandle, /// Optional overlay composer for creating a new review-level comment. comment_composer: Option>, /// Precise position to auto-scroll to once editor layout completes pending_precise_scroll: Option, /// Comment to scroll to once the view finishes loading. pending_jump_to_comment: Option, active_comment_model: Option>, init_project_button: ViewHandle, #[cfg(not(target_family = "wasm"))] open_repository_button: ViewHandle, ui_state_handles: UiStateHandles, diff_state_model: ModelHandle, diff_selector: ViewHandle, header: CodeReviewHeader, terminal_view: Option>, position_id_prefix: String, /// Whether the view is currently open (subscribed to diff state model). is_open: bool, /// Global LSP footer for the code review panel (workspace mode). code_review_footer: Option>, /// Active git-operation dialog overlay (commit / push / publish), if open. git_dialog: Option>, } impl CodeReviewView { pub fn repo_path(&self) -> Option<&PathBuf> { self.active_repo.as_ref().map(|repo| &repo.repo_path) } pub fn diff_state_model(&self) -> &ModelHandle { &self.diff_state_model } pub fn update_current_repo(&mut self, repo_path: Option, ctx: &mut ViewContext) { safe_info!( safe: ("Code Review: update_current_repo called. Branches cleared."), full: ("Code Review: update_current_repo called with repo_path: {:?}", repo_path) ); // Take the queue from the old state (after cancelling pending work) so // we can reuse it in the new RepositoryState instead of dropping and // recreating it. let reused_queue = self.active_repo.take().map(|mut repo| { repo.file_invalidation.cancel_all(); repo.file_invalidation.queue }); let created_new_queue = reused_queue.is_none(); self.active_repo = repo_path.map(|p| { let queue = reused_queue.unwrap_or_else(|| SyncQueue::new_streaming(ctx.background_executor())); RepositoryState::new(p, queue) }); if created_new_queue { self.start_streaming_listener(ctx); } } /// Prepares for a full invalidation by aborting in-flight file invalidation /// tasks and marking that a full reload is pending. fn queue_full_invalidation(&mut self) { if let Some(repo) = self.active_repo.as_mut() { repo.file_invalidation.cancel_all(); repo.file_invalidation.invalidate_all_pending = true; } } /// Cancels in-flight file invalidation tasks and triggers a full diff /// reload for the active repository. fn load_diffs_for_active_repo(&mut self, fetch_base: bool, ctx: &mut ViewContext) { self.queue_full_invalidation(); self.diff_state_model.update(ctx, |model, ctx| { model.load_diffs_for_current_repo(fetch_base, ctx); }); } /// Called when the code review view is opened/attached to a pane group. /// Subscribes to the diff state model and triggers diff loading. pub fn on_open(&mut self, repo_path: Option, ctx: &mut ViewContext) { if self.is_open { return; } self.is_open = true; self.update_current_repo(repo_path, ctx); ctx.subscribe_to_model(&self.diff_state_model, Self::handle_diff_state_model_event); self.load_diffs_for_active_repo(false, ctx); if self.repo_path().is_some() { self.fetch_branches_and_setup_dropdown(ctx); } ctx.notify(); // Create global LSP footer for the code review panel if let Some(repo_path) = self.repo_path().cloned() { let footer = ctx.add_typed_action_view(|ctx| CodeFooterView::new_for_workspace(repo_path, ctx)); ctx.subscribe_to_view(&footer, Self::handle_footer_event); self.code_review_footer = Some(footer); // Subscribe to PersistedWorkspace events to refresh the footer // UI after LSP installation succeeds or fails. #[cfg(feature = "local_fs")] { use crate::ai::persisted_workspace::{PersistedWorkspace, PersistedWorkspaceEvent}; // PersistedWorkspace handles spawning the server after install; // we only subscribe to refresh the footer UI. ctx.subscribe_to_model(&PersistedWorkspace::handle(ctx), |me, _, event, ctx| { match event { PersistedWorkspaceEvent::InstallationSucceeded | PersistedWorkspaceEvent::InstallationFailed => { if let Some(footer) = &me.code_review_footer { footer.update(ctx, |_, ctx| ctx.notify()); } } _ => {} } }); } } self.diff_state_model.update(ctx, |model, ctx| { model.set_code_review_metadata_refresh_enabled(true, ctx); }); } /// Called when the code review view is closed/detached. /// Unsubscribes from the diff state model. pub fn on_close(&mut self, ctx: &mut ViewContext) { self.is_open = false; ctx.unsubscribe_to_model(&self.diff_state_model); self.code_review_footer = None; self.diff_state_model.update(ctx, |model, ctx| { model.set_code_review_metadata_refresh_enabled(false, ctx); }); if let Some(repo) = self.active_repo.as_mut() { repo.file_invalidation.cancel_all(); } } /// Handles events from the global LSP footer in workspace mode. fn handle_footer_event( &mut self, _footer: ViewHandle, event: &CodeFooterViewEvent, ctx: &mut ViewContext, ) { match event { CodeFooterViewEvent::RunTabConfigSkill { .. } => {} CodeFooterViewEvent::RestartAllServers { servers } => { for server in servers { server.update(ctx, |server, ctx| { server.restart(ctx); }); } } CodeFooterViewEvent::StopAllServers { servers } => { for server in servers { server.update(ctx, |server, ctx| { let _ = server.stop(true, ctx); }); } } CodeFooterViewEvent::StartAllServers { servers } => { for server in servers { server.update(ctx, |server, ctx| { let _ = server.manual_start(ctx); }); } } CodeFooterViewEvent::ManageServers => { ctx.dispatch_typed_action(&WorkspaceAction::ShowSettingsPage( SettingsSection::EditorAndCodeReview, )); } CodeFooterViewEvent::RestartServer { server } => { server.update(ctx, |server, ctx| { server.restart(ctx); }); } CodeFooterViewEvent::StopServer { server } => { server.update(ctx, |server, ctx| { let _ = server.stop(true, ctx); }); } CodeFooterViewEvent::StartServer { server } => { server.update(ctx, |server, ctx| { let _ = server.manual_start(ctx); }); } CodeFooterViewEvent::OpenLogs { path } => { #[cfg(not(target_family = "wasm"))] { // Look up the LSP server for this path and emit the log path let lsp_manager = lsp::LspManagerModel::handle(ctx); if let Some(server) = lsp_manager.as_ref(ctx).server_for_path(path, ctx) { let repo_root = server.as_ref(ctx).initial_workspace().to_path_buf(); let server_type = server.as_ref(ctx).server_type(); let log_path = crate::code::lsp_logs::log_file_path(server_type, &repo_root); ctx.emit(CodeReviewViewEvent::OpenLspLogs { log_path }); } } let _ = path; } CodeFooterViewEvent::EnableLSP { path, server_type } => { Self::handle_enable_lsp(path, *server_type, ctx); } CodeFooterViewEvent::InstallAndEnableLSP { path, server_type } => { Self::handle_install_and_enable_lsp(path, *server_type, ctx); } } } /// Enables an LSP server for the workspace. Uses the provided server_type if given, /// otherwise derives it from the path. #[cfg(feature = "local_fs")] fn handle_enable_lsp( path: &Path, server_type: Option, ctx: &mut ViewContext, ) { use crate::ai::persisted_workspace::{LspTask, PersistedWorkspace}; let server_type = server_type.or_else(|| lsp::LanguageId::from_path(path).map(|id| id.server_type())); let Some(server_type) = server_type else { return; }; let repo_root = PersistedWorkspace::as_ref(ctx) .root_for_workspace(path) .map(|p| p.to_path_buf()) .or_else(|| { repo_metadata::repositories::DetectedRepositories::as_ref(ctx) .get_root_for_path(path) }) .or_else(|| path.parent().map(|p| p.to_path_buf())); let Some(repo_root) = repo_root else { return; }; PersistedWorkspace::handle(ctx).update(ctx, |workspace, _ctx| { workspace.enable_lsp_server_for_path(&repo_root, server_type); }); PersistedWorkspace::handle(ctx).update(ctx, |workspace, ctx| { workspace.execute_lsp_task( LspTask::Spawn { file_path: path.to_path_buf(), }, ctx, ); }); } /// Installs and enables an LSP server for the workspace. #[cfg(feature = "local_fs")] fn handle_install_and_enable_lsp( path: &Path, server_type: Option, ctx: &mut ViewContext, ) { use crate::ai::persisted_workspace::{LspTask, PersistedWorkspace}; let server_type = server_type.or_else(|| lsp::LanguageId::from_path(path).map(|id| id.server_type())); let Some(server_type) = server_type else { return; }; let repo_root = PersistedWorkspace::as_ref(ctx) .root_for_workspace(path) .map(|p| p.to_path_buf()) .or_else(|| { repo_metadata::repositories::DetectedRepositories::as_ref(ctx) .get_root_for_path(path) }) .or_else(|| path.parent().map(|p| p.to_path_buf())); let Some(repo_root) = repo_root else { return; }; PersistedWorkspace::handle(ctx).update(ctx, |workspace, ctx| { workspace.execute_lsp_task( LspTask::Install { file_path: path.to_path_buf(), repo_root, server_type, }, ctx, ); }); } #[cfg(not(feature = "local_fs"))] fn handle_enable_lsp( _path: &Path, _server_type: Option, _ctx: &mut ViewContext, ) { } #[cfg(not(feature = "local_fs"))] fn handle_install_and_enable_lsp( _path: &Path, _server_type: Option, _ctx: &mut ViewContext, ) { } fn set_active_repo_comment_model( &mut self, new_handle: Option>, ctx: &mut ViewContext, ) { if self.active_comment_model == new_handle { return; } if let Some(old_model) = &self.active_comment_model { ctx.unsubscribe_to_model(old_model); } self.active_comment_model = new_handle.clone(); if let Some(new_model) = new_handle.clone() { ctx.subscribe_to_model(&new_model, Self::handle_comment_model_event); } self.comment_list_view.update(ctx, |view, ctx| { view.set_comment_model(new_handle, ctx); }); self.update_editor_comment_markers(ctx); } fn handle_comment_model_event( &mut self, model: ModelHandle, event: &ReviewCommentBatchEvent, ctx: &mut ViewContext, ) { if self.active_comment_model.as_ref() != Some(&model) { return; } self.update_editor_comment_markers(ctx); if let ReviewCommentBatchEvent::Changed { should_reposition_comments: true, } = event { if self.all_editors_loaded() { let diff_mode = self.diff_state_model.as_ref(ctx).diff_mode(); self.reposition_comments_in_file(&diff_mode, ctx); } } } fn clear_comment_locations( &self, editor_file_paths: &[(ViewHandle, PathBuf)], ctx: &mut ViewContext, ) { for (editor, _) in editor_file_paths { editor.update(ctx, |local_editor, ctx| { local_editor.editor().update(ctx, |code_editor, ctx| { code_editor.clear_comment_locations(ctx); }); }); } } fn collect_comments_by_file( &self, model: &ModelHandle, editor_file_paths: &[(ViewHandle, PathBuf)], ctx: &mut ViewContext, ) -> HashMap> { model.read(ctx, |batch, _| { editor_file_paths .iter() .map(|(_, file_path)| { (file_path.clone(), batch.editor_comments_for_file(file_path)) }) .collect::>() }) } /// Creates a new `ListState` with scroll preservation and sets up debounced /// scroll tracking. Used during construction and full invalidation. fn create_list_state(ctx: &mut ViewContext) -> ListState { let view_handle: WeakViewHandle = ctx.handle(); let render_handle = view_handle.clone(); #[cfg(not(target_family = "wasm"))] let adjustment_handle = view_handle; let (list_state, scroll_rx) = ListState::new_with_scroll_preservation( move |index, scroll_offset, app| { let view_handle = render_handle .upgrade(app) .expect("CodeReviewView dropped during render"); view_handle .as_ref(app) .render_diff_at_index(index, scroll_offset, app) }, #[cfg(not(target_family = "wasm"))] move |index, captured_context, app| { Self::adjust_scroll_offset(&adjustment_handle, index, captured_context, app) }, #[cfg(target_family = "wasm")] move |_index, _captured_context, _app| None, ); Self::setup_scroll_tracking(scroll_rx, ctx); list_state } fn update_editor_comment_markers(&mut self, ctx: &mut ViewContext) { let CodeReviewViewState::Loaded(state) = self.state() else { return; }; let Some(repo_path) = self.repo_path().cloned() else { return; }; let editor_file_paths = state.editor_absolute_file_paths(&repo_path); let Some(model) = self.active_comment_model.as_ref() else { self.clear_comment_locations(&editor_file_paths, ctx); return; }; let comments_by_file = self.collect_comments_by_file(model, &editor_file_paths, ctx); for (editor, file_path) in editor_file_paths { let comments = comments_by_file .get(&file_path) .cloned() .unwrap_or_default(); editor.update(ctx, |local_editor, ctx| { local_editor.editor().update(ctx, |code_editor, ctx| { code_editor.set_comment_locations(comments.into_iter(), ctx); }); }); } } pub fn new( repo_path: Option, diff_state_model: ModelHandle, comment_batch_model: Option>, terminal_view: Option>, ctx: &mut ViewContext, ) -> Self { // TODO(asweet): Migrate subscription and event handling of diff_state_model to RepositoryState let diff_selector = ctx.add_typed_action_view(DiffSelector::new); ctx.subscribe_to_view(&diff_selector, |me, _, event, ctx| { me.handle_diff_selector_event(event, ctx); }); let random_str = rand::thread_rng() .sample_iter(&Alphanumeric) .take(8) .map(char::from) .collect(); let maximize_button = ctx.add_typed_action_view(move |_| { // Since the view isn't part of a pane group yet, default to not-maximized. The button will be updated //when focus state changes. let (icon, tooltip_text) = (Icon::Maximize, "Maximize"); ActionButton::new("", NakedTheme) .with_icon(icon) .with_tooltip(tooltip_text) .with_tooltip_positioning_provider(Arc::new(MenuPositioning::BelowInputBox)) .on_click(|ctx| ctx.dispatch_typed_action(CodeReviewAction::ToggleMaximize)) }); let header_dropdown_button = ctx.add_typed_action_view(|_ctx| { let theme: Arc = if FeatureFlag::GitOperationsInCodeReview.is_enabled() { Arc::new(NakedTheme) } else { Arc::new(PaneHeaderTheme) }; ActionButton::new_with_boxed_theme("", theme) .with_icon(Icon::DotsVertical) .on_click(|ctx| ctx.dispatch_typed_action(CodeReviewAction::OpenHeaderMenu)) }); let file_nav_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("", NakedTheme) .with_icon(Icon::FileCopy) .with_tooltip("Show file navigation") .on_click(|ctx| ctx.dispatch_typed_action(CodeReviewAction::ToggleFileSidebar)) }); let git_primary_action_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Commit", SecondaryTheme) .with_size(ButtonSize::Small) .with_icon(Icon::GitCommit) .with_adjoined_side(AdjoinedSide::Right) .on_click(|ctx| ctx.dispatch_typed_action(CodeReviewAction::OpenCommitDialog)) }); let git_operations_chevron = ctx.add_typed_action_view(|_ctx| { ActionButton::new("", SecondaryTheme) .with_size(ButtonSize::Small) .with_icon(Icon::ChevronDown) .with_adjoined_side(AdjoinedSide::Left) .on_click(|ctx| { ctx.dispatch_typed_action(CodeReviewAction::ToggleGitOperationsMenu) }) }); let git_operations_menu = ctx.add_typed_action_view(|_| { Menu::new() .prevent_interaction_with_other_elements() .with_drop_shadow() }); ctx.subscribe_to_view(&git_operations_menu, |me, _, event, ctx| match event { MenuEvent::ItemSelected | MenuEvent::Close { .. } => { me.git_operations_menu_open = false; me.git_operations_chevron.update(ctx, |button, ctx| { button.set_active(false, ctx); }); ctx.notify(); } MenuEvent::ItemHovered => {} }); let list_state = Self::create_list_state(ctx); let window_id = ctx.window_id(); let view_id = ctx.view_id(); let undo_action_button = ctx.add_typed_action_view(move |ctx| { let keybinding = custom_tag_to_keystroke(CustomAction::Undo.into()); let mut action_button = ActionButton::new("Undo", NakedTheme) .with_size(ButtonSize::Small) .on_click(move |ctx| { ctx.dispatch_typed_action(WorkspaceAction::UndoRevertInCodeReviewPane { window_id, view_id, }) }); if let Some(keybinding) = keybinding { action_button = action_button.with_keybinding(KeystrokeSource::Fixed(keybinding), ctx); } action_button }); let discard_confirm_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Discard changes", DangerPrimaryTheme) .on_click(|ctx| ctx.dispatch_typed_action(CodeReviewAction::ConfirmDiscardFile)) }); let discard_cancel_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Cancel", NakedTheme).on_click(|ctx| { ctx.dispatch_typed_action(CodeReviewAction::CancelDiscardFile); }) }); ctx.subscribe_to_model(&GlobalCodeReviewModel::handle(ctx), |me, _, event, ctx| { let GlobalCodeReviewEvent::DiffReverted { window_id, view_id } = event; if ctx.window_id() == *window_id && ctx.view_id() == *view_id { me.maybe_undo_revert(ctx); } }); // The diff selector re-reads Appearance on render, so theme / font // changes are picked up automatically. // Header dropdown menu let header_menu = ctx.add_typed_action_view(|_| { Menu::new() .prevent_interaction_with_other_elements() .with_drop_shadow() }); ctx.subscribe_to_view(&header_menu, move |me, _, event, ctx| match event { MenuEvent::ItemSelected | MenuEvent::Close { .. } => { me.header_menu_open = false; me.update_header_dropdown_active_state(ctx); ctx.notify(); } MenuEvent::ItemHovered => { // No-op for now. } }); let discard_dialog_state = DiscardDialogState { show_discard_confirm_dialog: false, discard_file_paths: Vec::new(), selected_files: HashMap::new(), file_checkbox_mouse_states: HashMap::new(), discard_confirm_button, discard_cancel_button, stash_changes_enabled: false, stash_changes_checkbox_mouse_state: MouseStateHandle::default(), operation_type: DiscardOperationType::AllUncommittedChanges, file_list_scroll_state: ClippedScrollStateHandle::default(), }; let self_handle = ctx.handle(); let find_model = ctx.add_model(|ctx| CodeReviewFindModel::new(self_handle.clone(), ctx)); let find_bar = ctx.add_typed_action_view(|ctx| { let mut view = Find::new(find_model.clone(), ctx); view.display_find_within_block = FindWithinBlockState::Hidden; view }); ctx.subscribe_to_view(&find_bar, move |me, view_handle, event, ctx| { me.handle_find_event(view_handle, event, ctx); }); ctx.subscribe_to_model(&find_model, |me, _, event, ctx| { me.handle_find_model_event(event, ctx); }); let comment_list_view = ctx .add_typed_action_view(|ctx| CommentListView::new(repo_path.clone(), self_handle, ctx)); ctx.subscribe_to_view(&comment_list_view, |me, _, event, ctx| { me.handle_comment_list_event(event, ctx); }); let ui_state_handles = UiStateHandles::default(); let header = CodeReviewHeader::new(); let init_project_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Initialize codebase", NakedTheme) .with_size(ButtonSize::Small) .with_tooltip("Enables codebase indexing and WARP.md") .with_tooltip_alignment(TooltipAlignment::Center) .on_click(|ctx| { ctx.dispatch_typed_action(CodeReviewAction::InitProjectForCurrentDirectory) }) }); #[cfg(not(target_family = "wasm"))] let open_repository_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Open repository", NakedTheme) .with_size(ButtonSize::Small) .with_tooltip("Navigate to a repo and initialize it for coding") .with_tooltip_alignment(TooltipAlignment::Center) .on_click(|ctx| ctx.dispatch_typed_action(CodeReviewAction::OpenRepository)) }); let has_repo = repo_path.is_some(); let active_repo = repo_path.map(|repo_path| { RepositoryState::new( repo_path.clone(), SyncQueue::new_streaming(ctx.background_executor()), ) }); let mut view = Self { active_repo, ui_state_handles, diff_state_model, focus_handle: None, diff_selector, maximize_button, header_dropdown_button, file_nav_button, git_primary_action_button, git_operations_chevron, git_operations_menu, git_operations_menu_open: false, file_sidebar_expanded: false, file_sidebar_expanded_before_maximize: None, position_id_prefix: random_str, viewported_list_state: list_state, scroll_state: ScrollStateHandle::default(), terminal_view, window_id: ctx.window_id(), undo_action_button, last_revert: None, containing_pane_id: None, header_menu, header_menu_open: false, view_position_id: format!("code_review_view_{}", ctx.view_id()), code_review_list_position_id: format!("code_review_view_list_{}", ctx.view_id()), header_position_id: format!("code_review_view_header_{}", ctx.view_id()), discard_dialog_state, header, find_model, find_bar, comment_list_view, comment_composer: None, pending_precise_scroll: None, pending_jump_to_comment: None, active_comment_model: None, init_project_button, #[cfg(not(target_family = "wasm"))] open_repository_button, is_open: false, code_review_footer: None, git_dialog: None, }; view.set_active_repo_comment_model(comment_batch_model, ctx); if has_repo { view.start_streaming_listener(ctx); view.fetch_branches_and_setup_dropdown(ctx); view.invalidate_all(None, ctx); } view } pub fn set_terminal_view(&mut self, terminal_view: WeakViewHandle) { self.terminal_view = Some(terminal_view); } pub fn set_review_destination( &mut self, destination: ReviewDestination, ctx: &mut ViewContext, ) { self.comment_list_view .update(ctx, |comment_list_view, ctx| { comment_list_view.set_review_destination(destination, ctx); }); } pub fn debug_review_comment_state(&self, ctx: &AppContext) -> CodeReviewCommentDebugState { let comment_list = self.comment_list_view.as_ref(ctx).debug_state(ctx); CodeReviewCommentDebugState { repo_path: self.repo_path().cloned(), has_active_comment_model: self.active_comment_model.is_some(), comment_list, } } fn handle_focus_state_event( &mut self, event: &PaneGroupFocusEvent, ctx: &mut ViewContext, ) { if self .focus_handle .as_ref() .is_some_and(|handle| handle.is_affected(event)) { self.update_maximize_button(ctx); } } fn update_maximize_button(&mut self, ctx: &mut ViewContext) { let Some(focus_handle) = &self.focus_handle else { return; }; let is_maximized = focus_handle.is_maximized(ctx); let (icon, tooltip) = if is_maximized { (Icon::Minimize, "Restore") } else { (Icon::Maximize, "Maximize") }; self.maximize_button.update(ctx, |button, ctx| { button.set_icon(Some(icon), ctx); button.set_tooltip(Some(tooltip), ctx); }); } fn update_file_nav_button_tooltip(&self, ctx: &mut ViewContext) { let tooltip = if self.file_sidebar_expanded { "Hide file navigation" } else { "Show file navigation" }; self.file_nav_button.update(ctx, |button, ctx| { button.set_tooltip(Some(tooltip), ctx); }); } fn open_file_sidebar(&mut self, ctx: &mut ViewContext) { self.file_sidebar_expanded = true; if let Some(containing_pane_id) = self.containing_pane_id { if let Some(pane_width) = ctx .element_position_by_id(containing_pane_id.position_id()) .map(|rect| rect.width()) { if let Ok(mut state) = self.ui_state_handles.sidebar_resizable_state.lock() { state.set_size(pane_width * FILE_SIDEBAR_PANE_WIDTH_PERCENTAGE); } } } } /// Handles file sidebar state transitions when the maximize state changes. /// On maximize: saves the current sidebar state and opens the sidebar. /// On minimize: restores the sidebar to its pre-maximize state. pub fn handle_maximization_toggle(&mut self, is_maximized: bool, ctx: &mut ViewContext) { if is_maximized && self.file_sidebar_expanded_before_maximize.is_none() { // Transitioning to maximized: save current sidebar state and open it self.file_sidebar_expanded_before_maximize = Some(self.file_sidebar_expanded); if !self.file_sidebar_expanded { self.open_file_sidebar(ctx); self.update_file_nav_button_tooltip(ctx); ctx.notify(); } } else if !is_maximized { if let Some(was_expanded) = self.file_sidebar_expanded_before_maximize.take() { // Transitioning to minimized: restore saved sidebar state if self.file_sidebar_expanded != was_expanded { self.file_sidebar_expanded = was_expanded; self.update_file_nav_button_tooltip(ctx); ctx.notify(); } } } } fn fetch_branches_and_setup_dropdown(&mut self, ctx: &mut ViewContext) { let Some(repo_path) = self.repo_path().cloned() else { return; }; let fetched_repo_path = repo_path.clone(); ctx.spawn( async move { DiffStateModel::get_all_branches(&repo_path, None, false /* include_remotes */) .await }, move |me, branches_result, ctx| { // If the active repo changed while branches were being fetched, // discard the stale result. if me.repo_path() != Some(&fetched_repo_path) { return; } match branches_result { Ok(branches) => { if let Some(repo) = me.active_repo.as_mut() { let branch_count = branches.len(); let repo_path = &repo.repo_path; safe_info!( safe: ("Code Review: Set available_branches with {} branches", branch_count), full: ( "Code Review: Set available_branches for repo {:?} with {} branches", repo_path, branch_count ) ); repo.available_branches = branches; } me.update_diff_selector_selection(ctx); } Err(err) => { log::warn!("Failed to fetch branches: {err}"); // Fallback to default dropdown with just uncommitted changes and main branch if let Some(repo) = me.active_repo.as_mut() { let repo_path = &repo.repo_path; safe_info!( safe: ("Code Review: Set available_branches to empty (fallback after error)"), full: ( "Code Review: Set available_branches to empty for repo {:?} (fallback after error)", repo_path ) ); repo.available_branches = vec![]; } me.update_diff_selector_selection(ctx); } } }, ); } pub(crate) fn build_diff_targets(&self, ctx: &ViewContext) -> Vec { let Some(repo) = self.active_repo.as_ref() else { return Vec::new(); }; let (current_mode, current_branch_name) = self.diff_state_model.read(ctx, |model, _| { (model.diff_mode(), model.get_current_branch_name()) }); let mut targets = Vec::new(); // 1. Always add "Uncommitted changes" first. targets.push(DiffTarget::new( "Uncommitted changes", DiffMode::Head, matches!(current_mode, DiffMode::Head), )); // 2. If the current mode targets a branch not in the local branch // list, add it after "Uncommitted changes". if let DiffMode::OtherBranch(ref branch_name) = current_mode { let already_present = repo .available_branches .iter() .any(|(name, _)| name == branch_name); if !already_present { targets.push(DiffTarget::new( branch_name.clone(), DiffMode::OtherBranch(branch_name.clone()), true, )); } } // 3. Main branch, if known. let main_branch = repo.available_branches.iter().find(|(_, is_main)| *is_main); if let Some((main_branch_name, _)) = main_branch { targets.push(DiffTarget::new( main_branch_name.clone(), DiffMode::MainBranch, matches!(current_mode, DiffMode::MainBranch), )); } // 4. Other branches, filtered to exclude main and the currently // checked-out branch (the latter is functionally the same as // "Uncommitted changes"). for (branch_name, is_main) in repo.available_branches.iter() { if *is_main { continue; } if let Some(current_name) = ¤t_branch_name { if branch_name == current_name { continue; } } let is_selected = match ¤t_mode { DiffMode::OtherBranch(name) => name == branch_name, DiffMode::Head | DiffMode::MainBranch => false, }; targets.push(DiffTarget::new( branch_name.clone(), DiffMode::OtherBranch(branch_name.clone()), is_selected, )); } targets } fn update_diff_selector_selection(&mut self, ctx: &mut ViewContext) { let targets = self.build_diff_targets(ctx); let selector = self.diff_selector.clone(); selector.update(ctx, |selector, ctx| { selector.set_targets(targets, ctx); }); } fn handle_diff_selector_event( &mut self, event: &DiffSelectorEvent, ctx: &mut ViewContext, ) { match event { DiffSelectorEvent::SelectMode(mode) => { // Dispatching a typed action from this view's own event callback // would bubble past `CodeReviewView` instead of re-entering // `handle_action`, so call the shared helper directly. self.apply_diff_mode(mode.clone(), ctx); } } } /// Shared body for `CodeReviewAction::SetDiffMode` and /// `DiffSelectorEvent::SelectMode`: sends a telemetry event for the /// mode change and updates the diff state model. fn apply_diff_mode(&mut self, mode: DiffMode, ctx: &mut ViewContext) { if self .diff_state_model .read(ctx, |model, _| model.diff_mode()) == mode { return; } send_telemetry_from_ctx!( CodeReviewTelemetryEvent::BaseChanged { mode: mode.clone() }, ctx ); self.diff_state_model.update(ctx, |model, ctx| { model.set_diff_mode(mode, false, ctx); }); } fn handle_find_event( &mut self, _find_bar: ViewHandle>, event: &FindViewEvent, ctx: &mut ViewContext, ) { match event { FindViewEvent::CloseFindBar => { self.close_find_bar(ctx); ctx.focus_self(); } FindViewEvent::Update { query } => { self.find_model.update(ctx, |model, model_ctx| { model.update_query(query.clone(), self.editor_handles(), model_ctx); }); } #[cfg_attr(target_family = "wasm", allow(unused_variables))] FindViewEvent::NextMatch { direction } => { #[cfg(not(target_family = "wasm"))] self.find_model.update(ctx, |model, model_ctx| { model.focus_next_find_match(*direction, self.editor_handles(), model_ctx); }); } FindViewEvent::ToggleCaseSensitivity { is_case_sensitive } => { self.find_model.update(ctx, |model, model_ctx| { model.set_case_sensitive(*is_case_sensitive, self.editor_handles(), model_ctx); }); } FindViewEvent::ToggleRegexSearch { is_regex_enabled } => { self.find_model.update(ctx, |model, model_ctx| { model.set_regex(*is_regex_enabled, self.editor_handles(), model_ctx); }); } FindViewEvent::ToggleFindInBlock { .. } => { log::warn!("Toggle find in block is not supported for code review"); } } } fn handle_find_model_event(&mut self, event: &FindEvent, ctx: &mut ViewContext) { match event { FindEvent::RanFind => { self.update_search_decorations(ctx); } FindEvent::UpdatedFocusedMatch => { self.update_search_decorations(ctx); self.scroll_to_selected_match(ctx); } } } fn handle_comment_list_event(&mut self, event: &CommentListEvent, ctx: &mut ViewContext) { match event { CommentListEvent::Submitted => { self.handle_submit_review_with_comments(ctx); ctx.notify(); } CommentListEvent::Cancelled => { self.clear_review_comments(ctx); ctx.notify(); } CommentListEvent::DeleteComment { comment_id } => { self.delete_comment_by_id(*comment_id, ctx); } CommentListEvent::EditComment(comment_id) => { self.handle_edit_comment(comment_id, ctx); ctx.notify(); } CommentListEvent::JumpToCommentLocation(comment_id) => { self.handle_jump_to_comment_location(comment_id, ctx); ctx.notify(); } } } #[cfg(not(target_family = "wasm"))] fn update_search_decorations(&mut self, ctx: &mut ViewContext) { let CodeReviewViewState::Loaded(state) = self.state() else { return; }; let mut matches_by_editor = self.find_model.as_ref(ctx).matches_by_editor(); let selected_match = self.find_model.as_ref(ctx).selected_match_info(); for file_state in state.file_states.values() { let Some(editor_state) = &file_state.editor_state else { continue; }; let editor_id = editor_state.editor.id(); let ranges = matches_by_editor.remove(&editor_id).unwrap_or_default(); let selected_range_index = selected_match .as_ref() .filter(|info| info.editor_id == editor_id) .map(|info| info.index_within_editor); editor_state.editor.update(ctx, |local_editor, ctx| { local_editor.editor().update(ctx, |editor, ctx| { editor.set_find_highlights(ranges, selected_range_index, ctx); }); }); } } #[cfg(target_family = "wasm")] fn update_search_decorations(&mut self, _ctx: &mut ViewContext) { unreachable!("Code review is not available on wasm") } fn open_review_comment_composer( &mut self, existing_comment: Option, ctx: &mut ViewContext, ) { if self.comment_composer.is_some() { return; } let comment_model = ctx.add_model(EditorCommentsModel::new); let comment_model_handle = comment_model.clone(); let composer = ctx.add_typed_action_view(move |ctx| { CommentEditor::new(ctx, comment_model_handle.clone()) }); if let Some(comment) = &existing_comment { composer.update(ctx, |editor, ctx| { editor.reopen_saved_comment( &comment.id, None, &comment.content, &comment.origin, ctx, ); }); } ctx.subscribe_to_view(&composer, |me, _, event, ctx| match event { CommentEditorEvent::CommentSaved { id, comment_text, .. } => { if let Some(id) = id { if let Some(comment) = me.get_comment_by_id(*id, ctx) { let mut updated = comment; updated.content = comment_text.clone(); updated.last_update_time = chrono::Local::now(); me.update_review_comment(updated, ctx); } } else { let base = me.get_diff_base(ctx).ok(); let head = me.get_current_head(ctx); let new_comment = AttachedReviewComment { id: CommentId::new(), content: comment_text.clone(), target: AttachedReviewCommentTarget::General, last_update_time: chrono::Local::now(), base, head, outdated: false, origin: CommentOrigin::Native, }; me.update_review_comment(new_comment, ctx); } } CommentEditorEvent::DeleteComment { id } => { me.delete_comment_by_id(*id, ctx); me.comment_composer = None; me.update_header_dropdown_active_state(ctx); ctx.notify(); } CommentEditorEvent::CloseEditor => { me.comment_composer = None; me.update_header_dropdown_active_state(ctx); ctx.notify(); } _ => {} }); self.comment_composer = Some(composer.clone()); // Focus the comment editor when the composer opens ctx.focus(&composer); self.update_header_dropdown_active_state(ctx); ctx.notify(); } fn handle_edit_comment(&mut self, comment_id: &CommentId, ctx: &mut ViewContext) { let Some(comment) = self.get_comment_by_id(*comment_id, ctx) else { log::error!("Couldn't find code review comment by ID"); return; }; let CodeReviewViewState::Loaded(state) = self.state() else { return; }; match &comment.target { AttachedReviewCommentTarget::Line { absolute_file_path, line, .. } => { let Some((editor_index, file_state)) = Self::editor_for_comment(&comment, state) else { log::warn!("Couldn't find editor for file: {absolute_file_path:?}"); return; }; let Some(editor_state) = &file_state.editor_state.as_ref() else { log::error!( "CodeReviewView could not fetch editor for file {:?}", file_state.file_diff.file_path ); return; }; editor_state.editor().update(ctx, |local_editor, ctx| { local_editor.editor().update(ctx, |editor, ctx| { editor.open_existing_comment( &comment.id, line, &comment.content, comment.origin(), ctx, ); }); }); self.scroll_to_line(editor_index, line, COMMENT_EDITOR_SCROLL_BUFFER, ctx); } AttachedReviewCommentTarget::General => { self.open_review_comment_composer(Some(comment), ctx); } AttachedReviewCommentTarget::File { .. } => { log::error!( "Attempted to edit a file-level comment; file-level comments are not editable" ); } } } pub fn handle_jump_to_comment_location( &mut self, comment_id: &CommentId, ctx: &mut ViewContext, ) { let Some(comment) = self.get_comment_by_id(*comment_id, ctx) else { if !matches!(self.state(), CodeReviewViewState::Loaded(_)) { self.pending_jump_to_comment = Some(*comment_id); } else { log::warn!("CodeReviewView couldn't find code review comment by ID"); } return; }; self.comment_list_view.update(ctx, |comment_list, ctx| { comment_list.scroll_to_comment(*comment_id, ctx); }); let CodeReviewViewState::Loaded(state) = &self.state() else { self.pending_jump_to_comment = Some(*comment_id); return; }; match &comment.target { AttachedReviewCommentTarget::Line { absolute_file_path, .. } | AttachedReviewCommentTarget::File { absolute_file_path } => { let Some((editor_index, _file_state)) = Self::editor_for_comment(&comment, state) else { log::warn!("Couldn't find editor for file: {absolute_file_path:?}"); return; }; if let AttachedReviewCommentTarget::Line { line, .. } = &comment.target { self.scroll_to_line(editor_index, line, 0.0, ctx); } else { self.viewported_list_state .scroll_to_with_offset(editor_index, Pixels::new(0.0)); } } AttachedReviewCommentTarget::General => { // Review-level comments only need to be scrolled into view in the comment list. } } } fn editor_for_comment<'a>( comment: &AttachedReviewComment, state: &'a LoadedState, ) -> Option<(usize, &'a FileState)> { let file_path = match &comment.target { AttachedReviewCommentTarget::Line { absolute_file_path, .. } | AttachedReviewCommentTarget::File { absolute_file_path } => absolute_file_path, AttachedReviewCommentTarget::General => return None, }; state .file_states .values() .enumerate() .find(|(_, file_state)| { let editor_filepath = &file_state.file_diff.file_path; // Editor file paths are relative, while comment filepaths are absolute. file_path.ends_with(editor_filepath) }) } fn scroll_to_line( &mut self, editor_index: usize, line: &EditorLineLocation, buffer: f32, ctx: &mut ViewContext, ) { let CodeReviewViewState::Loaded(state) = self.state() else { return; }; let Some(editor_state) = state .file_states .get_index(editor_index) .map(|(_, fs)| fs) .and_then(|fs| fs.editor_state.as_ref()) else { log::warn!("No editor state found for index {editor_index}"); return; }; let (start_offset, end_offset) = editor_state .editor .as_ref(ctx) .editor() .read(ctx, |code_editor_view, ctx| { code_editor_view.line_location_to_offsets(line, ctx) }); self.scroll_to_position(editor_index, start_offset, end_offset, buffer, ctx); } fn scroll_to_selected_match(&mut self, ctx: &mut ViewContext) { let CodeReviewViewState::Loaded(state) = self.state() else { return; }; let Some(selected_match_info) = self.find_model.as_ref(ctx).selected_match_info() else { return; }; let Some(editor_index) = state.file_states.values().position(|file_state| { file_state .editor_state .as_ref() .map(|es| es.editor.id() == selected_match_info.editor_id) .unwrap_or(false) }) else { return; }; // Buffer is 3 times the editor line height let buffer = Appearance::as_ref(ctx).monospace_font_size() * CODE_REVIEW_EDITOR_LINE_HEIGHT_RATIO * 3.; self.scroll_to_position( editor_index, selected_match_info.start_offset, selected_match_info.end_offset, buffer, ctx, ) } fn scroll_to_position( &mut self, editor_index: usize, start_offset: CharOffset, end_offset: CharOffset, buffer: f32, ctx: &mut ViewContext, ) { if let Some((start_top_y, end_bottom_y)) = self.get_match_character_bounds(editor_index, start_offset, end_offset, ctx) { // Bounds are available - apply scrolling immediately self.vertically_scroll_to_match(editor_index, start_top_y, end_bottom_y, buffer); self.horizontally_scroll_to_match(editor_index, start_offset, end_offset, ctx); } else { // Character bounds aren't available, editor hasn't been laid out yet // Scroll conservatively to trigger layout without overshooting if editor_index > self.viewported_list_state.get_scroll_index() { // Moving DOWN - scroll until the file header is a bit above the bottom of the viewport self.viewported_list_state.scroll_to_with_offset( editor_index, Pixels::new(FILE_HEADER_HEIGHT) - self.viewported_list_state.get_viewport_height() + Pixels::new(5.0), ); } else { // Moving UP - scroll until the editor beneath target is a bit below the top of the viewport self.viewported_list_state.scroll_to_with_offset( editor_index + 1, -Pixels::new(EDITOR_GAP) - Pixels::new(5.0), ); } // Scroll to the precise position once the editor is laid out self.pending_precise_scroll = Some(PendingPreciseScroll { editor_index, start_offset, end_offset, buffer, }); #[cfg(not(target_family = "wasm"))] { let CodeReviewViewState::Loaded(state) = self.state() else { return; }; ctx.subscribe_to_view( &state.file_states[editor_index] .editor_state .as_ref() .unwrap() .editor, move |view, editor_view, event, ctx| { if let LocalCodeEditorEvent::ViewportUpdated = event { let Some(pending) = view.pending_precise_scroll.take() else { return; }; let CodeReviewViewState::Loaded(state) = view.state() else { // Put it back if we're not in the right state yet. view.pending_precise_scroll = Some(pending); return; }; let firing_editor_index = state.file_states.values().position(|file_state| { file_state .editor_state .as_ref() .map(|es| es.editor.id() == editor_view.id()) .unwrap_or(false) }); // Only apply if the firing editor matches the pending target to avoid race conditions if firing_editor_index == Some(pending.editor_index) { // Character bounds are available now after layout if let Some((start_top_y, end_bottom_y)) = view .get_match_character_bounds( pending.editor_index, pending.start_offset, pending.end_offset, ctx, ) { view.vertically_scroll_to_match( pending.editor_index, start_top_y, end_bottom_y, pending.buffer, ); view.horizontally_scroll_to_match( pending.editor_index, pending.start_offset, pending.end_offset, ctx, ); } } else { // Wrong editor fired - put pending back view.pending_precise_scroll = Some(pending); } } }, ); } } } #[cfg(target_family = "wasm")] fn get_match_character_bounds( &self, _editor_index: usize, _start_offset: CharOffset, _end_offset: CharOffset, _ctx: &ViewContext, ) -> Option<(Pixels, Pixels)> { unreachable!("get_match_character_bounds should not run on wasm"); } #[cfg(not(target_family = "wasm"))] fn get_match_character_bounds( &self, editor_index: usize, start_offset: CharOffset, end_offset: CharOffset, ctx: &ViewContext, ) -> Option<(Pixels, Pixels)> { let CodeReviewViewState::Loaded(state) = self.state() else { return None; }; let render_state = state .file_states .get_index(editor_index) .map(|(_, fs)| fs) .and_then(|file_state| file_state.editor_state.as_ref()) .map(|editor_state| { editor_state .editor .as_ref(ctx) .editor() .as_ref(ctx) .model .as_ref(ctx) .render_state() .as_ref(ctx) })?; if let (Some((start_top_y, _)), Some((_, end_bottom_y))) = ( render_state.character_vertical_bounds(start_offset), render_state.character_vertical_bounds(end_offset.saturating_sub(&CharOffset::from(1))), ) { Some((start_top_y, end_bottom_y)) } else { None } } /// Applies vertical scrolling to bring a match into view vertically fn vertically_scroll_to_match( &mut self, editor_index: usize, start_top_y: Pixels, end_bottom_y: Pixels, buffer: f32, ) { // Don't scroll if match is already visible (check without buffer) if self.viewported_list_state.is_vertical_range_visible( editor_index, start_top_y, Pixels::new(FILE_HEADER_HEIGHT) + end_bottom_y, ) { return; } let current_item = self.viewported_list_state.get_scroll_index(); if (current_item == editor_index && start_top_y < self.viewported_list_state.get_scroll_offset()) || editor_index < current_item { // Scroll to top: same file with match above, or moving up to higher file self.viewported_list_state .scroll_to_with_offset(editor_index, start_top_y - Pixels::new(buffer)); } else { // Scroll to bottom: same file with match below, or moving down to lower file self.viewported_list_state.scroll_to_with_offset( editor_index, Pixels::new(FILE_HEADER_HEIGHT) + end_bottom_y + Pixels::new(buffer) - self.viewported_list_state.get_viewport_height(), ); } } #[cfg(target_family = "wasm")] fn horizontally_scroll_to_match( &self, _editor_index: usize, _start_offset: CharOffset, _end_offset: CharOffset, _ctx: &mut ViewContext, ) { unreachable!("horizontally_scroll_to_match should not run on wasm"); } #[cfg(not(target_family = "wasm"))] fn horizontally_scroll_to_match( &self, editor_index: usize, start_offset: CharOffset, end_offset: CharOffset, ctx: &mut ViewContext, ) { let CodeReviewViewState::Loaded(state) = self.state() else { return; }; let Some(editor_state) = state .file_states .get_index(editor_index) .map(|(_, fs)| fs) .and_then(|fs| fs.editor_state.as_ref()) else { return; }; editor_state.editor.update(ctx, |local_editor, ctx| { local_editor.editor().update(ctx, |editor, ctx| { editor .model .as_ref(ctx) .render_state() .clone() .update(ctx, |render, _| { render.request_autoscroll_to(AutoScrollMode::ScrollOffsetsIntoViewport( start_offset..end_offset, )); }); }); }); } fn show_find_bar(&mut self, ctx: &mut ViewContext) { let selected_text = self.editor_handles().find_map(|editor_handle| { editor_handle .as_ref(ctx) .editor() .as_ref(ctx) .selected_text(ctx) .filter(|text| !text.contains('\n')) }); self.find_model.update(ctx, |model, _| { model.set_is_find_bar_open(true); }); if let Some(text) = selected_text { self.find_bar.update(ctx, |find_bar, ctx| { find_bar.set_query_text(&text, ctx); }); self.find_model.update(ctx, |model, model_ctx| { model.update_query(Some(text), self.editor_handles(), model_ctx); }); } else { self.find_model.update(ctx, |model, model_ctx| { model.run_search(self.editor_handles(), model_ctx); }); } send_telemetry_from_ctx!( CodeReviewTelemetryEvent::FindBarToggled { is_open: true }, ctx ); ctx.focus(&self.find_bar); self.update_search_decorations(ctx); ctx.notify(); } fn close_find_bar(&mut self, ctx: &mut ViewContext) { self.find_model.update(ctx, |model, _| { model.set_is_find_bar_open(false); model.clear_results(); }); send_telemetry_from_ctx!( CodeReviewTelemetryEvent::FindBarToggled { is_open: false }, ctx ); // Clear finder match decorations #[cfg(not(target_family = "wasm"))] if let CodeReviewViewState::Loaded(state) = self.state() { for file_state in state.file_states.values() { if let Some(editor_state) = &file_state.editor_state { editor_state.editor.update(ctx, |local_editor, ctx| { local_editor.editor().update(ctx, |editor, ctx| { editor.set_find_highlights(Vec::new(), None, ctx) }); }); } } } ctx.notify(); } fn handle_diff_state_model_event( &mut self, diff_state_model: ModelHandle, event: &DiffStateModelEvent, ctx: &mut ViewContext, ) { match event { DiffStateModelEvent::RepositoryChanged => { let old_path = self.repo_path().cloned(); let repo_path = diff_state_model.read(ctx, |model, _| model.active_repository_path(ctx)); safe_info!( safe: ("Code Review: Repository changed. Branch list cleared."), full: ( "Code Review: Repository changed event - old path: {:?}, new path: {:?}", old_path, repo_path ) ); // Abort in-flight file invalidation tasks on the old repo before // it is potentially replaced. self.queue_full_invalidation(); if self.repo_path() != repo_path.as_ref() { self.update_current_repo(repo_path.clone(), ctx); } // update_current_repo replaces active_repo with a fresh // RepositoryState, discarding the invalidate_all_pending flag // that queue_full_invalidation just set. Re-apply it so the // new repo also defers file invalidations until the full reload // completes. (state == None covers this today, but the explicit // flag makes the invariant resilient to future changes.) if let Some(repo) = self.active_repo.as_mut() { repo.file_invalidation.invalidate_all_pending = true; } let repo_path_for_list = repo_path.clone().unwrap_or_default(); self.comment_list_view.update(ctx, |view, ctx| { // TODO(alokedesai): Update how we model repo path so that it's optional. // There are no guarantees that CodeReviewView is within a repo. view.set_repo_path(repo_path_for_list.clone(), ctx); }); self.invalidate_all(None, ctx); } DiffStateModelEvent::DiffMetadataChanged(InvalidationBehavior::All(source)) => { // If the invalidation is an index lock change AND we don't have an already pending invalidation, // don't eagerly reload all of the diffs. if matches!(source, InvalidationSource::IndexLockChange) { if let Some(repo) = self.active_repo.as_mut() { if !repo.file_invalidation.invalidate_all_pending { return; } } } self.fetch_branches_and_setup_dropdown(ctx); self.load_diffs_for_active_repo(false, ctx); self.update_aggregate_stats(ctx); if FeatureFlag::GitOperationsInCodeReview.is_enabled() { self.update_git_operations_ui(ctx); } } DiffStateModelEvent::DiffMetadataChanged(InvalidationBehavior::AllLockedIndex) => { // The git index is locked (e.g. during pull/merge). Cancel // in-flight work and mark a full invalidation pending, but skip // the diff reload — the data would be stale. When the lock // clears, the watcher will fire a normal `All` event. self.queue_full_invalidation(); } DiffStateModelEvent::DiffMetadataChanged(InvalidationBehavior::Files(files)) => { self.invalidate_files(files.clone(), ctx); self.update_aggregate_stats(ctx); if FeatureFlag::GitOperationsInCodeReview.is_enabled() { self.update_git_operations_ui(ctx); } } DiffStateModelEvent::DiffMetadataChanged(InvalidationBehavior::PromptRefresh) => { self.update_aggregate_stats(ctx); if FeatureFlag::GitOperationsInCodeReview.is_enabled() { self.update_git_operations_ui(ctx); } } DiffStateModelEvent::CurrentBranchChanged => { self.update_diff_selector_selection(ctx); } DiffStateModelEvent::DiffModeChanged { should_fetch_base } => { // Update the dropdown selection to reflect the new mode let should_fetch_base = *should_fetch_base; self.update_diff_selector_selection(ctx); self.load_diffs_for_active_repo(should_fetch_base, ctx); self.invalidate_all(None, ctx); ctx.notify(); } DiffStateModelEvent::NewDiffsComputed(diffs) => { self.invalidate_all(diffs.as_ref(), ctx); // After the view state is refreshed with fresh diffs, re-evaluate // the git operations button (Commit / Push / Create PR) so that // e.g. committing shows "Push" instead of staying on "Commit". if FeatureFlag::GitOperationsInCodeReview.is_enabled() { self.update_git_operations_ui(ctx); } } } } fn update_header_dropdown_active_state(&mut self, ctx: &mut ViewContext) { let is_active = self.header_menu_open || self.comment_composer.is_some(); self.header_dropdown_button.update(ctx, |button, ctx| { button.set_active(is_active, ctx); }); } fn invalidate_files(&mut self, files: Vec, ctx: &mut ViewContext) { let diff_mode = self.diff_state_model.as_ref(ctx).diff_mode(); // TODO: Remove pending file invalidations — pause the queue instead. // Defer file invalidation if a full reload is in-flight or the diff is still loading. { let Some(repo) = self.active_repo.as_mut() else { return; }; if repo.file_invalidation.invalidate_all_pending || matches!(repo.state, CodeReviewViewState::None) { match &mut repo.pending_file_updates { Some(pending_file_update) => { pending_file_update .update_with_file_invalidation(repo.repo_path.clone(), files); } None => { repo.pending_file_updates = Some(PendingFileUpdate { repo_path: repo.repo_path.clone(), pending_file_edits: HashSet::from_iter(files), }); } } return; } } let Some(repo) = self.active_repo.as_ref() else { return; }; let repo_path = repo.repo_path.clone(); let merge_base = repo.file_invalidation.merge_base.clone(); self.enqueue_file_invalidations(files, diff_mode, merge_base, repo_path); } /// Processes any pending file invalidations that occurred during a full refresh. fn flush_pending_invalidations(&mut self, ctx: &mut ViewContext) { let pending = self.active_repo.as_mut().and_then(|repo| { repo.file_invalidation.invalidate_all_pending = false; repo.pending_file_updates .take() .filter(|p| p.repo_path == repo.repo_path) }); if let Some(pending) = pending { self.invalidate_files(pending.pending_file_edits.into_iter().collect(), ctx); } } /// Starts the streaming listener that receives broadcast results from the /// file invalidation queue. fn start_streaming_listener(&mut self, ctx: &mut ViewContext) { let Some(repo) = self.active_repo.as_ref() else { return; }; let rx = repo.file_invalidation.queue.subscribe(); ctx.spawn_stream_local( rx, |me, broadcast_result, ctx| { let queue_active = me .active_repo .as_ref() .is_some_and(|repo| !repo.file_invalidation.invalidate_all_pending); if queue_active { let anyhow_result = match broadcast_result { Ok(arc_value) => Arc::try_unwrap(arc_value).map_err(|_| { anyhow::anyhow!("broadcast result has multiple owners, cannot unwrap") }), Err(arc_error) => Err(anyhow::anyhow!("{arc_error}")), }; me.update_from_single_file_diff_result(anyhow_result, ctx); } }, |_, _| {}, ); } /// Enqueues individual file invalidation tasks into the [`SyncQueue`], /// skipping files that are already queued. fn enqueue_file_invalidations( &mut self, files: Vec, mode: DiffMode, merge_base: Option, repo_path: PathBuf, ) { let Some(repo) = self.active_repo.as_ref() else { return; }; let queue = repo.file_invalidation.queue.clone(); for file in files { let task = FileInvalidationTask { file, repo_path: repo_path.clone(), mode: mode.clone(), merge_base: merge_base.clone(), }; queue.enqueue(task, None, "file-invalidation"); } } /// Updates the code review view with the diff result for a single updated /// file from the queue-based invalidation path. fn update_from_single_file_diff_result( &mut self, result: anyhow::Result<(PathBuf, Option)>, ctx: &mut ViewContext, ) { match result { Ok((file_path, updated_diff)) => { let mut diff_data = { let Some(repo) = self.active_repo.as_mut() else { return; }; match repo.pop_loaded_state() { Some(data) => data, None => return, } }; let existing_index = diff_data.file_states.get_index_of(&file_path); match (existing_index, updated_diff) { (Some(index), Some(diff)) => { let status_changed = file_status_changed_deleted_state( &diff_data.file_states[index].file_diff.status, &diff.file_diff.status, ); if status_changed { diff_data.file_states.shift_remove_index(index); self.viewported_list_state.remove(index); let new_states = self .build_view_state_for_file_diffs(std::slice::from_ref(&diff), ctx); diff_data.file_states.extend( new_states .into_iter() .map(|state| (state.file_diff.file_path.clone(), state)), ); } else { let current = &mut diff_data.file_states[index]; let should_apply = current .editor_state .as_ref() .map(|es| !es.has_unsaved_changes(ctx)) .unwrap_or(true); if should_apply { current.file_diff = diff.file_diff; } self.viewported_list_state .invalidate_height_for_index(index); } } (Some(index), None) => { diff_data.file_states.shift_remove_index(index); self.viewported_list_state.remove(index); } (None, Some(diff)) => { let new_states = self.build_view_state_for_file_diffs(std::slice::from_ref(&diff), ctx); diff_data.file_states.extend( new_states .into_iter() .map(|state| (state.file_diff.file_path.clone(), state)), ); } (None, None) => {} } if let Some(repo) = self.active_repo.as_mut() { repo.state = CodeReviewViewState::Loaded(diff_data); } self.update_editor_comment_markers(ctx); GlobalBufferModel::handle(ctx).update(ctx, |model, ctx| { model.remove_deallocated_buffers(ctx); }); ctx.notify(); } Err(e) => { if ChannelState::enable_debug_features() { log::error!("Failed to retrieve diff state for single file: {e}. Retrying..."); } send_telemetry_from_ctx!( CodeReviewTelemetryEvent::LoadDiffFailed { error: e.to_string(), }, ctx ); self.load_diffs_for_active_repo(false, ctx); } } } fn update_aggregate_stats(&mut self, ctx: &mut ViewContext) { let Some(diff_stats) = self .diff_state_model .as_ref(ctx) .get_stats_for_current_mode() else { return; }; let Some(CodeReviewViewState::Loaded(loaded_state)) = self.state_mut() else { return; }; loaded_state.total_additions = diff_stats.total_additions; loaded_state.total_deletions = diff_stats.total_deletions; loaded_state.files_changed = diff_stats.files_changed; ctx.notify(); } /// Updates state for the view when new git diffs come in. fn invalidate_all( &mut self, diff_data: Option<&GitDiffWithBaseContent>, ctx: &mut ViewContext, ) { if self.active_repo.is_none() { return; }; match self.diff_state(ctx) { DiffState::Loading => { if let Some(repo) = self.active_repo.as_mut() { log::info!( "Code Review Panel: Setting state to loading after receiving 'loading' message." ); repo.state = CodeReviewViewState::None; } ctx.notify(); return; } DiffState::NotInRepository => { if let Some(repo) = self.active_repo.as_mut() { if repo.repo_path.as_os_str().is_empty() { repo.state = CodeReviewViewState::NoRepoFound; } else { log::info!( "Code Review Panel: Setting state to loading after receiving 'not in repository' message." ); repo.state = CodeReviewViewState::None; } } ctx.notify(); return; } DiffState::Error(err) => { if let Some(repo) = self.active_repo.as_mut() { repo.state = CodeReviewViewState::Error(err); } ctx.notify(); return; } DiffState::Loaded(_) => (), }; let Some(diff_data) = diff_data else { log::warn!("Trying to reload diff but there is no git diff base"); return; }; // Deallocate global buffers that are going to be invalidated. if let Some(repo) = self.active_repo.as_mut() { repo.state = CodeReviewViewState::None; GlobalBufferModel::handle(ctx).update(ctx, |model, ctx| { model.remove_deallocated_buffers(ctx); }); } // Create a new list state for this update self.viewported_list_state = Self::create_list_state(ctx); let file_states_vec = self.build_view_state_for_file_diffs(&diff_data.files, ctx); if let Some(repo) = self.active_repo.as_mut() { repo.state = CodeReviewViewState::Loaded(LoadedState { file_states: file_states_vec .into_iter() .map(|fs| (fs.file_diff.file_path.clone(), fs)) .collect(), total_additions: diff_data.total_additions, total_deletions: diff_data.total_deletions, files_changed: diff_data.files_changed, }); } self.recompute_merge_base_and_flush(ctx); if self.all_editors_loaded() { let diff_mode = self.diff_state_model.as_ref(ctx).diff_mode(); self.reposition_comments_in_file(&diff_mode, ctx); } self.update_editor_comment_markers(ctx); if let Some(comment_id) = self.pending_jump_to_comment.take() { self.handle_jump_to_comment_location(&comment_id, ctx); } ctx.notify(); } /// Recomputes the merge base commit for the current diff mode after a full /// reload, then flushes any file invalidations that were deferred while the /// reload was in-flight. /// /// We cache the merge base on [`FileInvalidationState`] so that individual /// file invalidations (triggered by the file watcher) can diff against the /// correct base without re-running `git merge-base` on every file change. /// The cache is invalidated by [`FileInvalidationState::cancel_all`] at the /// start of every full reload, so it is always fresh by the time /// watcher-driven invalidations resume. /// /// For non-Head diff modes the computation is async (it shells out to git), /// so we keep the `invalidate_all_pending` flag set until it completes. /// This ensures watcher-driven file invalidations are deferred rather than /// enqueued without a merge base. fn recompute_merge_base_and_flush(&mut self, ctx: &mut ViewContext) { let diff_mode = self.diff_state_model.as_ref(ctx).diff_mode(); if !matches!(diff_mode, DiffMode::Head) { if let Some(repo) = self.active_repo.as_ref() { let repo_path = repo.repo_path.clone(); let handle = ctx.spawn( async move { DiffStateModel::compute_merge_base(&repo_path, &diff_mode).await }, |me, result, ctx| { if let Some(repo) = me.active_repo.as_mut() { repo.file_invalidation.merge_base_handle = None; } match &result { Ok(merge_base) => { if let Some(repo) = me.active_repo.as_mut() { repo.file_invalidation.merge_base = Some(merge_base.clone()); } } Err(e) => { log::error!("Failed to compute merge base: {e}"); } } me.flush_pending_invalidations(ctx); }, ); if let Some(repo) = self.active_repo.as_mut() { repo.file_invalidation.merge_base_handle = Some(handle); } } } else { self.flush_pending_invalidations(ctx); } } /// Builds view state for the given file diffs, returning the list of newly created file states. fn build_view_state_for_file_diffs( &self, files: &[FileDiffAndContent], ctx: &mut ViewContext, ) -> Vec { let git_operation_blocked = self .diff_state_model .as_ref(ctx) .is_git_operation_blocked(ctx); let discard_tooltip_text = if git_operation_blocked { get_discard_button_disabled_tooltip(git_operation_blocked) } else { "Discard changes".to_string() }; let mut file_states = vec![]; for file in files { let editor_state = { #[cfg(not(target_family = "wasm"))] { self.create_code_review_model_with_global_buffer(file, ctx) } #[cfg(target_family = "wasm")] { self.create_code_review_model(file, ctx) } }; let is_expanded = self.should_auto_expand_file(&file.file_diff); let file_path = file.file_diff.file_path.clone(); let file_line = file_line_for_open(&file.file_diff); let chevron_path = file_path.clone(); let initial_icon = if is_expanded { Icon::ChevronDown } else { Icon::ChevronRight }; let chevron_button = ctx.add_typed_action_view(move |_ctx| { ActionButton::new("", NakedTheme) .with_icon(initial_icon) .with_size(ButtonSize::InlineActionHeader) .on_click(move |ctx| { ctx.dispatch_typed_action(CodeReviewAction::ToggleFileExpanded( chevron_path.clone(), )) }) }); let open_tab_path = file_path.clone(); let open_in_tab_button = ctx.add_typed_action_view(move |_ctx| { ActionButton::new("", NakedTheme) .with_icon(Icon::LinkExternal) .with_size(ButtonSize::InlineActionHeader) .with_tooltip("Open file") .on_click(move |ctx| { ctx.dispatch_typed_action(CodeReviewAction::OpenInNewTab { path: open_tab_path.clone(), line_and_column: file_line.map(|line| LineAndColumnArg { line_num: line, column_num: None, }), }) }) }); let discard_path = file.file_diff.file_path.clone(); let discard_tooltip = discard_tooltip_text.clone(); let discard_button = ctx.add_typed_action_view(move |ctx| { let mut button = ActionButton::new("", NakedTheme) .with_icon(Icon::ReverseLeft) .with_size(ButtonSize::InlineActionHeader) .with_tooltip(discard_tooltip); if git_operation_blocked { button.set_disabled(true, ctx); } else { button = button.on_click(move |ctx| { ctx.dispatch_typed_action(CodeReviewAction::ShowDiscardConfirmDialog(Some( discard_path.clone(), ))) }); } button }); let context_path = file.file_diff.file_path.clone(); let add_context_button = ctx.add_typed_action_view(move |_ctx| { ActionButton::new("", NakedTheme) .with_icon(Icon::Paperclip) .with_size(ButtonSize::InlineActionHeader) .with_tooltip("Add file diff as context") .on_click(move |ctx| { ctx.dispatch_typed_action(CodeReviewAction::AddDiffSetAsContext( DiffSetScope::File(context_path.clone()), )) }) }); let copy_path = file.file_diff.file_path.clone(); let copy_path_button = ctx.add_typed_action_view(move |_ctx| { ActionButton::new("", NakedTheme) .with_icon(Icon::Copy) .with_size(ButtonSize::InlineActionHeader) .with_tooltip("Copy file path") .on_click(move |ctx| { ctx.dispatch_typed_action(CodeReviewAction::CopyFilePath(copy_path.clone())) }) }); file_states.push(FileState { file_diff: file.file_diff.clone(), editor_state, is_expanded, chevron_button, open_in_tab_button, discard_button, add_context_button, copy_path_button, sidebar_mouse_state: MouseStateHandle::default(), header_mouse_state: MouseStateHandle::default(), }) } // Populate the viewported list with file diffs for _ in file_states.iter() { self.viewported_list_state.add_item(); } file_states } fn render_diff_at_index( &self, index: usize, scroll_offset: ScrollOffset, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let diff_state = match self.state() { CodeReviewViewState::Loaded(LoadedState { file_states, .. }) => file_states, // This should never happen--we only render the list when in the loaded state. _ => return Empty::new().finish(), }; let Some((_, file_state)) = diff_state.get_index(index) else { return Empty::new().finish(); }; self.render_file_diff(file_state, index, scroll_offset, appearance, app) } fn should_auto_expand_file(&self, file: &FileDiff) -> bool { self.active_repo .as_ref() .map(|repo| repo.should_auto_expand_file(file)) .unwrap_or(false) } fn get_existing_diffset_comment(&self, app: &AppContext) -> Option { self.active_comment_model .as_ref() .and_then(|model| model.read(app, |batch, _| batch.diffset_comment().cloned())) } /// Updates or adds a review comment. fn update_review_comment( &mut self, comment: AttachedReviewComment, ctx: &mut ViewContext, ) { let Some(model) = self.active_comment_model.clone() else { return; }; let is_existing = model.read(ctx, |batch, _| { batch.get_review_comment_by_id(comment.id).is_some() }); model.update(ctx, move |batch, ctx| { batch.upsert_comment(comment.clone(), ctx); }); // Telemetry: record whether this was a new comment or an edit. if is_existing { send_telemetry_from_ctx!(CodeReviewTelemetryEvent::CommentEdited, ctx); } else { send_telemetry_from_ctx!(CodeReviewTelemetryEvent::CommentAdded, ctx); } } /// Clears all review comments. fn clear_review_comments(&mut self, ctx: &mut ViewContext) { if let Some(model) = self.active_comment_model.clone() { model.update(ctx, |batch, ctx| { batch.clear_all(ctx); }); } } fn delete_comment_by_id(&mut self, id: CommentId, ctx: &mut ViewContext) { if let Some(model) = self.active_comment_model.clone() { let is_imported = model .read(ctx, |batch, _| { batch .get_review_comment_by_id(id) .map(|c| c.origin.is_imported_from_github()) }) .unwrap_or(false); model.update(ctx, |batch, ctx| { batch.delete_comment(id, ctx); }); send_telemetry_from_ctx!( CodeReviewTelemetryEvent::CommentDeleted { is_imported }, ctx ); } } pub fn editor_lens_for_location( &self, path: &Path, line: Range, ctx: &AppContext, ) -> Option> { let editor = self.editor_for_path(path, ctx)?; Some( editor .as_ref(ctx) .editor() .as_ref(ctx) .lens_for_line_range(line, ctx), ) } /// Get the terminal view for the current repo. Returns None if no repo or no terminal. pub fn terminal_view(&self, app: &AppContext) -> Option> { self.terminal_view.as_ref().and_then(|tv| tv.upgrade(app)) } fn diff_state(&self, app: &AppContext) -> DiffState { self.diff_state_model.read(app, |model, _| model.get()) } /// Get the state of the current repo. Returns None if no repo. fn state(&self) -> &CodeReviewViewState { if let Some(repo) = self.active_repo.as_ref() { &repo.state } else { &CodeReviewViewState::NoRepoFound } } /// Get mutable state of the current repo. Returns None if no repo. fn state_mut(&mut self) -> Option<&mut CodeReviewViewState> { Some(&mut self.active_repo.as_mut()?.state) } #[cfg(not(target_family = "wasm"))] fn session_env(&self, app: &AppContext) -> Option { let terminal_view = self.terminal_view.as_ref()?.upgrade(app)?; terminal_view.read(app, |terminal, ctx| { let session = terminal .active_block_session_id() .and_then(|id| terminal.sessions_model().as_ref(ctx).get(id)); let is_local = terminal.active_session_is_local(ctx); let is_remote = matches!(is_local, Some(false)); let is_wsl = session.as_ref().map(|s| s.is_wsl()).unwrap_or(false); let enablement = if is_remote { CodingPanelEnablementState::RemoteSession { has_remote_server: false, } } else if is_wsl { CodingPanelEnablementState::UnsupportedSession } else { CodingPanelEnablementState::Enabled }; Some(GitSessionState { enablement }) }) } #[cfg(target_family = "wasm")] fn render_no_repo_for_env( &self, _app: &AppContext, appearance: &Appearance, ) -> Box { Self::render_wsl_state(appearance, None) } #[cfg(not(target_family = "wasm"))] fn render_no_repo_for_env( &self, app: &AppContext, appearance: &Appearance, ) -> Box { match self.session_env(app) { Some(state) if matches!( state.enablement, CodingPanelEnablementState::RemoteSession { .. } ) => { self.render_remote_state_with_buttons(appearance) } Some(state) if matches!( state.enablement, CodingPanelEnablementState::UnsupportedSession ) => { self.render_wsl_state_with_buttons(appearance) } None => self.render_not_repo_state_with_buttons(appearance), Some(_) => self.render_not_repo_state_with_buttons(appearance), } } /// Converts GitDiffData hunks to DiffDelta format for CodeEditorView.apply_diffs fn convert_hunks_to_diff_deltas(hunks: &[DiffHunk]) -> Vec { let mut diff_deltas = Vec::new(); for hunk in hunks { let mut current_replacement_start: Option = None; let mut current_insertion = String::new(); let mut has_removals = false; let mut old_line = hunk.old_start_line; for line in &hunk.lines { match line.line_type { DiffLineType::Add => { if current_replacement_start.is_none() { current_replacement_start = Some(old_line); } current_insertion.push_str(&line.text); current_insertion.push('\n'); } DiffLineType::Delete => { if current_replacement_start.is_none() { current_replacement_start = Some(old_line); } has_removals = true; old_line += 1; } DiffLineType::Context => { if let Some(start) = current_replacement_start.take() { let end = if has_removals { old_line } else { start }; diff_deltas.push(ai::diff_validation::DiffDelta { replacement_line_range: start..end, insertion: current_insertion.clone(), }); current_insertion.clear(); has_removals = false; } old_line += 1; } DiffLineType::HunkHeader => { continue; } } } if let Some(start) = current_replacement_start.take() { let end = if has_removals { old_line } else { start }; diff_deltas.push(ai::diff_validation::DiffDelta { replacement_line_range: start..end, insertion: current_insertion, }); } } diff_deltas } #[cfg(not(target_family = "wasm"))] fn create_code_review_model_with_global_buffer( &self, file: &FileDiffAndContent, ctx: &mut ViewContext, ) -> Option { let repo_path = self.repo_path()?; // Skip editor creation for binary files or files without content (e.g., pure renames) if file.file_diff.is_binary || file.content_at_head.is_none() { None } else if matches!(file.file_diff.status, GitFileStatus::Deleted) { // For deleted files, the file doesn't exist on disk anymore, so we can't use // GlobalBufferModel. Instead, use the non-global buffer approach which directly // populates the editor with content_at_head. self.create_code_review_model(file, ctx) } else { let self_handle = ctx.handle(); let full_file_path = repo_path.join(&file.file_diff.file_path); let local_code_view = ctx.add_typed_action_view(|ctx| { let editor = LocalCodeEditorView::new_with_global_buffer( &full_file_path, |buffer_state, ctx| { ctx.add_typed_action_view(|ctx| { let mut editor_view = CodeEditorView::new( None, Some(buffer_state.buffer), CodeEditorRenderOptions::new( VerticalExpansionBehavior::InfiniteHeight, ) .lazy_layout() .line_height_override(CODE_REVIEW_EDITOR_LINE_HEIGHT_RATIO) .with_show_comment_editor_provider(ShowCommentEditor { comment_list_save_position_id: self .code_review_list_position_id .clone(), window_id: ctx.window_id(), }) .with_show_find_references_provider(ShowFindReferencesCard { editor_window_id: ctx.window_id(), parent_scrollable_position_id: Some( self.code_review_list_position_id.clone(), ), }), ctx, ) .with_add_context_button() // Enable add context button for code review .with_revert_diff_hunk_button() // Enable revert diff button for code review .with_comment_button() // Enable comment button for code review .with_collapsible_diffs(false) // Disable collapsible diffs .disable_diff_indicator_expansion_on_hover() .with_gutter_hover_target(GutterHoverTarget::Line) // Show gutter element when hovering the line. .disable_find_and_replace(); // Disable find and replace since parts of the file are hidden from view editor_view.set_show_nav_bar(false); // Now we hand off hidden lines calculation to the editor model itself. editor_view.hide_lines_outside_of_active_diff(4, ctx); editor_view }) }, false, None, ctx, ) .with_selection_as_context(Box::new(move |_, app| { self_handle.upgrade(app).and_then(|code_review_view| { code_review_view.as_ref(app).terminal_view(app) }) })); editor }); let inner_editor = local_code_view.as_ref(ctx).editor().clone(); ctx.subscribe_to_view(&inner_editor, { let file_path = file.file_diff.file_path.clone(); move |this, editor, event, ctx| { this.handle_code_editor_event(file_path.clone(), editor, event, ctx); } }); Self::apply_diff_to_code_editor( &local_code_view, file, true, &self.comment_line_numbers_for_file(&file.file_diff.file_path, ctx), ctx, ); ctx.subscribe_to_view(&local_code_view, { let diff_file_path = file.file_diff.file_path.clone(); move |me, editor, event, ctx| { me.handle_local_code_editor_events( editor, event, &full_file_path, &diff_file_path, ctx, ); } }); Some(CodeReviewEditorState::new(local_code_view)) } } /// Creates CodeReviewModel for each file, containing the CodeEditorView and file data fn create_code_review_model( &self, file: &FileDiffAndContent, ctx: &mut ViewContext, ) -> Option { let repo_path = self.repo_path()?; if file.file_diff.is_binary { None } else { let self_handle = ctx.handle(); let code_editor_view = ctx.add_typed_action_view(|ctx| { let mut editor_view = CodeEditorView::new( None, None, CodeEditorRenderOptions::new(VerticalExpansionBehavior::InfiniteHeight) .lazy_layout() .line_height_override(CODE_REVIEW_EDITOR_LINE_HEIGHT_RATIO) .with_show_comment_editor_provider(ShowCommentEditor { comment_list_save_position_id: self .code_review_list_position_id .clone(), window_id: ctx.window_id(), }), ctx, ) .with_add_context_button() // Enable add context button for code review .with_revert_diff_hunk_button() // Enable revert diff button for code review .with_comment_button() // Enable comment button for code review .with_collapsible_diffs(false) // Disable collapsible diffs .disable_diff_indicator_expansion_on_hover() .with_gutter_hover_target(GutterHoverTarget::Line) // Show gutter element when hovering the line. .disable_find_and_replace(); // Disable find and replace since parts of the file are hidden from view editor_view.set_show_nav_bar(false); editor_view }); let full_file_path = repo_path.join(&file.file_diff.file_path); code_editor_view.update(ctx, |editor, ctx| { editor.set_language_with_path(&full_file_path, ctx); }); ctx.subscribe_to_view(&code_editor_view, { let file_path = file.file_diff.file_path.clone(); move |this, editor, event, ctx| { this.handle_code_editor_event(file_path.clone(), editor, event, ctx); } }); let local_code_view = ctx.add_typed_action_view(|ctx| { let mut local_code_view = LocalCodeEditorView::new(code_editor_view, None, false, None, ctx); if FeatureFlag::HoaCodeReview.is_enabled() { local_code_view = local_code_view.with_selection_as_context(Box::new(move |_, app| { self_handle.upgrade(app).and_then(|code_review_view| { code_review_view.as_ref(app).terminal_view(app) }) })); } // Deleted files have no file backing — no FileModel, no GlobalBufferModel. // file_id() will be None for these editors; no downstream code in code_review // relies on file_id for deleted entries (save/conflict flows early-return on None). // Content is populated via reset_with_state in apply_diff_to_code_editor. local_code_view }); let comment_line_numbers = self.comment_line_numbers_for_file(&file.file_diff.file_path, ctx); Self::apply_diff_to_code_editor( &local_code_view, file, true, &comment_line_numbers, ctx, ); ctx.subscribe_to_view(&local_code_view, { let diff_file_path = file.file_diff.file_path.clone(); move |me, editor, event, ctx| { me.handle_local_code_editor_events( editor, event, &full_file_path, &diff_file_path, ctx, ); } }); // For non-global buffer mode, content is loaded synchronously via apply_diff_to_code_editor, // so mark as loaded immediately. Some(CodeReviewEditorState::new_loaded(local_code_view)) } } fn handle_local_code_editor_events( &mut self, editor: ViewHandle, event: &LocalCodeEditorEvent, full_file_path: &Path, diff_file_path: &Path, ctx: &mut ViewContext, ) { match event { LocalCodeEditorEvent::FileSaved => { send_telemetry_from_ctx!(CodeReviewTelemetryEvent::FileSaved, ctx); ctx.emit(CodeReviewViewEvent::FileSaved { path: full_file_path.to_path_buf(), }); } LocalCodeEditorEvent::FailedToSave { error } => { ctx.emit(CodeReviewViewEvent::FileSaveError { path: full_file_path.to_path_buf(), error: error.clone(), }); } LocalCodeEditorEvent::DelayedRenderingFlushed => { // Mark the editor as loaded so we can render it. // This is only relevant for global buffer mode. self.mark_editor_loaded_for_file(diff_file_path, ctx); ctx.notify(); } LocalCodeEditorEvent::FailedToLoad { error } => { // Also mark as loaded on failure so we don't wait forever. self.mark_editor_loaded_for_file(diff_file_path, ctx); ctx.emit(CodeReviewViewEvent::FileLoadError { path: full_file_path.to_path_buf(), error: error.clone(), }); ctx.notify(); } LocalCodeEditorEvent::SelectionAddedAsContext { relative_file_path, line_range, selected_text, } => { self.insert_selection_as_context( relative_file_path.clone(), line_range.start.as_usize(), line_range.end.as_usize(), selected_text.clone(), ctx, ); } LocalCodeEditorEvent::DiscardUnsavedChanges { path: _path } => { #[cfg(feature = "local_fs")] GlobalBufferModel::handle(ctx).update(ctx, |global_buffer, ctx| { global_buffer.discard_unsaved_changes(_path, ctx); }); } LocalCodeEditorEvent::CommentSaved { comment } => { let Some(file_path) = editor.as_ref(ctx).file_path() else { log::error!( "Attempted to attach code review comment to a LocalCodeEditorView without a file path" ); return; }; let base = self.get_diff_base(ctx).ok(); let head = self.get_current_head(ctx); let comment_with_file_context = AttachedReviewComment::from_editor_review_comment( comment.clone(), file_path.to_path_buf(), base, head, ); self.update_review_comment(comment_with_file_context, ctx); ctx.notify(); } LocalCodeEditorEvent::DeleteComment { id } => { self.delete_comment_by_id(*id, ctx); } LocalCodeEditorEvent::RequestOpenComment(comment_id) => { let Some(existing_comment) = self.get_comment_by_id(*comment_id, ctx) else { log::warn!("Tried to reopen non-existent comment with ID {comment_id:?}"); return; }; match &existing_comment.target { AttachedReviewCommentTarget::Line { line, .. } => { let comment_text = &existing_comment.content; let origin = &existing_comment.origin; editor.update(ctx, |local_code_editor, ctx| { local_code_editor.editor().update(ctx, |code_editor, ctx| { code_editor.open_existing_comment( comment_id, line, comment_text, origin, ctx, ); }); }); } AttachedReviewCommentTarget::File { .. } | AttachedReviewCommentTarget::General => { log::error!("Tried to reopen a non-line review comment."); } } } LocalCodeEditorEvent::DiffStatusUpdated => {} LocalCodeEditorEvent::ViewportUpdated => {} LocalCodeEditorEvent::LayoutInvalidated => { if let CodeReviewViewState::Loaded(state) = self.state() { if let Some(index) = state .file_states .iter() .position(|f| f.1.file_diff.file_path == *diff_file_path) { self.viewported_list_state .invalidate_height_for_index(index); ctx.notify(); } } } #[cfg(not(target_family = "wasm"))] LocalCodeEditorEvent::GotoDefinition { path, line, column, source_server_id, } => { // Register the external file so it can use LSP features. // The manager will skip registration if the path is under an existing workspace. let lsp_manager = lsp::LspManagerModel::handle(ctx); lsp_manager.update(ctx, |mgr, _| { mgr.maybe_register_external_file(path, *source_server_id); }); self.open_file_in_tab( path, Some(LineAndColumnArg { // LSP uses 0-indexed lines, but we display 1-indexed line_num: *line + 1, column_num: Some(*column), }), ctx, ); } #[cfg(not(target_family = "wasm"))] LocalCodeEditorEvent::OpenLspLogs { log_path } => { ctx.emit(CodeReviewViewEvent::OpenLspLogs { log_path: log_path.clone(), }); } _ => (), } } fn get_comment_by_id(&self, id: CommentId, app: &AppContext) -> Option { self.active_comment_model.as_ref().and_then(|model| { model.read(app, |batch, _| batch.get_review_comment_by_id(id).cloned()) }) } fn comment_line_numbers_for_file(&self, file_path: &Path, app: &AppContext) -> Vec { self.active_comment_model .as_ref() .map(|model| { model.read(app, |batch, _| { batch.comment_line_numbers_for_file(file_path).collect_vec() }) }) .unwrap_or_default() } /// Marks the editor for the given file path as loaded. /// This is called when LocalCodeEditorEvent::DelayedRenderingFlushed or FailedToLoad fires. fn mark_editor_loaded_for_file(&mut self, file_path: &Path, ctx: &mut ViewContext) { let Some(repo) = self.active_repo.as_mut() else { return; }; let CodeReviewViewState::Loaded(loaded_state) = &mut repo.state else { return; }; if let Some(file_state) = loaded_state.file_states.get_mut(file_path) { if let Some(editor_state) = &mut file_state.editor_state { editor_state.set_loaded(); } } if self.all_editors_loaded() { let diff_mode = self.diff_state_model.as_ref(ctx).diff_mode(); self.reposition_comments_in_file(&diff_mode, ctx); } } /// Returns true if all editors with editor_state have finished loading their buffer content. /// For global buffer mode, this checks the is_loaded flag on each CodeReviewEditorState. /// Returns true if there are no file states or if global buffer is not enabled. fn all_editors_loaded(&self) -> bool { let Some(repo) = self.active_repo.as_ref() else { return true; }; let CodeReviewViewState::Loaded(loaded_state) = &repo.state else { return true; }; // Check if all editors with editor_state are loaded loaded_state .file_states .values() .filter_map(|file_state| file_state.editor_state.as_ref()) .all(|editor_state| editor_state.is_loaded()) } fn apply_diff_to_code_editor( code_editor_view: &ViewHandle, file: &FileDiffAndContent, is_initial_setup: bool, comment_line_numbers: &[LineCount], ctx: &mut ViewContext, ) { if let Some(file_content) = &file.content_at_head { let version = ContentVersion::new(); let diff_deltas = Self::convert_hunks_to_diff_deltas(&file.file_diff.hunks); // For deleted files, we need to populate the buffer directly since the file // doesn't exist on disk and can't be loaded via GlobalBufferModel. let is_deleted_file = matches!(file.file_diff.status, GitFileStatus::Deleted); code_editor_view.update(ctx, |local_editor, ctx| { // Preserve cursor position for non-initial setups (when refreshing diffs) let saved_selections = if !is_initial_setup { Some(Self::capture_cursor_position( local_editor.editor().as_ref(ctx), ctx, )) } else { None }; let mut range = None; // When global buffer is enabled (and file is not deleted), we only need to set the base to the content at HEAD. // For deleted files or when global buffer is disabled, we need to populate the buffer directly. if is_deleted_file { let line_count = file_content.lines().count(); range = Some(calculate_hidden_lines( &diff_deltas, line_count, comment_line_numbers, )); let state = InitialBufferState::plain_text(file_content).with_version(version); // Reset editor state with incoming content. local_editor.reset_with_state(state, ctx); } #[cfg(not(target_family = "wasm"))] if !is_deleted_file { // We only want to recompute diff is the file is loaded. If not, we can rely on the file load event // for diff computation. let file_loaded = local_editor.file_loaded(ctx); local_editor.editor().update(ctx, |editor, ctx| { editor.set_base(file_content, file_loaded, ctx); }); } local_editor.editor().update(ctx, |editor, ctx| { // When global buffer is enabled (and file is not deleted), hidden line configuration is handed off to the model itself. if is_deleted_file { if let Some(range) = range { editor.model.update(ctx, |model, ctx| { model.set_hidden_lines(range, ctx); }); } if !diff_deltas.is_empty() { editor.apply_diffs(diff_deltas, ctx); } } editor.expand_diffs(ctx); // Restore cursor position if it was saved if let Some(selections) = saved_selections { Self::restore_cursor_position(editor, selections, ctx); } if is_initial_setup { if FeatureFlag::CodeReviewSaveChanges.is_enabled() { editor.set_interaction_state(InteractionState::Editable, ctx); } else { editor.set_interaction_state(InteractionState::Selectable, ctx); } } }); }); } } /// Relocates comments by updating their line locations based on the current state of editors. /// /// This is a pure function that takes comments and returns updated comments without mutation. /// For each `Line` comment, it finds the matching editor via file path and computes the new /// target location. Comments without matching editors or without matching line content are /// marked as outdated. /// /// Returns a tuple of (all_comments_with_updated_targets, fallback_count). fn relocate_comments( comments: impl IntoIterator, state: &LoadedState, repo_path: &Path, ctx: &mut ViewContext, ) -> RelocateCommentsResult { let mut fallback_count = 0; let editor_file_paths = state.editor_absolute_file_paths(repo_path); let relocated_comments = comments .into_iter() .map(|mut comment| { if comment.target.absolute_file_path().is_none() { // General comments pass through unchanged. return comment; }; let matching_editor = match &comment.target { AttachedReviewCommentTarget::Line { absolute_file_path, .. } | AttachedReviewCommentTarget::File { absolute_file_path } => editor_file_paths .iter() .find(|(_, editor_path)| editor_path == absolute_file_path) .map(|(editor, _)| editor), AttachedReviewCommentTarget::General => None, }; let Some(editor_view) = matching_editor else { // If there's no matching editor, mark the comment as outdated. // The comment retains its original content so it can still be displayed. if FeatureFlag::PRCommentsSlashCommand.is_enabled() { comment.outdated = true; } return comment; }; let AttachedReviewCommentTarget::Line { absolute_file_path, line, content, } = &comment.target else { // File-level comments with matching editors pass through unchanged. return comment; }; let (new_location, new_content, used_fallback) = editor_view.update(ctx, |local_editor, ctx| { local_editor.editor().update(ctx, |editor, ctx| { editor.model.update(ctx, |model, ctx| { model.get_new_line_location(line, content.original_text(), ctx) }) }) }); if used_fallback { fallback_count += 1; if FeatureFlag::PRCommentsSlashCommand.is_enabled() { comment.outdated = true; } } else { comment.outdated = false; comment.target = AttachedReviewCommentTarget::Line { absolute_file_path: absolute_file_path.to_path_buf(), line: new_location, content: new_content, }; } comment }) .collect(); RelocateCommentsResult { comments: relocated_comments, fallback_count, } } fn reposition_comments_in_file(&mut self, diff_mode: &DiffMode, ctx: &mut ViewContext) { let Some(model) = &self.active_comment_model else { log::error!("Failed to relocate PR comments: CodeReviewView diff state not loaded",); return; }; let Some(repo_path) = self.repo_path() else { log::error!("Failed to relocate PR comments: CodeReviewView has no repo path"); return; }; let CodeReviewViewState::Loaded(state) = self.state() else { log::warn!("Failed to relocate PR comments: CodeReviewView diff state not loaded"); return; }; let mut comments = model.update(ctx, |batch, _| batch.take_comments()); let pending_imported = model.update(ctx, |batch, _| { batch.take_pending_imported_comments_for_branch(diff_mode) }); let newly_imported = attach_pending_imported_comments(pending_imported, repo_path); let newly_imported_ids: HashSet = newly_imported.iter().map(|c| c.id).collect(); comments.extend(newly_imported); if comments.is_empty() { return; } let RelocateCommentsResult { comments: relocated_comments, fallback_count, } = Self::relocate_comments(comments, state, repo_path, ctx); if fallback_count > 0 { send_telemetry_from_ctx!( CodeReviewTelemetryEvent::CommentRelocationFailed { fallback_count }, ctx ); } if !newly_imported_ids.is_empty() { let (active_count, outdated_count) = relocated_comments .iter() .filter(|c| newly_imported_ids.contains(&c.id)) .fold((0usize, 0usize), |(active, outdated), c| { if c.outdated { (active, outdated + 1) } else { (active + 1, outdated) } }); send_telemetry_from_ctx!( CodeReviewTelemetryEvent::CommentsAttached { active_count, outdated_count, }, ctx ); } model.update(ctx, |batch, ctx| { batch.upsert_comments(relocated_comments, ctx); }); ctx.notify(); } /// Opens the comment list tray to display comments. pub(crate) fn expand_comment_list(&mut self, ctx: &mut ViewContext) { self.comment_list_view.update(ctx, |comment_list, ctx| { comment_list.expand(ctx); }); } /// Opens the comment list tray and scrolls to the given comment. pub(crate) fn expand_comment_list_and_scroll_to_comment( &mut self, comment_id: CommentId, ctx: &mut ViewContext, ) { self.comment_list_view.update(ctx, |comment_list, ctx| { comment_list.expand(ctx); comment_list.scroll_to_comment(comment_id, ctx); }); } fn render_placeholder_header(appearance: &Appearance) -> Box { let theme = appearance.theme(); let header_text = "Loading open changes..."; let loading_icon = Icon::Loading .to_galaxyui_icon(galaxy_core::ui::theme::Fill::Solid( internal_colors::neutral_6(theme), )) .finish(); let loading_icon = Container::new( ConstrainedBox::new(loading_icon) .with_height(appearance.ui_font_size()) .with_width(appearance.ui_font_size()) .finish(), ) .with_margin_right(10.) .finish(); let mut flex = Flex::row() .with_main_axis_alignment(MainAxisAlignment::Start) .with_cross_axis_alignment(CrossAxisAlignment::Center); flex.add_child(loading_icon); flex.add_child( Container::new( Text::new( header_text, appearance.ui_font_family(), appearance.ui_font_size(), ) .with_style(Properties::default().weight(Weight::Semibold)) .with_color(theme.main_text_color(theme.background()).into()) .finish(), ) .finish(), ); flex.finish() } /// Renders the loading state pub fn render_loading_state(appearance: &Appearance) -> Box { let placeholder = (0..4).map(|_| { Shrinkable::new( 1., Container::new(CodeReviewView::render_code_diff_placeholder(appearance)) .with_margin_bottom(EDITOR_GAP) .finish(), ) .finish() }); Container::new( Flex::column() .with_child( Container::new(CodeReviewView::render_placeholder_header(appearance)) .with_padding_bottom(12.) .finish(), ) .with_children(placeholder) .finish(), ) .with_uniform_padding(16.) .with_padding_top(CONTENT_TOP_MARGIN) .finish() } fn render_code_diff_placeholder(appearance: &Appearance) -> Box { let theme = appearance.theme(); let base_gradient_color_start = internal_colors::neutral_2(theme); // Make the end of gradient the same color but with 10% opacity. let mut base_gradient_color_end = base_gradient_color_start; base_gradient_color_end.a = 26; // Percent widths to render for each line in the diff placeholder. let percent_widths = vec![0.6, 0.77, 0.34, 0.88, 0.25, 0.07, 0.7]; let lines = percent_widths.into_iter().map(|percent_width| { let rect = ConstrainedBox::new( Rect::new() .with_horizontal_background_gradient( base_gradient_color_start, base_gradient_color_end, ) .finish(), ) .with_height(18.) .finish(); let rect = Align::new(Percentage::width(percent_width, rect).finish()) .left() .finish(); Shrinkable::new(1., Container::new(rect).with_vertical_padding(1.).finish()).finish() }); let lines = Flex::column().with_children(lines).finish(); let corner_radius = Radius::Pixels(8.); Container::new( Flex::column() .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( ConstrainedBox::new( Rect::new() .with_horizontal_background_gradient( base_gradient_color_start, base_gradient_color_end, ) .with_corner_radius(CornerRadius::with_top(corner_radius)) .finish(), ) .with_height(24.) .finish(), ) .with_child( Shrinkable::new( 1., Container::new(lines) .with_horizontal_padding(12.) .with_vertical_padding(4.) .finish(), ) .finish(), ) .finish(), ) .with_corner_radius(CornerRadius::with_all(corner_radius)) .with_border(Border::all(1.).with_border_fill(base_gradient_color_start)) .finish() } fn render_error_state(&self, error: &str, appearance: &Appearance) -> Box { let theme = appearance.theme(); let main_column = Flex::column() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Container::new( ConstrainedBox::new( Icon::AlertTriangle .to_galaxyui_icon(galaxy_core::ui::theme::Fill::Solid( internal_colors::neutral_6(theme), )) .finish(), ) .with_width(24.) .with_height(24.) .finish(), ) .with_margin_bottom(12.) .finish(), ) .with_child( Text::new( "Error loading diffs", appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_style(Properties::default().weight(Weight::Semibold)) .with_color(theme.sub_text_color(theme.background()).into()) .finish(), ) .with_child( ConstrainedBox::new( Shrinkable::new( 1., Container::new( Shrinkable::new( 1., Text::new( error.to_string(), appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_color(theme.disabled_text_color(theme.background()).into()) .finish(), ) .finish(), ) .with_margin_top(4.) .finish(), ) .finish(), ) .with_max_width(425.) .finish(), ) .with_child( Container::new( appearance .ui_builder() .button( ButtonVariant::Secondary, self.ui_state_handles.retry_button_mouse_state.clone(), ) .with_text_and_icon_label(TextAndIcon::new( TextAndIconAlignment::IconFirst, " Retry".to_string(), Icon::Refresh.to_galaxyui_icon(galaxy_core::ui::theme::Fill::Solid( theme.main_text_color(theme.background()).into(), )), MainAxisSize::Min, MainAxisAlignment::SpaceBetween, vec2f(16., 16.), )) .with_style(UiComponentStyles { font_weight: Some(Weight::Semibold), padding: Some(Coords { top: 4., bottom: 4., left: 8., right: 8., }), ..Default::default() }) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(CodeReviewAction::RefreshGitState) }) .finish(), ) .with_margin_top(12.) .finish(), ) .finish(); Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::Center) .with_child(main_column) .finish() } #[cfg(not(target_family = "wasm"))] fn render_no_repo_found_state_with_buttons( &self, appearance: &Appearance, message: &'static str, buttons: InitButtons, ) -> Box { let theme = appearance.theme(); let main_column = Flex::column() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Container::new( ConstrainedBox::new( Icon::FolderClosed .to_galaxyui_icon(galaxy_core::ui::theme::Fill::Solid( internal_colors::neutral_6(theme), )) .finish(), ) .with_width(24.) .with_height(24.) .finish(), ) .with_margin_bottom(12.) .finish(), ) .with_child( Text::new( "Cannot detect diffs for this folder", appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_style(Properties::default().weight(Weight::Semibold)) .with_color(theme.sub_text_color(theme.background()).into()) .finish(), ) .with_child( Container::new( Text::new( message, appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_color(theme.disabled_text_color(theme.background()).into()) .finish(), ) .with_margin_top(4.) .finish(), ); let mut buttons_row = Flex::row() .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center); match buttons { InitButtons::OpenRepository => { buttons_row.add_child(ChildView::new(&self.open_repository_button).finish()); } InitButtons::InitProject => { buttons_row.add_child(ChildView::new(&self.init_project_button).finish()); } InitButtons::None => {} } let main_column = main_column .with_child( Container::new(buttons_row.finish()) .with_margin_top(16.) .finish(), ) .finish(); Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::Center) .with_child(main_column) .finish() } pub fn render_no_repo_found_state( appearance: &Appearance, message: &'static str, open_repo_button: Option>, ) -> Box { let theme = appearance.theme(); let main_column = Flex::column() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Container::new( ConstrainedBox::new( Icon::FolderClosed .to_galaxyui_icon(galaxy_core::ui::theme::Fill::Solid( internal_colors::neutral_6(theme), )) .finish(), ) .with_width(24.) .with_height(24.) .finish(), ) .with_margin_bottom(12.) .finish(), ) .with_child( Text::new( "Cannot detect diffs for this folder", appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_style(Properties::default().weight(Weight::Semibold)) .with_color(theme.sub_text_color(theme.background()).into()) .finish(), ) .with_child( Container::new( Text::new( message, appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_color(theme.disabled_text_color(theme.background()).into()) .finish(), ) .with_margin_top(4.) .finish(), ); let main_column = if let Some(button) = open_repo_button { main_column .with_child(Container::new(button).with_margin_top(16.).finish()) .finish() } else { main_column.finish() }; Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::Center) .with_child(main_column) .finish() } pub fn render_remote_state( appearance: &Appearance, open_repo_button: Option>, ) -> Box { Self::render_no_repo_found_state(appearance, REMOTE_TEXT, open_repo_button) } pub fn render_wsl_state( appearance: &Appearance, open_repo_button: Option>, ) -> Box { Self::render_no_repo_found_state(appearance, WSL_TEXT, open_repo_button) } pub fn render_not_repo_state( appearance: &Appearance, open_repo_button: Option>, ) -> Box { Self::render_no_repo_found_state(appearance, DISABLED_TEXT, open_repo_button) } #[cfg(not(target_family = "wasm"))] fn render_remote_state_with_buttons(&self, appearance: &Appearance) -> Box { self.render_no_repo_found_state_with_buttons( appearance, REMOTE_TEXT, InitButtons::OpenRepository, ) } #[cfg(not(target_family = "wasm"))] fn render_wsl_state_with_buttons(&self, appearance: &Appearance) -> Box { self.render_no_repo_found_state_with_buttons( appearance, WSL_TEXT, InitButtons::OpenRepository, ) } #[cfg(not(target_family = "wasm"))] fn render_not_repo_state_with_buttons(&self, appearance: &Appearance) -> Box { self.render_no_repo_found_state_with_buttons( appearance, DISABLED_TEXT, InitButtons::OpenRepository, ) } fn render_loaded_state( &self, state: &LoadedState, appearance: &Appearance, is_in_split_pane: bool, app: &galaxyui::AppContext, ) -> Box { let top_section = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_main_axis_alignment(MainAxisAlignment::Start) .with_child(self.render_header(state, appearance, is_in_split_pane, app)) .with_child(self.render_content(state, appearance, app)); let top_section_with_margin = ConstrainedBox::new( Container::new(Shrinkable::new(1., top_section.finish()).finish()) .with_margin_left(CONTENT_LEFT_MARGIN) .with_margin_right(CONTENT_RIGHT_MARGIN) .with_margin_bottom(5.) .finish(), ) .with_min_width(180.) .finish(); // Bottom section: comment list view // The view handles its own resizable logic internally let bottom_section = ChildView::new(&self.comment_list_view).finish(); // Global LSP footer (below comment list) // Only show the footer if there are changes and all editors have finished loading. let footer_section = if !state.file_states.is_empty() && self.all_editors_loaded() { self.code_review_footer .as_ref() .map(|footer| ChildView::new(footer).finish()) } else { None }; // Wrap all sections in a vertical flex that takes full height let mut col = Flex::column() .with_main_axis_size(MainAxisSize::Max) .with_child(Shrinkable::new(1., top_section_with_margin).finish()) .with_child(bottom_section); if let Some(footer) = footer_section { col.add_child(footer); } col.finish() } /// Renders the state when there are no changes on the current branch. fn render_no_changes_state( &self, appearance: &Appearance, app: &AppContext, ) -> Box { let theme = appearance.theme(); let mut main_row = Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max); let mut zero_state_column = Flex::column() .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Container::new( ConstrainedBox::new( Icon::Diff .to_galaxyui_icon(galaxy_core::ui::theme::Fill::Solid( internal_colors::neutral_6(theme), )) .finish(), ) .with_width(48.) .with_height(48.) .finish(), ) .with_margin_bottom(16.) .finish(), ) .with_child( Text::new("No open changes", appearance.ui_font_family(), 16.) .with_style(Properties::default().weight(Weight::Semibold)) .with_color(theme.main_text_color(theme.surface_2()).into()) .finish(), ) .with_child( Container::new( Text::new( "As you or the Agent make changes, you'll be able to track them here.", appearance.ui_font_family(), 14., ) .with_color(theme.sub_text_color(theme.surface_2()).into()) .finish(), ) .with_margin_top(8.) .finish(), ); let should_show_init = self .repo_path() .map(|path| { let has_steps = InitProjectModel::should_have_available_steps(path, app); let is_terminal_in_correct_dir = self .terminal_view(app) .and_then(|view| { view.read(app, |t, _| t.pwd().map(|pwd| pwd == path.to_string_lossy())) }) .unwrap_or(false); has_steps && is_terminal_in_correct_dir }) .unwrap_or(false); if should_show_init { zero_state_column.add_child( Container::new(ChildView::new(&self.init_project_button).finish()) .with_margin_top(16.) .finish(), ); } else if let Some(repo_path) = self.repo_path() { // Check for initialized rules if let Some(rules) = ProjectContextModel::as_ref(app).find_applicable_rules(repo_path) { if let Some(first_rule) = rules.active_rules.first() { if let Some(file_name) = first_rule.path.file_name().and_then(|n| n.to_str()) { zero_state_column.add_child( Container::new( Text::new( format!("Repo is initialized with a {file_name} file."), appearance.ui_font_family(), 12., ) .with_color(theme.sub_text_color(theme.surface_2()).into()) .finish(), ) .with_margin_top(8.) .finish(), ); } } } } let zero_state_content = Container::new(zero_state_column.finish()).finish(); // Add expandable spacers on left and right to center the content and force full width. main_row.add_child(Shrinkable::new(1., Empty::new().finish()).finish()); main_row.add_child(zero_state_content); main_row.add_child(Shrinkable::new(1., Empty::new().finish()).finish()); Shrinkable::new( 1., Container::new(Clipped::new(main_row.finish()).finish()) .with_border(Border::new(1.0).with_border_fill(theme.outline())) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_uniform_padding(16.) .with_margin_bottom(16.) .finish(), ) .finish() } /// Renders the header with diff mode dropdown and overflow menu. fn render_header( &self, state: &LoadedState, appearance: &Appearance, is_in_split_pane: bool, app: &AppContext, ) -> Box { let has_menu_flags = FeatureFlag::DiscardPerFileAndAllChanges.is_enabled() || FeatureFlag::DiffSetAsContext.is_enabled() || FeatureFlag::FileAndDiffSetComments.is_enabled(); let has_changes = matches!(self.state(), CodeReviewViewState::Loaded(loaded) if !loaded.to_diff_stats().has_no_changes()); let has_header_menu_items = has_menu_flags && (!FeatureFlag::GitOperationsInCodeReview.is_enabled() || has_changes); let code_review_header_fields = CodeReviewHeaderFields { is_in_split_pane, maximize_button: self.maximize_button.clone(), diff_selector: self.diff_selector.clone(), header_menu: self.header_menu.clone(), header_menu_open: self.header_menu_open, diff_state_model: self.diff_state_model.clone(), header_dropdown_button: self.header_dropdown_button.clone(), has_header_menu_items, file_nav_button: if FeatureFlag::GitOperationsInCodeReview.is_enabled() && self.has_file_states() { Some(self.file_nav_button.clone()) } else { None }, primary_git_action_mode: self.primary_git_action_mode(app), git_primary_action_button: self.git_primary_action_button.clone(), git_operations_chevron: self.git_operations_chevron.clone(), git_operations_menu: self.git_operations_menu.clone(), git_operations_menu_open: self.git_operations_menu_open, }; let header = if FeatureFlag::GitOperationsInCodeReview.is_enabled() { self.header .render_new(appearance, &code_review_header_fields) } else { self.header .render(state, appearance, &code_review_header_fields, app) }; SavePosition::new(header, &self.header_position_id).finish() } /// Prepares review comments and emits an event for a higher-level view to route /// them to an available terminal. fn handle_submit_review_with_comments(&mut self, ctx: &mut ViewContext) { let Some(model) = self.active_comment_model.as_ref() else { return; }; let review_comments = model.read(ctx, |batch, _| batch.clone()); let active_comments: Vec<_> = review_comments .comments .into_iter() .filter(|c| !c.outdated) .collect(); if active_comments.is_empty() { log::info!("No review comments to submit"); return; } let Some(repo_path) = self.repo_path().cloned() else { log::warn!("No active repo path for submitting review"); return; }; let active_batch = ReviewCommentBatch::from_comments(active_comments); let diff_set = self.collect_diff_set(&active_batch); let agent_comment_batch = AgentReviewCommentBatch { comments: active_batch.comments, diff_set, }; ctx.emit(CodeReviewViewEvent::SubmitReviewComments { comments: agent_comment_batch, repo_path, }); } /// Called by the routing layer (RightPanelView) after attempting to submit review /// comments to a terminal. pub fn handle_review_submission_result( &mut self, result: ReviewSubmissionResult, ctx: &mut ViewContext, ) { match result { ReviewSubmissionResult::Success { comment_count, file_count, destination, } => { log::info!("Successfully submitted review comments to terminal"); send_telemetry_from_ctx!( CodeReviewTelemetryEvent::ReviewSubmitted { comment_count, file_count, destination, }, ctx ); self.clear_review_comments(ctx); ToastStack::handle(ctx).update(ctx, |stack, ctx| { let toast = DismissibleToast::default("Comments sent to agent".into()); stack.add_ephemeral_toast(toast, self.window_id, ctx); }); ctx.emit(CodeReviewViewEvent::ReviewSubmitted); ctx.notify(); } ReviewSubmissionResult::Error => { log::error!("Failed to submit review comments"); let error_message = "Could not submit comments to the agent".to_string(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = DismissibleToast::error(error_message); toast_stack.add_ephemeral_toast(toast, self.window_id, ctx); }); } } } /// TODO(CODE-1649): de-duplicate entries in the diff set. fn collect_diff_set( &self, review_comments: &ReviewCommentBatch, ) -> HashMap> { let mut diff_set: HashMap> = HashMap::new(); let repo_path = self.repo_path(); for comment in &review_comments.comments { match &comment.target { AttachedReviewCommentTarget::Line { absolute_file_path, line, content, } => { if let Some(line_number) = line.line_number() { let hunk = DiffSetHunk { line_range: line_number..line_number + 1, diff_content: content.content.clone(), lines_added: content.lines_added.as_u32(), lines_removed: content.lines_removed.as_u32(), }; let file_key = repo_path .and_then(|repo_path| absolute_file_path.strip_prefix(repo_path).ok()) .unwrap_or(absolute_file_path.as_path()) .to_string_lossy() .to_string(); diff_set.entry(file_key).or_default().push(hunk); } } AttachedReviewCommentTarget::File { absolute_file_path } => { if let CodeReviewViewState::Loaded(loaded_state) = self.state() { let file_diffs = loaded_state .file_states .values() .filter(|fs| absolute_file_path.ends_with(&fs.file_diff.file_path)) .map(|fs| &fs.file_diff); let hunks = convert_file_diffs_to_diffset_hunks(file_diffs); diff_set.extend(hunks); } } AttachedReviewCommentTarget::General => { if let CodeReviewViewState::Loaded(loaded_state) = self.state() { let file_diffs = loaded_state.file_states.values().map(|fs| &fs.file_diff); let hunks = convert_file_diffs_to_diffset_hunks(file_diffs); diff_set.extend(hunks); } } } } diff_set } /// Renders additions and deletions counts pub fn render_additions_and_deletions( stats: &DiffStats, appearance: &Appearance, minus_px: f32, ) -> Flex { let mut counts_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); // Add additions count counts_row.add_child( Container::new( Text::new( format!("+{}", stats.total_additions), appearance.ui_font_family(), appearance.ui_font_size() - minus_px, ) .with_color(add_color(appearance)) .finish(), ) .with_margin_right(4.) .finish(), ); // Add deletions count counts_row.add_child( Container::new( Text::new( format!("-{}", stats.total_deletions), appearance.ui_font_family(), appearance.ui_font_size() - minus_px, ) .with_color(remove_color(appearance)) .finish(), ) .finish(), ); counts_row } /// Renders the diff statistics pub fn render_diff_stats(stats: &DiffStats, appearance: &Appearance) -> Box { let theme = appearance.theme(); // Create diff stats chip content let mut counts_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); // Add file icon let file_icon = Icon::File .to_galaxyui_icon(galaxy_core::ui::theme::Fill::Solid( internal_colors::neutral_6(theme), )) .finish(); counts_row.add_child( Container::new( ConstrainedBox::new(file_icon) .with_height(appearance.ui_font_size()) .with_width(appearance.ui_font_size()) .finish(), ) .with_margin_right(4.) .finish(), ); // Add file count counts_row.add_child( Container::new( Text::new_inline( stats.files_changed.to_string(), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color( galaxy_core::ui::theme::Fill::Solid(internal_colors::neutral_6(theme)).into(), ) .with_line_height_ratio(appearance.line_height_ratio()) .with_style(Properties::default().weight(Weight::Semibold)) .finish(), ) .with_margin_right(4.) .finish(), ); if stats.has_no_changes() { return counts_row.finish(); } // Add separator counts_row.add_child( Container::new( Text::new( " • ", appearance.ui_font_family(), appearance.ui_font_size(), ) .with_style(Properties::default().weight(Weight::Bold)) .with_color( galaxy_core::ui::theme::Fill::Solid(internal_colors::neutral_6(theme)).into(), ) .finish(), ) .with_margin_right(4.) .finish(), ); counts_row.add_child(Self::render_additions_and_deletions(stats, appearance, 0.).finish()); counts_row.finish() } /// Renders the content area with all file diffs fn render_content( &self, state: &LoadedState, appearance: &Appearance, app: &AppContext, ) -> Box { if state.file_states.is_empty() { return self.render_no_changes_state(appearance, app); } let mut sidebar_and_diffs_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Stretch); let sidebar_on_right = FeatureFlag::GitOperationsInCodeReview.is_enabled(); // When the flag is off, sidebar goes on the left (legacy). if !sidebar_on_right && self.file_sidebar_expanded && !state.file_states.is_empty() { sidebar_and_diffs_row .add_child(Container::new(self.render_file_sidebar(state, appearance)).finish()); let vertical_separator = ConstrainedBox::new( Rect::new() .with_background(appearance.theme().outline()) .finish(), ) .with_width(1.) .finish(); sidebar_and_diffs_row.add_child(vertical_separator); } let axis_config = SingleAxisConfig::Manual { handle: self.scroll_state.clone(), child: NewScrollableElement::finish_scrollable(List::new( self.viewported_list_state.clone(), )), }; let scrollable_diffs = NewScrollable::vertical( axis_config, appearance.theme().nonactive_ui_detail().into(), appearance.theme().active_ui_detail().into(), galaxyui::elements::Fill::None, ) .with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false)) .with_propagate_mousewheel_if_not_handled(true) .with_always_handle_events_first(false) .finish(); let scrollable_diffs = SavePosition::new(scrollable_diffs, &self.code_review_list_position_id).finish(); let diffs_container = if self.file_sidebar_expanded && !state.file_states.is_empty() { let margin = if sidebar_on_right { Container::new(scrollable_diffs).with_margin_right(15.) } else { Container::new(scrollable_diffs).with_margin_left(15.) }; margin.finish() } else { scrollable_diffs }; sidebar_and_diffs_row.add_child(Shrinkable::new(1., diffs_container).finish()); // When the flag is on, sidebar goes on the right (new layout). if sidebar_on_right && self.file_sidebar_expanded && !state.file_states.is_empty() { let vertical_separator = ConstrainedBox::new( Rect::new() .with_background(appearance.theme().outline()) .finish(), ) .with_width(1.) .finish(); sidebar_and_diffs_row.add_child(vertical_separator); sidebar_and_diffs_row .add_child(Container::new(self.render_file_sidebar(state, appearance)).finish()); } Shrinkable::new(1., sidebar_and_diffs_row.finish()).finish() } fn render_file_sidebar( &self, state: &LoadedState, appearance: &Appearance, ) -> Box { let mut column = Flex::column() .with_main_axis_alignment(MainAxisAlignment::Start) .with_cross_axis_alignment(CrossAxisAlignment::Start); for (file_index, file_state) in state.file_states.values().enumerate() { let file_row = self.render_file_sidebar_row(file_state, appearance); column.add_child( Hoverable::new(file_state.sidebar_mouse_state.clone(), |mouse_state| { let mut container = Container::new(Shrinkable::new(1., file_row).finish()) .with_vertical_padding(5.) .with_horizontal_padding(8.) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))); if mouse_state.is_hovered() { container = container.with_background(galaxy_core::ui::theme::Fill::Solid( internal_colors::neutral_3(appearance.theme()), )) } container.finish() }) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(CodeReviewAction::FileSelected(file_index)); }) .with_cursor(Cursor::PointingHand) .finish(), ); } let scrollable_content = NewScrollable::vertical( SingleAxisConfig::Clipped { handle: self.ui_state_handles.sidebar_scroll_state.clone(), child: column.finish(), }, appearance.theme().nonactive_ui_detail().into(), appearance.theme().active_ui_detail().into(), galaxyui::elements::Fill::None, ) .with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false)) .finish(); // We need an Align to ensure the Resizable takes up the full height of the sidebar. // This way, the click target for resizing doesn't shrink with a short or empty file list. let sidebar_on_right = FeatureFlag::GitOperationsInCodeReview.is_enabled(); let sidebar_content = if sidebar_on_right { Container::new(scrollable_content) .with_padding_left(8.) .finish() } else { Container::new(scrollable_content) .with_padding_right(8.) .finish() }; let mut resizable = Resizable::new( self.ui_state_handles.sidebar_resizable_state.clone(), sidebar_content, ); if sidebar_on_right { resizable = resizable.with_dragbar_side(DragBarSide::Left); } resizable .on_resize(move |ctx, _| { ctx.notify(); }) .with_bounds_callback(Box::new(Self::file_sidebar_bounds_callback)) .finish() } fn file_sidebar_bounds_callback(_window_bounds: Vector2F) -> (f32, f32) { (FILE_SIDEBAR_MIN_WIDTH, FILE_SIDEBAR_MAX_WIDTH) } fn render_file_sidebar_row( &self, file_state: &FileState, appearance: &Appearance, ) -> Box { let file_name = file_state .file_diff .file_path .file_name() .and_then(|file_name| file_name.to_str()) .unwrap_or_default(); let dir_path = file_state .file_diff .file_path .parent() .and_then(|parent| parent.to_str()) .unwrap_or_default(); let additions = file_state.file_diff.additions(); let deletions = file_state.file_diff.deletions(); // Create the main row for the file entry let mut file_row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); let mut file_and_directory = Flex::row(); const SMALLER_TEXT_RATIO: f32 = 0.9; // File name (prominent) file_and_directory.add_child( Container::new( ConstrainedBox::new( Text::new( file_name.to_string(), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color( appearance .theme() .main_text_color(appearance.theme().surface_2()) .into(), ) .soft_wrap(false) .finish(), ) .with_max_width(190.) .finish(), ) .with_margin_right(4.) .finish(), ); // Directory path (muted and smaller) if !dir_path.is_empty() { file_and_directory.add_child( Shrinkable::new( 1., Text::new( dir_path.to_string(), appearance.ui_font_family(), appearance.ui_font_size() * SMALLER_TEXT_RATIO, // Slightly smaller ) .with_color( appearance .theme() .sub_text_color(appearance.theme().surface_2()) .into(), ) .with_clip(ClipConfig::end()) .soft_wrap(false) .with_line_height_ratio(DEFAULT_UI_LINE_HEIGHT_RATIO / SMALLER_TEXT_RATIO) .with_compute_baseline_position_fn(Box::new(|args| { // Calculate baseline position as if we were using the larger font size. // This ensures both text elements have the same baseline. let larger_font_size = args.font_size / SMALLER_TEXT_RATIO; default_compute_baseline_position( larger_font_size, DEFAULT_UI_LINE_HEIGHT_RATIO, args.ascent * (larger_font_size / args.font_size), args.descent * (larger_font_size / args.font_size), ) })) .finish(), ) .finish(), ); } file_row.add_child( Shrinkable::new(1., Clipped::new(file_and_directory.finish()).finish()).finish(), ); // Right side: additions/deletions let mut changes_text = Text::new( "", appearance.ui_font_family(), appearance.ui_font_size() * SMALLER_TEXT_RATIO, ) .with_line_height_ratio(DEFAULT_UI_LINE_HEIGHT_RATIO / SMALLER_TEXT_RATIO) .with_compute_baseline_position_fn(Box::new(|args| { // Calculate baseline position as if we were using the larger font size. // This ensures all text elements have the same baseline. let larger_font_size = args.font_size / SMALLER_TEXT_RATIO; default_compute_baseline_position( larger_font_size, DEFAULT_UI_LINE_HEIGHT_RATIO, args.ascent * (larger_font_size / args.font_size), args.descent * (larger_font_size / args.font_size), ) })); if additions > 0 { changes_text.add_text_with_highlights( format!("+{additions}"), add_color(appearance), Properties::default(), ); } if deletions > 0 { if !changes_text.text().is_empty() { changes_text.add_text_with_highlights( " ", remove_color(appearance), Properties::default(), ); } changes_text.add_text_with_highlights( format!("-{deletions}"), remove_color(appearance), Properties::default(), ); } if !changes_text.text().is_empty() { file_row.add_child( Container::new(changes_text.finish()) .with_margin_left(8.) .finish(), ); } file_row.finish() } fn file_index_position(&self, file_index: usize) -> String { format!("CodeReviewView-{}-{file_index}", self.position_id_prefix) } fn file_diff_header_position(&self, file_index: usize) -> String { format!( "CodeReviewView-{}-DiffHeader-{file_index}", self.position_id_prefix ) } /// Renders a single file's diff fn render_file_diff( &self, file: &FileState, file_index: usize, scroll_offset_from_top: ScrollOffset, appearance: &Appearance, app: &AppContext, ) -> Box { let is_item_being_scrolled = file_index == scroll_offset_from_top.list_item_index(); // This helps us avoid rendering the sticky header for the first time when scrolled to the very top. let is_first_item_with_no_scroll = file_index == 0 && scroll_offset_from_top.list_item_index() == 0 && scroll_offset_from_top.offset_from_start().as_f32() < 1.; let file_header = if is_item_being_scrolled && file.is_expanded && !is_first_item_with_no_scroll { Empty::new().finish() } else { let header = SavePosition::new( self.render_file_header(file, appearance, app), &self.file_diff_header_position(file_index), ) .finish(); if file.is_expanded { header } else { // The file header is saved as the position if the diff is not expanded. SavePosition::new(header, &self.file_index_position(file_index)).finish() } }; let mut content = Flex::column().with_child(file_header); let mut stack = Stack::new().with_constrain_absolute_children(); // Only show file content if expanded. if file.is_expanded { stack.add_child( SavePosition::new( Container::new(self.render_file_content(file, appearance)) .with_margin_top( if is_item_being_scrolled && !is_first_item_with_no_scroll { // This is the height of the header bar needs to be present. Otherwise, // the file contents shift up by this amount. if let Some(header_rect) = app.element_position_by_id_at_last_frame( self.window_id, self.file_diff_header_position(file_index), ) { header_rect.height() } else { FILE_HEADER_HEIGHT } } else { 0. }, ) .finish(), &self.file_index_position(file_index), ) .finish(), ); if is_item_being_scrolled && !is_first_item_with_no_scroll { let sticky_file_header = self.render_file_header(file, appearance, app); stack.add_positioned_child( sticky_file_header, // We effectively make this an absolutely positioned header. OffsetPositioning::offset_from_parent( vec2f(0., scroll_offset_from_top.offset_from_start().as_f32()), galaxyui::elements::ParentOffsetBounds::ParentByPosition, galaxyui::elements::ParentAnchor::TopMiddle, galaxyui::elements::ChildAnchor::TopMiddle, ), ); } content.add_child(stack.finish()); } Container::new(Shrinkable::new(1., content.finish()).finish()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_margin_bottom(EDITOR_GAP) .finish() } /// Renders the file header with name and status fn render_file_header( &self, file: &FileState, appearance: &Appearance, app: &AppContext, ) -> Box { let theme = appearance.theme(); let file_name = file.file_diff.file_path.display().to_string(); let mut left_section = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min) .with_child( Container::new(ChildView::new(&file.chevron_button).finish()) .with_margin_right(8.) .finish(), ); if let GitFileStatus::Renamed { old_path } = &file.file_diff.status { left_section.add_children([ Shrinkable::new( 1., Text::new( old_path.clone(), appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_color(theme.main_text_color(theme.surface_2()).into()) .with_clip(ClipConfig::start()) .soft_wrap(false) .finish(), ) .finish(), Text::new( " → ", appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_color(theme.main_text_color(theme.surface_2()).into()) .finish(), Shrinkable::new( 1., Container::new( Text::new( file_name, appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_color(theme.main_text_color(theme.surface_2()).into()) .with_clip(ClipConfig::start()) .soft_wrap(false) .finish(), ) .with_margin_right(8.) .finish(), ) .finish(), ]); } else { left_section.add_child( Shrinkable::new( 1., Container::new( Shrinkable::new( 1., Text::new( file_name, appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_color(theme.main_text_color(theme.surface_2()).into()) .with_clip(ClipConfig::start()) .soft_wrap(false) .finish(), ) .finish(), ) .with_margin_right(8.) .finish(), ) .finish(), ); } left_section.add_child(if let Some(editor_state) = &file.editor_state { if editor_state.has_unsaved_changes(app) { let save_keystroke = Keystroke::parse("cmdorctrl-s").unwrap_or_default(); let save_shortcut = save_keystroke.displayed(); let tooltip_text = format!("This file has unsaved changes. {save_shortcut} to save"); render_unsaved_circle_with_tooltip( editor_state.unsaved_changes_mouse_state(), tooltip_text, 10.67, 8., appearance, ) } else { Empty::new().finish() } } else { Empty::new().finish() }); left_section.add_child( EventHandler::new( Container::new(ChildView::new(&file.copy_path_button).finish()) .with_margin_right(8.) .finish(), ) .on_left_mouse_up(|_, _, _| DispatchEventResult::StopPropagation) .on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation) .finish(), ); left_section.add_child(self.render_file_stats(&file.file_diff, appearance)); let mut right_row = Flex::row() .with_main_axis_alignment(MainAxisAlignment::End) .with_cross_axis_alignment(CrossAxisAlignment::Center); // Add file diff as context button (before remove button) if FeatureFlag::DiffSetAsContext.is_enabled() { right_row.add_child( EventHandler::new( Container::new(ChildView::new(&file.add_context_button).finish()) .with_margin_left(4.) .finish(), ) .on_left_mouse_up(|_, _, _| DispatchEventResult::StopPropagation) .on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation) .finish(), ); } if FeatureFlag::DiscardPerFileAndAllChanges.is_enabled() { right_row.add_child( EventHandler::new( Container::new(ChildView::new(&file.discard_button).finish()) .with_margin_left(4.) .finish(), ) .on_left_mouse_up(|_, _, _| DispatchEventResult::StopPropagation) .on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation) .finish(), ); } right_row.add_child( EventHandler::new( Container::new(ChildView::new(&file.open_in_tab_button).finish()) .with_margin_left(4.) .finish(), ) .on_left_mouse_up(|_, _, _| DispatchEventResult::StopPropagation) .on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation) .finish(), ); let right_section = right_row.finish(); let file_path_for_toggle = file.file_diff.file_path.clone(); let outer_bg = theme.background(); let inner_corner_radius = if file.is_expanded { CornerRadius::with_top(Radius::Pixels(8.)) } else { CornerRadius::with_all(Radius::Pixels(8.)) }; let inner_header = Hoverable::new(file.header_mouse_state.clone(), |mouse_state| { let header_bg = if mouse_state.is_hovered() { neutral_3(appearance.theme()) } else { neutral_2(appearance.theme()) }; Container::new( Clipped::new( Shrinkable::new( 1., Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Shrinkable::new(1., left_section.finish()).finish()) .with_child(right_section) .finish(), ) .finish(), ) .finish(), ) .with_background(header_bg) .with_vertical_padding(8.) .with_horizontal_padding(16.) .with_corner_radius(inner_corner_radius) .with_border( Border::new(1.) .with_sides( true, /* top */ true, /* left */ false, /* bottom */ true, /* right */ ) .with_border_fill(theme.surface_3()), ) .finish() }) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(CodeReviewAction::ToggleFileExpanded( file_path_for_toggle.clone(), )) }) .with_defer_events_to_children() .finish(); Container::new(inner_header) .with_background(outer_bg) .finish() } /// Renders file-specific statistics fn render_file_stats(&self, file: &FileDiff, appearance: &Appearance) -> Box { let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); let no_line_changes_present = file.additions() == 0 && file.deletions() == 0; if file.is_binary || no_line_changes_present { row.add_child( Container::new( Text::new("0", appearance.ui_font_family(), appearance.ui_font_size()) .with_color( appearance .theme() .disabled_text_color(appearance.theme().background()) .into(), ) .finish(), ) .finish(), ) } else { row.add_children([ Container::new( Text::new( format!("+{}", file.additions()), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(add_color(appearance)) .finish(), ) .with_margin_right(4.) .finish(), Container::new( Text::new("•", appearance.ui_font_family(), appearance.ui_font_size()) .with_style(Properties::default().weight(Weight::Bold)) .with_color( galaxy_core::ui::theme::Fill::Solid(internal_colors::neutral_6( appearance.theme(), )) .into(), ) .finish(), ) .with_margin_right(4.) .finish(), Container::new( Text::new( format!("-{}", file.deletions()), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(remove_color(appearance)) .finish(), ) .with_margin_left(4.) .finish(), ]); } Container::new(row.finish()) .with_background(appearance.theme().background()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.))) .with_horizontal_padding(8.) .with_vertical_padding(4.) .with_border( Border::all(1.).with_border_fill(galaxy_core::ui::theme::Fill::Solid( internal_colors::neutral_4(appearance.theme()), )), ) .finish() } fn styled_file_content_container( content: Box, theme: &WarpTheme, ) -> Box { Container::new( Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_child(Shrinkable::new(1., content).finish()) .finish(), ) .with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.))) .with_background(theme.background()) .with_vertical_padding(8.) .with_horizontal_padding(16.) .with_border( Border::new(1.) .with_sides( false, /* top */ true, /* left */ true, /* bottom */ true, /* right */ ) .with_border_fill(theme.surface_3()), ) .finish() } /// Renders the file content (hunks for text files using LocalCodeEditorView, placeholder for binary) fn render_file_content(&self, file: &FileState, appearance: &Appearance) -> Box { let theme = appearance.theme(); let diff_size = file.file_diff.size; if diff_size == DiffSize::Unrenderable { return Self::styled_file_content_container( Text::new( "Diff is too large to render", appearance.monospace_font_family(), appearance.monospace_font_size(), ) .with_color(remove_color(appearance)) .finish(), theme, ); } if file.file_diff.is_binary { Self::styled_file_content_container( Text::new( "Binary file - no diff available", appearance.monospace_font_family(), appearance.monospace_font_size(), ) .with_color(theme.main_text_color(theme.background()).into()) .finish(), theme, ) } else if file.file_diff.status.is_renamed() && file.file_diff.is_empty() { Self::styled_file_content_container( Text::new( "File renamed without changes", appearance.monospace_font_family(), appearance.monospace_font_size(), ) .with_color(theme.main_text_color(theme.background()).into()) .finish(), theme, ) } else if file.file_diff.status.is_new_file() && file.file_diff.is_empty() { Self::styled_file_content_container( Text::new( "New empty file", appearance.monospace_font_family(), appearance.monospace_font_size(), ) .with_color(theme.main_text_color(theme.background()).into()) .finish(), theme, ) } else if let Some(editor_state) = file.editor_state.as_ref() { Hoverable::new(editor_state.editor_mouse_state.clone(), |_| { Container::new(ChildView::new(&editor_state.editor).finish()) .with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.))) .with_background(theme.background()) .with_border( Border::new(1.) .with_sides( false, /* top */ true, /* left */ true, /* bottom */ true, /* right */ ) .with_border_fill(theme.surface_3()), ) .finish() }) .with_cursor(Cursor::IBeam) .finish() } else { Self::styled_file_content_container( Text::new( "Unable to load file content", appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(theme.sub_text_color(theme.background()).into()) .finish(), theme, ) } } fn revert_hunk_toast_id(&self, ctx: &mut ViewContext) -> String { format!("diff_removed_{}", ctx.view_id()) } #[cfg(feature = "local_fs")] fn attach_diff_not_allowed_toast_id(&self, ctx: &mut ViewContext) -> String { format!("attach_diff_not_allowed_{}", ctx.view_id()) } fn attach_context_not_allowed_toast_id(&self, ctx: &mut ViewContext) -> String { format!("attach_context_not_allowed_{}", ctx.view_id()) } fn render_stats_fallback(appearance: &Appearance) -> Box { Container::new( Text::new("0", appearance.ui_font_family(), appearance.ui_font_size()) .with_color( appearance .theme() .disabled_text_color(appearance.theme().background()) .into(), ) .finish(), ) .finish() } fn get_file_stats( file_path: &PathBuf, loaded: &LoadedState, appearance: &Appearance, ) -> Box { if let Some(file_state) = loaded.file_states.get(file_path) { let additions = file_state.file_diff.additions(); let deletions = file_state.file_diff.deletions(); let file_diff_stats = DiffStats { files_changed: 1, total_additions: additions, total_deletions: deletions, }; Self::render_additions_and_deletions(&file_diff_stats, appearance, 1.).finish() } else { Self::render_stats_fallback(appearance) } } /// If checkbox_element is Some, it will be rendered on the left fn render_single_file_row( file_path: &Path, stats: Box, checkbox_element: Option>, app: &AppContext, ) -> Box { let file_path_element = render_file_search_row( file_path, FileSearchRowOptions { ..Default::default() }, app, ); let mut left = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); if let Some(checkbox) = checkbox_element { left.add_child(checkbox); } left.add_child(Shrinkable::new(1., file_path_element).finish()); Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Shrinkable::new(1., left.finish()).finish()) .with_child(stats) .finish() } fn render_file_row(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); if self.discard_dialog_state.discard_file_paths.is_empty() { return Text::new( "No file selected", appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color( appearance .theme() .sub_text_color(appearance.theme().background()) .into(), ) .finish(); } let CodeReviewViewState::Loaded(loaded) = self.state() else { return Text::new( "No files to discard", appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color( appearance .theme() .sub_text_color(appearance.theme().background()) .into(), ) .finish(); }; let is_discard_all = matches!( self.discard_dialog_state.operation_type, DiscardOperationType::AllUncommittedChanges | DiscardOperationType::AllChangesAgainstBranch(_) ); if is_discard_all { // Removing all files: show list of all files with individual stats and checkboxes let mut file_list = Flex::column(); for file_path in &self.discard_dialog_state.discard_file_paths { let file_stats = Self::get_file_stats(file_path, loaded, appearance); let is_selected = self .discard_dialog_state .selected_files .get(file_path) .copied() .unwrap_or(true); let mouse_state = self .discard_dialog_state .file_checkbox_mouse_states .get(file_path) .cloned() .unwrap_or_default(); let file_path_for_closure = file_path.clone(); let checkbox = appearance .ui_builder() .checkbox(mouse_state, Some(10.5)) .check(is_selected) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(CodeReviewAction::ToggleFileSelection( file_path_for_closure.clone(), )); }) .finish(); let row = Self::render_single_file_row(file_path, file_stats, Some(checkbox), app); file_list.add_child(row); } file_list.finish() } else { // Removing single file: show that file with individual stats (no checkbox) let file_path = &self.discard_dialog_state.discard_file_paths[0]; let file_stats = Self::get_file_stats(file_path, loaded, appearance); Self::render_single_file_row(file_path, file_stats, None, app) } } fn render_discard_confirm_dialog(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let discard_button = Container::new( ChildView::new(&self.discard_dialog_state.discard_confirm_button).finish(), ) .with_margin_left(8.) .finish(); let cancel_button = ChildView::new(&self.discard_dialog_state.discard_cancel_button).finish(); let file_row_content = self.render_file_row(app); let scrollable_file_row = NewScrollable::vertical( SingleAxisConfig::Clipped { handle: self.discard_dialog_state.file_list_scroll_state.clone(), child: file_row_content, }, appearance.theme().nonactive_ui_detail().into(), appearance.theme().active_ui_detail().into(), galaxyui::elements::Fill::None, ) .with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false)) .finish(); let file_row = ConstrainedBox::new(scrollable_file_row) .with_max_height(200.0) .finish(); let (title, description) = ( self.discard_dialog_state.operation_type.title(), self.discard_dialog_state.operation_type.description(), ); let mut dialog_builder = Dialog::new( title, description, UiComponentStyles { width: Some(460.), padding: Some(Coords::uniform(24.).bottom(12.)), ..dialog_styles(appearance) }, ) .with_child(Container::new(file_row).finish()) .with_separator(); // hide stash option entirely if there's no HEAD (git doesn't let you stash with no HEAD) let can_stash = self.diff_state_model.as_ref(app).has_head(); if self .discard_dialog_state .operation_type .is_uncommitted_changes() && can_stash { let stash_checkbox = Container::new( appearance .ui_builder() .checkbox( self.discard_dialog_state .stash_changes_checkbox_mouse_state .clone(), Some(16.), ) .check(self.discard_dialog_state.stash_changes_enabled) .with_label( appearance.ui_builder().span("Stash changes").with_style( UiComponentStyles { font_size: Some(appearance.ui_font_size()), font_color: Some( appearance .theme() .main_text_color(appearance.theme().background()) .into(), ), ..Default::default() }, ), ) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(CodeReviewAction::ToggleStashChanges); }) .finish(), ) .finish(); dialog_builder = dialog_builder.with_bottom_row_left_child(stash_checkbox); } let dialog = Container::new( dialog_builder .with_bottom_row_child(cancel_button) .with_bottom_row_child(discard_button) .build() .finish(), ) .with_margin_top(35.) .finish(); // Stack needed so that dialog can get bounds information, // specifically to ensure no overlap with the window's traffic lights let mut stack = Stack::new(); stack.add_positioned_child( dialog, OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::Center, ChildAnchor::Center, ), ); // This blurs the background and makes it uninteractable Container::new(Align::new(stack.finish()).finish()) .with_background_color(appearance.theme().blurred_background_overlay().into()) .with_corner_radius(app.windows().window_corner_radius()) .finish() } fn create_file_status_info(&self, path: PathBuf) -> FileStatusInfo { let status = match self.state() { CodeReviewViewState::Loaded(loaded_state) => loaded_state .file_states .get(&path) .map(|fs| fs.file_diff.status.clone()) .unwrap_or(GitFileStatus::Modified), _ => GitFileStatus::Modified, }; FileStatusInfo { path, status } } fn discard_file(&mut self, path: &Path, should_stash: bool, ctx: &mut ViewContext) { let file_info = self.create_file_status_info(path.to_path_buf()); let branch_name = match &self.discard_dialog_state.operation_type { DiscardOperationType::FileChangesAgainstBranch(None) => { Some(self.diff_state_model.as_ref(ctx).get_main_branch_name()) } DiscardOperationType::FileChangesAgainstBranch(Some(branch)) => { Some(Some(branch.clone())) } _ => None, }; self.diff_state_model.update(ctx, |model, ctx| { model.discard_files(vec![file_info], should_stash, branch_name.flatten(), ctx); }); self.discard_dialog_state.stash_changes_enabled = false; } fn discard_multiple_files( &mut self, file_paths: Vec, should_stash: bool, ctx: &mut ViewContext, ) { let file_infos: Vec = file_paths .into_iter() .map(|path| self.create_file_status_info(path)) .collect(); let branch_name = match &self.discard_dialog_state.operation_type { DiscardOperationType::AllChangesAgainstBranch(None) => { Some(self.diff_state_model.as_ref(ctx).get_main_branch_name()) } DiscardOperationType::AllChangesAgainstBranch(Some(branch)) => { Some(Some(branch.clone())) } _ => None, }; self.diff_state_model.update(ctx, |model, ctx| { model.discard_files(file_infos, should_stash, branch_name.flatten(), ctx); }); self.discard_dialog_state.stash_changes_enabled = false; } fn handle_code_editor_event( &mut self, file_path: PathBuf, editor: ViewHandle, event: &CodeEditorEvent, ctx: &mut ViewContext, ) { match event { CodeEditorEvent::DiffHunkContextAdded { line_range } => { self.insert_diff_hunk_as_context(file_path, line_range.clone(), ctx); } CodeEditorEvent::DiffReverted => { // Show toast notification that diff was removed. let version = editor.as_ref(ctx).version(ctx); self.last_revert = Some((editor, version)); let toast_id = self.revert_hunk_toast_id(ctx); crate::workspace::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = crate::view_components::DismissibleToast::default( "Diff removed".to_string(), ) .with_object_id(toast_id) .with_action_button(self.undo_action_button.clone()); toast_stack.add_ephemeral_toast(toast, self.window_id, ctx); }); // Focus the view in case the focus was in a different pane before clicking the revert button. // Otherwise, the undo and save actions will not work. ctx.focus_self(); } CodeEditorEvent::HiddenSectionExpanded => { if let CodeReviewViewState::Loaded(LoadedState { file_states, .. }) = self.state() { if let Some(index) = file_states.get_index_of(&file_path) { self.viewported_list_state .invalidate_height_for_index(index); ctx.notify(); } } } CodeEditorEvent::Focused => { ctx.emit(CodeReviewViewEvent::Pane(PaneEvent::FocusSelf)); } CodeEditorEvent::ContentChanged { origin, .. } => { if origin.from_user() { ctx.emit(CodeReviewViewEvent::FileEdited { path: file_path }); if let Some((view_handle, content_version)) = self.last_revert.take() { let same_content_version = content_version == editor.as_ref(ctx).version(ctx); // If the revert was for a different editor or the content version is the same, keep the revert. if view_handle.id() != editor.id() || same_content_version { self.last_revert = Some((view_handle, content_version)); } else { self.last_revert = None; self.dismiss_revert_toast(ctx); } } if self.find_model.as_ref(ctx).is_find_bar_open() && FeatureFlag::CodeReviewFind.is_enabled() { self.find_model.update(ctx, |model, model_ctx| { model.run_search(self.editor_handles(), model_ctx); }); } } } _ => {} } } fn insert_selection_as_context( &mut self, file_path: String, start_line: usize, end_line: usize, selected_text: String, ctx: &mut ViewContext, ) { if let Some(terminal_view) = self.terminal_view.as_ref().and_then(|tv| tv.upgrade(ctx)) { // If a CLI agent is active, send appropriate content to the PTY. let prompt = if start_line == end_line { // Single-line: send the literal text with file/line context. build_selection_substring_prompt(&file_path, start_line, &selected_text) } else { // Multi-line: send a line-range reference with format note. build_selection_line_range_prompt(&file_path, start_line, end_line) }; if let Some(routing) = terminal_view.update(ctx, |tv, ctx| { tv.try_send_text_to_cli_agent_or_rich_input(prompt, ctx) }) { let destination = match routing { CliAgentRouting::RichInput => CodeReviewContextDestination::RichInput, CliAgentRouting::Pty => CodeReviewContextDestination::Pty, }; send_telemetry_from_ctx!( CodeReviewTelemetryEvent::AddToContext { origin: AddToContextOrigin::SelectedText, destination, diff_set_scope: None, }, ctx ); return; } let is_long_running = terminal_view.read(ctx, |terminal_view, _| terminal_view.is_long_running()); if is_long_running { let toast_id = self.attach_context_not_allowed_toast_id(ctx); crate::workspace::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = crate::view_components::DismissibleToast::default( "Cannot attach context when terminal is running".to_string(), ) .with_object_id(toast_id); toast_stack.add_ephemeral_toast(toast, self.window_id, ctx); }); return; } // Otherwise insert the location snippet into the input buffer (original behavior). let location = format!("{file_path}:{start_line}-{end_line} "); send_telemetry_from_ctx!( CodeReviewTelemetryEvent::AddToContext { origin: AddToContextOrigin::SelectedText, destination: CodeReviewContextDestination::AgentInput, diff_set_scope: None, }, ctx ); terminal_view.update(ctx, |terminal_view, ctx| { terminal_view.input().update(ctx, |input, ctx| { input.append_to_buffer(&location, ctx); // Ensure agent mode for AI features input.ensure_agent_mode_for_ai_features(true, ctx); }); }); } } fn maybe_undo_revert(&mut self, ctx: &mut ViewContext) { if let Some((editor, _)) = self.last_revert.take() { editor.update(ctx, |editor, ctx| { editor.undo(ctx); }); self.dismiss_revert_toast(ctx); } } fn dismiss_revert_toast(&mut self, ctx: &mut ViewContext) { let window_id = ctx.window_id(); let revert_hunk_toast_id = self.revert_hunk_toast_id(ctx); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.remove_toast_by_identifier(revert_hunk_toast_id, window_id, ctx); }); } pub fn has_unsaved_changes(&self, ctx: &AppContext) -> bool { !self.get_unsaved_file_paths(ctx).is_empty() } /// Insert diff set as context in the terminal input (either all files or a specific file) #[cfg(feature = "local_fs")] fn insert_diff_as_context(&mut self, scope: DiffSetScope, ctx: &mut ViewContext) { let Some(repo_path) = self.repo_path() else { return; }; if let Some(terminal_view) = self .terminal_view .as_ref() .and_then(|view| view.upgrade(ctx)) { let active_cli_agent = terminal_view.read(ctx, |tv, ctx| tv.active_cli_agent(ctx)); let diff_set_scope = match &scope { DiffSetScope::All => DiffSetContextScope::All, DiffSetScope::File(_) => DiffSetContextScope::File, }; // CLI agent path: write per-file hunk ranges to the PTY (or rich input if open). if active_cli_agent.is_some() { if let CodeReviewViewState::Loaded(state) = self.state() { let files_to_process = match &scope { DiffSetScope::All => state .file_states .values() .map(|fs| &fs.file_diff) .collect_vec(), DiffSetScope::File(target_path) => state .file_states .values() .filter(|fs| fs.file_diff.file_path == *target_path) .map(|fs| &fs.file_diff) .collect_vec(), }; let file_diffs = convert_file_diffs_to_diffset_hunks(files_to_process.into_iter()); let routing = terminal_view.update(ctx, |tv, ctx| { tv.send_diff_context_to_cli_agent_or_rich_input(&file_diffs, ctx) }); let destination = match routing { Some(CliAgentRouting::RichInput) => CodeReviewContextDestination::RichInput, _ => CodeReviewContextDestination::Pty, }; send_telemetry_from_ctx!( CodeReviewTelemetryEvent::AddToContext { origin: AddToContextOrigin::CodeReviewHeader, destination, diff_set_scope: Some(diff_set_scope), }, ctx ); } return; } let is_input_box_visible = terminal_view.read(ctx, |terminal_view, _| { terminal_view.is_input_box_visible(&terminal_view.model.lock(), ctx) }); if !is_input_box_visible { let toast_id = self.attach_diff_not_allowed_toast_id(ctx); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = DismissibleToast::default( "Cannot attach diff while input is not available".to_string(), ) .with_object_id(toast_id); toast_stack.add_ephemeral_toast(toast, self.window_id, ctx); }); return; } if let CodeReviewViewState::Loaded(state) = self.state() { // Filter files based on scope let files_to_process = match &scope { DiffSetScope::All => state .file_states .values() .map(|fs| &fs.file_diff) .collect_vec(), DiffSetScope::File(target_path) => state .file_states .get(target_path) .into_iter() .map(|fs| &fs.file_diff) .collect_vec(), }; if files_to_process.is_empty() { if let DiffSetScope::File(path) = &scope { log::warn!("Could not find file state for path: {}", path.display()); } return; } // Use the shared function to convert diff data with relative paths let file_diffs = convert_file_diffs_to_diffset_hunks(files_to_process.into_iter()); let base = match self.get_diff_base(ctx) { Ok(base) => base, Err(err) => { log::error!( "CodeReviewView could not find diff base when attaching diff as context: {err:?}" ); return; } }; // Create attachment reference and key based on scope let main_branch_name = self.diff_state_model.as_ref(ctx).get_main_branch_name(); let (attachment_reference, attachment_key) = create_attachment_reference_and_key( &scope, &self.diff_state_model.as_ref(ctx).diff_mode(), main_branch_name.as_deref(), repo_path, ); // Insert the reference into the terminal input terminal_view.update(ctx, |terminal_view, ctx| { terminal_view.input().update(ctx, |input, ctx| { input.append_to_buffer(&format!("{attachment_reference} "), ctx); input.ensure_agent_mode_for_ai_features(true, ctx); }); }); send_telemetry_from_ctx!( CodeReviewTelemetryEvent::AddToContext { origin: AddToContextOrigin::CodeReviewHeader, destination: CodeReviewContextDestination::AgentAttachment, diff_set_scope: Some(diff_set_scope), }, ctx ); // Register the DiffSet attachment in the terminal view's AI context model. let current = self.get_current_head(ctx); terminal_view.update(ctx, |terminal_view, ctx| { register_diffset_attachment( terminal_view.ai_context_model(), attachment_key, file_diffs, current, base, ctx, ); // Enter agent view if enabled and not already active if FeatureFlag::AgentView.is_enabled() && !terminal_view .agent_view_controller() .as_ref(ctx) .is_active() { terminal_view.enter_agent_view_for_new_conversation( None, AgentViewEntryOrigin::CodeReviewContext, ctx, ); } }); } } } #[cfg(not(feature = "local_fs"))] fn insert_diff_as_context(&mut self, _scope: DiffSetScope, _ctx: &mut ViewContext) { log::error!("insert_diff_as_context is not supported without the local_fs feature"); } fn get_current_head(&self, ctx: &ViewContext) -> Option { self.diff_state_model .as_ref(ctx) .get_current_branch_name() .map(CurrentHead::BranchName) } fn get_diff_base(&self, ctx: &ViewContext) -> anyhow::Result { match self.diff_state_model.as_ref(ctx).diff_mode() { DiffMode::Head => Ok(DiffBase::UncommittedChanges), DiffMode::MainBranch => { let main_branch_name = self.diff_state_model.as_ref(ctx).get_main_branch_name(); match main_branch_name { Some(name) => Ok(DiffBase::BranchName(name)), None => Err(anyhow::anyhow!("unable to determine main branch name")), } } DiffMode::OtherBranch(branch_name) => Ok(DiffBase::BranchName(branch_name)), } } /// Configures the code review view to display and scroll to a specific imported comment. /// Sets the diff base, expands the comment list, and queues a jump to the comment location. pub(crate) fn navigate_to_imported_comment( &mut self, comment_id: CommentId, diff_mode: DiffMode, ctx: &mut ViewContext, ) { self.set_diff_base(diff_mode, ctx); self.expand_comment_list_and_scroll_to_comment(comment_id, ctx); self.pending_jump_to_comment = Some(comment_id); } pub(crate) fn set_diff_base(&mut self, diff_mode: DiffMode, ctx: &mut ViewContext) { self.diff_state_model.update(ctx, |diff_state_model, ctx| { diff_state_model.set_diff_mode_and_fetch_base(diff_mode, ctx); }) } /// Insert diff hunk as an inline attachment in the terminal input fn insert_diff_hunk_as_context( &mut self, file_path: PathBuf, line_range: Range, ctx: &mut ViewContext, ) { let Some(repo_path) = self.repo_path() else { return; }; // Try to get the terminal view and insert the context if let Some(terminal_view) = self.terminal_view.as_ref().and_then(|tv| tv.upgrade(ctx)) { let is_long_running = terminal_view.read(ctx, |terminal_view, _| terminal_view.is_long_running()); let active_cli_agent = terminal_view.read(ctx, |tv, ctx| tv.active_cli_agent(ctx)); let relative_path = if file_path.is_absolute() { file_path .strip_prefix(repo_path) .unwrap_or(&file_path) .to_path_buf() } else { file_path.clone() }; // Case 1: CLI agent — send location + change stats to PTY or rich input if active_cli_agent.is_some() { if let Some((_, lines_added, lines_removed)) = self.extract_diff_hunk_data(&relative_path, &line_range) { // Use relative_path so the prompt shows repo-relative paths (e.g. src/foo.rs) // rather than absolute machine-specific paths. let start_line = line_range.start.as_usize() + 1; let end_line = line_range.end.as_usize(); let routing = terminal_view.update(ctx, |tv, ctx| { tv.send_diff_hunk_to_cli_agent_or_rich_input( &relative_path, start_line, end_line, lines_added, lines_removed, ctx, ) }); let destination = match routing { Some(CliAgentRouting::RichInput) => CodeReviewContextDestination::RichInput, _ => CodeReviewContextDestination::Pty, }; send_telemetry_from_ctx!( CodeReviewTelemetryEvent::AddToContext { origin: AddToContextOrigin::Gutter, destination, diff_set_scope: None, }, ctx ); } return; } // Case 2: Generic long-running (non-CLI-agent) — insert file path + line range as text if is_long_running { // When a command is running, just insert the file path and line range as text // (similar to file tree drag/drop behavior) let full_path = repo_path.join(&file_path); let start_line = line_range.start.as_usize() + 1; let end_line = line_range.end.as_usize(); let path_with_range = format!("{}:{start_line}-{end_line} ", full_path.display()); terminal_view.update(ctx, |terminal_view, ctx| { terminal_view.handle_file_tree_drop_on_active_command(&path_with_range, ctx); }); send_telemetry_from_ctx!( CodeReviewTelemetryEvent::AddToContext { origin: AddToContextOrigin::Gutter, destination: CodeReviewContextDestination::ActiveCommandBuffer, diff_set_scope: None, }, ctx ); return; } if let Some((hunk, lines_added, lines_removed)) = self.extract_diff_hunk_data(&relative_path, &line_range) { // Create a descriptive key using filename and line range let filename = relative_path.display().to_string(); // Use 1-indexed, inclusive line numbers for the user visible range. let diff_hunk_key = format!("{filename}:{}-{}", line_range.start + 1, line_range.end); let attachment_reference = format!("",); // Insert the reference into the terminal input and lock into agent mode terminal_view.update(ctx, |terminal_view, ctx| { terminal_view.input().update(ctx, |input, ctx| { input.append_to_buffer(&format!("{attachment_reference} "), ctx); input.ensure_agent_mode_for_ai_features(true, ctx); }); }); // Convert the diff hunk to a formatted diff string let diff_content = self.format_diff_hunk_content(&hunk); // Determine the diff base from the current diff state let diff_base = match self .diff_state_model .read(ctx, |model, _| model.diff_mode()) { DiffMode::Head => DiffBase::UncommittedChanges, DiffMode::MainBranch => { let main_branch_name = self .diff_state_model .read(ctx, |model, _| model.get_main_branch_name()); match main_branch_name { Some(name) => DiffBase::BranchName(name), None => { log::warn!( "Unable to determine main branch name when inserting diff hunk context." ); return; } } } DiffMode::OtherBranch(branch_name) => DiffBase::BranchName(branch_name), }; send_telemetry_from_ctx!( CodeReviewTelemetryEvent::AddToContext { origin: AddToContextOrigin::Gutter, destination: CodeReviewContextDestination::AgentAttachment, diff_set_scope: None, }, ctx ); // Create the DiffHunk attachment let attachment = AIAgentAttachment::DiffHunk { file_path: filename.clone(), line_range: line_range.clone(), diff_content, lines_added, lines_removed, current: None, // We don't have current branch info here base: diff_base, }; // Register the attachment with the terminal's AI controller using the new key format terminal_view.update(ctx, |terminal_view, ctx| { terminal_view .ai_context_model() .update(ctx, |context_model, _| { context_model.register_diff_hunk_attachment(diff_hunk_key, attachment); }); // Enter agent view if enabled and not already active if FeatureFlag::AgentView.is_enabled() && !terminal_view .agent_view_controller() .as_ref(ctx) .is_active() { terminal_view.enter_agent_view_for_new_conversation( None, AgentViewEntryOrigin::CodeReviewContext, ctx, ); } }); } } } /// Extract diff hunk data for the given file and line range fn extract_diff_hunk_data( &self, file_path: &PathBuf, line_range: &Range, ) -> Option<(DiffHunk, u32, u32)> { if let CodeReviewViewState::Loaded(state) = self.state() { // Find the file state that matches the given file path let file_state = state.file_states.get(file_path)?; let file_diff = &file_state.file_diff; // Convert editor line range to 1-indexed, exclusive line numbers let requested_start = line_range.start.as_usize() + 1; let requested_end = line_range.end.as_usize() + 1; // Find the diff hunk that contains this line range for hunk in file_diff.hunks.iter() { // Check if this hunk overlaps with the requested line range let hunk_start = hunk.new_start_line; let hunk_end = hunk_start + hunk.lines.len(); if requested_start <= hunk_end && requested_end >= hunk_start { // Filter the hunk lines to only include those within the requested range let mut filtered_lines = Vec::new(); let mut current_line = hunk.new_start_line; let mut lines_added = 0u32; let mut lines_removed = 0u32; for line in &hunk.lines { // For additions and context lines, check if they're in the requested range let include_line = match line.line_type { DiffLineType::Add | DiffLineType::Context => { current_line >= requested_start && current_line < requested_end } DiffLineType::Delete => { // Include deletions if they're relevant to the range. // CODE-1638: Deletion hunks are anchored to the line after the removed line, // so allow one extra line past requested_end. current_line >= requested_start && current_line <= requested_end } DiffLineType::HunkHeader => false, }; if include_line { filtered_lines.push(line.clone()); match line.line_type { DiffLineType::Add => lines_added += 1, DiffLineType::Delete => lines_removed += 1, _ => {} } } // Advance line counter for non-deletion lines if !matches!(line.line_type, DiffLineType::Delete) { current_line += 1; } } // Create a filtered hunk with only the relevant lines let filtered_hunk = DiffHunk { old_start_line: hunk.old_start_line, old_line_count: hunk.old_line_count, new_start_line: requested_start, new_line_count: filtered_lines.len(), lines: filtered_lines, unified_diff_start: hunk.unified_diff_start, unified_diff_end: hunk.unified_diff_end, }; return Some((filtered_hunk, lines_added, lines_removed)); } } } None } /// Format a diff hunk into a standard diff format string fn format_diff_hunk_content(&self, hunk: &DiffHunk) -> String { let mut diff_lines = Vec::new(); for line in &hunk.lines { match line.line_type { DiffLineType::Add => diff_lines.push(format!("+{}", line.text)), DiffLineType::Delete => diff_lines.push(format!("-{}", line.text)), DiffLineType::Context => diff_lines.push(line.text.clone()), DiffLineType::HunkHeader => continue, } } diff_lines.join("\n") } fn save_files(&mut self, paths: &[PathBuf], ctx: &mut ViewContext) { for path in paths { self.save_file(path, ctx); } } fn save_file(&mut self, path: &PathBuf, ctx: &mut ViewContext) { if let CodeReviewViewState::Loaded(state) = self.state() { if let Some(file_state) = state.file_states.get(path) { if let Some(editor) = file_state.editor_state.as_ref().map(|state| state.editor()) { if let Err(err) = editor.update(ctx, |local_editor, ctx| local_editor.save_local(ctx)) { safe_error!( safe: ("Failed to save file: {err}"), full: ("Failed to save file {}: {err:?}", path.display()) ); } } } } } /// Captures the current cursor position and selections from the editor fn capture_cursor_position(editor: &CodeEditorView, app: &AppContext) -> Vec { editor .model .as_ref(app) .selections(app) .mapped(|selection| SelectionOffsets { head: selection.head, tail: selection.tail, }) .into_vec() } /// Restores cursor position and selections to the editor fn restore_cursor_position( editor: &CodeEditorView, selections: Vec, ctx: &mut galaxyui::ViewContext, ) { if let Ok(selections_vec1) = Vec1::try_from_vec(selections) { editor.model.update(ctx, |model, ctx| { model.vim_set_selections( selections_vec1, AutoScrollBehavior::None, // Don't auto-scroll when restoring position ctx, ); }); } } /// Refreshes diffs, metadata, and PR info after a git operation (commit, push, etc.). fn refresh_after_git_operation(&mut self, ctx: &mut ViewContext) { self.load_diffs_for_active_repo(false, ctx); self.diff_state_model.update(ctx, |model, ctx| { model.refresh_diff_metadata_for_current_repo(InvalidationBehavior::PromptRefresh, ctx); model.refresh_pr_info(ctx); }); ctx.notify(); } /// Returns whether the working tree has uncommitted changes. /// /// This reads the `against_head` metadata directly rather than the loaded /// view state, because the loaded state reflects whatever diff mode the /// user currently has selected (e.g. `MainBranch`). In non-`Head` modes /// committed changes still appear in the stats, so using them here would /// make the button stay on "Commit" after a successful commit. fn has_uncommitted_changes(&self, app: &AppContext) -> bool { self.diff_state_model .as_ref(app) .get_uncommitted_stats() .is_some_and(|stats| !stats.has_no_changes()) } /// Opens a `GitDialog` overlay for the given `kind`. Centralizes the /// common guards (single-dialog invariant, git-ops blocked check, repo /// + branch lookup), the per-kind dialog construction, and the event /// subscription that clears `git_dialog` + refreshes repo state when the /// dialog completes. Each dialog mode handles its own success/failure /// toasts internally. fn open_git_dialog(&mut self, kind: GitDialogKind, ctx: &mut ViewContext) { if self.git_dialog.is_some() { return; } if self .diff_state_model .as_ref(ctx) .is_git_operation_blocked(ctx) { return; } let Some(repo_path) = self.repo_path().cloned() else { return; }; let branch_name = self .diff_state_model .read(ctx, |model, _| model.get_current_branch_name()) .unwrap_or_default(); let parent_branch_name = self .diff_state_model .read(ctx, |model, _| model.get_parent_branch_name()); let dialog = match kind { GitDialogKind::Commit => { // Hide the "Commit and create PR" intent when it wouldn't make // sense: a PR already exists for this branch, or we're on the // repo's main branch (creating a PR from main is invalid). // `has_upstream` controls the label/icon on the push-chained // intent (Commit and push vs Commit and publish). let diff_state = self.diff_state_model.as_ref(ctx); let allow_create_pr = diff_state.pr_info().is_none() && !diff_state.is_on_main_branch(); let has_upstream = diff_state.upstream_ref().is_some(); ctx.add_typed_action_view(|ctx| { GitDialog::new_for_commit( repo_path, branch_name, parent_branch_name, allow_create_pr, has_upstream, ctx, ) }) } GitDialogKind::Push { publish } => { let commits = self .diff_state_model .read(ctx, |model, _| model.unpushed_commits().to_vec()); ctx.add_typed_action_view(|ctx| { GitDialog::new_for_push(repo_path, branch_name, publish, commits, ctx) }) } GitDialogKind::CreatePr => ctx.add_typed_action_view(|ctx| { GitDialog::new_for_pr(repo_path, branch_name, parent_branch_name, ctx) }), }; ctx.subscribe_to_view(&dialog, move |me, _, event, ctx| { match event { GitDialogEvent::Completed => { me.git_dialog = None; me.refresh_after_git_operation(ctx); } GitDialogEvent::Cancelled => { me.git_dialog = None; } } ctx.notify(); }); self.git_dialog = Some(dialog.clone()); ctx.focus(&dialog); ctx.notify(); } /// Computes the current primary git action from diff stats and the diff state model. fn primary_git_action_mode(&self, app: &AppContext) -> PrimaryGitActionMode { let diff_state = self.diff_state_model.as_ref(app); let has_uncommitted_changes = self.has_uncommitted_changes(app); let has_upstream = diff_state.upstream_ref().is_some(); let has_local_commits = !diff_state.unpushed_commits().is_empty(); // False when upstream == main (e.g. after `git checkout -b feature origin/master`), // which means the branch hasn't been pushed to its own remote ref yet. let upstream_differs_from_main = diff_state.upstream_differs_from_main(); if has_uncommitted_changes { PrimaryGitActionMode::Commit } else if !has_upstream && has_local_commits { PrimaryGitActionMode::Publish } else if has_local_commits { PrimaryGitActionMode::Push } else if diff_state.pr_info().is_some() { PrimaryGitActionMode::ViewPr } else if has_upstream && !diff_state.is_on_main_branch() && upstream_differs_from_main { PrimaryGitActionMode::CreatePr } else { // Nothing actionable — show Commit disabled. PrimaryGitActionMode::Commit } } /// Updates the primary git operations button, chevron visibility, and /// related state to match the current [`PrimaryGitActionMode`]. fn update_git_operations_ui(&mut self, ctx: &mut ViewContext) { let mode = self.primary_git_action_mode(ctx); match mode { PrimaryGitActionMode::Commit => { let disabled = !self.has_uncommitted_changes(ctx); self.git_primary_action_button.update(ctx, |button, ctx| { button.set_label("Commit", ctx); button.set_icon(Some(Icon::GitCommit), ctx); button.set_disabled(disabled, ctx); button.set_tooltip(disabled.then_some("No changes to commit"), ctx); button.set_on_click( |ctx| ctx.dispatch_typed_action(CodeReviewAction::OpenCommitDialog), ctx, ); button.set_adjoined_side(AdjoinedSide::Right, ctx); }); self.git_operations_chevron.update(ctx, |button, ctx| { button.set_disabled(disabled, ctx); button.set_tooltip(disabled.then_some("No git actions available"), ctx); }); } PrimaryGitActionMode::Push => { self.git_primary_action_button.update(ctx, |button, ctx| { button.set_label("Push", ctx); button.set_icon(Some(Icon::ArrowUp), ctx); button.set_disabled(false, ctx); button.set_on_click( |ctx| ctx.dispatch_typed_action(CodeReviewAction::OpenPushDialog), ctx, ); button.set_adjoined_side(AdjoinedSide::Right, ctx); }); self.git_operations_chevron.update(ctx, |button, ctx| { button.set_disabled(false, ctx); }); } PrimaryGitActionMode::CreatePr => { self.git_primary_action_button.update(ctx, |button, ctx| { button.set_label("Create PR", ctx); button.set_icon(Some(Icon::Github), ctx); button.set_disabled(false, ctx); button.set_on_click( |ctx| ctx.dispatch_typed_action(CodeReviewAction::OpenCreatePrDialog), ctx, ); button.clear_adjoined_side(ctx); }); } PrimaryGitActionMode::ViewPr => { let pr_info = self.diff_state_model.as_ref(ctx).pr_info().cloned(); if let Some(pr_info) = pr_info { let url = pr_info.url.clone(); let number = pr_info.number; let label = format!("PR #{number}"); self.git_primary_action_button.update(ctx, |button, ctx| { button.set_label(label, ctx); button.set_icon(Some(Icon::Github), ctx); button.set_disabled(false, ctx); button.set_on_click( move |ctx| { ctx.dispatch_typed_action(CodeReviewAction::ViewPr(url.clone())) }, ctx, ); button.clear_adjoined_side(ctx); }); } } PrimaryGitActionMode::Publish => { self.git_primary_action_button.update(ctx, |button, ctx| { button.set_label("Publish", ctx); button.set_icon(Some(Icon::UploadCloud), ctx); button.set_disabled(false, ctx); button.set_on_click( |ctx| ctx.dispatch_typed_action(CodeReviewAction::PublishBranch), ctx, ); button.clear_adjoined_side(ctx); }); } } ctx.notify(); } /// Returns the "Commit" dropdown item. Label/icon/action are fixed; /// only the disabled state flips across modes (enabled in Commit mode, /// disabled in Push mode where there's nothing to commit). fn commit_menu_item(disabled: bool) -> MenuItem { MenuItemFields::new("Commit") .with_icon(Icon::GitCommit) .with_on_select_action(CodeReviewAction::OpenCommitDialog) .with_disabled(disabled) .into_item() } /// Returns the "send commits to remote" dropdown item: `Push` when the /// branch already has an upstream, `Publish` otherwise (first push also /// sets the upstream). fn push_or_publish_menu_item(has_upstream: bool, disabled: bool) -> MenuItem { if has_upstream { MenuItemFields::new("Push") .with_icon(Icon::ArrowUp) .with_on_select_action(CodeReviewAction::OpenPushDialog) .with_disabled(disabled) .into_item() } else { MenuItemFields::new("Publish") .with_icon(Icon::UploadCloud) .with_on_select_action(CodeReviewAction::PublishBranch) .with_disabled(disabled) .into_item() } } /// Returns the PR dropdown item: "PR #N" linking to the existing PR, or /// "Create PR" to open the dialog. Create PR is disabled on main, when the /// branch has no upstream, or when the upstream is the same ref as main /// (e.g. a worktree branch whose tracking was auto-set to origin/master). fn pr_menu_item(&self, app: &AppContext) -> MenuItem { let diff_state = self.diff_state_model.as_ref(app); if let Some(pr_info) = diff_state.pr_info().cloned() { MenuItemFields::new(format!("PR #{}", pr_info.number)) .with_icon(Icon::Github) .with_on_select_action(CodeReviewAction::ViewPr(pr_info.url)) .into_item() } else { let is_on_main = diff_state.is_on_main_branch(); let has_upstream = diff_state.upstream_ref().is_some(); let upstream_differs_from_main = diff_state.upstream_differs_from_main(); MenuItemFields::new("Create PR") .with_icon(Icon::Github) .with_on_select_action(CodeReviewAction::OpenCreatePrDialog) .with_disabled(is_on_main || !has_upstream || !upstream_differs_from_main) .into_item() } } /// Items for the git operations dropdown (chevron button). All three /// operations (Commit / Push / Create PR) are always listed so the /// dropdown shape is stable across modes; the primary mode determines /// which are enabled. fn git_operations_menu_items(&self, app: &AppContext) -> Vec> { let diff_state = self.diff_state_model.as_ref(app); let has_local_commits = !diff_state.unpushed_commits().is_empty(); let has_upstream = diff_state.upstream_ref().is_some(); match self.primary_git_action_mode(app) { PrimaryGitActionMode::Commit => vec![ Self::commit_menu_item(false), // Middle item sends existing commits to the remote. Uncommitted // changes in the working tree don't block this — only whether // there are local commits to send. Self::push_or_publish_menu_item(has_upstream, !has_local_commits), // PR item handles its own disabled state (main branch, no // upstream). Uncommitted changes don't block it: the PR is // based on whatever's already been pushed. self.pr_menu_item(app), ], PrimaryGitActionMode::Push => vec![ Self::commit_menu_item(true), Self::push_or_publish_menu_item(has_upstream, false), self.pr_menu_item(app), ], PrimaryGitActionMode::CreatePr | PrimaryGitActionMode::ViewPr | PrimaryGitActionMode::Publish => { // Chevron is hidden in these modes, so the menu is never opened. vec![] } } } /// Items for the header overflow menu (three-dots button). fn header_menu_items(&self, ctx: &mut ViewContext) -> Vec> { if FeatureFlag::GitOperationsInCodeReview.is_enabled() { self.header_menu_items_new(ctx) } else { self.header_menu_items_legacy(ctx) } } /// Legacy menu items — gated on FileAndDiffSetComments only. fn header_menu_items_legacy( &self, ctx: &mut ViewContext, ) -> Vec> { let mut items = Vec::new(); if !FeatureFlag::FileAndDiffSetComments.is_enabled() { return items; } let mut has_changes = false; if let CodeReviewViewState::Loaded(loaded) = self.state() { has_changes = !loaded.to_diff_stats().has_no_changes(); } if FeatureFlag::DiffSetAsContext.is_enabled() && has_changes { items.push( MenuItemFields::new("Add diff set as context") .with_icon(Icon::Paperclip) .with_on_select_action(CodeReviewAction::AddDiffSetAsContext(DiffSetScope::All)) .into_item(), ); } let (comment_label, comment_icon) = if self.get_existing_diffset_comment(ctx).is_some() { ("Show saved comment", Icon::MessageText) } else { ("Add comment", Icon::MessagePlusSquare) }; items.push( MenuItemFields::new(comment_label) .with_icon(comment_icon) .with_on_select_action(CodeReviewAction::OpenCommentComposerFromHeader) .into_item(), ); items } /// New menu items — individually gated, includes discard and AI check. fn header_menu_items_new( &self, ctx: &mut ViewContext, ) -> Vec> { let mut items = Vec::new(); let has_changes = matches!(self.state(), CodeReviewViewState::Loaded(loaded) if !loaded.to_diff_stats().has_no_changes()); let is_ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx); if is_ai_enabled && FeatureFlag::DiffSetAsContext.is_enabled() && has_changes { items.push( MenuItemFields::new("Add diff set as context") .with_icon(Icon::Paperclip) .with_on_select_action(CodeReviewAction::AddDiffSetAsContext(DiffSetScope::All)) .into_item(), ); } if FeatureFlag::FileAndDiffSetComments.is_enabled() && has_changes { let (comment_label, comment_icon) = if self.get_existing_diffset_comment(ctx).is_some() { ("Show saved comment", Icon::MessageText) } else { ("Add comment", Icon::MessagePlusSquare) }; items.push( MenuItemFields::new(comment_label) .with_icon(comment_icon) .with_on_select_action(CodeReviewAction::OpenCommentComposerFromHeader) .into_item(), ); } if FeatureFlag::DiscardPerFileAndAllChanges.is_enabled() && has_changes { items.push( MenuItemFields::new("Discard all") .with_icon(Icon::ReverseLeft) .with_on_select_action(CodeReviewAction::ShowDiscardConfirmDialog(None)) .into_item(), ); } items } fn get_unsaved_file_paths(&self, app: &AppContext) -> Vec { let mut unsaved_paths = Vec::new(); if let CodeReviewViewState::Loaded(state) = self.state() { for file_state in state.file_states.values() { if let Some(model) = &file_state.editor_state { if model.has_unsaved_changes(app) { unsaved_paths.push(file_state.file_diff.file_path.clone()); } } } } unsaved_paths } pub fn set_pane_id(&mut self, pane_id: PaneId) { self.containing_pane_id = Some(pane_id); } pub fn file_sidebar_expanded(&self) -> bool { self.file_sidebar_expanded } pub fn has_file_states(&self) -> bool { if let CodeReviewViewState::Loaded(loaded_state) = self.state() { !loaded_state.file_states.is_empty() } else { false } } /// Returns the diff stats from the loaded state — the same source used by /// the inner `CodeReviewHeader`. Returns `None` if diffs haven't loaded yet. pub fn loaded_diff_stats(&self) -> Option { if let CodeReviewViewState::Loaded(loaded_state) = self.state() { Some(loaded_state.to_diff_stats()) } else { None } } pub fn open_file_in_tab( &self, path: &Path, line_and_column: Option, ctx: &mut ViewContext, ) { let Some(repo_path) = self.repo_path() else { return; }; let full_path = repo_path.join(path); ctx.emit(CodeReviewViewEvent::OpenFileInNewTab { path: full_path, line_and_column, }); } #[cfg(not(feature = "local_fs"))] fn open_code_review_file( &self, _full_path: PathBuf, _line_and_column: Option, _ctx: &mut ViewContext, ) { } #[cfg(feature = "local_fs")] fn open_code_review_file( &self, full_path: PathBuf, line_and_column: Option, ctx: &mut ViewContext, ) { let settings = EditorSettings::as_ref(ctx); let target = resolve_file_target_with_editor_choice( &full_path, *settings.open_code_panels_file_editor, *settings.prefer_markdown_viewer, *settings.open_file_layout, None, ); send_telemetry_from_ctx!( TelemetryEvent::CodePanelsFileOpened { entrypoint: CodePanelsFileOpenEntrypoint::CodeReview, target: target.clone(), }, ctx ); ctx.emit(CodeReviewViewEvent::OpenFileWithTarget { path: full_path, target, line_col: line_and_column, }); } pub(super) fn editor_for_path( &self, path: &Path, ctx: &AppContext, ) -> Option> { match self.state() { CodeReviewViewState::Loaded(loaded) => loaded .file_states .values() .filter_map(|file| file.editor_state.as_ref()) .find_map(|state| { let editor_path = state.editor.as_ref(ctx).file_path(); if editor_path == Some(path) { Some(state.editor.clone()) } else { None } }), _ => None, } } pub(super) fn editor_handles( &self, ) -> Box> + '_> { match self.state() { CodeReviewViewState::Loaded(loaded) => Box::new( loaded .file_states .values() .filter(|file| file.is_expanded) .filter_map(|file| file.editor_state.as_ref()) .map(|state| state.editor.clone()), ), _ => Box::new(std::iter::empty()), } } } /// Returns the line number of the first line in the file affected by the diff. fn file_line_for_open(file_diff: &FileDiff) -> Option { file_diff.hunks.first().and_then(|hunk| { let mut last_context_line_number = None; for line in &hunk.lines { match line.line_type { // If the hunk has additions, open the file at the start of the addition. DiffLineType::Add => return line.new_line_number, // If the hunk is only a deletion, open the file where the deleted lines would have been, // which we know from the previous context lines. DiffLineType::Delete => { return last_context_line_number .map(|context_line: usize| context_line.saturating_add(1)); } DiffLineType::Context => { last_context_line_number = line.new_line_number; } DiffLineType::HunkHeader => {} } } None }) } impl Entity for CodeReviewView { type Event = CodeReviewViewEvent; } impl View for CodeReviewView { fn render(&self, ctx: &AppContext) -> Box { let appearance = Appearance::as_ref(ctx); if self.active_repo.is_none() { return self.render_no_repo_for_env(ctx, appearance); } let is_in_split_pane = self .focus_handle .as_ref() .map(|h| h.split_pane_state(ctx).is_in_split_pane()) .unwrap_or(false); let main_content = match self.state() { CodeReviewViewState::None => CodeReviewView::render_loading_state(appearance), CodeReviewViewState::Loaded(loaded_state) => { // For global buffer mode, show loading state until all editors have loaded // their buffer content. This prevents a brief flash of empty editors. if !self.all_editors_loaded() { CodeReviewView::render_loading_state(appearance) } else { self.render_loaded_state(loaded_state, appearance, is_in_split_pane, ctx) } } CodeReviewViewState::Error(err) => self.render_error_state(err, appearance), CodeReviewViewState::NoRepoFound => self.render_no_repo_for_env(ctx, appearance), }; let content_with_handler = EventHandler::new(Container::new(main_content).finish()) .on_left_mouse_down(|ctx, _, _| { ctx.dispatch_typed_action(CodeReviewAction::FocusView); DispatchEventResult::StopPropagation }) .finish(); let mut stack = Stack::new() .with_child(content_with_handler) .with_constrain_absolute_children(); // Pane-level comment composer overlay, anchored to the header position. if let Some(composer) = &self.comment_composer { let editor = ChildView::new(composer).finish(); let styled_composer = Container::new( ConstrainedBox::new(editor) .with_max_width(DEFAULT_COMMENT_MAX_WIDTH) .with_max_height(260.) .finish(), ) .with_margin_left(32.) .with_margin_right(32.) .finish(); stack.add_positioned_child( styled_composer, OffsetPositioning::offset_from_save_position_element( self.header_position_id.as_str(), vec2f(0., -40.), PositionedElementOffsetBounds::ParentByPosition, PositionedElementAnchor::BottomRight, ChildAnchor::TopRight, ), ); } // Header dropdown menu is rendered inline with the header button in CodeReviewHeader if self.find_model.as_ref(ctx).is_find_bar_open() { stack.add_child(ChildView::new(&self.find_bar).finish()); } let show_modal = self.discard_dialog_state.show_discard_confirm_dialog || self.git_dialog.is_some(); if show_modal { let mut dialog_stack = Stack::new() .with_child(SavePosition::new(stack.finish(), &self.view_position_id).finish()); if self.discard_dialog_state.show_discard_confirm_dialog { dialog_stack.add_positioned_overlay_child( self.render_discard_confirm_dialog(ctx), OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::Center, ChildAnchor::Center, ), ); } else if let Some(git_dialog) = &self.git_dialog { dialog_stack.add_positioned_overlay_child( ChildView::new(git_dialog).finish(), OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::Center, ChildAnchor::Center, ), ); } dialog_stack.finish() } else { SavePosition::new(stack.finish(), &self.view_position_id).finish() } } fn ui_name() -> &'static str { "CodeReviewView" } } impl TypedActionView for CodeReviewView { type Action = CodeReviewAction; fn handle_action(&mut self, action: &CodeReviewAction, ctx: &mut ViewContext) { match action { CodeReviewAction::OpenInNewTab { path, line_and_column, } => { let Some(repo_path) = self.repo_path() else { return; }; let full_path = repo_path.join(path); self.open_code_review_file(full_path, *line_and_column, ctx); } CodeReviewAction::ToggleFileExpanded(path) => { let (file_index, now_expanded, chevron_button) = { let Some(repo) = self.active_repo.as_mut() else { return; }; if let CodeReviewViewState::Loaded(state) = &mut repo.state { if let Some(index) = state.file_states.get_index_of(path) { let file = &mut state.file_states[index]; file.is_expanded = !file.is_expanded; let now_expanded = file.is_expanded; repo.file_expanded .insert(file.file_diff.file_path.clone(), now_expanded); (index, now_expanded, file.chevron_button.clone()) } else { return; } } else { return; } }; // Update the chevron button icon based on expanded state chevron_button.update(ctx, |button, ctx| { let icon = if now_expanded { Icon::ChevronDown } else { Icon::ChevronRight }; button.set_icon(Some(icon), ctx); }); self.viewported_list_state .invalidate_height_for_index(file_index); // If the file gets collapsed and had a sticky header, then we scroll to make the header in view. if !now_expanded && self.viewported_list_state.is_scrolled_to_item(file_index) { self.viewported_list_state.scroll_to(file_index); } if self.find_model.as_ref(ctx).is_find_bar_open() && FeatureFlag::CodeReviewFind.is_enabled() { self.find_model.update(ctx, |model, model_ctx| { model.run_search(self.editor_handles(), model_ctx); }); } ctx.notify(); } CodeReviewAction::SetDiffMode(mode) => { self.apply_diff_mode(mode.clone(), ctx); } CodeReviewAction::ToggleFileSidebar => { if self.file_sidebar_expanded { self.file_sidebar_expanded = false; // If the sidebar is closed while maximized, update the saved // pre-maximize state so we don't reopen it on minimize. if self.file_sidebar_expanded_before_maximize.is_some() { self.file_sidebar_expanded_before_maximize = Some(false); } } else { self.open_file_sidebar(ctx); } self.update_file_nav_button_tooltip(ctx); ctx.notify(); } CodeReviewAction::FileSelected(file_index) => { // Early-return when repo/state/file is missing to avoid calling // invalidate_height_for_index or scroll_to with an invalid index. let was_expanded = { let Some(repo) = self.active_repo.as_mut() else { return; }; let CodeReviewViewState::Loaded(state) = &mut repo.state else { return; }; let Some((_, file)) = state.file_states.get_index_mut(*file_index) else { return; }; let was_expanded = file.is_expanded; file.is_expanded = true; was_expanded }; self.viewported_list_state .invalidate_height_for_index(*file_index); if !was_expanded && self.find_model.as_ref(ctx).is_find_bar_open() && FeatureFlag::CodeReviewFind.is_enabled() { self.find_model.update(ctx, |model, model_ctx| { model.run_search(self.editor_handles(), model_ctx); }); } ctx.notify(); self.viewported_list_state.scroll_to(*file_index); ctx.notify(); } CodeReviewAction::ToggleMaximize => { // Determine if we're minimizing or maximizing let is_currently_maximized = self .focus_handle .as_ref() .is_some_and(|h| h.is_maximized(ctx)); let state_change = if is_currently_maximized { PaneStateChange::Minimized } else { PaneStateChange::Maximized }; send_telemetry_from_ctx!( CodeReviewTelemetryEvent::PaneStateChanged { state_change }, ctx ); ctx.emit(CodeReviewViewEvent::Pane(PaneEvent::ToggleMaximized)); } CodeReviewAction::SaveAllFiles { paths } => { self.save_files(paths, ctx); } CodeReviewAction::SaveAllUnsavedFiles => { let unsaved_files = self.get_unsaved_file_paths(ctx); self.save_files(unsaved_files.as_slice(), ctx); } CodeReviewAction::RefreshGitState => { self.load_diffs_for_active_repo(false, ctx); } CodeReviewAction::UndoRevert => { self.maybe_undo_revert(ctx); } CodeReviewAction::Close => { ctx.emit(CodeReviewViewEvent::Pane(PaneEvent::Close)); } CodeReviewAction::OpenHeaderMenu => { // Header dropdown: build items and toggle the menu open/closed. let items = self.header_menu_items(ctx); if items.is_empty() { return; } if self.header_menu_open { // Close the menu by clearing items. self.header_menu.update(ctx, |menu, ctx| { menu.set_items(Vec::new(), ctx); ctx.notify(); }); self.header_menu_open = false; } else { self.header_menu.update(ctx, move |menu, ctx| { menu.set_items(items, ctx); ctx.notify(); }); self.header_menu_open = true; } self.update_header_dropdown_active_state(ctx); ctx.notify(); } CodeReviewAction::OpenCommentComposerFromHeader => { // Show the review comment composer overlay if it's not already open. let existing_comment = self.get_existing_diffset_comment(ctx); self.open_review_comment_composer(existing_comment, ctx); } CodeReviewAction::EmitPaneEvent(event) => { ctx.emit(CodeReviewViewEvent::Pane(event.clone())); } CodeReviewAction::ShowDiscardConfirmDialog(file_path) => { self.discard_dialog_state.show_discard_confirm_dialog = true; let current_diff_mode = self.diff_state_model.as_ref(ctx).diff_mode(); if let Some(path) = file_path { // Single file remove self.discard_dialog_state.discard_file_paths = vec![path.clone()]; self.discard_dialog_state.operation_type = match current_diff_mode { DiffMode::Head => DiscardOperationType::FileUncommittedChanges, DiffMode::MainBranch => { DiscardOperationType::FileChangesAgainstBranch(None) } DiffMode::OtherBranch(branch) => { DiscardOperationType::FileChangesAgainstBranch(Some(branch)) } }; } else { // All files remove self.discard_dialog_state.operation_type = match current_diff_mode { DiffMode::Head => DiscardOperationType::AllUncommittedChanges, DiffMode::MainBranch => DiscardOperationType::AllChangesAgainstBranch(None), DiffMode::OtherBranch(branch) => { DiscardOperationType::AllChangesAgainstBranch(Some(branch)) } }; // Collect all file paths from loaded state if let CodeReviewViewState::Loaded(loaded) = self.state() { self.discard_dialog_state.discard_file_paths = loaded.file_states.keys().cloned().collect(); // Initialize all files as selected by default self.discard_dialog_state.selected_files.clear(); self.discard_dialog_state.file_checkbox_mouse_states.clear(); for file_path in &self.discard_dialog_state.discard_file_paths { self.discard_dialog_state .selected_files .insert(file_path.clone(), true); self.discard_dialog_state .file_checkbox_mouse_states .insert(file_path.clone(), MouseStateHandle::default()); } } } ctx.notify(); } CodeReviewAction::ConfirmDiscardFile => { let is_discard_all = matches!( self.discard_dialog_state.operation_type, DiscardOperationType::AllUncommittedChanges | DiscardOperationType::AllChangesAgainstBranch(_) ); if is_discard_all { // Get list of selected files let selected_files: Vec = self .discard_dialog_state .discard_file_paths .iter() .filter(|path| { *self .discard_dialog_state .selected_files .get(*path) .unwrap_or(&false) }) .cloned() .collect(); if !selected_files.is_empty() { self.discard_multiple_files( selected_files, self.discard_dialog_state.stash_changes_enabled, ctx, ); } } else { let file_path = self.discard_dialog_state.discard_file_paths[0].clone(); self.discard_file( &file_path, self.discard_dialog_state.stash_changes_enabled, ctx, ); } self.discard_dialog_state.show_discard_confirm_dialog = false; self.discard_dialog_state.discard_file_paths.clear(); self.discard_dialog_state.selected_files.clear(); self.discard_dialog_state.file_checkbox_mouse_states.clear(); self.discard_dialog_state.stash_changes_enabled = false; ctx.notify(); } CodeReviewAction::CancelDiscardFile => { self.discard_dialog_state.show_discard_confirm_dialog = false; self.discard_dialog_state.discard_file_paths.clear(); ctx.notify(); } CodeReviewAction::ToggleStashChanges => { self.discard_dialog_state.stash_changes_enabled = !self.discard_dialog_state.stash_changes_enabled; ctx.notify(); } CodeReviewAction::ToggleFileSelection(ref file_path) => { if let Some(selected) = self.discard_dialog_state.selected_files.get_mut(file_path) { *selected = !*selected; ctx.notify(); } } CodeReviewAction::AddDiffSetAsContext(scope) => { self.insert_diff_as_context(scope.clone(), ctx); } CodeReviewAction::CopyFilePath(path) => { if let Some(repo_path) = self.repo_path() { let absolute_path = repo_path.join(path); if let Some(path_str) = absolute_path.to_str() { ctx.clipboard() .write(ClipboardContent::plain_text(path_str.to_string())); } } } CodeReviewAction::ShowFindBar => self.show_find_bar(ctx), CodeReviewAction::FocusView => { ctx.focus_self(); } CodeReviewAction::OpenRepository => { if let Some(terminal_view) = self.terminal_view(ctx) { terminal_view.update(ctx, |terminal, ctx| { terminal.handle_action(&TerminalAction::PickRepoToOpen, ctx); }); } } CodeReviewAction::InitProjectForCurrentDirectory => { if let Some(terminal_view) = self.terminal_view(ctx) { terminal_view.update(ctx, |terminal, ctx| { terminal.handle_action(&TerminalAction::InitProject, ctx); }); } } CodeReviewAction::OpenCommitDialog => { self.open_git_dialog(GitDialogKind::Commit, ctx); } CodeReviewAction::PublishBranch => { self.open_git_dialog(GitDialogKind::Push { publish: true }, ctx); } CodeReviewAction::OpenPushDialog => { self.open_git_dialog(GitDialogKind::Push { publish: false }, ctx); } CodeReviewAction::OpenCreatePrDialog => { self.open_git_dialog(GitDialogKind::CreatePr, ctx); } CodeReviewAction::ViewPr(url) => { ctx.open_url(url); } CodeReviewAction::ToggleGitOperationsMenu => { let items = self.git_operations_menu_items(ctx); if items.is_empty() { return; } if self.git_operations_menu_open { self.git_operations_menu_open = false; } else { self.git_operations_menu.update(ctx, move |menu, ctx| { menu.set_items(items, ctx); ctx.notify(); }); self.git_operations_menu_open = true; } self.git_operations_chevron.update(ctx, |button, ctx| { button.set_active(self.git_operations_menu_open, ctx); }); ctx.notify(); } } } } impl BackingView for CodeReviewView { type PaneHeaderOverflowMenuAction = CodeReviewAction; type CustomAction = (); type AssociatedData = (); fn handle_pane_header_overflow_menu_action( &mut self, action: &Self::PaneHeaderOverflowMenuAction, ctx: &mut ViewContext, ) { self.handle_action(action, ctx); } fn close(&mut self, ctx: &mut ViewContext) { let unsaved_file_paths = self.get_unsaved_file_paths(ctx); if !unsaved_file_paths.is_empty() && ChannelState::channel() != Channel::Integration { let file_names = unsaved_file_paths .iter() .filter_map(|path| { path.file_name() .and_then(|name| name.to_str()) .map(|s| s.to_string()) }) .collect::>() .join(", "); let summary = UnsavedStateSummary::for_editor_tab( Some(file_names), vec![CodeEditorStatus::new(true)], ctx, ); let handle_save_intent = |intent: PendingSaveIntent| { let handle = ctx.handle().clone(); let paths_to_save = unsaved_file_paths.clone(); move |ctx: &mut AppContext| { if let Some(view) = handle.upgrade(ctx) { view.update(ctx, |view, ctx| match intent { PendingSaveIntent::Save => { view.handle_action( &CodeReviewAction::SaveAllFiles { paths: paths_to_save, }, ctx, ); ctx.emit(CodeReviewViewEvent::Pane(PaneEvent::Close)); } PendingSaveIntent::Discard => { ctx.emit(CodeReviewViewEvent::Pane(PaneEvent::Close)); } PendingSaveIntent::Cancel => {} }); } } }; let dialog = summary .dialog() .on_save_changes(handle_save_intent(PendingSaveIntent::Save)) .on_discard_changes(handle_save_intent(PendingSaveIntent::Discard)) .on_cancel(handle_save_intent(PendingSaveIntent::Cancel)) .build(); if cfg!(all(not(target_family = "wasm"), target_os = "macos")) { AppContext::show_native_platform_modal(ctx, dialog); } else if cfg!(all( not(target_family = "wasm"), any(target_os = "linux", target_os = "windows") )) { // Find the workspace to show the Warp-native modal if let Some(workspace) = ctx .views_of_type::(ctx.window_id()) .and_then(|workspaces| workspaces.first().cloned()) { workspace.update(ctx, |view, ctx| { view.show_native_modal(dialog, ctx); }); } } } else { ctx.emit(CodeReviewViewEvent::Pane(PaneEvent::Close)); } } fn focus_contents(&mut self, ctx: &mut ViewContext) { // TODO: Focus the contents of the view. ctx.focus_self(); } fn handle_custom_action(&mut self, _action: &Self::CustomAction, _ctx: &mut ViewContext) { } fn render_header_content( &self, _ctx: &view::HeaderRenderContext<'_>, _app: &AppContext, ) -> view::HeaderContent { view::HeaderContent::simple("Reviewing code changes") } fn set_focus_handle(&mut self, focus_handle: PaneFocusHandle, ctx: &mut ViewContext) { ctx.subscribe_to_model( focus_handle.focus_state_handle(), |code_review, _handle, event, ctx| { code_review.handle_focus_state_event(event, ctx); }, ); self.focus_handle = Some(focus_handle); } } #[derive(Debug)] struct ShowCommentEditor { comment_list_save_position_id: String, window_id: WindowId, } impl ShowCommentEditorProvider for ShowCommentEditor { fn should_show_comment_editor(&self, editor_line_location: RectF, app: &AppContext) -> bool { let Some(comment_list_view_position) = app.element_position_by_id_at_last_frame( self.window_id, &self.comment_list_save_position_id, ) else { return false; }; comment_list_view_position.contains_point(editor_line_location.upper_right()) || comment_list_view_position.contains_point(editor_line_location.lower_left()) } } #[path = "scroll_preservation.rs"] mod scroll_preservation; use scroll_preservation::RelocatableScrollContext; #[cfg(feature = "integration_tests")] #[path = "code_review_view_integration.rs"] mod code_review_view_integration; #[cfg(feature = "integration_tests")] pub use code_review_view_integration::CodeReviewVisibleAnchorForTest; #[cfg(test)] #[path = "code_review_view_tests.rs"] mod tests;