From 72656afa6d477d57ca875b1b7c8423483e32dc74 Mon Sep 17 00:00:00 2001 From: John Tur Date: Tue, 14 Jul 2026 12:40:22 -0400 Subject: [PATCH 1/5] Use more efficient thread pool API on Windows (#60917) This interface uses fewer syscalls and has less internal synchronization (since the `TP_WORK` object is not exposed). Release Notes: - N/A --- crates/gpui_windows/src/dispatcher.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/crates/gpui_windows/src/dispatcher.rs b/crates/gpui_windows/src/dispatcher.rs index ba492515edb..55f18b50c07 100644 --- a/crates/gpui_windows/src/dispatcher.rs +++ b/crates/gpui_windows/src/dispatcher.rs @@ -12,11 +12,10 @@ use windows::Win32::{ Foundation::{FILETIME, LPARAM, WPARAM}, Media::{timeBeginPeriod, timeEndPeriod}, System::Threading::{ - CloseThreadpoolTimer, CloseThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWork, - GetCurrentThread, PTP_CALLBACK_INSTANCE, PTP_TIMER, PTP_WORK, SetThreadPriority, - SetThreadpoolTimer, SubmitThreadpoolWork, THREAD_PRIORITY_TIME_CRITICAL, + CloseThreadpoolTimer, CreateThreadpoolTimer, GetCurrentThread, PTP_CALLBACK_INSTANCE, + PTP_TIMER, SetThreadPriority, SetThreadpoolTimer, THREAD_PRIORITY_TIME_CRITICAL, TP_CALLBACK_ENVIRON_V3, TP_CALLBACK_PRIORITY, TP_CALLBACK_PRIORITY_HIGH, - TP_CALLBACK_PRIORITY_LOW, TP_CALLBACK_PRIORITY_NORMAL, + TP_CALLBACK_PRIORITY_LOW, TP_CALLBACK_PRIORITY_NORMAL, TrySubmitThreadpoolCallback, }, UI::WindowsAndMessaging::PostMessageW, }; @@ -66,11 +65,8 @@ impl WindowsDispatcher { let context = runnable.into_raw().as_ptr() as *mut c_void; unsafe { - if let Ok(work) = - CreateThreadpoolWork(Some(run_work_callback), Some(context), Some(&environ)) - { - SubmitThreadpoolWork(work); - } + TrySubmitThreadpoolCallback(Some(run_work_callback), Some(context), Some(&environ)) + .log_err(); } } @@ -179,11 +175,9 @@ impl PlatformDispatcher for WindowsDispatcher { unsafe extern "system" fn run_work_callback( _instance: PTP_CALLBACK_INSTANCE, context: *mut c_void, - work: PTP_WORK, ) { let runnable = unsafe { RunnableVariant::from_raw(NonNull::new_unchecked(context as *mut ())) }; WindowsDispatcher::execute_runnable(runnable); - unsafe { CloseThreadpoolWork(work) }; } unsafe extern "system" fn run_timer_callback( From ef2430c8f36f50130f072f533edac4d8a9c3bf78 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 14 Jul 2026 19:57:03 +0300 Subject: [PATCH 2/5] Properly notify on inline diagnostics toggle (#60978) Closes https://github.com/zed-industries/zed/issues/60959 Release Notes: - Fixed inline diagnostics not instantly [dis]appearing after toggling --- crates/editor/src/diagnostics.rs | 163 ++++++++++++++++++++++++++++++- 1 file changed, 159 insertions(+), 4 deletions(-) diff --git a/crates/editor/src/diagnostics.rs b/crates/editor/src/diagnostics.rs index f3e3137b3fa..a1e40bdb392 100644 --- a/crates/editor/src/diagnostics.rs +++ b/crates/editor/src/diagnostics.rs @@ -331,13 +331,12 @@ impl Editor { self.set_max_diagnostics_severity(new_severity, cx); if self.diagnostics_enabled { self.active_diagnostics = ActiveDiagnostic::None; + self.refresh_inline_diagnostics(false, window, cx); + } else { self.inline_diagnostics_update = Task::ready(()); self.inline_diagnostics.clear(); - } else { - self.refresh_inline_diagnostics(false, window, cx); + cx.notify(); } - - cx.notify(); } pub(super) fn all_diagnostics_active(&self) -> bool { @@ -464,6 +463,7 @@ impl Editor { { self.inline_diagnostics_update = Task::ready(()); self.inline_diagnostics.clear(); + cx.notify(); return; } @@ -596,3 +596,158 @@ impl Editor { } } } + +#[cfg(test)] +mod tests { + use crate::{ + actions::{ToggleDiagnostics, ToggleInlineDiagnostics}, + editor_tests::init_test, + test::editor_test_context::EditorTestContext, + }; + use gpui::{TestAppContext, UpdateGlobal}; + use indoc::indoc; + use language::DiagnosticSourceKind; + use lsp::LanguageServerId; + use settings::{DelayMs, SettingsStore}; + use std::sync::{ + Arc, + atomic::{self, AtomicUsize}, + }; + use util::path; + + fn setup_inline_diagnostics(cx: &mut EditorTestContext) { + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + let inline = settings + .diagnostics + .get_or_insert_default() + .inline + .get_or_insert_default(); + inline.enabled = Some(true); + inline.update_debounce_ms = Some(DelayMs(0)); + }); + }); + }); + + cx.set_state(indoc! {" + fn func(abc dˇef: i32) -> u32 { + } + "}); + + let lsp_store = + cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store()); + cx.update(|_, cx| { + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), + version: None, + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 12), + lsp::Position::new(0, 15), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: "cannot find value `def`".to_string(), + ..Default::default() + }], + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap() + }); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, _, _| { + assert_eq!( + editor.inline_diagnostics.len(), + 1, + "inline diagnostics should appear after the language server publishes them" + ); + }); + } + + #[gpui::test] + async fn test_toggle_diagnostics_refreshes_inline_diagnostics(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + setup_inline_diagnostics(&mut cx); + + cx.update_editor(|editor, window, cx| { + editor.toggle_diagnostics(&ToggleDiagnostics, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|editor, _, _| { + assert_eq!( + editor.inline_diagnostics.len(), + 0, + "inline diagnostics should be cleared after disabling diagnostics" + ); + }); + + cx.update_editor(|editor, window, cx| { + editor.toggle_diagnostics(&ToggleDiagnostics, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|editor, _, _| { + assert_eq!( + editor.inline_diagnostics.len(), + 1, + "inline diagnostics should reappear after re-enabling diagnostics, without further editor events" + ); + }); + } + + #[gpui::test] + async fn test_toggle_inline_diagnostics_notifies_on_hide(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + setup_inline_diagnostics(&mut cx); + + let notify_count = Arc::new(AtomicUsize::new(0)); + let editor = cx.editor.clone(); + let _subscription = cx.update({ + let notify_count = notify_count.clone(); + move |_, cx| { + cx.observe(&editor, move |_, _| { + notify_count.fetch_add(1, atomic::Ordering::SeqCst); + }) + } + }); + + cx.update_editor(|editor, window, cx| { + editor.toggle_inline_diagnostics(&ToggleInlineDiagnostics, window, cx); + }); + cx.update_editor(|editor, _, _| { + assert_eq!( + editor.inline_diagnostics.len(), + 0, + "inline diagnostics should be cleared after toggling them off" + ); + }); + assert_eq!( + notify_count.load(atomic::Ordering::SeqCst), + 1, + "toggling inline diagnostics off should notify to repaint the editor" + ); + + cx.update_editor(|editor, window, cx| { + editor.toggle_inline_diagnostics(&ToggleInlineDiagnostics, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|editor, _, _| { + assert_eq!( + editor.inline_diagnostics.len(), + 1, + "inline diagnostics should reappear after toggling them back on" + ); + }); + } +} From ba0eea3f49108ec6533dc95fe03da04cb3ec79c9 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 14 Jul 2026 22:45:05 +0530 Subject: [PATCH 3/5] settings_content: Fix globally excluded language servers starting (#60984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/issues/60763 Follow-up to #60000. Global language server exclusions were added to per-language settings without removing an explicit enable for the same server, allowing it to still start. For example, this setting should disable `vtsls` for every language: ```json { "language_servers": ["!vtsls", "..."] } ``` However, TypeScript’s default configuration explicitly enables `vtsls`, causing it to still start. Release Notes: - Fixed language servers starting despite being disabled globally. --- crates/settings_content/src/language.rs | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index 71beec78f7d..9c1c8f34f8c 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -67,6 +67,12 @@ impl merge_from::MergeFrom for AllLanguageSettingsContent { language_settings.merge_from(&other.defaults); if let Some(mut language_server_overrides) = language_server_overrides { if let Some(disabled) = &globally_disabled_servers { + for disabled_server in disabled { + if let Some(enabled_server) = disabled_server.strip_prefix('!') { + language_server_overrides.retain(|entry| entry != enabled_server); + } + } + let insert_before = language_server_overrides .iter() .position(|entry| entry == REST_OF_LANGUAGE_SERVERS) @@ -1327,6 +1333,55 @@ mod test { ); } + #[test] + fn test_global_language_server_disable_overrides_per_language_enable() { + let mut base = AllLanguageSettingsContent { + defaults: LanguageSettingsContent { + language_servers: Some(vec![REST_OF_LANGUAGE_SERVERS.into()]), + ..LanguageSettingsContent::default() + }, + languages: LanguageToSettingsMap( + [( + "TypeScript".into(), + LanguageSettingsContent { + language_servers: Some(vec![ + "!typescript-language-server".into(), + "vtsls".into(), + REST_OF_LANGUAGE_SERVERS.into(), + ]), + ..LanguageSettingsContent::default() + }, + )] + .into_iter() + .collect(), + ), + ..AllLanguageSettingsContent::default() + }; + + let user = AllLanguageSettingsContent { + defaults: LanguageSettingsContent { + language_servers: Some(vec!["!vtsls".into(), REST_OF_LANGUAGE_SERVERS.into()]), + ..LanguageSettingsContent::default() + }, + ..AllLanguageSettingsContent::default() + }; + + base.merge_from(&user); + + let expected = vec![ + "!typescript-language-server".to_string(), + "!vtsls".to_string(), + REST_OF_LANGUAGE_SERVERS.to_string(), + ]; + assert_eq!( + base.languages + .0 + .get("TypeScript") + .and_then(|settings| settings.language_servers.as_deref()), + Some(expected.as_slice()), + ); + } + #[test] fn test_language_servers_merge_no_per_language_config_uses_global() { let mut base = AllLanguageSettingsContent { From 3e7681382571dec382f7d0f89275f397580b4107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yara=20=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7=EF=B8=8F?= Date: Tue, 14 Jul 2026 19:18:23 +0200 Subject: [PATCH 4/5] pickers: Remove `picker::ToMultiBuffer` action we did not land (#60979) Missed this when shipping the pickers. The feature is still planned and tracked in the tracking issue. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/picker/src/picker.rs | 2 -- crates/picker_preview/src/picker_preview.rs | 17 ++++++----------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index f3c8b0a2684..9f8baac0cba 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -76,8 +76,6 @@ actions!( SetPreviewHidden, /// Opens the footer's actions menu. ToggleActionsMenu, - /// Take the picker's content and open it in a multibuffer - ToMultiBuffer, /// Toggles multi-select mode, in which clicking items adds them to /// the selection instead of opening them ToggleMultiSelect, diff --git a/crates/picker_preview/src/picker_preview.rs b/crates/picker_preview/src/picker_preview.rs index eb4e179171c..3870dc36901 100644 --- a/crates/picker_preview/src/picker_preview.rs +++ b/crates/picker_preview/src/picker_preview.rs @@ -3,13 +3,11 @@ use std::sync::Arc; use gpui::{ - Action, AnyElement, App, AppContext as _, Context, Entity, IntoElement, Pixels, StyledText, - Task, Window, px, + AnyElement, App, AppContext as _, Context, Entity, IntoElement, Pixels, StyledText, Task, + Window, px, }; use language::{Bias, Buffer, HighlightedText, HighlightedTextBuilder, ToPoint}; -use picker::{ - MatchLocation, PreviewBackend, PreviewLayout, PreviewSource, PreviewUpdate, ToMultiBuffer, -}; +use picker::{MatchLocation, PreviewBackend, PreviewLayout, PreviewSource, PreviewUpdate}; use project::{Project, Symbol}; use rope::Point; use settings::Settings; @@ -335,7 +333,7 @@ impl EditorPreview { div() .flex_1() .overflow_hidden() - .child(self.editor_as_giant_button()) + .child(self.occluded_editor()) .into_any_element() } } @@ -357,7 +355,7 @@ impl EditorPreview { .child(content) } - fn editor_as_giant_button(&self) -> impl IntoElement { + fn occluded_editor(&self) -> impl IntoElement { div() .relative() .size_full() @@ -367,10 +365,7 @@ impl EditorPreview { .id("picker-preview-editor") .absolute() .inset_0() - .occlude() - .on_click(|_, window, cx| { - window.dispatch_action(ToMultiBuffer.boxed_clone(), cx); - }), + .occlude(), ) } } From 48a0cbcfd89cf2ee1fc3eda00484cf3128424f12 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:05:11 -0300 Subject: [PATCH 5/5] git_ui: Improve the staged & unstaged grouping in the git panel (#60976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow up to https://github.com/zed-industries/zed/pull/59884 This PR refines how the git panel behaves when grouping changes by staging state, with a focus on making staging workflows predictable and conflict resolution safe. - Renamed the "Group By" menu options to describe what you actually see: **"Tracked & Untracked"** (was "Status") and **"Staged & Unstaged"** (was "Staging"). - When grouping by staged & unstaged, both sections now stay visible even when empty, showing a placeholder message ("No staged changes yet" / "No unstaged changes"). Previously an empty section disappeared entirely, which made the panel layout jump around as you staged and unstaged files, and made it harder to tell at a glance that nothing was staged yet. - Section header controls are now consistent checkboxes everywhere (previously a mix of checkboxes and +/− icon buttons), with "Stage All" / "Unstage All" tooltips. Headers of empty sections render no checkbox and don't react to clicks or hover. I appreciate the debate that happened in the PR linked above about this but I was personally having a hard time understanding what was the difference in interaction given the action was exactly the same, we were just having different UIs, which looked inconsistent. - Arrow-key navigation now skips over section headers and empty-section placeholder rows in both flat and tree view, instead of getting stuck or selecting non-interactive rows — including when jumping to the first or last entry. - In the staged & unstaged grouping, a partially staged file appears in both sections. Each row's checkbox and tooltip now follow the section it's rendered in: rows in Staged always unstage, rows in Unstaged always stage — including via shift-click range operations, which now also support bulk *unstaging* within the Staged section (previously ranges could only stage). The context menu's Stage/Unstage label follows the same rule and stays in sync with in-flight staging operations. - Conflicted files are grouped by whether the current merge marked them conflicted (rather than raw status), so a conflict you've resolved stays visible under "Conflicts" until the merge concludes. On top of that, resolution is now one-way in the UI: - Ticking a conflicted file's checkbox marks it resolved (stages it). Once resolved, the checkbox is disabled with a "Conflict marked as resolved" tooltip — unticking it would silently discard git's record of the unmerged base/ours/theirs versions, a round-trip git can't actually perform. - The same lock applies everywhere the file can be reached: the keyboard toggle, folder checkboxes in tree view, the Conflicts section header (disabled once all conflicts are resolved), "Stage All"/"Unstage All" on the Staged/Unstaged headers, and shift-click range sweeps — none of them will resolve or un-resolve a conflict as a side effect. - The explicit `git: unstage file` action still works as a deliberate escape hatch. --- Here's a video, where you can see I stage and unstage whole files, make a partial staging, and get into a merge-conflict state, where the conflicted files are tagged as resolved: https://github.com/user-attachments/assets/d27ef9c6-5691-45c1-91ab-c3b152aaaa60 --- Release Notes: - N/A _(given the feature hasn't been released yet_) --- crates/git_ui/src/git_panel.rs | 959 ++++++++++++++++++++++----------- 1 file changed, 648 insertions(+), 311 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 85465f98cb4..29328c7746b 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -82,9 +82,8 @@ use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ ButtonLike, Checkbox, Chip, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, - IconButtonShape, IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle, - ProjectEmptyState, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, - prelude::*, + IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes, + Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::paths::PathStyle; use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath}; @@ -328,7 +327,7 @@ fn git_panel_view_options_menu( }) .item({ let view_options_menu_state = view_options_menu_state.clone(); - ContextMenuEntry::new("Status") + ContextMenuEntry::new("Tracked & Untracked") .toggle(IconPosition::End, state.group_by == GitPanelGroupBy::Status) .handler(move |window, cx| { if state.group_by != GitPanelGroupBy::Status { @@ -342,7 +341,7 @@ fn git_panel_view_options_menu( }) .item({ let view_options_menu_state = view_options_menu_state.clone(); - ContextMenuEntry::new("Staging") + ContextMenuEntry::new("Staged & Unstaged") .toggle( IconPosition::End, state.group_by == GitPanelGroupBy::Staging, @@ -476,11 +475,55 @@ struct ProjectedChangeEntry { index: usize, } -#[derive(Clone, Copy)] -struct StagingAction { - stage: bool, - icon: IconName, - label: &'static str, +/// What clicking a staging control should do. +/// +/// In the "staged & unstaged" grouping, a partially staged file appears in both the +/// "Staged" and "Unstaged" sections at once, so a row's meaning comes from +/// the section it is rendered in rather than from the file's own state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StageIntent { + Stage, + Unstage, + Toggle, +} + +impl StageIntent { + fn for_section(section: Section) -> Self { + match section { + Section::Staged => StageIntent::Unstage, + Section::Unstaged => StageIntent::Stage, + _ => StageIntent::Toggle, + } + } + + /// Resolves to a concrete direction (`true` = stage), consulting the + /// current stage status only when no section dictates one. + fn resolve_with(self, stage_status: impl FnOnce() -> StageStatus) -> bool { + match self { + StageIntent::Stage => true, + StageIntent::Unstage => false, + StageIntent::Toggle => match stage_status() { + StageStatus::Staged => false, + StageStatus::Unstaged | StageStatus::PartiallyStaged => true, + }, + } + } + + fn checkbox_state(self, entry_state: impl FnOnce() -> ToggleState) -> ToggleState { + match self { + StageIntent::Stage => ToggleState::Unselected, + StageIntent::Unstage => ToggleState::Selected, + StageIntent::Toggle => entry_state(), + } + } + + fn label(self, stage_status: impl FnOnce() -> StageStatus) -> &'static str { + if self.resolve_with(stage_status) { + "Stage" + } else { + "Unstage" + } + } } #[derive(Clone, Copy, PartialEq, Eq)] @@ -500,9 +543,17 @@ impl GitHeaderEntry { } Section::Tracked => !status.is_created(), Section::New => status.is_created(), - Section::Staged => GitPanel::stage_status_for_entry(status_entry, repo).has_staged(), + // Conflicted files render only under the Conflict section, so the + // Staged/Unstaged bulk operations must not sweep them up: "Unstage + // All" would silently un-resolve conflicts, and "Stage All" would + // silently mark them resolved. + Section::Staged => { + !repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) + && GitPanel::stage_status_for_entry(status_entry, repo).has_staged() + } Section::Unstaged => { - GitPanel::stage_status_for_entry(status_entry, repo).has_unstaged() + !repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) + && GitPanel::stage_status_for_entry(status_entry, repo).has_unstaged() } } } @@ -523,6 +574,7 @@ enum GitListEntry { TreeStatus(GitTreeStatusEntry), Directory(GitTreeDirEntry), Header(GitHeaderEntry), + EmptySection(Section), } impl GitListEntry { @@ -549,6 +601,13 @@ impl GitListEntry { _ => 0, } } + + fn is_selectable(&self) -> bool { + matches!( + self, + GitListEntry::Status(_) | GitListEntry::TreeStatus(_) | GitListEntry::Directory(_) + ) + } } enum GitPanelViewMode { @@ -1239,7 +1298,7 @@ impl GitPanel { .map(|status| status.status) .map(|status| { if GitPanelSettings::get_global(cx).group_by == GitPanelGroupBy::Staging { - if status.is_conflicted() { + if repo.had_conflict_on_last_merge_head_change(&repo_path) { Section::Conflict } else if status.staging().has_staged() { Section::Staged @@ -1566,35 +1625,42 @@ impl GitPanel { return; } - if matches!( - self.entries.get(new_index.saturating_sub(1)), - Some(GitListEntry::Header(..)) - ) && new_index == 0 - { - return; - } - - if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) { - self.selected_entry = match &self.view_mode { - GitPanelViewMode::Flat => Some(new_index.saturating_sub(1)), - GitPanelViewMode::Tree(tree_view_state) => { - maybe!({ - let current_logical_index = tree_view_state - .logical_indices - .iter() - .position(|&i| i == new_index)?; - - tree_view_state - .logical_indices - .get(current_logical_index.saturating_sub(1)) - .copied() - }) + let candidate = match &self.view_mode { + GitPanelViewMode::Flat => { + let mut candidate = new_index; + loop { + match self.entries.get(candidate) { + Some(entry) if entry.is_selectable() => break Some(candidate), + Some(_) => { + if candidate == 0 { + break None; + } + candidate -= 1; + } + None => break None, + } } - }; - } else { - self.selected_entry = Some(new_index); - } + } + GitPanelViewMode::Tree(state) => { + let mut position = state.logical_indices.iter().position(|&i| i == new_index); + loop { + let Some(current) = position else { break None }; + let Some(&index) = state.logical_indices.get(current) else { + break None; + }; + match self.entries.get(index) { + Some(entry) if entry.is_selectable() => break Some(index), + _ => position = current.checked_sub(1), + } + } + } + }; + let Some(candidate) = candidate else { + return; + }; + + self.selected_entry = Some(candidate); self.scroll_to_selected_entry(cx); } @@ -1642,18 +1708,54 @@ impl GitPanel { } }; - if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) { - self.selected_entry = Some(new_index.saturating_add(1)); - } else { - self.selected_entry = Some(new_index); - } + let candidate = match &self.view_mode { + GitPanelViewMode::Flat => { + let mut candidate = new_index; + loop { + match self.entries.get(candidate) { + Some(entry) if entry.is_selectable() => break Some(candidate), + Some(_) => candidate += 1, + None => break None, + } + } + } + GitPanelViewMode::Tree(state) => { + let mut position = state.logical_indices.iter().position(|&i| i == new_index); + loop { + let Some(current) = position else { break None }; + let Some(&index) = state.logical_indices.get(current) else { + break None; + }; + match self.entries.get(index) { + Some(entry) if entry.is_selectable() => break Some(index), + _ => position = Some(current + 1), + } + } + } + }; + let Some(candidate) = candidate else { + return; + }; + + self.selected_entry = Some(candidate); self.scroll_to_selected_entry(cx); } fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - if self.entries.last().is_some() { - self.selected_entry = Some(self.entries.len() - 1); + let last_entry = match &self.view_mode { + GitPanelViewMode::Flat => self.entries.iter().rposition(GitListEntry::is_selectable), + GitPanelViewMode::Tree(state) => { + state.logical_indices.iter().rev().copied().find(|&index| { + self.entries + .get(index) + .is_some_and(GitListEntry::is_selectable) + }) + } + }; + + if let Some(last_entry) = last_entry { + self.selected_entry = Some(last_entry); self.scroll_to_selected_entry(cx); } } @@ -2375,6 +2477,7 @@ impl GitPanel { fn toggle_staged_for_entry( &mut self, entry: &GitListEntry, + intent: StageIntent, _window: &mut Window, cx: &mut Context, ) { @@ -2387,47 +2490,29 @@ impl GitPanel { let (stage, repo_paths) = { let repo = active_repository.read(cx); match entry { - GitListEntry::Status(status_entry) => { + GitListEntry::Status(status_entry) + | GitListEntry::TreeStatus(GitTreeStatusEntry { + entry: status_entry, + .. + }) => { let repo_paths = vec![status_entry.clone()]; - let stage = match GitPanel::stage_status_for_entry(status_entry, &repo) { - StageStatus::Staged => { - if let Some(op) = self.bulk_staging.clone() - && op.anchor == status_entry.repo_path - { - clear_anchor = Some(op.anchor); - } - false - } - StageStatus::Unstaged | StageStatus::PartiallyStaged => { - set_anchor = Some(status_entry.repo_path.clone()); - true - } - }; - (stage, repo_paths) - } - GitListEntry::TreeStatus(status_entry) => { - let repo_paths = vec![status_entry.entry.clone()]; - let stage = match GitPanel::stage_status_for_entry(&status_entry.entry, &repo) { - StageStatus::Staged => { - if let Some(op) = self.bulk_staging.clone() - && op.anchor == status_entry.entry.repo_path - { - clear_anchor = Some(op.anchor); - } - false - } - StageStatus::Unstaged | StageStatus::PartiallyStaged => { - set_anchor = Some(status_entry.entry.repo_path.clone()); - true - } - }; + let stage = intent + .resolve_with(|| GitPanel::stage_status_for_entry(status_entry, &repo)); + + if stage { + set_anchor = Some(status_entry.repo_path.clone()); + } else if let Some(op) = self.bulk_staging.clone() + && op.anchor == status_entry.repo_path + { + clear_anchor = Some(op.anchor); + } (stage, repo_paths) } GitListEntry::Header(section) => { - let goal_staged_state = match section.header { - Section::Staged => false, - Section::Unstaged => true, - _ => !self.header_state(section.header).selected(), + let goal_staged_state = match intent { + StageIntent::Stage => true, + StageIntent::Unstage => false, + StageIntent::Toggle => !self.header_state(section.header).selected(), }; let entries = self .change_entries_by_path() @@ -2442,17 +2527,13 @@ impl GitPanel { (goal_staged_state, entries) } GitListEntry::Directory(entry) => { - let goal_staged_state = match entry.key.section { - Section::Staged => StageStatus::Unstaged, - Section::Unstaged => StageStatus::Staged, - _ => match self.stage_status_for_directory(entry, repo) { - StageStatus::Staged => StageStatus::Unstaged, - StageStatus::Unstaged | StageStatus::PartiallyStaged => { - StageStatus::Staged - } - }, + let goal_stage = + intent.resolve_with(|| self.stage_status_for_directory(entry, repo)); + let goal_staged_state = if goal_stage { + StageStatus::Staged + } else { + StageStatus::Unstaged }; - let goal_stage = goal_staged_state == StageStatus::Staged; let entries = self .view_mode @@ -2468,6 +2549,7 @@ impl GitPanel { .collect::>(); (goal_stage, entries) } + GitListEntry::EmptySection(_) => return, } }; if let Some(anchor) = clear_anchor { @@ -2622,20 +2704,20 @@ impl GitPanel { return; }; - if let Some(action) = self.staging_action_for_entry_index(selected_index) - && let Some(entry) = selected_entry.status_entry() - { - self.change_file_stage(action.stage, vec![entry.clone()], cx); - } else { - self.toggle_staged_for_entry(&selected_entry, window, cx); + if self.is_resolved_conflict(selected_index, cx) { + return; } + + let intent = self.stage_intent_for_entry_index(selected_index); + self.toggle_staged_for_entry(&selected_entry, intent, window, cx); } fn stage_range(&mut self, _: &git::StageRange, _window: &mut Window, cx: &mut Context) { let Some(index) = self.selected_entry else { return; }; - self.stage_bulk(index, cx); + let stage = self.stage_intent_for_entry_index(index) != StageIntent::Unstage; + self.stage_bulk(index, stage, cx); } fn stage_selected(&mut self, _: &git::StageFile, _window: &mut Window, cx: &mut Context) { @@ -4494,7 +4576,7 @@ impl GitPanel { single_staged_entry = Some(entry.clone()); } - if group_by_staging_state && entry.status.is_conflicted() { + if group_by_staging_state && is_conflict { conflict_entries.push(entry); } else if group_by_staging_state { if staging.has_staged() { @@ -4606,6 +4688,17 @@ impl GitPanel { ] }; + // Keep Staged/Unstaged headers pinned even when empty (as long as there's + // anything to show at all) so the layout stays stable while staging. + let has_any_section_entries = section_entries + .iter() + .any(|(_, entries)| !entries.is_empty()); + let show_when_empty = |section: Section| { + group_by_staging_state + && has_any_section_entries + && matches!(section, Section::Staged | Section::Unstaged) + }; + match &mut self.view_mode { GitPanelViewMode::Tree(tree_state) => { tree_state.logical_indices.clear(); @@ -4616,7 +4709,7 @@ impl GitPanel { let mut tree_state = std::mem::take(tree_state); for (section, entries) in section_entries { - if entries.is_empty() { + if entries.is_empty() && !show_when_empty(section) { continue; } @@ -4630,6 +4723,16 @@ impl GitPanel { ); } + if entries.is_empty() { + push_entry( + self, + GitListEntry::EmptySection(section), + section, + true, + Some(&mut tree_state.logical_indices), + ); + } + for (entry, is_visible) in tree_state.build_tree_entries(section, entries, &mut seen_directories) { @@ -4651,7 +4754,7 @@ impl GitPanel { } GitPanelViewMode::Flat => { for (section, entries) in section_entries { - if entries.is_empty() { + if entries.is_empty() && !show_when_empty(section) { continue; } @@ -4665,6 +4768,16 @@ impl GitPanel { ); } + if entries.is_empty() { + push_entry( + self, + GitListEntry::EmptySection(section), + section, + true, + None, + ); + } + for entry in entries { push_entry(self, GitListEntry::Status(entry), section, true, None); } @@ -4736,25 +4849,35 @@ impl GitPanel { }) } - fn staging_action_for_section(section: Section) -> Option { - match section { - Section::Staged => Some(StagingAction { - stage: false, - icon: IconName::Dash, - label: "Unstage", - }), - Section::Unstaged => Some(StagingAction { - stage: true, - icon: IconName::Plus, - label: "Stage", - }), - _ => None, - } + fn stage_intent_for_entry_index(&self, ix: usize) -> StageIntent { + self.section_for_entry_index(ix) + .map_or(StageIntent::Toggle, StageIntent::for_section) } - fn staging_action_for_entry_index(&self, ix: usize) -> Option { - self.section_for_entry_index(ix) - .and_then(Self::staging_action_for_section) + // A conflict that has been marked resolved (fully staged) is locked + // against toggling: unstaging would rebuild the index entry from HEAD, + // silently discarding the unmerged (base/ours/theirs) stages — a + // round-trip git can't actually perform. The explicit git::UnstageFile + // action remains as an escape hatch. + fn is_resolved_conflict(&self, ix: usize, cx: &App) -> bool { + if self.section_for_entry_index(ix) != Some(Section::Conflict) { + return false; + } + let Some(entry) = self.entries.get(ix) else { + return false; + }; + let Some(repo) = self.active_repository.as_ref() else { + return false; + }; + let repo = repo.read(cx); + match entry { + GitListEntry::Directory(directory) => { + self.stage_status_for_directory(directory, repo) == StageStatus::Staged + } + entry => entry.status_entry().is_some_and(|status_entry| { + GitPanel::stage_status_for_entry(status_entry, repo) == StageStatus::Staged + }), + } } fn diff_target_for_section(section: Option
) -> DiffTarget { @@ -4765,20 +4888,6 @@ impl GitPanel { } } - fn staging_action_button( - id: ElementId, - icon: IconName, - action: &'static str, - disabled: bool, - ) -> IconButton { - IconButton::new(id, icon) - .disabled(disabled) - .icon_size(IconSize::Small) - .shape(IconButtonShape::Square) - .style(ButtonStyle::Subtle) - .aria_label(action) - } - fn update_counts(&mut self, repo: &Repository) { self.show_placeholders = false; self.conflicted_count = 0; @@ -5078,7 +5187,7 @@ impl GitPanel { GitListEntry::Directory(dir) => { Some(Self::item_width_estimate(0, dir.name.len(), dir.depth)) } - GitListEntry::Header(_) => None, + GitListEntry::Header(_) | GitListEntry::EmptySection(_) => None, } } @@ -6654,7 +6763,7 @@ impl GitPanel { move |_, window, cx| { git_panel .update(cx, |this, cx| { - this.toggle_staged_for_entry(&entry, window, cx); + this.toggle_staged_for_entry(&entry, StageIntent::Toggle, window, cx); cx.stop_propagation(); }) .ok(); @@ -6754,6 +6863,9 @@ impl GitPanel { cx, )); } + Some(GitListEntry::EmptySection(section)) => { + items.push(this.render_empty_section(*section)); + } None => {} } } @@ -6817,25 +6929,33 @@ impl GitPanel { let id: ElementId = ElementId::Name(format!("header_{}", ix).into()); let checkbox_id: ElementId = ElementId::Name(format!("header_{}_checkbox", ix).into()); let group_name: SharedString = format!("header_{}", ix).into(); - let toggle_state = self.header_state(header.header); let section = header.header; let weak = cx.weak_entity(); - let staging_action = Self::staging_action_for_section(section); - let staging_conflict = GitPanelSettings::get_global(cx).group_by - == GitPanelGroupBy::Staging - && section == Section::Conflict; + let stage_intent = StageIntent::for_section(section); + let toggle_state = stage_intent.checkbox_state(|| self.header_state(header.header)); + + let all_conflicts_resolved = section == Section::Conflict + && self.conflicted_count > 0 + && self.conflicted_staged_count == self.conflicted_count; + + let section_is_empty = !self + .entries + .get(ix + 1) + .is_some_and(GitListEntry::is_selectable); h_flex() .id(id) - .when(!staging_conflict, |this| this.cursor_pointer()) .group(group_name) .h(self.list_item_height()) .w_full() - .pl_3() + .pl_2p5() .pr_1() .gap_2() .justify_between() - .hover(|s| s.bg(cx.theme().colors().ghost_element_hover)) + .when(!section_is_empty && !all_conflicts_resolved, |this| { + this.cursor_pointer() + .hover(|s| s.bg(cx.theme().colors().ghost_element_hover)) + }) .border_1() .border_r_2() .child( @@ -6843,32 +6963,39 @@ impl GitPanel { .color(Color::Muted) .size(LabelSize::Small), ) - .child(if staging_conflict { - div().into_any_element() - } else if let Some(action) = staging_action { - Self::staging_action_button( - checkbox_id, - action.icon, - action.label, - !has_write_access, - ) - .tooltip(move |_window, cx| Tooltip::simple(format!("{} all", action.label), cx)) - .into_any_element() + .child(if section_is_empty { + gpui::Empty.into_any_element() } else { - Checkbox::new(checkbox_id, toggle_state) - .disabled(!has_write_access) + let checkbox = Checkbox::new(checkbox_id, toggle_state) + .disabled(!has_write_access || all_conflicts_resolved) .fill() - .elevation(ElevationIndex::Surface) - .into_any_element() + .elevation(ElevationIndex::Surface); + let tooltip_label = if all_conflicts_resolved { + Some("All conflicts marked as resolved") + } else { + match stage_intent { + StageIntent::Stage => Some("Stage All"), + StageIntent::Unstage => Some("Unstage All"), + StageIntent::Toggle => None, + } + }; + if let Some(label) = tooltip_label { + checkbox + .tooltip(move |_window, cx| Tooltip::simple(label, cx)) + .into_any_element() + } else { + checkbox.into_any_element() + } }) .on_click(move |_, window, cx| { - if !has_write_access || staging_conflict { + if !has_write_access || section_is_empty || all_conflicts_resolved { return; } weak.update(cx, |this, cx| { this.toggle_staged_for_entry( &GitListEntry::Header(GitHeaderEntry { header: section }), + stage_intent, window, cx, ); @@ -6879,6 +7006,26 @@ impl GitPanel { .into_any_element() } + fn render_empty_section(&self, section: Section) -> AnyElement { + let message = match section { + Section::Staged => "No staged changes yet", + Section::Unstaged => "No unstaged changes", + _ => "No changes", + }; + h_flex() + .h(self.list_item_height()) + .w_full() + .pl_2p5() + .pr_1() + .opacity(0.8) + .child( + Label::new(message) + .color(Color::Placeholder) + .size(LabelSize::Small), + ) + .into_any_element() + } + pub fn load_commit_details( &self, sha: String, @@ -6900,15 +7047,20 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) { - let staging_action = self.staging_action_for_entry_index(ix); + let stage_intent = self.stage_intent_for_entry_index(ix); let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else { return; }; - let stage_title = match staging_action { - Some(StagingAction { stage: true, .. }) => "Stage File", - Some(StagingAction { stage: false, .. }) => "Unstage File", - None if entry.status.staging().is_fully_staged() => "Unstage File", - None => "Stage File", + // Resolve against the pending-op-aware status (like the checkboxes do) + // so the menu label can't lag behind a just-clicked checkbox. + let repo = self.active_repository.as_ref().map(|repo| repo.read(cx)); + let stage_title = if stage_intent.resolve_with(|| match repo { + Some(repo) => GitPanel::stage_status_for_entry(entry, repo), + None => entry.status.staging(), + }) { + "Stage File" + } else { + "Unstage File" }; let restore_title = if entry.status.is_created() { "Trash File" @@ -7059,19 +7211,19 @@ impl GitPanel { ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into()); let stage_status = GitPanel::stage_status_for_entry(entry, &repo); - let section = self.section_for_entry_index(ix); - let staging_conflict = settings.group_by == GitPanelGroupBy::Staging - && section == Some(Section::Conflict) - && status.is_conflicted(); - let staging_action = self.staging_action_for_entry_index(ix); - let mut is_staged: ToggleState = match stage_status { - StageStatus::Staged => ToggleState::Selected, - StageStatus::Unstaged => ToggleState::Unselected, - StageStatus::PartiallyStaged => ToggleState::Indeterminate, - }; - if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() { - is_staged = ToggleState::Selected; - } + let stage_intent = self.stage_intent_for_entry_index(ix); + let resolved_conflict = self.is_resolved_conflict(ix, cx); + let toggle_state = stage_intent.checkbox_state(|| { + if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() { + ToggleState::Selected + } else { + match stage_status { + StageStatus::Staged => ToggleState::Selected, + StageStatus::Unstaged => ToggleState::Unselected, + StageStatus::PartiallyStaged => ToggleState::Indeterminate, + } + } + }); let handle = cx.weak_entity(); @@ -7148,7 +7300,7 @@ impl GitPanel { .id(id) .h(self.list_item_height()) .w_full() - .pl_3() + .pl_2p5() .pr_1() .gap_1p5() .border_1() @@ -7176,67 +7328,25 @@ impl GitPanel { .flex_none() .occlude() .cursor_pointer() - .child(if staging_conflict { - Self::staging_action_button( - checkbox_id, - IconName::Check, - "Mark as Resolved", - !has_write_access, - ) - .on_click({ - let entry = entry.clone(); - let this = cx.weak_entity(); - move |_, _window, cx| { - this.update(cx, |this, cx| { - if !has_write_access { - return; - } - this.change_file_stage(true, vec![entry.clone()], cx); - cx.stop_propagation(); - }) - .ok(); - } - }) - .tooltip(move |_window, cx| Tooltip::simple("Mark as Resolved", cx)) - .into_any_element() - } else if let Some(action) = staging_action { - Self::staging_action_button( - checkbox_id, - action.icon, - action.label, - !has_write_access, - ) - .on_click({ - let entry = entry.clone(); - let this = cx.weak_entity(); - move |_, _window, cx| { - this.update(cx, |this, cx| { - if !has_write_access { - return; - } - this.change_file_stage(action.stage, vec![entry.clone()], cx); - cx.stop_propagation(); - }) - .ok(); - } - }) - .tooltip(move |_window, cx| Tooltip::simple(action.label, cx)) - .into_any_element() - } else { - Checkbox::new(checkbox_id, is_staged) - .disabled(!has_write_access) + .child( + Checkbox::new(checkbox_id, toggle_state) .fill() .elevation(ElevationIndex::Surface) + .disabled(!has_write_access || resolved_conflict) .on_click_ext({ let entry = entry.clone(); let this = cx.weak_entity(); move |_, click, window, cx| { this.update(cx, |this, cx| { - if !has_write_access { + if !has_write_access || resolved_conflict { return; } if click.modifiers().shift { - this.stage_bulk(ix, cx); + this.stage_bulk( + ix, + stage_intent != StageIntent::Unstage, + cx, + ); } else { let list_entry = if GitPanelSettings::get_global(cx).tree_view { @@ -7247,7 +7357,12 @@ impl GitPanel { } else { GitListEntry::Status(entry.clone()) }; - this.toggle_staged_for_entry(&list_entry, window, cx); + this.toggle_staged_for_entry( + &list_entry, + stage_intent, + window, + cx, + ); } cx.stop_propagation(); }) @@ -7255,16 +7370,14 @@ impl GitPanel { } }) .tooltip(move |_window, cx| { - let action = match stage_status { - StageStatus::Staged => "Unstage", - StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", - }; - let tooltip_name = action.to_string(); - - Tooltip::for_action(tooltip_name, &ToggleStaged, cx) - }) - .into_any_element() - }), + if resolved_conflict { + Tooltip::simple("Conflict marked as resolved", cx) + } else { + let action = stage_intent.label(|| stage_status); + Tooltip::for_action(action, &ToggleStaged, cx) + } + }), + ), ) .on_click({ cx.listener(move |this, event: &ClickEvent, window, cx| { @@ -7360,18 +7473,13 @@ impl GitPanel { StageStatus::PartiallyStaged }; - let toggle_state: ToggleState = match stage_status { + let stage_intent = StageIntent::for_section(entry.key.section); + let resolved_conflict = self.is_resolved_conflict(ix, cx); + let toggle_state = stage_intent.checkbox_state(|| match stage_status { StageStatus::Staged => ToggleState::Selected, StageStatus::Unstaged => ToggleState::Unselected, StageStatus::PartiallyStaged => ToggleState::Indeterminate, - }; - let staging_action = if settings.group_by == GitPanelGroupBy::Staging { - Self::staging_action_for_section(entry.key.section) - } else { - None - }; - let staging_conflict = - settings.group_by == GitPanelGroupBy::Staging && entry.key.section == Section::Conflict; + }); let name_row = h_flex() .min_w_0() @@ -7397,7 +7505,7 @@ impl GitPanel { .h(self.list_item_height()) .min_w_0() .w_full() - .pl_3() + .pl_2p5() .pr_1() .gap_1p5() .justify_between() @@ -7416,37 +7524,9 @@ impl GitPanel { .flex_none() .occlude() .cursor_pointer() - .child(if staging_conflict { - div().into_any_element() - } else if let Some(action) = staging_action { - Self::staging_action_button( - checkbox_id, - action.icon, - action.label, - !has_write_access, - ) - .on_click({ - let entry = entry.clone(); - let this = cx.weak_entity(); - move |_, window, cx| { - this.update(cx, |this, cx| { - if !has_write_access { - return; - } - let list_entry = GitListEntry::Directory(entry.clone()); - this.toggle_staged_for_entry(&list_entry, window, cx); - cx.stop_propagation(); - }) - .ok(); - } - }) - .tooltip(move |_window, cx| { - Tooltip::simple(format!("{} folder", action.label), cx) - }) - .into_any_element() - } else { + .child( Checkbox::new(checkbox_id, toggle_state) - .disabled(!has_write_access) + .disabled(!has_write_access || resolved_conflict) .fill() .elevation(ElevationIndex::Surface) .on_click({ @@ -7454,11 +7534,12 @@ impl GitPanel { let this = cx.weak_entity(); move |_, window, cx| { this.update(cx, |this, cx| { - if !has_write_access { + if !has_write_access || resolved_conflict { return; } this.toggle_staged_for_entry( &GitListEntry::Directory(entry.clone()), + stage_intent, window, cx, ); @@ -7468,14 +7549,14 @@ impl GitPanel { } }) .tooltip(move |_window, cx| { - let action = match stage_status { - StageStatus::Staged => "Unstage", - StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", - }; - Tooltip::simple(format!("{action} folder"), cx) - }) - .into_any_element() - }), + if resolved_conflict { + Tooltip::simple("Conflicts marked as resolved", cx) + } else { + let action = stage_intent.label(|| stage_status); + Tooltip::simple(format!("{action} Folder"), cx) + } + }), + ), ) .on_click({ let key = entry.key.clone(); @@ -7621,14 +7702,17 @@ impl GitPanel { }) } - fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) { + fn stage_bulk(&mut self, mut index: usize, stage: bool, cx: &mut Context<'_, Self>) { let Some(op) = self.bulk_staging.as_ref() else { return; }; let Some(mut anchor_index) = self.entry_by_path(&op.anchor) else { return; }; - if let Some(entry) = self.entries.get(index) + // Only a staged anchor survives the next entries refresh, so there's no + // point re-anchoring on the entry we're about to unstage. + if stage + && let Some(entry) = self.entries.get(index) && let Some(entry) = entry.status_entry() { self.set_bulk_staging_anchor(entry.repo_path.clone(), cx); @@ -7636,14 +7720,21 @@ impl GitPanel { if index < anchor_index { std::mem::swap(&mut index, &mut anchor_index); } + let Some(repo) = self.active_repository.clone() else { + return; + }; + let repo = repo.read(cx); + // Conflicts only change staging via their own explicit controls; a + // range sweep must neither mark them resolved nor un-resolve them. let entries = self .entries .get(anchor_index..=index) .unwrap_or_default() .iter() .filter_map(|entry| entry.status_entry().cloned()) + .filter(|entry| !repo.had_conflict_on_last_merge_head_change(&entry.repo_path)) .collect::>(); - self.change_file_stage(true, entries, cx); + self.change_file_stage(stage, entries, cx); } fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) { @@ -9464,16 +9555,12 @@ mod tests { ] ); assert_eq!( - panel - .staging_action_for_entry_index(projections[0].index) - .map(|action| action.stage), - Some(false) + panel.stage_intent_for_entry_index(projections[0].index), + StageIntent::Unstage ); assert_eq!( - panel - .staging_action_for_entry_index(projections[1].index) - .map(|action| action.stage), - Some(true) + panel.stage_intent_for_entry_index(projections[1].index), + StageIntent::Stage ); panel.entries.clone() }); @@ -9665,6 +9752,14 @@ mod tests { status: FileStatus::Unmerged(..), .. }), + Header(GitHeaderEntry { + header: Section::Staged + }), + EmptySection(Section::Staged), + Header(GitHeaderEntry { + header: Section::Unstaged + }), + EmptySection(Section::Unstaged), ], ); panel @@ -9691,6 +9786,14 @@ mod tests { status: FileStatus::Unmerged(..), .. }), + Header(GitHeaderEntry { + header: Section::Staged + }), + EmptySection(Section::Staged), + Header(GitHeaderEntry { + header: Section::Unstaged + }), + EmptySection(Section::Unstaged), ] )); }); @@ -9713,12 +9816,246 @@ mod tests { staging: StageStatus::Staged, .. }), + Header(GitHeaderEntry { + header: Section::Unstaged + }), + EmptySection(Section::Unstaged), ], ); assert_eq!(panel.entry_count, 1); }); } + #[gpui::test] + async fn test_resolved_conflict_is_locked_against_unstaging(cx: &mut TestAppContext) { + use GitListEntry::*; + + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "conflict.rs": "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n", + "staged.rs": "staged content", + "unstaged.rs": "unstaged content", + }), + ) + .await; + + let unresolved_status = FileStatus::Unmerged(UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + }); + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ("conflict.rs", unresolved_status), + ("staged.rs", FileStatus::index(StatusCode::Modified)), + ("unstaged.rs", StatusCode::Modified.worktree()), + ], + ); + // With MERGE_HEAD present (an in-progress merge), a resolved conflict + // keeps rendering under the Conflict section instead of moving to Staged. + fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { + state.refs.insert("MERGE_HEAD".into(), "merge-sha".into()); + }) + .unwrap(); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + cx.executor().run_until_parked(); + + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + fn stage_status_of( + panel: &Entity, + cx: &VisualTestContext, + path: &str, + ) -> StageStatus { + panel.read_with(cx, |panel, cx| { + let repo = panel + .active_repository + .as_ref() + .expect("active repository should exist") + .read(cx); + let entry = panel + .change_entries_by_path() + .find(|entry| entry.repo_path == repo_path(path)) + .expect("entry should exist") + .clone(); + GitPanel::stage_status_for_entry(&entry, repo) + }) + } + + let conflict_entry = panel.read_with(&cx, |panel, _| { + pretty_assertions::assert_matches!( + panel.entries.as_slice(), + &[ + Header(GitHeaderEntry { + header: Section::Conflict + }), + Status(GitStatusEntry { + status: FileStatus::Unmerged(..), + .. + }), + Header(GitHeaderEntry { + header: Section::Staged + }), + Status(GitStatusEntry { + staging: StageStatus::Staged, + .. + }), + Header(GitHeaderEntry { + header: Section::Unstaged + }), + Status(GitStatusEntry { + staging: StageStatus::Unstaged, + .. + }), + ], + ); + panel + .entries + .get(1) + .and_then(GitListEntry::status_entry) + .cloned() + .expect("conflict entry should exist") + }); + + // Resolve the conflict: simulate what `git add` does to the status. + panel.update_in(&mut cx, |panel, window, cx| { + panel.toggle_staged_for_entry( + &GitListEntry::Status(conflict_entry.clone()), + StageIntent::Toggle, + window, + cx, + ); + }); + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ("conflict.rs", FileStatus::index(StatusCode::Modified)), + ("staged.rs", FileStatus::index(StatusCode::Modified)), + ("unstaged.rs", StatusCode::Modified.worktree()), + ], + ); + cx.run_until_parked(); + await_git_panel_entries(&panel, &mut cx).await; + + // The resolved conflict stays in the Conflict section, staged. + panel.read_with(&cx, |panel, cx| { + assert_eq!( + panel.section_for_entry_index( + panel + .entry_by_path(&repo_path("conflict.rs")) + .expect("conflict entry should exist") + ), + Some(Section::Conflict) + ); + assert!( + panel.is_resolved_conflict( + panel.entry_by_path(&repo_path("conflict.rs")).unwrap(), + cx + ) + ); + }); + assert_eq!( + stage_status_of(&panel, &cx, "conflict.rs"), + StageStatus::Staged + ); + + // The keyboard toggle must not unstage a resolved conflict. + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = panel.entry_by_path(&repo_path("conflict.rs")); + panel.toggle_staged_for_selected(&ToggleStaged, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + stage_status_of(&panel, &cx, "conflict.rs"), + StageStatus::Staged + ); + + // "Unstage All" on the Staged header must skip resolved conflicts while + // still unstaging regular staged files. + panel.update_in(&mut cx, |panel, window, cx| { + panel.toggle_staged_for_entry( + &GitListEntry::Header(GitHeaderEntry { + header: Section::Staged, + }), + StageIntent::Unstage, + window, + cx, + ); + }); + cx.run_until_parked(); + assert_eq!( + stage_status_of(&panel, &cx, "staged.rs"), + StageStatus::Unstaged + ); + assert_eq!( + stage_status_of(&panel, &cx, "conflict.rs"), + StageStatus::Staged + ); + + // A shift-click range sweep anchored at the conflict must skip it too. + let staged_entry = panel.read_with(&cx, |panel, _| { + panel + .change_entries_by_path() + .find(|entry| entry.repo_path == repo_path("staged.rs")) + .cloned() + .expect("staged entry should exist") + }); + panel.update_in(&mut cx, |panel, _window, cx| { + panel.change_file_stage(true, vec![staged_entry], cx); + }); + cx.run_until_parked(); + await_git_panel_entries(&panel, &mut cx).await; + + panel.update_in(&mut cx, |panel, _window, cx| { + panel.set_bulk_staging_anchor(repo_path("conflict.rs"), cx); + let last_index = panel.entries.len() - 1; + panel.stage_bulk(last_index, false, cx); + }); + cx.run_until_parked(); + assert_eq!( + stage_status_of(&panel, &cx, "staged.rs"), + StageStatus::Unstaged + ); + assert_eq!( + stage_status_of(&panel, &cx, "conflict.rs"), + StageStatus::Staged + ); + } + #[gpui::test] async fn test_group_by_staging_primary_action_stages_partially_staged_files( cx: &mut TestAppContext, @@ -9990,7 +10327,7 @@ mod tests { let second_status_entry = entries[3].clone(); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&second_status_entry, window, cx); + panel.toggle_staged_for_entry(&second_status_entry, StageIntent::Toggle, window, cx); }); panel.update_in(cx, |panel, window, cx| { @@ -10039,7 +10376,7 @@ mod tests { let third_status_entry = entries[4].clone(); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&third_status_entry, window, cx); + panel.toggle_staged_for_entry(&third_status_entry, StageIntent::Toggle, window, cx); }); panel.update_in(cx, |panel, window, cx| { @@ -10201,7 +10538,7 @@ mod tests { let second_status_entry = entries[3].clone(); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&second_status_entry, window, cx); + panel.toggle_staged_for_entry(&second_status_entry, StageIntent::Toggle, window, cx); }); cx.update(|_window, cx| { @@ -10269,7 +10606,7 @@ mod tests { let third_status_entry = entries[4].clone(); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&third_status_entry, window, cx); + panel.toggle_staged_for_entry(&third_status_entry, StageIntent::Toggle, window, cx); }); panel.update_in(cx, |panel, window, cx| { @@ -11412,7 +11749,7 @@ mod tests { let first_status_entry = entries[1].clone(); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&first_status_entry, window, cx); + panel.toggle_staged_for_entry(&first_status_entry, StageIntent::Toggle, window, cx); }); cx.read(|cx| { @@ -11449,7 +11786,7 @@ mod tests { let second_status_entry = entries[3].clone(); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&second_status_entry, window, cx); + panel.toggle_staged_for_entry(&second_status_entry, StageIntent::Toggle, window, cx); }); cx.read(|cx| { @@ -11486,7 +11823,7 @@ mod tests { assert!(message.is_none()); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&first_status_entry, window, cx); + panel.toggle_staged_for_entry(&first_status_entry, StageIntent::Toggle, window, cx); }); cx.read(|cx| { @@ -11522,7 +11859,7 @@ mod tests { assert_eq!(message, Some("Create untracked".to_string())); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&second_status_entry, window, cx); + panel.toggle_staged_for_entry(&second_status_entry, StageIntent::Toggle, window, cx); }); cx.read(|cx| {