first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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.)`,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+1023
-1166
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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
+96
-137
@@ -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, ¤t_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
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user