first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -5,12 +5,7 @@
//!
//! Separated into its own module so the two codepaths are easy to distinguish.
use crate::code_review::code_review_view::{
CodeReviewAction, CodeReviewHeaderFields, PrimaryGitActionMode,
};
use crate::code_review::diff_selector::DiffSelector;
use crate::menu::Menu;
use crate::view_components::action_button::ActionButton;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::features::FeatureFlag;
use galaxyui::elements::{
ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Flex,
@@ -18,11 +13,15 @@ use galaxyui::elements::{
ParentOffsetBounds, Shrinkable, Stack,
};
use galaxyui::{Element, ViewHandle};
use pathfinder_geometry::vector::vec2f;
use crate::appearance::Appearance;
use super::CodeReviewHeader;
use crate::appearance::Appearance;
use crate::code_review::code_review_view::{
CodeReviewAction, CodeReviewHeaderFields, PrimaryGitActionMode,
};
use crate::code_review::diff_selector::DiffSelector;
use crate::menu::Menu;
use crate::view_components::action_button::ActionButton;
impl CodeReviewHeader {
/// Entry-point for the new header layout (feature-flagged behind
@@ -114,9 +113,7 @@ impl CodeReviewHeader {
);
}
let button_row = Container::new(row.finish()).with_margin_right(4.).finish();
let mut stack = Stack::new().with_child(button_row);
let mut stack = Stack::new().with_child(row.finish());
if code_review_header_fields.git_operations_menu_open {
stack.add_positioned_overlay_child(
ChildView::new(&code_review_header_fields.git_operations_menu).finish(),
@@ -129,7 +126,11 @@ impl CodeReviewHeader {
);
}
Some(stack.finish())
Some(
Container::new(stack.finish())
.with_margin_right(4.)
.finish(),
)
}
/// Like `render_header_dropdown_button` but without `margin_left(4.)`,
+24 -31
View File
@@ -1,36 +1,28 @@
mod header_revamp;
use crate::code_review::code_review_view::{
CodeReviewHeaderFields, CodeReviewView, CONTENT_TOP_MARGIN,
};
use crate::{
appearance::Appearance,
code_review::{
code_review_view::{get_discard_button_disabled_tooltip, CodeReviewAction, LoadedState},
diff_state::DiffStateModel,
},
menu::Menu,
ui_components::icons::Icon,
view_components::action_button::ActionButton,
};
use galaxy_core::features::FeatureFlag;
use galaxyui::elements::{Hoverable, ParentElement};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::{Coords, UiComponent};
use galaxyui::{
elements::{
Align, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CrossAxisAlignment,
Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentOffsetBounds, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Stack,
},
fonts::{Properties, Weight},
ui_components::{
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
components::UiComponentStyles,
},
AppContext, Element, ModelHandle, ViewHandle,
};
use pathfinder_geometry::vector::vec2f;
use galaxy_core::features::FeatureFlag;
use galaxyui::elements::{
Align, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Flex,
Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch,
Stack,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{AppContext, Element, ModelHandle, ViewHandle};
use crate::appearance::Appearance;
use crate::code_review::code_review_view::{
get_discard_button_disabled_tooltip, CodeReviewAction, CodeReviewHeaderFields, CodeReviewView,
LoadedState, CONTENT_TOP_MARGIN,
};
use crate::code_review::diff_state::DiffStateModel;
use crate::menu::Menu;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::ActionButton;
// This is a best effort guess of the size of all of the elements in the header to know when we should start to wrap to the second row
const HEADER_WRAP_BREAKPOINT: f32 = 450.;
@@ -497,7 +489,8 @@ impl CodeReviewHeader {
}
fn get_header_text(diff_state_model: &ModelHandle<DiffStateModel>, app: &AppContext) -> String {
let branch_name = diff_state_model.read(app, |model, _| model.get_current_branch_name());
let branch_name =
diff_state_model.read(app, |model, ctx| model.get_current_branch_name(ctx));
branch_name.unwrap_or("Reviewing open changes".to_string())
}
}
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,19 @@
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use galaxy_editor::model::CoreEditorModel;
use galaxy_editor::render::model::{
BlockItem, HitTestOptions, LineCount, Location, RenderLineLocation,
};
use galaxyui::{units::Pixels, AppContext, ViewContext};
use galaxyui::units::Pixels;
use galaxyui::{AppContext, ViewContext};
use super::{CodeReviewView, CodeReviewViewState, FILE_HEADER_HEIGHT};
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code::editor::line::EditorLineLocation;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CodeReviewVisibleAnchorForTest {
pub file_path: PathBuf,
pub file_path: String,
pub line_number: usize,
pub line_text: String,
}
@@ -69,7 +71,7 @@ impl CodeReviewView {
pub fn scroll_to_line_for_test(
&mut self,
path: &Path,
path: &str,
line_number: usize,
ctx: &mut ViewContext<Self>,
) -> bool {
@@ -130,7 +132,7 @@ impl CodeReviewView {
/// Scrolls the code review to the header region of the given file.
/// The header region is the area above the editor content (< FILE_HEADER_HEIGHT).
pub fn scroll_to_header_for_test(&mut self, path: &Path, ctx: &mut ViewContext<Self>) -> bool {
pub fn scroll_to_header_for_test(&mut self, path: &str, ctx: &mut ViewContext<Self>) -> bool {
let CodeReviewViewState::Loaded(state) = self.state() else {
return false;
};
@@ -166,7 +168,7 @@ impl CodeReviewView {
}
/// Scrolls the code review past the end of editor content into the footer region.
pub fn scroll_to_footer_for_test(&mut self, path: &Path, ctx: &mut ViewContext<Self>) -> bool {
pub fn scroll_to_footer_for_test(&mut self, path: &str, ctx: &mut ViewContext<Self>) -> bool {
let CodeReviewViewState::Loaded(state) = self.state() else {
return false;
};
@@ -217,7 +219,7 @@ impl CodeReviewView {
/// Scans forward from the y-offset of `near_line` to find the first TemporaryBlock.
pub fn scroll_to_deleted_range_for_test(
&mut self,
path: &Path,
path: &str,
near_line: usize,
ctx: &mut ViewContext<Self>,
) -> bool {
@@ -355,11 +357,14 @@ impl CodeReviewView {
pub fn line_text_for_test(
&self,
path: &Path,
path: &str,
line_number: usize,
ctx: &AppContext,
) -> Option<String> {
let editor = if let Some(editor) = self.editor_for_path(path, ctx) {
// Test helper: probe by both the raw path (wrapped as a local
// `LocalOrRemotePath`) and by the repo-joined absolute path.
let local_path = LocalOrRemotePath::Local(PathBuf::from(path));
let editor = if let Some(editor) = self.editor_for_path(&local_path, ctx) {
editor
} else {
let absolute_path = self.repo_path()?.join(path);
+104 -111
View File
@@ -1,8 +1,25 @@
use std::path::PathBuf;
use std::sync::Arc;
use ai::agent::action::InsertReviewComment;
use chrono::Local;
use lsp::LspManagerModel;
use repo_metadata::repositories::DetectedRepositories;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use warp_editor::content::buffer::InitialBufferState;
use warp_editor::render::element::VerticalExpansionBehavior;
use warp_editor::render::model::LineCount;
use warpui::elements::{Empty, MouseStateHandle};
use warpui::platform::WindowStyle;
use warpui::{App, ViewHandle};
use super::*;
use crate::ai::persisted_workspace::PersistedWorkspace;
use crate::ai::request_usage_model::AIRequestUsageModel;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code::editor::view::{CodeEditorRenderOptions, CodeEditorView};
use crate::code::local_code_editor::LocalCodeEditorView;
use crate::code_review::comments::{
@@ -13,11 +30,12 @@ use crate::code_review::comments::{
use crate::code_review::diff_size_limits::DiffSize;
use crate::code_review::diff_state::{DiffStateModel, FileDiff, GitFileStatus};
use crate::code_review::editor_state::CodeReviewEditorState;
use crate::code_review::git_repo_model::GitRepoModels;
use crate::code_review::GlobalCodeReviewModel;
use crate::pane_group::WorkingDirectoriesModel;
use crate::server::server_api::{
team::MockTeamClient, workspace::MockWorkspaceClient, ServerApiProvider,
};
use crate::server::server_api::team::MockTeamClient;
use crate::server::server_api::workspace::MockWorkspaceClient;
use crate::server::server_api::ServerApiProvider;
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::terminal::local_shell::LocalShellState;
@@ -27,20 +45,6 @@ use crate::workspace::sync_inputs::SyncedInputState;
use crate::workspace::ActiveSession;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::NotebookKeybindings;
use ai::agent::action::InsertReviewComment;
use chrono::Local;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use galaxy_editor::content::buffer::InitialBufferState;
use galaxy_editor::render::element::VerticalExpansionBehavior;
use galaxy_editor::render::model::LineCount;
use galaxyui::elements::{Empty, MouseStateHandle};
use galaxyui::platform::WindowStyle;
use galaxyui::{App, ViewHandle};
use lsp::LspManagerModel;
use repo_metadata::repositories::DetectedRepositories;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Default)]
struct TestView;
@@ -73,6 +77,7 @@ fn initialize_test_app(app: &mut App) {
app.add_singleton_model(|_| VimRegisters::new());
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(|_| GitRepoModels::new());
app.add_singleton_model(|_| LspManagerModel::new());
app.add_singleton_model(|_| LocalShellState::NotLoaded);
app.add_singleton_model(PersistedWorkspace::new_for_test);
@@ -161,7 +166,7 @@ fn create_line_comment(
id: CommentId::new(),
content: comment_content.to_string(),
target: AttachedReviewCommentTarget::Line {
absolute_file_path: file_path.into(),
absolute_file_path: LocalOrRemotePath::Local(file_path.into()),
line: EditorLineLocation::Current {
line_number: line_count,
line_range: line_count..LineCount::from(line_number + 1),
@@ -189,7 +194,7 @@ fn create_file_comment(
id: CommentId::new(),
content: comment_content.to_string(),
target: AttachedReviewCommentTarget::File {
absolute_file_path: file_path.into(),
absolute_file_path: LocalOrRemotePath::Local(file_path.into()),
},
last_update_time: Local::now(),
base: None,
@@ -243,6 +248,7 @@ use crate::view_components::action_button::{ActionButton, NakedTheme};
/// Test context that holds all common test state
struct TestContext {
repo_path: PathBuf,
repo_location: LocalOrRemotePath,
#[allow(dead_code)]
window_id: galaxyui::WindowId,
state: LoadedState,
@@ -251,26 +257,28 @@ struct TestContext {
impl TestContext {
/// Initialize common test state with a single file editor
fn new(app: &mut App, file_path: PathBuf, editor_content: &str) -> Self {
fn new(app: &mut App, file_path: impl Into<String>, editor_content: &str) -> Self {
initialize_test_app(app);
let editor = create_editor_with_content(app, editor_content);
let repo_path = PathBuf::from("/repo");
let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
let state = create_loaded_state_with_editors(app, window_id, vec![(file_path, editor)]);
let state =
create_loaded_state_with_editors(app, window_id, vec![(file_path.into(), editor)]);
let diff_state_model = app.add_model(|ctx| DiffStateModel::new(None, ctx));
let diff_state_model = app.add_model(DiffStateModel::new_for_test);
let working_directories_model = app.add_model(|_| WorkingDirectoriesModel::new());
let repo_key = LocalOrRemotePath::Local(repo_path.clone());
let code_review_comment_batch =
working_directories_model.update(app, |working_directories, ctx| {
working_directories.get_or_create_code_review_comments(repo_path.as_path(), ctx)
working_directories.get_or_create_code_review_comments(&repo_key, ctx)
});
let code_review_view = app.add_view(window_id, |ctx| {
CodeReviewView::new(
Some(repo_path.clone()),
Some(repo_key.clone()),
diff_state_model,
code_review_comment_batch,
None,
@@ -279,7 +287,8 @@ impl TestContext {
});
Self {
repo_path,
repo_path: repo_path.clone(),
repo_location: LocalOrRemotePath::Local(repo_path),
window_id,
state,
code_review_view,
@@ -292,7 +301,7 @@ impl TestContext {
fn create_loaded_state_with_editors(
app: &mut App,
window_id: galaxyui::WindowId,
file_editors: Vec<(PathBuf, ViewHandle<LocalCodeEditorView>)>,
file_editors: Vec<(String, ViewHandle<LocalCodeEditorView>)>,
) -> LoadedState {
let file_states = file_editors
.into_iter()
@@ -339,17 +348,13 @@ fn create_loaded_state_with_editors(
#[test]
fn test_relocate_comments_empty_input() {
App::test((), |mut app| async move {
let ctx = TestContext::new(
&mut app,
PathBuf::from("test.txt"),
"line 1\nline 2\nline 3",
);
let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3");
ctx.code_review_view.update(&mut app, |_view, view_ctx| {
let RelocateCommentsResult {
comments: relocated,
fallback_count: fallbacks,
} = CodeReviewView::relocate_comments(vec![], &ctx.state, &ctx.repo_path, view_ctx);
} = CodeReviewView::relocate_comments(vec![], &ctx.state, &ctx.repo_location, view_ctx);
assert!(
relocated.is_empty(),
@@ -363,11 +368,7 @@ fn test_relocate_comments_empty_input() {
#[test]
fn test_relocate_comments_general_comment_passes_through() {
App::test((), |mut app| async move {
let ctx = TestContext::new(
&mut app,
PathBuf::from("test.txt"),
"line 1\nline 2\nline 3",
);
let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3");
let general_comment = create_general_comment("This is a general comment");
let original_id = general_comment.id;
@@ -379,7 +380,7 @@ fn test_relocate_comments_general_comment_passes_through() {
} = CodeReviewView::relocate_comments(
vec![general_comment],
&ctx.state,
&ctx.repo_path,
&ctx.repo_location,
view_ctx,
);
@@ -400,11 +401,11 @@ fn test_relocate_comments_general_comment_passes_through() {
#[test]
fn test_relocate_comments_file_comment_passes_through() {
App::test((), |mut app| async move {
let file_path = PathBuf::from("test.txt");
let ctx = TestContext::new(&mut app, file_path.clone(), "line 1\nline 2\nline 3");
let file_path = "test.txt";
let ctx = TestContext::new(&mut app, file_path, "line 1\nline 2\nline 3");
let file_comment =
create_file_comment(ctx.repo_path.join(&file_path), "This is a file comment");
create_file_comment(ctx.repo_path.join(file_path), "This is a file comment");
let original_id = file_comment.id;
ctx.code_review_view.update(&mut app, |_view, view_ctx| {
@@ -414,7 +415,7 @@ fn test_relocate_comments_file_comment_passes_through() {
} = CodeReviewView::relocate_comments(
vec![file_comment],
&ctx.state,
&ctx.repo_path,
&ctx.repo_location,
view_ctx,
);
@@ -438,11 +439,7 @@ fn test_relocate_comments_line_comment_no_matching_editor_marked_outdated() {
let _flag_override = FeatureFlag::PRCommentsSlashCommand.override_enabled(true);
// Editor is for "test.txt" but comment is for "other.txt"
let ctx = TestContext::new(
&mut app,
PathBuf::from("test.txt"),
"line 1\nline 2\nline 3",
);
let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3");
let line_comment =
create_line_comment("/repo/other.txt", 1, "line 1", "Comment on other file");
@@ -455,7 +452,7 @@ fn test_relocate_comments_line_comment_no_matching_editor_marked_outdated() {
} = CodeReviewView::relocate_comments(
vec![line_comment],
&ctx.state,
&ctx.repo_path,
&ctx.repo_location,
view_ctx,
);
@@ -480,11 +477,11 @@ fn test_relocate_comments_line_comment_no_matching_editor_marked_outdated() {
#[test]
fn test_relocate_comments_multiple_comment_types() {
App::test((), |mut app| async move {
let file_path = PathBuf::from("test.txt");
let ctx = TestContext::new(&mut app, file_path.clone(), "line 1\nline 2\nline 3");
let file_path = "test.txt";
let ctx = TestContext::new(&mut app, file_path, "line 1\nline 2\nline 3");
let general_comment = create_general_comment("General comment");
let file_comment = create_file_comment(ctx.repo_path.join(&file_path), "File comment");
let file_comment = create_file_comment(ctx.repo_path.join(file_path), "File comment");
let line_comment = create_line_comment("/repo/test.txt", 1, "line 1", "Line comment");
let general_id = general_comment.id;
@@ -496,7 +493,12 @@ fn test_relocate_comments_multiple_comment_types() {
let RelocateCommentsResult {
comments: relocated,
fallback_count: _,
} = CodeReviewView::relocate_comments(comments, &ctx.state, &ctx.repo_path, view_ctx);
} = CodeReviewView::relocate_comments(
comments,
&ctx.state,
&ctx.repo_location,
view_ctx,
);
assert_eq!(
relocated.len(),
@@ -528,8 +530,7 @@ fn test_relocate_comments_multiple_comment_types() {
#[test]
fn test_relocate_comments_line_comment_with_absolute_path() {
App::test((), |mut app| async move {
let file_path = PathBuf::from("test.txt");
let ctx = TestContext::new(&mut app, file_path.clone(), "line 1\nline 2\nline 3");
let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3");
// Comment with absolute path matching the editor's file
let line_comment = create_line_comment("/repo/test.txt", 1, "line 1", "Line comment");
@@ -542,7 +543,7 @@ fn test_relocate_comments_line_comment_with_absolute_path() {
} = CodeReviewView::relocate_comments(
vec![line_comment],
&ctx.state,
&ctx.repo_path,
&ctx.repo_location,
view_ctx,
);
@@ -587,7 +588,8 @@ fn test_attach_pending_imported_comment_formats_body_and_uses_absolute_path() {
},
);
let attached = attach_pending_imported_comments(vec![pending], repo_path.as_path());
let repo_location = LocalOrRemotePath::Local(repo_path.clone());
let attached = attach_pending_imported_comments(vec![pending], &repo_location);
assert_eq!(attached.len(), 1);
assert_eq!(attached[0].content, "**@alice**:\nHello world");
@@ -596,7 +598,10 @@ fn test_attach_pending_imported_comment_formats_body_and_uses_absolute_path() {
AttachedReviewCommentTarget::Line {
absolute_file_path, ..
} => {
assert_eq!(*absolute_file_path, repo_path.join("test.txt"));
assert_eq!(
*absolute_file_path,
LocalOrRemotePath::Local(repo_path.join("test.txt")),
);
}
_ => panic!("expected line comment target"),
}
@@ -667,9 +672,10 @@ fn test_attach_pending_imported_thread_flattens_depth_first_sorted_by_timestamp(
let latest_timestamp = reply_nested.last_update_time;
let repo_location = LocalOrRemotePath::Local(repo_path.clone());
let attached = attach_pending_imported_comments(
vec![reply_late, root, reply_nested, reply_early],
repo_path.as_path(),
&repo_location,
);
assert_eq!(attached.len(), 1);
@@ -683,7 +689,10 @@ fn test_attach_pending_imported_thread_flattens_depth_first_sorted_by_timestamp(
AttachedReviewCommentTarget::Line {
absolute_file_path, ..
} => {
assert_eq!(*absolute_file_path, repo_path.join("test.txt"));
assert_eq!(
*absolute_file_path,
LocalOrRemotePath::Local(repo_path.join("test.txt")),
);
}
_ => panic!("expected root line target to be preserved"),
}
@@ -695,11 +704,7 @@ fn test_relocate_comments_file_comment_no_matching_editor_marked_outdated() {
let _flag_override = FeatureFlag::PRCommentsSlashCommand.override_enabled(true);
// Editor is for "test.txt" but comment is for "other.txt"
let ctx = TestContext::new(
&mut app,
PathBuf::from("test.txt"),
"line 1\nline 2\nline 3",
);
let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3");
let file_comment = create_file_comment("/repo/other.txt", "Comment on other file");
let original_id = file_comment.id;
@@ -711,7 +716,7 @@ fn test_relocate_comments_file_comment_no_matching_editor_marked_outdated() {
} = CodeReviewView::relocate_comments(
vec![file_comment],
&ctx.state,
&ctx.repo_path,
&ctx.repo_location,
view_ctx,
);
@@ -740,8 +745,7 @@ fn test_relocate_comments_line_removed_marked_outdated() {
// Editor has "line 1\nline 3" (line 2 was removed)
// Comment was attached to "line 2" which no longer exists
let file_path = PathBuf::from("test.txt");
let ctx = TestContext::new(&mut app, file_path.clone(), "line 1\nline 3");
let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 3");
// Create a comment that was attached to "line 2" at line index 1
let line_comment =
@@ -755,7 +759,7 @@ fn test_relocate_comments_line_removed_marked_outdated() {
} = CodeReviewView::relocate_comments(
vec![line_comment],
&ctx.state,
&ctx.repo_path,
&ctx.repo_location,
view_ctx,
);
@@ -780,19 +784,24 @@ fn test_relocate_comments_line_removed_marked_outdated() {
#[test]
fn test_setup_dropdown_with_branches_includes_all_items() {
App::test((), |mut app| async move {
let ctx = TestContext::new(
&mut app,
PathBuf::from("test.txt"),
"line 1\nline 2\nline 3",
);
let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3");
// Populate branches and compute targets via the selector's build method.
let target_count = ctx.code_review_view.update(&mut app, |view, view_ctx| {
if let Some(repo) = view.active_repo.as_mut() {
repo.available_branches = vec![
("main".to_string(), true),
("feature-1".to_string(), false),
("feature-2".to_string(), false),
BranchEntry {
name: "main".to_string(),
is_main: true,
},
BranchEntry {
name: "feature-1".to_string(),
is_main: false,
},
BranchEntry {
name: "feature-2".to_string(),
is_main: false,
},
];
}
view.build_diff_targets(view_ctx).len()
@@ -813,11 +822,7 @@ fn test_setup_dropdown_with_branches_includes_all_items() {
#[test]
fn test_setup_dropdown_without_branches_only_has_uncommitted_changes() {
App::test((), |mut app| async move {
let ctx = TestContext::new(
&mut app,
PathBuf::from("test.txt"),
"line 1\nline 2\nline 3",
);
let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3");
// Ensure branches are empty (simulates the bug state) and count targets.
let target_count = ctx.code_review_view.update(&mut app, |view, view_ctx| {
@@ -837,18 +842,22 @@ fn test_setup_dropdown_without_branches_only_has_uncommitted_changes() {
#[test]
fn test_on_close_then_on_open_reinitializes_repo_state() {
App::test((), |mut app| async move {
let ctx = TestContext::new(
&mut app,
PathBuf::from("test.txt"),
"line 1\nline 2\nline 3",
);
let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3");
let repo_path = ctx.repo_path.clone();
// Populate branches to simulate a working state
let target_count_before = ctx.code_review_view.update(&mut app, |view, view_ctx| {
if let Some(repo) = view.active_repo.as_mut() {
repo.available_branches =
vec![("main".to_string(), true), ("feature-1".to_string(), false)];
repo.available_branches = vec![
BranchEntry {
name: "main".to_string(),
is_main: true,
},
BranchEntry {
name: "feature-1".to_string(),
is_main: false,
},
];
}
view.build_diff_targets(view_ctx).len()
});
@@ -862,27 +871,13 @@ fn test_on_close_then_on_open_reinitializes_repo_state() {
// Re-open the view
ctx.code_review_view.update(&mut app, |view, view_ctx| {
view.on_open(Some(repo_path.clone()), view_ctx);
view.on_open(view_ctx);
assert!(view.is_open, "View should be open after on_open");
assert_eq!(
view.repo_path(),
Some(&repo_path),
"Repo path should be set after on_open"
);
// available_branches should be empty after on_open resets the repo state,
// because update_current_repo creates a fresh RepositoryState.
// The async fetch_branches_and_rebuild_diff_selector has been initiated
// but hasn't completed yet (git command will fail in test env).
let branches_count = view
.active_repo
.as_ref()
.map(|repo| repo.available_branches.len())
.unwrap_or(0);
assert_eq!(
branches_count, 0,
"Branches should be empty immediately after on_open (async fetch pending)"
view.repo_path().and_then(LocalOrRemotePath::to_local_path),
Some(repo_path.as_path()),
"Repo path should be preserved after on_open (set at construction)"
);
});
});
@@ -891,12 +886,11 @@ fn test_on_close_then_on_open_reinitializes_repo_state() {
#[test]
fn test_handle_edit_comment_scrolls_with_buffer() {
App::test((), |mut app| async move {
let file_path = PathBuf::from("test.txt");
let content = (0..100)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let ctx = TestContext::new(&mut app, file_path.clone(), &content);
let ctx = TestContext::new(&mut app, "test.txt", &content);
// Create a line comment targeting this file
let line_comment = create_line_comment("/repo/test.txt", 5, "line 5", "Review comment");
@@ -944,8 +938,7 @@ fn test_active_comments_not_marked_outdated() {
App::test((), |mut app| async move {
let _flag_override = FeatureFlag::PRCommentsSlashCommand.override_enabled(true);
let file_path = PathBuf::from("test.txt");
let ctx = TestContext::new(&mut app, file_path.clone(), "line 1\nline 2\nline 3");
let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3");
// Comment attached to "line 2" which exists in the editor
let line_comment =
@@ -959,7 +952,7 @@ fn test_active_comments_not_marked_outdated() {
} = CodeReviewView::relocate_comments(
vec![line_comment],
&ctx.state,
&ctx.repo_path,
&ctx.repo_location,
view_ctx,
);
+127 -127
View File
@@ -1,62 +1,64 @@
use std::borrow::Cow;
use crate::ai::AIRequestUsageModel;
use crate::code::editor::comment_editor::DEFAULT_COMMENT_MAX_WIDTH;
use crate::code::editor::view::{CodeEditorEvent, CodeEditorView};
use crate::code_review::comment_rendering::CommentViewCard;
use crate::code_review::comments::{
AttachedReviewComment, AttachedReviewCommentTarget, CommentId, CommentOrigin,
ReviewCommentBatch, ReviewCommentBatchEvent,
};
use crate::code_review::CodeReviewTelemetryEvent;
use crate::menu::{Event, Menu, MenuItem, MenuItemFields};
use crate::notebooks::editor::view::{EditorViewEvent, RichTextEditorView};
use crate::send_telemetry_from_ctx;
use crate::settings::AISettings;
use crate::view_components::action_button::{
ActionButton, ActionButtonTheme, ButtonSize, NakedTheme, SecondaryTheme,
};
use crate::{
appearance::Appearance, code_review::code_review_view::CodeReviewView,
ui_components::icons::Icon, workspace::view::right_panel::ReviewDestination,
};
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::color::blend::Blend;
use galaxy_editor::model::CoreEditorModel;
use indexmap::IndexMap;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use std::path::PathBuf;
use string_offset::CharOffset;
use vec1::vec1;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::color::internal_colors::{
accent_overlay_2, accent_overlay_3, neutral_1, neutral_3, neutral_4, neutral_6, text_main,
text_sub,
};
use galaxy_core::ui::theme::Fill;
use galaxy_editor::model::CoreEditorModel;
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::new_scrollable::{NewScrollable, ScrollableAppearance, SingleAxisConfig};
use galaxyui::elements::resizable::{
resizable_state_handle, DragBarSide, Resizable, ResizableStateHandle,
};
use galaxyui::elements::{
Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Dismiss, DispatchEventResult, Element, Empty, EventHandler,
Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
OffsetPositioning, ParentElement, PositionedElementAnchor, PositionedElementOffsetBounds,
Radius, SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable, Stack,
Text,
};
use galaxyui::keymap::Keystroke;
use galaxyui::platform::Cursor;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::units::Pixels;
use galaxyui::{
clipboard::ClipboardContent,
elements::{
new_scrollable::{NewScrollable, ScrollableAppearance, SingleAxisConfig},
resizable::{resizable_state_handle, DragBarSide, Resizable, ResizableStateHandle},
Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, Dismiss, DispatchEventResult, Element, Empty,
EventHandler, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
OffsetPositioning, ParentElement, PositionedElementAnchor, PositionedElementOffsetBounds,
Radius, SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable,
Stack, Text,
},
platform::Cursor,
ui_components::{
button::{ButtonTooltipPosition, ButtonVariant},
components::{UiComponent, UiComponentStyles},
},
units::Pixels,
AppContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle, WeakViewHandle,
};
use crate::ai::request_usage_model::{AIRequestUsageModel, AIRequestUsageModelEvent};
use crate::appearance::Appearance;
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code::editor::comment_editor::DEFAULT_COMMENT_MAX_WIDTH;
use crate::code::editor::view::{CodeEditorEvent, CodeEditorView};
use crate::code_review::code_review_view::CodeReviewView;
use crate::code_review::comment_rendering::CommentViewCard;
use crate::code_review::comments::{
AttachedReviewComment, AttachedReviewCommentTarget, CommentId, CommentOrigin,
ReviewCommentBatch, ReviewCommentBatchEvent,
};
use crate::code_review::telemetry_event::CodeReviewTelemetryEvent;
use crate::menu::{Event, Menu, MenuItem, MenuItemFields};
use crate::notebooks::editor::view::{EditorViewEvent, RichTextEditorView};
use crate::send_telemetry_from_ctx;
use crate::settings::AISettings;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{
ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme,
SecondaryTheme,
};
use crate::workspace::view::right_panel::ReviewDestination;
/// Header text for the outdated section when there is exactly one outdated comment.
const OUTDATED_SECTION_HEADER_SINGULAR: &str = "1 comment will be omitted because it is outdated.";
/// Header text format for the outdated section when there are multiple outdated comments.
@@ -131,7 +133,6 @@ struct ViewState {
chevron_mouse_state: MouseStateHandle,
outdated_chevron_mouse_state: MouseStateHandle,
cancel_button_mouse_state: MouseStateHandle,
submit_button_mouse_state: MouseStateHandle,
resizable_state: ResizableStateHandle,
}
@@ -142,7 +143,6 @@ impl Default for ViewState {
chevron_mouse_state: Default::default(),
outdated_chevron_mouse_state: Default::default(),
cancel_button_mouse_state: Default::default(),
submit_button_mouse_state: Default::default(),
resizable_state: resizable_state_handle(300.0),
}
}
@@ -183,7 +183,7 @@ pub struct CommentListView {
/// Set once the user has manually collapsed or expanded the outdated section.
is_outdated_section_collapsed: Option<bool>,
repo_path: PathBuf,
repo_path: Option<LocalOrRemotePath>,
view_state: ViewState,
/// The best available destination for sending review comments.
/// Pushed down from RightPanelView.
@@ -192,11 +192,12 @@ pub struct CommentListView {
active_overflow_comment_id: Option<CommentId>,
pending_scroll_to_comment: Option<CommentId>,
comments_button: ViewHandle<ActionButton>,
send_button: ViewHandle<ActionButton>,
}
impl CommentListView {
pub fn new(
initial_repo_path: Option<PathBuf>,
initial_repo_path: Option<LocalOrRemotePath>,
parent: WeakViewHandle<CodeReviewView>,
ctx: &mut ViewContext<Self>,
) -> Self {
@@ -210,6 +211,21 @@ impl CommentListView {
})
});
let send_button = ctx.add_view(|ctx| {
ActionButton::new("Send to Agent", PrimaryTheme)
.with_size(ButtonSize::Small)
.with_keybinding(
KeystrokeSource::Fixed(
Keystroke::parse(crate::code_review::CODE_REVIEW_SUBMIT_KEYSTROKE)
.unwrap_or_default(),
),
ctx,
)
.on_click(|ctx| {
ctx.dispatch_typed_action(CommentListAction::Submit);
})
});
ctx.subscribe_to_view(&menu, |me, _, event, ctx| match event {
Event::ItemSelected => {}
Event::Close { .. } => {
@@ -218,19 +234,27 @@ impl CommentListView {
Event::ItemHovered => {}
});
// Keep the stored button state in sync when AI availability changes.
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |me, _, event, ctx| {
if let AIRequestUsageModelEvent::RequestUsageUpdated = event {
me.sync_send_button(ctx);
}
});
Self {
parent,
comment_model: None,
comments_by_id: IndexMap::new(),
is_collapsed: true,
is_outdated_section_collapsed: None,
repo_path: initial_repo_path.unwrap_or_default(),
repo_path: initial_repo_path,
view_state: ViewState::default(),
overflow_menu: menu,
review_destination: ReviewDestination::None,
active_overflow_comment_id: None,
pending_scroll_to_comment: None,
comments_button,
send_button,
}
}
@@ -276,10 +300,15 @@ impl CommentListView {
) {
if self.review_destination != destination {
self.review_destination = destination;
self.sync_send_button(ctx);
ctx.notify();
}
}
fn repo_is_local(&self) -> Option<bool> {
self.repo_path.as_ref().map(LocalOrRemotePath::is_local)
}
pub fn debug_state(&self, ctx: &AppContext) -> CommentListDebugState {
let ai_available = AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx);
let ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
@@ -367,7 +396,7 @@ impl CommentListView {
let entry = if let Some(mut existing) = self.comments_by_id.shift_remove(&id) {
existing
.card
.update_source(comment, Some(&self.repo_path), ctx);
.update_source(comment, self.repo_path.as_ref(), ctx);
existing
} else {
let card = CommentViewCard::new(
@@ -375,7 +404,7 @@ impl CommentListView {
false, /* always_use_static_diff */
false, /* disable_scrolling */
Some(Pixels::new(DEFAULT_COMMENT_MAX_WIDTH)),
Some(&self.repo_path),
self.repo_path.as_ref(),
ctx,
);
@@ -436,14 +465,7 @@ impl CommentListView {
}
self.recompute_comment_button_label(ctx);
ctx.notify();
}
pub fn set_repo_path(&mut self, repo_path: PathBuf, ctx: &mut ViewContext<Self>) {
self.repo_path = repo_path;
for state in self.comments_by_id.values_mut() {
state.card.update_title(Some(&self.repo_path));
}
self.sync_send_button(ctx);
ctx.notify();
}
@@ -556,7 +578,7 @@ impl CommentListView {
fn render_panel(&self, appearance: &Appearance, ctx: &AppContext) -> Box<dyn Element> {
let theme = appearance.theme();
let header = self.render_header(appearance, ctx);
let header = self.render_header(appearance);
let mut comments_column = Flex::column()
.with_main_axis_alignment(MainAxisAlignment::Start)
@@ -751,14 +773,14 @@ impl CommentListView {
.finish()
}
fn render_header(&self, appearance: &Appearance, ctx: &AppContext) -> Box<dyn Element> {
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let mut header_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
header_row.add_child(self.render_header_left(appearance));
header_row.add_child(self.render_header_right(appearance, ctx));
header_row.add_child(self.render_header_right(appearance));
Container::new(Clipped::new(Shrinkable::new(1., header_row.finish()).finish()).finish())
.with_background(neutral_3(theme))
@@ -866,12 +888,12 @@ impl CommentListView {
.finish()
}
fn render_header_right(&self, appearance: &Appearance, ctx: &AppContext) -> Box<dyn Element> {
fn render_header_right(&self, appearance: &Appearance) -> Box<dyn Element> {
let mut right_section = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
right_section.add_child(self.render_cancel_button(appearance));
right_section.add_child(self.render_send_button(appearance, ctx));
right_section.add_child(ChildView::new(&self.send_button).finish());
right_section.finish()
}
@@ -901,6 +923,38 @@ impl CommentListView {
.any(|state| !state.card.source().outdated)
}
/// Whether the queued review comments can currently be sent to an agent.
pub fn can_send(&self, ctx: &AppContext) -> bool {
let has_sendable_comments = self.has_non_outdated_comments();
match &self.review_destination {
ReviewDestination::None => false,
// CLI agents don't consume AI credits, so bypass the ai check.
ReviewDestination::Cli(_) => has_sendable_comments,
ReviewDestination::Warp => {
AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx) && has_sendable_comments
}
}
}
/// Keep the stored "Send to Agent" button's enabled state and tooltip in sync with the current
/// destination / comment / AI-availability state.
fn sync_send_button(&mut self, ctx: &mut ViewContext<Self>) {
let ai_available = AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx);
let ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
let enabled = self.can_send(ctx);
let tooltip = Self::send_button_tooltip_text(
&self.review_destination,
self.has_non_outdated_comments(),
ai_available,
ai_enabled,
)
.into_owned();
self.send_button.update(ctx, |button, ctx| {
button.set_disabled(!enabled, ctx);
button.set_tooltip(Some(tooltip), ctx);
});
}
/// Computes the tooltip text for the send button based on current state.
fn send_button_tooltip_text(
destination: &ReviewDestination,
@@ -929,68 +983,6 @@ impl CommentListView {
}
}
fn render_send_button(&self, appearance: &Appearance, ctx: &AppContext) -> Box<dyn Element> {
let ai_available = AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx);
let ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
let has_sendable_comments = self.has_non_outdated_comments();
// CLI agents don't consume AI credits, so bypass the ai_available check.
let enable_send = match &self.review_destination {
ReviewDestination::None => false,
ReviewDestination::Cli(_) => has_sendable_comments,
ReviewDestination::Warp => ai_available && has_sendable_comments,
};
let tooltip_text = Self::send_button_tooltip_text(
&self.review_destination,
has_sendable_comments,
ai_available,
ai_enabled,
);
let tooltip = appearance
.ui_builder()
.tool_tip(tooltip_text.into_owned())
.build()
.finish();
let button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.view_state.submit_button_mouse_state.clone(),
)
.with_text_label("Send to Agent".to_string())
.with_tooltip(|| tooltip)
.with_tooltip_position(ButtonTooltipPosition::AboveLeft);
if enable_send {
EventHandler::new(button.build().finish())
.on_left_mouse_down(move |ctx, _, _| {
ctx.dispatch_typed_action(CommentListAction::Submit);
DispatchEventResult::StopPropagation
})
.finish()
} else {
// Custom disabled button appearance because setting the `disabled` property
// on the button itself prevents all hoverable interaction (including tooltips).
let background_fill = appearance.theme().surface_3();
let foreground_color = appearance
.theme()
.disabled_text_color(background_fill)
.into_solid();
button
.with_style(UiComponentStyles {
background: Some(background_fill.into_solid().into()),
border_color: Some(foreground_color.into()),
font_color: Some(foreground_color),
..Default::default()
})
.build()
.finish()
}
}
fn render_comment(
&self,
comment_state: &CommentDisplayState,
@@ -1124,7 +1116,7 @@ impl View for CommentListView {
let appearance = Appearance::as_ref(ctx);
if self.is_collapsed {
self.render_header(appearance, ctx)
self.render_header(appearance)
} else {
let mut panel = self.render_panel(appearance, ctx);
@@ -1187,6 +1179,7 @@ impl TypedActionView for CommentListView {
// Telemetry: comment list view expanded.
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::CommentListExpanded {
is_local: self.repo_is_local(),
comment_count: self.comments_by_id.len(),
},
ctx
@@ -1203,7 +1196,9 @@ impl TypedActionView for CommentListView {
ctx.emit(CommentListEvent::Cancelled);
}
CommentListAction::Submit => {
ctx.emit(CommentListEvent::Submitted);
if self.can_send(ctx) {
ctx.emit(CommentListEvent::Submitted);
}
}
CommentListAction::ShowOverflow { comment_id } => {
let current_overflow = self.active_overflow_comment_id.take();
@@ -1275,7 +1270,12 @@ impl TypedActionView for CommentListView {
self.close_overflow_menu(ctx);
}
CommentListAction::JumpToCommentLocation(comment_id) => {
send_telemetry_from_ctx!(CodeReviewTelemetryEvent::CommentListItemClicked, ctx);
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::CommentListItemClicked {
is_local: self.repo_is_local(),
},
ctx
);
ctx.emit(CommentListEvent::JumpToCommentLocation(*comment_id));
}
}
+31 -31
View File
@@ -3,12 +3,27 @@
//! These functions are used by both the `CommentListView` (in the code review panel)
//! and the blocklist's imported comments rendering.
use std::path::Path;
use std::rc::Rc;
use chrono::{Duration, Local};
use pathfinder_color::ColorU;
use galaxy_core::ui::theme::color::internal_colors::{neutral_1, neutral_2, text_sub};
use galaxy_core::ui::theme::Fill;
use warp_editor::content::buffer::InitialBufferState;
use warp_editor::render::element::VerticalExpansionBehavior;
use warpui::elements::new_scrollable::ScrollableAppearance;
use warpui::elements::{
Border, ChildView, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth,
Shrinkable, Text,
};
use warpui::platform::Cursor;
use warpui::text_layout::ClipConfig;
use warpui::units::Pixels;
use warpui::{AppContext, Element, EventContext, SingletonEntity, View, ViewContext, ViewHandle};
use crate::appearance::Appearance;
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code::editor::comment_editor::create_readonly_comment_markdown_editor;
use crate::code::editor::view::{CodeEditorRenderOptions, CodeEditorView};
use crate::code_review::comments::{
@@ -17,21 +32,6 @@ use crate::code_review::comments::{
use crate::editor::InteractionState;
use crate::notebooks::editor::view::RichTextEditorView;
use crate::util::time_format::human_readable_approx_duration;
use galaxy_core::ui::theme::color::internal_colors::{neutral_1, neutral_2, text_sub};
use galaxy_core::ui::theme::Fill;
use galaxy_editor::content::buffer::InitialBufferState;
use galaxy_editor::render::element::VerticalExpansionBehavior;
use galaxyui::elements::new_scrollable::ScrollableAppearance;
use galaxyui::elements::ScrollbarWidth;
use galaxyui::elements::{
Border, ChildView, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::platform::Cursor;
use galaxyui::text_layout::ClipConfig;
use galaxyui::units::Pixels;
use galaxyui::{AppContext, Element, EventContext, SingletonEntity, View, ViewContext, ViewHandle};
use pathfinder_color::ColorU;
/// Configuration for making the comment header clickable.
pub(crate) struct HeaderClickHandler {
@@ -229,7 +229,7 @@ fn render_comment_text_section(
/// highlighting is set based on the file path.
fn create_static_diff_content_editor<V: View>(
content: &LineDiffContent,
file_path: &Path,
file_path: Option<&LocalOrRemotePath>,
ctx: &mut ViewContext<V>,
) -> ViewHandle<CodeEditorView> {
let editor = ctx.add_typed_action_view(|ctx| {
@@ -252,7 +252,10 @@ fn create_static_diff_content_editor<V: View>(
let original_text = content.original_text();
let state = InitialBufferState::plain_text(original_text.trim());
view.reset(state, ctx);
view.set_language_with_path(file_path, ctx);
if let Some(file_path) = file_path {
let language_path = file_path.path_component();
view.set_language_with_path(&language_path, ctx);
}
});
editor
}
@@ -299,7 +302,7 @@ impl CommentViewCard {
always_use_static_diff: bool,
disable_scrolling: bool,
max_width: Option<Pixels>,
repo_path: Option<&Path>,
repo_path: Option<&LocalOrRemotePath>,
ctx: &mut ViewContext<V>,
) -> Self {
let comment_editor = create_readonly_comment_markdown_editor(
@@ -334,7 +337,8 @@ impl CommentViewCard {
{
if always_use_static_diff || comment.outdated {
Some(CommentDiffContent::StaticEditor(
create_static_diff_content_editor(content, absolute_file_path, ctx),
// Language detection only needs the path component (extension).
create_static_diff_content_editor(content, Some(absolute_file_path), ctx),
))
} else {
Some(CommentDiffContent::EditorLens)
@@ -356,7 +360,7 @@ impl CommentViewCard {
pub(crate) fn update_source<V: View>(
&mut self,
new_source: AttachedReviewComment,
repo_path: Option<&Path>,
repo_path: Option<&LocalOrRemotePath>,
ctx: &mut ViewContext<V>,
) {
self.comment_editor.update(ctx, |editor, ctx| {
@@ -449,23 +453,19 @@ impl CommentViewCard {
matches!(self.diff_content, Some(CommentDiffContent::EditorLens))
}
/// Recomputes the cached display title.
pub(crate) fn update_title(&mut self, repo_path: Option<&Path>) {
self.title = Self::compute_title(&self.source, repo_path);
}
/// Refreshes the cached `last_updated_duration` to the current time.
pub(crate) fn refresh_last_updated_duration(&mut self) {
self.last_updated_duration = Local::now() - self.source.last_update_time;
}
fn compute_title(source: &AttachedReviewComment, repo_path: Option<&Path>) -> String {
fn compute_title(
source: &AttachedReviewComment,
repo_path: Option<&LocalOrRemotePath>,
) -> String {
let file_path = source.target.absolute_file_path().map(|p| {
repo_path
.and_then(|rp| p.strip_prefix(rp).ok())
.unwrap_or(p)
.display()
.to_string()
.and_then(|rp| rp.strip_repo_prefix(p))
.unwrap_or_else(|| p.display_path())
});
let line_number = source.target.line_number().map(|lc| lc.as_u32() + 1);
+19 -16
View File
@@ -1,11 +1,15 @@
use super::{
AttachedReviewComment, AttachedReviewCommentTarget, CommentId, PendingImportedReviewComment,
};
use crate::{code::editor::EditorReviewComment, code_review::diff_state::DiffMode};
use std::collections::HashMap;
use galaxy_core::features::FeatureFlag;
use galaxy_editor::render::model::LineCount;
use galaxyui::{Entity, ModelContext};
use std::{collections::HashMap, path::Path};
use super::{
AttachedReviewComment, AttachedReviewCommentTarget, CommentId, PendingImportedReviewComment,
};
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code::editor::EditorReviewComment;
use crate::code_review::diff_state::DiffMode;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReviewCommentBatchEvent {
@@ -54,27 +58,23 @@ impl ReviewCommentBatch {
self.comments.iter().all(|comment| comment.outdated)
}
/// `file` param should always be the filepath from the root of the repository.
/// This ensures we'll never confuse files in different subdirectories with
/// the same suffix, for example `/my_repo/src/a/file.txt` and `/my_repo/src/b/file.txt`.
/// `file` should be the host-aware absolute path for the editor file.
pub fn file_comments<'a>(
&'a self,
file: &'a Path,
file: &'a LocalOrRemotePath,
) -> impl Iterator<Item = &'a AttachedReviewComment> + 'a {
self.comments.iter().filter(move |comment| {
comment
.target
.absolute_file_path()
.is_some_and(|comment_file| comment_file.ends_with(file))
.is_some_and(|comment_file| comment_file == file)
})
}
/// `file` param should always be the filepath from the root of the repository.
/// This ensures we'll never confuse files in different subdirectories with
/// the same suffix, for example `/my_repo/src/a/file.txt` and `/my_repo/src/b/file.txt`.
/// `file` should be the host-aware absolute path for the editor file.
pub fn comment_line_numbers_for_file<'a>(
&'a self,
file: &'a Path,
file: &'a LocalOrRemotePath,
) -> impl Iterator<Item = LineCount> + 'a {
self.file_comments(file).filter_map(move |comment| {
if let AttachedReviewCommentTarget::Line {
@@ -83,7 +83,7 @@ impl ReviewCommentBatch {
..
} = &comment.target
{
if comment_file_path.ends_with(file) {
if comment_file_path == file {
line.line_number()
} else {
None
@@ -94,7 +94,10 @@ impl ReviewCommentBatch {
})
}
pub(crate) fn editor_comments_for_file(&self, file: &Path) -> Vec<EditorReviewComment> {
pub(crate) fn editor_comments_for_file(
&self,
file: &LocalOrRemotePath,
) -> Vec<EditorReviewComment> {
self.file_comments(file)
.filter(|comment| {
if FeatureFlag::PRCommentsSlashCommand.is_enabled() {
+10 -10
View File
@@ -1,7 +1,10 @@
use std::path::PathBuf;
use chrono::Local;
use galaxy_editor::render::model::LineCount;
use galaxyui::App;
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code::editor::line::EditorLineLocation;
use crate::code_review::comments::{
AttachedReviewComment, AttachedReviewCommentTarget, CommentOrigin, LineDiffContent,
@@ -13,7 +16,7 @@ fn line_comment(file_path: &str, line_number: usize, content: &str) -> AttachedR
id: Default::default(),
content: content.to_string(),
target: AttachedReviewCommentTarget::Line {
absolute_file_path: file_path.into(),
absolute_file_path: LocalOrRemotePath::Local(PathBuf::from(file_path)),
line: EditorLineLocation::Current {
line_number: LineCount::from(line_number),
line_range: LineCount::from(line_number)..LineCount::from(line_number + 1),
@@ -110,15 +113,11 @@ fn file_and_line_queries_filter_by_suffix() {
});
model.read(&app, |batch, _| {
let file_comments: Vec<_> = batch
.file_comments(std::path::Path::new("src/lib.rs"))
.collect();
let file_path = LocalOrRemotePath::Local(PathBuf::from("/repo/src/lib.rs"));
let file_comments: Vec<_> = batch.file_comments(&file_path).collect();
assert_eq!(file_comments.len(), 1);
assert_eq!(file_comments[0].content, "a");
let line_numbers: Vec<_> = batch
.comment_line_numbers_for_file(std::path::Path::new("src/lib.rs"))
.collect();
let line_numbers: Vec<_> = batch.comment_line_numbers_for_file(&file_path).collect();
assert_eq!(line_numbers, vec![LineCount::from(3)]);
});
});
@@ -147,8 +146,9 @@ fn editor_comments_for_file_includes_only_line_comments() {
});
model.read(&app, |batch, _| {
let editor_comments =
batch.editor_comments_for_file(std::path::Path::new("src/lib.rs"));
let editor_comments = batch.editor_comments_for_file(&LocalOrRemotePath::Local(
PathBuf::from("/repo/src/lib.rs"),
));
assert_eq!(editor_comments.len(), 1);
assert_eq!(editor_comments[0].id, comment_a.id);
assert_eq!(editor_comments[0].comment_content, "a");
+15 -12
View File
@@ -1,13 +1,14 @@
use crate::{
ai::agent::{CurrentHead, DiffBase},
code::editor::{line::EditorLineLocation, EditorReviewComment},
};
use std::fmt::{Display, Formatter};
use chrono::{DateTime, Local};
use galaxy_editor::render::model::LineCount;
use std::fmt::{Display, Formatter};
use std::path::PathBuf;
use warp_multi_agent_api::{self as api};
use crate::ai::agent::{CurrentHead, DiffBase};
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code::editor::line::EditorLineLocation;
use crate::code::editor::EditorReviewComment;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum CommentOrigin {
/// Comments originally created in the Warp UI.
@@ -123,7 +124,9 @@ impl From<AttachedReviewComment> for api::ReviewComment {
});
api::review_comment::CommentTarget::CommentedLine(api::DiffHunk {
file_path: absolute_file_path.to_string_lossy().to_string(),
// For the agent/GitHub API we send the path bytes only;
// the comment's owning batch is already host-scoped.
file_path: absolute_file_path.display_path(),
line_range,
diff_content: content.content,
lines_added: content.lines_added.as_u32(),
@@ -135,7 +138,7 @@ impl From<AttachedReviewComment> for api::ReviewComment {
AttachedReviewCommentTarget::File { absolute_file_path } => {
api::review_comment::CommentTarget::CommentedFile(
api::review_comment::CommentedFile {
file_path: absolute_file_path.to_string_lossy().to_string(),
file_path: absolute_file_path.display_path(),
current: val.head.to_owned().map(Into::into),
base: val.base.map(Into::into),
},
@@ -163,18 +166,18 @@ impl From<AttachedReviewComment> for api::ReviewComment {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AttachedReviewCommentTarget {
Line {
absolute_file_path: PathBuf,
absolute_file_path: LocalOrRemotePath,
line: EditorLineLocation,
content: LineDiffContent,
},
File {
absolute_file_path: PathBuf,
absolute_file_path: LocalOrRemotePath,
},
General,
}
impl AttachedReviewCommentTarget {
pub(crate) fn absolute_file_path(&self) -> Option<&PathBuf> {
pub(crate) fn absolute_file_path(&self) -> Option<&LocalOrRemotePath> {
match self {
AttachedReviewCommentTarget::Line {
absolute_file_path, ..
@@ -195,7 +198,7 @@ impl AttachedReviewCommentTarget {
impl AttachedReviewComment {
pub(crate) fn from_editor_review_comment(
comment: EditorReviewComment,
absolute_file_path: PathBuf,
absolute_file_path: LocalOrRemotePath,
base: Option<DiffBase>,
head: Option<CurrentHead>,
) -> AttachedReviewComment {
+5 -6
View File
@@ -1,11 +1,10 @@
use ai::agent::action::InsertReviewComment;
use chrono::{DateTime, Local};
use std::path::PathBuf;
use super::{
comment::ImportedCommentDetails, PendingImportedReviewComment,
PendingImportedReviewCommentTarget,
};
use ai::agent::action::InsertReviewComment;
use chrono::{DateTime, Local};
use super::comment::ImportedCommentDetails;
use super::{PendingImportedReviewComment, PendingImportedReviewCommentTarget};
use crate::code_review::comments::diff_hunk_parser::parse_diff_hunk;
#[derive(Debug)]
@@ -4,13 +4,10 @@ use ai::agent::action::CommentSide;
use galaxy_editor::render::model::LineCount;
use num_traits::SaturatingSub;
use crate::{
code::editor::line::EditorLineLocation,
code_review::{
comments::LineDiffContent,
diff_state::{DiffLineType, DiffStateModel},
},
};
use crate::code::editor::line::EditorLineLocation;
use crate::code_review::comments::LineDiffContent;
use crate::code_review::diff_state::DiffLineType;
use crate::util::git::parse_unified_diff_header;
#[derive(Debug)]
pub(crate) enum DiffHunkParseError {
@@ -89,7 +86,7 @@ fn get_diff_line_from_diff_hunk(
let diff_hunk_header = parsed_lines
.first()
.ok_or(DiffHunkParseError::EmptyHunk)
.and_then(|line| DiffStateModel::parse_unified_diff_header(line).map_err(Into::into))?;
.and_then(|line| parse_unified_diff_header(line).map_err(Into::into))?;
let mut index_in_file = match side {
CommentSide::Left => diff_hunk_header.old_start_line,
+5 -5
View File
@@ -1,10 +1,10 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use super::comment::{
AttachedReviewComment, AttachedReviewCommentTarget, CommentId, CommentOrigin,
};
use super::pending_imported::{PendingImportedReviewComment, PendingImportedReviewCommentTarget};
use crate::code::buffer_location::LocalOrRemotePath;
/// Converts pending imported provider comments into attached review comments by:
/// * flattening threaded replies
@@ -12,7 +12,7 @@ use super::pending_imported::{PendingImportedReviewComment, PendingImportedRevie
/// * converting repo-relative file paths to absolute file paths
pub(crate) fn attach_pending_imported_comments(
pending_comments: Vec<PendingImportedReviewComment>,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
) -> Vec<AttachedReviewComment> {
if pending_comments.is_empty() {
return Vec::new();
@@ -63,7 +63,7 @@ pub(crate) fn attach_pending_imported_comments(
fn flatten_pending_imported_thread(
root: &PendingImportedReviewComment,
children_map: &HashMap<&str, Vec<&PendingImportedReviewComment>>,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
) -> AttachedReviewComment {
const THREAD_REPLY_DIVIDER: &str = "\n---\n";
@@ -82,13 +82,13 @@ fn flatten_pending_imported_thread(
line,
diff_content,
} => AttachedReviewCommentTarget::Line {
absolute_file_path: repo_path.join(relative_file_path),
absolute_file_path: repo_path.join(&relative_file_path.to_string_lossy()),
line: line.clone(),
content: diff_content.clone(),
},
PendingImportedReviewCommentTarget::File { relative_file_path } => {
AttachedReviewCommentTarget::File {
absolute_file_path: repo_path.join(relative_file_path),
absolute_file_path: repo_path.join(&relative_file_path.to_string_lossy()),
}
}
PendingImportedReviewCommentTarget::General => AttachedReviewCommentTarget::General,
@@ -1,8 +1,9 @@
use crate::code::editor::line::EditorLineLocation;
use chrono::{DateTime, Local};
use std::path::PathBuf;
use chrono::{DateTime, Local};
use super::comment::{ImportedCommentDetails, LineDiffContent};
use crate::code::editor::line::EditorLineLocation;
/// Pending imported GitHub review comment.
///
+10 -19
View File
@@ -1,20 +1,19 @@
use std::collections::HashMap;
use warp_editor::render::model::LineCount;
use crate::ai::agent::DiffSetHunk;
use crate::code_review::diff_state::{DiffLineType, FileDiff};
use galaxy_editor::render::model::LineCount;
use std::collections::HashMap;
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
use std::path::Path;
use crate::ai::agent::{AIAgentAttachment, CurrentHead, DiffBase};
use crate::ai::blocklist::BlocklistAIContextModel;
use crate::code_review::{diff_state::DiffMode, DiffSetScope};
use galaxyui::{AppContext, ModelHandle};
}
}
/// Converts file diffs into a HashMap of file paths to DiffSetHunks
/// If repo_path is provided, file paths will be relative to the repo root
/// Converts file diffs into a map keyed by repo-relative path strings.
pub fn convert_file_diffs_to_diffset_hunks<'a, I>(files: I) -> HashMap<String, Vec<DiffSetHunk>>
where
I: Iterator<Item = &'a FileDiff>,
@@ -22,7 +21,7 @@ where
let mut file_diffs: HashMap<String, Vec<DiffSetHunk>> = HashMap::new();
for file_diff in files {
let file_path = file_diff.file_path.display().to_string();
let repo_relative_path = file_diff.file_path.clone();
let mut file_hunks = Vec::new();
for hunk in file_diff.hunks.iter() {
@@ -60,7 +59,7 @@ where
}
if !file_hunks.is_empty() {
file_diffs.insert(file_path, file_hunks);
file_diffs.insert(repo_relative_path, file_hunks);
}
}
@@ -73,7 +72,6 @@ pub fn create_attachment_reference_and_key(
scope: &DiffSetScope,
diff_mode: &DiffMode,
main_branch_name: Option<&str>,
repo_path: &Path,
) -> (String, String) {
match scope {
DiffSetScope::All => {
@@ -90,16 +88,9 @@ pub fn create_attachment_reference_and_key(
let key = diff_set_description.clone();
(format!("<change:{key}>"), key)
}
DiffSetScope::File(file_path) => {
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()
};
let key = relative_path.display().to_string();
DiffSetScope::File(repo_relative_path) => {
debug_assert!(!std::path::Path::new(repo_relative_path).is_absolute());
let key = repo_relative_path.clone();
(format!("<change:{key}>"), key)
}
}
+17 -20
View File
@@ -5,31 +5,28 @@ use std::cmp;
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
use galaxy_core::ui::theme::Fill;
use galaxy_editor::editor::NavigationKey;
use galaxyui::color::ColorU;
use galaxyui::elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss,
DispatchEventResult, DropShadow, Element, Empty, EventHandler, Flex, Highlight, MainAxisSize,
MouseInBehavior, ParentElement, Radius, ScrollStateHandle, Scrollable, ScrollableElement,
ScrollbarWidth, Text, UniformList, UniformListState,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::keymap::FixedBinding;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
color::ColorU,
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss,
DispatchEventResult, DropShadow, Element, Empty, EventHandler, Flex, Highlight,
MainAxisSize, MouseInBehavior, ParentElement, Radius, ScrollStateHandle, Scrollable,
ScrollableElement, ScrollbarWidth, Text, UniformList, UniformListState,
},
fonts::{Properties, Weight},
id,
keymap::FixedBinding,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Entity, FocusContext, SingletonEntity as _, TypedActionView, View, ViewContext,
id, AppContext, Entity, FocusContext, SingletonEntity as _, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::{
appearance::Appearance,
code_review::{diff_selector::DiffTarget, diff_state::DiffMode},
editor::{
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
TextOptions,
},
ui_components::icons::Icon,
use crate::appearance::Appearance;
use crate::code_review::diff_selector::DiffTarget;
use crate::code_review::diff_state::DiffMode;
use crate::editor::{
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, TextOptions,
};
use crate::ui_components::icons::Icon;
const MENU_WIDTH: f32 = 280.;
const MENU_MAX_LIST_HEIGHT: f32 = 200.;
+17 -24
View File
@@ -1,34 +1,27 @@
//! Trigger button + [`CodeReviewDiffMenu`] overlay for picking the diff
//! target in the code review header.
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element,
Flex, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
Radius, Stack, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::keymap::FixedBinding;
use galaxyui::platform::Cursor;
use galaxyui::text_layout::ClipConfig;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
elements::{
ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Element, Flex, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, Stack, Text,
},
fonts::{Properties, Weight},
id,
keymap::FixedBinding,
platform::Cursor,
text_layout::ClipConfig,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Entity, FocusContext, SingletonEntity as _, TypedActionView, View, ViewContext,
id, AppContext, Entity, FocusContext, SingletonEntity as _, TypedActionView, View, ViewContext,
ViewHandle,
};
use pathfinder_geometry::vector::vec2f;
use crate::{
appearance::Appearance,
code_review::{
diff_menu::{CodeReviewDiffMenu, CodeReviewDiffMenuEvent},
diff_state::DiffMode,
},
ui_components::icons::Icon,
};
use crate::appearance::Appearance;
use crate::code_review::diff_menu::{CodeReviewDiffMenu, CodeReviewDiffMenuEvent};
use crate::code_review::diff_state::DiffMode;
use crate::ui_components::icons::Icon;
/// A single selectable target in the diff selector menu.
#[derive(Debug, Clone)]
+31 -5
View File
@@ -1,3 +1,5 @@
use std::fmt;
use serde::{Deserialize, Serialize};
use super::diff_state::{DiffHunk, DiffLineType};
@@ -5,8 +7,11 @@ use super::diff_state::{DiffHunk, DiffLineType};
/**
* Maximum diff size that we will attempt to render. Diffs larger than this
* should not be rendered to avoid performance issues.
*
* Also reused as the per-file limit for base content in a remote session.
* Files larger than this should not be sent over the wire and should not be rendered.
*/
const MAX_DIFF_SIZE: usize = 4_375_000; // 4.375MB in decimal
pub const MAX_DIFF_SIZE: usize = 4_375_000; // 4.375MB in decimal
/**
* Reasonable limit for diff size. Diffs bigger than this _could_ be displayed
@@ -38,8 +43,29 @@ pub enum DiffSize {
Normal,
/// Large diff that should be collapsed by default but can be expanded
Large,
/// Diff that's too large to render safely
Unrenderable,
/// Diff that cannot be rendered
Unrenderable(UnrenderableReason),
}
/// Why a [`DiffSize::Unrenderable`] file cannot be rendered.
#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum UnrenderableReason {
/// The diff/patch itself is too large to render performantly (computed
/// locally from the patch via [`compute_diff_size`]).
DiffTooLarge,
/// The base file content was withheld because it exceeded the per-file wire
/// budget ([`MAX_DIFF_SIZE`]). Only produced when serializing a diff for a
/// remote subscriber.
FileTooLarge,
}
impl fmt::Display for UnrenderableReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DiffTooLarge => write!(f, "Diff is too large to render"),
Self::FileTooLarge => write!(f, "File is too large to render"),
}
}
}
/// Determines if a diff size exceeds the maximum renderable limit
@@ -62,7 +88,7 @@ fn is_diff_too_large(diff: &[DiffHunk]) -> bool {
/// Categorizes a diff based on multiple size heuristics
pub fn compute_diff_size(diffs: &[DiffHunk], diff_size: usize) -> DiffSize {
if is_diff_unrenderable(diff_size) {
return DiffSize::Unrenderable;
return DiffSize::Unrenderable(UnrenderableReason::DiffTooLarge);
}
let additions = diffs
@@ -79,7 +105,7 @@ pub fn compute_diff_size(diffs: &[DiffHunk], diff_size: usize) -> DiffSize {
// To avoid performance issues, set a lower render limit for deletion lines.
if deletions > DELETION_LINE_RENDER_LIMIT {
return DiffSize::Unrenderable;
return DiffSize::Unrenderable(UnrenderableReason::DiffTooLarge);
}
if is_buffer_too_large(diff_size)
+223
View File
@@ -0,0 +1,223 @@
//! Typed errors for diff state operations.
//!
//! [`DiffStateError`] is the single error type used across every diff-state operation:
//! - per-file invalidation
//! - full diff load
//! - metadata load
//! - remote-daemon snapshot/error responses
//! The same pool of git / filesystem failures can surface in any of these operations,
//! so a single classifier keeps the code DRY and ensures every site reports failures the same way.
//!
//! A [`DiffStateError`] pairs a sanitized [`DiffStateErrorKind`] with the raw
//! underlying error, but only the sanitized half is ever emitted off-device:
//! - [`std::fmt::Display`] renders only the sanitized `kind`, so passing this
//! through [`galaxy_core::report_error!`] or code-review telemetry keeps logs,
//! Sentry, and analytics free of repo paths, refs, command output, or
//! secrets. The raw cause is never exposed via `Display` or `source`.
//!
//! For [`DiffStateErrorKind::Unknown`] the raw cause is additionally consulted
//! via [`AnyhowErrorExt::is_actionable`] so registered non-actionable causes
//! (transient I/O, network, etc.) auto-demote it to a warning instead of a
//! Sentry capture.
//!
//! Use the operation tag [`super::DiffOperation`] alongside this error in telemetry to distinguish where a given failure originated.
use galaxy_core::errors::{AnyhowErrorExt, ErrorExt};
use galaxy_core::sync_queue::IsTransientError;
/// Sanitized classification of a [`DiffStateError`]. Every variant has a
/// fixed, PII-free [`std::fmt::Display`] string that is safe to send to logs
/// and Sentry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub(crate) enum DiffStateErrorKind {
// ── Git environment / repository state ──────────────────────────────
#[error("git rejected repository ownership")]
GitRejectedRepositoryOwnership,
#[error("git is unavailable")]
GitUnavailable,
#[error("git lfs is unavailable")]
GitLfsUnavailable,
#[error("xcode license is not accepted")]
XcodeLicenseNotAccepted,
#[error("invalid empty pathspec")]
InvalidEmptyPathspec,
#[error("path is outside repository")]
PathOutsideRepository,
#[error("path is not a git repository")]
NotGitRepository,
#[error("repository is not a work tree")]
NotWorkTree,
#[error("repository resource is not accessible")]
RepositoryPathNotAccessible,
#[error("path is not valid UTF-8")]
NonUtf8Path,
#[error("git revision is unavailable")]
GitRevisionUnavailable,
#[error("git head tree is invalid")]
GitHeadTreeInvalid,
#[error("git status output is invalid")]
InvalidGitStatusOutput,
#[error("repository path is invalid")]
RepositoryPathInvalid,
// ── Remote daemon application-level outcomes ────────────────────────
/// The remote daemon reported `DiffState::Loaded` but no `GitDiffData`
/// accompanied it. Only constructed by `RemoteDiffStateModel`.
#[error("server returned empty diff data")]
EmptyDiffData,
// ── Unclassified ────────────────────────────────────────────────────
/// Unrecognized error. Add a dedicated variant once a new pattern is
/// identified from the raw text recorded in telemetry.
#[error("unknown diff state error")]
Unknown,
}
impl DiffStateErrorKind {
fn classify(message: &str) -> Option<Self> {
if message.contains("detected dubious ownership in repository") {
Some(Self::GitRejectedRepositoryOwnership)
} else if message.contains("No such file or directory")
|| message.contains("program not found")
|| message.contains("No developer tools were found")
{
Some(Self::GitUnavailable)
} else if message.contains("git-lfs: command not found") {
Some(Self::GitLfsUnavailable)
} else if message.contains("Xcode license agreements") {
Some(Self::XcodeLicenseNotAccepted)
} else if message.contains("empty string is not a valid pathspec") {
Some(Self::InvalidEmptyPathspec)
} else if message.contains("outside repository") {
Some(Self::PathOutsideRepository)
} else if message.contains("not a git repository") {
Some(Self::NotGitRepository)
} else if message.contains("this operation must be run in a work tree") {
Some(Self::NotWorkTree)
} else if message.contains("Operation not permitted")
|| message.contains("Permission denied")
{
Some(Self::RepositoryPathNotAccessible)
} else if message.contains("non-UTF-8 path") {
Some(Self::NonUtf8Path)
} else if message.contains("bad revision") || message.contains("unknown revision") {
Some(Self::GitRevisionUnavailable)
} else if message.contains("bad tree object HEAD") {
Some(Self::GitHeadTreeInvalid)
} else if message.contains("os error 267") {
Some(Self::RepositoryPathInvalid)
} else if message.contains("Invalid status code") {
Some(Self::InvalidGitStatusOutput)
} else {
None
}
}
}
/// A diff-state failure: a sanitized [`DiffStateErrorKind`] paired with the
/// raw underlying error. See the module docs for how the two halves are
/// routed to telemetry vs. logs / Sentry.
#[derive(Debug, thiserror::Error)]
#[error("{kind}")]
pub(crate) struct DiffStateError {
kind: DiffStateErrorKind,
/// Raw underlying error. Consulted only for [`DiffStateErrorKind::Unknown`]
/// actionability and never exposed via `Display`, `source`, or telemetry,
/// so logs, Sentry, and analytics only ever see the sanitized `kind`.
cause: anyhow::Error,
}
impl DiffStateError {
/// Build a `DiffStateError` from a plain error message string. Used when
/// the source error has already been flattened to a `String` (e.g. by
/// `DiffsWithBaseContent::changes`, or by the remote daemon over the
/// wire).
pub(crate) fn from_message(message: &str) -> Self {
Self {
kind: DiffStateErrorKind::classify(message).unwrap_or(DiffStateErrorKind::Unknown),
cause: anyhow::anyhow!("{message}"),
}
}
/// Build the [`DiffStateErrorKind::EmptyDiffData`] error, reported when the
/// remote daemon claims `DiffState::Loaded` but sends no diff data.
pub(crate) fn empty_diff_data() -> Self {
let kind = DiffStateErrorKind::EmptyDiffData;
let cause = anyhow::anyhow!("{kind}");
Self { kind, cause }
}
/// Logs the raw underlying error locally, then reports the sanitized
/// [`DiffStateError`] through the normal reporting path.
pub(crate) fn report_and_log(&self) {
let cause = &self.cause;
log::warn!("Diff state error: {cause:#}");
galaxy_core::report_error!(self);
}
}
impl From<anyhow::Error> for DiffStateError {
fn from(cause: anyhow::Error) -> Self {
let kind = DiffStateErrorKind::classify(&format!("{cause:#}"))
.unwrap_or(DiffStateErrorKind::Unknown);
Self { kind, cause }
}
}
impl ErrorExt for DiffStateError {
fn is_actionable(&self) -> bool {
match self.kind {
// Caller / engineering bugs — surface to Sentry at error level.
DiffStateErrorKind::InvalidEmptyPathspec
| DiffStateErrorKind::InvalidGitStatusOutput
| DiffStateErrorKind::EmptyDiffData => true,
// Unknown errors defer to the anyhow chain so registered
// transient/non-actionable causes (network, transient I/O, etc.)
// log at warn level instead of paging us via Sentry.
DiffStateErrorKind::Unknown => self.cause.is_actionable(),
// User environment failures — not our bug; log as warning.
DiffStateErrorKind::GitRejectedRepositoryOwnership
| DiffStateErrorKind::GitUnavailable
| DiffStateErrorKind::GitLfsUnavailable
| DiffStateErrorKind::XcodeLicenseNotAccepted
| DiffStateErrorKind::PathOutsideRepository
| DiffStateErrorKind::NotGitRepository
| DiffStateErrorKind::NotWorkTree
| DiffStateErrorKind::RepositoryPathNotAccessible
| DiffStateErrorKind::NonUtf8Path
| DiffStateErrorKind::GitRevisionUnavailable
| DiffStateErrorKind::GitHeadTreeInvalid
| DiffStateErrorKind::RepositoryPathInvalid => false,
}
}
}
galaxy_core::errors::register_error!(DiffStateError);
impl IsTransientError for DiffStateError {
fn is_transient(&self) -> bool {
match self.kind {
// Repo / filesystem state can briefly churn while the queue is
// processing invalidations, so these are worth the sync queue's
// short retry budget.
DiffStateErrorKind::RepositoryPathNotAccessible
| DiffStateErrorKind::GitRevisionUnavailable
| DiffStateErrorKind::GitHeadTreeInvalid
| DiffStateErrorKind::EmptyDiffData
| DiffStateErrorKind::Unknown => true,
// Caller bugs, invalid inputs, missing tools, and user-actionable
// environment setup issues won't resolve by retrying the same
// operation a few seconds later.
DiffStateErrorKind::GitRejectedRepositoryOwnership
| DiffStateErrorKind::GitUnavailable
| DiffStateErrorKind::GitLfsUnavailable
| DiffStateErrorKind::XcodeLicenseNotAccepted
| DiffStateErrorKind::InvalidEmptyPathspec
| DiffStateErrorKind::PathOutsideRepository
| DiffStateErrorKind::NotGitRepository
| DiffStateErrorKind::NotWorkTree
| DiffStateErrorKind::NonUtf8Path
| DiffStateErrorKind::RepositoryPathInvalid
| DiffStateErrorKind::InvalidGitStatusOutput => false,
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,353 @@
use super::*;
use crate::util::git::{
parse_range, parse_unified_diff_header, sort_branches_main_first, BranchEntry,
};
#[test]
fn test_parse_range_with_comma() {
let (start, count) =
parse_range("10,5").expect("parse_range should succeed for range with count");
assert_eq!(start, 10);
assert_eq!(count, 5);
}
#[test]
fn test_parse_range_without_comma() {
let (start, count) =
parse_range("10").expect("parse_range should succeed for range without count");
assert_eq!(start, 10);
assert_eq!(count, 1);
}
#[test]
fn test_parse_unified_diff_header_basic() {
let header = "@@ -10,5 +12,7 @@";
let parsed = parse_unified_diff_header(header)
.expect("parse_unified_diff_header should succeed for basic header");
assert_eq!(parsed.old_start_line, 10);
assert_eq!(parsed.old_line_count, 5);
assert_eq!(parsed.new_start_line, 12);
assert_eq!(parsed.new_line_count, 7);
}
#[test]
fn test_parse_unified_diff_header_with_context() {
let header = "@@ -4978,33 +4978,43 @@ impl TerminalView {";
let parsed = parse_unified_diff_header(header)
.expect("parse_unified_diff_header should succeed for header with context");
assert_eq!(parsed.old_start_line, 4978);
assert_eq!(parsed.old_line_count, 33);
assert_eq!(parsed.new_start_line, 4978);
assert_eq!(parsed.new_line_count, 43);
}
#[test]
fn test_parse_unified_diff_header_single_line() {
let header = "@@ -10 +12,3 @@";
let parsed = parse_unified_diff_header(header)
.expect("parse_unified_diff_header should succeed for single line header");
assert_eq!(parsed.old_start_line, 10);
assert_eq!(parsed.old_line_count, 1);
assert_eq!(parsed.new_start_line, 12);
assert_eq!(parsed.new_line_count, 3);
}
#[test]
fn test_sort_branches_main_first_empty() {
let branches: Vec<BranchEntry> = vec![];
let result: Vec<_> = sort_branches_main_first(&branches).collect();
assert!(result.is_empty());
}
#[test]
fn test_sort_branches_main_first_no_main() {
let branches = vec![
BranchEntry {
name: "feature-a".to_string(),
is_main: false,
},
BranchEntry {
name: "feature-b".to_string(),
is_main: false,
},
BranchEntry {
name: "feature-c".to_string(),
is_main: false,
},
];
let result: Vec<_> = sort_branches_main_first(&branches).collect();
// No main branches — order should be unchanged.
assert_eq!(result, branches.iter().collect::<Vec<_>>());
}
#[test]
fn test_sort_branches_main_first_promotes_main() {
let branches = vec![
BranchEntry {
name: "feature-a".to_string(),
is_main: false,
},
BranchEntry {
name: "main".to_string(),
is_main: true,
},
BranchEntry {
name: "feature-b".to_string(),
is_main: false,
},
];
let result: Vec<_> = sort_branches_main_first(&branches)
.map(|entry| entry.name.as_str())
.collect();
assert_eq!(result, vec!["main", "feature-a", "feature-b"]);
}
#[test]
fn test_sort_branches_main_first_main_already_first() {
let branches = vec![
BranchEntry {
name: "main".to_string(),
is_main: true,
},
BranchEntry {
name: "feature-a".to_string(),
is_main: false,
},
BranchEntry {
name: "feature-b".to_string(),
is_main: false,
},
];
let result: Vec<_> = sort_branches_main_first(&branches)
.map(|entry| entry.name.as_str())
.collect();
assert_eq!(result, vec!["main", "feature-a", "feature-b"]);
}
#[test]
fn test_sort_branches_main_first_preserves_recency_order_for_non_main() {
// Non-main branches should remain in their original (recency) order.
let branches = vec![
BranchEntry {
name: "recent-feature".to_string(),
is_main: false,
},
BranchEntry {
name: "main".to_string(),
is_main: true,
},
BranchEntry {
name: "older-feature".to_string(),
is_main: false,
},
BranchEntry {
name: "oldest-feature".to_string(),
is_main: false,
},
];
let result: Vec<_> = sort_branches_main_first(&branches)
.map(|entry| entry.name.as_str())
.collect();
assert_eq!(
result,
vec!["main", "recent-feature", "older-feature", "oldest-feature"]
);
}
#[test]
fn test_sort_branches_main_first_multiple_main_flags() {
// Defensive: both flagged as main (shouldn't happen in practice, but
// sort_branches_main_first should handle it gracefully).
let branches = vec![
BranchEntry {
name: "feature".to_string(),
is_main: false,
},
BranchEntry {
name: "main".to_string(),
is_main: true,
},
BranchEntry {
name: "master".to_string(),
is_main: true,
},
];
let result: Vec<_> = sort_branches_main_first(&branches)
.map(|entry| entry.name.as_str())
.collect();
// Both main-flagged entries appear first, non-main last.
assert_eq!(result, vec!["main", "master", "feature"]);
}
#[test]
fn test_parse_unified_diff_header_malformed() {
let header = "not a diff header";
let result = parse_unified_diff_header(header);
assert!(result.is_err());
let header2 = "@@ incomplete";
let result2 = parse_unified_diff_header(header2);
assert!(result2.is_err());
}
#[test]
fn test_parse_git_status_modified_file_with_spaces() {
// Porcelain v2 output for a modified file with spaces in the name.
// Format: 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
let status_output = "1 .M N... 100644 100644 100644 abc1234 def5678 test file.txt";
let result = LocalDiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "test file.txt");
assert_eq!(result[0].1, GitFileStatus::Modified);
}
#[test]
fn test_parse_git_status_modified_file_with_multiple_spaces() {
// Filename with multiple spaces.
let status_output = "1 .M N... 100644 100644 100644 abc1234 def5678 path to/my test file.txt";
let result = LocalDiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "path to/my test file.txt");
assert_eq!(result[0].1, GitFileStatus::Modified);
}
#[test]
fn test_parse_git_status_new_file_with_spaces() {
let status_output = "1 A. N... 000000 100644 100644 0000000 abc1234 new file name.rs";
let result = LocalDiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "new file name.rs");
assert_eq!(result[0].1, GitFileStatus::New);
}
#[test]
fn test_parse_git_status_renamed_file_with_spaces() {
// Porcelain v2 renamed entry (type 2) with spaces in the new path.
// Format: 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>\0<origPath>
let status_output =
"2 R. N... 100644 100644 100644 abc1234 def5678 R100 new name.txt\0old name.txt";
let result = LocalDiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "new name.txt");
assert!(matches!(
&result[0].1,
GitFileStatus::Renamed { old_path } if old_path == "old name.txt"
));
}
#[test]
fn test_parse_git_status_untracked_file_with_spaces() {
let status_output = "? my untracked file.txt";
let result = LocalDiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "my untracked file.txt");
assert_eq!(result[0].1, GitFileStatus::Untracked);
}
#[test]
fn test_parse_git_status_unmerged_file_with_spaces() {
// Porcelain v2 unmerged entry (type u) with spaces in the path.
// Format: u <xy> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
let status_output =
"u UU N... 100644 100644 100644 100644 abc1234 def5678 ghi9012 conflict file.txt";
let result = LocalDiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "conflict file.txt");
assert_eq!(result[0].1, GitFileStatus::Conflicted);
}
#[test]
fn test_parse_git_status_mixed_entries_with_spaces() {
// Multiple entries separated by NUL, mixing files with and without spaces.
let status_output = "1 .M N... 100644 100644 100644 abc1234 def5678 test file.txt\0\
1 .M N... 100644 100644 100644 abc1234 def5678 normal.txt\0\
? another file with spaces.rs";
let result = LocalDiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].0, "test file.txt");
assert_eq!(result[1].0, "normal.txt");
assert_eq!(result[2].0, "another file with spaces.rs");
}
#[test]
fn test_parse_git_status_file_without_spaces_still_works() {
// Ensure the splitn change doesn't break files without spaces.
let status_output = "1 .M N... 100644 100644 100644 abc1234 def5678 simple.txt";
let result = LocalDiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "simple.txt");
assert_eq!(result[0].1, GitFileStatus::Modified);
}
#[tokio::test]
async fn untracked_directory_diff_is_empty_and_non_binary() {
let repo_dir = tempfile::tempdir().expect("create temp repo dir");
std::fs::create_dir(repo_dir.path().join("nested-repo")).expect("create nested dir");
// `git status` reports a nested repo/worktree as a single untracked
// directory entry (with a trailing slash). It must short-circuit to an
// empty non-binary diff — the error fallback would otherwise mislabel it
// as binary and the view would render "Binary file - no diff available"
// instead of "New empty file".
let diff = LocalDiffStateModel::get_file_diff(
repo_dir.path(),
"nested-repo/",
&GitFileStatus::Untracked,
false,
None,
)
.await
.expect("get_file_diff should succeed for an untracked directory");
assert!(!diff.is_binary);
assert_eq!(diff.hunks.len(), 0);
assert_eq!(diff.status, GitFileStatus::Untracked);
}
#[tokio::test]
async fn untracked_directory_has_no_baseline_content() {
let repo_dir = tempfile::tempdir().expect("create temp repo dir");
std::fs::create_dir(repo_dir.path().join("nested-repo")).expect("create nested dir");
std::fs::write(repo_dir.path().join("new-file.txt"), "hello\n").expect("write file");
// No baseline for a directory entry, so no editor is constructed for it.
let dir_content = LocalDiffStateModel::get_file_content_at_head(
repo_dir.path(),
"nested-repo/",
&GitFileStatus::Untracked,
)
.await;
assert_eq!(dir_content, None);
// Regular untracked files keep their empty baseline.
let file_content = LocalDiffStateModel::get_file_content_at_head(
repo_dir.path(),
"new-file.txt",
&GitFileStatus::Untracked,
)
.await;
assert_eq!(file_content, Some(String::new()));
}
#[tokio::test]
async fn num_lines_in_file_if_non_binary_counts_lines_in_text_file() {
let dir = tempfile::tempdir().expect("create temp dir");
let file_path = dir.path().join("file.txt");
std::fs::write(&file_path, "one\ntwo\nthree\n").expect("write file");
let num_lines = LocalDiffStateModel::num_lines_in_file_if_non_binary(&file_path)
.await
.expect("counting a regular file should succeed");
assert_eq!(num_lines, Some(3));
}
#[tokio::test]
async fn num_lines_in_file_if_non_binary_errors_for_directory() {
let dir = tempfile::tempdir().expect("create temp dir");
// Directories aren't countable. The metadata callers degrade this error
// to a 0-line contribution per entry instead of failing the whole
// metadata computation.
let result = LocalDiffStateModel::num_lines_in_file_if_non_binary(dir.path()).await;
assert!(result.is_err());
}
+883
View File
@@ -0,0 +1,883 @@
//! Unified diff state module.
//!
//! [`DiffStateModel`] is an enum that provides a unified API over local and remote models.
//! It holds one of [`LocalDiffStateModel`] or [`RemoteDiffStateModel`] and dispatches
//! operations to whichever is active.
//! All consumers should use `DiffStateModel` rather than accessing sub-models directly.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use galaxy_core::SessionId;
use warp_util::remote_path::RemotePath;
use warp_util::standardized_path::StandardizedPath;
use warpui::{AppContext, ModelContext, ModelHandle};
use crate::code_review::diff_size_limits::DiffSize;
use crate::util::git::{BranchEntry, Commit, FileChangeEntry, PrInfo};
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
mod local;
#[cfg(feature = "local_fs")]
pub(crate) use local::diff_metadata_against_head;
pub use local::LocalDiffStateModel;
mod remote;
pub use remote::RemoteDiffStateModel;
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
mod error;
pub(crate) use error::DiffStateError;
/// What to chain after a commit: commit only, commit + push, or commit + push
/// + create-PR. The single shared commit-chain vocabulary, used end to end: the
/// commit dialog stores the user's selection as this, both the local
/// (`git_actions::run_commit_chain`) and remote (`DiffStateModel::git_commit_chain`)
/// backends accept it, and it's converted to the wire enum
/// (`proto::GitCommitChainMode`) at the manager boundary via the `From` impl in
/// the `diff_state_proto` module.
#[allow(clippy::enum_variant_names)] // `Commit` prefix is intentional: every chain starts with a commit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommitChainMode {
CommitOnly,
CommitAndPush,
CommitAndCreatePr,
}
/// Identifies the host of a [`DiffStateModel`] so failure telemetry can be
/// attributed to where the model actually ran. This is more specific than the
/// local/remote split already encoded by `is_local`: a [`LocalDiffStateModel`]
/// can be instantiated on the user's client (`ClientLocal`) or on a remote
/// daemon (`RemoteDaemon`) serving subscribers, and only the host knows which.
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
pub enum BackendOrigin {
/// `LocalDiffStateModel` running on the user's client against local files.
#[serde(rename = "client_local")]
ClientLocal,
/// `RemoteDiffStateModel` running on the user's client; talks to a daemon.
#[serde(rename = "client_remote")]
ClientRemote,
/// `LocalDiffStateModel` running on a remote daemon, serving subscribers.
#[serde(rename = "remote_daemon")]
RemoteDaemon,
}
/// Identifies the diff-state operation that produced a [`DiffStateError`]
/// on the `LoadDiffFailed` telemetry path. Carried alongside the error so
/// failures can be sliced by originating operation — every operation shares
/// the same failure pool, so the error variant alone doesn't reveal where
/// it came from.
///
/// Metadata-load failures are reported through a dedicated
/// `LoadMetadataFailed` event and therefore don't need a variant here.
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
pub enum DiffOperation {
/// Per-file diff refresh triggered by the file-invalidation queue.
#[serde(rename = "file_invalidation")]
FileInvalidation,
/// Full repo-wide diff snapshot load.
#[serde(rename = "diff_load")]
DiffLoad,
/// Client-side reaction to a remote daemon's diff-state response.
#[serde(rename = "remote_diff")]
RemoteDiff,
}
// -- Shared types ──────────────────────────────────────────────────────
/// Represents the status of a file in the git working directory
/// This matches Git Desktop's AppFileStatusKind enum
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum GitFileStatus {
New,
Modified,
Deleted,
Renamed { old_path: String },
Copied { old_path: String },
Untracked,
Conflicted,
}
impl GitFileStatus {
pub fn is_renamed(&self) -> bool {
matches!(self, Self::Renamed { .. })
}
pub fn is_new_file(&self) -> bool {
matches!(self, Self::New | Self::Untracked)
}
}
#[derive(Clone, Debug)]
pub struct FileStatusInfo {
pub path: StandardizedPath,
pub status: GitFileStatus,
}
impl TryFrom<&str> for GitFileStatus {
type Error = anyhow::Error;
fn try_from(status_code: &str) -> Result<Self> {
match status_code {
".M" | "M." | "MM" => Ok(GitFileStatus::Modified),
".A" | "A." | "AM" => Ok(GitFileStatus::New),
".D" | "D." | "AD" => Ok(GitFileStatus::Deleted),
_ => Ok(GitFileStatus::Modified), // Default fallback
}
}
}
/// Represents a single line in a diff hunk, as rendered by `git diff`.
/// This matches Git Desktop's DiffLine structure.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DiffLine {
pub line_type: DiffLineType,
pub old_line_number: Option<usize>,
pub new_line_number: Option<usize>,
pub text: String,
pub no_trailing_newline: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum DiffLineType {
Context,
Add,
Delete,
HunkHeader,
}
/// Represents a hunk of changes in a file diff, as rendered by `git diff`,
/// including the header and context lines before/after an insertion or
/// deletion.
/// This matches Git Desktop's DiffHunk structure.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DiffHunk {
pub old_start_line: usize,
pub old_line_count: usize,
pub new_start_line: usize,
pub new_line_count: usize,
pub lines: Vec<DiffLine>,
pub unified_diff_start: usize,
pub unified_diff_end: usize,
}
/// Represents the diff for a single file, as rendered by `git diff`.
/// This matches Git Desktop's FileDiff structure.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FileDiff {
/// Repo-relative path for this diff file. Absolute file identities should use
/// `StandardizedPath` or `LocalOrRemotePath` at API boundaries.
pub file_path: String,
pub status: GitFileStatus,
pub hunks: Arc<Vec<DiffHunk>>,
pub is_binary: bool,
pub is_autogenerated: bool,
pub max_line_number: usize,
pub has_hidden_bidi_chars: bool,
pub size: DiffSize,
}
impl FileDiff {
pub fn is_empty(&self) -> bool {
self.additions() == 0 && self.deletions() == 0
}
/// Returns the number of added lines in this file diff
pub fn additions(&self) -> usize {
self.hunks
.iter()
.flat_map(|hunk| &hunk.lines)
.filter(|line| line.line_type == DiffLineType::Add)
.count()
}
/// Returns the number of deleted lines in this file diff
pub fn deletions(&self) -> usize {
self.hunks
.iter()
.flat_map(|hunk| &hunk.lines)
.filter(|line| line.line_type == DiffLineType::Delete)
.count()
}
}
/// IMPORTANT: This struct contains expensive data like the full content of diff files
/// at base. This should not be cloned at any time.
#[derive(Debug)]
pub struct FileDiffAndContent {
pub file_diff: FileDiff,
/// Full file content at the diff base (HEAD or merge-base), used by the
/// code review editor to render inline diffs (`set_base`).
///
/// `None` means no usable baseline exists and no editor is constructed:
/// binary files, non-file entries (e.g. nested repo/worktree directories),
/// failed `git show`, or content that was never loaded / was withheld on
/// the wire (reconstruction from cached `GitDiffData`, over-budget files).
///
/// `Some("")` means a baseline exists but is empty: new/untracked files
/// that don't exist at the base (the diff correctly renders everything as
/// additions) or files genuinely empty at the base commit.
pub content_at_head: Option<String>,
}
/// IMPORTANT: This struct contains expensive data like the full content of diff files
/// at base. This should not be cloned at any time.
#[derive(Debug)]
pub struct GitDiffWithBaseContent {
pub files: Vec<FileDiffAndContent>,
pub total_additions: usize,
pub total_deletions: usize,
pub files_changed: usize,
}
impl From<GitDiffWithBaseContent> for GitDiffData {
fn from(value: GitDiffWithBaseContent) -> Self {
Self {
files: value.files.into_iter().map(|file| file.file_diff).collect(),
total_additions: value.total_additions,
total_deletions: value.total_deletions,
files_changed: value.files_changed,
}
}
}
impl From<&GitDiffWithBaseContent> for GitDiffData {
fn from(value: &GitDiffWithBaseContent) -> Self {
Self {
files: value
.files
.iter()
.map(|file| file.file_diff.clone())
.collect(),
total_additions: value.total_additions,
total_deletions: value.total_deletions,
files_changed: value.files_changed,
}
}
}
impl From<&GitDiffData> for GitDiffWithBaseContent {
fn from(value: &GitDiffData) -> Self {
Self {
files: value
.files
.iter()
.map(|file_diff| FileDiffAndContent {
file_diff: file_diff.clone(),
content_at_head: None,
})
.collect(),
total_additions: value.total_additions,
total_deletions: value.total_deletions,
files_changed: value.files_changed,
}
}
}
/// Represents the complete git diff information for a repository
#[derive(Clone)]
pub struct GitDiffData {
pub files: Vec<FileDiff>,
pub total_additions: usize,
pub total_deletions: usize,
pub files_changed: usize,
}
impl GitDiffData {
pub fn is_dirty(&self) -> bool {
self.total_additions + self.total_deletions + self.files_changed > 0
}
}
/// Some actions should only apply when a [`GitDiffData`] is dirty, i.e. not empty. This enum allows
/// callers to express this preference.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum GitDeltaPreference {
Always,
OnlyDirty,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Default, Serialize)]
pub enum DiffMode {
/// Show changes in working directory against latest commit (git diff)
#[default]
Head,
/// Show changes in working directory against main branch (git diff $(git merge-base HEAD origin/master))
MainBranch,
/// Show changes in working directory against an arbitrary branch (git diff $(git merge-base HEAD <branch>))
OtherBranch(#[serde(skip_serializing)] String),
}
impl DiffMode {
/// Creates a DiffMode from a branch name.
/// If the branch matches the repository's main branch, returns `MainBranch`;
/// otherwise returns `OtherBranch(branch)`.
pub fn from_branch(branch: &str, main_branch_name: Option<&str>) -> Self {
if main_branch_name == Some(branch) {
DiffMode::MainBranch
} else {
DiffMode::OtherBranch(branch.to_string())
}
}
}
/// User-visible representation of the diffs we've loaded,
/// which only includes changes against the specific base the user has selected.
#[derive(Debug)]
pub enum DiffState {
NotInRepository,
Loading,
Error(String),
Loaded,
/// The remote connection was lost. The model will re-subscribe
/// automatically when a session becomes available.
Disconnected,
}
#[derive(Clone, Debug, Default)]
pub struct DiffMetadata {
pub main_branch_name: String,
pub current_branch_name: String,
pub against_head: DiffMetadataAgainstBase,
pub against_base_branch: Option<DiffMetadataAgainstBase>,
pub has_head_commit: bool,
pub unpushed_commits: Vec<Commit>,
pub upstream_ref: Option<String>,
}
#[derive(Clone, Default, Debug)]
pub struct DiffMetadataAgainstBase {
pub aggregate_stats: DiffStats,
/// Per-file change entries (path + additions/deletions) for this base.
/// Populated from the same numstat that produces `aggregate_stats`, so the
/// git dialog's Changes box can render without a working-tree read — this
/// is what lets the box populate for remote repos, where the list rides
/// along in synced metadata.
pub files: Vec<FileChangeEntry>,
}
impl DiffMetadataAgainstBase {
pub fn is_dirty(&self) -> bool {
!self.aggregate_stats.has_no_changes()
}
}
#[derive(Debug, Copy, Clone, Default)]
pub struct DiffStats {
pub files_changed: usize,
pub total_additions: usize,
pub total_deletions: usize,
}
impl DiffStats {
pub(crate) fn has_no_changes(&self) -> bool {
self.files_changed == 0
}
}
#[derive(Debug)]
pub enum DiffStateModelEvent {
/// Event dispatched when the current branch changes.
CurrentBranchChanged,
/// Event dispatched when new diffs are computed (full reload).
NewDiffsComputed {
diffs: Option<Arc<GitDiffWithBaseContent>>,
load_duration: Option<Duration>,
},
/// Event dispatched when a single file's diff is updated incrementally.
SingleFileUpdated {
/// Repo-relative path for the updated file.
path: String,
diff: Option<Arc<FileDiffAndContent>>,
},
/// Event dispatched when diff metadata (stats, branch info) is refreshed.
MetadataRefreshed(Box<DiffMetadata>),
/// The remote connection was lost. Stale diffs should be preserved while
/// the model waits for a new subscription.
ConnectionLost,
/// Branch list received from the backend (local git or remote server).
BranchesReceived(Vec<BranchEntry>),
/// A remote git operation completed. The model has already applied any
/// successful metadata delta to the cached metadata.
GitOpCompleted(GitOpResult),
/// An AI-generated commit message arrived from the remote daemon (issued
/// at commit-dialog open). `Ok` carries the message, `Err` the error
/// string. The `GitDialog` populates its message editor from this; the
/// local path fills the editor directly without going through an event.
CommitMessageGenerated(Result<String, String>),
/// Committed branch files (`merge_base(HEAD, main)..HEAD`) arrived for the
/// Create PR dialog's Changes box. Fetched on dialog open and delivered the
/// same way for both backends: the local model computes them off-thread and
/// emits this; the remote model emits it on the daemon's RPC response.
BranchCommittedFilesReceived(Vec<FileChangeEntry>),
}
/// Result of a remote git operation, emitted via
/// `DiffStateModelEvent::GitOpCompleted`. The model applies the post-op
/// delta before emitting, so the dialog only handles UI concerns.
#[derive(Debug, Clone)]
pub enum GitOpResult {
/// Commit chain completed. `Ok(Some(pr))` when create-PR was part of
/// the chain; `Ok(None)` for commit-only or commit-and-push.
CommitChainCompleted(Result<Option<PrInfo>, String>),
/// Standalone push completed.
PushCompleted(Result<(), String>),
/// Standalone create-PR completed.
PrCreated(Result<PrInfo, String>),
}
// ── Unified model ────────────────────────────────────────────────────────
/// Unified diff state model that dispatches to a local or remote backend.
///
/// Only one variant is populated at a time, since a diff state belongs to
/// exactly one repository (either local or remote). All consumers should
/// interact with this enum rather than accessing sub-models directly.
pub enum DiffStateModel {
Local(ModelHandle<LocalDiffStateModel>),
Remote(ModelHandle<RemoteDiffStateModel>),
}
impl warpui::Entity for DiffStateModel {
type Event = DiffStateModelEvent;
}
impl DiffStateModel {
// ── Construction ─────────────────────────────────────────────────
/// Creates a new local-backed `DiffStateModel`. The wrapper subscribes
/// to the inner model so it can forward events.
pub fn new_local(path: PathBuf, ctx: &mut ModelContext<Self>) -> Self {
let repo_path = Some(path.display().to_string());
let local = ctx
.add_model(|ctx| LocalDiffStateModel::new(repo_path, BackendOrigin::ClientLocal, ctx));
ctx.subscribe_to_model(&local, |me, _, event, ctx| me.forward_event(event, ctx));
Self::Local(local)
}
/// Creates a new remote-backed `DiffStateModel`. The model is keyed by
/// `(host_id, repo, mode)` and shared across sessions viewing the same
/// repo. `preferred_session` is the session that opened this review (when
/// known): `GetDiffState` is session-scoped, so the manager dispatches it
/// over that session when it's connected and falls back to any connected
/// session for the host otherwise. Callers must ensure a session for the
/// host is connected before constructing.
pub fn new_remote(
remote_path: RemotePath,
preferred_session: Option<SessionId>,
ctx: &mut ModelContext<Self>,
) -> Self {
let remote = ctx.add_model(|ctx| {
RemoteDiffStateModel::new(remote_path, DiffMode::default(), preferred_session, ctx)
});
ctx.subscribe_to_model(&remote, |me, _, event, ctx| me.forward_event(event, ctx));
Self::Remote(remote)
}
// ── Event forwarding ─────────────────────────────────────────────
fn forward_event(&mut self, event: &DiffStateModelEvent, ctx: &mut ModelContext<Self>) {
match event {
DiffStateModelEvent::CurrentBranchChanged => {
ctx.emit(DiffStateModelEvent::CurrentBranchChanged);
}
DiffStateModelEvent::NewDiffsComputed {
diffs,
load_duration,
} => {
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: diffs.clone(),
load_duration: *load_duration,
});
}
DiffStateModelEvent::SingleFileUpdated { path, diff } => {
ctx.emit(DiffStateModelEvent::SingleFileUpdated {
path: path.clone(),
diff: diff.clone(),
});
}
DiffStateModelEvent::MetadataRefreshed(metadata) => {
ctx.emit(DiffStateModelEvent::MetadataRefreshed(metadata.clone()));
}
DiffStateModelEvent::ConnectionLost => {
ctx.emit(DiffStateModelEvent::ConnectionLost);
}
DiffStateModelEvent::BranchesReceived(branches) => {
ctx.emit(DiffStateModelEvent::BranchesReceived(branches.clone()));
}
DiffStateModelEvent::GitOpCompleted(result) => {
ctx.emit(DiffStateModelEvent::GitOpCompleted(result.clone()));
}
DiffStateModelEvent::CommitMessageGenerated(result) => {
ctx.emit(DiffStateModelEvent::CommitMessageGenerated(result.clone()));
}
DiffStateModelEvent::BranchCommittedFilesReceived(files) => {
ctx.emit(DiffStateModelEvent::BranchCommittedFilesReceived(
files.clone(),
));
}
}
}
// ── Unified read API ─────────────────────────────────────────────
pub(crate) fn get(&self, ctx: &AppContext) -> DiffState {
match self {
Self::Local(m) => m.as_ref(ctx).get(),
Self::Remote(m) => m.as_ref(ctx).get(),
}
}
pub(crate) fn diff_mode(&self, ctx: &AppContext) -> DiffMode {
match self {
Self::Local(m) => m.as_ref(ctx).diff_mode(),
Self::Remote(m) => m.as_ref(ctx).diff_mode(),
}
}
pub(crate) fn get_uncommitted_stats(&self, ctx: &AppContext) -> Option<DiffStats> {
match self {
Self::Local(m) => m.as_ref(ctx).get_uncommitted_stats(),
Self::Remote(m) => m.as_ref(ctx).get_uncommitted_stats(),
}
}
/// Per-file entries for the uncommitted-vs-HEAD changes, sourced from
/// synced metadata (`against_head.files`). The per-file counterpart to
/// `get_uncommitted_stats`. Empty until metadata loads. Available for both
/// backends, so the commit dialog's Changes box works for remote repos
/// without reading the working tree.
pub(crate) fn uncommitted_file_entries<'a>(
&self,
ctx: &'a AppContext,
) -> &'a [FileChangeEntry] {
match self {
Self::Local(m) => m.as_ref(ctx).uncommitted_file_entries(),
Self::Remote(m) => m.as_ref(ctx).uncommitted_file_entries(),
}
}
pub(crate) fn get_main_branch_name(&self, ctx: &AppContext) -> Option<String> {
match self {
Self::Local(m) => m.as_ref(ctx).get_main_branch_name(),
Self::Remote(m) => m.as_ref(ctx).get_main_branch_name(),
}
}
pub fn get_current_branch_name(&self, ctx: &AppContext) -> Option<String> {
match self {
Self::Local(m) => m.as_ref(ctx).get_current_branch_name(),
Self::Remote(m) => m.as_ref(ctx).get_current_branch_name(),
}
}
pub(crate) fn is_on_main_branch(&self, ctx: &AppContext) -> bool {
match self {
Self::Local(m) => m.as_ref(ctx).is_on_main_branch(),
Self::Remote(m) => m.as_ref(ctx).is_on_main_branch(),
}
}
pub(crate) fn unpushed_commits<'a>(&self, ctx: &'a AppContext) -> &'a [Commit] {
match self {
Self::Local(m) => m.as_ref(ctx).unpushed_commits(),
Self::Remote(m) => m.as_ref(ctx).unpushed_commits(),
}
}
pub(crate) fn upstream_ref<'a>(&self, ctx: &'a AppContext) -> Option<&'a str> {
match self {
Self::Local(m) => m.as_ref(ctx).upstream_ref(),
Self::Remote(m) => m.as_ref(ctx).upstream_ref(),
}
}
pub(crate) fn upstream_differs_from_main(&self, ctx: &AppContext) -> bool {
match self {
Self::Local(m) => m.as_ref(ctx).upstream_differs_from_main(),
Self::Remote(m) => m.as_ref(ctx).upstream_differs_from_main(),
}
}
pub(crate) fn is_git_operation_blocked(&self, ctx: &AppContext) -> bool {
match self {
Self::Local(m) => m.as_ref(ctx).is_git_operation_blocked(ctx),
// Remote git ops rely on the daemon-side `.git` sentinel as the
// authoritative guard, so the client doesn't pre-emptively block.
Self::Remote(_) => false,
}
}
pub(crate) fn has_head(&self, ctx: &AppContext) -> bool {
match self {
Self::Local(m) => m.as_ref(ctx).has_head(),
Self::Remote(m) => m.as_ref(ctx).has_head(),
}
}
// ── Unified write API ─────────────────────────────────────────────
/// `preferred_session` is the session that triggered this call (the
/// session showing the review). It's forwarded per-call to the remote
/// model so the `GetDiffState` RPC rides that session; the local backend
/// ignores it. The remote model never caches it.
pub(crate) fn set_diff_mode(
&self,
mode: DiffMode,
should_fetch_base: bool,
track_load_duration: bool,
preferred_session: Option<SessionId>,
ctx: &mut ModelContext<Self>,
) {
match self {
Self::Local(local) => {
local.update(ctx, |local, ctx| {
local.set_diff_mode(mode, should_fetch_base, track_load_duration, ctx);
});
}
Self::Remote(model) => {
model.update(ctx, |model, ctx| {
model.set_diff_mode(mode, track_load_duration, preferred_session, ctx);
});
}
}
}
pub(crate) fn set_diff_mode_and_fetch_base(
&self,
mode: DiffMode,
preferred_session: Option<SessionId>,
ctx: &mut ModelContext<Self>,
) {
match self {
Self::Local(local) => {
local.update(ctx, |local, ctx| {
local.set_diff_mode_and_fetch_base(mode, ctx);
});
}
Self::Remote(model) => {
model.update(ctx, |model, ctx| {
model.set_diff_mode(mode, true, preferred_session, ctx);
});
}
}
}
pub(crate) fn load_diffs_for_current_repo(
&self,
should_fetch_base: bool,
track_load_duration: bool,
preferred_session: Option<SessionId>,
ctx: &mut ModelContext<Self>,
) {
match self {
Self::Local(local) => {
local.update(ctx, |local, ctx| {
local.load_diffs_for_current_repo(should_fetch_base, track_load_duration, ctx);
});
}
Self::Remote(remote) => {
remote.update(ctx, |remote, ctx| {
remote.fetch_fresh_snapshot(track_load_duration, preferred_session, ctx);
});
}
}
}
pub(crate) fn set_code_review_metadata_refresh_enabled(
&self,
enabled: bool,
ctx: &mut ModelContext<Self>,
) {
match self {
Self::Local(local) => {
local.update(ctx, |local, ctx| {
local.set_code_review_metadata_refresh_enabled(enabled, ctx);
});
}
Self::Remote(_) => {}
}
}
pub(crate) fn fetch_branches(&self, ctx: &mut ModelContext<Self>) {
match self {
Self::Local(local) => {
local.update(ctx, |local, ctx| {
local.fetch_branches(ctx);
});
}
Self::Remote(model) => {
model.update(ctx, |model, ctx| {
model.fetch_branches(ctx);
});
}
}
}
pub(crate) fn refresh_metadata_after_git_operation(&self, ctx: &mut ModelContext<Self>) {
match self {
Self::Local(local) => {
local.update(ctx, |local, ctx| {
local.refresh_metadata_after_git_operation(ctx);
});
}
Self::Remote(_) => {}
}
}
pub(crate) fn discard_files(
&self,
file_infos: Vec<FileStatusInfo>,
should_stash: bool,
branch_name: Option<String>,
ctx: &mut ModelContext<Self>,
) {
match self {
Self::Local(local) => {
local.update(ctx, |local, ctx| {
local.discard_files(file_infos, should_stash, branch_name, ctx);
});
}
Self::Remote(model) => {
model.update(ctx, |model, ctx| {
model.discard_files(file_infos, should_stash, branch_name, ctx);
});
}
}
}
/// Runs a commit chain (commit, then optionally push/create-PR).
pub(crate) fn git_commit_chain(
&self,
mode: CommitChainMode,
message: String,
include_unstaged: bool,
branch: String,
autogenerate_pr_content: bool,
ctx: &mut ModelContext<Self>,
) {
match self {
Self::Local(local) => local.update(ctx, |local, ctx| {
local.git_commit_chain(
mode,
message,
include_unstaged,
branch,
autogenerate_pr_content,
ctx,
);
}),
Self::Remote(remote) => remote.update(ctx, |remote, ctx| {
remote.git_commit_chain(
mode,
message,
include_unstaged,
branch,
autogenerate_pr_content,
ctx,
);
}),
}
}
/// Issues an AI commit-message generation request.
pub(crate) fn generate_commit_message(
&self,
include_unstaged: bool,
branch_name: String,
ctx: &mut ModelContext<Self>,
) {
match self {
Self::Local(local) => local.update(ctx, |local, ctx| {
local.generate_commit_message(include_unstaged, branch_name, ctx);
}),
Self::Remote(remote) => remote.update(ctx, |remote, ctx| {
remote.generate_commit_message(include_unstaged, branch_name, ctx);
}),
}
}
/// Pushes the given git branch to the remote origin.
pub(crate) fn git_push(&self, branch: String, ctx: &mut ModelContext<Self>) {
match self {
Self::Local(local) => local.update(ctx, |local, ctx| {
local.git_push(branch, ctx);
}),
Self::Remote(remote) => remote.update(ctx, |remote, ctx| {
remote.git_push(branch, ctx);
}),
}
}
/// Creates a PR for the current branch.
///
/// When `autogenerate_content` is set, the PR title/body are AI-generated,
/// otherwise fallback to `gh pr create --fill`.
pub(crate) fn create_pr(
&self,
branch: String,
autogenerate_content: bool,
ctx: &mut ModelContext<Self>,
) {
match self {
Self::Local(local) => local.update(ctx, |local, ctx| {
local.create_pr(branch, autogenerate_content, ctx);
}),
Self::Remote(remote) => remote.update(ctx, |remote, ctx| {
remote.create_pr(branch, autogenerate_content, ctx);
}),
}
}
/// Fetches the committed branch files (`merge_base(HEAD, main)..HEAD`) for
/// the Create PR dialog's Changes box. Both backends deliver the result via
/// `DiffStateModelEvent::BranchCommittedFilesReceived`: the local model
/// computes them from committed history off-thread; the remote model issues
/// the `GitGetCommittedBranchFiles` RPC. Committed-only, so uncommitted and
/// untracked changes are excluded — matching what the PR will contain.
pub(crate) fn fetch_committed_branch_files(&self, ctx: &mut ModelContext<Self>) {
match self {
Self::Local(local) => local.update(ctx, |local, ctx| {
local.fetch_committed_branch_files(ctx);
}),
Self::Remote(model) => model.update(ctx, |model, ctx| {
model.fetch_committed_branch_files(ctx);
}),
}
}
#[cfg(feature = "local_fs")]
pub(crate) fn stop_active_watcher(&self, ctx: &mut ModelContext<Self>) {
match self {
Self::Local(local) => {
local.update(ctx, |local, ctx| {
local.stop_active_watcher(ctx);
});
}
Self::Remote(remote) => {
remote.update(ctx, |remote, ctx| {
remote.unsubscribe(ctx);
});
}
}
}
}
#[cfg(test)]
impl DiffStateModel {
/// Test-only constructor that creates a local-backend model without a
/// repository. All existing tests exercise local behavior; add a
/// `new_for_test_remote` variant when remote-backend tests are needed.
pub fn new_for_test(ctx: &mut ModelContext<Self>) -> Self {
let local = ctx.add_model(LocalDiffStateModel::new_for_test);
ctx.subscribe_to_model(&local, |me, _, event, ctx| me.forward_event(event, ctx));
Self::Local(local)
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod wrapper_tests;
@@ -0,0 +1,63 @@
use super::{DiffMode, DiffState, DiffStateModel};
#[test]
fn new_for_test_creates_local_variant() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(DiffStateModel::new_for_test);
handle.read(&app, |model, _ctx| {
assert!(matches!(model, DiffStateModel::Local(_)));
});
});
}
#[test]
fn get_returns_not_in_repository_for_test_model() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(DiffStateModel::new_for_test);
let state = handle.read(&app, |model, ctx| model.get(ctx));
assert!(matches!(state, DiffState::NotInRepository));
});
}
#[test]
fn diff_mode_defaults_to_head() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(DiffStateModel::new_for_test);
let mode = handle.read(&app, |model, ctx| model.diff_mode(ctx));
assert!(matches!(mode, DiffMode::Head));
});
}
#[test]
fn has_head_false_for_test_model() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(DiffStateModel::new_for_test);
let has_head = handle.read(&app, |model, ctx| model.has_head(ctx));
assert!(!has_head);
});
}
#[test]
fn branch_info_none_for_test_model() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(DiffStateModel::new_for_test);
handle.read(&app, |model, ctx| {
assert_eq!(model.get_main_branch_name(ctx), None);
assert_eq!(model.get_current_branch_name(ctx), None);
assert!(!model.is_on_main_branch(ctx));
assert!(model.unpushed_commits(ctx).is_empty());
assert_eq!(model.upstream_ref(ctx), None);
assert!(!model.upstream_differs_from_main(ctx));
assert!(!model.is_git_operation_blocked(ctx));
});
});
}
#[test]
fn uncommitted_stats_none_for_test_model() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(DiffStateModel::new_for_test);
let stats = handle.read(&app, |model, ctx| model.get_uncommitted_stats(ctx));
assert!(stats.is_none());
});
}
+878
View File
@@ -0,0 +1,878 @@
//! Remote diff state model.
//!
//! Client-side model for a single remote repository diff state subscription
//! received from the remote server. Presents the same read API as
//! `LocalDiffStateModel` and emits the same `DiffStateModelEvent` variants.
//!
//! The active [`DiffMode`] can change; the model handles this by unsubscribing
//! from the old `(repo_path, mode)` subscription and re-subscribing with the
//! new mode.
use std::sync::Arc;
use instant::Instant;
use remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
use galaxy_core::{send_telemetry_from_ctx, HostId, SessionId};
use warp_util::remote_path::RemotePath;
use warp_util::standardized_path::StandardizedPath;
use warpui::{ModelContext, SingletonEntity};
use super::{
BackendOrigin, CommitChainMode, DiffMetadata, DiffMode, DiffOperation, DiffState,
DiffStateError, DiffStateModelEvent, DiffStats, FileDiffAndContent, GitDiffData,
GitDiffWithBaseContent,
};
use crate::code_review::telemetry_event::CodeReviewTelemetryEvent;
use crate::remote_server::diff_state_proto::{try_decode_file_delta, try_decode_snapshot};
use crate::remote_server::proto;
use crate::util::git::{BranchEntry, Commit, FileChangeEntry, PrInfo};
// ── Internal state ────────────────────────────────────────────────
#[derive(Default)]
enum InternalRemoteDiffState {
#[default]
Loading,
NotInRepository,
Loaded(GitDiffData),
Error(String),
/// The remote connection was lost. Preserves stale data until the model
/// can re-establish the server-side subscription.
Disconnected,
}
// ── Model ────────────────────────────────────────────────────────────────────
pub struct RemoteDiffStateModel {
remote_path: RemotePath,
mode: DiffMode,
state: InternalRemoteDiffState,
metadata: Option<DiffMetadata>,
/// Start time for the latest caller-tracked full diff snapshot request.
tracked_diff_load_start_time: Option<Instant>,
}
impl warpui::Entity for RemoteDiffStateModel {
type Event = DiffStateModelEvent;
}
impl RemoteDiffStateModel {
/// Creates a new remote diff state model.
///
/// Identity is `(host_id, repo_path, mode)`. The model is session-agnostic:
/// the manager resolves a connected session for the host on every outbound
/// RPC, and host-level connect/disconnect events drive subscription
/// lifecycle.
///
/// `preferred_session` is the session that opened this review (the
/// triggering callsite). It is used only for the *initial* `GetDiffState`
/// dispatch and is deliberately not stored: a shared, long-lived model
/// must not pin a session, and later re-triggers supply their own session
/// (or `None`) rather than reusing a stale one.
///
/// A session for this host is required at construction time. The model starts in `Loading` and
/// issues the initial `GetDiffState` request. Runtime disconnects transition the model through
/// `mark_disconnected`; subsequent reconnects re-subscribe via the `HostConnected` event handler.
pub fn new(
remote_path: RemotePath,
mode: DiffMode,
preferred_session: Option<SessionId>,
ctx: &mut ModelContext<Self>,
) -> Self {
// Subscribe to RemoteServerManager push events and filter by remote_path and diff_mode
let mgr_handle = RemoteServerManager::handle(ctx);
ctx.subscribe_to_model(&mgr_handle, |me, _, event, ctx| {
me.handle_manager_event(event, ctx)
});
let host_id = remote_path.host_id.clone();
let repo_path = remote_path.path.clone();
let mode_clone = mode.clone();
mgr_handle.update(ctx, |mgr, ctx| {
mgr.get_diff_state(
host_id,
repo_path,
proto::DiffMode::from(&mode_clone),
preferred_session,
ctx,
);
});
Self {
remote_path,
mode,
state: InternalRemoteDiffState::Loading,
metadata: None,
tracked_diff_load_start_time: None,
}
}
// ── Event handler ───────────────────────────────────────────
fn matches_remote_path_and_mode(
&self,
host_id: &HostId,
repo_path: &StandardizedPath,
mode: &proto::DiffMode,
) -> bool {
let remote_mode = proto::DiffMode::from(&self.mode);
self.remote_path.matches(host_id, repo_path) && mode == &remote_mode
}
fn handle_manager_event(
&mut self,
event: &RemoteServerManagerEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
RemoteServerManagerEvent::DiffStateSnapshotReceived {
host_id,
repo_path,
mode,
snapshot,
} => {
if !self.matches_remote_path_and_mode(host_id, repo_path, mode) {
return;
}
self.handle_snapshot_received(snapshot, ctx);
}
RemoteServerManagerEvent::DiffStateMetadataUpdateReceived {
host_id,
repo_path,
mode,
update,
} => {
if !self.matches_remote_path_and_mode(host_id, repo_path, mode) {
return;
}
self.handle_metadata_update_received(update, ctx);
}
RemoteServerManagerEvent::DiffStateFileDeltaReceived {
host_id,
repo_path,
mode,
delta,
} => {
if !self.matches_remote_path_and_mode(host_id, repo_path, mode) {
return;
}
self.handle_file_delta_received(delta, ctx);
}
RemoteServerManagerEvent::GetBranchesResponse {
repo_path, result, ..
} if repo_path == &self.remote_path.path => {
let branches = match result {
Ok(branch_infos) => branch_infos
.iter()
.map(|info| BranchEntry {
name: info.name.clone(),
is_main: info.is_main,
})
.collect(),
Err(err) => {
log::warn!("RemoteDiffStateModel: GetBranches failed: {err}");
vec![]
}
};
ctx.emit(DiffStateModelEvent::BranchesReceived(branches));
}
RemoteServerManagerEvent::CommitChainResponse {
host_id,
repo_path,
result,
} if self.remote_path.matches(host_id, repo_path) => {
self.handle_git_commit_chain_response(result, ctx);
}
RemoteServerManagerEvent::GitPushResponse {
host_id,
repo_path,
result,
} if self.remote_path.matches(host_id, repo_path) => {
self.handle_git_push_response(result, ctx);
}
RemoteServerManagerEvent::CreatePrResponse {
host_id,
repo_path,
result,
} if self.remote_path.matches(host_id, repo_path) => {
self.handle_create_pr_response(result, ctx);
}
RemoteServerManagerEvent::GenerateCommitMessageResponse {
host_id,
repo_path,
result,
} if self.remote_path.matches(host_id, repo_path) => {
// AI ran on the daemon; just relay the result to the dialog.
ctx.emit(DiffStateModelEvent::CommitMessageGenerated(result.clone()));
}
RemoteServerManagerEvent::GetCommittedBranchFilesResponse {
host_id,
repo_path,
result,
} if self.remote_path.matches(host_id, repo_path) => {
self.handle_get_committed_branch_files_response(result, ctx);
}
RemoteServerManagerEvent::HostDisconnected { host_id }
if host_id == &self.remote_path.host_id =>
{
self.mark_disconnected(ctx);
}
RemoteServerManagerEvent::HostConnected { host_id }
if host_id == &self.remote_path.host_id
&& matches!(self.state, InternalRemoteDiffState::Disconnected) =>
{
// Reconnect is event-driven with no viewing-session in scope
// (and the prior session may be gone), so re-subscribe over
// any connected session for the host.
self.resubscribe(false, None, ctx);
}
_ => {}
}
}
/// Marks the model as disconnected, preserving any stale data and
/// emitting `ConnectionLost`.
fn mark_disconnected(&mut self, ctx: &mut ModelContext<Self>) {
if matches!(self.state, InternalRemoteDiffState::Disconnected) {
return;
}
self.tracked_diff_load_start_time = None;
self.state = InternalRemoteDiffState::Disconnected;
ctx.emit(DiffStateModelEvent::ConnectionLost);
}
/// Re-sends `GetDiffState` for this model's `(host_id, repo, mode)` and
/// transitions to `Loading` while waiting for a fresh snapshot.
///
/// `preferred_session` is supplied by the triggering callsite (the
/// session-scoped view) so the request rides the connection that needs the
/// result; `None` (e.g. reconnect) falls back to any connected session.
fn resubscribe(
&mut self,
track_load_duration: bool,
preferred_session: Option<SessionId>,
ctx: &mut ModelContext<Self>,
) {
// Always overwrite to avoid carrying a stale `Instant` from a prior
// tracked load that was interrupted by a session blip.
self.tracked_diff_load_start_time = track_load_duration.then(Instant::now);
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
let mode = self.mode.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.get_diff_state(
host_id,
repo_path,
proto::DiffMode::from(&mode),
preferred_session,
ctx,
);
});
self.state = InternalRemoteDiffState::Loading;
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: None,
load_duration: None,
});
}
// ── Proto → state conversion helpers ────────────────────────────────────────────────
fn handle_snapshot_received(
&mut self,
snapshot: &proto::DiffStateSnapshot,
ctx: &mut ModelContext<Self>,
) {
match try_decode_snapshot(snapshot) {
Ok((metadata, state, diffs)) => self.apply_snapshot(metadata, state, diffs, ctx),
Err(error) => {
self.tracked_diff_load_start_time = None;
galaxy_core::safe_error!(
safe: ("RemoteDiffStateModel: failed to decode diff state snapshot"),
full: ("RemoteDiffStateModel: failed to decode diff state snapshot: {error}")
);
}
}
}
fn handle_metadata_update_received(
&mut self,
update: &proto::DiffStateMetadataUpdate,
ctx: &mut ModelContext<Self>,
) {
match update
.metadata
.as_ref()
.map(DiffMetadata::try_from)
.transpose()
{
Ok(Some(metadata)) => self.apply_metadata_update(&metadata, ctx),
Ok(None) => {}
Err(error) => {
galaxy_core::safe_error!(
safe: ("RemoteDiffStateModel: failed to decode diff state metadata update"),
full: ("RemoteDiffStateModel: failed to decode diff state metadata update: {error}")
);
}
}
}
fn handle_file_delta_received(
&mut self,
delta: &proto::DiffStateFileDelta,
ctx: &mut ModelContext<Self>,
) {
match try_decode_file_delta(delta) {
Ok((file_path, diff, metadata)) => {
self.apply_file_delta(file_path, diff, metadata, ctx)
}
Err(error) => {
galaxy_core::safe_error!(
safe: ("RemoteDiffStateModel: failed to decode diff state file delta"),
full: ("RemoteDiffStateModel: failed to decode diff state file delta: {error}")
);
}
}
}
// ── Apply methods ──────────────────────────────────────────────────────
/// Requests a fresh diff snapshot from the remote server, including file
/// content. Unlike the former `replay_latest_diffs` (which reconstructed
/// data from cached `GitDiffData` and lost `content_at_head`), this sends
/// an actual `GetDiffState` RPC so the server can reload content from disk.
///
/// Does NOT transition to `Loading` or emit `NewDiffsComputed(None)` first,
/// so existing views subscribed to this model won't flash a loading state.
/// The server response arrives as a `DiffStateSnapshotReceived` event and
/// flows through `apply_snapshot` normally.
pub(crate) fn fetch_fresh_snapshot(
&mut self,
track_load_duration: bool,
preferred_session: Option<SessionId>,
ctx: &mut ModelContext<Self>,
) {
if track_load_duration {
self.tracked_diff_load_start_time = Some(Instant::now());
}
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
let mode = self.mode.clone();
// `preferred_session` is supplied per-call by the triggering view (the
// session showing the review); `None` falls back to any connected
// session for the host. Never cached on this shared model.
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.get_diff_state(
host_id,
repo_path,
proto::DiffMode::from(&mode),
preferred_session,
ctx,
);
});
}
fn apply_snapshot(
&mut self,
metadata: Option<DiffMetadata>,
state: DiffState,
diffs: Option<GitDiffWithBaseContent>,
ctx: &mut ModelContext<Self>,
) {
// Update metadata, detecting branch changes.
if let Some(metadata) = &metadata {
self.apply_metadata_update(metadata, ctx);
}
// Update state.
match state {
// Disconnected is never produced by proto deserialization.
DiffState::Disconnected => {}
DiffState::NotInRepository => {
self.tracked_diff_load_start_time = None;
self.state = InternalRemoteDiffState::NotInRepository;
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: None,
load_duration: None,
});
}
DiffState::Loading => {
self.state = InternalRemoteDiffState::Loading;
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: None,
load_duration: None,
});
}
DiffState::Error(msg) => {
let load_duration = self
.tracked_diff_load_start_time
.take()
.map(|start| start.elapsed());
let err = DiffStateError::from_message(&msg);
err.report_and_log();
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::LoadDiffFailed {
backend_origin: BackendOrigin::ClientRemote,
operation: DiffOperation::RemoteDiff,
mode: self.mode.clone(),
error: err.to_string(),
load_duration,
},
ctx
);
self.state = InternalRemoteDiffState::Error(msg);
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: None,
load_duration: None,
});
}
DiffState::Loaded => {
let Some(base_content) = diffs else {
let load_duration = self
.tracked_diff_load_start_time
.take()
.map(|start| start.elapsed());
let err = DiffStateError::empty_diff_data();
err.report_and_log();
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::LoadDiffFailed {
backend_origin: BackendOrigin::ClientRemote,
operation: DiffOperation::RemoteDiff,
mode: self.mode.clone(),
error: err.to_string(),
load_duration,
},
ctx
);
self.state = InternalRemoteDiffState::Error(err.to_string());
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: None,
load_duration: None,
});
return;
};
let diffs = GitDiffData::from(&base_content);
let load_duration = self
.tracked_diff_load_start_time
.take()
.map(|start| start.elapsed());
self.state = InternalRemoteDiffState::Loaded(diffs);
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: Some(Arc::new(base_content)),
load_duration,
});
}
}
}
fn apply_metadata_update(&mut self, metadata: &DiffMetadata, ctx: &mut ModelContext<Self>) {
let previous_branch = self
.metadata
.as_ref()
.map(|m| m.current_branch_name.as_str());
let branch_changed =
previous_branch.is_some_and(|prev| prev != metadata.current_branch_name.as_str());
let metadata = metadata.clone();
self.metadata = Some(metadata.clone());
// Only emit CurrentBranchChanged when there was a previous branch to
// compare against. On the first metadata update (initial snapshot)
// previous_branch is None — that's initial population, not a switch.
if branch_changed {
ctx.emit(DiffStateModelEvent::CurrentBranchChanged);
}
ctx.emit(DiffStateModelEvent::MetadataRefreshed(Box::new(metadata)));
}
fn apply_file_delta(
&mut self,
file_path: String,
diff: Option<FileDiffAndContent>,
metadata: Option<DiffMetadata>,
ctx: &mut ModelContext<Self>,
) {
if let Some(metadata) = &metadata {
self.apply_metadata_update(metadata, ctx);
}
let InternalRemoteDiffState::Loaded(ref mut diffs) = self.state else {
// Ignore file deltas until the initial snapshot has loaded.
return;
};
if let Some(ref new_diff) = diff {
if let Some(pos) = diffs.files.iter().position(|f| f.file_path == file_path) {
diffs.files[pos] = new_diff.file_diff.clone();
} else {
diffs.files.push(new_diff.file_diff.clone());
}
} else {
diffs.files.retain(|f| f.file_path != file_path);
}
diffs.total_additions = diffs.files.iter().map(|f| f.additions()).sum();
diffs.total_deletions = diffs.files.iter().map(|f| f.deletions()).sum();
diffs.files_changed = diffs.files.len();
ctx.emit(DiffStateModelEvent::SingleFileUpdated {
path: file_path,
diff: diff.map(Arc::new),
});
}
// ── Cleanup ──────────────────────────────────────────────────────
/// Sends `UnsubscribeDiffState` to the server. Call before dropping the
/// model (the wrapper calls it during mode switch / pane close).
pub fn unsubscribe(&self, ctx: &mut ModelContext<Self>) {
RemoteServerManager::handle(ctx)
.as_ref(ctx)
.unsubscribe_diff_state(
self.remote_path.host_id.clone(),
&self.remote_path.path,
proto::DiffMode::from(&self.mode),
);
}
// ── Read API (matching LocalDiffStateModel interface) ────────────
pub fn get(&self) -> DiffState {
match &self.state {
InternalRemoteDiffState::NotInRepository => DiffState::NotInRepository,
InternalRemoteDiffState::Loading => DiffState::Loading,
InternalRemoteDiffState::Loaded(_) => DiffState::Loaded,
InternalRemoteDiffState::Error(msg) => DiffState::Error(msg.clone()),
InternalRemoteDiffState::Disconnected => DiffState::Disconnected,
}
}
pub fn diff_mode(&self) -> DiffMode {
self.mode.clone()
}
pub fn get_uncommitted_stats(&self) -> Option<DiffStats> {
self.metadata
.as_ref()
.map(|m| m.against_head.aggregate_stats)
}
/// Per-file entries for uncommitted-vs-HEAD changes, from synced metadata.
pub fn uncommitted_file_entries(&self) -> &[FileChangeEntry] {
self.metadata
.as_ref()
.map(|m| m.against_head.files.as_slice())
.unwrap_or(&[])
}
pub fn get_main_branch_name(&self) -> Option<String> {
self.metadata
.as_ref()
.map(|m| m.main_branch_name.clone())
.filter(|s| !s.is_empty())
}
pub fn get_current_branch_name(&self) -> Option<String> {
self.metadata
.as_ref()
.map(|m| m.current_branch_name.clone())
.filter(|s| !s.is_empty())
}
pub fn is_on_main_branch(&self) -> bool {
self.metadata.as_ref().is_some_and(|m| {
!m.current_branch_name.is_empty() && m.current_branch_name == m.main_branch_name
})
}
pub fn unpushed_commits(&self) -> &[Commit] {
self.metadata
.as_ref()
.map(|m| m.unpushed_commits.as_slice())
.unwrap_or(&[])
}
pub fn upstream_ref(&self) -> Option<&str> {
self.metadata
.as_ref()
.and_then(|m| m.upstream_ref.as_deref())
}
pub fn upstream_differs_from_main(&self) -> bool {
match (self.upstream_ref(), self.get_main_branch_name().as_deref()) {
(Some(upstream), Some(main)) => upstream != main,
_ => false,
}
}
pub fn has_head(&self) -> bool {
self.metadata.as_ref().is_some_and(|m| m.has_head_commit)
}
pub fn remote_path(&self) -> RemotePath {
self.remote_path.clone()
}
// ── Git operation event handlers ─────────────────────────────────
/// Converts a proto `GitOpDelta` to domain types and applies it through
/// the shared `apply_git_op_delta` (the single delta-application path), so
/// the proto-driven and domain-driven callers stay in sync.
fn apply_delta_from_proto(
&mut self,
delta: &remote_server::proto::GitOpDelta,
ctx: &mut ModelContext<Self>,
) {
let commits = delta.unpushed_commits.iter().map(Commit::from).collect();
self.apply_git_op_delta(commits, delta.upstream_ref.clone(), ctx);
}
fn handle_git_commit_chain_response(
&mut self,
result: &Result<remote_server::manager::CommitChainSuccess, String>,
ctx: &mut ModelContext<Self>,
) {
let domain_result = match result {
Ok(success) => {
// Apply the delta before emitting the completion event so the
// header updates immediately. PR info is returned in the event
// result and refreshed through the shared `GitHubRepoModel`.
let commits = success
.delta
.unpushed_commits
.iter()
.map(Commit::from)
.collect();
let pr_info = success.pr_info.as_ref().map(PrInfo::from);
let metadata = self.metadata.get_or_insert_with(DiffMetadata::default);
metadata.unpushed_commits = commits;
metadata.upstream_ref = success.delta.upstream_ref.clone();
ctx.emit(DiffStateModelEvent::MetadataRefreshed(Box::new(
metadata.clone(),
)));
Ok(pr_info)
}
Err(msg) => Err(msg.clone()),
};
ctx.emit(DiffStateModelEvent::GitOpCompleted(
super::GitOpResult::CommitChainCompleted(domain_result),
));
}
fn handle_git_push_response(
&mut self,
result: &Result<remote_server::proto::GitOpDelta, String>,
ctx: &mut ModelContext<Self>,
) {
let domain_result = match result {
Ok(delta) => {
self.apply_delta_from_proto(delta, ctx);
Ok(())
}
Err(msg) => Err(msg.clone()),
};
ctx.emit(DiffStateModelEvent::GitOpCompleted(
super::GitOpResult::PushCompleted(domain_result),
));
}
fn handle_create_pr_response(
&mut self,
result: &Result<remote_server::proto::PrInfo, String>,
ctx: &mut ModelContext<Self>,
) {
let domain_result = match result {
Ok(proto_pr) => Ok(PrInfo::from(proto_pr)),
Err(msg) => Err(msg.clone()),
};
ctx.emit(DiffStateModelEvent::GitOpCompleted(
super::GitOpResult::PrCreated(domain_result),
));
}
/// Handles a `GetCommittedBranchFilesResponse`: converts the proto entries
/// to domain types and emits `BranchCommittedFilesReceived` for the Create
/// PR dialog's Changes box. On error, logs and emits an empty list so the
/// dialog renders an empty box rather than showing stale data.
fn handle_get_committed_branch_files_response(
&self,
result: &Result<Vec<remote_server::proto::FileChangeEntry>, String>,
ctx: &mut ModelContext<Self>,
) {
let files = match result {
Ok(files) => files.iter().map(FileChangeEntry::from).collect(),
Err(msg) => {
log::warn!("RemoteDiffStateModel: GetCommittedBranchFiles failed: {msg}");
Vec::new()
}
};
ctx.emit(DiffStateModelEvent::BranchCommittedFilesReceived(files));
}
// ── Remote git operations (async; results arrive via manager events) ──
//
// Each dispatches via `RemoteServerManager` and returns immediately; the
// response lands as a manager event in `handle_manager_event`, which
// converts it into the corresponding `DiffStateModelEvent`.
/// Runs a commit chain via the remote server manager. The result
/// arrives as a `CommitChainResponse` manager event, handled above.
#[allow(clippy::too_many_arguments)]
pub fn git_commit_chain(
&self,
mode: CommitChainMode,
message: String,
include_unstaged: bool,
branch: String,
autogenerate_pr_content: bool,
ctx: &mut ModelContext<Self>,
) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.git_commit_chain(
host_id,
repo_path,
proto::GitCommitChainMode::from(&mode),
message,
include_unstaged,
branch,
autogenerate_pr_content,
ctx,
);
});
}
/// Issues an AI commit-message generation request via the remote server
/// manager. The result arrives as a `GenerateCommitMessageResponse`
/// manager event, handled in `handle_manager_event`.
pub fn generate_commit_message(
&self,
include_unstaged: bool,
branch_name: String,
ctx: &mut ModelContext<Self>,
) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.git_generate_commit_message(host_id, repo_path, include_unstaged, branch_name, ctx);
});
}
/// Fetches the committed branch files (`merge_base(HEAD, main)..HEAD`) for
/// the current branch via the remote `GitGetCommittedBranchFiles` RPC. The
/// result arrives as a `GetCommittedBranchFilesResponse` manager event,
/// handled in `handle_manager_event`, which emits
/// `BranchCommittedFilesReceived` for the Create PR dialog.
pub fn fetch_committed_branch_files(&self, ctx: &mut ModelContext<Self>) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.git_get_committed_branch_files(host_id, repo_path, ctx);
});
}
/// Pushes the branch via the remote server manager.
pub fn git_push(&self, branch: String, ctx: &mut ModelContext<Self>) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.git_push_branch(host_id, repo_path, branch, ctx);
});
}
/// Creates a PR via the remote server manager. When `autogenerate_content`
/// is set, the daemon AI-generates the PR title/body (falling back to
/// `gh pr create --fill`); `branch` is passed as context for that generation.
#[allow(clippy::too_many_arguments)]
pub fn create_pr(
&self,
branch: String,
autogenerate_content: bool,
ctx: &mut ModelContext<Self>,
) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.git_create_pr(host_id, repo_path, branch, autogenerate_content, ctx);
});
}
// ── Write API ────────────────────────────────────────────────────
pub fn set_diff_mode(
&mut self,
mode: DiffMode,
track_load_duration: bool,
preferred_session: Option<SessionId>,
ctx: &mut ModelContext<Self>,
) {
if self.mode == mode {
return;
}
// Unsubscribe from the old mode before switching, then re-send
// GetDiffState for the new mode over `preferred_session` (the
// triggering view's session) when provided, else any connected
// session for the host.
self.unsubscribe(ctx);
self.mode = mode;
self.resubscribe(track_load_duration, preferred_session, ctx);
}
/// Fetches branches for the remote repository via the `GetBranches` RPC.
/// The response is handled in `handle_manager_event` which emits
/// `DiffStateModelEvent::BranchesReceived`.
pub fn fetch_branches(&self, ctx: &mut ModelContext<Self>) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.get_branches(host_id, repo_path, None, false, ctx);
});
}
/// Sends a `DiscardFiles` request to the remote server.
/// The server's watcher will push updated diff snapshots on success.
pub fn discard_files(
&self,
file_infos: Vec<super::FileStatusInfo>,
should_stash: bool,
branch_name: Option<String>,
ctx: &mut ModelContext<Self>,
) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
let mode = self.mode.clone();
let proto_files = file_infos.iter().map(proto::FileStatusInfo::from).collect();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.discard_files(
host_id,
repo_path,
proto_files,
should_stash,
branch_name,
proto::DiffMode::from(&mode),
ctx,
);
});
}
/// Applies a post-git-operation delta (refreshed unpushed commits +
/// upstream ref returned by the daemon) to the cached metadata and emits
/// `MetadataRefreshed`, so the code review header updates immediately
/// rather than waiting for the next server-pushed snapshot.
pub fn apply_git_op_delta(
&mut self,
unpushed_commits: Vec<Commit>,
upstream_ref: Option<String>,
ctx: &mut ModelContext<Self>,
) {
let metadata = self.metadata.get_or_insert_with(DiffMetadata::default);
metadata.unpushed_commits = unpushed_commits;
metadata.upstream_ref = upstream_ref;
ctx.emit(DiffStateModelEvent::MetadataRefreshed(Box::new(
metadata.clone(),
)));
}
}
#[cfg(test)]
#[path = "remote_tests.rs"]
mod remote_tests;
@@ -0,0 +1,915 @@
use std::sync::{Arc, Mutex};
use remote_server::manager::RemoteServerManagerEvent;
use warp_util::remote_path::RemotePath;
use super::InternalRemoteDiffState;
use crate::auth::AuthStateProvider;
use crate::code_review::diff_size_limits::DiffSize;
use crate::code_review::diff_state::{
DiffHunk, DiffLine, DiffLineType, DiffMetadata, DiffMetadataAgainstBase, DiffMode, DiffState,
DiffStateModelEvent, DiffStats, FileDiff, FileDiffAndContent, GitDiffData,
GitDiffWithBaseContent, GitFileStatus, RemoteDiffStateModel,
};
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
use crate::util::git::Commit;
impl RemoteDiffStateModel {
fn new_for_test(
mode: DiffMode,
state: InternalRemoteDiffState,
metadata: Option<DiffMetadata>,
) -> Self {
Self {
remote_path: RemotePath::new(
remote_server::HostId::new("test-host".to_string()),
warp_util::standardized_path::StandardizedPath::try_new("/test/repo")
.expect("test repo path should be valid and absolute"),
),
mode,
state,
metadata,
tracked_diff_load_start_time: None,
}
}
}
fn empty_metadata(branch: &str) -> DiffMetadata {
DiffMetadata {
main_branch_name: "main".to_string(),
current_branch_name: branch.to_string(),
against_head: DiffMetadataAgainstBase {
aggregate_stats: DiffStats::default(),
files: vec![],
},
against_base_branch: None,
has_head_commit: true,
unpushed_commits: vec![],
upstream_ref: None,
}
}
/// Snapshot inputs grouped for ergonomic test construction. Mirrors the
/// argument list of `RemoteDiffStateModel::apply_snapshot`.
struct SnapshotInputs {
metadata: Option<DiffMetadata>,
state: DiffState,
diffs: Option<GitDiffWithBaseContent>,
}
fn loaded_snapshot_with_files(files: Vec<FileDiffAndContent>) -> SnapshotInputs {
let files_changed = files.len();
SnapshotInputs {
metadata: Some(empty_metadata("feature")),
state: DiffState::Loaded,
diffs: Some(GitDiffWithBaseContent {
files,
total_additions: 1,
total_deletions: 0,
files_changed,
}),
}
}
fn simple_file(path: &str) -> FileDiffAndContent {
simple_file_with_content(path, None)
}
fn simple_file_with_content(path: &str, content_at_base: Option<&str>) -> FileDiffAndContent {
FileDiffAndContent {
file_diff: FileDiff {
file_path: path.to_string(),
status: GitFileStatus::Modified,
hunks: Arc::new(vec![DiffHunk {
old_start_line: 1,
old_line_count: 1,
new_start_line: 1,
new_line_count: 2,
lines: vec![DiffLine {
line_type: DiffLineType::Add,
old_line_number: None,
new_line_number: Some(2),
text: "+new line".to_string(),
no_trailing_newline: false,
}],
unified_diff_start: 0,
unified_diff_end: 1,
}]),
is_binary: false,
is_autogenerated: false,
max_line_number: 10,
has_hidden_bidi_chars: false,
size: DiffSize::Normal,
},
content_at_head: content_at_base.map(str::to_string),
}
}
fn test_metadata(branch: &str) -> DiffMetadata {
DiffMetadata {
main_branch_name: "main".to_string(),
current_branch_name: branch.to_string(),
against_head: DiffMetadataAgainstBase {
aggregate_stats: DiffStats {
files_changed: 1,
total_additions: 5,
total_deletions: 2,
},
files: vec![],
},
against_base_branch: None,
has_head_commit: true,
unpushed_commits: vec![Commit {
hash: "abc123".to_string(),
subject: "test commit".to_string(),
files_changed: 1,
additions: 5,
deletions: 2,
files: vec![],
}],
upstream_ref: Some("origin/feature".to_string()),
}
}
fn initialize_test_app(app: &mut warpui::App) {
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
}
#[test]
fn apply_snapshot_loaded_with_diffs() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
let SnapshotInputs {
metadata,
state,
diffs,
} = loaded_snapshot_with_files(vec![simple_file("src/main.rs")]);
handle.update(&mut app, |m, ctx| {
m.apply_snapshot(metadata, state, diffs, ctx)
});
handle.read(&app, |m, _| {
assert!(matches!(m.get(), DiffState::Loaded));
assert!(m.metadata.is_some());
});
});
}
#[test]
fn get_committed_branch_files_response_emits_domain_files() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
let emitted = Arc::new(Mutex::new(Vec::new()));
{
let emitted = emitted.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&handle, move |_, event, _| {
if let DiffStateModelEvent::BranchCommittedFilesReceived(files) = event {
emitted
.lock()
.expect("emitted mutex should not be poisoned")
.push(files.clone());
}
});
});
}
// Success: proto entries are converted to domain entries and emitted.
let proto_files = vec![remote_server::proto::FileChangeEntry {
path: "src/main.rs".to_string(),
additions: 3,
deletions: 1,
}];
handle.update(&mut app, |m, ctx| {
m.handle_get_committed_branch_files_response(&Ok(proto_files), ctx);
});
// Error: an empty list is emitted so the dialog shows an empty box
// rather than stale data.
handle.update(&mut app, |m, ctx| {
m.handle_get_committed_branch_files_response(&Err("boom".to_string()), ctx);
});
let emitted = emitted
.lock()
.expect("emitted mutex should not be poisoned");
assert_eq!(emitted.len(), 2);
assert_eq!(emitted[0].len(), 1);
assert_eq!(emitted[0][0].path, "src/main.rs");
assert_eq!(emitted[0][0].additions, 3);
assert_eq!(emitted[0][0].deletions, 1);
assert!(emitted[1].is_empty());
});
}
#[test]
fn apply_snapshot_loaded_preserves_content_at_base_in_event() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
let emitted_content = Arc::new(Mutex::new(Vec::new()));
{
let emitted_content = emitted_content.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&handle, move |_, event, _| {
if let DiffStateModelEvent::NewDiffsComputed {
diffs: Some(diffs), ..
} = event
{
emitted_content
.lock()
.expect("emitted content mutex should not be poisoned")
.push(
diffs
.files
.first()
.and_then(|file| file.content_at_head.clone()),
);
}
});
});
}
let SnapshotInputs {
metadata,
state,
diffs,
} = loaded_snapshot_with_files(vec![simple_file_with_content(
"src/main.rs",
Some("base content"),
)]);
handle.update(&mut app, |m, ctx| {
m.apply_snapshot(metadata, state, diffs, ctx)
});
assert_eq!(
emitted_content
.lock()
.expect("emitted content mutex should not be poisoned")
.as_slice(),
&[Some("base content".to_string())]
);
});
}
#[test]
fn apply_snapshot_loaded_without_diffs_becomes_error() {
warpui::App::test((), |mut app| async move {
initialize_test_app(&mut app);
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
handle.update(&mut app, |m, ctx| {
m.apply_snapshot(None, DiffState::Loaded, None, ctx)
});
assert!(matches!(
handle.read(&app, |m, _| m.get()),
DiffState::Error(_)
));
});
}
#[test]
fn apply_snapshot_not_in_repository() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
handle.update(&mut app, |m, ctx| {
m.apply_snapshot(None, DiffState::NotInRepository, None, ctx)
});
assert!(matches!(
handle.read(&app, |m, _| m.get()),
DiffState::NotInRepository
));
});
}
#[test]
fn apply_snapshot_error_stores_message() {
warpui::App::test((), |mut app| async move {
initialize_test_app(&mut app);
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
handle.update(&mut app, |m, ctx| {
m.apply_snapshot(None, DiffState::Error("git failed".to_string()), None, ctx)
});
assert!(
matches!(handle.read(&app, |m, _| m.get()), DiffState::Error(ref msg) if msg == "git failed")
);
});
}
#[test]
fn mark_disconnected_transitions_state_and_emits_connection_lost() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
let connection_lost_count = Arc::new(Mutex::new(0));
{
let connection_lost_count = connection_lost_count.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&handle, move |_, event, _| {
if matches!(event, DiffStateModelEvent::ConnectionLost) {
*connection_lost_count
.lock()
.expect("connection lost count mutex should not be poisoned") += 1;
}
});
});
}
handle.update(&mut app, |m, ctx| m.mark_disconnected(ctx));
handle.read(&app, |m, _| {
assert!(matches!(m.get(), DiffState::Disconnected));
});
assert_eq!(
*connection_lost_count
.lock()
.expect("connection lost count mutex should not be poisoned"),
1
);
// Idempotent: a second call should not re-emit ConnectionLost.
handle.update(&mut app, |m, ctx| m.mark_disconnected(ctx));
assert_eq!(
*connection_lost_count
.lock()
.expect("connection lost count mutex should not be poisoned"),
1
);
});
}
#[test]
fn apply_metadata_first_time_sets_branch() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
let meta = empty_metadata("feature");
handle.update(&mut app, |m, ctx| m.apply_metadata_update(&meta, ctx));
assert_eq!(
handle
.read(&app, |m, _| m.get_current_branch_name())
.as_deref(),
Some("feature")
);
});
}
#[test]
fn apply_metadata_branch_change_updates_branch() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
Some(test_metadata("feature-a")),
)
});
let meta = empty_metadata("feature-b");
handle.update(&mut app, |m, ctx| m.apply_metadata_update(&meta, ctx));
assert_eq!(
handle
.read(&app, |m, _| m.get_current_branch_name())
.as_deref(),
Some("feature-b")
);
});
}
#[test]
fn apply_file_delta_ignored_when_not_loaded() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
let file_path = "src/main.rs".to_string();
let diff = Some(simple_file("src/main.rs"));
handle.update(&mut app, |m, ctx| {
m.apply_file_delta(file_path, diff, None, ctx)
});
assert!(matches!(
handle.read(&app, |m, _| m.get()),
DiffState::Loading
));
});
}
#[test]
fn apply_file_delta_adds_file() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loaded(GitDiffData {
files: vec![],
total_additions: 0,
total_deletions: 0,
files_changed: 0,
}),
None,
)
});
let file_path = "src/new.rs".to_string();
let diff = Some(simple_file("src/new.rs"));
handle.update(&mut app, |m, ctx| {
m.apply_file_delta(file_path, diff, None, ctx)
});
assert!(matches!(
handle.read(&app, |m, _| m.get()),
DiffState::Loaded
));
});
}
#[test]
fn apply_snapshot_preserves_repo_relative_file_paths() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
let SnapshotInputs {
metadata,
state,
diffs,
} = loaded_snapshot_with_files(vec![simple_file("src/main.rs")]);
handle.update(&mut app, |m, ctx| {
m.apply_snapshot(metadata, state, diffs, ctx)
});
handle.read(&app, |m, _| {
let InternalRemoteDiffState::Loaded(diffs) = &m.state else {
panic!("state should be Loaded");
};
assert_eq!(diffs.files.len(), 1);
assert_eq!(diffs.files[0].file_path, "src/main.rs");
});
});
}
#[test]
fn apply_snapshot_emits_event_with_repo_relative_paths() {
// Subscribers to NewDiffsComputed should see repo-relative paths so they
// can index into the loaded state by the same key.
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
let emitted_paths = Arc::new(Mutex::new(Vec::new()));
{
let emitted_paths = emitted_paths.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&handle, move |_, event, _| {
if let DiffStateModelEvent::NewDiffsComputed {
diffs: Some(diffs), ..
} = event
{
emitted_paths
.lock()
.expect("emitted paths mutex should not be poisoned")
.extend(
diffs
.files
.iter()
.map(|file| file.file_diff.file_path.clone()),
);
}
});
});
}
let SnapshotInputs {
metadata,
state,
diffs,
} = loaded_snapshot_with_files(vec![simple_file("src/main.rs")]);
handle.update(&mut app, |m, ctx| {
m.apply_snapshot(metadata, state, diffs, ctx)
});
assert_eq!(
emitted_paths
.lock()
.expect("emitted paths mutex should not be poisoned")
.as_slice(),
&[String::from("src/main.rs")]
);
});
}
#[test]
fn apply_file_delta_preserves_repo_relative_file_path() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loaded(GitDiffData {
files: vec![],
total_additions: 0,
total_deletions: 0,
files_changed: 0,
}),
None,
)
});
let file_path = "src/new.rs".to_string();
let diff = Some(simple_file("src/new.rs"));
handle.update(&mut app, |m, ctx| {
m.apply_file_delta(file_path, diff, None, ctx)
});
handle.read(&app, |m, _| {
let InternalRemoteDiffState::Loaded(diffs) = &m.state else {
panic!("state should be Loaded");
};
assert_eq!(diffs.files.len(), 1);
assert_eq!(diffs.files[0].file_path, "src/new.rs");
});
});
}
#[test]
fn apply_file_delta_emits_event_with_repo_relative_path() {
// The SingleFileUpdated event payload should also use the repo-relative
// path so subscribers can match against the stored state by key.
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loaded(GitDiffData {
files: vec![],
total_additions: 0,
total_deletions: 0,
files_changed: 0,
}),
None,
)
});
let emitted_paths = Arc::new(Mutex::new(Vec::new()));
{
let emitted_paths = emitted_paths.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&handle, move |_, event, _| {
if let DiffStateModelEvent::SingleFileUpdated { path, .. } = event {
emitted_paths
.lock()
.expect("emitted paths mutex should not be poisoned")
.push(path.clone());
}
});
});
}
let file_path = "src/new.rs".to_string();
let diff = Some(simple_file("src/new.rs"));
handle.update(&mut app, |m, ctx| {
m.apply_file_delta(file_path, diff, None, ctx)
});
assert_eq!(
emitted_paths
.lock()
.expect("emitted paths mutex should not be poisoned")
.as_slice(),
&[String::from("src/new.rs")]
);
});
}
#[test]
fn apply_file_delta_preserves_content_at_base_in_event() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loaded(GitDiffData {
files: vec![],
total_additions: 0,
total_deletions: 0,
files_changed: 0,
}),
None,
)
});
let emitted_content = Arc::new(Mutex::new(Vec::new()));
{
let emitted_content = emitted_content.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&handle, move |_, event, _| {
if let DiffStateModelEvent::SingleFileUpdated {
diff: Some(diff), ..
} = event
{
emitted_content
.lock()
.expect("emitted content mutex should not be poisoned")
.push(diff.content_at_head.clone());
}
});
});
}
let file_path = "src/new.rs".to_string();
let diff = Some(simple_file_with_content(
"src/new.rs",
Some("old file content"),
));
handle.update(&mut app, |m, ctx| {
m.apply_file_delta(file_path, diff, None, ctx)
});
assert_eq!(
emitted_content
.lock()
.expect("emitted content mutex should not be poisoned")
.as_slice(),
&[Some("old file content".to_string())]
);
});
}
#[test]
fn apply_file_delta_none_removes_file() {
warpui::App::test((), |mut app| async move {
let existing = GitDiffData {
files: vec![FileDiff {
file_path: "src/old.rs".to_string(),
status: GitFileStatus::Modified,
hunks: Arc::new(vec![]),
is_binary: false,
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
size: DiffSize::Normal,
}],
total_additions: 0,
total_deletions: 0,
files_changed: 1,
};
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loaded(existing),
None,
)
});
let file_path = "src/old.rs".to_string();
handle.update(&mut app, |m, ctx| {
m.apply_file_delta(file_path, None, None, ctx)
});
handle.read(&app, |m, _| {
let InternalRemoteDiffState::Loaded(diffs) = &m.state else {
panic!("state should remain loaded");
};
assert!(diffs.files.is_empty());
});
});
}
#[test]
fn read_api_with_metadata() {
let m = RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
Some(test_metadata("feature")),
);
assert_eq!(m.get_main_branch_name().as_deref(), Some("main"));
assert_eq!(m.get_current_branch_name().as_deref(), Some("feature"));
assert!(!m.is_on_main_branch());
assert_eq!(m.unpushed_commits().len(), 1);
assert_eq!(m.upstream_ref(), Some("origin/feature"));
assert!(m.upstream_differs_from_main());
assert!(m.has_head());
assert_eq!(
m.get_uncommitted_stats()
.expect("uncommitted stats should be present")
.total_additions,
5
);
}
#[test]
fn read_api_defaults_without_metadata() {
let m =
RemoteDiffStateModel::new_for_test(DiffMode::Head, InternalRemoteDiffState::Loading, None);
assert_eq!(m.get_main_branch_name(), None);
assert_eq!(m.get_current_branch_name(), None);
assert!(!m.is_on_main_branch());
assert!(m.unpushed_commits().is_empty());
assert!(m.upstream_ref().is_none());
assert!(!m.upstream_differs_from_main());
assert!(!m.has_head());
assert!(m.get_uncommitted_stats().is_none());
}
#[test]
fn is_on_main_branch_true_when_matching() {
let mut meta = test_metadata("main");
meta.current_branch_name = "main".into();
let m = RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
Some(meta),
);
assert!(m.is_on_main_branch());
}
#[test]
fn get_returns_each_state_variant() {
assert!(matches!(
RemoteDiffStateModel::new_for_test(DiffMode::Head, InternalRemoteDiffState::Loading, None)
.get(),
DiffState::Loading
));
assert!(matches!(
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::NotInRepository,
None
)
.get(),
DiffState::NotInRepository
));
assert!(matches!(
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Error("x".into()),
None
)
.get(),
DiffState::Error(_)
));
assert!(matches!(
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Disconnected,
None
)
.get(),
DiffState::Disconnected
));
}
#[test]
fn diff_mode_preserved() {
let m = RemoteDiffStateModel::new_for_test(
DiffMode::OtherBranch("develop".into()),
InternalRemoteDiffState::Loading,
None,
);
assert_eq!(m.diff_mode(), DiffMode::OtherBranch("develop".into()));
}
#[test]
fn empty_branch_names_become_none() {
let mut meta = test_metadata("");
meta.main_branch_name = String::new();
meta.current_branch_name = String::new();
let m = RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
Some(meta),
);
assert_eq!(m.get_main_branch_name(), None);
assert_eq!(m.get_current_branch_name(), None);
}
#[test]
fn host_disconnected_for_matching_host_transitions_to_disconnected() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
let connection_lost_count = Arc::new(Mutex::new(0));
{
let connection_lost_count = connection_lost_count.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&handle, move |_, event, _| {
if matches!(event, DiffStateModelEvent::ConnectionLost) {
*connection_lost_count
.lock()
.expect("connection lost count mutex should not be poisoned") += 1;
}
});
});
}
let event = RemoteServerManagerEvent::HostDisconnected {
host_id: remote_server::HostId::new("test-host".to_string()),
};
handle.update(&mut app, |m, ctx| m.handle_manager_event(&event, ctx));
handle.read(&app, |m, _| {
assert!(matches!(m.get(), DiffState::Disconnected));
});
assert_eq!(
*connection_lost_count
.lock()
.expect("connection lost count mutex should not be poisoned"),
1
);
});
}
#[test]
fn host_disconnected_for_other_host_is_ignored() {
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
let event = RemoteServerManagerEvent::HostDisconnected {
host_id: remote_server::HostId::new("other-host".to_string()),
};
handle.update(&mut app, |m, ctx| m.handle_manager_event(&event, ctx));
handle.read(&app, |m, _| {
assert!(matches!(m.get(), DiffState::Loading));
});
});
}
#[test]
fn session_disconnected_is_ignored_by_session_agnostic_model() {
// Per-session lifecycle events are no longer the model's concern; the
// manager picks a connected client at RPC dispatch time and only
// host-level connect/disconnect drive state transitions.
warpui::App::test((), |mut app| async move {
let handle = app.add_model(|_ctx| {
RemoteDiffStateModel::new_for_test(
DiffMode::Head,
InternalRemoteDiffState::Loading,
None,
)
});
let event = RemoteServerManagerEvent::SessionDisconnected {
session_id: galaxy_core::SessionId::default(),
host_id: remote_server::HostId::new("test-host".to_string()),
exit_status: None,
was_reconnect_attempt: false,
};
handle.update(&mut app, |m, ctx| m.handle_manager_event(&event, ctx));
handle.read(&app, |m, _| {
assert!(matches!(m.get(), DiffState::Loading));
});
});
}
-238
View File
@@ -1,238 +0,0 @@
use super::*;
#[test]
fn test_parse_range_with_comma() {
let (start, count) = DiffStateModel::parse_range("10,5")
.expect("parse_range should succeed for range with count");
assert_eq!(start, 10);
assert_eq!(count, 5);
}
#[test]
fn test_parse_range_without_comma() {
let (start, count) = DiffStateModel::parse_range("10")
.expect("parse_range should succeed for range without count");
assert_eq!(start, 10);
assert_eq!(count, 1);
}
#[test]
fn test_parse_unified_diff_header_basic() {
let header = "@@ -10,5 +12,7 @@";
let parsed = DiffStateModel::parse_unified_diff_header(header)
.expect("parse_unified_diff_header should succeed for basic header");
assert_eq!(parsed.old_start_line, 10);
assert_eq!(parsed.old_line_count, 5);
assert_eq!(parsed.new_start_line, 12);
assert_eq!(parsed.new_line_count, 7);
}
#[test]
fn test_parse_unified_diff_header_with_context() {
let header = "@@ -4978,33 +4978,43 @@ impl TerminalView {";
let parsed = DiffStateModel::parse_unified_diff_header(header)
.expect("parse_unified_diff_header should succeed for header with context");
assert_eq!(parsed.old_start_line, 4978);
assert_eq!(parsed.old_line_count, 33);
assert_eq!(parsed.new_start_line, 4978);
assert_eq!(parsed.new_line_count, 43);
}
#[test]
fn test_parse_unified_diff_header_single_line() {
let header = "@@ -10 +12,3 @@";
let parsed = DiffStateModel::parse_unified_diff_header(header)
.expect("parse_unified_diff_header should succeed for single line header");
assert_eq!(parsed.old_start_line, 10);
assert_eq!(parsed.old_line_count, 1);
assert_eq!(parsed.new_start_line, 12);
assert_eq!(parsed.new_line_count, 3);
}
#[test]
fn test_sort_branches_main_first_empty() {
let branches: Vec<(String, bool)> = vec![];
let result: Vec<_> = DiffStateModel::sort_branches_main_first(&branches).collect();
assert!(result.is_empty());
}
#[test]
fn test_sort_branches_main_first_no_main() {
let branches = vec![
("feature-a".to_string(), false),
("feature-b".to_string(), false),
("feature-c".to_string(), false),
];
let result: Vec<_> = DiffStateModel::sort_branches_main_first(&branches).collect();
// No main branches — order should be unchanged.
assert_eq!(result, branches.iter().collect::<Vec<_>>());
}
#[test]
fn test_sort_branches_main_first_promotes_main() {
let branches = vec![
("feature-a".to_string(), false),
("main".to_string(), true),
("feature-b".to_string(), false),
];
let result: Vec<_> = DiffStateModel::sort_branches_main_first(&branches)
.map(|(name, _)| name.as_str())
.collect();
assert_eq!(result, vec!["main", "feature-a", "feature-b"]);
}
#[test]
fn test_sort_branches_main_first_main_already_first() {
let branches = vec![
("main".to_string(), true),
("feature-a".to_string(), false),
("feature-b".to_string(), false),
];
let result: Vec<_> = DiffStateModel::sort_branches_main_first(&branches)
.map(|(name, _)| name.as_str())
.collect();
assert_eq!(result, vec!["main", "feature-a", "feature-b"]);
}
#[test]
fn test_sort_branches_main_first_preserves_recency_order_for_non_main() {
// Non-main branches should remain in their original (recency) order.
let branches = vec![
("recent-feature".to_string(), false),
("main".to_string(), true),
("older-feature".to_string(), false),
("oldest-feature".to_string(), false),
];
let result: Vec<_> = DiffStateModel::sort_branches_main_first(&branches)
.map(|(name, _)| name.as_str())
.collect();
assert_eq!(
result,
vec!["main", "recent-feature", "older-feature", "oldest-feature"]
);
}
#[test]
fn test_sort_branches_main_first_multiple_main_flags() {
// Defensive: both flagged as main (shouldn't happen in practice, but
// sort_branches_main_first should handle it gracefully).
let branches = vec![
("feature".to_string(), false),
("main".to_string(), true),
("master".to_string(), true),
];
let result: Vec<_> = DiffStateModel::sort_branches_main_first(&branches)
.map(|(name, _)| name.as_str())
.collect();
// Both main-flagged entries appear first, non-main last.
assert_eq!(result, vec!["main", "master", "feature"]);
}
#[test]
fn test_parse_unified_diff_header_malformed() {
let header = "not a diff header";
let result = DiffStateModel::parse_unified_diff_header(header);
assert!(result.is_err());
let header2 = "@@ incomplete";
let result2 = DiffStateModel::parse_unified_diff_header(header2);
assert!(result2.is_err());
}
#[test]
fn test_parse_git_status_modified_file_with_spaces() {
// Porcelain v2 output for a modified file with spaces in the name.
// Format: 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
let status_output = "1 .M N... 100644 100644 100644 abc1234 def5678 test file.txt";
let result = DiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, std::path::PathBuf::from("test file.txt"));
assert_eq!(result[0].1, GitFileStatus::Modified);
}
#[test]
fn test_parse_git_status_modified_file_with_multiple_spaces() {
// Filename with multiple spaces.
let status_output = "1 .M N... 100644 100644 100644 abc1234 def5678 path to/my test file.txt";
let result = DiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
result[0].0,
std::path::PathBuf::from("path to/my test file.txt")
);
assert_eq!(result[0].1, GitFileStatus::Modified);
}
#[test]
fn test_parse_git_status_new_file_with_spaces() {
let status_output = "1 A. N... 000000 100644 100644 0000000 abc1234 new file name.rs";
let result = DiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, std::path::PathBuf::from("new file name.rs"));
assert_eq!(result[0].1, GitFileStatus::New);
}
#[test]
fn test_parse_git_status_renamed_file_with_spaces() {
// Porcelain v2 renamed entry (type 2) with spaces in the new path.
// Format: 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>\0<origPath>
let status_output =
"2 R. N... 100644 100644 100644 abc1234 def5678 R100 new name.txt\0old name.txt";
let result = DiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, std::path::PathBuf::from("new name.txt"));
assert!(matches!(
&result[0].1,
GitFileStatus::Renamed { old_path } if old_path == "old name.txt"
));
}
#[test]
fn test_parse_git_status_untracked_file_with_spaces() {
let status_output = "? my untracked file.txt";
let result = DiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
result[0].0,
std::path::PathBuf::from("my untracked file.txt")
);
assert_eq!(result[0].1, GitFileStatus::Untracked);
}
#[test]
fn test_parse_git_status_unmerged_file_with_spaces() {
// Porcelain v2 unmerged entry (type u) with spaces in the path.
// Format: u <xy> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
let status_output =
"u UU N... 100644 100644 100644 100644 abc1234 def5678 ghi9012 conflict file.txt";
let result = DiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, std::path::PathBuf::from("conflict file.txt"));
assert_eq!(result[0].1, GitFileStatus::Conflicted);
}
#[test]
fn test_parse_git_status_mixed_entries_with_spaces() {
// Multiple entries separated by NUL, mixing files with and without spaces.
let status_output = "1 .M N... 100644 100644 100644 abc1234 def5678 test file.txt\0\
1 .M N... 100644 100644 100644 abc1234 def5678 normal.txt\0\
? another file with spaces.rs";
let result = DiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].0, std::path::PathBuf::from("test file.txt"));
assert_eq!(result[1].0, std::path::PathBuf::from("normal.txt"));
assert_eq!(
result[2].0,
std::path::PathBuf::from("another file with spaces.rs")
);
}
#[test]
fn test_parse_git_status_file_without_spaces_still_works() {
// Ensure the splitn change doesn't break files without spaces.
let status_output = "1 .M N... 100644 100644 100644 abc1234 def5678 simple.txt";
let result = DiffStateModel::parse_git_status(status_output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, std::path::PathBuf::from("simple.txt"));
assert_eq!(result[0].1, GitFileStatus::Modified);
}
+22 -22
View File
@@ -1,31 +1,23 @@
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use galaxy_core::sync_queue::{IsTransientError, SyncQueueTaskTrait};
use galaxy_core::sync_queue::SyncQueueTaskTrait;
use super::diff_state::{DiffMode, DiffStateModel, FileDiffAndContent};
use super::diff_state::{DiffMode, DiffStateError, FileDiffAndContent, LocalDiffStateModel};
#[derive(Debug, thiserror::Error)]
#[error(transparent)]
pub struct FileInvalidationError(#[from] anyhow::Error);
impl IsTransientError for FileInvalidationError {
fn is_transient(&self) -> bool {
true
}
}
pub struct FileInvalidationTask {
pub file: PathBuf,
pub repo_path: PathBuf,
pub mode: DiffMode,
pub merge_base: Option<String>,
pub(crate) struct FileInvalidationTask {
pub(crate) file: PathBuf,
pub(crate) repo_path: PathBuf,
pub(crate) mode: DiffMode,
pub(crate) merge_base: Option<String>,
}
impl SyncQueueTaskTrait for FileInvalidationTask {
type Error = FileInvalidationError;
type Result = (PathBuf, Option<FileDiffAndContent>);
type Error = DiffStateError;
/// The first element is the repo-relative path of the updated file.
type Result = (String, Option<Arc<FileDiffAndContent>>);
#[cfg(not(target_arch = "wasm32"))]
type Fut = Pin<Box<dyn Future<Output = Result<Self::Result, Self::Error>> + Send>>;
#[cfg(target_arch = "wasm32")]
@@ -37,9 +29,17 @@ impl SyncQueueTaskTrait for FileInvalidationTask {
let mode = self.mode.clone();
let merge_base = self.merge_base.clone();
Box::pin(async move {
DiffStateModel::retrieve_diff_state(&repo_path, &file, &mode, merge_base.as_deref())
.await
.map_err(FileInvalidationError::from)
// File invalidation runs local git commands against a local repo path,
// so using LocalDiffStateModel directly is correct — remote repos use a
// separate mechanism and never go through this queue.
LocalDiffStateModel::retrieve_diff_state(
&repo_path,
&file,
&mode,
merge_base.as_deref(),
)
.await
.map_err(DiffStateError::from)
})
}
}
+26 -12
View File
@@ -1,7 +1,7 @@
use crate::code::local_code_editor::LocalCodeEditorView;
use crate::code_review::code_review_view::CodeReviewView;
use crate::code_review::telemetry_event::CodeReviewTelemetryEvent;
use crate::view_components::find::{FindDirection, FindEvent, FindModel};
use std::collections::HashMap;
use std::ops::Range;
use string_offset::CharOffset;
#[cfg(not(target_family = "wasm"))]
use galaxy_core::channel::ChannelState;
use galaxy_core::send_telemetry_from_ctx;
@@ -10,13 +10,13 @@ use galaxy_editor::content::find::SearchConfig;
#[cfg(not(target_family = "wasm"))]
use galaxy_editor::search::Searcher;
use galaxy_editor::search::{RestorableSearchResults, SelectedResult};
use galaxyui::WeakViewHandle;
use galaxyui::{
r#async::SpawnedFutureHandle, AppContext, Entity, EntityId, ModelContext, ViewHandle,
};
use std::collections::HashMap;
use std::ops::Range;
use string_offset::CharOffset;
use galaxyui::r#async::SpawnedFutureHandle;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ViewHandle, WeakViewHandle};
use crate::code::local_code_editor::LocalCodeEditorView;
use crate::code_review::code_review_view::CodeReviewView;
use crate::code_review::telemetry_event::CodeReviewTelemetryEvent;
use crate::view_components::find::{FindDirection, FindEvent, FindModel};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchMatch {
@@ -97,6 +97,12 @@ impl CodeReviewFindModel {
self.results = None;
}
fn repo_is_local(&self, ctx: &AppContext) -> Option<bool> {
self.weak_view_handle
.upgrade(ctx)
.and_then(|view| view.as_ref(ctx).repo_is_local())
}
pub fn update_query(
&mut self,
query: Option<String>,
@@ -116,6 +122,7 @@ impl CodeReviewFindModel {
self.case_sensitive = case_sensitive;
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::FindBarModeChanged {
is_local: self.repo_is_local(ctx),
case_sensitive: self.case_sensitive,
regex: self.regex,
},
@@ -133,6 +140,7 @@ impl CodeReviewFindModel {
self.regex = regex;
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::FindBarModeChanged {
is_local: self.repo_is_local(ctx),
case_sensitive: self.case_sensitive,
regex: self.regex,
},
@@ -156,7 +164,13 @@ impl CodeReviewFindModel {
return;
}
send_telemetry_from_ctx!(CodeReviewTelemetryEvent::FindNavigated { direction }, ctx);
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::FindNavigated {
is_local: self.repo_is_local(ctx),
direction,
},
ctx
);
let next_index = if let Some(selected) = &self.selected_match {
match direction {
+29 -15
View File
@@ -1,13 +1,29 @@
use std::path::PathBuf;
use std::sync::Arc;
use repo_metadata::repositories::DetectedRepositories;
use string_offset::CharOffset;
use galaxy_core::ui::appearance::Appearance;
use warp_editor::content::buffer::InitialBufferState;
use warp_editor::render::element::VerticalExpansionBehavior;
use warpui::elements::Empty;
use warpui::platform::WindowStyle;
use warpui::{App, Element as _, ModelHandle, SingletonEntity, ViewHandle};
use super::*;
use crate::ai::request_usage_model::AIRequestUsageModel;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code::editor::view::{CodeEditorRenderOptions, CodeEditorView};
use crate::code::local_code_editor::LocalCodeEditorView;
use crate::code_review::code_review_view::CodeReviewView;
use crate::code_review::diff_state::DiffStateModel;
use crate::code_review::GlobalCodeReviewModel;
use crate::pane_group::WorkingDirectoriesModel;
use crate::server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient};
use crate::server::server_api::team::MockTeamClient;
use crate::server::server_api::workspace::MockWorkspaceClient;
use crate::server::server_api::ServerApiProvider;
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::test_util::settings::initialize_settings_for_tests;
@@ -16,16 +32,6 @@ use crate::workspace::sync_inputs::SyncedInputState;
use crate::workspace::ActiveSession;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::NotebookKeybindings;
use galaxy_core::ui::appearance::Appearance;
use galaxy_editor::content::buffer::InitialBufferState;
use galaxy_editor::render::element::VerticalExpansionBehavior;
use galaxyui::elements::Empty;
use galaxyui::platform::WindowStyle;
use galaxyui::{App, Element as _, ModelHandle, ViewHandle};
use repo_metadata::repositories::DetectedRepositories;
use std::path::PathBuf;
use std::sync::Arc;
use string_offset::CharOffset;
#[derive(Default)]
struct TestView;
@@ -166,6 +172,14 @@ fn initialize_test_app(app: &mut App) {
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|_| ActiveSession::default());
app.add_singleton_model(NotebookKeybindings::new);
// CodeReviewView reads AI usage/availability when comments are populated
// (e.g. to compute the comment tray's "Send to Agent" button state), so
// register the same AI singletons the other code_review tests use.
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|ctx| {
AIRequestUsageModel::new_for_test(ServerApiProvider::as_ref(ctx).get_ai_client(), ctx)
});
}
fn create_find_model_with_query(
@@ -176,18 +190,18 @@ fn create_find_model_with_query(
) -> ModelHandle<CodeReviewFindModel> {
let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
let diff_state_model =
app.add_model(|ctx| DiffStateModel::new(Some("/tmp/test".to_string()), ctx));
let diff_state_model = app.add_model(DiffStateModel::new_for_test);
let repo_path = PathBuf::from("/tmp/test");
let working_directories_model = app.add_model(|_| WorkingDirectoriesModel::new());
let repo_key = LocalOrRemotePath::Local(repo_path);
let code_review_comment_batch =
working_directories_model.update(app, |working_directories, ctx| {
working_directories.get_or_create_code_review_comments(repo_path.as_path(), ctx)
working_directories.get_or_create_code_review_comments(&repo_key, ctx)
});
let code_review_view = app.add_view(window_id, |ctx| {
CodeReviewView::new(
Some(repo_path),
Some(repo_key),
diff_state_model,
code_review_comment_batch,
None,
+165
View File
@@ -0,0 +1,165 @@
//! Shared git "action" orchestration: the commit-chain, push, create-PR, and
//! view-PR workflows behind the code-review git buttons.
//!
//! These compose the single-command primitives in [`crate::util::git`] (plus
//! AI title/body generation) into the end-to-end actions a button triggers.
//! They are intentionally backend-agnostic: the local code-review dialog and
//! the remote-server daemon both call them, so local and remote behave
//! identically. Git ops are host-scoped and not tied to a diff-state model, so
//! this logic lives here rather than on a model.
//!
//! Callers own everything *around* the action: UI (toasts, telemetry, dialog
//! lifecycle), transport/model (applying the returned delta to a
//! `DiffStateModel`, building wire responses), and any execution-time guards
//! (e.g. the daemon's `git_operation_in_progress` backstop).
use std::path::Path;
use crate::ai::generate_code_review_content::api::{GenerateCodeReviewContentRequest, OutputType};
use crate::code_review::diff_state::CommitChainMode;
use crate::server::server_api::ai::AIClient;
use crate::util::git::{self, get_branch_commit_messages, get_diff_for_pr, Commit, PrInfo};
/// Runs the commit chain — always commits, then optionally pushes, then
/// optionally creates a PR per `mode` — and returns the post-chain delta
/// (refreshed unpushed commits + upstream ref) plus any created PR. The delta
/// is computed once after the whole chain settles.
///
/// When the chain creates a PR, `ai_client` (when `Some`) generates the
/// title/body with a `--fill` fallback; pass `None` to skip AI entirely.
pub async fn run_commit_chain(
repo_path: &Path,
mode: CommitChainMode,
message: &str,
include_unstaged: bool,
branch: &str,
ai_client: Option<&dyn AIClient>,
path_env: Option<&str>,
) -> anyhow::Result<(Vec<Commit>, Option<String>, Option<PrInfo>)> {
git::run_commit(repo_path, message, include_unstaged, path_env).await?;
let pr_info = match mode {
CommitChainMode::CommitOnly => None,
CommitChainMode::CommitAndPush => {
git::run_push(repo_path, branch, path_env).await?;
None
}
CommitChainMode::CommitAndCreatePr => {
git::run_push(repo_path, branch, path_env).await?;
Some(create_pr(repo_path, branch, ai_client, path_env).await?)
}
};
let (commits, upstream_ref) = git::compute_unpushed_state(repo_path).await;
Ok((commits, upstream_ref, pr_info))
}
/// Pushes `branch` (setting upstream) and returns the refreshed
/// unpushed/upstream delta.
pub async fn run_push(
repo_path: &Path,
branch: &str,
path_env: Option<&str>,
) -> anyhow::Result<(Vec<Commit>, Option<String>)> {
git::run_push(repo_path, branch, path_env).await?;
Ok(git::compute_unpushed_state(repo_path).await)
}
/// Creates a PR for `branch`. When `ai_client` is `Some`, generates the
/// title/body via AI with a `gh pr create --fill` fallback; otherwise creates
/// the PR with `--fill`.
pub async fn create_pr(
repo_path: &Path,
branch: &str,
ai_client: Option<&dyn AIClient>,
path_env: Option<&str>,
) -> anyhow::Result<PrInfo> {
match ai_client {
Some(ai) => create_pr_with_ai_content(repo_path, branch, ai, path_env).await,
None => git::create_pr(repo_path, None, None, path_env).await,
}
}
/// Generates an AI commit message for the working-tree changes.
/// Bails when there's nothing to summarize (empty diff) or the model returns an empty message.
pub async fn generate_commit_message(
repo_path: &Path,
branch_name: &str,
include_unstaged: bool,
ai_client: &dyn AIClient,
) -> anyhow::Result<String> {
let diff = git::get_diff_for_commit_message(repo_path, include_unstaged).await?;
// Skip the AI round trip when there's nothing to summarize.
if diff.trim().is_empty() {
anyhow::bail!("no changes to generate a commit message from");
}
let generated = ai_client
.generate_code_review_content(GenerateCodeReviewContentRequest {
output_type: OutputType::CommitMessage,
diff,
branch_name: branch_name.to_string(),
commit_messages: Vec::new(),
})
.await?
.content;
let trimmed = generated.trim();
if trimmed.is_empty() {
anyhow::bail!("AI returned an empty commit message");
}
Ok(trimmed.to_string())
}
/// Generates PR title and body via AI (in parallel) and creates the PR.
/// Falls back to `gh pr create --fill` if AI generation fails or returns
/// empty content, so AI-assisted and manual PR creation produce PRs the same
/// way.
async fn create_pr_with_ai_content(
repo_path: &Path,
branch_name: &str,
code_review_ai: &dyn AIClient,
path_env: Option<&str>,
) -> anyhow::Result<PrInfo> {
let diff = get_diff_for_pr(repo_path).await?;
let commit_messages = get_branch_commit_messages(repo_path)
.await
.unwrap_or_default();
let title_req = GenerateCodeReviewContentRequest {
output_type: OutputType::PrTitle,
diff: diff.clone(),
branch_name: branch_name.to_string(),
commit_messages: commit_messages.clone(),
};
let body_req = GenerateCodeReviewContentRequest {
output_type: OutputType::PrDescription,
diff,
branch_name: branch_name.to_string(),
commit_messages,
};
match futures::try_join!(
code_review_ai.generate_code_review_content(title_req),
code_review_ai.generate_code_review_content(body_req),
) {
Ok((title_resp, body_resp))
if !title_resp.content.trim().is_empty() && !body_resp.content.trim().is_empty() =>
{
git::create_pr(
repo_path,
Some(&title_resp.content),
Some(&body_resp.content),
path_env,
)
.await
}
Ok(_) => {
// Empty title/body would make `gh pr create` fail; fall back to --fill.
log::warn!(
"AI PR content generation returned empty title/body, falling back to --fill"
);
git::create_pr(repo_path, None, None, path_env).await
}
Err(err) => {
log::warn!("AI PR content generation failed, falling back to --fill: {err}");
git::create_pr(repo_path, None, None, path_env).await
}
}
}
+216 -229
View File
@@ -4,59 +4,37 @@
use std::path::Path;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{
elements::{
ChildView, ClippedScrollStateHandle, Container, CornerRadius, CrossAxisAlignment, Element,
Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Text,
},
ui_components::{
components::{UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
AppContext, SingletonEntity, ViewContext, ViewHandle,
use galaxyui::elements::{
ChildView, ClippedScrollStateHandle, Container, CornerRadius, CrossAxisAlignment, Element,
Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Text,
};
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::ui_components::switch::SwitchStateHandle;
use warpui::{AppContext, SingletonEntity, ViewContext, ViewHandle};
use crate::{
ai::generate_code_review_content::api::{GenerateCodeReviewContentRequest, OutputType},
code_review::git_dialog::{
interactive_path_future,
pr::{create_pr_with_ai_content, show_pr_created_toast},
render_branch_section, render_file_changes_box, should_send_git_ops_ai_request, show_toast,
user_facing_git_error, GitDialog, GitDialogAction, GitDialogEvent, GitDialogMode,
},
editor::{
EditorOptions, EditorView, Event as EditorEvent, InteractionState,
PropagateAndNoOpNavigationKeys, TextOptions,
},
server::server_api::ServerApiProvider,
ui_components::icons::Icon,
util::git::{FileChangeEntry, PrInfo},
view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme},
use crate::code_review::diff_state::CommitChainMode;
use crate::code_review::git_dialog::pr::show_pr_created_toast;
use crate::code_review::git_dialog::{
render_branch_section, render_file_changes_box, should_send_git_ops_ai_request, show_toast,
user_facing_git_error, GitDialog, GitDialogAction, GitDialogEvent, GitDialogMode,
};
/// What should happen after a successful commit.
#[allow(clippy::enum_variant_names)] // `Commit` prefix is intentional: describes the always-present first stage.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommitIntent {
CommitOnly,
CommitAndPush,
CommitAndCreatePr,
}
/// What actually happened when a commit confirm ran to completion. Keeps
/// the "which stages ran" information separate from the user's selected
/// intent so the callback can't drift out of sync with the async body.
enum CommitOutcome {
Committed,
Pushed,
PrCreated(PrInfo),
}
use crate::code_review::telemetry_event::{
CodeReviewTelemetryEvent, GitDialogStatus, GitOperationKind,
};
use crate::editor::{
EditorOptions, EditorView, Event as EditorEvent, InteractionState,
PropagateAndNoOpNavigationKeys, TextOptions,
};
use crate::ui_components::icons::Icon;
use crate::util::git::{get_file_change_entries, FileChangeEntry, PrInfo};
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
/// Commit-specific sub-actions, dispatched wrapped in `GitDialogAction::Commit`.
#[derive(Clone, Debug, PartialEq)]
pub enum CommitSubAction {
SetIntent(CommitIntent),
SetIntent(CommitChainMode),
ToggleIncludeUnstaged,
ToggleChangesExpanded,
}
@@ -76,7 +54,7 @@ const FALLBACK_PLACEHOLDER_TEXT: &str = "Type a commit message";
const LOADING_LABEL: &str = "Committing\u{2026}";
pub struct CommitState {
intent: CommitIntent,
pub(super) intent: CommitChainMode,
include_unstaged: bool,
file_changes: Vec<FileChangeEntry>,
changes_expanded: bool,
@@ -94,14 +72,14 @@ pub struct CommitState {
}
pub(super) fn new_state(
repo_path: &Path,
local_repo_path: Option<&Path>,
allow_create_pr: bool,
has_upstream: bool,
ctx: &mut ViewContext<GitDialog>,
) -> CommitState {
// Dialog always opens with the plain commit intent; the user picks
// something else via the segmented intent selector inside the dialog.
let intent = CommitIntent::CommitOnly;
let intent = CommitChainMode::CommitOnly;
// `CommitAndPush` always runs `git push --set-upstream`, so it works
// whether or not the branch already has an upstream — but the label
// and icon flip to communicate the user-visible difference.
@@ -151,7 +129,7 @@ pub(super) fn new_state(
.with_icon(Icon::GitCommit)
.on_click(|ctx| {
ctx.dispatch_typed_action(GitDialogAction::Commit(CommitSubAction::SetIntent(
CommitIntent::CommitOnly,
CommitChainMode::CommitOnly,
)))
})
});
@@ -162,7 +140,7 @@ pub(super) fn new_state(
.with_icon(push_icon)
.on_click(|ctx| {
ctx.dispatch_typed_action(GitDialogAction::Commit(CommitSubAction::SetIntent(
CommitIntent::CommitAndPush,
CommitChainMode::CommitAndPush,
)))
})
});
@@ -175,7 +153,7 @@ pub(super) fn new_state(
.with_icon(Icon::Github)
.on_click(|ctx| {
ctx.dispatch_typed_action(GitDialogAction::Commit(CommitSubAction::SetIntent(
CommitIntent::CommitAndCreatePr,
CommitChainMode::CommitAndCreatePr,
)))
})
}))
@@ -184,33 +162,25 @@ pub(super) fn new_state(
};
let include_unstaged = true;
let repo_path_for_load = repo_path.to_path_buf();
ctx.spawn(
async move {
crate::util::git::get_file_change_entries(&repo_path_for_load, include_unstaged).await
},
move |me, result, ctx| {
let GitDialogMode::Commit(state) = &mut me.mode else {
return;
};
let has_changes = match result {
Ok(entries) => {
let has_changes = !entries.is_empty();
state.file_changes = entries;
has_changes
// Local repos load the changes list from the working tree here; remote
// repos source the Changes box from synced metadata.
if let Some(repo_path) = local_repo_path {
let repo_path_for_load = repo_path.to_path_buf();
ctx.spawn(
async move { get_file_change_entries(&repo_path_for_load, include_unstaged).await },
move |me, result, ctx| {
let GitDialogMode::Commit(state) = &mut me.mode else {
return;
};
match result {
Ok(entries) => state.file_changes = entries,
Err(err) => log::warn!("Failed to load file changes: {err}"),
}
Err(err) => {
log::warn!("Failed to load file changes: {err}");
false
}
};
me.refresh_confirm_enabled(ctx);
ctx.notify();
if ai_autogen_enabled && has_changes {
generate_commit_message(me.repo_path(), me.branch_name(), include_unstaged, ctx);
}
},
);
me.refresh_confirm_enabled(ctx);
ctx.notify();
},
);
}
let state = CommitState {
intent,
@@ -234,88 +204,118 @@ pub(super) fn on_focus(state: &CommitState, ctx: &mut ViewContext<GitDialog>) {
}
pub(super) fn is_ready_to_confirm(state: &CommitState, app: &AppContext) -> bool {
// Confirm requires at least one file change and a non-empty commit
// message. While open-time autogen is in flight the editor is still
// empty, so this keeps the button disabled until the draft lands (or the
// user types something).
!state.file_changes.is_empty() && commit_message(state, app).is_some()
// Confirm requires committable changes and a non-empty commit message.
// While open-time autogen is in flight the editor is still empty, so this
// keeps the button disabled until the draft lands (or the user types
// something).
has_committable_changes(state) && commit_message(state, app).is_some()
}
/// Whether there's at least one change to commit — the guard that keeps
/// Confirm disabled when there's nothing to commit.
///
/// Gates on `file_changes`, which already reflects the active "include
/// unstaged" scope: local re-reads the working tree on toggle, while remote
/// shows the full synced set (it can't re-scope client-side). The daemon-side
/// `run_commit` is the authoritative backstop that rejects an empty commit —
/// e.g. "exclude unstaged" with nothing staged — surfacing it as an error
/// toast rather than a phantom success.
fn has_committable_changes(state: &CommitState) -> bool {
!state.file_changes.is_empty()
}
/// Returns a tooltip to show on the disabled Confirm button when the
/// user needs to take action, or `None` when no tooltip is needed.
pub(super) fn confirm_tooltip(state: &CommitState, app: &AppContext) -> Option<&'static str> {
if !state.file_changes.is_empty() && commit_message(state, app).is_none() {
Some("Enter a commit message")
} else {
None
// Only nudge for a missing message; an empty Changes box is self-evident,
// and gating a tooltip on it would also flash during the open-time load.
if has_committable_changes(state) && commit_message(state, app).is_none() {
return Some("Enter a commit message");
}
None
}
/// Populates the commit message editor from an AI-generated message. Shared
/// by both backends, whose open-time autogen arrives via the
/// `CommitMessageGenerated` model event, so both behave identically: on
/// success, fill the editor unless the user already typed; on failure, swap
/// to the manual-type placeholder (no toast — the empty editor tells the
/// story and the failure isn't retryable).
pub(super) fn apply_generated_commit_message(
me: &mut GitDialog,
result: Result<String, String>,
ctx: &mut ViewContext<GitDialog>,
) {
let editor_handle = match me.mode() {
GitDialogMode::Commit(state) => state.message_editor.clone(),
_ => return,
};
match result {
Ok(generated) => {
let user_typed = !editor_handle.as_ref(ctx).buffer_text(ctx).trim().is_empty();
editor_handle.update(ctx, |editor, ctx| {
// Swap "Generating\u{2026}" for the manual-type prompt so it
// shows if the user later clears the generated draft.
editor.set_placeholder_text(FALLBACK_PLACEHOLDER_TEXT, ctx);
// User input wins — don't clobber their text.
if !user_typed {
editor.system_reset_buffer_text(generated.trim(), ctx);
}
});
me.refresh_confirm_enabled(ctx);
ctx.notify();
}
Err(err) => {
log::warn!("Failed to autogenerate commit message: {err}");
editor_handle.update(ctx, |editor, ctx| {
editor.set_placeholder_text(FALLBACK_PLACEHOLDER_TEXT, ctx);
});
me.refresh_confirm_enabled(ctx);
ctx.notify();
}
}
}
/// Kicks off an open-time AI commit-message generation. On success, writes
/// the result into the message editor (unless the user has already typed
/// something). On failure, silently swaps the placeholder to the manual
/// prompt so the user can type their own — no toast because the failure
/// isn't retryable and the empty editor already tells the story.
fn generate_commit_message(
repo_path: &Path,
branch_name: &str,
include_unstaged: bool,
ctx: &mut ViewContext<GitDialog>,
) {
let repo_path = repo_path.to_path_buf();
let branch_name = branch_name.to_string();
let code_review_ai = ServerApiProvider::handle(ctx).read(ctx, |p, _| p.get_ai_client());
/// Kicks off AI commit-message autogen request.
/// The model runs the generation (local in-process, remote on the daemon) and
/// the result returns via `DiffStateModelEvent::CommitMessageGenerated`, applied by `apply_generated_commit_message`.
pub(super) fn maybe_start_commit_message_autogen(me: &GitDialog, ctx: &mut ViewContext<GitDialog>) {
if !should_send_git_ops_ai_request(ctx) {
return;
}
// Generate from the same scope that will be committed (the "include
// unstaged" toggle), so the message describes what `run_commit` stages
// rather than always assuming the full working set.
let include_unstaged = match me.mode() {
GitDialogMode::Commit(state) => state.include_unstaged,
_ => return,
};
let branch_name = me.branch_name().to_string();
me.diff_state_model().update(ctx, |m, ctx| {
m.generate_commit_message(include_unstaged, branch_name, ctx);
});
}
ctx.spawn(
async move {
let diff =
crate::util::git::get_diff_for_commit_message(&repo_path, include_unstaged).await?;
let generated = code_review_ai
.generate_code_review_content(GenerateCodeReviewContentRequest {
output_type: OutputType::CommitMessage,
diff,
branch_name,
commit_messages: Vec::new(),
})
.await?
.content;
if generated.trim().is_empty() {
anyhow::bail!("AI returned an empty commit message");
}
anyhow::Ok(generated)
},
|me, result, ctx| {
let editor_handle = match &me.mode {
GitDialogMode::Commit(state) => state.message_editor.clone(),
_ => return,
};
match result {
Ok(generated) => {
let user_typed = !editor_handle.as_ref(ctx).buffer_text(ctx).trim().is_empty();
editor_handle.update(ctx, |editor, ctx| {
// Swap "Generating\u{2026}" for the manual-type
// prompt so it shows if the user later clears the
// generated draft.
editor.set_placeholder_text(FALLBACK_PLACEHOLDER_TEXT, ctx);
// User input wins — don't clobber their text.
if !user_typed {
editor.system_reset_buffer_text(generated.trim(), ctx);
}
});
me.refresh_confirm_enabled(ctx);
ctx.notify();
}
Err(err) => {
log::warn!("Failed to autogenerate commit message: {err}");
editor_handle.update(ctx, |editor, ctx| {
editor.set_placeholder_text(FALLBACK_PLACEHOLDER_TEXT, ctx);
});
me.refresh_confirm_enabled(ctx);
ctx.notify();
}
}
},
);
/// Sources the commit Changes box from synced metadata (`against_head.files`).
/// Remote repos can't read the working tree, so the list comes from metadata
/// instead of `get_file_change_entries`. No-op for local repos, which load it
/// from the working tree in `new_state` (and re-scope it on the unstaged
/// toggle). Safe to call on open and on every metadata refresh.
pub(super) fn refresh_remote_file_changes(me: &mut GitDialog, ctx: &mut ViewContext<GitDialog>) {
if !me.repo_location().is_remote() {
return;
}
let entries = me.diff_state_model().read(ctx, |model, ctx| {
model.uncommitted_file_entries(ctx).to_vec()
});
{
let GitDialogMode::Commit(state) = me.mode_mut() else {
return;
};
state.file_changes = entries;
}
me.refresh_confirm_enabled(ctx);
ctx.notify();
}
pub(super) fn handle_sub_action(
@@ -341,7 +341,13 @@ pub(super) fn handle_sub_action(
if let GitDialogMode::Commit(state) = me.mode_mut() {
state.include_unstaged = !state.include_unstaged;
}
// Local re-reads the working tree scoped to the new toggle (its
// spawn callback re-evaluates Confirm when it lands). Remote can't
// re-scope its synced list, so it keeps showing the full set; the
// daemon-side commit is the backstop that rejects an empty staged
// set when unstaged is excluded.
reload_file_changes(me, ctx);
me.refresh_confirm_enabled(ctx);
ctx.notify();
}
CommitSubAction::ToggleChangesExpanded => {
@@ -365,11 +371,11 @@ pub(super) fn start_confirm(me: &mut GitDialog, ctx: &mut ViewContext<GitDialog>
};
let intent = state.intent;
let include_unstaged = state.include_unstaged;
let ai_autogen_enabled = should_send_git_ops_ai_request(ctx);
let message_editor = state.message_editor.clone();
let repo_path = me.repo_path().clone();
let branch_name = me.branch_name().to_string();
let parent_branch = me.parent_branch_name.clone();
// When the chain includes create-PR, AI-generate the PR title/body when the
// user has it enabled (ignored for commit-only / commit-and-push).
let autogenerate_pr_content = should_send_git_ops_ai_request(ctx);
me.set_loading(LOADING_LABEL, ctx);
@@ -378,81 +384,60 @@ pub(super) fn start_confirm(me: &mut GitDialog, ctx: &mut ViewContext<GitDialog>
editor.set_interaction_state(InteractionState::Disabled, ctx);
});
let code_review_ai = if ai_autogen_enabled {
Some(ServerApiProvider::handle(ctx).read(ctx, |p, _| p.get_ai_client()))
} else {
None
};
let path_future = interactive_path_future(ctx);
me.diff_state_model().update(ctx, |m, ctx| {
m.git_commit_chain(
intent,
message,
include_unstaged,
branch_name,
autogenerate_pr_content,
ctx,
);
});
}
ctx.spawn(
async move {
let path_env = path_future.await;
let path_env_ref = path_env.as_deref();
crate::util::git::run_commit(&repo_path, &message, include_unstaged, path_env_ref)
.await?;
let outcome = match intent {
CommitIntent::CommitOnly => CommitOutcome::Committed,
CommitIntent::CommitAndPush => {
crate::util::git::run_push(&repo_path, &branch_name, path_env_ref).await?;
CommitOutcome::Pushed
}
CommitIntent::CommitAndCreatePr => {
crate::util::git::run_push(&repo_path, &branch_name, path_env_ref).await?;
let pr = match code_review_ai {
Some(ai) => {
// Reuse pr.rs's AI-title/body-with-fallback helper so
// the standalone PR flow and this chain always produce
// PRs the same way.
create_pr_with_ai_content(
&repo_path,
&branch_name,
parent_branch.as_deref(),
ai.as_ref(),
path_env_ref,
)
.await?
}
None => {
// AI autogen disabled (global toggle, per-feature
// toggle, or enterprise) — skip AI entirely and use
// `gh pr create --fill`
crate::util::git::create_pr(
&repo_path,
None,
None,
parent_branch.as_deref(),
path_env_ref,
)
.await?
}
};
CommitOutcome::PrCreated(pr)
}
/// Shared commit-chain completion for both backends: toast + telemetry + close.
/// `Ok(Some)` means create-PR ran; `Ok(None)` is a plain commit / commit-and-push.
pub(super) fn finish_commit_chain(
me: &GitDialog,
intent: CommitChainMode,
result: Result<Option<PrInfo>, String>,
ctx: &mut ViewContext<GitDialog>,
) {
let operation = match intent {
CommitChainMode::CommitOnly => GitOperationKind::CommitOnly,
CommitChainMode::CommitAndPush => GitOperationKind::CommitAndPush,
CommitChainMode::CommitAndCreatePr => GitOperationKind::CommitAndCreatePr,
};
let (status, error) = match &result {
Ok(_) => (GitDialogStatus::Succeeded, None),
Err(err) => (GitDialogStatus::Failed, Some(err.clone())),
};
match &result {
Ok(Some(pr)) => show_pr_created_toast(pr, ctx),
Ok(None) => {
let msg = if matches!(intent, CommitChainMode::CommitOnly) {
"Changes successfully committed."
} else {
"Changes committed and pushed."
};
anyhow::Ok(outcome)
},
move |_me, result, ctx| {
match result {
Ok(CommitOutcome::Committed) => {
show_toast("Changes successfully committed.", ctx);
}
Ok(CommitOutcome::Pushed) => {
show_toast("Changes committed and pushed.", ctx);
}
Ok(CommitOutcome::PrCreated(pr)) => {
show_pr_created_toast(&pr, ctx);
}
Err(err) => {
log::error!("Commit failed: {err}");
show_toast(user_facing_git_error(&err.to_string()), ctx);
}
}
// Success or failure, the dialog is done and the parent should
// close it and refresh.
ctx.emit(GitDialogEvent::Completed);
show_toast(msg, ctx);
}
Err(err) => {
log::error!("Commit failed: {err}");
show_toast(user_facing_git_error(err), ctx);
}
}
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::GitDialogCompleted {
is_local: Some(!me.repo_location().is_remote()),
operation,
status,
error,
},
ctx
);
ctx.emit(GitDialogEvent::Completed);
}
fn handle_editor_event(me: &mut GitDialog, event: &EditorEvent, ctx: &mut ViewContext<GitDialog>) {
@@ -472,26 +457,28 @@ fn handle_editor_event(me: &mut GitDialog, event: &EditorEvent, ctx: &mut ViewCo
fn apply_intent_selector(state: &CommitState, ctx: &mut ViewContext<GitDialog>) {
state.commit_button.update(ctx, |b, ctx| {
b.set_active(state.intent == CommitIntent::CommitOnly, ctx);
b.set_active(state.intent == CommitChainMode::CommitOnly, ctx);
});
state.commit_and_push_button.update(ctx, |b, ctx| {
b.set_active(state.intent == CommitIntent::CommitAndPush, ctx);
b.set_active(state.intent == CommitChainMode::CommitAndPush, ctx);
});
if let Some(button) = &state.commit_and_create_pr_button {
button.update(ctx, |b, ctx| {
b.set_active(state.intent == CommitIntent::CommitAndCreatePr, ctx);
b.set_active(state.intent == CommitChainMode::CommitAndCreatePr, ctx);
});
}
}
fn reload_file_changes(me: &mut GitDialog, ctx: &mut ViewContext<GitDialog>) {
let repo_path = me.repo_path().clone();
let Some(repo_path) = me.repo_location().to_local_path().map(Path::to_path_buf) else {
return;
};
let include_unstaged = match me.mode() {
GitDialogMode::Commit(state) => state.include_unstaged,
_ => return,
};
ctx.spawn(
async move { crate::util::git::get_file_change_entries(&repo_path, include_unstaged).await },
async move { get_file_change_entries(&repo_path, include_unstaged).await },
|me, result, ctx| {
if let GitDialogMode::Commit(state) = &mut me.mode {
match result {
+220 -77
View File
@@ -9,42 +9,40 @@
//! + confirm async, extend `GitDialogMode`, add the per-mode action and
//! outcome variant, and wire up dispatch.
use std::path::PathBuf;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{
elements::{
Align, Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable,
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex, Hoverable,
Icon as IconElement, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, Radius, ScrollbarWidth, Stack, Text,
},
keymap::{self, FixedBinding},
platform::Cursor,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use pathfinder_geometry::vector::vec2f;
#[cfg(feature = "local_tty")]
use crate::terminal::local_shell::LocalShellState;
use crate::{
code::editor::{add_color, remove_color},
settings::AISettings,
ui_components::{
dialog::{dialog_styles, Dialog},
icons::Icon,
},
util::git::{Commit, FileChangeEntry},
view_components::{
action_button::{ActionButton, ButtonSize, NakedTheme, SecondaryTheme},
DismissibleToast,
},
workspace::ToastStack,
workspaces::user_workspaces::UserWorkspaces,
use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::{
Align, Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable,
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex, Hoverable,
Icon as IconElement, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, Radius, ScrollbarWidth, Stack, Text,
};
use galaxyui::keymap::{self, FixedBinding};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code::editor::{add_color, remove_color};
use crate::code_review::diff_state::{
CommitChainMode, DiffStateModel, DiffStateModelEvent, GitOpResult,
};
use crate::code_review::telemetry_event::{
CodeReviewTelemetryEvent, GitDialogStatus, GitOperationKind,
};
use crate::settings::AISettings;
use crate::ui_components::dialog::{dialog_styles, Dialog};
use crate::ui_components::icons::Icon;
use crate::util::git::{Commit, FileChangeEntry};
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme, SecondaryTheme};
use crate::view_components::DismissibleToast;
use crate::workspace::ToastStack;
use crate::workspaces::user_workspaces::UserWorkspaces;
pub(crate) mod commit;
pub(crate) mod pr;
@@ -72,25 +70,6 @@ pub fn init(ctx: &mut AppContext) {
)]);
}
/// Future that resolves to the user's interactive-shell `PATH` (or `None`
/// if capture failed). Result is cached in `LocalShellState`.
#[cfg(feature = "local_tty")]
pub(super) fn interactive_path_future(
ctx: &mut ViewContext<GitDialog>,
) -> futures::future::BoxFuture<'static, Option<String>> {
LocalShellState::handle(ctx).update(ctx, |shell_state, ctx| {
shell_state.get_interactive_path_env_var(ctx)
})
}
#[cfg(not(feature = "local_tty"))]
pub(super) fn interactive_path_future(
_ctx: &mut ViewContext<GitDialog>,
) -> futures::future::BoxFuture<'static, Option<String>> {
use futures::FutureExt;
futures::future::ready(None).boxed()
}
/// Top-level action dispatched to `GitDialog`.
///
/// `Cancel` / `Confirm` are shared across modes; mode-specific actions are
@@ -132,8 +111,7 @@ fn show_toast(msg: impl Into<String>, ctx: &mut ViewContext<GitDialog>) {
///
/// Folds the parent feature flag, the user's dedicated per-feature AI toggle
/// (which itself requires active AI / auth / remote-session org policy to
/// allow AI), and an enterprise check with the same Warp-plan exception and
/// dogfood override as `share_block_modal.rs::should_send_title_gen_request`.
/// allow AI), and the current team's Git Operations AI tier policy.
///
/// When this returns `false`, call sites skip AI entirely: commit.rs opens
/// with the manual-type placeholder and pr.rs goes straight to
@@ -141,7 +119,7 @@ fn show_toast(msg: impl Into<String>, ctx: &mut ViewContext<GitDialog>) {
fn should_send_git_ops_ai_request(app: &AppContext) -> bool {
FeatureFlag::GitOperationsInCodeReview.is_enabled()
&& AISettings::as_ref(app).is_git_operations_autogen_enabled(app)
&& UserWorkspaces::as_ref(app).ai_allowed_for_current_team()
&& UserWorkspaces::as_ref(app).is_git_operations_ai_enabled()
}
/// Maps a raw git error string to a user-friendly toast message. Known
@@ -149,7 +127,11 @@ fn should_send_git_ops_ai_request(app: &AppContext) -> bool {
/// message (the raw error is always logged separately at the call site).
fn user_facing_git_error(raw: &str) -> &'static str {
let lower = raw.to_lowercase();
if lower.contains("nothing to commit") {
if lower.contains("no changes added to commit") {
// Distinct from a clean tree: changes exist but nothing is staged
// (e.g. "include unstaged" off with an empty index).
"No staged changes to commit."
} else if lower.contains("nothing to commit") {
"No changes to commit."
} else if lower.contains("please tell me who you are")
|| lower.contains("author identity unknown")
@@ -187,6 +169,10 @@ fn user_facing_git_error(raw: &str) -> &'static str {
// Phrases mirror `context_chips::current_prompt::is_gh_auth_error`,
// which has been vetted against real `gh` failure output.
"GitHub CLI not authenticated. Run `gh auth login`."
} else if lower.contains("another git operation is in progress") {
// Daemon-side guard for a repo mid-merge/rebase/cherry-pick or with a
// held index lock (see `git_operation_in_progress`).
"Another git operation is in progress. Finish or abort it first."
} else {
"Git operation failed."
}
@@ -480,9 +466,9 @@ pub enum GitDialogMode {
}
pub struct GitDialog {
repo_path: PathBuf,
repo_location: LocalOrRemotePath,
diff_state_model: ModelHandle<DiffStateModel>,
branch_name: String,
parent_branch_name: Option<String>,
mode: GitDialogMode,
loading: bool,
confirm_button: ViewHandle<ActionButton>,
@@ -492,9 +478,9 @@ pub struct GitDialog {
impl GitDialog {
pub fn new_for_commit(
repo_path: PathBuf,
repo_location: LocalOrRemotePath,
diff_state_model: ModelHandle<DiffStateModel>,
branch_name: String,
parent_branch_name: Option<String>,
allow_create_pr: bool,
has_upstream: bool,
ctx: &mut ViewContext<Self>,
@@ -505,23 +491,37 @@ impl GitDialog {
// will actually run on click.
let (confirm_button, cancel_button, close_button) =
Self::build_dialog_buttons("Confirm", None, ctx);
let state = commit::new_state(&repo_path, allow_create_pr, has_upstream, ctx);
let this = Self {
repo_path,
ctx.subscribe_to_model(&diff_state_model, Self::handle_diff_state_event);
let state = commit::new_state(
repo_location.to_local_path(),
allow_create_pr,
has_upstream,
ctx,
);
let mut this = Self {
repo_location,
diff_state_model,
branch_name,
parent_branch_name,
mode: GitDialogMode::Commit(state),
loading: false,
confirm_button,
cancel_button,
close_button,
};
// Open-time AI commit-message autogen runs for both backends; the model
// generates it (local in-process, remote on the daemon) and the result
// returns via the diff-state subscription wired up just above.
commit::maybe_start_commit_message_autogen(&this, ctx);
// Remote repos source the Changes box from synced metadata (the local
// path loads it from the working tree in `commit::new_state`).
commit::refresh_remote_file_changes(&mut this, ctx);
this.refresh_confirm_enabled(ctx);
this
}
pub fn new_for_push(
repo_path: PathBuf,
repo_location: LocalOrRemotePath,
diff_state_model: ModelHandle<DiffStateModel>,
branch_name: String,
publish: bool,
commits: Vec<Commit>,
@@ -532,11 +532,12 @@ impl GitDialog {
Some(push::confirm_icon(publish)),
ctx,
);
ctx.subscribe_to_model(&diff_state_model, Self::handle_diff_state_event);
let state = push::new_state(publish, commits);
Self {
repo_path,
repo_location,
diff_state_model,
branch_name,
parent_branch_name: None,
mode: GitDialogMode::Push(state),
loading: false,
confirm_button,
@@ -546,24 +547,32 @@ impl GitDialog {
}
pub fn new_for_pr(
repo_path: PathBuf,
repo_location: LocalOrRemotePath,
diff_state_model: ModelHandle<DiffStateModel>,
branch_name: String,
parent_branch_name: Option<String>,
base_branch_name: Option<String>,
ctx: &mut ViewContext<Self>,
) -> Self {
let (confirm_button, cancel_button, close_button) =
Self::build_dialog_buttons(pr::confirm_label_for(), Some(pr::confirm_icon_for()), ctx);
let state = pr::new_state(&repo_path, parent_branch_name.as_deref(), ctx);
Self {
repo_path,
ctx.subscribe_to_model(&diff_state_model, Self::handle_diff_state_event);
let state = pr::new_state(base_branch_name);
let mut this = Self {
repo_location,
diff_state_model,
branch_name,
parent_branch_name,
mode: GitDialogMode::CreatePr(state),
loading: false,
confirm_button,
cancel_button,
close_button,
}
};
// Fetch the committed branch diff on open (committed-only, so the
// Changes box previews exactly what the PR will contain). Both backends
// deliver the result via `BranchCommittedFilesReceived`, applied in
// `handle_diff_state_event`.
pr::fetch_committed_file_changes(&mut this, ctx);
this
}
fn build_dialog_buttons(
@@ -600,8 +609,77 @@ impl GitDialog {
(confirm_button, cancel_button, close_button)
}
fn repo_path(&self) -> &PathBuf {
&self.repo_path
fn repo_location(&self) -> &LocalOrRemotePath {
&self.repo_location
}
fn diff_state_model(&self) -> &ModelHandle<DiffStateModel> {
&self.diff_state_model
}
// ── Model event handling ─────────────────────────────────────────
fn handle_diff_state_event(
&mut self,
_model: ModelHandle<DiffStateModel>,
event: &DiffStateModelEvent,
ctx: &mut ViewContext<Self>,
) {
// Commit-message autogen arrives at dialog open (before any op is
// initiated), so it's handled outside the `loading` gate the
// op-completion events use below.
if let DiffStateModelEvent::CommitMessageGenerated(result) = event {
commit::apply_generated_commit_message(self, result.clone(), ctx);
return;
}
// Commit mode (remote) sources its Changes box from synced metadata, so
// refresh it whenever metadata lands. Arrives independently of any
// in-flight op, so it's handled outside the `loading` gate below.
if let DiffStateModelEvent::MetadataRefreshed(_) = event {
commit::refresh_remote_file_changes(self, ctx);
return;
}
// The create-PR dialog fetches its committed file list on open
// (committed-only, so it matches what the PR will contain); the result
// arrives here and populates the Changes box.
if let DiffStateModelEvent::BranchCommittedFilesReceived(files) = event {
pr::apply_committed_file_changes(self, files.clone(), ctx);
return;
}
let DiffStateModelEvent::GitOpCompleted(result) = event else {
return;
};
// Only act when we're in a loading state (i.e. we initiated the op).
if !self.loading {
return;
}
match result {
GitOpResult::CommitChainCompleted(result) => {
let intent = match &self.mode {
GitDialogMode::Commit(state) => state.intent,
_ => return,
};
// Unified completion path (toast + telemetry + close) for both
// backends; the model already applied the delta / PR info to
// metadata before emitting this event.
commit::finish_commit_chain(self, intent, result.clone(), ctx);
}
GitOpResult::PushCompleted(result) => {
let publish = match &self.mode {
GitDialogMode::Push(state) => state.publish,
_ => return,
};
push::finish_push(
self,
publish,
result.clone().map_err(|e| anyhow::anyhow!(e)),
ctx,
);
}
GitOpResult::PrCreated(result) => {
pr::finish_create_pr(self, result.clone().map_err(|e| anyhow::anyhow!(e)), ctx);
}
}
}
fn branch_name(&self) -> &str {
@@ -672,6 +750,20 @@ impl GitDialog {
}
}
fn header_icon(&self) -> Icon {
match &self.mode {
GitDialogMode::Commit(_) => Icon::GitCommit,
GitDialogMode::Push(state) => {
if state.publish {
Icon::UploadCloud
} else {
Icon::ArrowUp
}
}
GitDialogMode::CreatePr(_) => Icon::Github,
}
}
fn render_body(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
match &self.mode {
@@ -685,6 +777,7 @@ impl GitDialog {
/// it in centered overlay chrome with a blurred background.
fn render_dialog(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let close = ChildView::new(&self.close_button).finish();
let cancel = ChildView::new(&self.cancel_button).finish();
@@ -694,6 +787,25 @@ impl GitDialog {
let body = self.render_body(app);
let surface2 = theme.surface_2();
let icon_color = theme.main_text_color(surface2).into_solid();
let header_icon = Container::new(
ConstrainedBox::new(
IconElement::new(
<Icon as Into<&'static str>>::into(self.header_icon()),
icon_color,
)
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_uniform_padding(8.)
.with_background(surface2)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish();
let dialog = Dialog::new(
self.title().to_string(),
None,
@@ -703,6 +815,7 @@ impl GitDialog {
..dialog_styles(appearance)
},
)
.with_header_icon(header_icon)
.with_close_button(close)
.with_child(body)
.with_separator()
@@ -768,6 +881,36 @@ impl TypedActionView for GitDialog {
match action {
GitDialogAction::Cancel => {
if !self.loading {
let operation = match &self.mode {
GitDialogMode::Commit(state) => match state.intent {
CommitChainMode::CommitOnly => GitOperationKind::CommitOnly,
CommitChainMode::CommitAndPush => GitOperationKind::CommitAndPush,
CommitChainMode::CommitAndCreatePr => {
GitOperationKind::CommitAndCreatePr
}
},
GitDialogMode::Push(state) => {
if state.publish {
GitOperationKind::Publish
} else {
GitOperationKind::Push
}
}
GitDialogMode::CreatePr(_) => GitOperationKind::CreatePr,
};
// Derive the real local/remote value rather than hardcoding
// it, so cancel telemetry matches the repo the dialog acts
// on (the completion paths report the same value).
let is_local = !self.repo_location.is_remote();
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::GitDialogCompleted {
is_local: Some(is_local),
operation,
status: GitDialogStatus::Cancelled,
error: None,
},
ctx
);
ctx.emit(GitDialogEvent::Cancelled);
}
}
+86 -141
View File
@@ -4,30 +4,24 @@
//! with expandable per-file stats. On confirm, spawns `create_pr` and shows
//! a toast with a clickable "Open PR" link.
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{
elements::{
ClippedScrollStateHandle, Container, Element, Flex, MouseStateHandle, ParentElement, Text,
},
SingletonEntity, ViewContext,
use galaxyui::elements::{
ClippedScrollStateHandle, Container, Element, Flex, MouseStateHandle, ParentElement, Text,
};
use warpui::{SingletonEntity, ViewContext};
use crate::{
ai::generate_code_review_content::api::{GenerateCodeReviewContentRequest, OutputType},
code_review::git_dialog::{
interactive_path_future, render_branch_section, render_file_changes_box,
should_send_git_ops_ai_request, show_toast, user_facing_git_error, GitDialog,
GitDialogAction, GitDialogEvent, GitDialogMode,
},
server::server_api::{ai::AIClient, ServerApiProvider},
ui_components::icons::Icon,
util::git::{
create_pr, get_branch_commit_messages, get_branch_diff_entries, get_diff_for_pr,
FileChangeEntry, PrInfo,
},
view_components::{DismissibleToast, ToastLink},
workspace::ToastStack,
use crate::code_review::git_dialog::{
render_branch_section, render_file_changes_box, should_send_git_ops_ai_request, show_toast,
user_facing_git_error, GitDialog, GitDialogAction, GitDialogEvent, GitDialogMode,
};
use crate::code_review::telemetry_event::{
CodeReviewTelemetryEvent, GitDialogStatus, GitOperationKind,
};
use crate::ui_components::icons::Icon;
use crate::util::git::{FileChangeEntry, PrInfo};
use crate::view_components::{DismissibleToast, ToastLink};
use crate::workspace::ToastStack;
/// PR-mode sub-actions, dispatched wrapped in `GitDialogAction::Pr`.
#[derive(Clone, Debug, PartialEq)]
@@ -36,6 +30,7 @@ pub enum PrSubAction {
}
pub struct PrState {
base_branch_name: Option<String>,
file_changes: Vec<FileChangeEntry>,
changes_expanded: bool,
summary_mouse_state: MouseStateHandle,
@@ -60,31 +55,12 @@ pub(super) fn is_ready_to_confirm(_state: &PrState) -> bool {
true
}
pub(super) fn new_state(
repo_path: &std::path::Path,
parent_branch: Option<&str>,
ctx: &mut ViewContext<GitDialog>,
) -> PrState {
let diff_repo_path = repo_path.to_path_buf();
let parent_branch = parent_branch.map(|s| s.to_string());
ctx.spawn(
async move { get_branch_diff_entries(&diff_repo_path, parent_branch.as_deref()).await },
|me, result, ctx| {
if let GitDialogMode::CreatePr(state) = &mut me.mode {
match result {
Ok(entries) => {
state.file_changes = entries;
ctx.notify();
}
Err(err) => {
log::error!("Failed to load branch diff entries: {err}");
}
}
}
},
);
pub(super) fn new_state(base_branch_name: Option<String>) -> PrState {
PrState {
base_branch_name: base_branch_name.map(|name| {
let name = name.trim();
name.strip_prefix("origin/").unwrap_or(name).to_string()
}),
file_changes: Vec::new(),
changes_expanded: false,
summary_mouse_state: MouseStateHandle::default(),
@@ -92,6 +68,37 @@ pub(super) fn new_state(
}
}
/// Kicks off an on-demand fetch of the committed branch diff
/// (`merge_base(HEAD, main)..HEAD`) for the create-PR Changes box. The result
/// arrives via `DiffStateModelEvent::BranchCommittedFilesReceived` and is
/// applied by [`apply_committed_file_changes`]. Unlike the working-tree-based
/// `against_base_branch` metadata, this is committed-only, so the box previews
/// exactly what `gh pr create` will include — not uncommitted or untracked
/// changes. Called on dialog open; local computes it off-thread, remote fetches
/// it via RPC.
pub(super) fn fetch_committed_file_changes(me: &mut GitDialog, ctx: &mut ViewContext<GitDialog>) {
me.diff_state_model().update(ctx, |model, ctx| {
model.fetch_committed_branch_files(ctx);
});
}
/// Applies the committed branch files delivered via
/// `DiffStateModelEvent::BranchCommittedFilesReceived` to the create-PR
/// Changes box. No-op when the dialog isn't in create-PR mode.
pub(super) fn apply_committed_file_changes(
me: &mut GitDialog,
files: Vec<FileChangeEntry>,
ctx: &mut ViewContext<GitDialog>,
) {
{
let GitDialogMode::CreatePr(state) = me.mode_mut() else {
return;
};
state.file_changes = files;
}
ctx.notify();
}
pub(super) fn handle_sub_action(
me: &mut GitDialog,
action: &PrSubAction,
@@ -111,113 +118,46 @@ pub(super) fn start_confirm(me: &mut GitDialog, ctx: &mut ViewContext<GitDialog>
let GitDialogMode::CreatePr(_) = me.mode() else {
return;
};
let repo_path = me.repo_path().clone();
let branch_name = me.branch_name().to_string();
let parent_branch = me.parent_branch_name.clone();
// AI-generate the PR title/body when the user has it enabled; falls back to
// `gh pr create --fill`.
let autogenerate_content = should_send_git_ops_ai_request(ctx);
me.set_loading(loading_label_for(), ctx);
let code_review_ai = if should_send_git_ops_ai_request(ctx) {
Some(ServerApiProvider::handle(ctx).read(ctx, |p, _| p.get_ai_client()))
} else {
None
};
let path_future = interactive_path_future(ctx);
ctx.spawn(
async move {
let path_env = path_future.await;
if let Some(code_review_ai) = code_review_ai.as_ref() {
create_pr_with_ai_content(
&repo_path,
&branch_name,
parent_branch.as_deref(),
code_review_ai.as_ref(),
path_env.as_deref(),
)
.await
} else {
create_pr(
&repo_path,
None,
None,
parent_branch.as_deref(),
path_env.as_deref(),
)
.await
}
},
move |_me, result, ctx| {
match result {
Ok(pr_info) => {
show_pr_created_toast(&pr_info, ctx);
}
Err(err) => {
log::error!("Failed to create PR: {err}");
show_toast(user_facing_git_error(&err.to_string()), ctx);
}
}
ctx.emit(GitDialogEvent::Completed);
},
);
me.diff_state_model().update(ctx, |m, ctx| {
m.create_pr(branch_name, autogenerate_content, ctx);
});
}
/// Generates PR title and body via AI (in parallel) and creates the PR.
/// Falls back to `gh pr create --fill` if AI generation fails or returns
/// empty content.
pub(super) async fn create_pr_with_ai_content(
repo_path: &std::path::Path,
branch_name: &str,
parent_branch: Option<&str>,
code_review_ai: &dyn AIClient,
path_env: Option<&str>,
) -> anyhow::Result<PrInfo> {
let diff = get_diff_for_pr(repo_path, parent_branch).await?;
let commit_messages = get_branch_commit_messages(repo_path, parent_branch)
.await
.unwrap_or_default();
let title_req = GenerateCodeReviewContentRequest {
output_type: OutputType::PrTitle,
diff: diff.clone(),
branch_name: branch_name.to_string(),
commit_messages: commit_messages.clone(),
/// Shared create-PR completion: toast (with Open PR link) + telemetry +
/// close.
pub(super) fn finish_create_pr(
me: &GitDialog,
result: anyhow::Result<PrInfo>,
ctx: &mut ViewContext<GitDialog>,
) {
let (status, error) = match &result {
Ok(_) => (GitDialogStatus::Succeeded, None),
Err(err) => (GitDialogStatus::Failed, Some(err.to_string())),
};
let body_req = GenerateCodeReviewContentRequest {
output_type: OutputType::PrDescription,
diff,
branch_name: branch_name.to_string(),
commit_messages,
};
match futures::try_join!(
code_review_ai.generate_code_review_content(title_req),
code_review_ai.generate_code_review_content(body_req),
) {
Ok((title_resp, body_resp))
if !title_resp.content.trim().is_empty() && !body_resp.content.trim().is_empty() =>
{
create_pr(
repo_path,
Some(&title_resp.content),
Some(&body_resp.content),
parent_branch,
path_env,
)
.await
}
Ok(_) => {
// Empty title/body would make `gh pr create` fail; fall back to --fill.
log::warn!(
"AI PR content generation returned empty title/body, falling back to --fill"
);
crate::util::git::create_pr(repo_path, None, None, parent_branch, path_env).await
}
match &result {
Ok(pr_info) => show_pr_created_toast(pr_info, ctx),
Err(err) => {
log::warn!("AI PR content generation failed, falling back to --fill: {err}");
crate::util::git::create_pr(repo_path, None, None, parent_branch, path_env).await
log::error!("Failed to create PR: {err}");
show_toast(user_facing_git_error(&err.to_string()), ctx);
}
}
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::GitDialogCompleted {
is_local: Some(!me.repo_location().is_remote()),
operation: GitOperationKind::CreatePr,
status,
error,
},
ctx
);
ctx.emit(GitDialogEvent::Completed);
}
/// Shows a toast announcing PR creation with a clickable "Open PR" link.
@@ -237,6 +177,11 @@ pub(super) fn render_body(
branch_name: &str,
appearance: &Appearance,
) -> Box<dyn Element> {
let base_branch = state
.base_branch_name
.as_deref()
.unwrap_or("default branch");
let branch_name = format!("{branch_name} \u{2192} {base_branch}");
Flex::column()
.with_child(
Container::new(render_branch_section(branch_name, appearance))
+66 -97
View File
@@ -1,33 +1,33 @@
//! Push / publish mode for [`GitDialog`].
//!
//! Renders the branch's unpushed commit list with lazy per-commit file
//! expansion. A single `publish: bool` flag toggles between pushing an
//! existing branch and publishing a new one (setting upstream). On confirm,
//! spawns `run_push`.
//! Renders the branch's unpushed commit list with per-commit file expansion.
//! Each commit's file list is captured up front on `Commit.files`, so
//! expansion is a pure toggle (no per-commit fetch). A single `publish: bool`
//! flag toggles between pushing an existing branch and publishing a new one
//! (setting upstream). On confirm, spawns `run_push`.
use std::collections::HashMap;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{
elements::{
Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Element, Flex, Hoverable, MainAxisAlignment,
MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Text,
},
platform::Cursor,
ViewContext,
use galaxyui::elements::{
Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Element, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Text,
};
use warpui::platform::Cursor;
use warpui::ViewContext;
use crate::{
code::editor::{add_color, remove_color},
code_review::git_dialog::{
interactive_path_future, render_branch_section, render_chevron_icon, render_file_list,
show_toast, user_facing_git_error, GitDialog, GitDialogAction, GitDialogEvent,
GitDialogMode,
},
ui_components::icons::Icon,
util::git::{Commit, FileChangeEntry},
use crate::code::editor::{add_color, remove_color};
use crate::code_review::git_dialog::{
render_branch_section, render_chevron_icon, render_file_list, show_toast,
user_facing_git_error, GitDialog, GitDialogAction, GitDialogEvent, GitDialogMode,
};
use crate::code_review::telemetry_event::{
CodeReviewTelemetryEvent, GitDialogStatus, GitOperationKind,
};
use crate::ui_components::icons::Icon;
use crate::util::git::Commit;
/// Push-specific sub-actions, dispatched wrapped in `GitDialogAction::Push`.
#[derive(Clone, Debug, PartialEq)]
@@ -39,7 +39,6 @@ pub struct PushState {
pub(super) publish: bool,
commits: Vec<Commit>,
expanded: HashMap<String, bool>,
commit_files: HashMap<String, Vec<FileChangeEntry>>,
commit_mouse_states: HashMap<String, MouseStateHandle>,
commits_scroll_state: ClippedScrollStateHandle,
}
@@ -53,7 +52,6 @@ pub(super) fn new_state(publish: bool, commits: Vec<Commit>) -> PushState {
publish,
commits,
expanded: HashMap::new(),
commit_files: HashMap::new(),
commit_mouse_states,
commits_scroll_state: ClippedScrollStateHandle::default(),
}
@@ -90,42 +88,12 @@ pub(super) fn handle_sub_action(
) {
match action {
PushSubAction::ToggleCommit(hash) => {
let (should_fetch, repo_path) = {
let repo_path = me.repo_path().clone();
let GitDialogMode::Push(state) = me.mode_mut() else {
return;
};
// File lists are captured up front on `Commit.files`, so expansion
// is a pure toggle.
if let GitDialogMode::Push(state) = me.mode_mut() {
let is_expanded = state.expanded.entry(hash.clone()).or_insert(false);
*is_expanded = !*is_expanded;
let should_fetch = *is_expanded && !state.commit_files.contains_key(hash);
(should_fetch, repo_path)
};
if should_fetch {
let hash_for_cb = hash.clone();
let hash_for_async = hash.clone();
ctx.spawn(
async move {
crate::util::git::get_commit_files(&repo_path, &hash_for_async).await
},
move |me, result, ctx| {
if let GitDialogMode::Push(state) = &mut me.mode {
match result {
Ok(files) => {
state.commit_files.insert(hash_for_cb, files);
ctx.notify();
}
Err(e) => {
log::warn!("Failed to fetch files for commit: {e}");
state.expanded.insert(hash_for_cb, false);
ctx.notify();
}
}
}
},
);
}
ctx.notify();
}
}
@@ -136,37 +104,54 @@ pub(super) fn start_confirm(me: &mut GitDialog, ctx: &mut ViewContext<GitDialog>
GitDialogMode::Push(state) => state.publish,
_ => return,
};
let repo_path = me.repo_path().clone();
let branch = me.branch_name().to_string();
me.set_loading(loading_label(publish), ctx);
let path_future = interactive_path_future(ctx);
me.diff_state_model().update(ctx, |m, ctx| {
m.git_push(branch, ctx);
});
}
ctx.spawn(
async move {
let path_env = path_future.await;
crate::util::git::run_push(&repo_path, &branch, path_env.as_deref()).await
},
move |me, result, ctx| {
match result {
Ok(_) => {
let toast_msg = if publish {
"Branch successfully published."
} else {
"Changes successfully pushed."
};
show_toast(toast_msg, ctx);
}
Err(e) => {
log::error!("Push failed: {e}");
show_toast(user_facing_git_error(&e.to_string()), ctx);
}
}
let _ = me;
ctx.emit(GitDialogEvent::Completed);
/// Shared push completion: toast + telemetry + close.
pub(super) fn finish_push(
me: &GitDialog,
publish: bool,
result: anyhow::Result<()>,
ctx: &mut ViewContext<GitDialog>,
) {
let (status, error) = match &result {
Ok(_) => (GitDialogStatus::Succeeded, None),
Err(err) => (GitDialogStatus::Failed, Some(err.to_string())),
};
match result {
Ok(_) => {
let toast_msg = if publish {
"Branch successfully published."
} else {
"Changes successfully pushed."
};
show_toast(toast_msg, ctx);
}
Err(e) => {
log::error!("Push failed: {e}");
show_toast(user_facing_git_error(&e.to_string()), ctx);
}
}
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::GitDialogCompleted {
is_local: Some(!me.repo_location().is_remote()),
operation: if publish {
GitOperationKind::Publish
} else {
GitOperationKind::Push
},
status,
error,
},
ctx
);
ctx.emit(GitDialogEvent::Completed);
}
pub(super) fn render_body(
@@ -315,23 +300,7 @@ fn render_commits_section(state: &PushState, appearance: &Appearance) -> Box<dyn
commit_col.add_child(clickable_summary);
if is_expanded {
if let Some(files) = state.commit_files.get(&commit.hash) {
commit_col.add_child(render_file_list(files, appearance));
} else {
let loading = Container::new(
Text::new(
"Loading…",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(sub_color)
.finish(),
)
.with_padding_left(12.)
.with_padding_bottom(6.)
.finish();
commit_col.add_child(loading);
}
commit_col.add_child(render_file_list(&commit.files, appearance));
}
let bordered_commit = Container::new(commit_col.finish())
@@ -1,126 +1,25 @@
use galaxyui::{Entity, SingletonEntity};
#[cfg(feature = "local_fs")]
use galaxyui::ModelContext;
#[cfg(feature = "local_fs")]
use std::path::{Path, PathBuf};
use std::time::Duration;
#[cfg(feature = "local_fs")]
use {
crate::throttle::throttle,
crate::util::git::{detect_current_branch_display, detect_main_branch},
async_channel::Sender,
galaxyui::{r#async::SpawnedFutureHandle, ModelHandle, WeakModelHandle},
repo_metadata::{
repositories::DetectedRepositories,
repository::{RepositorySubscriber, SubscriberId},
Repository, RepositoryUpdate,
},
std::{collections::HashMap, time::Duration},
};
use async_channel::Sender;
use repo_metadata::repository::{RepositorySubscriber, SubscriberId};
use repo_metadata::{Repository, RepositoryUpdate};
use galaxyui::r#async::SpawnedFutureHandle;
use galaxyui::{Entity, ModelContext, ModelHandle};
#[cfg(feature = "local_fs")]
use super::diff_state::DiffStats;
/// Public metadata exposed to consumers — the subset of diff metadata
/// that the git chip (prompt display, agent view footer) needs.
#[cfg(feature = "local_fs")]
#[derive(Debug, Clone)]
pub struct GitStatusMetadata {
pub current_branch_name: String,
pub main_branch_name: String,
pub stats_against_head: DiffStats,
}
// ── GitStatusUpdateModel (singleton cache) ──────────────────────────────────
/// Singleton model that acts as a cache / factory for per-repository
/// [`GitRepoStatusModel`] instances.
///
/// Multiple terminals in the same repo share a single sub-model. When the last
/// strong handle to a sub-model is dropped, the watcher is torn down
/// automatically.
pub struct GitStatusUpdateModel {
#[cfg(feature = "local_fs")]
repos: HashMap<PathBuf, WeakModelHandle<GitRepoStatusModel>>,
}
// ── Non-local_fs stub ───────────────────────────────────────────────────────
#[cfg(not(feature = "local_fs"))]
#[allow(dead_code)]
impl GitStatusUpdateModel {
pub fn new() -> Self {
Self {}
}
}
// ── local_fs implementation ─────────────────────────────────────────────────
#[cfg(feature = "local_fs")]
impl GitStatusUpdateModel {
pub fn new() -> Self {
Self {
repos: HashMap::new(),
}
}
/// Get or create a per-repo status model for `repo_path`.
///
/// If a live model already exists for this path, returns a new strong handle
/// to it. Otherwise, creates a new [`GitRepoStatusModel`] with an active
/// filesystem watcher and returns a handle to it.
///
/// Callers hold the returned `ModelHandle` for as long as they need updates.
/// When all handles are dropped, the model (and its watcher) is torn down.
pub fn subscribe(
&mut self,
repo_path: &Path,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<ModelHandle<GitRepoStatusModel>> {
let repo_path_buf = repo_path.to_path_buf();
// Check the cache for an existing live model.
if let Some(weak) = self.repos.get(&repo_path_buf) {
if let Some(handle) = weak.upgrade(ctx) {
return Ok(handle);
}
}
// Create a new sub-model.
let Some(repository_model) =
DetectedRepositories::as_ref(ctx).get_watched_repo_for_path(repo_path, ctx)
else {
anyhow::bail!(
"No watched repository found for path: {}",
repo_path.display()
);
};
let handle = ctx
.add_model(|ctx| GitRepoStatusModel::new(repo_path_buf.clone(), repository_model, ctx));
self.repos.insert(repo_path_buf, handle.downgrade());
Ok(handle)
}
}
impl Entity for GitStatusUpdateModel {
type Event = ();
}
impl SingletonEntity for GitStatusUpdateModel {}
// ── GitRepoStatusModel ──────────────────────────────────────────────────────
use super::{GitRepoStatusEvent, GitStatusMetadata};
use crate::code_review::diff_state::diff_metadata_against_head;
use crate::context_chips::display_chip::GitBranchTrackingStatus;
use crate::throttle::throttle;
use crate::util::git::{detect_current_branch_display, detect_main_branch};
/// Per-repository model that owns the filesystem watcher and exposes git status
/// metadata. Consumers hold a `ModelHandle<GitRepoStatusModel>` and subscribe
/// metadata. Consumers hold a `ModelHandle<GitRepoStatusModel>` and subscribe
/// to its events directly — no path-filtering required.
///
/// When all strong handles are dropped the model (and its watcher) is
/// automatically torn down.
#[cfg(feature = "local_fs")]
pub struct GitRepoStatusModel {
pub struct LocalGitRepoStatusModel {
repo_path: PathBuf,
repository: ModelHandle<Repository>,
subscriber_id: Option<SubscriberId>,
@@ -128,23 +27,14 @@ pub struct GitRepoStatusModel {
computing_metadata_abort_handle: Option<SpawnedFutureHandle>,
}
#[cfg(feature = "local_fs")]
#[derive(Debug)]
pub enum GitRepoStatusEvent {
/// Emitted whenever the metadata changes (branch name, diff stats, etc.).
MetadataChanged,
}
#[cfg(feature = "local_fs")]
impl Entity for GitRepoStatusModel {
impl Entity for LocalGitRepoStatusModel {
type Event = GitRepoStatusEvent;
}
#[cfg(feature = "local_fs")]
impl GitRepoStatusModel {
impl LocalGitRepoStatusModel {
/// Create a new per-repo status model, set up the filesystem watcher, and
/// kick off the initial metadata computation.
fn new(
pub(super) fn new(
repo_path: PathBuf,
repository_model: ModelHandle<Repository>,
ctx: &mut ModelContext<Self>,
@@ -238,7 +128,7 @@ impl GitRepoStatusModel {
));
}
// ── internal helpers ────────────────────────────────────────────────
// ── internal helpers ─────────────────────────────────────────────
fn handle_metadata_result(
&mut self,
@@ -246,7 +136,9 @@ impl GitRepoStatusModel {
ctx: &mut ModelContext<Self>,
) {
match result {
Ok(metadata) => self.metadata = Some(metadata),
Ok(metadata) => {
self.metadata = Some(metadata);
}
Err(e) => {
log::warn!("GitRepoStatusModel: metadata load failed: {e}");
self.metadata = None;
@@ -260,7 +152,7 @@ impl GitRepoStatusModel {
if update.is_empty() {
return false;
}
if update.commit_updated || update.index_lock_detected {
if update.commit_updated || update.index_lock_detected || update.remote_ref_updated {
return true;
}
// Check if any non-ignored file was touched.
@@ -276,6 +168,70 @@ impl GitRepoStatusModel {
changed_count > 0
}
fn parse_branch_tracking_counts(output: &str) -> Option<(u32, u32, u32)> {
let mut parts = output.split_whitespace();
let ahead = parts.next()?.parse().ok()?;
let behind = parts.next()?.parse().ok()?;
let equivalent = parts.next().map(str::parse).transpose().ok()?.unwrap_or(0);
Some((ahead, behind, equivalent))
}
async fn branch_tracking_status(
repo_path: &Path,
current_branch_name: &str,
) -> GitBranchTrackingStatus {
let upstream = warp_util::git::run_git_command(
repo_path,
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
)
.await
.ok()
.and_then(|output| {
output
.lines()
.next()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
});
let Some(upstream) = upstream else {
return GitBranchTrackingStatus::new(current_branch_name.to_string(), None, 0, 0);
};
let counts = warp_util::git::run_git_command(
repo_path,
&[
"rev-list",
"--left-right",
"--cherry-mark",
"--count",
"HEAD...@{u}",
],
)
.await
.ok()
.and_then(|output| Self::parse_branch_tracking_counts(&output));
let Some((ahead, behind, equivalent)) = counts else {
return GitBranchTrackingStatus::without_counts(
current_branch_name.to_string(),
Some(upstream),
);
};
if ahead == 0 && behind == 0 && equivalent > 0 {
return GitBranchTrackingStatus::rebased(current_branch_name.to_string(), upstream);
}
GitBranchTrackingStatus::new(
current_branch_name.to_string(),
Some(upstream),
ahead,
behind,
)
}
/// Compute metadata for a repo — branch names and diff stats against HEAD.
///
/// This reuses logic extracted from `DiffStateModel::load_metadata_for_repo`
@@ -288,19 +244,21 @@ impl GitRepoStatusModel {
// shows the short SHA instead of the literal "HEAD").
let current_branch_name = detect_current_branch_display(&repo_path).await?;
// Diff stats against HEAD.
let stats_against_head =
super::diff_state::DiffStateModel::diff_metadata_against_head(&repo_path).await?;
let stats_against_head = diff_metadata_against_head(&repo_path).await?;
let branch_tracking_status =
Self::branch_tracking_status(&repo_path, &current_branch_name).await;
Ok(GitStatusMetadata {
current_branch_name,
main_branch_name,
stats_against_head: stats_against_head.aggregate_stats,
branch_tracking_status,
})
}
}
#[cfg(all(test, feature = "local_fs"))]
impl GitRepoStatusModel {
#[cfg(test)]
impl LocalGitRepoStatusModel {
pub(crate) fn new_for_test(
repository: ModelHandle<Repository>,
metadata: Option<GitStatusMetadata>,
@@ -324,8 +282,11 @@ impl GitRepoStatusModel {
}
}
#[cfg(feature = "local_fs")]
impl Drop for GitRepoStatusModel {
#[cfg(test)]
#[path = "local_tests.rs"]
mod tests;
impl Drop for LocalGitRepoStatusModel {
fn drop(&mut self) {
// Note: we cannot call `repository.update()` here because `Drop` does
// not have access to `ModelContext`. The `Repository` model will clean
@@ -338,12 +299,10 @@ impl Drop for GitRepoStatusModel {
// ── Repository subscriber adapter ───────────────────────────────────────────
#[cfg(feature = "local_fs")]
struct GitStatusRepositorySubscriber {
repository_update_tx: Sender<RepositoryUpdate>,
}
#[cfg(feature = "local_fs")]
impl RepositorySubscriber for GitStatusRepositorySubscriber {
fn on_scan(
&mut self,
@@ -0,0 +1,49 @@
use std::path::PathBuf;
use repo_metadata::{RepositoryUpdate, TargetFile};
use super::*;
#[test]
fn should_refresh_metadata_ignores_ignored_file_updates() {
let mut ignored_update = RepositoryUpdate::default();
ignored_update
.modified
.insert(TargetFile::new(PathBuf::from("/repo/ignored.log"), true));
assert!(!LocalGitRepoStatusModel::should_refresh_metadata(
&ignored_update
));
let mut tracked_update = RepositoryUpdate::default();
tracked_update
.modified
.insert(TargetFile::new(PathBuf::from("/repo/src/main.rs"), false));
assert!(LocalGitRepoStatusModel::should_refresh_metadata(
&tracked_update
));
let remote_ref_update = RepositoryUpdate {
remote_ref_updated: true,
..Default::default()
};
assert!(LocalGitRepoStatusModel::should_refresh_metadata(
&remote_ref_update
));
}
#[cfg(feature = "local_fs")]
#[test]
fn parse_branch_tracking_counts_accepts_git_rev_list_output() {
assert_eq!(
LocalGitRepoStatusModel::parse_branch_tracking_counts("2\t3\n"),
Some((2, 3, 0))
);
assert_eq!(
LocalGitRepoStatusModel::parse_branch_tracking_counts("10 0 4"),
Some((10, 0, 4))
);
assert_eq!(
LocalGitRepoStatusModel::parse_branch_tracking_counts("error"),
None
);
}
+134
View File
@@ -0,0 +1,134 @@
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
#[cfg(feature = "local_fs")]
mod local;
#[cfg(feature = "local_fs")]
pub use local::LocalGitRepoStatusModel;
mod remote;
pub use remote::RemoteGitRepoStatusModel;
use super::diff_state::DiffStats;
pub use super::git_repo_models::GitRepoModels;
use crate::context_chips::display_chip::GitBranchTrackingStatus;
/// Public metadata exposed to consumers — the subset of diff metadata
/// that the git chip (prompt display, agent view footer) needs.
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
#[derive(Debug, Clone)]
pub struct GitStatusMetadata {
pub current_branch_name: String,
pub main_branch_name: String,
pub stats_against_head: DiffStats,
pub branch_tracking_status: GitBranchTrackingStatus,
}
// ── GitRepoStatusModel ──────────────────────────────────────────────────────
#[derive(Debug)]
pub enum GitRepoStatusEvent {
/// Emitted whenever the metadata changes (branch name, diff stats, etc.).
MetadataChanged,
}
// ── Unified GitRepoStatusModel (local or remote backend) ────────────────────
/// Unified per-repo git status model that dispatches to a local or remote
/// backend, mirroring [`crate::code_review::diff_state::DiffStateModel`].
///
/// Consumers (prompt chips, tabs, code review, agent context) hold a
/// `ModelHandle<GitRepoStatusModel>` and subscribe to its [`GitRepoStatusEvent`]s
/// without caring whether the repository is local or on an SSH host. Only one
/// variant is populated at a time.
pub enum GitRepoStatusModel {
#[cfg(feature = "local_fs")]
Local(ModelHandle<LocalGitRepoStatusModel>),
Remote(ModelHandle<RemoteGitRepoStatusModel>),
}
impl Entity for GitRepoStatusModel {
type Event = GitRepoStatusEvent;
}
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
impl GitRepoStatusModel {
/// Re-emit a sub-model event so subscribers of the unified model observe
/// the same `GitRepoStatusEvent`s regardless of backend.
fn forward_event(&mut self, event: &GitRepoStatusEvent, ctx: &mut ModelContext<Self>) {
match event {
GitRepoStatusEvent::MetadataChanged => ctx.emit(GitRepoStatusEvent::MetadataChanged),
}
}
/// Mode-independent status metadata (branch names + HEAD diff stats).
pub fn metadata<'a>(&self, ctx: &'a AppContext) -> Option<&'a GitStatusMetadata> {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.as_ref(ctx).metadata(),
Self::Remote(m) => m.as_ref(ctx).metadata(),
}
}
/// Force a metadata refresh (branch names, diff stats).
pub fn refresh_metadata(&self, ctx: &mut ModelContext<Self>) {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.update(ctx, |m, ctx| m.refresh_metadata(ctx)),
Self::Remote(m) => m.update(ctx, |m, ctx| m.request_snapshot(ctx)),
}
}
}
#[cfg(feature = "local_fs")]
pub(super) fn new_local_git_repo_status_model(
repo_path: std::path::PathBuf,
repository_model: ModelHandle<repo_metadata::Repository>,
ctx: &mut ModelContext<GitRepoModels>,
) -> ModelHandle<GitRepoStatusModel> {
let inner = ctx.add_model(|ctx| LocalGitRepoStatusModel::new(repo_path, repository_model, ctx));
ctx.add_model(|ctx| {
ctx.subscribe_to_model(&inner, |me, _, event, ctx| {
GitRepoStatusModel::forward_event(me, event, ctx)
});
GitRepoStatusModel::Local(inner)
})
}
pub(super) fn new_remote_git_repo_status_model(
remote_path: warp_util::remote_path::RemotePath,
ctx: &mut ModelContext<GitRepoModels>,
) -> ModelHandle<GitRepoStatusModel> {
let inner = ctx.add_model(|ctx| RemoteGitRepoStatusModel::new(remote_path, ctx));
ctx.add_model(|ctx| {
ctx.subscribe_to_model(&inner, |me, _, event, ctx| {
GitRepoStatusModel::forward_event(me, event, ctx)
});
GitRepoStatusModel::Remote(inner)
})
}
#[cfg(all(test, feature = "local_fs"))]
impl GitRepoStatusModel {
/// Wraps a local-backend test model in the unified enum.
pub(crate) fn new_local_for_test(
repository: ModelHandle<repo_metadata::Repository>,
metadata: Option<GitStatusMetadata>,
ctx: &mut ModelContext<Self>,
) -> Self {
let inner =
ctx.add_model(move |_| LocalGitRepoStatusModel::new_for_test(repository, metadata));
ctx.subscribe_to_model(&inner, |me, _, event, ctx| me.forward_event(event, ctx));
Self::Local(inner)
}
pub(crate) fn set_metadata_for_test(
&mut self,
metadata: Option<GitStatusMetadata>,
ctx: &mut ModelContext<Self>,
) {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.update(ctx, |m, ctx| m.set_metadata_for_test(metadata, ctx)),
Self::Remote(_) => unreachable!("remote test models are not used"),
}
}
}
@@ -0,0 +1,86 @@
use remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
use warp_util::remote_path::RemotePath;
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use super::{GitRepoStatusEvent, GitStatusMetadata};
use crate::remote_server::proto;
/// Client-side per-repo git status for a repository on an SSH host.
///
/// Holds the latest [`GitStatusMetadata`] for its `(host_id, repo_path)`,
/// emitting [`GitRepoStatusEvent`]s on change. On construction (and again on
/// reconnect) it sends an `UpdateGitStatus` notification asking the daemon to
/// push the current snapshot; live watcher updates then arrive as
/// `GitStatusPush` messages filtered by `(host_id, repo_path)`.
/// `HostDisconnected` preserves stale data.
pub struct RemoteGitRepoStatusModel {
remote_path: RemotePath,
metadata: Option<GitStatusMetadata>,
}
impl Entity for RemoteGitRepoStatusModel {
type Event = GitRepoStatusEvent;
}
impl RemoteGitRepoStatusModel {
pub fn new(remote_path: RemotePath, ctx: &mut ModelContext<Self>) -> Self {
let mgr = RemoteServerManager::handle(ctx);
ctx.subscribe_to_model(&mgr, Self::handle_manager_event);
let model = Self {
remote_path,
metadata: None,
};
model.request_snapshot(ctx);
model
}
fn handle_manager_event(
&mut self,
_: ModelHandle<RemoteServerManager>,
event: &RemoteServerManagerEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
RemoteServerManagerEvent::GitStatusPushReceived {
host_id,
repo_path,
metadata,
} if self.remote_path.matches(host_id, repo_path) => {
self.apply_push(metadata, ctx);
}
RemoteServerManagerEvent::HostConnected { host_id }
if host_id == &self.remote_path.host_id =>
{
self.request_snapshot(ctx);
}
_ => {}
}
}
pub(super) fn request_snapshot(&self, ctx: &mut ModelContext<Self>) {
RemoteServerManager::handle(ctx).update(ctx, |mgr, _| {
mgr.update_git_status(self.remote_path.host_id.clone(), &self.remote_path.path);
});
}
/// Decode a pushed `GitStatusMetadata` (branch + stats) and replace the
/// stored value, emitting `MetadataChanged`.
fn apply_push(&mut self, metadata: &proto::GitStatusMetadata, ctx: &mut ModelContext<Self>) {
match GitStatusMetadata::try_from(metadata) {
Ok(status) => {
self.metadata = Some(status);
ctx.emit(GitRepoStatusEvent::MetadataChanged);
}
Err(error) => {
galaxy_core::safe_error!(
safe: ("RemoteGitRepoStatusModel: failed to decode git status push"),
full: ("RemoteGitRepoStatusModel: failed to decode git status push: {error}")
);
}
}
}
pub fn metadata(&self) -> Option<&GitStatusMetadata> {
self.metadata.as_ref()
}
}
+163
View File
@@ -0,0 +1,163 @@
use std::collections::HashMap;
#[cfg(feature = "local_fs")]
use repo_metadata::repositories::DetectedRepositories;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle};
#[cfg(feature = "local_fs")]
use super::git_repo_model::new_local_git_repo_status_model;
use super::git_repo_model::{new_remote_git_repo_status_model, GitRepoStatusModel};
#[cfg(feature = "local_fs")]
use super::github_repo_model::LocalGitHubRepoModel;
use super::github_repo_model::{GitHubRepoModel, RemoteGitHubRepoModel};
// ── GitRepoModels (singleton cache) ─────────────────────────────────────────
/// Singleton model that acts as a cache / factory for per-repository
/// [`GitRepoStatusModel`] and [`GitHubRepoModel`] instances.
///
/// Multiple terminals in the same repo share a single sub-model. When the last
/// strong handle to a sub-model is dropped, the models are torn down automatically.
pub struct GitRepoModels {
// Per-repo status / GitHub-info models, keyed by `LocalOrRemotePath` so a
// single cache covers both local (watcher-backed) and remote (push
// receiver) repos. Each entry stores the unified-enum handle; callers in
// the same repo share it, and it is torn down when the last strong handle
// is dropped.
git_status_models: HashMap<LocalOrRemotePath, WeakModelHandle<GitRepoStatusModel>>,
github_repo_models: HashMap<LocalOrRemotePath, WeakModelHandle<GitHubRepoModel>>,
}
impl GitRepoModels {
pub fn new() -> Self {
Self {
git_status_models: HashMap::new(),
github_repo_models: HashMap::new(),
}
}
/// Get or create the per-repo status model for `repo`, returning a unified
/// [`GitRepoStatusModel`] handle that dispatches to a local watcher-backed
/// model or a remote push receiver based on the location.
///
/// Multiple callers in the same repo share one model (cached by
/// `LocalOrRemotePath`); it is torn down when the last strong handle is
/// dropped.
///
/// Callers hold the returned `ModelHandle` for as long as they need updates.
pub fn subscribe(
&mut self,
repo: &LocalOrRemotePath,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<ModelHandle<GitRepoStatusModel>> {
if let Some(handle) = self
.git_status_models
.get(repo)
.and_then(|weak| weak.upgrade(ctx))
{
return Ok(handle);
}
let handle = match repo {
LocalOrRemotePath::Local(repo_path) => {
#[cfg(feature = "local_fs")]
{
let Some(repository_model) = DetectedRepositories::as_ref(ctx)
.get_local_watched_repo_for_path(repo_path, ctx)
else {
anyhow::bail!(
"No watched repository found for path: {}",
repo_path.display()
);
};
new_local_git_repo_status_model(repo_path.clone(), repository_model, ctx)
}
#[cfg(not(feature = "local_fs"))]
{
anyhow::bail!(
"No watched repository found for path: {}",
repo_path.display()
);
}
}
LocalOrRemotePath::Remote(remote_path) => {
new_remote_git_repo_status_model(remote_path.clone(), ctx)
}
};
self.git_status_models
.insert(repo.clone(), handle.downgrade());
Ok(handle)
}
/// Get or create the per-repo GitHub-info model for `repo`, returning a
/// unified [`GitHubRepoModel`] handle that dispatches to a local
/// `gh`-driven model or a remote push receiver based on the location.
///
/// The local backend subscribes to the sibling git status model to track
/// the current branch and fetches PR / repository info on creation, on
/// branch change, and on a periodic timer. Multiple callers in the same
/// repo share one model (cached by `LocalOrRemotePath`).
///
/// Callers hold the returned `ModelHandle` for as long as they need updates.
pub fn subscribe_github_repo(
&mut self,
repo: &LocalOrRemotePath,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<ModelHandle<GitHubRepoModel>> {
if let Some(handle) = self
.github_repo_models
.get(repo)
.and_then(|weak| weak.upgrade(ctx))
{
return Ok(handle);
}
let handle = match repo {
LocalOrRemotePath::Local(repo_path) => {
#[cfg(feature = "local_fs")]
{
// LocalGitHubRepoModel needs a sibling GitRepoStatusModel for
// branch info.
let git_status = self.subscribe(repo, ctx)?;
let repo_path = repo_path.clone();
let inner =
ctx.add_model(|ctx| LocalGitHubRepoModel::new(repo_path, git_status, ctx));
ctx.add_model(|ctx| {
ctx.subscribe_to_model(&inner, |me, _, event, ctx| {
GitHubRepoModel::forward_event(me, event, ctx)
});
GitHubRepoModel::Local(inner)
})
}
#[cfg(not(feature = "local_fs"))]
{
anyhow::bail!(
"Local GitHub repo info is unavailable without local_fs: {}",
repo_path.display()
);
}
}
LocalOrRemotePath::Remote(remote_path) => {
let inner =
ctx.add_model(|ctx| RemoteGitHubRepoModel::new(remote_path.clone(), ctx));
ctx.add_model(|ctx| {
ctx.subscribe_to_model(&inner, |me, _, event, ctx| {
GitHubRepoModel::forward_event(me, event, ctx)
});
GitHubRepoModel::Remote(inner)
})
}
};
self.github_repo_models
.insert(repo.clone(), handle.downgrade());
Ok(handle)
}
}
impl Entity for GitRepoModels {
type Event = ();
}
impl SingletonEntity for GitRepoModels {}
@@ -0,0 +1,389 @@
use std::path::PathBuf;
use std::time::Duration;
use settings::Setting as _;
use warpui::r#async::SpawnedFutureHandle;
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity as _};
use super::GitHubRepoEvent;
use crate::code_review::git_repo_model::{GitRepoStatusEvent, GitRepoStatusModel};
use crate::report_if_error;
#[cfg(feature = "local_tty")]
use crate::terminal::local_shell::LocalShellState;
use crate::terminal::session_settings::{GithubPrPromptChipDefaultValidation, SessionSettings};
use crate::util::git::{
get_pr_for_branch, get_repository_info, is_gh_auth_error, is_gh_missing_error, PrInfo,
RepositoryInfo,
};
const PR_INFO_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
const GITHUB_INFO_PERIODIC_REFRESH: Duration = Duration::from_secs(60);
const REPOSITORY_INFO_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
/// Per-repository model that owns the GitHub-sourced metadata lifecycle for a
/// single repo — the values fetched through the (relatively expensive) `gh`
/// CLI rather than local `git`:
/// - `pr_info` for the current branch (`gh pr view`), and
/// - `repository_info` (name/owner) for the repo (`gh repo view`).
///
/// `GitHubRepoModel` is created lazily when a consumer asks for it via
/// [`crate::code_review::git_repo_model::GitRepoModels::subscribe_github_repo`].
/// While at least one strong `ModelHandle<GitHubRepoModel>` is alive, the model:
/// - tracks the current branch by subscribing to its sibling
/// [`GitRepoStatusModel`] for `MetadataChanged` events,
/// - fetches `gh pr view` for the current branch on creation, on branch
/// change, and on a periodic timer,
/// - fetches `gh repo view` on creation and re-checks it on the periodic
/// timer (independent of the branch), and
/// - emits [`GitHubRepoEvent`] when the cached PR or repository info moves.
///
/// `repository_info` is intentionally NOT refreshed on branch change: the
/// repo's name/owner does not depend on the checked-out branch, so a branch
/// flip must not trigger a fresh `gh repo view`.
///
/// When the last strong handle is dropped, the model is torn down and any
/// in-flight `gh` fetch is aborted. The sibling [`GitRepoStatusModel`] is
/// retained via a strong handle, so creating a `LocalGitHubRepoModel` keeps git
/// status alive for as long as GitHub info is needed.
pub struct LocalGitHubRepoModel {
repo_path: PathBuf,
/// Strong handle to the sibling git-status model. Keeps it alive so we
/// always have a branch source.
git_status: ModelHandle<GitRepoStatusModel>,
/// Current branch name, mirrored from `git_status`. `None` until the
/// sibling's metadata is available.
branch: Option<String>,
/// PR info for `branch`. `None` means no fetch has succeeded yet, the
/// branch has no PR, or fetching is suppressed (gh missing/auth error).
pr_info: Option<PrInfo>,
/// Repository info (name/owner) returned by `gh repo view`. Branch-
/// independent; fetched on creation and re-checked on the periodic tick.
repository_info: Option<RepositoryInfo>,
/// Handle for the in-flight `gh pr view` fetch, if any. Aborted in `Drop`.
/// Used to avoid overlapping PR-info fetches; branch changes abort the
/// current handle before starting a new branch's fetch.
refreshing_pr_info_abort_handle: Option<SpawnedFutureHandle>,
/// Handle for the in-flight `gh repo view` fetch, if any. Aborted in
/// `Drop`. Used to avoid overlapping repository-info fetches.
repository_info_abort_handle: Option<SpawnedFutureHandle>,
/// Handle for the pending periodic-refresh tick. Aborted in `Drop` so
/// the timer doesn't outlive the model.
periodic_refresh_handle: Option<SpawnedFutureHandle>,
}
impl Entity for LocalGitHubRepoModel {
type Event = GitHubRepoEvent;
}
impl LocalGitHubRepoModel {
/// Create a new per-repo GitHub-info model.
///
/// Subscribes to `git_status` for `MetadataChanged` events to track the
/// current branch. Seeds `branch` from the sibling's current metadata
/// (if any) and kicks off an initial PR fetch when the branch is known.
/// Also schedules a periodic refresh so a previously-suppressed default
/// chip can recover after the user installs/authenticates `gh`, and kicks
/// off the one-shot `gh repo view` fetch.
pub(crate) fn new(
repo_path: PathBuf,
git_status: ModelHandle<GitRepoStatusModel>,
ctx: &mut ModelContext<Self>,
) -> Self {
let branch = git_status
.as_ref(ctx)
.metadata(ctx)
.map(|m| m.current_branch_name.clone());
// Track branch changes from the sibling. Only PR info depends on the
// branch — repository info is deliberately left untouched here.
ctx.subscribe_to_model(&git_status, |me, _, event, ctx| match event {
GitRepoStatusEvent::MetadataChanged => {
let new_branch = me
.git_status
.as_ref(ctx)
.metadata(ctx)
.map(|m| m.current_branch_name.clone());
if new_branch != me.branch {
me.branch = new_branch;
if me.pr_info.take().is_some() {
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
if let Some(handle) = me.refreshing_pr_info_abort_handle.take() {
handle.abort();
}
me.refresh_pr_info(ctx);
}
}
});
let mut model = Self {
repo_path,
git_status,
branch,
pr_info: None,
repository_info: None,
refreshing_pr_info_abort_handle: None,
repository_info_abort_handle: None,
periodic_refresh_handle: None,
};
// Schedule periodic refresh of PR info and repository info.
// This is necessary to recover from transient `gh` command failures.
model.schedule_periodic_refresh(ctx);
// Fetch repository info which is branch-independent.
model.refresh_repository_info(ctx);
// Fetch PR info if the branch is known.
if model.branch.is_some() {
model.refresh_pr_info(ctx);
}
model
}
/// Schedules a periodic timer that refreshes PR info and repository info.
fn schedule_periodic_refresh(&mut self, ctx: &mut ModelContext<Self>) {
let handle = ctx.spawn(
async {
async_io::Timer::after(GITHUB_INFO_PERIODIC_REFRESH).await;
},
|me, _, ctx| {
me.refresh_pr_info(ctx);
me.refresh_repository_info(ctx);
me.schedule_periodic_refresh(ctx);
},
);
self.periodic_refresh_handle = Some(handle);
}
/// PR info for the current branch.
pub fn pr_info(&self) -> Option<&PrInfo> {
self.pr_info.as_ref()
}
/// Repository info (name/owner) returned by `gh repo view`.
pub fn repository_info(&self) -> Option<&RepositoryInfo> {
self.repository_info.as_ref()
}
/// Whether a `gh pr view` fetch is currently in flight.
pub fn is_refreshing_pr_info(&self) -> bool {
self.refreshing_pr_info_abort_handle.is_some()
}
/// Manually trigger a PR-info refresh. Called after `gh`/`gt` commands
/// complete, since those don't touch `.git/` so the filesystem watcher won't
/// catch them.
pub fn refresh_pr_info(&mut self, ctx: &mut ModelContext<Self>) {
let Some(branch) = self.branch.clone() else {
return;
};
// Branch changes abort in-flight fetches, so any handle
// here is already for the current branch.
if self.refreshing_pr_info_abort_handle.is_some() {
return;
}
let repo_path = self.repo_path.clone();
#[cfg(feature = "local_tty")]
let path_future = {
// Use the shell's interactive PATH so `gh` can be found when Warp
// was launched outside of a login shell, e.g. from the macOS GUI.
LocalShellState::handle(ctx).update(ctx, |shell_state, ctx| {
shell_state.get_interactive_path_env_var(ctx)
})
};
#[cfg(not(feature = "local_tty"))]
let path_future = futures::future::ready(None);
let branch_for_callback = branch.clone();
let abort_handle = ctx.spawn(
async move {
let path_env = path_future.await;
let fetch = get_pr_for_branch(&repo_path, path_env.as_deref());
let timeout = async_io::Timer::after(PR_INFO_FETCH_TIMEOUT);
futures::pin_mut!(fetch);
match futures::future::select(fetch, timeout).await {
futures::future::Either::Left((result, _)) => result,
futures::future::Either::Right((_, _)) => {
Err(anyhow::anyhow!("PR info fetch timed out"))
}
}
},
move |me, result, ctx| {
me.refreshing_pr_info_abort_handle = None;
me.handle_fetch_result(result, branch_for_callback, ctx);
},
);
self.refreshing_pr_info_abort_handle = Some(abort_handle);
}
/// Fetch repository info (`gh repo view`). Branch-independent: kicked off
/// on creation and re-checked by the periodic timer on each tick. Never
/// called from the branch-change path, so switching branches does not
/// trigger a `gh repo view`.
pub fn refresh_repository_info(&mut self, ctx: &mut ModelContext<Self>) {
// Guard against overlapping fetches.
if self.repository_info_abort_handle.is_some() {
return;
}
let repo_path = self.repo_path.clone();
#[cfg(feature = "local_tty")]
let path_future = {
// Use the shell's interactive PATH so `gh` can be found when Warp
// was launched outside of a login shell, e.g. from the macOS GUI.
LocalShellState::handle(ctx).update(ctx, |shell_state, ctx| {
shell_state.get_interactive_path_env_var(ctx)
})
};
#[cfg(not(feature = "local_tty"))]
let path_future = futures::future::ready(None);
self.repository_info_abort_handle = Some(ctx.spawn(
async move {
let path_env = path_future.await;
let fetch = get_repository_info(&repo_path, path_env.as_deref());
let timeout = async_io::Timer::after(REPOSITORY_INFO_FETCH_TIMEOUT);
futures::pin_mut!(fetch);
match futures::future::select(fetch, timeout).await {
futures::future::Either::Left((result, _)) => result,
futures::future::Either::Right((_, _)) => {
Err(anyhow::anyhow!("Repository info fetch timed out"))
}
}
},
|me, result, ctx| {
me.repository_info_abort_handle = None;
me.handle_repository_info_result(result, ctx);
},
));
}
fn handle_repository_info_result(
&mut self,
result: anyhow::Result<Option<RepositoryInfo>>,
ctx: &mut ModelContext<Self>,
) {
match result {
Ok(repository_info) => {
if self.repository_info != repository_info {
self.repository_info = repository_info;
ctx.emit(GitHubRepoEvent::RepositoryInfoChanged);
}
}
Err(err) => {
log::debug!("GitHubRepoModel: repository info load failed: {err}");
}
}
}
fn handle_fetch_result(
&mut self,
result: anyhow::Result<Option<PrInfo>>,
branch: String,
ctx: &mut ModelContext<Self>,
) {
match result {
Ok(pr_info) => {
Self::maybe_validate_github_pr_default(ctx);
// Only emit when the updated branch is still current.
if self.branch.as_deref() == Some(branch.as_str()) {
let changed = self.pr_info.as_ref() != pr_info.as_ref();
self.pr_info = pr_info;
if changed {
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
}
}
Err(e) => {
let error_msg = e.to_string();
if is_gh_missing_error(&error_msg) || is_gh_auth_error(&error_msg) {
log::info!(
"GitHubRepoModel: suppressing default PR chip \
due to deterministic gh setup error"
);
if self.pr_info.take().is_some() {
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
Self::maybe_suppress_github_pr_default(ctx);
}
// On transient errors, keep existing PR info to avoid
// flashing the UI.
}
}
}
fn maybe_suppress_github_pr_default(ctx: &mut ModelContext<Self>) {
let current = *SessionSettings::as_ref(ctx).github_pr_chip_default_validation;
if current != GithubPrPromptChipDefaultValidation::Suppressed {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.github_pr_chip_default_validation
.set_value(GithubPrPromptChipDefaultValidation::Suppressed, ctx));
});
}
}
fn maybe_validate_github_pr_default(ctx: &mut ModelContext<Self>) {
let current = *SessionSettings::as_ref(ctx).github_pr_chip_default_validation;
if current != GithubPrPromptChipDefaultValidation::Validated {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.github_pr_chip_default_validation
.set_value(GithubPrPromptChipDefaultValidation::Validated, ctx));
});
}
}
}
#[cfg(test)]
impl LocalGitHubRepoModel {
/// Inert constructor: no branch-tracking subscription, timers, or `gh`
/// fetch, so tests stay deterministic and never spawn a real subprocess.
/// Drive state via the `set_*_for_test` helpers.
pub(crate) fn new_for_test(git_status: ModelHandle<GitRepoStatusModel>) -> Self {
Self {
repo_path: PathBuf::from("/test"),
git_status,
branch: None,
pr_info: None,
repository_info: None,
refreshing_pr_info_abort_handle: None,
repository_info_abort_handle: None,
periodic_refresh_handle: None,
}
}
pub(crate) fn set_pr_info_for_test(
&mut self,
pr_info: Option<PrInfo>,
ctx: &mut ModelContext<Self>,
) {
self.pr_info = pr_info;
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
pub(crate) fn set_repository_info_for_test(
&mut self,
repository_info: Option<RepositoryInfo>,
ctx: &mut ModelContext<Self>,
) {
self.repository_info = repository_info;
ctx.emit(GitHubRepoEvent::RepositoryInfoChanged);
}
}
#[cfg(test)]
#[path = "local_tests.rs"]
mod tests;
impl Drop for LocalGitHubRepoModel {
fn drop(&mut self) {
if let Some(h) = self.refreshing_pr_info_abort_handle.take() {
h.abort();
}
if let Some(h) = self.repository_info_abort_handle.take() {
h.abort();
}
if let Some(h) = self.periodic_refresh_handle.take() {
h.abort();
}
}
}
@@ -0,0 +1,167 @@
use repo_metadata::DirectoryWatcher;
use warp_util::standardized_path::StandardizedPath;
use warpui::{App, ModelHandle};
use super::*;
use crate::code_review::git_repo_model::GitRepoStatusModel;
use crate::util::git::RepositoryInfo;
fn pr(number: u64) -> PrInfo {
PrInfo {
number,
url: format!("https://github.com/warp/warp/pull/{number}"),
state: "OPEN".to_string(),
draft: false,
base_branch: "main".to_string(),
}
}
fn repository_info() -> RepositoryInfo {
RepositoryInfo {
name: "warp".to_string(),
owner: Some("warpdotdev".to_string()),
}
}
fn test_repository_handle(
app: &mut App,
temp_dir: &tempfile::TempDir,
) -> ModelHandle<repo_metadata::Repository> {
let watcher_handle = app.add_singleton_model(DirectoryWatcher::new_for_testing);
watcher_handle.update(app, |watcher, ctx| {
watcher
.add_directory(
StandardizedPath::from_local_canonicalized(temp_dir.path()).unwrap(),
ctx,
)
.unwrap()
})
}
/// Builds an inert `GitHubRepoModel` over a throwaway sibling git-status
/// model. The model never subscribes or fetches; tests drive state directly.
fn new_github_repo_model_for_test(
app: &mut App,
) -> (tempfile::TempDir, ModelHandle<LocalGitHubRepoModel>) {
let temp_dir = tempfile::TempDir::new().unwrap();
let repository = test_repository_handle(app, &temp_dir);
let git_status =
app.add_model(move |ctx| GitRepoStatusModel::new_local_for_test(repository, None, ctx));
let model = app.add_model(move |_| LocalGitHubRepoModel::new_for_test(git_status));
(temp_dir, model)
}
#[test]
fn pr_info_cleared_on_branch_change() {
App::test((), |mut app| async move {
let (_temp_dir, model) = new_github_repo_model_for_test(&mut app);
// On feature-a with a cached PR.
model.update(&mut app, |model, ctx| {
model.branch = Some("feature-a".to_string());
model.set_pr_info_for_test(Some(pr(123)), ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.pr_info(), Some(&pr(123)));
});
// Switching branches clears the now-stale PR.
model.update(&mut app, |model, ctx| {
model.branch = Some("feature-b".to_string());
if model.pr_info.take().is_some() {
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
});
model.read(&app, |model, _| {
assert_eq!(model.pr_info(), None);
});
});
}
#[test]
fn repository_info_preserved_on_fetch_error() {
App::test((), |mut app| async move {
let (_temp_dir, model) = new_github_repo_model_for_test(&mut app);
model.update(&mut app, |model, ctx| {
model.set_repository_info_for_test(Some(repository_info()), ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.repository_info(), Some(&repository_info()));
});
model.update(&mut app, |model, ctx| {
model.handle_repository_info_result(Err(anyhow::anyhow!("gh failed")), ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.repository_info(), Some(&repository_info()));
});
});
}
#[test]
fn repository_info_cleared_on_authoritative_empty_result() {
App::test((), |mut app| async move {
let (_temp_dir, model) = new_github_repo_model_for_test(&mut app);
model.update(&mut app, |model, ctx| {
model.set_repository_info_for_test(Some(repository_info()), ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.repository_info(), Some(&repository_info()));
});
model.update(&mut app, |model, ctx| {
model.handle_repository_info_result(Ok(None), ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.repository_info(), None);
});
});
}
#[test]
fn pr_info_cleared_when_branch_goes_away() {
App::test((), |mut app| async move {
let (_temp_dir, model) = new_github_repo_model_for_test(&mut app);
model.update(&mut app, |model, ctx| {
model.branch = Some("feature-a".to_string());
model.set_pr_info_for_test(Some(pr(123)), ctx);
});
// Branch goes to `None` (e.g. metadata load failure / detached HEAD).
model.update(&mut app, |model, ctx| {
model.branch = None;
if model.pr_info.take().is_some() {
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
});
model.read(&app, |model, _| {
assert_eq!(model.pr_info(), None);
});
});
}
#[test]
fn repository_info_survives_branch_change() {
App::test((), |mut app| async move {
let (_temp_dir, model) = new_github_repo_model_for_test(&mut app);
model.update(&mut app, |model, ctx| {
model.branch = Some("feature-a".to_string());
model.set_repository_info_for_test(Some(repository_info()), ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.repository_info(), Some(&repository_info()));
});
// Repository info is branch-independent — a branch change leaves it.
model.update(&mut app, |model, _| {
model.branch = Some("feature-b".to_string());
});
model.read(&app, |model, _| {
assert_eq!(model.repository_info(), Some(&repository_info()));
});
});
}
@@ -0,0 +1,136 @@
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
#[cfg(feature = "local_fs")]
mod local;
#[cfg(feature = "local_fs")]
pub use local::LocalGitHubRepoModel;
mod remote;
pub use remote::RemoteGitHubRepoModel;
#[cfg(all(test, feature = "local_fs"))]
use crate::code_review::git_repo_model::GitRepoStatusModel;
use crate::util::git::{PrInfo, RepositoryInfo};
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
#[derive(Debug)]
pub enum GitHubRepoEvent {
/// Emitted when `pr_info` changes value (fetch result differs from
/// cached, branch change cleared the cache, etc.).
PrInfoChanged,
/// Emitted when `repository_info` changes value.
RepositoryInfoChanged,
}
// ── Unified GitHubRepoModel (local or remote backend) ───────────────────────
/// Unified per-repo GitHub-info model that dispatches to a local or remote
/// backend, mirroring [`crate::code_review::git_repo_model::GitRepoStatusModel`].
///
/// Consumers (prompt chips, code review, agent context) hold a
/// `ModelHandle<GitHubRepoModel>` and subscribe to its [`GitHubRepoEvent`]s
/// without caring whether the repository is local or on an SSH host.
pub enum GitHubRepoModel {
#[cfg(feature = "local_fs")]
Local(ModelHandle<LocalGitHubRepoModel>),
Remote(ModelHandle<RemoteGitHubRepoModel>),
}
impl Entity for GitHubRepoModel {
type Event = GitHubRepoEvent;
}
impl GitHubRepoModel {
/// Re-emit a sub-model event so subscribers of the unified model observe
/// the same `GitHubRepoEvent`s regardless of backend.
pub(crate) fn forward_event(&mut self, event: &GitHubRepoEvent, ctx: &mut ModelContext<Self>) {
match event {
GitHubRepoEvent::PrInfoChanged => ctx.emit(GitHubRepoEvent::PrInfoChanged),
GitHubRepoEvent::RepositoryInfoChanged => {
ctx.emit(GitHubRepoEvent::RepositoryInfoChanged)
}
}
}
/// PR info for the current branch.
pub fn pr_info<'a>(&self, ctx: &'a AppContext) -> Option<&'a PrInfo> {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.as_ref(ctx).pr_info(),
Self::Remote(m) => m.as_ref(ctx).pr_info(),
}
}
/// Repository info (name/owner) returned by `gh repo view`.
pub fn repository_info<'a>(&self, ctx: &'a AppContext) -> Option<&'a RepositoryInfo> {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.as_ref(ctx).repository_info(),
Self::Remote(m) => m.as_ref(ctx).repository_info(),
}
}
/// Whether a `gh pr view` fetch is currently in flight.
pub fn is_refreshing_pr_info(&self, ctx: &AppContext) -> bool {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.as_ref(ctx).is_refreshing_pr_info(),
Self::Remote(m) => m.as_ref(ctx).is_refreshing_pr_info(),
}
}
/// Force a PR info refresh (e.g. after a `gh`/`gt` command completes).
pub fn refresh_pr_info(&self, ctx: &mut ModelContext<Self>) {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.update(ctx, |m, ctx| m.refresh_pr_info(ctx)),
Self::Remote(m) => m.update(ctx, |m, ctx| m.refresh_pr_info(ctx)),
}
}
/// Force a repository-info refresh.
pub fn refresh_repository_info(&self, ctx: &mut ModelContext<Self>) {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.update(ctx, |m, ctx| m.refresh_repository_info(ctx)),
Self::Remote(m) => m.update(ctx, |m, ctx| m.refresh_repository_info(ctx)),
}
}
}
#[cfg(all(test, feature = "local_fs"))]
impl GitHubRepoModel {
/// Wraps an inert local-backend test model in the unified enum.
pub(crate) fn new_local_for_test(
git_status: ModelHandle<GitRepoStatusModel>,
ctx: &mut ModelContext<Self>,
) -> Self {
let inner = ctx.add_model(move |_| LocalGitHubRepoModel::new_for_test(git_status));
ctx.subscribe_to_model(&inner, |me, _, event, ctx| me.forward_event(event, ctx));
Self::Local(inner)
}
pub(crate) fn set_pr_info_for_test(
&mut self,
pr_info: Option<PrInfo>,
ctx: &mut ModelContext<Self>,
) {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.update(ctx, |m, ctx| m.set_pr_info_for_test(pr_info, ctx)),
Self::Remote(_) => unreachable!("remote test models are not used"),
}
}
pub(crate) fn set_repository_info_for_test(
&mut self,
repository_info: Option<RepositoryInfo>,
ctx: &mut ModelContext<Self>,
) {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.update(ctx, |m, ctx| {
m.set_repository_info_for_test(repository_info, ctx)
}),
Self::Remote(_) => unreachable!("remote test models are not used"),
}
}
}
@@ -0,0 +1,148 @@
use remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
use warp_util::remote_path::RemotePath;
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use super::GitHubRepoEvent;
use crate::remote_server::proto;
use crate::util::git::{PrInfo, RepositoryInfo};
/// Client-side per-repo GitHub info for a repository on an SSH host.
///
/// Presents the same read surface as [`super::LocalGitHubRepoModel`] and emits the
/// same [`GitHubRepoEvent`]s so the unified [`super::GitHubRepoModel`] can substitute
/// it transparently (mirrors `RemoteGitRepoStatusModel`).
///
/// Pure push receiver: holds the latest PR / repository info for its
/// `(host_id, repo_path)`. On construction (and again on reconnect) it sends
/// `UpdateGitHubPrInfo` / `UpdateGitHubRepoInfo` notifications asking the daemon
/// to create the per-repo model if needed and refresh; results then arrive as
/// server-broadcast push messages filtered by `(host_id, repo_path)`. The
/// daemon's `GitHubRepoModel` is the single source of truth, so there is no
/// request/response and no client-side refresh state. `HostDisconnected`
/// preserves stale data.
pub struct RemoteGitHubRepoModel {
remote_path: RemotePath,
pr_info: Option<PrInfo>,
repository_info: Option<RepositoryInfo>,
}
impl Entity for RemoteGitHubRepoModel {
type Event = GitHubRepoEvent;
}
impl RemoteGitHubRepoModel {
pub fn new(remote_path: RemotePath, ctx: &mut ModelContext<Self>) -> Self {
let mgr = RemoteServerManager::handle(ctx);
ctx.subscribe_to_model(&mgr, Self::handle_manager_event);
let model = Self {
remote_path,
pr_info: None,
repository_info: None,
};
model.request_github_info(ctx);
model
}
fn handle_manager_event(
&mut self,
_: ModelHandle<RemoteServerManager>,
event: &RemoteServerManagerEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
RemoteServerManagerEvent::GitHubPrInfoPushReceived {
host_id,
repo_path,
pr_info,
} if self.remote_path.matches(host_id, repo_path) => {
self.apply_pr_info_push(pr_info.as_ref(), ctx);
}
RemoteServerManagerEvent::GitHubRepositoryInfoPushReceived {
host_id,
repo_path,
repository_info,
} if self.remote_path.matches(host_id, repo_path) => {
self.apply_repository_info_push(repository_info.as_ref(), ctx);
}
RemoteServerManagerEvent::HostConnected { host_id }
if host_id == &self.remote_path.host_id =>
{
self.request_github_info(ctx);
}
_ => {}
}
}
/// Asks the daemon to (create and) refresh both PR and repository info.
/// Fire-and-forget; results arrive as push broadcasts.
fn request_github_info(&self, ctx: &mut ModelContext<Self>) {
self.request_pr_info(ctx);
self.request_repository_info(ctx);
}
fn request_pr_info(&self, ctx: &mut ModelContext<Self>) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, _| {
mgr.update_github_pr_info(host_id, &repo_path);
});
}
fn request_repository_info(&self, ctx: &mut ModelContext<Self>) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, _| {
mgr.update_github_repo_info(host_id, &repo_path);
});
}
/// Replace the stored PR info from a push, emitting `PrInfoChanged` only
/// when the value moved.
fn apply_pr_info_push(
&mut self,
pr_info: Option<&proto::PrInfo>,
ctx: &mut ModelContext<Self>,
) {
let pr_info = pr_info.map(PrInfo::from);
if self.pr_info != pr_info {
self.pr_info = pr_info;
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
}
/// Replace the stored repository info from a push, emitting
/// `RepositoryInfoChanged` only when the value moved.
fn apply_repository_info_push(
&mut self,
repository_info: Option<&proto::RepositoryInfo>,
ctx: &mut ModelContext<Self>,
) {
let repository_info = repository_info.map(RepositoryInfo::from);
if self.repository_info != repository_info {
self.repository_info = repository_info;
ctx.emit(GitHubRepoEvent::RepositoryInfoChanged);
}
}
pub fn pr_info(&self) -> Option<&PrInfo> {
self.pr_info.as_ref()
}
pub fn repository_info(&self) -> Option<&RepositoryInfo> {
self.repository_info.as_ref()
}
/// Always `false`: the remote backend does not track refresh state, since
/// results arrive as broadcasts with no request correlation.
pub fn is_refreshing_pr_info(&self) -> bool {
false
}
pub fn refresh_pr_info(&self, ctx: &mut ModelContext<Self>) {
self.request_pr_info(ctx);
}
pub fn refresh_repository_info(&self, ctx: &mut ModelContext<Self>) {
self.request_repository_info(ctx);
}
}
+2 -191
View File
@@ -70,194 +70,5 @@ pub fn calculate_hidden_lines(
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_diffs() {
let hidden_lines = calculate_hidden_lines(&[], 20, &[]);
let expected_hidden = [LineCount::from(0)..LineCount::from(20)]
.into_iter()
.collect::<RangeSet<LineCount>>();
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_single_diff_middle_of_file() {
// File with 20 lines, change at lines 10-12 (1-indexed)
let diffs = vec![DiffDelta {
replacement_line_range: 10..13, // 1-indexed, replacing lines 10, 11, 12
insertion: "new line 1\nnew line 2\nnew line 3".to_string(), // 3 lines
}];
let hidden_lines = calculate_hidden_lines(&diffs, 20, &[]);
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(0.into()..5.into()); // lines 1-5 (1-indexed)
expected_hidden.insert(16.into()..20.into()); // lines 17-20 (1-indexed)
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_insertion_at_beginning() {
// Insert at the very beginning of file
let diffs = vec![DiffDelta {
replacement_line_range: 1..1, // Insert at beginning
insertion: "new line 1\nnew line 2".to_string(), // 2 lines
}];
let hidden_lines = calculate_hidden_lines(&diffs, 20, &[]);
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(4.into()..20.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_multiple_diffs_overlapping_context() {
// Two changes close enough that their context overlaps
let diffs = vec![
DiffDelta {
replacement_line_range: 5..6, // 1-indexed, replace line 5
insertion: "change 1".to_string(), // 1 line
},
DiffDelta {
replacement_line_range: 8..9, // 1-indexed, replace line 8
insertion: "change 2".to_string(), // 1 line
},
];
let hidden_lines = calculate_hidden_lines(&diffs, 20, &[]);
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(12.into()..20.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_multiple_diffs_separate_context() {
// Two changes far apart
let diffs = vec![
DiffDelta {
replacement_line_range: 3..4, // 1-indexed, replace line 3
insertion: "change 1".to_string(), // 1 line
},
DiffDelta {
replacement_line_range: 15..16, // 1-indexed, replace line 15
insertion: "change 2".to_string(), // 1 line
},
];
let hidden_lines = calculate_hidden_lines(&diffs, 20, &[]);
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(7.into()..10.into());
expected_hidden.insert(19.into()..20.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_comments_only_no_diffs() {
// File with 20 lines, comments at lines 5 and 15 (0-indexed)
let comment_lines = vec![RenderLineCount::from(5), RenderLineCount::from(15)];
let hidden_lines = calculate_hidden_lines(&[], 20, &comment_lines);
let mut expected_hidden = RangeSet::new();
// Comment at line 5 shows lines 1-9 (0-indexed), so hide lines 0 and 10-14
expected_hidden.insert(0.into()..1.into());
expected_hidden.insert(10.into()..11.into());
// Comment at line 15 shows lines 11-19 (0-indexed), so hide line 20
// Note: lines 10-19 are visible due to comment at line 15
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_comment_overlapping_with_diff_context() {
// File with 20 lines, diff at lines 8-9 (1-indexed) and comment at line 10 (0-indexed)
let diffs = vec![DiffDelta {
replacement_line_range: 8..10, // 1-indexed, replacing lines 8, 9
insertion: "new line 1\nnew line 2".to_string(),
}];
let comment_lines = vec![RenderLineCount::from(10)];
let hidden_lines = calculate_hidden_lines(&diffs, 20, &comment_lines);
// Diff at lines 8-9 (1-indexed) = lines 7-8 (0-indexed) with ±4 context = lines 3-12
// Comment at line 10 (0-indexed) with ±4 context = lines 6-14
// Combined visible range: lines 3-14
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(0.into()..3.into());
expected_hidden.insert(15.into()..20.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_comment_separate_from_diffs() {
// File with 30 lines, diff at lines 5-6 (1-indexed) and comment at line 20 (0-indexed)
let diffs = vec![DiffDelta {
replacement_line_range: 5..7, // 1-indexed, replacing lines 5, 6
insertion: "changed line".to_string(),
}];
let comment_lines = vec![RenderLineCount::from(20)];
let hidden_lines = calculate_hidden_lines(&diffs, 30, &comment_lines);
// Diff at lines 5-6 (1-indexed) = lines 4-5 (0-indexed) with ±4 context = lines 0-9
// Comment at line 20 (0-indexed) with ±4 context = lines 16-24
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(10.into()..16.into());
expected_hidden.insert(25.into()..30.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_comment_outside_file_bounds() {
// File with 10 lines, comment at line 15 (0-indexed) - outside bounds
let comment_lines = vec![RenderLineCount::from(15)];
let hidden_lines = calculate_hidden_lines(&[], 10, &comment_lines);
// Comment is outside file bounds, so everything should be hidden
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(0.into()..10.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_comment_at_file_beginning() {
// File with 20 lines, comment at line 1 (0-indexed)
let comment_lines = vec![RenderLineCount::from(1)];
let hidden_lines = calculate_hidden_lines(&[], 20, &comment_lines);
// Comment at line 1 with ±4 context = lines 0-5 (saturating_sub handles negative)
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(6.into()..20.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_comment_at_file_end() {
// File with 20 lines, comment at line 18 (0-indexed)
let comment_lines = vec![RenderLineCount::from(18)];
let hidden_lines = calculate_hidden_lines(&[], 20, &comment_lines);
// Comment at line 18 with ±4 context = lines 14-19 (clamped to file bounds)
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(0.into()..14.into());
assert_eq!(hidden_lines, expected_hidden);
}
}
#[path = "hidden_lines_tests.rs"]
mod tests;
+189
View File
@@ -0,0 +1,189 @@
use super::*;
#[test]
fn test_empty_diffs() {
let hidden_lines = calculate_hidden_lines(&[], 20, &[]);
let expected_hidden = [LineCount::from(0)..LineCount::from(20)]
.into_iter()
.collect::<RangeSet<LineCount>>();
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_single_diff_middle_of_file() {
// File with 20 lines, change at lines 10-12 (1-indexed)
let diffs = vec![DiffDelta {
replacement_line_range: 10..13, // 1-indexed, replacing lines 10, 11, 12
insertion: "new line 1\nnew line 2\nnew line 3".to_string(), // 3 lines
}];
let hidden_lines = calculate_hidden_lines(&diffs, 20, &[]);
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(0.into()..5.into()); // lines 1-5 (1-indexed)
expected_hidden.insert(16.into()..20.into()); // lines 17-20 (1-indexed)
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_insertion_at_beginning() {
// Insert at the very beginning of file
let diffs = vec![DiffDelta {
replacement_line_range: 1..1, // Insert at beginning
insertion: "new line 1\nnew line 2".to_string(), // 2 lines
}];
let hidden_lines = calculate_hidden_lines(&diffs, 20, &[]);
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(4.into()..20.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_multiple_diffs_overlapping_context() {
// Two changes close enough that their context overlaps
let diffs = vec![
DiffDelta {
replacement_line_range: 5..6, // 1-indexed, replace line 5
insertion: "change 1".to_string(), // 1 line
},
DiffDelta {
replacement_line_range: 8..9, // 1-indexed, replace line 8
insertion: "change 2".to_string(), // 1 line
},
];
let hidden_lines = calculate_hidden_lines(&diffs, 20, &[]);
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(12.into()..20.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_multiple_diffs_separate_context() {
// Two changes far apart
let diffs = vec![
DiffDelta {
replacement_line_range: 3..4, // 1-indexed, replace line 3
insertion: "change 1".to_string(), // 1 line
},
DiffDelta {
replacement_line_range: 15..16, // 1-indexed, replace line 15
insertion: "change 2".to_string(), // 1 line
},
];
let hidden_lines = calculate_hidden_lines(&diffs, 20, &[]);
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(7.into()..10.into());
expected_hidden.insert(19.into()..20.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_comments_only_no_diffs() {
// File with 20 lines, comments at lines 5 and 15 (0-indexed)
let comment_lines = vec![RenderLineCount::from(5), RenderLineCount::from(15)];
let hidden_lines = calculate_hidden_lines(&[], 20, &comment_lines);
let mut expected_hidden = RangeSet::new();
// Comment at line 5 shows lines 1-9 (0-indexed), so hide lines 0 and 10-14
expected_hidden.insert(0.into()..1.into());
expected_hidden.insert(10.into()..11.into());
// Comment at line 15 shows lines 11-19 (0-indexed), so hide line 20
// Note: lines 10-19 are visible due to comment at line 15
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_comment_overlapping_with_diff_context() {
// File with 20 lines, diff at lines 8-9 (1-indexed) and comment at line 10 (0-indexed)
let diffs = vec![DiffDelta {
replacement_line_range: 8..10, // 1-indexed, replacing lines 8, 9
insertion: "new line 1\nnew line 2".to_string(),
}];
let comment_lines = vec![RenderLineCount::from(10)];
let hidden_lines = calculate_hidden_lines(&diffs, 20, &comment_lines);
// Diff at lines 8-9 (1-indexed) = lines 7-8 (0-indexed) with ±4 context = lines 3-12
// Comment at line 10 (0-indexed) with ±4 context = lines 6-14
// Combined visible range: lines 3-14
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(0.into()..3.into());
expected_hidden.insert(15.into()..20.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_comment_separate_from_diffs() {
// File with 30 lines, diff at lines 5-6 (1-indexed) and comment at line 20 (0-indexed)
let diffs = vec![DiffDelta {
replacement_line_range: 5..7, // 1-indexed, replacing lines 5, 6
insertion: "changed line".to_string(),
}];
let comment_lines = vec![RenderLineCount::from(20)];
let hidden_lines = calculate_hidden_lines(&diffs, 30, &comment_lines);
// Diff at lines 5-6 (1-indexed) = lines 4-5 (0-indexed) with ±4 context = lines 0-9
// Comment at line 20 (0-indexed) with ±4 context = lines 16-24
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(10.into()..16.into());
expected_hidden.insert(25.into()..30.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_comment_outside_file_bounds() {
// File with 10 lines, comment at line 15 (0-indexed) - outside bounds
let comment_lines = vec![RenderLineCount::from(15)];
let hidden_lines = calculate_hidden_lines(&[], 10, &comment_lines);
// Comment is outside file bounds, so everything should be hidden
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(0.into()..10.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_comment_at_file_beginning() {
// File with 20 lines, comment at line 1 (0-indexed)
let comment_lines = vec![RenderLineCount::from(1)];
let hidden_lines = calculate_hidden_lines(&[], 20, &comment_lines);
// Comment at line 1 with ±4 context = lines 0-5 (saturating_sub handles negative)
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(6.into()..20.into());
assert_eq!(hidden_lines, expected_hidden);
}
#[test]
fn test_comment_at_file_end() {
// File with 20 lines, comment at line 18 (0-indexed)
let comment_lines = vec![RenderLineCount::from(18)];
let hidden_lines = calculate_hidden_lines(&[], 20, &comment_lines);
// Comment at line 18 with ±4 context = lines 14-19 (clamped to file bounds)
let mut expected_hidden = RangeSet::new();
expected_hidden.insert(0.into()..14.into());
assert_eq!(hidden_lines, expected_hidden);
}
+48 -23
View File
@@ -6,8 +6,11 @@ pub mod diff_size_limits;
pub mod diff_state;
pub mod editor_state;
pub(crate) mod find_model;
pub(crate) mod git_actions;
pub(crate) mod git_dialog;
pub mod git_status_update;
pub mod git_repo_model;
mod git_repo_models;
pub mod github_repo_model;
mod hidden_lines;
pub mod telemetry_event;
#[cfg_attr(not(feature = "local_fs"), allow(unused_imports))]
@@ -18,18 +21,20 @@ pub(crate) mod comment_rendering;
pub mod comments;
pub(crate) mod diff_menu;
pub(crate) mod diff_selector;
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
pub(crate) mod file_invalidation_queue;
use code_review_view::CodeReviewAction;
use galaxyui::keymap::{EditableBinding, FixedBinding};
use galaxyui::{
id,
keymap::{EditableBinding, FixedBinding},
AppContext, Entity, EntityId, ModelContext, SingletonEntity, WeakViewHandle, WindowId,
id, AppContext, Entity, EntityId, ModelContext, SingletonEntity, WeakViewHandle, WindowId,
};
use std::path::{Path, PathBuf};
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint;
use crate::terminal::{view::TerminalView, CLIAgent};
use crate::terminal::view::TerminalView;
use crate::terminal::CLIAgent;
use crate::util::bindings::CustomAction;
/// Arguments needed to open or toggle the code review panel.
@@ -37,7 +42,7 @@ use crate::util::bindings::CustomAction;
/// review and perform follow-up work without relying on event ordering.
#[derive(Clone)]
pub struct CodeReviewPanelArg {
pub repo_path: Option<PathBuf>,
pub repo_path: Option<LocalOrRemotePath>,
pub terminal_view: WeakViewHandle<TerminalView>,
pub entrypoint: CodeReviewPaneEntrypoint,
pub focus_new_pane: bool,
@@ -48,9 +53,14 @@ pub struct CodeReviewPanelArg {
#[derive(Clone, Debug, PartialEq)]
pub enum DiffSetScope {
All,
File(PathBuf),
/// A single repo-relative file path in the diff set.
File(String),
}
/// The keystroke that submits in the code review panel. Meant to mirror the keystroke for
/// [`EditorViewEvent::CmdEnter`].
pub const CODE_REVIEW_SUBMIT_KEYSTROKE: &str = "cmdorctrl-enter";
/// Register keybindings for code review functionality.
pub fn init(app: &mut AppContext) {
app.register_editable_bindings([
@@ -69,14 +79,30 @@ pub fn init(app: &mut AppContext) {
.with_context_predicate(id!("CodeReviewView"))
.with_key_binding("cmdorctrl-f")
.with_enabled(|| crate::features::FeatureFlag::CodeReviewFind.is_enabled()),
EditableBinding::new(
"code_review:toggle_file_navigation",
"Toggle file navigation in code review",
CodeReviewAction::ToggleFileSidebar,
)
.with_context_predicate(id!("CodeReviewView_NotEditing"))
.with_key_binding("f")
.with_enabled(|| crate::features::FeatureFlag::GitOperationsInCodeReview.is_enabled()),
]);
app.register_fixed_bindings([FixedBinding::custom(
CustomAction::Undo,
CodeReviewAction::UndoRevert,
"Undo",
id!("CodeReviewView") & !id!("IMEOpen"),
)]);
app.register_fixed_bindings([
FixedBinding::custom(
CustomAction::Undo,
CodeReviewAction::UndoRevert,
"Undo",
id!("CodeReviewView") & !id!("IMEOpen"),
),
FixedBinding::new(
CODE_REVIEW_SUBMIT_KEYSTROKE,
CodeReviewAction::SubmitReviewComments,
id!("CodeReviewView_NotEditing"),
)
.with_command_description("Send code review comments to agent"),
]);
diff_menu::init(app);
diff_selector::init(app);
@@ -84,17 +110,17 @@ pub fn init(app: &mut AppContext) {
}
/// Uses heuristics to determine if a file is auto-generated.
fn is_file_autogenerated(file_path: &Path, content: Option<&str>) -> bool {
///
/// `file_path` is expected to be a repo-relative path (as a string),
/// matching the way file paths are stored on `FileDiff`.
fn is_file_autogenerated(file_path: &str, content: Option<&str>) -> bool {
const AUTOGEN_HEADERS: [&str; 3] = [
"Code generated by",
"This file is automatically generated",
"AUTO-GENERATED FILE",
];
let file_name = file_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
let file_name = file_path.rsplit('/').next().unwrap_or("");
// Check for specific lock files and autogenerated files by exact name
match file_name {
@@ -117,10 +143,9 @@ fn is_file_autogenerated(file_path: &Path, content: Option<&str>) -> bool {
}
// Check for directory structure hints.
let file_path_str = file_path.to_string_lossy();
if file_path_str.contains("__generated__/")
|| file_path_str.contains(".auto/")
|| file_path_str.contains("codegen/")
if file_path.contains("__generated__/")
|| file_path.contains(".auto/")
|| file_path.contains("codegen/")
{
return true;
}
@@ -145,7 +170,7 @@ fn is_file_autogenerated(file_path: &Path, content: Option<&str>) -> bool {
/// A [`SingletonEntity`] that the tracks events for the code review model throughought the app.
/// We need this because toasts are emitted in the Workspace, and want a click handler that triggers
/// behavior in a _specific_ review pane. We use this model get around restrictions that make it hard
/// to emit a CodeReviewView typed action from the toast because it's not in the view reponder chain of the
/// to emit a CodeReviewView typed action from the toast because it's not in the view responder chain of the
/// Workspace.
pub struct GlobalCodeReviewModel;
+3 -2
View File
@@ -1,8 +1,9 @@
use galaxy_core::features::FeatureFlag;
use galaxyui::{elements::ScrollOffset, units::Pixels, ViewContext, ViewHandle};
use galaxyui::elements::ScrollOffset;
use galaxyui::units::Pixels;
#[cfg(not(target_family = "wasm"))]
use galaxyui::{AppContext, WeakViewHandle};
use galaxyui::{ViewContext, ViewHandle};
#[cfg(not(target_family = "wasm"))]
use super::FILE_HEADER_HEIGHT;
+262 -43
View File
@@ -1,13 +1,74 @@
use crate::server::telemetry::CLIAgentType;
use crate::view_components::find::FindDirection;
use crate::{code_review::diff_state::DiffMode, features::FeatureFlag};
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
use std::fmt::Display;
use std::time::Duration;
use serde::Serialize;
use serde_json::json;
use serde_with::SerializeDisplay;
use std::fmt::Display;
use strum_macros::{EnumDiscriminants, EnumIter};
use crate::code_review::diff_state::{BackendOrigin, DiffMode, DiffOperation};
use crate::features::FeatureFlag;
use crate::server::telemetry::CLIAgentType;
use crate::view_components::find::FindDirection;
/// Identifies which git button the user clicked in the code review header.
/// Each variant maps to one of the primary action button / dropdown items.
#[derive(Clone, Copy, Debug, Serialize)]
pub enum GitButtonKind {
#[serde(rename = "commit")]
Commit,
#[serde(rename = "push")]
Push,
#[serde(rename = "publish")]
Publish,
#[serde(rename = "create_pr")]
CreatePr,
#[serde(rename = "view_pr")]
ViewPr,
}
/// Identifies which git operation actually ran when a `GitDialog` completed.
/// Distinguishes commit-dialog chained intents (e.g. commit-and-push) from
/// standalone push/publish/create-PR dialogs so analytics can tell the user
/// flows apart.
#[derive(Clone, Copy, Debug, Serialize)]
pub enum GitOperationKind {
/// Commit dialog with the commit-only intent.
#[serde(rename = "commit_only")]
CommitOnly,
/// Commit dialog with the commit-and-push intent.
#[serde(rename = "commit_and_push")]
CommitAndPush,
/// Commit dialog with the commit-and-create-PR intent.
#[serde(rename = "commit_and_create_pr")]
CommitAndCreatePr,
/// Standalone push dialog.
#[serde(rename = "push")]
Push,
/// Standalone publish dialog (push that also sets upstream).
#[serde(rename = "publish")]
Publish,
/// Standalone create-PR dialog.
#[serde(rename = "create_pr")]
CreatePr,
}
/// Terminal status of a `GitDialog`. Captures both async-op outcomes and
/// pre-confirmation user cancels in a single enum.
#[derive(Clone, Copy, Debug, Serialize)]
pub enum GitDialogStatus {
/// User confirmed the dialog and the underlying git operation succeeded.
#[serde(rename = "succeeded")]
Succeeded,
/// User confirmed the dialog and the underlying git operation failed.
#[serde(rename = "failed")]
Failed,
/// User cancelled the dialog (ESC / close button / cancel button) before
/// the async op ran.
#[serde(rename = "cancelled")]
Cancelled,
}
/// Entry points for opening the code review pane.
#[derive(Clone, Copy, Debug, SerializeDisplay, Default)]
pub enum CodeReviewPaneEntrypoint {
@@ -122,6 +183,7 @@ pub enum PaneStateChange {
pub enum CodeReviewTelemetryEvent {
/// Emitted when the code review pane is opened.
PaneOpened {
is_local: Option<bool>,
entrypoint: CodeReviewPaneEntrypoint,
is_code_mode_v2: bool,
/// The CLI agent type if opened from a CLI agent footer (e.g., Claude Code).
@@ -129,32 +191,67 @@ pub enum CodeReviewTelemetryEvent {
},
/// Emitted when a user adds content to AI context from code review.
AddToContext {
is_local: Option<bool>,
origin: AddToContextOrigin,
destination: CodeReviewContextDestination,
diff_set_scope: Option<DiffSetContextScope>,
},
/// Emitted when a user clicks the revert hunk button.
RevertHunkClicked,
RevertHunkClicked { is_local: Option<bool> },
/// Emitted when a file is saved in the code review pane.
FileSaved,
FileSaved { is_local: Option<bool> },
/// Emitted when the code review pane is minimized or maximized.
PaneStateChanged { state_change: PaneStateChange },
PaneStateChanged {
is_local: Option<bool>,
state_change: PaneStateChange,
},
/// Emitted when the diff base is changed (e.g., from uncommitted to main branch).
BaseChanged {
is_local: Option<bool>,
/// The new diff mode.
mode: DiffMode,
},
/// Failure when we are calculating the diff metadata.
CalculateDiffMetadataFailed { error: String },
/// Failure when we are loading the actual diff content.
LoadDiffFailed { error: String },
LoadMetadataFailed {
backend_origin: BackendOrigin,
mode: DiffMode,
error: String,
},
/// Failure when we are loading the actual diff content. Shared across
/// file-invalidation, full diff load, and remote diff paths; the
/// `operation` field distinguishes which one produced the failure.
LoadDiffFailed {
backend_origin: BackendOrigin,
operation: DiffOperation,
mode: DiffMode,
error: String,
/// Time elapsed between when the tracked diff load was requested and
/// when this failure was observed. `None` if no tracked load was in
/// flight (e.g. a background invalidation error).
load_duration: Option<Duration>,
},
/// Emitted when a full diff load completes successfully.
DiffLoadCompleted {
is_local: Option<bool>,
mode: DiffMode,
file_count: usize,
files_changed: usize,
total_additions: usize,
total_deletions: usize,
/// Time elapsed between when the diff load was requested and when the
/// diffs were ready. `None` if the load was not initiated through a
/// tracked entry point (e.g. background refresh).
load_duration: Option<Duration>,
},
/// Emitted when the code review find bar is opened or closed.
FindBarToggled {
is_local: Option<bool>,
/// Whether the find bar is now open.
is_open: bool,
},
/// Emitted when search mode settings are changed.
FindBarModeChanged {
is_local: Option<bool>,
/// Whether case-sensitive search is enabled.
case_sensitive: bool,
/// Whether regex search is enabled.
@@ -162,24 +259,30 @@ pub enum CodeReviewTelemetryEvent {
},
/// Emitted when the user navigates to the next or previous match.
FindNavigated {
is_local: Option<bool>,
/// Direction of navigation.
direction: FindDirection,
},
/// Emitted when the inline comment editor is opened in the code review pane.
CommentEditorOpened,
CommentEditorOpened { is_local: Option<bool> },
/// Emitted when a new comment is added to the inline review.
CommentAdded,
CommentAdded { is_local: Option<bool> },
/// Emitted when an existing comment is edited.
CommentEdited,
CommentEdited { is_local: Option<bool> },
/// Emitted when a comment is deleted from the inline review.
CommentDeleted { is_imported: bool },
CommentDeleted {
is_local: Option<bool>,
is_imported: bool,
},
/// Emitted when the bottom comment list panel is expanded.
CommentListExpanded {
is_local: Option<bool>,
/// Number of comments currently in the list.
comment_count: usize,
},
/// Emitted when the user submits an inline review to the agent.
ReviewSubmitted {
is_local: Option<bool>,
/// Number of comments in the submitted review.
comment_count: usize,
/// Number of unique files with comments.
@@ -188,9 +291,10 @@ pub enum CodeReviewTelemetryEvent {
destination: CodeReviewContextDestination,
},
/// Emitted when a comment in the list view is clicked to jump to its location.
CommentListItemClicked,
CommentListItemClicked { is_local: Option<bool> },
/// Emitted when one or more comments fail to be precisely relocated after code changes.
CommentRelocationFailed {
is_local: Option<bool>,
/// Number of comments that could not be matched to an exact line and had to fall back.
fallback_count: usize,
},
@@ -201,6 +305,7 @@ pub enum CodeReviewTelemetryEvent {
},
/// Emitted when the agent's insert_code_review_comments tool call is received and processed.
CommentsReceived {
is_local: Option<bool>,
/// Number of raw InsertReviewComment items from the tool call.
raw_count: usize,
/// Number of successfully converted PendingImportedReviewComments.
@@ -210,11 +315,30 @@ pub enum CodeReviewTelemetryEvent {
},
/// Emitted after newly-imported comments are relocated against editor lines.
CommentsAttached {
is_local: Option<bool>,
/// Number of non-outdated imported comments after relocation.
active_count: usize,
/// Number of outdated imported comments after relocation.
outdated_count: usize,
},
/// Emitted when a user clicks a git operation button in the code review
/// header (primary button or dropdown item).
GitButtonTriggered {
is_local: Option<bool>,
button: GitButtonKind,
},
/// Emitted when a git dialog reaches a terminal state — either the async
/// op succeeded / failed, or the user cancelled before confirming.
GitDialogCompleted {
is_local: Option<bool>,
/// The git operation that ran or would have run (e.g. `commit_and_push`
/// for the commit dialog with that chained intent).
operation: GitOperationKind,
/// Whether the dialog succeeded, failed, or was cancelled.
status: GitDialogStatus,
/// Raw error string when `status == Failed`, `None` otherwise.
error: Option<String>,
},
}
impl TelemetryEvent for CodeReviewTelemetryEvent {
@@ -225,85 +349,167 @@ impl TelemetryEvent for CodeReviewTelemetryEvent {
fn payload(&self) -> Option<serde_json::Value> {
match self {
CodeReviewTelemetryEvent::PaneOpened {
is_local,
entrypoint,
is_code_mode_v2,
cli_agent,
} => Some(
json!({ "entrypoint": entrypoint, "is_code_mode_v2": is_code_mode_v2, "agent_name": cli_agent}),
),
} => Some(json!({
"is_local": is_local,
"entrypoint": entrypoint,
"is_code_mode_v2": is_code_mode_v2,
"agent_name": cli_agent,
})),
CodeReviewTelemetryEvent::AddToContext {
is_local,
origin,
destination,
diff_set_scope,
} => Some(json!({
"is_local": is_local,
"origin": origin,
"destination": destination,
"diff_set_scope": diff_set_scope,
})),
CodeReviewTelemetryEvent::RevertHunkClicked => None,
CodeReviewTelemetryEvent::FileSaved => None,
CodeReviewTelemetryEvent::PaneStateChanged { state_change } => {
Some(json!({ "state_change": state_change }))
CodeReviewTelemetryEvent::RevertHunkClicked { is_local } => {
Some(json!({ "is_local": is_local }))
}
CodeReviewTelemetryEvent::BaseChanged { mode } => Some(json!({ "mode": mode })),
CodeReviewTelemetryEvent::CalculateDiffMetadataFailed { error } => {
Some(json!({ "error": error }))
CodeReviewTelemetryEvent::FileSaved { is_local } => {
Some(json!({ "is_local": is_local }))
}
CodeReviewTelemetryEvent::LoadDiffFailed { error } => Some(json!({ "error": error })),
CodeReviewTelemetryEvent::FindBarToggled { is_open } => {
Some(json!({ "is_open": is_open }))
CodeReviewTelemetryEvent::PaneStateChanged {
is_local,
state_change,
} => Some(json!({ "is_local": is_local, "state_change": state_change })),
CodeReviewTelemetryEvent::BaseChanged { is_local, mode } => {
Some(json!({ "is_local": is_local, "mode": mode }))
}
CodeReviewTelemetryEvent::LoadMetadataFailed {
backend_origin,
mode,
error,
} => Some(json!({
"backend_origin": backend_origin,
"mode": mode,
"error": error,
})),
CodeReviewTelemetryEvent::LoadDiffFailed {
backend_origin,
operation,
mode,
error,
load_duration,
} => Some(json!({
"backend_origin": backend_origin,
"operation": operation,
"mode": mode,
"error": error,
"load_duration": load_duration,
})),
CodeReviewTelemetryEvent::DiffLoadCompleted {
is_local,
mode,
file_count,
files_changed,
total_additions,
total_deletions,
load_duration,
} => Some(json!({
"is_local": is_local,
"mode": mode,
"file_count": file_count,
"files_changed": files_changed,
"total_additions": total_additions,
"total_deletions": total_deletions,
"load_duration": load_duration,
})),
CodeReviewTelemetryEvent::FindBarToggled { is_local, is_open } => {
Some(json!({ "is_local": is_local, "is_open": is_open }))
}
CodeReviewTelemetryEvent::FindBarModeChanged {
is_local,
case_sensitive,
regex,
} => Some(json!({
"is_local": is_local,
"case_sensitive": case_sensitive,
"regex": regex,
})),
CodeReviewTelemetryEvent::FindNavigated { direction } => {
Some(json!({ "direction": direction }))
CodeReviewTelemetryEvent::FindNavigated {
is_local,
direction,
} => Some(json!({ "is_local": is_local, "direction": direction })),
CodeReviewTelemetryEvent::CommentEditorOpened { is_local } => {
Some(json!({ "is_local": is_local }))
}
CodeReviewTelemetryEvent::CommentEditorOpened => None,
CodeReviewTelemetryEvent::CommentAdded => None,
CodeReviewTelemetryEvent::CommentEdited => None,
CodeReviewTelemetryEvent::CommentDeleted { is_imported } => {
Some(json!({ "is_imported": is_imported }))
CodeReviewTelemetryEvent::CommentAdded { is_local } => {
Some(json!({ "is_local": is_local }))
}
CodeReviewTelemetryEvent::CommentListExpanded { comment_count } => {
Some(json!({ "comment_count": comment_count }))
CodeReviewTelemetryEvent::CommentEdited { is_local } => {
Some(json!({ "is_local": is_local }))
}
CodeReviewTelemetryEvent::CommentDeleted {
is_local,
is_imported,
} => Some(json!({ "is_local": is_local, "is_imported": is_imported })),
CodeReviewTelemetryEvent::CommentListExpanded {
is_local,
comment_count,
} => Some(json!({ "is_local": is_local, "comment_count": comment_count })),
CodeReviewTelemetryEvent::ReviewSubmitted {
is_local,
comment_count,
file_count,
destination,
} => Some(json!({
"is_local": is_local,
"comment_count": comment_count,
"file_count": file_count,
"destination": destination,
})),
CodeReviewTelemetryEvent::CommentListItemClicked => None,
CodeReviewTelemetryEvent::CommentRelocationFailed { fallback_count } => {
Some(json!({ "fallback_count": fallback_count }))
CodeReviewTelemetryEvent::CommentListItemClicked { is_local } => {
Some(json!({ "is_local": is_local }))
}
CodeReviewTelemetryEvent::CommentRelocationFailed {
is_local,
fallback_count,
} => Some(json!({ "is_local": is_local, "fallback_count": fallback_count })),
CodeReviewTelemetryEvent::CommentResolved { resolved_count } => {
Some(json!({ "resolved_count": resolved_count }))
}
CodeReviewTelemetryEvent::CommentsReceived {
is_local,
raw_count,
converted_count,
thread_count,
} => Some(json!({
"is_local": is_local,
"raw_count": raw_count,
"converted_count": converted_count,
"thread_count": thread_count,
})),
CodeReviewTelemetryEvent::CommentsAttached {
is_local,
active_count,
outdated_count,
} => Some(json!({
"is_local": is_local,
"active_count": active_count,
"outdated_count": outdated_count,
})),
CodeReviewTelemetryEvent::GitButtonTriggered { is_local, button } => {
Some(json!({ "is_local": is_local, "button": button }))
}
CodeReviewTelemetryEvent::GitDialogCompleted {
is_local,
operation,
status,
error,
} => Some(json!({
"is_local": is_local,
"operation": operation,
"status": status,
"error": error,
})),
}
}
@@ -339,8 +545,9 @@ impl TelemetryEventDesc for CodeReviewTelemetryEventDiscriminants {
Self::FileSaved => "CodeReview.FileSaved",
Self::PaneStateChanged => "CodeReview.PaneStateChanged",
Self::BaseChanged => "CodeReview.BaseChanged",
Self::CalculateDiffMetadataFailed => "CodeReview.CalculateDiffMetadataFailed",
Self::LoadMetadataFailed => "CodeReview.LoadMetadataFailed",
Self::LoadDiffFailed => "CodeReview.LoadDiffFailed",
Self::DiffLoadCompleted => "CodeReview.DiffLoadCompleted",
Self::FindBarToggled => "CodeReview.FindBarToggled",
Self::FindBarModeChanged => "CodeReview.FindBarModeChanged",
Self::FindNavigated => "CodeReview.FindNavigated",
@@ -355,6 +562,8 @@ impl TelemetryEventDesc for CodeReviewTelemetryEventDiscriminants {
Self::CommentResolved => "CodeReview.CommentResolved",
Self::CommentsReceived => "CodeReview.CommentsReceived",
Self::CommentsAttached => "CodeReview.CommentsAttached",
Self::GitButtonTriggered => "CodeReview.GitButtonTriggered",
Self::GitDialogCompleted => "CodeReview.GitDialogCompleted",
}
}
@@ -366,8 +575,9 @@ impl TelemetryEventDesc for CodeReviewTelemetryEventDiscriminants {
Self::FileSaved => "File saved in code review pane",
Self::PaneStateChanged => "Code review pane minimized or maximized",
Self::BaseChanged => "Diff base changed in code review",
Self::CalculateDiffMetadataFailed => "Failure when calculating diff metadata",
Self::LoadMetadataFailed => "Failure when calculating diff metadata",
Self::LoadDiffFailed => "Failure when loading diff content",
Self::DiffLoadCompleted => "Diff content loaded successfully",
Self::FindBarToggled => "Code review find bar opened or closed",
Self::FindBarModeChanged => "Search mode changed in code review find bar",
Self::FindNavigated => "Navigated to next or previous match in code review find bar",
@@ -386,6 +596,12 @@ impl TelemetryEventDesc for CodeReviewTelemetryEventDiscriminants {
"Agent insert_code_review_comments tool call received and processed"
}
Self::CommentsAttached => "Newly-imported comments relocated against editor lines",
Self::GitButtonTriggered => {
"User clicked a git operation button in the code review header"
}
Self::GitDialogCompleted => {
"Git operation dialog reached a terminal state (succeeded, failed, or cancelled)"
}
}
}
@@ -394,6 +610,9 @@ impl TelemetryEventDesc for CodeReviewTelemetryEventDiscriminants {
Self::CommentsReceived | Self::CommentsAttached => {
EnablementState::Flag(FeatureFlag::PRCommentsV2)
}
Self::GitButtonTriggered | Self::GitDialogCompleted => {
EnablementState::Flag(FeatureFlag::GitOperationsInCodeReview)
}
_ => EnablementState::Always,
}
}