From 72656afa6d477d57ca875b1b7c8423483e32dc74 Mon Sep 17 00:00:00 2001 From: John Tur Date: Tue, 14 Jul 2026 12:40:22 -0400 Subject: [PATCH 01/22] 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 02/22] 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 03/22] 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 04/22] 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 05/22] 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| { From 75ac36d92aca726f262b8b00d3567fefbffb1d1c Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Tue, 14 Jul 2026 15:16:37 -0400 Subject: [PATCH 06/22] Surface the git graph context menu in the git panel's history tab (#60713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR refactors the Git Graph’s commit context menu into its own file so it can be reused by both the Git Graph and the Git panel’s History tab. SCR-20260709-qlxt Also, includes a "Show in Git Graph" option only shown in the history tab's context menu: https://github.com/user-attachments/assets/2e30648b-2802-4caa-a99e-2d135e2dc539 Release Notes: - Surfaced the git graph context menu in the git panel's history tab. --- crates/git_ui/src/commit_context_menu.rs | 243 +++++++++++++++++++++ crates/git_ui/src/git_graph.rs | 256 +++-------------------- crates/git_ui/src/git_panel.rs | 90 ++++++-- crates/git_ui/src/git_ui.rs | 1 + 4 files changed, 353 insertions(+), 237 deletions(-) create mode 100644 crates/git_ui/src/commit_context_menu.rs diff --git a/crates/git_ui/src/commit_context_menu.rs b/crates/git_ui/src/commit_context_menu.rs new file mode 100644 index 00000000000..e3affe8732e --- /dev/null +++ b/crates/git_ui/src/commit_context_menu.rs @@ -0,0 +1,243 @@ +use crate::commit_view::CommitView; +use git::Oid; +use gpui::{Action, ClipboardItem, Entity, FocusHandle, SharedString, WeakEntity, Window, actions}; +use project::{GIT_COMMAND_TASK_TAG, git_store::Repository}; + +use task::{TaskContext, TaskVariables, VariableName}; +use ui::{Color, ContextMenu, ContextMenuEntry, IconName, IconPosition, prelude::*}; +use workspace::Workspace; + +actions!( + git_graph, + [ + /// Copies the SHA of the selected commit to the clipboard. + CopyCommitSha, + /// Copies a tag from the selected commit to the clipboard. + CopyCommitTag, + /// Opens the commit view for the selected commit. + OpenCommitView, + ] +); + +const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.); +const CUSTOM_GIT_COMMANDS_DOCS_SLUG: &str = "tasks#custom-git-commands"; + +pub(crate) struct CommitContextMenuData { + pub(crate) sha: Oid, + pub(crate) tag_names: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum CommitContextMenuSource { + GitGraph, + GitPanel, +} + +pub(crate) fn commit_context_menu( + commit: CommitContextMenuData, + source: CommitContextMenuSource, + ref_name: Option, + focus_handle: FocusHandle, + repository: Option>, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, +) -> Entity { + let sha = commit.sha; + let sha_short = sha.display_short(); + let git_tasks = git_context_menu_tasks( + git_task_context(&repository, sha, ref_name.as_deref(), cx), + &workspace, + cx, + ); + let header = match &ref_name { + Some(ref_name) => format!("Ref {ref_name}"), + None => format!("Commit {sha_short}"), + }; + + ContextMenu::build(window, cx, move |context_menu, _, _| { + context_menu + .context(focus_handle) + .header(header) + .entry("View Commit", Some(OpenCommitView.boxed_clone()), { + let repository = repository.clone(); + let workspace = workspace.clone(); + move |window, cx| { + let Some(repository) = repository.clone() else { + return; + }; + CommitView::open( + sha.to_string(), + repository, + workspace.clone(), + None, + None, + window, + cx, + ); + } + }) + .entry( + "Copy SHA", + Some(CopyCommitSha.boxed_clone()), + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(sha.to_string())); + }, + ) + .when_some(ref_name.clone(), |menu, ref_name| { + menu.entry("Copy Ref Name", None, move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(ref_name.to_string())); + }) + }) + .when(ref_name.is_none(), |menu| { + menu.map(|menu| { + let tag_names = commit.tag_names.clone(); + let copy_tag_label = "Copy Tag"; + + match tag_names.as_slice() { + [] => menu.item( + ContextMenuEntry::new(copy_tag_label) + .action(CopyCommitTag.boxed_clone()) + .disabled(true), + ), + [tag_name] => { + let tag_name = tag_name.clone(); + let label = format!("{copy_tag_label}: {tag_name}"); + menu.entry( + label, + Some(CopyCommitTag.boxed_clone()), + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + tag_name.to_string(), + )); + }, + ) + } + _ => menu.submenu(copy_tag_label, move |menu, _window, _cx| { + let mut menu = menu.fixed_width(COMMIT_TAG_LIST_WIDTH_IN_REMS.into()); + + for tag_name in tag_names.clone() { + let tag_name_to_copy = tag_name.clone(); + menu = menu.entry(tag_name, None, move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + tag_name_to_copy.to_string(), + )); + }); + } + menu + }), + } + }) + }) + .when(source == CommitContextMenuSource::GitPanel, |menu| { + menu.entry("Show in Git Graph", None, move |window, cx| { + window.dispatch_action( + Box::new(crate::git_graph::OpenAtCommit { + sha: sha.to_string(), + }), + cx, + ); + }) + }) + .map(|mut menu| { + menu = menu.separator().header("Custom Commands"); + + if git_tasks.is_empty() { + return menu.item( + ContextMenuEntry::new("Learn More") + .icon(IconName::ArrowUpRight) + .icon_color(Color::Muted) + .icon_position(IconPosition::End) + .handler(|_window, cx| { + let docs_url = + release_channel::docs_url(CUSTOM_GIT_COMMANDS_DOCS_SLUG, cx); + cx.open_url(&docs_url); + }), + ); + } + + for (task_source_kind, resolved_task) in git_tasks { + let label = resolved_task.display_label().to_string(); + let workspace = workspace.clone(); + menu = menu.entry(label, None, move |window, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.schedule_resolved_task( + task_source_kind.clone(), + resolved_task.clone(), + false, + window, + cx, + ); + }) + .ok(); + }); + } + + menu + }) + }) +} + +fn git_task_context( + repository: &Option>, + commit_sha: git::Oid, + ref_name: Option<&str>, + cx: &App, +) -> Option { + let repository_path = repository + .as_ref()? + .upgrade()? + .read(cx) + .work_directory_abs_path + .to_path_buf(); + let repository_name = repository_path + .file_name() + .and_then(|name| name.to_str()) + .map(ToString::to_string); + let mut task_variables = TaskVariables::from_iter([ + (VariableName::GitSha, commit_sha.to_string()), + (VariableName::GitShaShort, commit_sha.display_short()), + ( + VariableName::GitRepositoryPath, + repository_path.to_string_lossy().into_owned(), + ), + ]); + + if let Some(repository_name) = repository_name { + task_variables.insert(VariableName::GitRepositoryName, repository_name); + } + if let Some(ref_name) = ref_name { + task_variables.insert(VariableName::GitRef, ref_name.to_string()); + } + + Some(TaskContext { + cwd: Some(repository_path), + task_variables, + ..TaskContext::default() + }) +} + +fn git_context_menu_tasks( + task_context: Option, + workspace: &WeakEntity, + cx: &App, +) -> Vec<(project::TaskSourceKind, task::ResolvedTask)> { + let Some(task_context) = task_context else { + return Vec::new(); + }; + let Some(workspace) = workspace.upgrade() else { + return Vec::new(); + }; + let project = workspace.read(cx).project().clone(); + let task_inventory = project.read_with(cx, |project, cx| { + project.task_store().read(cx).task_inventory().cloned() + }); + let Some(task_inventory) = task_inventory else { + return Vec::new(); + }; + + task_inventory + .read(cx) + .resolve_global_tasks_with_tag(GIT_COMMAND_TASK_TAG, &task_context) +} diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 343cf2abbdc..43995457906 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1,4 +1,6 @@ +pub use crate::commit_context_menu::{CopyCommitSha, CopyCommitTag, OpenCommitView}; use crate::{ + commit_context_menu::{CommitContextMenuData, CommitContextMenuSource, commit_context_menu}, commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip}, commit_view::CommitView, git_status_icon, @@ -17,9 +19,9 @@ use git::{ status::{FileStatus, StatusCode, TrackedStatus}, }; use gpui::{ - Action, Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, - DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, - Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollHandle, ScrollStrategy, + Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DismissEvent, + DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, + MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollHandle, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list, @@ -29,7 +31,7 @@ use markdown::{Markdown, MarkdownElement}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use picker::{Picker, PickerDelegate}; use project::{ - GIT_COMMAND_TASK_TAG, ProjectPath, TaskSourceKind, + ProjectPath, git_store::{ CommitDataState, GitGraphEvent, GitStore, GitStoreEvent, GraphDataResponse, Repository, RepositoryEvent, RepositoryId, @@ -47,12 +49,12 @@ use std::{ sync::{Arc, OnceLock}, time::{Duration, Instant}, }; -use task::{ResolvedTask, TaskContext, TaskVariables, VariableName}; + use theme::AccentColors; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ - Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, ContextMenuEntry, DiffStat, - Divider, HeaderResizeInfo, HighlightedLabel, IndentGuideColors, ListItem, ListItemSpacing, + Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, + HeaderResizeInfo, HighlightedLabel, IndentGuideColors, ListItem, ListItemSpacing, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns, prelude::*, redistribute_hidden_fractions, redistribute_hidden_widths, @@ -72,7 +74,6 @@ const LINE_WIDTH: Pixels = px(1.5); const RESIZE_HANDLE_WIDTH: f32 = 8.0; const COPIED_STATE_DURATION: Duration = Duration::from_secs(2); const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.); -const CUSTOM_GIT_COMMANDS_DOCS_SLUG: &str = "tasks#custom-git-commands"; const TREE_INDENT: f32 = 20.0; const TABLE_COLUMN_COUNT: usize = 4; const ROW_VERTICAL_PADDING: Pixels = px(4.0); @@ -580,12 +581,6 @@ actions!( [ /// Opens the Git Graph Tab. Open, - /// Copies the SHA of the selected commit to the clipboard. - CopyCommitSha, - /// Copies a tag from the selected commit to the clipboard. - CopyCommitTag, - /// Opens the commit view for the selected commit. - OpenCommitView, /// Focuses the search field. FocusSearch, /// Focuses the next git graph tab stop. @@ -2467,91 +2462,6 @@ impl GitGraph { self.copy_commit_tag(selected_entry_index, window, cx); } - fn git_task_context( - &self, - commit_sha: Oid, - ref_name: Option<&str>, - cx: &App, - ) -> Option { - let repository_path = self - .get_repository(cx)? - .read(cx) - .work_directory_abs_path - .to_path_buf(); - - let repository_name = repository_path - .file_name() - .and_then(|name| name.to_str()) - .map(ToString::to_string); - - let mut task_variables = TaskVariables::from_iter([ - (VariableName::GitSha, commit_sha.to_string()), - (VariableName::GitShaShort, commit_sha.display_short()), - ( - VariableName::GitRepositoryPath, - repository_path.to_string_lossy().into_owned(), - ), - ]); - - if let Some(repository_name) = repository_name { - task_variables.insert(VariableName::GitRepositoryName, repository_name); - } - - if let Some(ref_name) = ref_name { - task_variables.insert(VariableName::GitRef, ref_name.to_string()); - } - - Some(TaskContext { - cwd: Some(repository_path), - task_variables, - ..TaskContext::default() - }) - } - - fn git_context_menu_tasks( - &self, - task_context: &TaskContext, - cx: &App, - ) -> Vec<(TaskSourceKind, ResolvedTask)> { - let Some(workspace) = self.workspace.upgrade() else { - return Vec::new(); - }; - - let project = workspace.read(cx).project().clone(); - - let task_inventory = project.read_with(cx, |project, cx| { - project.task_store().read(cx).task_inventory().cloned() - }); - - let Some(task_inventory) = task_inventory else { - return Vec::new(); - }; - - task_inventory - .read(cx) - .resolve_global_tasks_with_tag(GIT_COMMAND_TASK_TAG, task_context) - } - - fn schedule_git_task( - &mut self, - task_source_kind: TaskSourceKind, - resolved_task: ResolvedTask, - window: &mut Window, - cx: &mut Context, - ) { - self.workspace - .update(cx, |workspace, cx| { - workspace.schedule_resolved_task( - task_source_kind, - resolved_task, - false, - window, - cx, - ); - }) - .ok(); - } - fn deploy_entry_context_menu( &mut self, position: Point, @@ -2563,129 +2473,27 @@ impl GitGraph { let Some(commit) = self.graph_data.commits.get(index) else { return; }; - let sha = commit.data.sha; - let sha_short = sha.display_short(); - let git_tasks = self - .git_task_context(sha, ref_name.as_deref(), cx) - .map(|task_context| self.git_context_menu_tasks(&task_context, cx)) - .unwrap_or_default(); - - let header = match &ref_name { - Some(ref_name) => format!("Ref {ref_name}"), - None => format!("Commit {sha_short}"), - }; - - let focus_handle = self.focus_handle.clone(); - let git_graph = cx.entity(); - let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| { - context_menu - .context(focus_handle) - .header(header) - .entry( - "View Commit", - Some(OpenCommitView.boxed_clone()), - window.handler_for(&git_graph, move |this, window, cx| { - this.open_commit_view(index, window, cx); - }), - ) - .entry( - "Copy SHA", - Some(CopyCommitSha.boxed_clone()), - window.handler_for(&git_graph, move |this, _window, cx| { - this.copy_commit_sha(index, cx); - }), - ) - .when_some(ref_name.clone(), |menu, ref_name| { - menu.entry("Copy Ref Name", None, move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(ref_name.to_string())); - }) - }) - .when(ref_name.is_none(), |menu| { - menu.map(|menu| { - let tag_names = commit - .data - .tag_names() - .into_iter() - .map(|tag_name| SharedString::from(tag_name.to_string())) - .collect::>(); - let copy_tag_label = "Copy Tag"; - - match tag_names.as_slice() { - [] => menu.item( - ContextMenuEntry::new(copy_tag_label) - .action(CopyCommitTag.boxed_clone()) - .disabled(true), - ), - [tag_name] => { - let tag_name = tag_name.clone(); - let label = format!("{copy_tag_label}: {tag_name}"); - menu.entry( - label, - Some(CopyCommitTag.boxed_clone()), - move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - tag_name.to_string(), - )); - }, - ) - } - _ => menu.submenu(copy_tag_label, move |menu, _window, _cx| { - let mut menu = - menu.fixed_width(COMMIT_TAG_LIST_WIDTH_IN_REMS.into()); - - for tag_name in tag_names.clone() { - let tag_name_to_copy = tag_name.clone(); - - menu = menu.entry(tag_name, None, move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - tag_name_to_copy.to_string(), - )); - }); - } - menu - }), - } - }) - }) - .map(|mut menu| { - menu = menu.separator().header("Custom Commands"); - - if git_tasks.is_empty() { - return menu.item( - ContextMenuEntry::new("Learn More") - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_position(IconPosition::End) - .handler(|_window, cx| { - let docs_url = release_channel::docs_url( - CUSTOM_GIT_COMMANDS_DOCS_SLUG, - cx, - ); - cx.open_url(&docs_url); - }), - ); - } - - for (task_source_kind, resolved_task) in git_tasks { - let label = resolved_task.display_label().to_string(); - - menu = menu.entry( - label, - None, - window.handler_for(&git_graph, move |this, window, cx| { - this.schedule_git_task( - task_source_kind.clone(), - resolved_task.clone(), - window, - cx, - ); - }), - ); - } - - menu - }) - }); + let repository = self + .get_repository(cx) + .map(|repository| repository.downgrade()); + let context_menu = commit_context_menu( + CommitContextMenuData { + sha: commit.data.sha, + tag_names: commit + .data + .tag_names() + .into_iter() + .map(|tag_name| SharedString::from(tag_name.to_string())) + .collect(), + }, + CommitContextMenuSource::GitGraph, + ref_name, + self.focus_handle.clone(), + repository, + self.workspace.clone(), + window, + cx, + ); self.set_context_menu(context_menu, position, Some(index), window, cx); } @@ -4946,7 +4754,9 @@ mod tests { use git::repository::{CommitData, InitialGraphCommitData}; use gpui::{TestAppContext, UpdateGlobal}; use project::git_store::{GitStoreEvent, RepositoryEvent}; - use project::{Project, TaskSourceKind, task_store::TaskSettingsLocation}; + use project::{ + GIT_COMMAND_TASK_TAG, Project, TaskSourceKind, task_store::TaskSettingsLocation, + }; use rand::prelude::*; use serde_json::json; use settings::{SettingsStore, ThemeSettingsContent}; diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 29328c7746b..12d9a1e7392 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1,4 +1,7 @@ use crate::askpass_modal::AskPassModal; +use crate::commit_context_menu::{ + CommitContextMenuData, CommitContextMenuSource, commit_context_menu, +}; use crate::commit_modal::CommitModal; use crate::commit_tooltip::{CommitAvatar, CommitTooltip}; use crate::commit_view::CommitView; @@ -6187,6 +6190,37 @@ impl GitPanel { ); } + fn deploy_history_context_menu( + &mut self, + position: Point, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(commit) = self.commit_history_entries().get(index).cloned() else { + return; + }; + let Some(repository) = self.active_repository.as_ref() else { + return; + }; + let context_menu = commit_context_menu( + CommitContextMenuData { + sha: commit.sha, + tag_names: commit.tag_names, + }, + CommitContextMenuSource::GitPanel, + None, + self.focus_handle.clone(), + Some(repository.downgrade()), + self.workspace.clone(), + window, + cx, + ); + self.focused_history_entry = Some(index); + self.history_keyboard_nav = false; + self.set_context_menu(context_menu, position, window, cx); + } + fn activate_changes_tab( &mut self, _: &ActivateChangesTab, @@ -6347,6 +6381,7 @@ impl GitPanel { let focused_history_entry = self.focused_history_entry; let is_panel_focused = self.focus_handle.is_focused(window); let show_focus_border = self.history_keyboard_nav; + let has_context_menu = self.context_menu.is_some(); let ahead_count = active_repository .read(cx) @@ -6485,13 +6520,20 @@ impl GitPanel { tag_names .iter() .take(MAX_HISTORY_TAG_CHIPS) - .cloned() .map(|tag_name| { + let tag_name = tag_name.clone(); Chip::new(tag_name.clone()) .truncate() - .tooltip(Tooltip::text( - tag_name, - )) + .when( + !has_context_menu, + |chip| { + chip.tooltip( + Tooltip::text( + tag_name, + ), + ) + }, + ) }), ) .when(hidden_tag_count > 0, |this| { @@ -6502,9 +6544,11 @@ impl GitPanel { Chip::new(format!( "+{hidden_tag_count}" )) - .tooltip(Tooltip::text( - hidden_tag_names, - )), + .when(!has_context_menu, |chip| { + chip.tooltip(Tooltip::text( + hidden_tag_names, + )) + }), ) }) })) @@ -6541,13 +6585,15 @@ impl GitPanel { .color(Color::Muted), ), ) - .tooltip(move |_, cx| { - Tooltip::with_meta( - "View Commit", - None, - short_sha.clone(), - cx, - ) + .when(!has_context_menu, |this| { + this.tooltip(move |_, cx| { + Tooltip::with_meta( + "View Commit", + None, + short_sha.clone(), + cx, + ) + }) }) .on_mouse_down(gpui::MouseButton::Left, { let git_panel = git_panel.clone(); @@ -6561,6 +6607,22 @@ impl GitPanel { .ok(); } }) + .on_mouse_down(MouseButton::Right, { + let git_panel = git_panel.clone(); + move |event, window, cx| { + git_panel + .update(cx, |panel, cx| { + panel.deploy_history_context_menu( + event.position, + index, + window, + cx, + ); + }) + .ok(); + cx.stop_propagation(); + } + }) .on_click(move |_, window, cx| { CommitView::open( sha_for_click.clone(), diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index aefd0eff50a..39e9299bc40 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -36,6 +36,7 @@ use crate::{ mod askpass_modal; pub mod branch_diff; pub mod branch_picker; +mod commit_context_menu; mod commit_modal; pub mod commit_tooltip; pub mod commit_view; From 2838ea3f59458fc550d844e78fb4fec8eaf39fa3 Mon Sep 17 00:00:00 2001 From: Hamza Paracha <86984199+DevDonzo@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:17:17 -0400 Subject: [PATCH 07/22] Show both local and remote checkouts in Recent Projects (#53953) This fixes #53917. Recent Projects was treating local and remote workspaces as the same entry whenever they resolved to the same checkout path. That meant one could hide the other, and if one of them was already open, the picker could end up hiding both. This change keeps the deduplication and picker filtering keyed on both the workspace location and the path list, so local and remote copies of the same repo can both appear when they should. ## Test plan - `rustfmt --edition 2024 crates/recent_projects/src/recent_projects.rs crates/workspace/src/persistence.rs` - `git diff --check -- crates/recent_projects/src/recent_projects.rs crates/workspace/src/persistence.rs` Release Notes: - Fixed Recent Projects hiding either the local or remote workspace when both used the same checkout path. Co-authored-by: Mikayla Maki --- crates/recent_projects/src/recent_projects.rs | 73 +++++++++++++++++-- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 369cff1cc6e..02a05f89293 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -17,7 +17,7 @@ use fs::Fs; #[cfg(target_os = "windows")] mod wsl_picker; -use remote::RemoteConnectionOptions; +use remote::{RemoteConnectionOptions, same_remote_connection_identity}; pub use remote_connection::{RemoteConnectionModal, connect, connect_with_modal}; pub use remote_connections::{navigate_to_positions, open_remote_project}; @@ -45,8 +45,8 @@ use ui::{ }; use util::{ResultExt, paths::PathExt}; use workspace::{ - HistoryManager, ModalView, MultiWorkspace, OpenMode, OpenOptions, OpenVisible, PathList, - RecentWorkspace, SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId, + HistoryManager, ModalView, MultiWorkspace, OpenMode, OpenOptions, OpenVisible, RecentWorkspace, + SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId, notifications::DetachAndPromptErr, with_active_or_new_workspace, }; use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote}; @@ -2433,14 +2433,24 @@ impl RecentProjectsDelegate { .any(|key| key.matches(&workspace.project_group_key())) } - fn is_open_folder(&self, paths: &PathList) -> bool { + fn is_open_folder(&self, workspace: &RecentWorkspace) -> bool { if self.open_folders.is_empty() { return false; } - for workspace_path in paths.paths() { + let workspace_host = match &workspace.location { + SerializedWorkspaceLocation::Local => None, + SerializedWorkspaceLocation::Remote(options) => Some(options), + }; + + for workspace_path in workspace.paths.paths() { for open_folder in &self.open_folders { - if workspace_path == &open_folder.path { + if workspace_path == &open_folder.path + && same_remote_connection_identity( + workspace_host, + open_folder.connection_options.as_ref(), + ) + { return true; } } @@ -2456,7 +2466,7 @@ impl RecentProjectsDelegate { ) -> bool { !self.is_current_workspace(workspace.workspace_id, cx) && !self.is_in_current_window_groups(workspace) - && !self.is_open_folder(&workspace.paths) + && !self.is_open_folder(workspace) } } @@ -2467,7 +2477,7 @@ mod tests { use serde_json::json; use settings::SettingsStore; use util::path; - use workspace::{AppState, open_paths}; + use workspace::{AppState, PathList, open_paths}; use super::*; @@ -2679,6 +2689,53 @@ mod tests { ); } + #[gpui::test] + fn is_open_folder_distinguishes_local_and_remote(cx: &mut TestAppContext) { + init_test(cx); + + let shared_path = PathBuf::from("/repo"); + let local_open_folder = OpenFolderEntry { + worktree_id: WorktreeId::from_usize(0), + name: "repo".into(), + path: shared_path.clone(), + branch: None, + is_active: false, + connection_options: None, + }; + + let delegate = RecentProjectsDelegate::new( + WeakEntity::new_invalid(), + false, + cx.update(|cx| cx.focus_handle()), + vec![local_open_folder], + Vec::new(), + ProjectPickerStyle::Modal, + ); + + let paths = PathList::new(&[shared_path]); + let local_workspace = RecentWorkspace { + workspace_id: WorkspaceId::from_i64(1), + location: SerializedWorkspaceLocation::Local, + paths: paths.clone(), + identity_paths: paths.clone(), + timestamp: Utc::now(), + }; + let remote_workspace = RecentWorkspace { + workspace_id: WorkspaceId::from_i64(2), + location: SerializedWorkspaceLocation::Remote(RemoteConnectionOptions::Mock( + remote::MockConnectionOptions { id: 0 }, + )), + paths: paths.clone(), + identity_paths: paths, + timestamp: Utc::now(), + }; + + // A local open folder should hide only the matching local recent + // project, not a remote checkout that shares the same path. + assert!(delegate.is_open_folder(&local_workspace)); + assert!(!delegate.is_open_folder(&remote_workspace)); + } + #[gpui::test] fn deleting_top_recent_project_preserves_scroll_position(cx: &mut TestAppContext) { let target = FIRST_RECENT_PROJECT; From fca4016aef0413798b53f241f6b3a26254ef4742 Mon Sep 17 00:00:00 2001 From: Ollie Rutherfurd Date: Tue, 14 Jul 2026 16:36:17 -0400 Subject: [PATCH 08/22] workspace: Add ToggleEditorZoom action to maximize active pane (#53911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VS Code has a command called "Toggle Maximize Editor Group" (`Cmd+K Cmd+M`) that expands the active editor pane to fill the entire center area, hiding editor split panes, but leaving docks and panels visible. Zed's existing `ToggleZoom` hides all panels and is closer to VS Code's Zen Mode. VS Code has both — it would be useful for Zed to as well. This is particularly useful for people working on laptops, and has been raised before (#32715, #4897, #32860), but the addition of the project panel alongside the agent panel has made it more relevant. I work with the editor split vertically, but with the project and agent panels both visible, on a laptop screen there's really only room for a single editor pane. This command makes it easy to switch to and from that mode. **Behavior:** - New action: `workspace::ToggleEditorZoom` - When activated, the active editor pane expands to fill 100% of the center/editor area, hiding sibling split panes - Docks and panels remain visible and unaffected - Toggling again restores the previous split layout - If `ToggleZoom` is active, it unzooms first, then maximizes Here's a demo: https://github.com/user-attachments/assets/fcf6748e-6c45-4c4a-8d9f-f106c6cd58ea 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 is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #32715 Release Notes: - Added `workspace::ToggleEditorZoom` action to maximize the active editor pane within the center area while keeping panels visible. --- crates/debugger_ui/src/session/running.rs | 1 + crates/terminal_view/src/terminal_panel.rs | 1 + crates/workspace/src/pane_group.rs | 47 +++++++- crates/workspace/src/workspace.rs | 134 ++++++++++++++++++++- 4 files changed, 176 insertions(+), 7 deletions(-) diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index bce007f8b0a..1226055eb3f 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -115,6 +115,7 @@ impl Render for RunningState { } else if let Some(active) = active { self.panes .render( + None, None, &ActivePaneDecorator::new(active, &self.workspace), window, diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index e1f31c3614c..602667c725a 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1364,6 +1364,7 @@ impl Render for TerminalPanel { .update(cx, |workspace, cx| { registrar.size_full().child(self.center.render( workspace.zoomed_item(), + None, &workspace::PaneRenderContext { follower_states: &HashMap::default(), active_call: workspace.active_call(), diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 6b5a3cafaba..dcd0b091a3f 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -228,11 +228,14 @@ impl PaneGroup { pub fn render( &self, zoomed: Option<&AnyWeakView>, + maximized: Option<&WeakEntity>, render_cx: &dyn PaneLeaderDecorator, window: &mut Window, cx: &mut App, ) -> impl IntoElement { - self.root.render(0, zoomed, render_cx, window, cx).element + self.root + .render(0, zoomed, maximized, render_cx, window, cx) + .element } pub fn panes(&self) -> Vec<&Entity> { @@ -528,6 +531,7 @@ impl Member { &self, basis: usize, zoomed: Option<&AnyWeakView>, + maximized: Option<&WeakEntity>, render_cx: &dyn PaneLeaderDecorator, window: &mut Window, cx: &mut App, @@ -541,6 +545,15 @@ impl Member { }; } + if let Some(maximized) = maximized { + if maximized.upgrade().as_ref() != Some(pane) { + return PaneRenderResult { + element: div().into_any(), + contains_active_pane: false, + }; + } + } + let decoration = render_cx.decorate(pane, cx); let is_active = pane == render_cx.active_pane(); @@ -569,7 +582,17 @@ impl Member { contains_active_pane: is_active, } } - Member::Axis(axis) => axis.render(basis + 1, zoomed, render_cx, window, cx), + Member::Axis(axis) => axis.render(basis + 1, zoomed, maximized, render_cx, window, cx), + } + } + + pub fn contains_pane(&self, needle: &Entity) -> bool { + match self { + Member::Pane(pane) => pane == needle, + Member::Axis(axis) => axis + .members + .iter() + .any(|member| member.contains_pane(needle)), } } @@ -922,10 +945,28 @@ impl PaneAxis { &self, basis: usize, zoomed: Option<&AnyWeakView>, + maximized: Option<&WeakEntity>, render_cx: &dyn PaneLeaderDecorator, window: &mut Window, cx: &mut App, ) -> PaneRenderResult { + if let Some(maximized) = maximized { + if let Some(maximized_pane) = maximized.upgrade() { + for member in &self.members { + if member.contains_pane(&maximized_pane) { + return member.render( + basis, + zoomed, + Some(maximized), + render_cx, + window, + cx, + ); + } + } + } + } + debug_assert!(self.members.len() == self.flexes.lock().len()); let mut active_pane_ix = None; let mut contains_active_pane = false; @@ -949,7 +990,7 @@ impl PaneAxis { } } - let result = member.render((basis + ix) * 10, zoomed, render_cx, window, cx); + let result = member.render((basis + ix) * 10, zoomed, None, render_cx, window, cx); if result.contains_active_pane { contains_active_pane = true; } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 23c9a5cf557..ace71b3de14 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -339,6 +339,9 @@ actions!( ToggleRightDock, /// Toggles zoom on the active pane. ToggleZoom, + /// Toggles maximizing the active editor pane within the center area, + /// hiding other split panes but leaving docks/panels unaffected. + ToggleEditorZoom, /// Toggles read-only mode for the active item (if supported by that item). ToggleReadOnlyFile, /// Zooms in on the active pane. @@ -1370,6 +1373,7 @@ pub struct Workspace { zoomed: Option, previous_dock_drag_coordinates: Option>, zoomed_position: Option, + maximized_pane: Option>, center: PaneGroup, left_dock: Entity, bottom_dock: Entity, @@ -1821,6 +1825,7 @@ impl Workspace { weak_self: weak_handle.clone(), zoomed: None, zoomed_position: None, + maximized_pane: None, previous_dock_drag_coordinates: None, center, panes: vec![center_pane.clone()], @@ -5540,6 +5545,13 @@ impl Workspace { None }); + if let Some(maximized) = &self.maximized_pane { + let is_center_pane = self.panes.contains(&pane); + if is_center_pane && maximized.upgrade().as_ref() != Some(&pane) { + self.maximized_pane = None; + } + } + self.dismiss_zoomed_items_to_reveal(dock_to_preserve, window, cx); if pane.read(cx).is_zoomed() { self.zoomed = Some(pane.downgrade().into()); @@ -5678,6 +5690,7 @@ impl Workspace { } pane::Event::ZoomIn => { if *pane == self.active_pane { + self.maximized_pane = None; pane.update(cx, |pane, cx| pane.set_zoomed(true, cx)); if pane.read(cx).has_focus(window, cx) { self.zoomed = Some(pane.downgrade().into()); @@ -5817,6 +5830,15 @@ impl Workspace { cx: &mut Context, ) { if self.center.remove(&pane, cx).unwrap() { + if self + .maximized_pane + .as_ref() + .and_then(|weak| weak.upgrade()) + .as_ref() + == Some(&pane) + { + self.maximized_pane = None; + } self.force_remove_pane(&pane, &focus_on, window, cx); self.unfollow_in_pane(&pane, window, cx); self.last_leaders_by_pane.remove(&pane.downgrade()); @@ -7731,6 +7753,7 @@ impl Workspace { }, )) .on_action(cx.listener(Workspace::toggle_centered_layout)) + .on_action(cx.listener(Workspace::toggle_editor_zoom)) .on_action(cx.listener( |workspace: &mut Workspace, action: &pane::ActivateNextItem, window, cx| { if let Some(active_dock) = workspace.active_dock(window, cx) { @@ -7995,6 +8018,37 @@ impl Workspace { }); } + pub fn toggle_editor_zoom( + &mut self, + _: &ToggleEditorZoom, + window: &mut Window, + cx: &mut Context, + ) { + if self.zoomed.is_some() { + self.active_pane.update(cx, |pane, cx| { + pane.set_zoomed(false, cx); + }); + self.zoomed = None; + self.zoomed_position = None; + cx.emit(Event::ZoomChanged); + } + + if let Some(maximized) = self.maximized_pane.take() { + if maximized.upgrade().as_ref() == Some(&self.active_pane) { + cx.notify(); + return; + } + } + + self.maximized_pane = Some(self.active_pane.downgrade()); + window.focus(&self.active_pane.focus_handle(cx), cx); + cx.notify(); + } + + pub fn is_pane_maximized(&self) -> bool { + self.maximized_pane.is_some() + } + fn adjust_padding(padding: Option) -> f32 { padding .unwrap_or(CenteredPaddingSettings::default().0) @@ -8255,10 +8309,13 @@ impl Workspace { this.track_focus(&self.region_focus_handles.editor) }) .size_full() - .child( - self.center - .render(self.zoomed.as_ref(), render_cx, window, cx), - ) + .child(self.center.render( + self.zoomed.as_ref(), + self.maximized_pane.as_ref(), + render_cx, + window, + cx, + )) } pub fn for_window(window: &Window, cx: &App) -> Option> { @@ -13571,6 +13628,75 @@ mod tests { }); } + #[gpui::test] + async fn test_toggle_editor_zoom(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let pane_a = workspace.update_in(cx, |workspace, _window, _cx| { + workspace.active_pane().clone() + }); + + workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(TestItem::new); + workspace.add_item(pane_a.clone(), Box::new(item), None, true, true, window, cx); + }); + + let pane_b = split_pane(cx, &workspace); + workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(TestItem::new); + workspace.add_item(pane_b.clone(), Box::new(item), None, true, true, window, cx); + }); + + // Toggle editor zoom maximizes the active pane + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_editor_zoom(&ToggleEditorZoom, window, cx); + assert!(workspace.is_pane_maximized()); + assert_eq!( + workspace.maximized_pane.as_ref().and_then(|w| w.upgrade()), + Some(pane_b.clone()), + ); + }); + + // Toggle again un-maximizes + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_editor_zoom(&ToggleEditorZoom, window, cx); + assert!(!workspace.is_pane_maximized()); + }); + + // Toggle editor zoom while zoomed: should unzoom then maximize + pane_b.update_in(cx, |pane, window, cx| { + pane.zoom_in(&ZoomIn, window, cx); + }); + workspace.update_in(cx, |_workspace, _window, cx| { + assert!(pane_b.read(cx).is_zoomed()); + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_editor_zoom(&ToggleEditorZoom, window, cx); + assert!(!pane_b.read(cx).is_zoomed()); + assert!(workspace.zoomed.is_none()); + assert!(workspace.is_pane_maximized()); + }); + + // Un-maximize for next test + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_editor_zoom(&ToggleEditorZoom, window, cx); + }); + + // Maximized pane cleared when it is removed + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_editor_zoom(&ToggleEditorZoom, window, cx); + assert!(workspace.is_pane_maximized()); + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.remove_pane(pane_b.clone(), None, window, cx); + assert!(!workspace.is_pane_maximized()); + }); + } + #[gpui::test] async fn test_toggle_all_docks(cx: &mut gpui::TestAppContext) { init_test(cx); From e3dd7b462ed86c91669f8329d0fa7808d94a3342 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Tue, 14 Jul 2026 17:06:27 -0400 Subject: [PATCH 09/22] Focus on the history tabs context menu when deploying (#61000) Release Notes: - N/A --- crates/git_ui/src/git_panel.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 12d9a1e7392..8a5f1202803 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -7192,9 +7192,11 @@ impl GitPanel { &mut self, context_menu: Entity, position: Point, - window: &Window, + window: &mut Window, cx: &mut Context, ) { + window.focus(&context_menu.focus_handle(cx), cx); + let subscription = cx.subscribe_in( &context_menu, window, From 6b9f448ffc1d0807c57dfc94e76b1b4c4a319e7a Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 14 Jul 2026 17:47:47 -0400 Subject: [PATCH 10/22] crashes: Capture glibc's abort diagnostic in crash reports (#60624) Linux crash reports for runtime-initiated aborts currently carry no information about why the runtime aborted. A sizable family of Sentry issues (FR-108 / [ZED-9SC](https://zed-dev.sentry.io/issues/7581925141/) among them) consists of glibc's malloc integrity checks detecting heap corruption and aborting from inside `malloc`/`realloc`/`free`, so each event lands on a random victim stack and the underlying problem stays invisible and ungroupable. glibc records the diagnostic it prints before aborting ("free(): invalid pointer", "double free or corruption", assertion failures, stack-smashing reports) in the private `__abort_msg` global specifically so it can be recovered post-mortem. This resolves that symbol's address at startup and sends it to the crash-handler process; when a crash comes in, the handler reads the message out of the crashed process's memory with `process_vm_readv` while the client is still parked in its signal handler awaiting the dump acknowledgement. The message is then attached to crash uploads as a searchable `abort_message` tag plus a full-text context. The crashed process does no work itself, which matters because in exactly these crashes the crashed thread may hold the allocator's arena lock, so it cannot safely allocate or format anything. Everything degrades gracefully to the current behavior when the symbol is unavailable (musl, future glibc changes). With the tag in place, Sentry fingerprint rules can fold these corruption aborts into a single tracked issue instead of minting a new single-event issue per victim stack, and the message text distinguishes double-frees from out-of-bounds metadata corruption when hunting the actual culprit. Release Notes: - N/A --- Cargo.lock | 1 + crates/crashes/Cargo.toml | 3 + crates/crashes/src/crashes.rs | 241 ++++++++++++++++++++++++++++++++++ crates/zed/src/reliability.rs | 15 +++ 4 files changed, 260 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a44cdb5271c..e6ef165bba8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4347,6 +4347,7 @@ version = "0.1.0" dependencies = [ "async-process", "crash-handler", + "libc", "log", "mach2 0.5.0", "minidumper", diff --git a/crates/crashes/Cargo.toml b/crates/crashes/Cargo.toml index f8b898112c1..1b118097cb4 100644 --- a/crates/crashes/Cargo.toml +++ b/crates/crashes/Cargo.toml @@ -16,6 +16,9 @@ serde_json.workspace = true system_specs.workspace = true zstd.workspace = true +[target.'cfg(target_os = "linux")'.dependencies] +libc.workspace = true + [target.'cfg(target_os = "macos")'.dependencies] mach2.workspace = true diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index 28217496cad..5d3fc954d30 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -138,6 +138,17 @@ where info!("crash signal handlers installed"); send_crash_server_message(&client, CrashServerMessage::Init(crash_init)); + #[cfg(all(target_os = "linux", target_env = "gnu"))] + if let Some(address) = abort_message_address() { + send_crash_server_message( + &client, + CrashServerMessage::AbortMessageLocation(AbortMessageLocation { + pid: process::id(), + address, + }), + ); + } + #[cfg(target_os = "linux")] handler.set_ptracer(Some(_crash_handler.id())); @@ -170,6 +181,7 @@ pub struct CrashServer { panic_info: Mutex>, active_gpu: Mutex>, user_info: Mutex>, + abort_message_location: Mutex>, has_connection: Arc, logs_dir: PathBuf, } @@ -179,11 +191,27 @@ pub struct CrashInfo { pub init: InitCrashHandler, pub panic: Option, pub minidump_error: Option, + /// The diagnostic the C runtime recorded before aborting the process, e.g. + /// glibc's "free(): invalid pointer". Only present when the crash was a + /// runtime-initiated abort rather than a signal like SIGSEGV or a panic. + #[serde(default)] + pub abort_message: Option, pub gpus: Vec, pub active_gpu: Option, pub user_info: Option, } +/// Where to find the C runtime's abort diagnostic in the crashed process's +/// memory. Sent by the client at startup so that after a crash the server can +/// recover the message with `process_vm_readv`; the crashed process itself +/// can't safely do this work, since its heap may be corrupt and its allocator +/// locks may be held by the crashed thread. +#[derive(Debug, Deserialize, Serialize, Clone, Copy)] +pub struct AbortMessageLocation { + pub pid: u32, + pub address: u64, +} + #[derive(Debug, Deserialize, Serialize, Clone)] pub struct InitCrashHandler { pub session_id: String, @@ -233,6 +261,110 @@ enum CrashServerMessage { Panic(CrashPanic), GPUInfo(GpuSpecs), UserInfo(UserInfo), + AbortMessageLocation(AbortMessageLocation), +} + +/// glibc records the diagnostic it prints just before aborting (malloc integrity +/// failures like "free(): invalid pointer", assertion failures, stack-smashing +/// reports) in the private global `__abort_msg`, specifically so it can be +/// recovered post-mortem. Resolve its address here, in a safe context at startup. +/// The symbol is only exported at the GLIBC_PRIVATE version, which plain `dlsym` +/// won't resolve, and it has no stability guarantee, so a null result (e.g. musl, +/// or a future glibc removing it) just disables this diagnostic. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +fn abort_message_address() -> Option { + let ptr = unsafe { + libc::dlvsym( + libc::RTLD_DEFAULT, + c"__abort_msg".as_ptr(), + c"GLIBC_PRIVATE".as_ptr(), + ) + }; + std::ptr::NonNull::new(ptr).map(|ptr| ptr.as_ptr() as u64) +} + +/// Read the crashed process's abort diagnostic. `__abort_msg` points to a +/// `struct abort_msg_s { unsigned int size; char msg[]; }` that glibc allocates +/// with mmap so that it stays intact even when the heap is corrupt. `size` is +/// the total byte size of that mapping (header included, rounded up to whole +/// pages), not the message length; the message itself is NUL-terminated. +#[cfg(target_os = "linux")] +fn read_abort_message(location: AbortMessageLocation) -> Option { + let pointer_bytes = read_process_memory(location.pid, location.address, size_of::())?; + let message_address = usize::from_ne_bytes(pointer_bytes.try_into().ok()?) as u64; + if message_address == 0 { + return None; + } + let size_bytes = read_process_memory(location.pid, message_address, size_of::())?; + let size = u32::from_ne_bytes(size_bytes.try_into().ok()?); + let message_bytes = read_process_memory( + location.pid, + message_address + size_of::() as u64, + abort_message_read_len(size)?, + )?; + parse_abort_message(&message_bytes) +} + +/// How many message bytes to read given the `size` field of glibc's +/// `abort_msg_s`. `size` holds the total size of the mmap'd allocation, so a +/// value that isn't a whole number of pages means the layout has changed and +/// we shouldn't trust it. Reading is capped at (one page minus the header), +/// which both bounds the work and ensures the read never extends past the end +/// of the mapping. +#[cfg(any(target_os = "linux", test))] +fn abort_message_read_len(size: u32) -> Option { + // Every Linux page size (4 KiB, 16 KiB, 64 KiB, ...) is a multiple of 4 KiB. + const PAGE_MULTIPLE: usize = 4096; + const MAX_READ: usize = 4096; + + let size = size as usize; + if size == 0 || !size.is_multiple_of(PAGE_MULTIPLE) { + log::warn!("__abort_msg size field {size} is not page-rounded; layout may have changed"); + return None; + } + Some(size.min(MAX_READ) - size_of::()) +} + +/// The message is NUL-terminated inside a zero-filled mapping, so truncate at +/// the first NUL; `trim` alone would keep the padding, since NUL is not +/// whitespace. +#[cfg(any(target_os = "linux", test))] +fn parse_abort_message(bytes: &[u8]) -> Option { + let len = bytes + .iter() + .position(|&byte| byte == 0) + .unwrap_or(bytes.len()); + let message = String::from_utf8_lossy(&bytes[..len]).trim().to_string(); + (!message.is_empty()).then_some(message) +} + +#[cfg(target_os = "linux")] +fn read_process_memory(pid: u32, address: u64, len: usize) -> Option> { + let mut buffer = vec![0u8; len]; + let local = libc::iovec { + iov_base: buffer.as_mut_ptr().cast(), + iov_len: len, + }; + let remote = libc::iovec { + iov_base: address as *mut libc::c_void, + iov_len: len, + }; + let bytes_read = + unsafe { libc::process_vm_readv(pid as libc::pid_t, &local, 1, &remote, 1, 0) }; + if bytes_read < 0 { + log::warn!( + "process_vm_readv of {len} bytes at {address:#x} in pid {pid} failed: {}", + io::Error::last_os_error() + ); + return None; + } + if bytes_read as usize != len { + log::warn!( + "process_vm_readv short read at {address:#x} in pid {pid}: {bytes_read} of {len} bytes" + ); + return None; + } + Some(buffer) } impl minidumper::ServerHandler for CrashServer { @@ -281,6 +413,14 @@ impl minidumper::ServerHandler for CrashServer { } }; + // The crashed process is still alive at this point: it stays parked in + // its signal handler until the server acknowledges the dump request, + // which happens after this callback returns. + #[cfg(target_os = "linux")] + let abort_message = (*self.abort_message_location.lock()).and_then(read_abort_message); + #[cfg(not(target_os = "linux"))] + let abort_message = None; + let crash_info = CrashInfo { init: self .initialization_params @@ -289,6 +429,7 @@ impl minidumper::ServerHandler for CrashServer { .expect("not initialized"), panic: self.panic_info.lock().clone(), minidump_error, + abort_message, active_gpu: self.active_gpu.lock().clone(), gpus, user_info: self.user_info.lock().clone(), @@ -320,6 +461,9 @@ impl minidumper::ServerHandler for CrashServer { CrashServerMessage::UserInfo(user_info) => { self.user_info.lock().replace(user_info); } + CrashServerMessage::AbortMessageLocation(location) => { + self.abort_message_location.lock().replace(location); + } } } @@ -523,6 +667,7 @@ pub fn crash_server(socket: &Path, logs_dir: PathBuf) { initialization_params: Mutex::default(), panic_info: Mutex::default(), user_info: Mutex::default(), + abort_message_location: Mutex::default(), has_connection, active_gpu: Mutex::default(), logs_dir, @@ -532,3 +677,99 @@ pub fn crash_server(socket: &Path, logs_dir: PathBuf) { ) .expect("failed to run server"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn abort_message_read_len_requires_page_rounded_total() { + assert_eq!(abort_message_read_len(0), None); + // A message length rather than a mapping total means the glibc layout + // has changed out from under us. + assert_eq!(abort_message_read_len(23), None); + assert_eq!(abort_message_read_len(4097), None); + // The read must stay within the mapping: one page minus the header. + assert_eq!(abort_message_read_len(4096), Some(4092)); + // Larger totals (long messages, larger page sizes) are clamped. + assert_eq!(abort_message_read_len(8192), Some(4092)); + assert_eq!(abort_message_read_len(65536), Some(4092)); + } + + #[test] + fn parse_abort_message_truncates_at_nul() { + let mut buffer = b"free(): invalid pointer\n\0".to_vec(); + buffer.resize(4092, 0); + assert_eq!( + parse_abort_message(&buffer), + Some("free(): invalid pointer".to_string()) + ); + } + + #[test] + fn parse_abort_message_handles_missing_nul() { + assert_eq!( + parse_abort_message(b"double free or corruption (out)"), + Some("double free or corruption (out)".to_string()) + ); + } + + #[test] + fn parse_abort_message_rejects_empty() { + assert_eq!(parse_abort_message(&[]), None); + assert_eq!(parse_abort_message(&[0; 16]), None); + assert_eq!(parse_abort_message(b"\n \0garbage after nul"), None); + } + + /// End-to-end check of `read_abort_message` against a synthetic + /// `abort_msg_s` in this very process (`process_vm_readv` may always read + /// one's own memory). The message page is followed by a `PROT_NONE` guard + /// page so the test fails if the read ever extends past the mapping glibc + /// would have allocated. + #[cfg(target_os = "linux")] + #[test] + fn read_abort_message_reads_glibc_layout_from_a_live_process() { + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as usize; + unsafe { + let mapping = libc::mmap( + std::ptr::null_mut(), + 2 * page_size, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_ANON | libc::MAP_PRIVATE, + -1, + 0, + ); + assert_ne!(mapping, libc::MAP_FAILED); + assert_eq!( + libc::mprotect( + mapping.cast::().add(page_size).cast(), + page_size, + libc::PROT_NONE + ), + 0 + ); + + mapping.cast::().write(page_size as u32); + let message = b"free(): invalid pointer\n\0"; + std::ptr::copy_nonoverlapping( + message.as_ptr(), + mapping.cast::().add(size_of::()), + message.len(), + ); + + // Stands in for the `__abort_msg` global: a pointer variable whose + // address we hand to the reader. + let abort_msg: *mut libc::c_void = mapping; + let location = AbortMessageLocation { + pid: process::id(), + address: (&raw const abort_msg) as u64, + }; + assert_eq!( + read_abort_message(location), + Some("free(): invalid pointer".to_string()) + ); + + libc::munmap(mapping, 2 * page_size); + } + } +} diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index b4a06b2629d..00917069d20 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -220,6 +220,21 @@ async fn upload_minidump( if let Some(minidump_error) = metadata.minidump_error.clone() { form = form.text("minidump_error", minidump_error); } + if let Some(abort_message) = metadata.abort_message.as_ref() { + // Sentry tag values are limited to 200 characters on a single line, so + // put a searchable prefix in the tag (which grouping rules also match + // on) and the full message in a context. + let tag: String = abort_message + .lines() + .next() + .unwrap_or_default() + .chars() + .take(200) + .collect(); + form = form + .text("sentry[tags][abort_message]", tag) + .text("sentry[contexts][abort][message]", abort_message.clone()); + } if let Some(is_staff) = &metadata .user_info From 4a3e0af532e4ad89baf634f4b94938b98beaa292 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Tue, 14 Jul 2026 20:54:27 -0500 Subject: [PATCH 11/22] Add a bespoke LSP request path for edit prediction context (#60947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes EP-193 Edit prediction context collection previously reused the editor goto-definition / goto-type-definition path, which resolves every LSP result into a `LocationLink` — opening a buffer per target (worktree creation, buffer registration, anchor conversion) before edit prediction gets a chance to filter. On remote projects the host additionally created a peer-visible buffer per link and the client waited on each one. This adds a bespoke request path that returns raw results first and only does the expensive work for results that survive filtering: - New `EditPredictionDefinition { path: ProjectPath, range: Range> }` boundary type. LSP wire types never leave `lsp_command.rs`; the workspace-only filter and URI→`ProjectPath` resolution happen while normalizing the LSP response, before any buffer exists, so the type encodes the workspace-only invariant. - New `GetEditPredictionDefinitions` / `GetEditPredictionTypeDefinitions` LSP commands and proto messages. Responses carry only path + UTF-16 range — the host never calls `create_buffer_for_peer` and the client never waits on remote buffers. - One public API: `Project::edit_prediction_definitions(buffer, position, include_type_definitions, cx)` fires both LSP requests concurrently and returns a merged, deduped list ("not capable" = empty). The definition/type-definition split was never used downstream, so `CacheEntry` now holds a single list and the fetch pipeline runs one task per identifier. - `edit_prediction_context` dedupes raw results before opening buffers; survivors open via `project.open_buffer(ProjectPath)` (skipping the invisible-worktree/yarn machinery of `open_local_buffer_via_lsp`, and working identically on remote projects), then clip → anchor → `MAX_TARGET_LEN`. - Removes the `workspace_only` flag from `GetDefinitions` / `GetTypeDefinitions`: edit prediction was its only user. The editor path always sent `false`, which proto3 doesn't encode, so normal goto-definition requests are wire-identical; old peers still sending `true` get unfiltered results (graceful degradation). The proto field numbers are `reserved`. Tested with a local test proving filtering precedes buffer opening (an out-of-workspace target never spawns a worktree, an oversized target is excluded from related files) and a collab test proving the remote round-trip returns correct paths/ranges without opening buffers on the client. Release Notes: - N/A --- .../tests/integration/integration_tests.rs | 103 +++++ .../src/edit_prediction_context.rs | 149 +++---- .../src/edit_prediction_context_tests.rs | 89 +++- .../src/fake_definition_lsp.rs | 34 ++ crates/project/src/lsp_command.rs | 412 +++++++++++++++--- crates/project/src/lsp_store.rs | 203 ++++++--- .../project/src/lsp_store/lsp_ext_command.rs | 1 - crates/project/src/project.rs | 34 +- crates/proto/proto/lsp.proto | 38 +- crates/proto/proto/zed.proto | 6 +- crates/proto/src/proto.rs | 30 ++ crates/rpc/src/proto_client.rs | 6 + 12 files changed, 847 insertions(+), 258 deletions(-) diff --git a/crates/collab/tests/integration/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index 38cac226bad..403e36487bf 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -5139,6 +5139,109 @@ async fn test_definition( }); } +#[gpui::test] +async fn test_edit_prediction_definition( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + let capabilities = lsp::ServerCapabilities { + definition_provider: Some(OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + capabilities, + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree( + path!("/root"), + json!({ + "a.rs": "const ONE: usize = TWO;", + "b.rs": "const TWO: usize = 2;", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + let (buffer_b, _handle) = project_b + .update(cx_b, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("a.rs")), cx) + }) + .await + .unwrap(); + + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.set_request_handler::( + |_, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Scalar( + lsp::Location::new( + lsp::Uri::from_file_path(path!("/root/b.rs")).unwrap(), + lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), + ), + ))) + }, + ); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let definitions = project_b + .update(cx_b, |project, cx| { + project.edit_prediction_definitions(&buffer_b, 19, false, cx) + }) + .await + .unwrap(); + + cx_b.read(|cx| { + assert_eq!(definitions.len(), 1); + assert_eq!( + definitions[0].path, + ProjectPath { + worktree_id, + path: rel_path("b.rs").into(), + } + ); + assert_eq!( + definitions[0].range.start.0, + language::PointUtf16::new(0, 6) + ); + assert_eq!(definitions[0].range.end.0, language::PointUtf16::new(0, 9)); + assert!( + project_b + .read(cx) + .get_open_buffer(&definitions[0].path, cx) + .is_none() + ); + }); +} + #[gpui::test(iterations = 10)] async fn test_references( executor: BackgroundExecutor, diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index 3069c13b65d..b5ab87a1738 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -5,8 +5,8 @@ use futures::{FutureExt, StreamExt as _, channel::mpsc, future}; use gpui::{ App, AppContext, AsyncApp, Context, Entity, EntityId, EventEmitter, Task, TaskExt, WeakEntity, }; -use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset as _}; -use project::{LocationLink, Project, ProjectPath}; +use language::{Anchor, Bias, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset as _}; +use project::{EditPredictionDefinition, Project, ProjectPath}; use smallvec::SmallVec; use std::{ collections::hash_map, @@ -77,20 +77,15 @@ struct Identifier { enum DefinitionTask { CacheHit(Arc), - CacheMiss( - Task< - Option<( - Task>>>, - Task>>>, - )>, - >, - ), + CacheMiss { + project: WeakEntity, + task: Task>>, + }, } #[derive(Debug)] struct CacheEntry { definitions: SmallVec<[CachedDefinition; 1]>, - type_definitions: SmallVec<[CachedDefinition; 1]>, } #[derive(Clone, Debug)] @@ -312,34 +307,21 @@ impl RelatedExcerptStore { DefinitionTask::CacheHit(entry.clone()) } else { let project = this.project.clone(); - let buffer = buffer.downgrade(); - DefinitionTask::CacheMiss(cx.spawn(async move |_, cx| { - let buffer = buffer.upgrade()?; - let definitions = project - .update(cx, |project, cx| { - project.workspace_definitions( - &buffer, - identifier.range.start, - cx, - ) - }) - .ok()?; - let type_definitions = project - .update(cx, |project, cx| { - // tombi LSP for toml will open a scratch buffer with the JSON schema of - // the toml file when a goto type definition is requested - if is_tombi_lsp_in_toml(project, &buffer, cx) { - return Task::ready(Ok(None)); - } - project.workspace_type_definitions( - &buffer, - identifier.range.start, - cx, - ) - }) - .ok()?; - Some((definitions, type_definitions)) - })) + let task = project + .update(cx, |project, cx| { + // tombi LSP for toml will open a scratch buffer with the JSON schema of + // the toml file when a goto type definition is requested + let include_type_definitions = + !is_tombi_lsp_in_toml(project, &buffer, cx); + project.edit_prediction_definitions( + &buffer, + identifier.range.start, + include_type_definitions, + cx, + ) + }) + .unwrap_or_else(|_| Task::ready(Ok(Vec::new()))); + DefinitionTask::CacheMiss { project, task } }; let cx = async_cx.clone(); @@ -348,50 +330,29 @@ impl RelatedExcerptStore { DefinitionTask::CacheHit(cache_entry) => { Some((identifier, cache_entry, None)) } - DefinitionTask::CacheMiss(task) => { - let (definitions, type_definitions) = task.await?; - let (definition_locations, type_definition_locations) = - futures::join!(definitions, type_definitions); + DefinitionTask::CacheMiss { project, task } => { + let definition_locations = task.await.log_err().unwrap_or_default(); let duration = start_time.elapsed(); - let definition_locations = - definition_locations.log_err().flatten().unwrap_or_default(); - let type_definition_locations = type_definition_locations - .log_err() - .flatten() - .unwrap_or_default(); - let definitions: SmallVec<[CachedDefinition; 1]> = - definition_locations - .into_iter() - .filter_map(|location| { + future::join_all(definition_locations.into_iter().map( + |definition| { + let project = project.clone(); let mut cx = cx.clone(); - process_definition(location, &mut cx) - }) - .collect(); - - let type_definitions: SmallVec<[CachedDefinition; 1]> = - type_definition_locations - .into_iter() - .filter_map(|location| { - let mut cx = cx.clone(); - process_definition(location, &mut cx) - }) - .filter(|type_def| { - !definitions.iter().any(|def| { - def.buffer.entity_id() - == type_def.buffer.entity_id() - && def.anchor_range == type_def.anchor_range - }) - }) - .collect(); + async move { + process_definition(definition, &project, &mut cx) + .await + } + }, + )) + .await + .into_iter() + .flatten() + .collect(); Some(( identifier, - Arc::new(CacheEntry { - definitions, - type_definitions, - }), + Arc::new(CacheEntry { definitions }), Some(duration), )) } @@ -469,11 +430,7 @@ async fn rebuild_related_files( let mut snapshots = HashMap::default(); let mut worktree_root_names = HashMap::default(); for entry in new_entries.values() { - for definition in entry - .definitions - .iter() - .chain(entry.type_definitions.iter()) - { + for definition in entry.definitions.iter() { if let hash_map::Entry::Vacant(e) = snapshots.entry(definition.buffer.entity_id()) { definition .buffer @@ -510,11 +467,7 @@ async fn rebuild_related_files( .get(identifier) .copied() .unwrap_or(usize::MAX); - for definition in entry - .definitions - .iter() - .chain(entry.type_definitions.iter()) - { + for definition in entry.definitions.iter() { let Some(snapshot) = snapshots.get(&definition.buffer.entity_id()) else { continue; }; @@ -635,16 +588,24 @@ use language::ToPoint as _; const MAX_TARGET_LEN: usize = 128; -fn process_definition(location: LocationLink, cx: &mut AsyncApp) -> Option { +async fn process_definition( + definition: EditPredictionDefinition, + project: &WeakEntity, + cx: &mut AsyncApp, +) -> Option { + let EditPredictionDefinition { path, range } = definition; + let buffer = project + .update(cx, |project, cx| project.open_buffer(path.clone(), cx)) + .ok()? + .await + .log_err()?; + cx.update(|cx| { - let buffer = location.target.buffer; let buffer_snapshot = buffer.read(cx); - let file = buffer_snapshot.file()?; - let path = ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path().clone(), - }; - let anchor_range = location.target.range; + let target_start = buffer_snapshot.clip_point_utf16(range.start, Bias::Left); + let target_end = buffer_snapshot.clip_point_utf16(range.end, Bias::Left); + let anchor_range = + buffer_snapshot.anchor_after(target_start)..buffer_snapshot.anchor_before(target_end); // If the target range is large, it likely means we requested the definition of an entire module. // For individual definitions, the target range should be small as it only covers the symbol. diff --git a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs index 5b8e9c6aecb..7c98f7a9926 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs @@ -5,10 +5,11 @@ use gpui::TestAppContext; use indoc::indoc; use language::{Point, ToPoint as _, rust_lang}; use lsp::FakeLanguageServer; -use project::{FakeFs, LocationLink, Project}; +use project::{FakeFs, LocationLink, Project, ProjectPath}; use serde_json::json; use settings::SettingsStore; use std::fmt::Write as _; +use util::rel_path::rel_path; use util::{path, test::marked_text_ranges}; #[gpui::test] @@ -561,9 +562,10 @@ async fn test_type_definition_deduplication(cx: &mut TestAppContext) { // In this project the only identifier near the cursor whose type definition // resolves is `TypeA`, and its GotoTypeDefinition returns the exact same - // location as GotoDefinition. After deduplication the CacheEntry for `TypeA` - // should have an empty `type_definitions` vec, meaning the type-definition - // path contributes nothing extra to the related-file output. + // location as GotoDefinition. After the definitions and type definitions are + // merged and deduped, the type-definition location is dropped, so it + // contributes nothing extra to the related-file output (the target location + // appears only once). fs.insert_tree( path!("/root"), json!({ @@ -638,6 +640,85 @@ async fn test_type_definition_deduplication(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_edit_prediction_filters_raw_definitions_before_opening_buffers( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "src": { + "main.rs": indoc! {" + // fake-definition-lsp-extra target /root/src/large.rs 0 0 0 129 + // fake-definition-lsp-extra target /root/src/valid.rs 0 3 0 9 + // fake-definition-lsp-extra target /outside.rs 0 3 0 10 + fn main() { + target(); + } + "}, + "valid.rs": "fn target() {}\n", + "large.rs": format!("{}\n", "a".repeat(MAX_TARGET_LEN + 16)), + }, + }), + ) + .await; + fs.insert_file(path!("/outside.rs"), "fn outside() {}\n".into()) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let mut servers = setup_fake_lsp(&project, cx); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + let _fake_language_server = servers.next().await.unwrap(); + cx.run_until_parked(); + + let related_excerpt_store = cx.new(|cx| RelatedExcerptStore::new(&project, cx)); + related_excerpt_store.update(cx, |store, cx| { + let position = { + let buffer = buffer.read(cx); + let offset = buffer + .text() + .find("target();") + .expect("target call not found"); + buffer.anchor_before(offset) + }; + + store.set_identifier_line_count(0); + store.refresh(buffer.clone(), position, cx); + }); + + cx.executor().advance_clock(DEBOUNCE_DURATION); + related_excerpt_store.update(cx, |store, cx| { + assert_related_files( + &store.related_files(cx), + &[ + ("root/src/valid.rs", &["fn target() {}"]), + ("root/src/main.rs", &["fn main() {\n target();\n}"]), + ], + ); + }); + + let worktree_id = buffer.read_with(cx, |buffer, cx| { + buffer.file().expect("buffer has file").worktree_id(cx) + }); + let valid_path = ProjectPath { + worktree_id, + path: rel_path("src/valid.rs").into(), + }; + project.read_with(cx, |project, cx| { + assert!(project.get_open_buffer(&valid_path, cx).is_some()); + assert_eq!(project.worktrees(cx).count(), 1); + }); +} + #[gpui::test] async fn test_definitions_ranked_by_cursor_proximity(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/edit_prediction_context/src/fake_definition_lsp.rs b/crates/edit_prediction_context/src/fake_definition_lsp.rs index 5b9e528b63f..d844597ef50 100644 --- a/crates/edit_prediction_context/src/fake_definition_lsp.rs +++ b/crates/edit_prediction_context/src/fake_definition_lsp.rs @@ -243,6 +243,12 @@ impl DefinitionIndex { .or_insert_with(Vec::new) .push(location); } + for (name, location) in extract_extra_definition_locations(content) { + self.definitions + .entry(name) + .or_insert_with(Vec::new) + .push(location); + } let type_annotations = extract_type_annotations(content) .into_iter() @@ -303,6 +309,34 @@ impl DefinitionIndex { } } +fn extract_extra_definition_locations(content: &str) -> Vec<(String, lsp::Location)> { + content + .lines() + .filter_map(|line| { + let mut parts = line + .trim() + .strip_prefix("// fake-definition-lsp-extra ")? + .split_whitespace(); + let name = parts.next()?.to_string(); + let path = PathBuf::from(parts.next()?); + let start_row = parts.next()?.parse().ok()?; + let start_column = parts.next()?.parse().ok()?; + let end_row = parts.next()?.parse().ok()?; + let end_column = parts.next()?.parse().ok()?; + Some(( + name, + lsp::Location::new( + Uri::from_file_path(path).ok()?, + lsp::Range::new( + lsp::Position::new(start_row, start_column), + lsp::Position::new(end_row, end_column), + ), + ), + )) + }) + .collect() +} + /// Extracts `identifier_name -> type_name` mappings from field declarations /// and function parameters. For example, `owner: Arc` produces /// `"owner" -> "Person"` by unwrapping common generic wrappers. diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 2f05aba49b5..bdc41891587 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -4,7 +4,7 @@ use crate::{ CodeAction, CompletionSource, CoreCompletion, CoreCompletionResponse, DocumentColor, DocumentHighlight, DocumentSymbol, Hover, HoverBlock, HoverBlockKind, InlayHint, InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location, - LocationLink, LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse, + LocationLink, LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse, ProjectPath, ProjectTransaction, PulledDiagnostics, ResolveState, lsp_store::{LocalLspStore, LspDocumentLink, LspFoldingRange, LspStore}, }; @@ -39,6 +39,7 @@ use std::{ cmp::Reverse, collections::hash_map, mem, ops::Range, path::Path, str::FromStr, sync::Arc, }; use text::{BufferId, LineEnding}; +use util::rel_path::RelPath; use util::{ResultExt as _, debug_panic}; pub use signature_help::SignatureHelp; @@ -189,7 +190,17 @@ pub(crate) struct PerformRename { #[derive(Debug, Clone, Copy)] pub struct GetDefinitions { pub position: PointUtf16, - pub workspace_only: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct EditPredictionDefinition { + pub path: ProjectPath, + pub range: Range>, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct GetEditPredictionDefinitions { + pub position: PointUtf16, } #[derive(Debug, Clone, Copy)] @@ -200,7 +211,11 @@ pub(crate) struct GetDeclarations { #[derive(Debug, Clone, Copy)] pub(crate) struct GetTypeDefinitions { pub position: PointUtf16, - pub workspace_only: bool, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct GetEditPredictionTypeDefinitions { + pub position: PointUtf16, } #[derive(Debug, Clone, Copy)] @@ -694,15 +709,7 @@ impl LspCommand for GetDefinitions { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp( - message, - lsp_store, - buffer, - server_id, - self.workspace_only, - cx, - ) - .await + location_links_from_lsp(message, lsp_store, buffer, server_id, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetDefinition { @@ -713,7 +720,6 @@ impl LspCommand for GetDefinitions { &buffer.anchor_before(self.position), )), version: serialize_version(&buffer.version()), - workspace_only: self.workspace_only, } } @@ -734,7 +740,6 @@ impl LspCommand for GetDefinitions { .await?; Ok(Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), - workspace_only: message.workspace_only, }) } @@ -764,6 +769,106 @@ impl LspCommand for GetDefinitions { } } +#[async_trait(?Send)] +impl LspCommand for GetEditPredictionDefinitions { + type Response = Vec; + type LspRequest = lsp::request::GotoDefinition; + type ProtoRequest = proto::GetEditPredictionDefinition; + + fn display_name(&self) -> &str { + "Get edit prediction definition" + } + + fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + capabilities + .server_capabilities + .definition_provider + .is_some_and(|capability| match capability { + OneOf::Left(supported) => supported, + OneOf::Right(_options) => true, + }) + } + + fn to_lsp( + &self, + path: &Path, + _: &Buffer, + _: &Arc, + _: &App, + ) -> Result { + Ok(lsp::GotoDefinitionParams { + text_document_position_params: make_lsp_text_document_position(path, self.position)?, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }) + } + + async fn response_from_lsp( + self, + message: Option, + lsp_store: Entity, + _: Entity, + _: LanguageServerId, + cx: AsyncApp, + ) -> Result> { + edit_prediction_definitions_from_lsp(message, lsp_store, cx) + } + + fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetEditPredictionDefinition { + proto::GetEditPredictionDefinition { + project_id, + buffer_id: buffer.remote_id().into(), + position: Some(language::proto::serialize_anchor( + &buffer.anchor_before(self.position), + )), + version: serialize_version(&buffer.version()), + } + } + + async fn from_proto( + message: proto::GetEditPredictionDefinition, + _: Entity, + buffer: Entity, + mut cx: AsyncApp, + ) -> Result { + Ok(Self { + position: edit_prediction_position_from_proto( + message.position, + message.version, + buffer, + &mut cx, + ) + .await?, + }) + } + + fn response_to_proto( + response: Vec, + _: &mut LspStore, + _: PeerId, + _: &clock::Global, + _: &mut App, + ) -> proto::GetEditPredictionDefinitionResponse { + proto::GetEditPredictionDefinitionResponse { + definitions: edit_prediction_definitions_to_proto(response), + } + } + + async fn response_from_proto( + self, + message: proto::GetEditPredictionDefinitionResponse, + _: Entity, + _: Entity, + _: AsyncApp, + ) -> Result> { + edit_prediction_definitions_from_proto(message.definitions) + } + + fn buffer_id_from_proto(message: &proto::GetEditPredictionDefinition) -> Result { + BufferId::new(message.buffer_id) + } +} + #[async_trait(?Send)] impl LspCommand for GetDeclarations { type Response = Vec; @@ -807,7 +912,7 @@ impl LspCommand for GetDeclarations { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, lsp_store, buffer, server_id, false, cx).await + location_links_from_lsp(message, lsp_store, buffer, server_id, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetDeclaration { @@ -909,7 +1014,7 @@ impl LspCommand for GetImplementations { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, lsp_store, buffer, server_id, false, cx).await + location_links_from_lsp(message, lsp_store, buffer, server_id, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetImplementation { @@ -1008,7 +1113,7 @@ impl LspCommand for GetTypeDefinitions { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, project, buffer, server_id, self.workspace_only, cx).await + location_links_from_lsp(message, project, buffer, server_id, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetTypeDefinition { @@ -1019,7 +1124,6 @@ impl LspCommand for GetTypeDefinitions { &buffer.anchor_before(self.position), )), version: serialize_version(&buffer.version()), - workspace_only: self.workspace_only, } } @@ -1040,7 +1144,6 @@ impl LspCommand for GetTypeDefinitions { .await?; Ok(Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), - workspace_only: message.workspace_only, }) } @@ -1070,6 +1173,103 @@ impl LspCommand for GetTypeDefinitions { } } +#[async_trait(?Send)] +impl LspCommand for GetEditPredictionTypeDefinitions { + type Response = Vec; + type LspRequest = lsp::request::GotoTypeDefinition; + type ProtoRequest = proto::GetEditPredictionTypeDefinition; + + fn display_name(&self) -> &str { + "Get edit prediction type definition" + } + + fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + !matches!( + &capabilities.server_capabilities.type_definition_provider, + None | Some(lsp::TypeDefinitionProviderCapability::Simple(false)) + ) + } + + fn to_lsp( + &self, + path: &Path, + _: &Buffer, + _: &Arc, + _: &App, + ) -> Result { + Ok(lsp::GotoTypeDefinitionParams { + text_document_position_params: make_lsp_text_document_position(path, self.position)?, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }) + } + + async fn response_from_lsp( + self, + message: Option, + lsp_store: Entity, + _: Entity, + _: LanguageServerId, + cx: AsyncApp, + ) -> Result> { + edit_prediction_definitions_from_lsp(message, lsp_store, cx) + } + + fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetEditPredictionTypeDefinition { + proto::GetEditPredictionTypeDefinition { + project_id, + buffer_id: buffer.remote_id().into(), + position: Some(language::proto::serialize_anchor( + &buffer.anchor_before(self.position), + )), + version: serialize_version(&buffer.version()), + } + } + + async fn from_proto( + message: proto::GetEditPredictionTypeDefinition, + _: Entity, + buffer: Entity, + mut cx: AsyncApp, + ) -> Result { + Ok(Self { + position: edit_prediction_position_from_proto( + message.position, + message.version, + buffer, + &mut cx, + ) + .await?, + }) + } + + fn response_to_proto( + response: Vec, + _: &mut LspStore, + _: PeerId, + _: &clock::Global, + _: &mut App, + ) -> proto::GetEditPredictionTypeDefinitionResponse { + proto::GetEditPredictionTypeDefinitionResponse { + definitions: edit_prediction_definitions_to_proto(response), + } + } + + async fn response_from_proto( + self, + message: proto::GetEditPredictionTypeDefinitionResponse, + _: Entity, + _: Entity, + _: AsyncApp, + ) -> Result> { + edit_prediction_definitions_from_proto(message.definitions) + } + + fn buffer_id_from_proto(message: &proto::GetEditPredictionTypeDefinition) -> Result { + BufferId::new(message.buffer_id) + } +} + fn language_server_for_buffer( lsp_store: &Entity, buffer: &Entity, @@ -1165,57 +1365,13 @@ pub async fn location_links_from_lsp( lsp_store: Entity, buffer: Entity, server_id: LanguageServerId, - workspace_only: bool, mut cx: AsyncApp, ) -> Result> { - let message = match message { - Some(message) => message, - None => return Ok(Vec::new()), - }; - - let mut unresolved_links = Vec::new(); - match message { - lsp::GotoDefinitionResponse::Scalar(loc) => { - unresolved_links.push((None, loc.uri, loc.range)); - } - - lsp::GotoDefinitionResponse::Array(locs) => { - unresolved_links.extend(locs.into_iter().map(|l| (None, l.uri, l.range))); - } - - lsp::GotoDefinitionResponse::Link(links) => { - unresolved_links.extend(links.into_iter().map(|l| { - ( - l.origin_selection_range, - l.target_uri, - l.target_selection_range, - ) - })); - } - } + let unresolved_links = definition_locations_from_lsp(message); let (_, language_server) = language_server_for_buffer(&lsp_store, &buffer, server_id, &mut cx)?; let mut definitions = Vec::new(); for (origin_range, target_uri, target_range) in unresolved_links { - if workspace_only - && !lsp_store.update(&mut cx, |this, cx| { - use util::paths::UrlExt as _; - let worktree_store = this.worktree_store().read(cx); - let path_style = worktree_store.path_style(); - let Ok(abs_path) = target_uri.clone().to_file_path_ext(path_style) else { - return false; - }; - worktree_store - .find_worktree(&abs_path, cx) - .is_some_and(|(worktree, _)| { - let worktree = worktree.read(cx); - worktree.is_visible() && !worktree.is_single_file() - }) - }) - { - continue; - } - let target_buffer_handle = lsp_store .update(&mut cx, |this, cx| { this.open_local_buffer_via_lsp(target_uri, language_server.server_id(), cx) @@ -1256,6 +1412,94 @@ pub async fn location_links_from_lsp( Ok(definitions) } +fn definition_locations_from_lsp( + message: Option, +) -> Vec<(Option, lsp::Uri, lsp::Range)> { + let Some(message) = message else { + return Vec::new(); + }; + + let mut locations = Vec::new(); + match message { + lsp::GotoDefinitionResponse::Scalar(location) => { + locations.push((None, location.uri, location.range)); + } + + lsp::GotoDefinitionResponse::Array(locations_from_lsp) => { + locations.extend( + locations_from_lsp + .into_iter() + .map(|location| (None, location.uri, location.range)), + ); + } + + lsp::GotoDefinitionResponse::Link(links) => { + locations.extend(links.into_iter().map(|link| { + ( + link.origin_selection_range, + link.target_uri, + link.target_selection_range, + ) + })); + } + } + locations +} + +fn edit_prediction_definitions_from_lsp( + message: Option, + lsp_store: Entity, + mut cx: AsyncApp, +) -> Result> { + let unresolved_locations = definition_locations_from_lsp(message); + lsp_store.update(&mut cx, |lsp_store, cx| { + use util::paths::UrlExt as _; + let mut definitions = Vec::new(); + let worktree_store = lsp_store.worktree_store().read(cx); + let path_style = worktree_store.path_style(); + + for (_, uri, range) in unresolved_locations { + let Ok(abs_path) = uri.to_file_path_ext(path_style) else { + continue; + }; + let Some((worktree, relative_path)) = worktree_store.find_worktree(&abs_path, cx) + else { + continue; + }; + let worktree = worktree.read(cx); + if !worktree.is_visible() || worktree.is_single_file() { + continue; + } + definitions.push(EditPredictionDefinition { + path: ProjectPath { + worktree_id: worktree.id(), + path: relative_path, + }, + range: point_from_lsp(range.start)..point_from_lsp(range.end), + }); + } + + Ok(definitions) + }) +} + +async fn edit_prediction_position_from_proto( + position: Option, + version: Vec, + buffer: Entity, + cx: &mut AsyncApp, +) -> Result { + let position = position + .and_then(deserialize_anchor) + .context("invalid position")?; + buffer + .update(cx, |buffer, _| { + buffer.wait_for_version(deserialize_version(&version)) + }) + .await?; + Ok(buffer.read_with(cx, |buffer, _| position.to_point_utf16(buffer))) +} + pub async fn location_link_from_lsp( link: lsp::LocationLink, lsp_store: &Entity, @@ -1363,6 +1607,46 @@ pub fn location_link_to_proto( } } +fn edit_prediction_definitions_to_proto( + definitions: Vec, +) -> Vec { + definitions + .into_iter() + .map(|definition| proto::EditPredictionDefinition { + worktree_id: definition.path.worktree_id.to_proto(), + path: definition.path.path.as_ref().to_proto(), + start: Some(proto::PointUtf16 { + row: definition.range.start.0.row, + column: definition.range.start.0.column, + }), + end: Some(proto::PointUtf16 { + row: definition.range.end.0.row, + column: definition.range.end.0.column, + }), + }) + .collect() +} + +fn edit_prediction_definitions_from_proto( + definitions: Vec, +) -> Result> { + definitions + .into_iter() + .map(|definition| { + let start = definition.start.context("missing definition start")?; + let end = definition.end.context("missing definition end")?; + Ok(EditPredictionDefinition { + path: ProjectPath { + worktree_id: worktree::WorktreeId::from_proto(definition.worktree_id), + path: RelPath::from_proto(&definition.path).context("invalid path")?, + }, + range: Unclipped(PointUtf16::new(start.row, start.column)) + ..Unclipped(PointUtf16::new(end.row, end.column)), + }) + }) + .collect() +} + #[async_trait(?Send)] impl LspCommand for GetReferences { type Response = Vec; diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 0500d15c33e..71d5b30cf88 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -6171,31 +6171,9 @@ impl LspStore { buffer: &Entity, position: PointUtf16, cx: &mut Context, - ) -> Task>>> { - self.definitions_with_filter(buffer, position, false, cx) - } - - pub fn workspace_definitions( - &mut self, - buffer: &Entity, - position: PointUtf16, - cx: &mut Context, - ) -> Task>>> { - self.definitions_with_filter(buffer, position, true, cx) - } - - fn definitions_with_filter( - &mut self, - buffer: &Entity, - position: PointUtf16, - workspace_only: bool, - cx: &mut Context, ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { - let request = GetDefinitions { - position, - workspace_only, - }; + let request = GetDefinitions { position }; if !self.is_capable_for_proto_request(buffer, &request, cx) { return Task::ready(Ok(None)); } @@ -6220,11 +6198,7 @@ impl LspStore { return Ok(None); }; let actions = join_all(responses.payload.into_iter().map(|response| { - GetDefinitions { - position, - workspace_only, - } - .response_from_proto( + GetDefinitions { position }.response_from_proto( response.response, lsp_store.clone(), buffer.clone(), @@ -6247,10 +6221,7 @@ impl LspStore { let definitions_task = self.request_multiple_lsp_locally( buffer, Some(position), - GetDefinitions { - position, - workspace_only, - }, + GetDefinitions { position }, cx, ); cx.background_spawn(async move { @@ -6266,6 +6237,107 @@ impl LspStore { } } + fn edit_prediction_definitions_for_command( + &mut self, + buffer: &Entity, + request: C, + position: PointUtf16, + cx: &mut Context, + ) -> Task>> + where + C: LspCommand> + Clone, + C::ProtoRequest: proto::LspRequestMessage, + ::Response: + Into<::Response>, + ::Result: Send, + ::Params: Send, + { + if let Some((upstream_client, project_id)) = self.upstream_client() { + if !self.is_capable_for_proto_request(buffer, &request, cx) { + return Task::ready(Ok(Vec::new())); + } + + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + + let request_task = upstream_client.request_lsp( + project_id, + None, + request_timeout, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); + let buffer = buffer.clone(); + cx.spawn(async move |weak_lsp_store, cx| { + let Some(lsp_store) = weak_lsp_store.upgrade() else { + return Ok(Vec::new()); + }; + let Some(responses) = request_task.await? else { + return Ok(Vec::new()); + }; + let actions = join_all(responses.payload.into_iter().map(|response| { + request.clone().response_from_proto( + response.response.into(), + lsp_store.clone(), + buffer.clone(), + cx.clone(), + ) + })) + .await; + + Ok(actions + .into_iter() + .collect::>>>()? + .into_iter() + .flatten() + .collect()) + }) + } else { + let definitions_task = + self.request_multiple_lsp_locally(buffer, Some(position), request, cx); + cx.background_spawn(async move { + Ok(definitions_task + .await + .into_iter() + .flat_map(|(_, definitions)| definitions) + .collect()) + }) + } + } + + pub fn edit_prediction_definitions( + &mut self, + buffer: &Entity, + position: PointUtf16, + include_type_definitions: bool, + cx: &mut Context, + ) -> Task>> { + let definitions = self.edit_prediction_definitions_for_command( + buffer, + GetEditPredictionDefinitions { position }, + position, + cx, + ); + let type_definitions = include_type_definitions.then(|| { + self.edit_prediction_definitions_for_command( + buffer, + GetEditPredictionTypeDefinitions { position }, + position, + cx, + ) + }); + cx.background_spawn(async move { + let mut merged = definitions.await?; + if let Some(type_definitions) = type_definitions { + merged.extend(type_definitions.await?); + } + let mut seen = HashSet::default(); + merged.retain(|definition| seen.insert(definition.clone())); + Ok(merged) + }) + } + pub fn declarations( &mut self, buffer: &Entity, @@ -6340,31 +6412,9 @@ impl LspStore { buffer: &Entity, position: PointUtf16, cx: &mut Context, - ) -> Task>>> { - self.type_definitions_with_filter(buffer, position, false, cx) - } - - pub fn workspace_type_definitions( - &mut self, - buffer: &Entity, - position: PointUtf16, - cx: &mut Context, - ) -> Task>>> { - self.type_definitions_with_filter(buffer, position, true, cx) - } - - fn type_definitions_with_filter( - &mut self, - buffer: &Entity, - position: PointUtf16, - workspace_only: bool, - cx: &mut Context, ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { - let request = GetTypeDefinitions { - position, - workspace_only, - }; + let request = GetTypeDefinitions { position }; if !self.is_capable_for_proto_request(buffer, &request, cx) { return Task::ready(Ok(None)); } @@ -6387,11 +6437,7 @@ impl LspStore { return Ok(None); }; let actions = join_all(responses.payload.into_iter().map(|response| { - GetTypeDefinitions { - position, - workspace_only, - } - .response_from_proto( + GetTypeDefinitions { position }.response_from_proto( response.response, lsp_store.clone(), buffer.clone(), @@ -6414,10 +6460,7 @@ impl LspStore { let type_definitions_task = self.request_multiple_lsp_locally( buffer, Some(position), - GetTypeDefinitions { - position, - workspace_only, - }, + GetTypeDefinitions { position }, cx, ); cx.background_spawn(async move { @@ -9615,6 +9658,22 @@ impl LspStore { ) .await?; } + Request::GetEditPredictionDefinition(get_edit_prediction_definition) => { + let position = get_edit_prediction_definition + .position + .clone() + .and_then(deserialize_anchor); + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_edit_prediction_definition, + position, + &mut cx, + ) + .await?; + } Request::GetDeclaration(get_declaration) => { let position = get_declaration .position @@ -9647,6 +9706,22 @@ impl LspStore { ) .await?; } + Request::GetEditPredictionTypeDefinition(get_edit_prediction_type_definition) => { + let position = get_edit_prediction_type_definition + .position + .clone() + .and_then(deserialize_anchor); + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_edit_prediction_type_definition, + position, + &mut cx, + ) + .await?; + } Request::GetImplementation(get_implementation) => { let position = get_implementation .position diff --git a/crates/project/src/lsp_store/lsp_ext_command.rs b/crates/project/src/lsp_store/lsp_ext_command.rs index dd7010275dc..bb994492d00 100644 --- a/crates/project/src/lsp_store/lsp_ext_command.rs +++ b/crates/project/src/lsp_store/lsp_ext_command.rs @@ -443,7 +443,6 @@ impl LspCommand for GoToParentModule { lsp_store, buffer, server_id, - false, cx, ) .await diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 5f104cf3128..722b712f155 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -99,6 +99,7 @@ use lsp::{ LanguageServerBinary, LanguageServerId, LanguageServerName, LanguageServerSelector, MessageActionItem, }; +pub use lsp_command::EditPredictionDefinition; use lsp_command::*; use lsp_store::{CompletionDocumentation, LspFormatTarget, OpenLspBufferHandle}; pub use manifest_tree::ManifestProvidersStore; @@ -4287,21 +4288,16 @@ impl Project { }) } - pub fn workspace_definitions( + pub fn edit_prediction_definitions( &mut self, buffer: &Entity, position: T, + include_type_definitions: bool, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { let position = position.to_point_utf16(buffer.read(cx)); - let guard = self.retain_remotely_created_models(cx); - let task = self.lsp_store.update(cx, |lsp_store, cx| { - lsp_store.workspace_definitions(buffer, position, cx) - }); - cx.background_spawn(async move { - let result = task.await; - drop(guard); - result + self.lsp_store.update(cx, |lsp_store, cx| { + lsp_store.edit_prediction_definitions(buffer, position, include_type_definitions, cx) }) } @@ -4341,24 +4337,6 @@ impl Project { }) } - pub fn workspace_type_definitions( - &mut self, - buffer: &Entity, - position: T, - cx: &mut Context, - ) -> Task>>> { - let position = position.to_point_utf16(buffer.read(cx)); - let guard = self.retain_remotely_created_models(cx); - let task = self.lsp_store.update(cx, |lsp_store, cx| { - lsp_store.workspace_type_definitions(buffer, position, cx) - }); - cx.background_spawn(async move { - let result = task.await; - drop(guard); - result - }) - } - pub fn implementations( &mut self, buffer: &Entity, diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index 33198b30acd..cf7c2b97661 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -8,13 +8,31 @@ message GetDefinition { uint64 buffer_id = 2; Anchor position = 3; repeated VectorClockEntry version = 4; - bool workspace_only = 5; + reserved 5; } message GetDefinitionResponse { repeated LocationLink links = 1; } +message GetEditPredictionDefinition { + uint64 project_id = 1; + uint64 buffer_id = 2; + Anchor position = 3; + repeated VectorClockEntry version = 4; +} + +message GetEditPredictionDefinitionResponse { + repeated EditPredictionDefinition definitions = 1; +} + +message EditPredictionDefinition { + uint64 worktree_id = 1; + string path = 2; + PointUtf16 start = 3; + PointUtf16 end = 4; +} + message GetDeclaration { uint64 project_id = 1; uint64 buffer_id = 2; @@ -31,12 +49,24 @@ message GetTypeDefinition { uint64 buffer_id = 2; Anchor position = 3; repeated VectorClockEntry version = 4; - bool workspace_only = 5; + reserved 5; } message GetTypeDefinitionResponse { repeated LocationLink links = 1; } + +message GetEditPredictionTypeDefinition { + uint64 project_id = 1; + uint64 buffer_id = 2; + Anchor position = 3; + repeated VectorClockEntry version = 4; +} + +message GetEditPredictionTypeDefinitionResponse { + repeated EditPredictionDefinition definitions = 1; +} + message GetImplementation { uint64 project_id = 1; uint64 buffer_id = 2; @@ -897,6 +927,8 @@ message LspQuery { GetFoldingRanges get_folding_ranges = 17; GetDocumentSymbols get_document_symbols = 18; GetDocumentLinks get_document_links = 19; + GetEditPredictionDefinition get_edit_prediction_definition = 20; + GetEditPredictionTypeDefinition get_edit_prediction_type_definition = 21; } } @@ -924,6 +956,8 @@ message LspResponse { GetFoldingRangesResponse get_folding_ranges_response = 15; GetDocumentSymbolsResponse get_document_symbols_response = 16; GetDocumentLinksResponse get_document_links_response = 17; + GetEditPredictionDefinitionResponse get_edit_prediction_definition_response = 18; + GetEditPredictionTypeDefinitionResponse get_edit_prediction_type_definition_response = 19; } uint64 server_id = 7; } diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 2a196e86e86..07ec0046747 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -59,6 +59,10 @@ message Envelope { GetDefinition get_definition = 32; GetDefinitionResponse get_definition_response = 33; + GetEditPredictionDefinition get_edit_prediction_definition = 462; + GetEditPredictionDefinitionResponse get_edit_prediction_definition_response = 463; + GetEditPredictionTypeDefinition get_edit_prediction_type_definition = 464; + GetEditPredictionTypeDefinitionResponse get_edit_prediction_type_definition_response = 465; // current max GetDeclaration get_declaration = 237; GetDeclarationResponse get_declaration_response = 238; GetTypeDefinition get_type_definition = 34; @@ -489,7 +493,7 @@ message Envelope { GitWorktreeCreatedAtResponse git_worktree_created_at_response = 458; TelemetryEvent telemetry_event = 459; ResolveCodeAction resolve_code_action = 460; - ResolveCodeActionResponse resolve_code_action_response = 461; // current max + ResolveCodeActionResponse resolve_code_action_response = 461; } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 3fbd2352757..f7dd604d82d 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -94,6 +94,10 @@ messages!( (GetDeclarationResponse, Background), (GetDefinition, Background), (GetDefinitionResponse, Background), + (GetEditPredictionDefinition, Background), + (GetEditPredictionDefinitionResponse, Background), + (GetEditPredictionTypeDefinition, Background), + (GetEditPredictionTypeDefinitionResponse, Background), (GetDocumentHighlights, Background), (GetDocumentHighlightsResponse, Background), (GetDocumentSymbols, Background), @@ -415,6 +419,14 @@ request_messages!( (GetCodeActions, GetCodeActionsResponse), (GetCompletions, GetCompletionsResponse), (GetDefinition, GetDefinitionResponse), + ( + GetEditPredictionDefinition, + GetEditPredictionDefinitionResponse + ), + ( + GetEditPredictionTypeDefinition, + GetEditPredictionTypeDefinitionResponse + ), (GetDeclaration, GetDeclarationResponse), (GetImplementation, GetImplementationResponse), (GetDocumentHighlights, GetDocumentHighlightsResponse), @@ -610,6 +622,16 @@ lsp_messages!( (GetCodeLens, GetCodeLensResponse, true), (GetDocumentDiagnostics, GetDocumentDiagnosticsResponse, true), (GetDefinition, GetDefinitionResponse, true), + ( + GetEditPredictionDefinition, + GetEditPredictionDefinitionResponse, + true + ), + ( + GetEditPredictionTypeDefinition, + GetEditPredictionTypeDefinitionResponse, + true + ), (GetDeclaration, GetDeclarationResponse, true), (GetTypeDefinition, GetTypeDefinitionResponse, true), (GetImplementation, GetImplementationResponse, true), @@ -650,6 +672,8 @@ entity_messages!( GetCodeLens, GetCompletions, GetDefinition, + GetEditPredictionDefinition, + GetEditPredictionTypeDefinition, GetDeclaration, GetImplementation, GetDocumentHighlights, @@ -987,6 +1011,12 @@ impl LspQuery { ("GetDocumentDiagnostics", false) } Some(lsp_query::Request::GetDefinition(_)) => ("GetDefinition", false), + Some(lsp_query::Request::GetEditPredictionDefinition(_)) => { + ("GetEditPredictionDefinition", false) + } + Some(lsp_query::Request::GetEditPredictionTypeDefinition(_)) => { + ("GetEditPredictionTypeDefinition", false) + } Some(lsp_query::Request::GetDeclaration(_)) => ("GetDeclaration", false), Some(lsp_query::Request::GetTypeDefinition(_)) => ("GetTypeDefinition", false), Some(lsp_query::Request::GetImplementation(_)) => ("GetImplementation", false), diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index be3d418c9e9..d1cdd8d9e51 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -396,12 +396,18 @@ impl AnyProtoClient { Response::GetDefinitionResponse(response) => { to_any_envelope(&envelope, response) } + Response::GetEditPredictionDefinitionResponse(response) => { + to_any_envelope(&envelope, response) + } Response::GetDeclarationResponse(response) => { to_any_envelope(&envelope, response) } Response::GetTypeDefinitionResponse(response) => { to_any_envelope(&envelope, response) } + Response::GetEditPredictionTypeDefinitionResponse(response) => { + to_any_envelope(&envelope, response) + } Response::GetImplementationResponse(response) => { to_any_envelope(&envelope, response) } From 88ce2d526eac922cf5d3d1b04d2048205af24769 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 15 Jul 2026 10:39:33 +0300 Subject: [PATCH 12/22] Show download percentage in download button's tooltip (#60990) Closes https://github.com/zed-industries/zed/discussions/60944 downloads Release Notes: - Started to show download percentage in download button's tooltip --- Cargo.lock | 2 + crates/title_bar/src/update_version.rs | 34 ++++- crates/ui/Cargo.toml | 2 + .../ui/src/components/collab/update_button.rs | 137 ++++++++++++++++-- 4 files changed, 159 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6ef165bba8..31c56677ac1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19697,9 +19697,11 @@ dependencies = [ "num-format", "schemars 1.0.4", "serde", + "settings", "smallvec", "strum 0.27.2", "theme", + "theme_settings", "ui_macros", "windows 0.61.3", ] diff --git a/crates/title_bar/src/update_version.rs b/crates/title_bar/src/update_version.rs index 876c00b9ba6..e90764ab81e 100644 --- a/crates/title_bar/src/update_version.rs +++ b/crates/title_bar/src/update_version.rs @@ -4,7 +4,7 @@ use anyhow::anyhow; use auto_update::{AutoUpdateStatus, AutoUpdater, UpdateCheckType}; use gpui::{Empty, Render}; use semver::Version; -use ui::{UpdateButton, prelude::*}; +use ui::{Tooltip, UpdateButton, prelude::*}; pub struct UpdateVersion { status: AutoUpdateStatus, @@ -82,7 +82,7 @@ impl UpdateVersion { } fn version_tooltip_message(version: &Version) -> String { - format!("Update to Version: {version}") + UpdateButton::version_tooltip_message(version) } } @@ -96,8 +96,20 @@ impl Render for UpdateVersion { UpdateButton::checking().into_any_element() } AutoUpdateStatus::Downloading { version, progress } => { - let version = Self::version_tooltip_message(version); - UpdateButton::downloading(version, *progress).into_any_element() + let rendered_version = version.clone(); + let tooltip = Tooltip::element(move |_, cx| { + let status = AutoUpdater::get(cx).map(|updater| updater.read(cx).status()); + let message = match &status { + Some(AutoUpdateStatus::Downloading { version, progress }) => { + UpdateButton::downloading_tooltip_message(version, *progress) + } + _ => Self::version_tooltip_message(&rendered_version), + }; + Label::new(message).into_any_element() + }); + UpdateButton::downloading(*progress) + .tooltip_fn(tooltip) + .into_any_element() } AutoUpdateStatus::Installing { version } => { let version = Self::version_tooltip_message(version); @@ -148,4 +160,18 @@ mod tests { "Update to Version: 1.0.0+nightly.14d9a4189f058d8736339b06ff2340101eaea5af" ); } + + #[test] + fn test_downloading_tooltip_message() { + let version = Version::new(1, 0, 0); + + let message = UpdateButton::downloading_tooltip_message(&version, None); + assert_eq!(message, "Update to Version: 1.0.0"); + + let message = UpdateButton::downloading_tooltip_message(&version, Some(0.454)); + assert_eq!(message, "Update to Version: 1.0.0 (45% downloaded)"); + + let message = UpdateButton::downloading_tooltip_message(&version, Some(1.5)); + assert_eq!(message, "Update to Version: 1.0.0 (100% downloaded)"); + } } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 7999a300648..ac9a2bbc60c 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -36,6 +36,8 @@ windows.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true [package.metadata.cargo-machete] ignored = ["log"] diff --git a/crates/ui/src/components/collab/update_button.rs b/crates/ui/src/components/collab/update_button.rs index 38d6a169341..0b94072e901 100644 --- a/crates/ui/src/components/collab/update_button.rs +++ b/crates/ui/src/components/collab/update_button.rs @@ -1,4 +1,4 @@ -use gpui::{AnyElement, ClickEvent, prelude::*}; +use gpui::{AnyElement, AnyView, ClickEvent, prelude::*}; use crate::{ButtonLike, CircularProgress, CommonAnimationExt, Tooltip, prelude::*}; @@ -13,7 +13,7 @@ pub struct UpdateButton { icon_animate: bool, icon_color: Option, message: SharedString, - tooltip: Option, + tooltip: Option AnyView + 'static>>, disabled: bool, show_dismiss: bool, progress: Option, @@ -51,7 +51,17 @@ impl UpdateButton { /// Sets the tooltip text shown on hover. pub fn tooltip(mut self, tooltip: impl Into) -> Self { - self.tooltip = Some(tooltip.into()); + self.tooltip = Some(Box::new(Tooltip::text(tooltip.into()))); + self + } + + /// Sets a tooltip builder invoked on every render, so the tooltip can + /// display content that changes while it stays visible. + pub fn tooltip_fn( + mut self, + tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, + ) -> Self { + self.tooltip = Some(Box::new(tooltip)); self } @@ -95,9 +105,8 @@ impl UpdateButton { .disabled(true) } - pub fn downloading(version: impl Into, progress: Option) -> Self { + pub fn downloading(progress: Option) -> Self { Self::new(IconName::Download, "Downloading Zed Update…") - .tooltip(version) .progress(progress) .disabled(true) } @@ -121,6 +130,24 @@ impl UpdateButton { .tooltip(error) .with_dismiss() } + + pub fn version_tooltip_message(version: impl std::fmt::Display) -> String { + format!("Update to Version: {version}") + } + + pub fn downloading_tooltip_message( + version: impl std::fmt::Display, + progress: Option, + ) -> String { + let message = Self::version_tooltip_message(version); + match progress { + Some(progress) => format!( + "{message} ({:.0}% downloaded)", + progress.clamp(0.0, 1.0) * 100.0 + ), + None => message, + } + } } impl RenderOnce for UpdateButton { @@ -154,7 +181,10 @@ impl RenderOnce for UpdateButton { } }; - let tooltip = self.tooltip.clone(); + let tooltip = self.tooltip; + + let button_id = ElementId::Name(self.message.clone()); + let dismiss_button_id = ElementId::Name(format!("dismiss-{}", self.message).into()); let label_row = h_flex() .h_full() @@ -168,18 +198,16 @@ impl RenderOnce for UpdateButton { .border_1() .border_color(border_color) .child( - ButtonLike::new("update-button") + ButtonLike::new(button_id) .child(label_row) - .when_some(tooltip, |this, tooltip| { - this.tooltip(Tooltip::text(tooltip)) - }) + .when_some(tooltip, |this, tooltip| this.tooltip(tooltip)) .disabled(self.disabled) .when_some(self.on_click, |this, handler| this.on_click(handler)), ) .when(self.show_dismiss, |this| { this.child( div().border_l_1().border_color(border_color).child( - IconButton::new("dismiss-update-button", IconName::Close) + IconButton::new(dismiss_button_id, IconName::Close) .icon_size(IconSize::Indicator) .when_some(self.on_dismiss, |this, handler| this.on_click(handler)) .tooltip(Tooltip::text("Dismiss")), @@ -215,7 +243,12 @@ impl Component for UpdateButton { single_example("Checking", UpdateButton::checking().into_any_element()), single_example( "Downloading", - UpdateButton::downloading(version, Some(0.45)).into_any_element(), + UpdateButton::downloading(Some(0.45)) + .tooltip(UpdateButton::downloading_tooltip_message( + version, + Some(0.45), + )) + .into_any_element(), ), single_example( "Installing", @@ -240,3 +273,83 @@ impl Component for UpdateButton { .into_any_element() } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Render, TestAppContext, point, px}; + use std::cell::Cell; + use std::rc::Rc; + use std::time::Duration; + + struct TestTooltip { + rendered: Rc>, + } + + impl Render for TestTooltip { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.rendered.set(self.rendered.get() + 1); + div().child("tooltip") + } + } + + struct PreviewLikeButtons { + tooltip_built: Rc>, + tooltip_rendered: Rc>, + } + + impl Render for PreviewLikeButtons { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let tooltip_built = self.tooltip_built.clone(); + let tooltip_rendered = self.tooltip_rendered.clone(); + crate::v_flex() + .size_full() + .child(UpdateButton::checking()) + .child( + UpdateButton::downloading(Some(0.5)).tooltip_fn(move |_, cx| { + tooltip_built.set(true); + let rendered = tooltip_rendered.clone(); + cx.new(|_| TestTooltip { rendered }).into() + }), + ) + .child(UpdateButton::updated("Update to Version: 1.0.0")) + } + } + + #[gpui::test] + async fn test_downloading_tooltip_shows_in_preview_like_layout(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + theme_settings::init(theme::LoadThemes::JustBase, cx); + }); + let tooltip_built = Rc::new(Cell::new(false)); + let tooltip_rendered = Rc::new(Cell::new(0)); + let (_view, cx) = cx.add_window_view({ + let tooltip_built = tooltip_built.clone(); + let tooltip_rendered = tooltip_rendered.clone(); + |_, _| PreviewLikeButtons { + tooltip_built, + tooltip_rendered, + } + }); + + cx.simulate_mouse_move(point(px(30.), px(30.)), None, gpui::Modifiers::default()); + cx.run_until_parked(); + cx.simulate_mouse_move(point(px(31.), px(30.)), None, gpui::Modifiers::default()); + cx.run_until_parked(); + + cx.executor().advance_clock(Duration::from_millis(600)); + cx.run_until_parked(); + + assert!(tooltip_built.get(), "tooltip should have been built"); + + tooltip_rendered.set(0); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + assert!( + tooltip_rendered.get() > 0, + "tooltip should still be rendered after another frame" + ); + } +} From 166f044fd046e0de73cd64a027505db90f523b97 Mon Sep 17 00:00:00 2001 From: Philipp Schaffrath Date: Wed, 15 Jul 2026 10:18:46 +0200 Subject: [PATCH 13/22] gpui: Linux wayland set exclusive zone and edge (#60163) # Objective The exclusive zone and exclusive edge of a wlr-layer-shell surface could only be set once, at creation, through `LayerShellOptions`. This adds runtime setters so a live layer-shell window such as a panel can update them without being recreated. ## Solution Add two methods on `Window`: - `set_exclusive_zone` updates how much screen space the surface reserves. A positive value reserves that distance from the anchored edge, 0 lets the surface be moved out of others' exclusive zones, and -1 ignores reserved space and may extend under other surfaces. - `set_exclusive_edge` chooses which anchored edge the exclusive zone applies to, which is needed to disambiguate a corner-anchored surface. Setting an exclusive edge the surface is not anchored to is a fatal protocol error, so the edge is validated (it must be a single edge that the surface anchor contains) and an invalid edge is logged and ignored. The same validation now also guards the creation path. Both setters only commit the surface when a change actually applies, and are no-ops on non-layer-shell windows and on other platforms. This is where these 2 are documented: https://wayland.app/protocols/wlr-layer-shell-unstable-v1#zwlr_layer_surface_v1:request:set_exclusive_zone https://wayland.app/protocols/wlr-layer-shell-unstable-v1#zwlr_layer_surface_v1:request:set_exclusive_edge ## Testing - Verified with a small local layer-shell window (a top bar anchored TOP, LEFT, RIGHT) with buttons that call the setters at runtime - No automated test was added, since this calls through to the compositor. - Reviewers without a layer-shell capable compositor (for example GNOME/Mutter) cannot exercise this, as `zwlr_layer_shell_v1` is unavailable there, I tested this on the [Smithay](https://github.com/Smithay/smithay/) based compositor [niri](https://github.com/niri-wm/niri) ## 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) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - N/A --- crates/gpui/src/platform.rs | 3 + crates/gpui/src/window.rs | 18 +++++ crates/gpui_linux/src/linux/wayland/window.rs | 77 +++++++++++++++++-- 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 3d23df0e944..df7973909c5 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -800,6 +800,9 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn show_window_menu(&self, _position: Point) {} fn start_window_move(&self) {} fn start_window_resize(&self, _edge: ResizeEdge) {} + fn set_exclusive_zone(&self, _zone: Pixels) {} + #[cfg(all(target_os = "linux", feature = "wayland"))] + fn set_exclusive_edge(&self, _edge: layer_shell::Anchor) {} fn set_input_region(&self, _region: Option<&[Bounds]>) {} fn window_decorations(&self) -> Decorations { Decorations::Server diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 15193b3c78b..4c8b4651c8c 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1997,6 +1997,24 @@ impl Window { self.platform_window.request_decorations(decorations); } + /// Set the exclusive zone for a layer-shell surface: how much screen space it + /// reserves so other surfaces avoid occluding it (e.g. a panel reserving space). + /// Positive values reserve that distance from the anchored edge, 0 lets the + /// surface be moved out of others' exclusive zones, and -1 ignores reserved + /// space and may extend under other surfaces. (Wayland layer-shell windows only) + pub fn set_exclusive_zone(&self, zone: Pixels) { + self.platform_window.set_exclusive_zone(zone); + } + + /// Set which anchored edge a layer-shell surface's exclusive zone applies to. + /// This is only needed to disambiguate a corner-anchored surface; otherwise the + /// edge is deduced from the anchor. The edge must be a single edge the surface + /// is anchored to, or it is ignored. (Wayland layer-shell windows only) + #[cfg(all(target_os = "linux", feature = "wayland"))] + pub fn set_exclusive_edge(&self, edge: crate::layer_shell::Anchor) { + self.platform_window.set_exclusive_edge(edge); + } + /// Start a window resize operation (Wayland) pub fn start_window_resize(&self, edge: ResizeEdge) { self.platform_window.start_window_resize(edge); diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 6d2753e131d..993b2ff3fad 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -36,8 +36,10 @@ use gpui::{ PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, - WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, - popup::PopupOptions, px, size, + WindowDecorations, WindowKind, WindowParams, + layer_shell::{Anchor, LayerShellNotSupportedError}, + popup::PopupOptions, + px, size, }; use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig, wgpu}; @@ -183,12 +185,12 @@ impl WaylandSurfaceState { } if let Some(exclusive_edge) = options.exclusive_edge { - layer_surface - .set_exclusive_edge(super::layer_shell::wayland_anchor(exclusive_edge)); + Self::apply_exclusive_edge(&layer_surface, options.anchor, exclusive_edge); } return Ok(WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, + anchor: options.anchor, })); } @@ -298,6 +300,7 @@ pub struct WaylandXdgSurfaceState { pub struct WaylandLayerSurfaceState { layer_surface: zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, + anchor: Anchor, } pub struct WaylandPopupSurfaceState { @@ -452,6 +455,49 @@ impl WaylandSurfaceState { } } + fn set_exclusive_zone(&self, zone: i32) -> bool { + if let WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) = + self + { + layer_surface.set_exclusive_zone(zone); + true + } else { + false + } + } + + /// An exclusive edge must be a single edge that the surface is anchored to, + /// otherwise the compositor raises a fatal `invalid_exclusive_edge` protocol + /// error. An invalid edge is logged and ignored. Returns whether it applied. + fn apply_exclusive_edge( + layer_surface: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, + anchor: Anchor, + edge: Anchor, + ) -> bool { + if edge.bits().count_ones() == 1 && anchor.contains(edge) { + layer_surface.set_exclusive_edge(super::layer_shell::wayland_anchor(edge)); + true + } else { + log::warn!( + "ignoring exclusive edge {edge:?}: must be a single edge of the surface anchor {anchor:?}" + ); + false + } + } + + fn set_exclusive_edge(&self, edge: Anchor) -> bool { + if let WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { + layer_surface, + anchor, + .. + }) = self + { + Self::apply_exclusive_edge(layer_surface, *anchor, edge) + } else { + false + } + } + fn destroy(&mut self) { match self { WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { @@ -470,7 +516,7 @@ impl WaylandSurfaceState { toplevel.destroy(); xdg_surface.destroy(); } - WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface }) => { + WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => { layer_surface.destroy(); } WaylandSurfaceState::Popup(WaylandPopupSurfaceState { @@ -1739,6 +1785,27 @@ impl PlatformWindow for WaylandWindow { } } + fn set_exclusive_zone(&self, zone: Pixels) { + let state = self.borrow(); + if state + .surface_state + .set_exclusive_zone(f32::from(zone) as i32) + { + // Commit to apply it immediately, otherwise it only takes effect + // on the next frame. + state.surface.commit(); + } + } + + fn set_exclusive_edge(&self, edge: Anchor) { + let state = self.borrow(); + if state.surface_state.set_exclusive_edge(edge) { + // Commit to apply it immediately, otherwise it only takes effect + // on the next frame. + state.surface.commit(); + } + } + fn set_input_region(&self, region: Option<&[Bounds]>) { let state = self.borrow(); match region { From b76e4db15df16b79f8ab8383904a92d7ebc60fca Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Wed, 15 Jul 2026 10:27:31 +0200 Subject: [PATCH 14/22] git: Add a gpg wrapper script to route GnuPG prompts via the askpass UI (#58791) This PR adds support for entering your git GPG passphrase through the askpass UI. As a daily Zed user I'm really missing this feature, this forces me to switch to a terminal or an external application. which is not ideal for me and most people that are forced to use GPG signing. Right now Zed can show a similar error (redacted) when you are trying to commit with GPG signing enabled: ``` error: gpg failed to sign the data: [GNUPG:] KEY_CONSIDERED 2 [GNUPG:] BEGIN_SIGNING H8 [GNUPG:] PINENTRY_LAUNCHED 4625 curses 1.3.2 - xterm-ghostty - - 501/20 0 gpg: signing failed: Inappropriate ioctl for device [GNUPG:] FAILURE sign gpg: signing failed: Inappropriate ioctl for device fatal: failed to write commit object ``` Here is the key configurion parts of my **global** git config: ``` [user] name = Remco Smits email = signingkey = [commit] gpgsign = true [tag] gpgsign = true ``` **Before** https://github.com/user-attachments/assets/60a06574-92f1-45df-a29a-8ae11db6751d **After** https://github.com/user-attachments/assets/92b648e9-b538-4ce5-af4c-136689d14e1a **How to test this?** 1. Create a key `gpg --full-generate-key` 2. Run `git config --global user.signingkey ` 3. Run `git config --global commit.gpgsign true` 4. Run & copy result from `gpg --armor --export ` and submit your public key to github [https://github.com/settings/gpg/new](https://github.com/settings/gpg/new) 5. Make a change and try committing 6. See that it promts for your passphrase :) **Note**: I used AI as an assistant to write this PR 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 is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Git: Passphrase prompts from GPG to unlock commit signing keys are now shown in Zed. Co-authored-by: Lukas Wirth --- Cargo.lock | 1 + crates/askpass/Cargo.toml | 3 + crates/askpass/src/askpass.rs | 136 ++++++++++++++++++++++++++++++++++ crates/git/src/repository.rs | 10 +++ 4 files changed, 150 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 31c56677ac1..b48dfe2dd53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -954,6 +954,7 @@ dependencies = [ "smol", "tempfile", "util", + "which 6.0.3", "windows 0.61.3", "zeroize", ] diff --git a/crates/askpass/Cargo.toml b/crates/askpass/Cargo.toml index 298d1a73695..671ab34df25 100644 --- a/crates/askpass/Cargo.toml +++ b/crates/askpass/Cargo.toml @@ -22,6 +22,9 @@ tempfile.workspace = true util.workspace = true zeroize.workspace = true +[target.'cfg(not(target_os = "windows"))'.dependencies] +which.workspace = true + [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true diff --git a/crates/askpass/src/askpass.rs b/crates/askpass/src/askpass.rs index e887841b584..dc5488ac117 100644 --- a/crates/askpass/src/askpass.rs +++ b/crates/askpass/src/askpass.rs @@ -91,6 +91,9 @@ pub struct AskPassSession { #[cfg(not(target_os = "windows"))] const ASKPASS_SCRIPT_NAME: &str = "askpass.sh"; +#[cfg(not(target_os = "windows"))] +const GPG_WRAPPER_SCRIPT_NAME: &str = "gpg-wrapper.sh"; + impl AskPassSession { /// This will create a new AskPassSession. /// You must retain this session until the master process exits. @@ -189,6 +192,15 @@ impl AskPassSession { self.askpass_task.script_path() } + /// Path to a script suitable for git's `gpg.program`, routing GnuPG + /// passphrase prompts through Zed's askpass UI. `None` if unavailable. + pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> { + #[cfg(not(target_os = "windows"))] + return self.askpass_task.gpg_wrapper_path(); + #[cfg(target_os = "windows")] + return None; + } + /// Returns the socket path to set as ZED_ASKPASS_SOCKET. /// /// On Windows, SSH_ASKPASS points directly to cli.exe. SSH passes only @@ -206,6 +218,8 @@ pub struct PasswordProxy { /// On Unix: path to the generated .sh askpass script (set as SSH_ASKPASS). /// On Windows: path to cli.exe (set as SSH_ASKPASS directly — no script needed). askpass_script_path: std::path::PathBuf, + #[cfg(not(target_os = "windows"))] + gpg_wrapper_script_path: Option, /// On Windows only: path to the Unix socket, passed as ZED_ASKPASS_SOCKET /// so cli.exe can find it without --askpass argument parsing. #[cfg(target_os = "windows")] @@ -238,6 +252,24 @@ impl PasswordProxy { let askpass_socket_path = askpass_socket.clone(); + // Create a gpg wrapper script that routes GnuPG passphrase prompts through + // the same socket (and thus through Zed's askpass UI). This only works on + // Unix where we control the pinentry via loopback mode. We compute the path + // before the socket task takes ownership of `temp_dir`, and write the file + // afterwards. + #[cfg(not(target_os = "windows"))] + let (gpg_wrapper_script_path, gpg_wrapper_script) = + match generate_gpg_wrapper_script(askpass_program, &askpass_socket_path) { + Ok(script) => ( + Some(temp_dir.path().join(GPG_WRAPPER_SCRIPT_NAME)), + Some(script), + ), + Err(err) => { + log::warn!("could not create gpg askpass wrapper: {err:#}"); + (None, None) + } + }; + let _task = executor.spawn(async move { maybe!(async move { let listener = @@ -291,9 +323,36 @@ impl PasswordProxy { })?; } + // Write the gpg wrapper script (computed above) and mark it executable. + #[cfg(not(target_os = "windows"))] + let gpg_wrapper_script_path = + if let Some((path, script)) = gpg_wrapper_script_path.zip(gpg_wrapper_script) { + match async { + fs::write(&path, script) + .await + .with_context(|| format!("creating gpg wrapper script at {path:?}"))?; + make_file_executable(&path).await.with_context(|| { + format!("marking gpg wrapper script executable at {path:?}") + })?; + anyhow::Ok(()) + } + .await + { + Ok(()) => Some(path), + Err(err) => { + log::warn!("could not write gpg askpass wrapper: {err:#}"); + None + } + } + } else { + None + }; + Ok(Self { _task, askpass_script_path, + #[cfg(not(target_os = "windows"))] + gpg_wrapper_script_path, #[cfg(target_os = "windows")] askpass_socket_path, }) @@ -307,6 +366,11 @@ impl PasswordProxy { pub fn socket_path(&self) -> impl AsRef { &self.askpass_socket_path } + + #[cfg(not(target_os = "windows"))] + pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> { + self.gpg_wrapper_script_path.as_deref() + } } /// Runs Zed in netcat mode for use in askpass. @@ -398,3 +462,75 @@ fn generate_askpass_script( "{shebang}\n{print_args} | {askpass_program} --askpass={askpass_socket} 2> /dev/null \n", )) } + +#[inline] +#[cfg(not(target_os = "windows"))] +fn generate_gpg_wrapper_script( + askpass_program: &std::path::Path, + askpass_socket: &std::path::Path, +) -> Result { + let shell_kind = ShellKind::Posix; + let gpg_program = find_gpg_program().context("could not find a gpg binary on PATH")?; + let gpg_program = gpg_program + .to_str() + .context("gpg program is on a non-utf8 path")?; + let gpg_program = shell_kind + .try_quote_prefix_aware(gpg_program) + .context("Failed to shell-escape gpg program path")?; + + let askpass_program = shell_kind.prepend_command_prefix( + askpass_program + .to_str() + .context("Askpass program is on a non-utf8 path")?, + ); + let askpass_program = shell_kind + .try_quote_prefix_aware(&askpass_program) + .context("Failed to shell-escape Askpass program path")?; + let askpass_socket = askpass_socket + .try_shell_safe(shell_kind) + .context("Failed to shell-escape Askpass socket path")?; + + let prompt = shell_kind + .try_quote_prefix_aware("Enter passphrase for your Git signing key:") + .context("Failed to shell-escape gpg passphrase prompt")?; + + // The wrapper inspects gpg's arguments and only prompts for a passphrase when + // git asks it to *sign* (e.g. `gpg -bsau `). Other invocations such as + // signature verification (`--verify`) run gpg unchanged so we never pop a + // spurious modal. When signing, we feed the passphrase to gpg on fd 3 via + // `--passphrase-fd 3` in loopback mode, so no pinentry/terminal is required. + Ok(format!( + r#"#!/bin/sh +for arg in "$@"; do + case "$arg" in + # Long-form signing options. + --sign|--detach-sign|--clearsign|--clear-sign) is_signing=1 ;; + # Skip other long options so flags like `--status-fd` don't match below. + --*) ;; + # Short-flag clusters containing `s`, e.g. git's `-bsau`. + -*s*) is_signing=1 ;; + esac +done + +# Not a signing request: run gpg as-is, leaving its prompting untouched. +if [ -z "${{is_signing}}" ]; then + exec {gpg_program} "$@" +fi + +# Signing: ask Zed for the passphrase, then hand it to gpg on fd 3. +passphrase=$(printf '%s\0' {prompt} | {askpass_program} --askpass={askpass_socket} 2>/dev/null) +exec {gpg_program} --pinentry-mode loopback --passphrase-fd 3 "$@" 3< Option { + ["gpg", "gpg2"] + .into_iter() + .find_map(|candidate| which::which(candidate).ok()) +} diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 94d3fac855e..0ff60eade2c 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -3744,6 +3744,16 @@ async fn run_git_command( .env("GIT_ASKPASS", ask_pass.script_path()) .env("SSH_ASKPASS", ask_pass.script_path()) .env("SSH_ASKPASS_REQUIRE", "force"); + + if !env.contains_key("GIT_CONFIG_COUNT") + && let Some(gpg_wrapper) = ask_pass.gpg_wrapper_path() + { + command + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "gpg.program") + .env("GIT_CONFIG_VALUE_0", gpg_wrapper); + } + #[cfg(target_os = "windows")] command.env("ZED_ASKPASS_SOCKET", ask_pass.socket_path()); let git_process = command.spawn()?; From febe242c0bf7eab5a283fc26f16d49c2fb9ae607 Mon Sep 17 00:00:00 2001 From: Xin Zhao Date: Wed, 15 Jul 2026 16:28:00 +0800 Subject: [PATCH 15/22] workspace: Fix toolchain not recovered correctly in remote development (#60030) # Objective Closes #55925 Closes #59094 When a Python project is opened and a toolchain is selected, Zed stores/persists this choice in the database. When the project is reopened later, that saved toolchain should be automatically selected. However, this recovery mechanism requires the associated worktree to be loaded first. In local development, the worktrees are loaded first, after which the toolchain is restored and activated: https://github.com/zed-industries/zed/blob/afaef857b987d924c5ec09265a83feb030283f48/crates/workspace/src/workspace.rs#L1916-L1955 But in remote development, the toolchain restoration block runs at the very beginning of initialization, before any worktrees are loaded: https://github.com/zed-industries/zed/blob/afaef857b987d924c5ec09265a83feb030283f48/crates/workspace/src/workspace.rs#L10381-L10413 Because the worktree does not exist yet when the toolchain is processed, `find_worktree` always returns `None`, and the saved toolchain is never activated. This breaks toolchain persistence for remote workspaces. ## Solution Move the toolchain activation block in `open_remote_project_inner` so it runs after the worktrees have been loaded, matching the sequence used in `new_local`. This ensures the worktree is present in the project when we perform the ID lookup and activate the toolchain. ## Testing Tested locally with a Windows client connecting to a remote Linux server, verifying that the previously selected virtual environment is correctly restored upon reopening the workspace. ## 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) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Fixed Python virtual environments not automatically restoring when reopening a remote development workspace. Co-authored-by: Lukas Wirth --- crates/workspace/src/workspace.rs | 54 +++++++++++++++++-------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index ace71b3de14..699e1ca5831 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -10809,28 +10809,6 @@ async fn open_remote_project_inner( source_workspace: Option>, cx: &mut AsyncApp, ) -> Result>>> { - let db = cx.update(|cx| WorkspaceDb::global(cx)); - let toolchains = db.toolchains(workspace_id).await?; - for (toolchain, worktree_path, path) in toolchains { - project - .update(cx, |this, cx| { - let Some(worktree_id) = - this.find_worktree(&worktree_path, cx) - .and_then(|(worktree, rel_path)| { - if rel_path.is_empty() { - Some(worktree.read(cx).id()) - } else { - None - } - }) - else { - return Task::ready(None); - }; - - this.activate_toolchain(ProjectPath { worktree_id, path }, toolchain, cx) - }) - .await; - } let mut project_paths_to_open = vec![]; let mut project_path_errors = vec![]; @@ -10856,8 +10834,13 @@ async fn open_remote_project_inner( let workspace = window.update(cx, |multi_workspace, window, cx| { let new_workspace = cx.new(|cx| { - let mut workspace = - Workspace::new(Some(workspace_id), project, app_state.clone(), window, cx); + let mut workspace = Workspace::new( + Some(workspace_id), + project.clone(), + app_state.clone(), + window, + cx, + ); workspace.update_history(cx); if let Some(ref serialized) = serialized_workspace { @@ -10880,6 +10863,29 @@ async fn open_remote_project_inner( new_workspace })?; + let db = cx.update(|cx| WorkspaceDb::global(cx)); + let toolchains = db.toolchains(workspace_id).await?; + for (toolchain, worktree_path, path) in toolchains { + project + .update(cx, |this, cx| { + let Some(worktree_id) = + this.find_worktree(&worktree_path, cx) + .and_then(|(worktree, rel_path)| { + if rel_path.is_empty() { + Some(worktree.read(cx).id()) + } else { + None + } + }) + else { + return Task::ready(None); + }; + + this.activate_toolchain(ProjectPath { worktree_id, path }, toolchain, cx) + }) + .await; + } + let items = window .update(cx, |_, window, cx| { window.activate_window(); From f181a2f47be57d1dc57412d42ac44df2fc7eca8f Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Wed, 15 Jul 2026 10:33:25 +0200 Subject: [PATCH 16/22] Split out `RelPath` into a separate crate (#61029) This is necessary to remove some `util` dependencies from crates, as well as better sharing for our projects. This also includes the WIP AbsPath abstraction as well as some bug fixes from internal tooling. Release Notes: - N/A or Added/Fixed/Improved ... --- Cargo.lock | 16 +- Cargo.toml | 2 + crates/acp_thread/src/mention.rs | 38 +- crates/agent/src/agent.rs | 16 +- crates/agent/src/templates.rs | 2 +- crates/agent/src/tools/edit_session.rs | 4 +- crates/agent_ui/src/agent_panel.rs | 2 +- crates/agent_ui/src/mention_set.rs | 3 +- crates/client/src/telemetry.rs | 2 +- crates/collab/src/db/queries/projects.rs | 2 +- .../random_project_collaboration_tests.rs | 4 +- crates/debugger_ui/src/debugger_panel.rs | 8 +- crates/debugger_ui/src/new_process_modal.rs | 9 +- .../src/session/running/stack_frame_list.rs | 2 +- crates/dev_container/src/devcontainer_api.rs | 10 +- crates/dev_container/src/lib.rs | 5 +- crates/edit_prediction/src/udiff.rs | 4 +- .../src/edit_prediction_context.rs | 2 +- .../src/editable_context.rs | 2 +- crates/editor/src/clipboard.rs | 4 +- crates/editor/src/editor_tests.rs | 14 +- crates/editor/src/items.rs | 4 +- crates/extension/Cargo.toml | 1 + crates/extension/src/extension.rs | 2 +- crates/extension/src/extension_builder.rs | 3 +- crates/extension/src/extension_manifest.rs | 13 +- crates/extension_host/src/extension_host.rs | 2 +- .../src/wasm_host/wit/since_v0_1_0.rs | 2 +- .../src/wasm_host/wit/since_v0_8_0.rs | 4 +- crates/file_finder/src/file_finder.rs | 8 +- crates/file_finder/src/file_finder_tests.rs | 4 +- crates/fs/Cargo.toml | 1 + crates/fs/src/fs.rs | 2 +- .../fuzzy_nucleo/benches/match_benchmark.rs | 12 +- crates/git/src/repository.rs | 8 +- crates/git/src/status.rs | 4 +- crates/git_ui/src/diff_multibuffer.rs | 4 +- crates/git_ui/src/git_panel.rs | 4 +- crates/git_ui/src/multi_diff_view.rs | 6 +- crates/image_viewer/src/image_viewer.rs | 2 +- crates/language/src/language.rs | 4 +- crates/language_core/Cargo.toml | 6 +- crates/language_core/src/language_config.rs | 7 +- crates/language_core/src/language_core.rs | 6 +- crates/language_core/src/toolchain.rs | 2 +- crates/languages/src/eslint.rs | 2 +- crates/languages/src/json.rs | 8 +- crates/languages/src/python.rs | 37 +- crates/languages/src/rust.rs | 2 +- crates/languages/src/typescript.rs | 4 +- crates/multi_buffer/src/path_key.rs | 2 +- .../open_path_prompt/src/open_path_prompt.rs | 24 +- crates/path/Cargo.toml | 23 + crates/path/LICENSE-APACHE | 1 + crates/path/src/abs_path.rs | 286 +++++++++++ crates/path/src/path.rs | 263 ++++++++++ crates/{util => path}/src/rel_path.rs | 382 ++++++++++---- crates/paths/src/paths.rs | 14 +- crates/project/Cargo.toml | 9 +- crates/project/src/buffer_store.rs | 2 +- crates/project/src/git_store.rs | 54 +- crates/project/src/image_store.rs | 2 +- crates/project/src/lsp_command.rs | 6 +- crates/project/src/lsp_store.rs | 29 +- crates/project/src/manifest_tree/path_trie.rs | 7 +- crates/project/src/prettier_store.rs | 2 +- crates/project/src/project.rs | 12 +- crates/project/src/project_settings.rs | 4 +- crates/project/src/toolchain_store.rs | 19 +- crates/project/src/worktree_store.rs | 8 +- crates/project/tests/integration/git_store.rs | 34 +- .../tests/integration/project_search.rs | 4 +- .../tests/integration/project_tests.rs | 2 +- crates/project_panel/benches/sorting.rs | 2 +- crates/project_panel/src/project_panel.rs | 35 +- crates/project_symbols/src/project_symbols.rs | 2 +- .../src/dev_container_suggest.rs | 4 +- crates/recent_projects/src/remote_servers.rs | 4 +- crates/remote/src/transport/docker.rs | 18 +- crates/remote/src/transport/ssh.rs | 26 +- crates/remote/src/transport/wsl.rs | 32 +- crates/remote_server/src/headless_project.rs | 6 +- crates/settings/src/editorconfig_store.rs | 4 +- crates/settings/src/settings_store.rs | 30 +- crates/settings_ui/src/settings_ui.rs | 6 +- crates/util/Cargo.toml | 4 +- crates/util/src/paths.rs | 484 ++++++------------ crates/util/src/util.rs | 36 +- crates/workspace/src/pane.rs | 4 +- crates/workspace/src/persistence.rs | 5 +- crates/workspace/src/security_modal.rs | 6 +- crates/worktree/src/worktree.rs | 77 +-- .../tests/integration/worktree_tests.rs | 8 +- 93 files changed, 1458 insertions(+), 839 deletions(-) create mode 100644 crates/path/Cargo.toml create mode 120000 crates/path/LICENSE-APACHE create mode 100644 crates/path/src/abs_path.rs create mode 100644 crates/path/src/path.rs rename crates/{util => path}/src/rel_path.rs (62%) diff --git a/Cargo.lock b/Cargo.lock index b48dfe2dd53..a3cc7323822 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6320,6 +6320,7 @@ dependencies = [ "log", "lsp", "parking_lot", + "path", "pretty_assertions", "proto", "semver", @@ -6909,6 +6910,7 @@ dependencies = [ "log", "notify 9.0.0-rc.4", "parking_lot", + "path", "paths", "proto", "rope", @@ -9755,13 +9757,13 @@ dependencies = [ "log", "lsp-types", "parking_lot", + "path", "regex", "schemars 1.0.4", "serde", "serde_json", "toml 0.8.23", "tree-sitter", - "util", ] [[package]] @@ -12706,6 +12708,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "path" +version = "0.1.0" +dependencies = [ + "anyhow", + "dunce", + "serde", + "tempfile", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -14041,6 +14053,7 @@ dependencies = [ "markdown", "node_runtime", "parking_lot", + "path", "paths", "percent-encoding", "postage", @@ -19955,6 +19968,7 @@ dependencies = [ "log", "mach2 0.5.0", "nix 0.29.0", + "path", "percent-encoding", "pretty_assertions", "rand 0.9.4", diff --git a/Cargo.toml b/Cargo.toml index 4f827f0c421..62c2381988b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -151,6 +151,7 @@ members = [ "crates/outline", "crates/outline_panel", "crates/panel", + "crates/path", "crates/paths", "crates/picker", "crates/picker_preview", @@ -414,6 +415,7 @@ outline = { path = "crates/outline" } outline_panel = { path = "crates/outline_panel" } panel = { path = "crates/panel" } paths = { path = "crates/paths" } +path = { path = "crates/path" } perf = { path = "tooling/perf" } picker = { path = "crates/picker" } picker_preview = { path = "crates/picker_preview" } diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index 93e7b4d846b..3686f2955c1 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -1095,8 +1095,7 @@ mod tests { #[test] fn test_posix_paths_are_not_rewritten_as_windows_drives() { - let parsed = - MentionUri::parse_hyperlink("/c/Projects/AGENTS.md", PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse_hyperlink("/c/Projects/AGENTS.md", PathStyle::Unix).unwrap(); match parsed { MentionUri::File { abs_path } => { assert_eq!(abs_path, PathBuf::from("/c/Projects/AGENTS.md")); @@ -1107,7 +1106,7 @@ mod tests { #[test] fn test_hyperlink_percent_escapes_are_decoded() { - let parsed = MentionUri::parse_hyperlink("/tmp/a%20b.rs", PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse_hyperlink("/tmp/a%20b.rs", PathStyle::Unix).unwrap(); assert_eq!( parsed, MentionUri::File { @@ -1126,15 +1125,14 @@ mod tests { ); // Separator escapes stay encoded (no introduced path traversal). - let parsed = MentionUri::parse_hyperlink("/tmp/a%2Fb.rs", PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse_hyperlink("/tmp/a%2Fb.rs", PathStyle::Unix).unwrap(); assert_eq!( parsed, MentionUri::File { abs_path: PathBuf::from("/tmp/a%2Fb.rs") } ); - let parsed = - MentionUri::parse_hyperlink("/tmp/..%2F..%2Fsecret", PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse_hyperlink("/tmp/..%2F..%2Fsecret", PathStyle::Unix).unwrap(); assert_eq!( parsed, MentionUri::File { @@ -1145,7 +1143,7 @@ mod tests { #[test] fn test_parse_keeps_bare_path_targets_verbatim() { - let parsed = MentionUri::parse("/tmp/a%20b.rs", PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse("/tmp/a%20b.rs", PathStyle::Unix).unwrap(); assert_eq!( parsed, MentionUri::File { @@ -1165,7 +1163,7 @@ mod tests { #[test] fn test_parse_hyperlink_literal_keeps_percent_escapes() { let literal = - MentionUri::parse_hyperlink_literal("/tmp/a%20b.rs", PathStyle::Posix).unwrap(); + MentionUri::parse_hyperlink_literal("/tmp/a%20b.rs", PathStyle::Unix).unwrap(); assert_eq!( literal, MentionUri::File { @@ -1175,7 +1173,7 @@ mod tests { // Line suffixes still parse. let literal = - MentionUri::parse_hyperlink_literal("/tmp/a%20b.rs:42", PathStyle::Posix).unwrap(); + MentionUri::parse_hyperlink_literal("/tmp/a%20b.rs:42", PathStyle::Unix).unwrap(); assert_eq!( literal, MentionUri::Selection { @@ -1200,27 +1198,27 @@ mod tests { fn test_parse_hyperlink_literal_returns_none_when_unambiguous() { // No percent escapes: identical to `parse_hyperlink`. assert_eq!( - MentionUri::parse_hyperlink_literal("/tmp/a b.rs", PathStyle::Posix), + MentionUri::parse_hyperlink_literal("/tmp/a b.rs", PathStyle::Unix), None ); // Invalid escape sequences are also left alone by `parse_hyperlink`. assert_eq!( - MentionUri::parse_hyperlink_literal("/tmp/100%_done.txt", PathStyle::Posix), + MentionUri::parse_hyperlink_literal("/tmp/100%_done.txt", PathStyle::Unix), None ); // Separator escapes are never decoded, so they're not ambiguous. assert_eq!( - MentionUri::parse_hyperlink_literal("/tmp/a%2Fb.rs", PathStyle::Posix), + MentionUri::parse_hyperlink_literal("/tmp/a%2Fb.rs", PathStyle::Unix), None ); // URLs are spec-encoded, not ambiguous. assert_eq!( - MentionUri::parse_hyperlink_literal("file:///tmp/a%20b.rs", PathStyle::Posix), + MentionUri::parse_hyperlink_literal("file:///tmp/a%20b.rs", PathStyle::Unix), None ); // Relative paths are not bare-path mentions. assert_eq!( - MentionUri::parse_hyperlink_literal("tmp/a%20b.rs", PathStyle::Posix), + MentionUri::parse_hyperlink_literal("tmp/a%20b.rs", PathStyle::Unix), None ); } @@ -1476,7 +1474,7 @@ mod tests { #[test] fn test_parse_absolute_file_path_with_row() { let file_path = "/path/to/file.rs:42"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, @@ -1494,7 +1492,7 @@ mod tests { #[test] fn test_parse_absolute_file_path_with_row_and_column() { let file_path = "/path/to/file.rs:42:5"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, @@ -1506,7 +1504,7 @@ mod tests { assert_eq!(line_range.end(), &41); assert_eq!(column, &Some(4)); - let parsed_again = MentionUri::parse(parsed.to_uri().as_ref(), PathStyle::Posix) + let parsed_again = MentionUri::parse(parsed.to_uri().as_ref(), PathStyle::Unix) .expect("selection URI with column should parse"); assert_eq!(parsed_again, parsed.clone()); } @@ -1517,7 +1515,7 @@ mod tests { #[test] fn test_parse_absolute_file_path_with_fragment_line() { let file_path = "/path/to/file.rs#L42"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, @@ -1589,7 +1587,7 @@ mod tests { #[test] fn test_parse_backticked_absolute_file_path() { let file_path = "`/path/to/file.rs`"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::File { abs_path } => { assert_eq!(abs_path, Path::new("/path/to/file.rs")); @@ -1601,7 +1599,7 @@ mod tests { #[test] fn test_parse_backticked_absolute_file_path_with_fragment_line() { let file_path = "`/path/to/file.rs#L42`"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 381af5e2fa3..f96174baac1 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -446,18 +446,22 @@ impl gpui::EventEmitter for NativeAgent {} static RULES_FILE_REL_PATHS: LazyLock>> = LazyLock::new(|| { RULES_FILE_NAMES .iter() - .filter_map(|name| RelPath::unix(name).ok().map(|path| path.into_arc())) + .filter_map(|name| { + RelPath::from_unix_str(name) + .ok() + .map(|path| path.into_arc()) + }) .collect() }); static AGENTS_PREFIX: LazyLock>> = LazyLock::new(|| { - RelPath::unix(AGENTS_DIR_NAME) + RelPath::from_unix_str(AGENTS_DIR_NAME) .ok() .map(|path| path.into_arc()) }); static SKILLS_PREFIX: LazyLock>> = LazyLock::new(|| { - RelPath::unix(project_skills_relative_path()) + RelPath::from_unix_str(project_skills_relative_path()) .ok() .map(|path| path.into_arc()) }); @@ -492,7 +496,7 @@ async fn expand_project_skills_directories( worktree: &Entity, cx: &mut AsyncApp, ) -> Result<()> { - let agents_dir = RelPath::unix(AGENTS_DIR_NAME)?; + let agents_dir = RelPath::from_unix_str(AGENTS_DIR_NAME)?; let Some(skills_prefix) = SKILLS_PREFIX.as_ref() else { return Ok(()); }; @@ -518,7 +522,7 @@ fn project_skill_files_from_worktree(worktree: &Worktree) -> Vec Vec Option { - let rel_path = util::rel_path::RelPath::unix("AGENTS.md").ok()?; + let rel_path = util::rel_path::RelPath::from_unix_str("AGENTS.md").ok()?; project .read(cx) .visible_worktrees(cx) diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 78579c80462..d04f222e186 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -1248,8 +1248,7 @@ fn full_mention_for_directory( |(worktree_path, full_path): (Arc, String)| { let rel_path = worktree_path .strip_prefix(&directory_path) - .log_err() - .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into()); + .map_or_else(|_| worktree_path.clone(), |rel_path| rel_path.into()); let open_task = project.update(cx, |project, cx| { project.buffer_store().update(cx, |buffer_store, cx| { diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index 0f7ee3174c3..771a0f56052 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -1077,7 +1077,7 @@ mod tests { .enumerate() .filter_map(|(i, path)| { Some(( - Arc::from(RelPath::unix(path).ok()?), + Arc::from(RelPath::from_unix_str(path).ok()?), ProjectEntryId::from_proto(i as u64 + 1), PathChange::Added, )) diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 3cf82e8518c..add702ff23b 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -961,7 +961,7 @@ impl Database { let path_style = if project.windows_paths { PathStyle::Windows } else { - PathStyle::Posix + PathStyle::Unix }; let features: Vec = serde_json::from_str(&project.features).unwrap_or_default(); diff --git a/crates/collab/tests/integration/random_project_collaboration_tests.rs b/crates/collab/tests/integration/random_project_collaboration_tests.rs index c997f16ad31..391cef208d3 100644 --- a/crates/collab/tests/integration/random_project_collaboration_tests.rs +++ b/crates/collab/tests/integration/random_project_collaboration_tests.rs @@ -448,7 +448,7 @@ impl RandomizedTest for ProjectCollaborationTest { .choose(rng) .unwrap(); if entry.path.as_ref().is_empty() { - worktree.root_name().into() + worktree.root_name().to_rel_path_buf() } else { worktree.root_name().join(&entry.path) } @@ -1524,7 +1524,7 @@ fn buffer_for_full_path( else { return false; }; - worktree.read(cx).root_name().join(&file.path()).as_ref() == full_path + worktree.read(cx).root_name().join(&file.path()) == *full_path }) }) .cloned() diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index aa1bd4455c6..8b2af4afbe9 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -1090,14 +1090,14 @@ impl DebugPanel { directory_in_worktree: dir, .. } => { - let relative_path = if dir.ends_with(RelPath::unix(".vscode").unwrap()) { - dir.join(RelPath::unix("launch.json").unwrap()) + let relative_path = if dir.ends_with(RelPath::from_unix_str(".vscode").unwrap()) { + dir.join(RelPath::from_unix_str("launch.json").unwrap()) } else { - dir.join(RelPath::unix("debug.json").unwrap()) + dir.join(RelPath::from_unix_str("debug.json").unwrap()) }; ProjectPath { worktree_id: id, - path: relative_path, + path: relative_path.into(), } } _ => return self.save_scenario(scenario, worktree_id, window, cx), diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index eabcdfbd429..bb72a8dd172 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -1069,10 +1069,10 @@ impl DebugDelegate { match path.components().next_back() { Some(".zed") => { - path.push(RelPath::unix("debug.json").unwrap()); + path.push(RelPath::from_unix_str("debug.json").unwrap()); } Some(".vscode") => { - path.push(RelPath::unix("launch.json").unwrap()); + path.push(RelPath::from_unix_str("launch.json").unwrap()); } _ => {} } @@ -1165,7 +1165,7 @@ impl DebugDelegate { id: _, directory_in_worktree: dir, id_base: _, - } => dir.ends_with(RelPath::unix(".zed").unwrap()), + } => dir.ends_with(RelPath::from_unix_str(".zed").unwrap()), _ => false, }); @@ -1186,7 +1186,8 @@ impl DebugDelegate { id_base: _, } => { !(hide_vscode - && dir.ends_with(RelPath::unix(".vscode").unwrap())) + && dir + .ends_with(RelPath::from_unix_str(".vscode").unwrap())) } _ => true, }) diff --git a/crates/debugger_ui/src/session/running/stack_frame_list.rs b/crates/debugger_ui/src/session/running/stack_frame_list.rs index 738a44df7d0..aa8f3483a68 100644 --- a/crates/debugger_ui/src/session/running/stack_frame_list.rs +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -524,7 +524,7 @@ impl StackFrameList { .filter(|path| { // Since we do not know if we are debugging on the host or (a remote/WSL) target, // we need to check if either the path is absolute as Posix or Windows. - is_absolute(path, PathStyle::Posix) || is_absolute(path, PathStyle::Windows) + is_absolute(path, PathStyle::Unix) || is_absolute(path, PathStyle::Windows) }) .map(|path| Arc::::from(Path::new(path))) }) diff --git a/crates/dev_container/src/devcontainer_api.rs b/crates/dev_container/src/devcontainer_api.rs index a5ba5edd6f8..395d50b6bd3 100644 --- a/crates/dev_container/src/devcontainer_api.rs +++ b/crates/dev_container/src/devcontainer_api.rs @@ -172,13 +172,13 @@ pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec Vec { let mut configs = Vec::new(); - let devcontainer_dir_path = RelPath::unix(".devcontainer").expect("valid path"); + let devcontainer_dir_path = RelPath::from_unix_str(".devcontainer").expect("valid path"); if let Some(devcontainer_entry) = snapshot.entry_for_path(devcontainer_dir_path) { if devcontainer_entry.is_dir() { log::debug!("find_configs_in_snapshot: Scanning .devcontainer directory"); let devcontainer_json_path = - RelPath::unix(".devcontainer/devcontainer.json").expect("valid path"); + RelPath::from_unix_str(".devcontainer/devcontainer.json").expect("valid path"); for entry in snapshot.child_entries(devcontainer_dir_path) { log::debug!( "find_configs_in_snapshot: Found entry: {:?}, is_file: {}, is_dir: {}", @@ -199,7 +199,7 @@ pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec let config_json_path = format!("{}/devcontainer.json", entry.path.as_unix_str()); - if let Ok(rel_config_path) = RelPath::unix(&config_json_path) { + if let Ok(rel_config_path) = RelPath::from_unix_str(&config_json_path) { if snapshot.entry_for_path(rel_config_path).is_some() { log::debug!( "find_configs_in_snapshot: Found config in subfolder: {}", @@ -223,7 +223,7 @@ pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec // Always include `.devcontainer.json` so the user can pick it from the UI // even when `.devcontainer/devcontainer.json` also exists. - let root_config_path = RelPath::unix(".devcontainer.json").expect("valid path"); + let root_config_path = RelPath::from_unix_str(".devcontainer.json").expect("valid path"); if snapshot .entry_for_path(root_config_path) .is_some_and(|entry| entry.is_file()) @@ -400,7 +400,7 @@ pub(crate) async fn apply_devcontainer_template( log::error!("Can't create relative path: {e}"); DevContainerError::FilesystemError })?; - let rel_path = RelPath::unix(relative_path) + let rel_path = RelPath::from_unix_str(relative_path) .map_err(|e| { log::error!("Can't create relative path: {e}"); DevContainerError::FilesystemError diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index 963c5a57a29..f34a81bef77 100644 --- a/crates/dev_container/src/lib.rs +++ b/crates/dev_container/src/lib.rs @@ -1579,11 +1579,12 @@ fn dispatch_apply_templates( }; if files.project_files.contains(&Arc::from( - RelPath::unix(".devcontainer/devcontainer.json").unwrap(), + RelPath::from_unix_str(".devcontainer/devcontainer.json").unwrap(), )) { let Some(workspace_task) = workspace .update_in(cx, |workspace, window, cx| { - let Ok(path) = RelPath::unix(".devcontainer/devcontainer.json") else { + let Ok(path) = RelPath::from_unix_str(".devcontainer/devcontainer.json") + else { return Task::ready(Err(anyhow!( "Couldn't create path for .devcontainer/devcontainer.json" ))); diff --git a/crates/edit_prediction/src/udiff.rs b/crates/edit_prediction/src/udiff.rs index 8f095d8bc70..8411adde72e 100644 --- a/crates/edit_prediction/src/udiff.rs +++ b/crates/edit_prediction/src/udiff.rs @@ -309,12 +309,12 @@ pub async fn refresh_worktree_entries( ) -> Result<()> { let mut rel_paths = Vec::new(); for path in paths { - if let Ok(rel_path) = RelPath::new(path, PathStyle::Posix) { + if let Ok(rel_path) = RelPath::new(path, PathStyle::Unix) { rel_paths.push(rel_path.into_arc()); } let path_without_root: PathBuf = path.components().skip(1).collect(); - if let Ok(rel_path) = RelPath::new(&path_without_root, PathStyle::Posix) { + if let Ok(rel_path) = RelPath::new(&path_without_root, PathStyle::Unix) { rel_paths.push(rel_path.into_arc()); } } diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index b5ab87a1738..d2be8f2ce6a 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -178,7 +178,7 @@ impl RelatedExcerptStore { .path .strip_prefix(worktree.root_name().as_unix_str()) .ok()?; - let relative_path = RelPath::new(relative_path, PathStyle::Posix).ok()?; + let relative_path = RelPath::new(relative_path, PathStyle::Unix).ok()?; let project_path = ProjectPath { worktree_id: worktree.id(), path: relative_path.into_owned().into(), diff --git a/crates/edit_prediction_context/src/editable_context.rs b/crates/edit_prediction_context/src/editable_context.rs index f985b5dbb07..ce275191417 100644 --- a/crates/edit_prediction_context/src/editable_context.rs +++ b/crates/edit_prediction_context/src/editable_context.rs @@ -652,7 +652,7 @@ async fn collect_git_log_context( .into_iter() .enumerate() { - let Ok(related_path) = RelPath::new(&related_path, PathStyle::Posix) else { + let Ok(related_path) = RelPath::new(&related_path, PathStyle::Unix) else { continue; }; let project_path = ProjectPath { diff --git a/crates/editor/src/clipboard.rs b/crates/editor/src/clipboard.rs index 02c4d6c818e..b6875bee480 100644 --- a/crates/editor/src/clipboard.rs +++ b/crates/editor/src/clipboard.rs @@ -740,9 +740,9 @@ fn unused_image_path( let mut filename = format!("image.{extension}"); let mut counter = 1u32; loop { - let candidate = dir_rel_path.join(RelPath::unix(&filename).ok()?); + let candidate = dir_rel_path.join(RelPath::from_unix_str(&filename).ok()?); if !exists(&candidate) { - return Some((filename, candidate)); + return Some((filename, candidate.into())); } filename = format!("image_{counter}.{extension}"); counter += 1; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index df2d411181f..8e933079c72 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -38855,7 +38855,7 @@ fn test_hunk_key(file_path: &str) -> DiffHunkKey { file_path: if file_path.is_empty() { Arc::from(util::rel_path::RelPath::empty()) } else { - Arc::from(util::rel_path::RelPath::unix(file_path).unwrap()) + Arc::from(util::rel_path::RelPath::from_unix_str(file_path).unwrap()) }, hunk_start_anchor: Anchor::Min, } @@ -38867,7 +38867,7 @@ fn test_hunk_key_with_anchor(file_path: &str, anchor: Anchor) -> DiffHunkKey { file_path: if file_path.is_empty() { Arc::from(util::rel_path::RelPath::empty()) } else { - Arc::from(util::rel_path::RelPath::unix(file_path).unwrap()) + Arc::from(util::rel_path::RelPath::from_unix_str(file_path).unwrap()) }, hunk_start_anchor: anchor, } @@ -39306,11 +39306,11 @@ fn test_comments_stored_for_multiple_hunks(cx: &mut TestAppContext) { // Create two different hunk keys (simulating two different files) let anchor = snapshot.anchor_before(Point::new(0, 0)); let key1 = DiffHunkKey { - file_path: Arc::from(util::rel_path::RelPath::unix("file1.rs").unwrap()), + file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file1.rs").unwrap()), hunk_start_anchor: anchor, }; let key2 = DiffHunkKey { - file_path: Arc::from(util::rel_path::RelPath::unix("file2.rs").unwrap()), + file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file2.rs").unwrap()), hunk_start_anchor: anchor, }; @@ -39378,11 +39378,11 @@ fn test_same_hunk_detected_by_matching_keys(cx: &mut TestAppContext) { // Create two keys with the same file path and anchor let key1 = DiffHunkKey { - file_path: Arc::from(util::rel_path::RelPath::unix("file.rs").unwrap()), + file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file.rs").unwrap()), hunk_start_anchor: anchor, }; let key2 = DiffHunkKey { - file_path: Arc::from(util::rel_path::RelPath::unix("file.rs").unwrap()), + file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file.rs").unwrap()), hunk_start_anchor: anchor, }; @@ -39399,7 +39399,7 @@ fn test_same_hunk_detected_by_matching_keys(cx: &mut TestAppContext) { // Create a key with different file path let different_file_key = DiffHunkKey { - file_path: Arc::from(util::rel_path::RelPath::unix("other.rs").unwrap()), + file_path: Arc::from(util::rel_path::RelPath::from_unix_str("other.rs").unwrap()), hunk_start_anchor: anchor, }; diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 4acdb10ff75..2bc890ed972 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -2221,14 +2221,14 @@ fn restore_serialized_buffer_contents( fn serialize_path_key(path_key: &PathKey) -> proto::PathKey { proto::PathKey { sort_prefix: path_key.sort_prefix, - path: path_key.path.to_proto(), + path: path_key.path.as_unix_str().to_owned(), } } fn deserialize_path_key(path_key: proto::PathKey) -> Option { Some(PathKey { sort_prefix: path_key.sort_prefix, - path: RelPath::from_proto(&path_key.path).ok()?, + path: RelPath::from_unix_str(&path_key.path).ok()?.into(), }) } diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml index 59fc9c3c7ac..55af1f4007a 100644 --- a/crates/extension/Cargo.toml +++ b/crates/extension/Cargo.toml @@ -26,6 +26,7 @@ language.workspace = true log.workspace = true lsp.workspace = true parking_lot.workspace = true +path.workspace = true proto.workspace = true semver.workspace = true serde.workspace = true diff --git a/crates/extension/src/extension.rs b/crates/extension/src/extension.rs index 2ec8c8ea5f4..850da7a69e8 100644 --- a/crates/extension/src/extension.rs +++ b/crates/extension/src/extension.rs @@ -56,7 +56,7 @@ pub trait Extension: Send + Sync + 'static { /// Returns a path relative to this extension's working directory. fn path_from_extension(&self, path: &Path) -> PathBuf { - util::normalize_path(&self.work_dir().join(path)) + path::normalize_path(&self.work_dir().join(path)) } async fn language_server_command( diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index 6229da6b8dc..03583f86a24 100644 --- a/crates/extension/src/extension_builder.rs +++ b/crates/extension/src/extension_builder.rs @@ -11,6 +11,7 @@ use futures::{ use heck::ToSnakeCase; use http_client::{self, AsyncBody, HttpClient}; use language::LanguageConfig; +use path::PathExt; use semver::Version; use serde::Deserialize; use std::{ @@ -20,7 +21,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use util::{ResultExt, command::Stdio, rel_path::PathExt}; +use util::{ResultExt, command::Stdio}; use wasm_encoder::{ComponentSectionId, Encode as _, RawSection, Section as _}; use wasmparser::Parser; diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index 6e9207a2e9f..5ec589d7183 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::ffi::OsStr; use std::fmt; use std::path::{Path, PathBuf}; @@ -11,7 +12,8 @@ use language::LanguageName; use lsp::LanguageServerName; use semver::Version; use serde::{Deserialize, Serialize}; -use util::rel_path::{PathExt, RelPathBuf}; +use util::paths::PathStyle; +use util::rel_path::{RelPath, RelPathBuf}; use crate::ExtensionCapability; @@ -211,9 +213,12 @@ pub fn build_debug_adapter_schema_path( ) -> anyhow::Result { match &meta.schema_path { Some(path) => Ok(path.clone()), - None => Path::new("debug_adapter_schemas") - .join(Path::new(adapter_name.as_ref()).with_extension("json")) - .to_rel_path_buf(), + None => RelPath::new( + &Path::new("debug_adapter_schemas") + .join(Path::new(adapter_name.as_ref()).with_extension("json")), + PathStyle::local(), + ) + .map(Cow::into_owned), } } diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index 676577cbd0c..9ebdef8cf29 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -57,7 +57,7 @@ use std::{ }; use task::TaskTemplates; use url::Url; -use util::{ResultExt, paths::RemotePathBuf, rel_path::PathExt}; +use util::{PathExt, ResultExt, paths::RemotePathBuf}; use wasm_host::{ WasmExtension, WasmHost, wit::{is_supported_wasm_api_version, wasm_api_version_range}, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs index 288b31b2202..83ba2ad3dfd 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs @@ -432,7 +432,7 @@ impl ExtensionImports for WasmState { self.on_main_thread(|cx| { async move { let path = location.as_ref().and_then(|location| { - RelPath::new(Path::new(&location.path), PathStyle::Posix).ok() + RelPath::new(Path::new(&location.path), PathStyle::Unix).ok() }); let location = path .as_ref() diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs index 25cd0c2d368..29971f00da5 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs @@ -595,7 +595,7 @@ impl HostWorktree for WasmState { ) -> wasmtime::Result> { let delegate = self.table.get(&delegate)?; Ok(delegate - .read_text_file(&RelPath::new(Path::new(&path), PathStyle::Posix)?) + .read_text_file(&RelPath::new(Path::new(&path), PathStyle::Unix)?) .await .map_err(|error| error.to_string())) } @@ -948,7 +948,7 @@ impl ExtensionImports for WasmState { self.on_main_thread(|cx| { async move { let path = location.as_ref().and_then(|location| { - RelPath::new(Path::new(&location.path), PathStyle::Posix).ok() + RelPath::new(Path::new(&location.path), PathStyle::Unix).ok() }); let location = path .as_ref() diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 71c1d03efd2..daf7b938e4b 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1272,7 +1272,11 @@ impl FileFinderDelegate { let full_path = if should_hide_root_in_entry_path(&worktree_store, cx) { entry_path.project.path.clone() } else { - worktree.read(cx).root_name().join(&entry_path.project.path) + worktree + .read(cx) + .root_name() + .join(&entry_path.project.path) + .into() }; let mut components = full_path.components(); let filename = components.next_back().unwrap_or(""); @@ -1841,7 +1845,7 @@ impl PickerDelegate for FileFinderDelegate { .all(|worktree| { worktree .read(cx) - .entry_for_path(RelPath::unix(prefix.split_at(1).0).unwrap()) + .entry_for_path(RelPath::from_unix_str(prefix.split_at(1).0).unwrap()) .is_none_or(|entry| !entry.is_dir()) }) { diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 9dfdf6c08c1..e83d7db791f 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -4584,7 +4584,7 @@ fn collect_search_matches(picker: &Picker) -> SearchEntries if let Some(path_match) = path_match.as_ref() { search_entries .history - .push(path_match.0.path_prefix.join(&path_match.0.path)); + .push(path_match.0.path_prefix.join(&path_match.0.path).into()); } else { // This occurs when the query is empty and we show history matches // that are outside the project. @@ -4597,7 +4597,7 @@ fn collect_search_matches(picker: &Picker) -> SearchEntries Match::Search(path_match) => { search_entries .search - .push(path_match.0.path_prefix.join(&path_match.0.path)); + .push(path_match.0.path_prefix.join(&path_match.0.path).into()); search_entries.search_matches.push(path_match.0.clone()); } Match::CreateNew(_) => {} diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 2d8ee40ab26..37588bb0eed 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -42,6 +42,7 @@ tempfile.workspace = true text.workspace = true time.workspace = true util.workspace = true +path.workspace = true is_executable = "1.0.5" notify = "9.0.0-rc.4" trash = { git = "https://github.com/zed-industries/trash-rs", rev = "47761739192828a66b11a94ba5420b82d63c03c5" } diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index ec6e0554b9a..afbfaebaedf 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -61,7 +61,7 @@ use git::{ status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus}, }; #[cfg(feature = "test-support")] -use util::normalize_path; +use path::normalize_path; #[cfg(feature = "test-support")] use smol::io::AsyncReadExt; diff --git a/crates/fuzzy_nucleo/benches/match_benchmark.rs b/crates/fuzzy_nucleo/benches/match_benchmark.rs index 8f6eedce491..ec1607a2bd6 100644 --- a/crates/fuzzy_nucleo/benches/match_benchmark.rs +++ b/crates/fuzzy_nucleo/benches/match_benchmark.rs @@ -233,7 +233,11 @@ fn generate_nucleo_path_candidates( paths .iter() .map(|path| { - fuzzy_nucleo::PathMatchCandidate::new(RelPath::unix(path).unwrap(), false, None) + fuzzy_nucleo::PathMatchCandidate::new( + RelPath::from_unix_str(path).unwrap(), + false, + None, + ) }) .collect() } @@ -245,7 +249,7 @@ fn generate_fuzzy_path_candidates( .iter() .map(|path| fuzzy::PathMatchCandidate { is_dir: false, - path: RelPath::unix(path).unwrap(), + path: RelPath::from_unix_str(path).unwrap(), char_bag: CharBag::from(path.as_str()), }) .collect() @@ -302,7 +306,7 @@ fn bench_path_matching(criterion: &mut Criterion) { query, case, size, - PathStyle::Posix, + PathStyle::Unix, ) }, BatchSize::SmallInput, @@ -325,7 +329,7 @@ fn bench_path_matching(criterion: &mut Criterion) { query, false, size, - PathStyle::Posix, + PathStyle::Unix, ) }, BatchSize::SmallInput, diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 0ff60eade2c..2f378f4adac 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1517,7 +1517,7 @@ impl GitRepository for RealGitRepository { let change = change?; let path = change.path; // git-show outputs `/`-delimited paths even on Windows. - let Some(rel_path) = RelPath::unix(path).log_err() else { + let Some(rel_path) = RelPath::from_unix_str(path).log_err() else { continue; }; @@ -3803,7 +3803,7 @@ impl std::fmt::Debug for RepoPath { impl RepoPath { pub fn new + ?Sized>(s: &S) -> Result { - let rel_path = RelPath::unix(s.as_ref())?; + let rel_path = RelPath::from_unix_str(s.as_ref())?; Ok(Self::from_rel_path(rel_path)) } @@ -3813,7 +3813,7 @@ impl RepoPath { } pub fn from_proto(proto: &str) -> Result { - let rel_path = RelPath::from_proto(proto)?; + let rel_path = RelPath::from_unix_str(proto)?.into(); Ok(Self(rel_path)) } @@ -3832,7 +3832,7 @@ impl RepoPath { #[cfg(any(test, feature = "test-support"))] pub fn repo_path + ?Sized>(s: &S) -> RepoPath { - RepoPath(RelPath::unix(s.as_ref()).unwrap().into()) + RepoPath(RelPath::from_unix_str(s.as_ref()).unwrap().into()) } impl AsRef> for RepoPath { diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index de1a0c54097..1a04d02e753 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -454,7 +454,7 @@ impl FromStr for GitStatus { let status = entry.as_bytes()[0..2].try_into().unwrap(); let status = FileStatus::from_bytes(status).log_err()?; // git-status outputs `/`-delimited repo paths, even on Windows. - let path = RepoPath::from_rel_path(RelPath::unix(path).log_err()?); + let path = RepoPath::from_rel_path(RelPath::from_unix_str(path).log_err()?); Some((path, status)) }) .collect::>(); @@ -544,7 +544,7 @@ impl FromStr for TreeDiff { let mut fields = s.split('\0'); let mut parsed = HashMap::default(); while let Some((status, path)) = fields.next().zip(fields.next()) { - let path = RepoPath::from_rel_path(RelPath::unix(path)?); + let path = RepoPath::from_rel_path(RelPath::from_unix_str(path)?); let mut fields = status.split(" ").skip(2); let old_sha = fields diff --git a/crates/git_ui/src/diff_multibuffer.rs b/crates/git_ui/src/diff_multibuffer.rs index 7c548952d7b..b20ed284035 100644 --- a/crates/git_ui/src/diff_multibuffer.rs +++ b/crates/git_ui/src/diff_multibuffer.rs @@ -1007,7 +1007,7 @@ fn name_sort_path(repo_path: &RelPath) -> Arc { return repo_path.into_arc(); }; let synthetic = format!("{}/{}", file_name, repo_path.as_unix_str()); - RelPath::unix(&synthetic) + RelPath::from_unix_str(&synthetic) .map(|path| path.into_arc()) .unwrap_or_else(|_| repo_path.into_arc()) } @@ -1032,7 +1032,7 @@ fn tree_sort_path(repo_path: &RelPath) -> Arc { } synthetic.push_str(component); } - RelPath::unix(&synthetic) + RelPath::from_unix_str(&synthetic) .map(|path| path.into_arc()) .unwrap_or_else(|_| repo_path.into_arc()) } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 8a5f1202803..e1c7d0e830e 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3232,7 +3232,7 @@ impl GitPanel { let worktree_snapshot = worktree.read(cx).snapshot(); for rules_name in RULES_FILE_NAMES { - if let Ok(rel_path) = RelPath::unix(rules_name) { + if let Ok(rel_path) = RelPath::from_unix_str(rules_name) { if let Some(entry) = worktree_snapshot.entry_for_path(rel_path) { if entry.is_file() { return Some(ProjectPath { @@ -11485,7 +11485,7 @@ mod tests { cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id()); let project_path = ProjectPath { worktree_id, - path: RelPath::unix("src/a/foo.rs").unwrap().into_arc(), + path: RelPath::from_unix_str("src/a/foo.rs").unwrap().into_arc(), }; panel.update_in(cx, |panel, window, cx| { diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs index 47bf5a2b727..7834a7319fb 100644 --- a/crates/git_ui/src/multi_diff_view.rs +++ b/crates/git_ui/src/multi_diff_view.rs @@ -94,7 +94,7 @@ fn register_entry( RelPath::new(rel, PathStyle::local()) .map(|r| r.into_owned().into()) .unwrap_or_else(|_| { - RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Posix) + RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Unix) .unwrap() .into_owned() .into() @@ -105,10 +105,10 @@ fn register_entry( .new_path .file_name() .and_then(|n| n.to_str()) - .and_then(|s| RelPath::new(Path::new(s), PathStyle::Posix).ok()) + .and_then(|s| RelPath::new(Path::new(s), PathStyle::Unix).ok()) .map(|r| r.into_owned().into()) .unwrap_or_else(|| { - RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Posix) + RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Unix) .unwrap() .into_owned() .into() diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index d881bcc2561..3dc434deb74 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -586,7 +586,7 @@ impl Item for ImageView { } fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String { - let mut path = image.file.path().clone(); + let mut path = image.file.path().to_rel_path_buf(); if project.visible_worktrees(cx).count() > 1 && let Some(worktree) = project.worktree_for_id(image.project_path(cx).worktree_id, cx) { diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 4c57001cd69..b9f87265c0c 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -50,8 +50,8 @@ pub use language_core::{ LanguageId, LanguageMatcher, OrderedListConfig, OutlineConfig, Override, OverrideConfig, OverrideEntry, PromptResponseContext, RedactionConfig, RunnableCapture, RunnableConfig, SoftWrap, Symbol, TaskListConfig, TextObject, TextObjectConfig, ToLspPosition, - WrapCharactersConfig, auto_indent_using_last_non_empty_line_default, deserialize_regex, - deserialize_regex_vec, regex_json_schema, regex_vec_json_schema, serialize_regex, + WrapCharactersConfig, default_true, deserialize_regex, deserialize_regex_vec, + regex_json_schema, regex_vec_json_schema, serialize_regex, }; pub use language_registry::{ LanguageName, LanguageServerStatusUpdate, LoadedLanguage, ServerHealth, diff --git a/crates/language_core/Cargo.toml b/crates/language_core/Cargo.toml index 91dca562a80..47a30d8316c 100644 --- a/crates/language_core/Cargo.toml +++ b/crates/language_core/Cargo.toml @@ -11,17 +11,17 @@ path = "src/language_core.rs" anyhow.workspace = true collections.workspace = true gpui_shared_string.workspace = true +gpui_util.workspace = true log.workspace = true +lsp-types.workspace = true parking_lot.workspace = true +path.workspace = true regex.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true toml.workspace = true tree-sitter.workspace = true -util.workspace = true -gpui_util.workspace = true -lsp-types.workspace = true [features] test-support = [] diff --git a/crates/language_core/src/language_config.rs b/crates/language_core/src/language_config.rs index 51903aef76a..5eea2d123b1 100644 --- a/crates/language_core/src/language_config.rs +++ b/crates/language_core/src/language_config.rs @@ -5,7 +5,6 @@ use regex::Regex; use schemars::{JsonSchema, SchemaGenerator, json_schema}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::{num::NonZeroU32, path::Path, sync::Arc}; -use util::serde::default_true; /// Controls the soft-wrapping behavior in the editor. #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -46,7 +45,7 @@ pub struct LanguageConfig { pub brackets: BracketPairConfig, /// If set to true, auto indentation uses last non empty line to determine /// the indentation level for a new line. - #[serde(default = "auto_indent_using_last_non_empty_line_default")] + #[serde(default = "default_true")] pub auto_indent_using_last_non_empty_line: bool, // Whether indentation of pasted content should be adjusted based on the context. #[serde(default)] @@ -166,7 +165,7 @@ impl Default for LanguageConfig { grammar: None, matcher: LanguageMatcher::default(), brackets: Default::default(), - auto_indent_using_last_non_empty_line: auto_indent_using_last_non_empty_line_default(), + auto_indent_using_last_non_empty_line: default_true(), auto_indent_on_paste: None, increase_indent_pattern: Default::default(), decrease_indent_pattern: Default::default(), @@ -480,7 +479,7 @@ pub struct WrapCharactersConfig { pub end_suffix: String, } -pub fn auto_indent_using_last_non_empty_line_default() -> bool { +pub fn default_true() -> bool { true } diff --git a/crates/language_core/src/language_core.rs b/crates/language_core/src/language_core.rs index f3292e1978d..8a45bd57a25 100644 --- a/crates/language_core/src/language_core.rs +++ b/crates/language_core/src/language_core.rs @@ -17,9 +17,9 @@ pub use highlight_map::{HighlightId, HighlightMap}; pub use language_config::{ BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, DecreaseIndentConfig, JsxTagAutoCloseConfig, LanguageConfig, LanguageConfigOverride, LanguageMatcher, - OrderedListConfig, Override, SoftWrap, TaskListConfig, WrapCharactersConfig, - auto_indent_using_last_non_empty_line_default, deserialize_regex, deserialize_regex_vec, - regex_json_schema, regex_vec_json_schema, serialize_regex, + OrderedListConfig, Override, SoftWrap, TaskListConfig, WrapCharactersConfig, default_true, + deserialize_regex, deserialize_regex_vec, regex_json_schema, regex_vec_json_schema, + serialize_regex, }; pub mod code_label; diff --git a/crates/language_core/src/toolchain.rs b/crates/language_core/src/toolchain.rs index 78bd69917fb..f5b09190975 100644 --- a/crates/language_core/src/toolchain.rs +++ b/crates/language_core/src/toolchain.rs @@ -7,7 +7,7 @@ use std::{path::Path, sync::Arc}; use gpui_shared_string::SharedString; -use util::rel_path::RelPath; +use path::rel_path::RelPath; use crate::{LanguageName, ManifestName}; diff --git a/crates/languages/src/eslint.rs b/crates/languages/src/eslint.rs index 2d0c88bd016..a876f392b9f 100644 --- a/crates/languages/src/eslint.rs +++ b/crates/languages/src/eslint.rs @@ -286,7 +286,7 @@ impl LspAdapter for EsLintLspAdapter { let file_path = requested_file_path .as_ref() .and_then(|abs_path| abs_path.strip_prefix(worktree_root).ok()) - .and_then(|p| RelPath::unix(&p).ok().map(ToOwned::to_owned)) + .and_then(|p| RelPath::from_unix_str(&p).ok().map(ToOwned::to_owned)) .unwrap_or_else(|| RelPath::empty().to_owned()); let override_options = cx.update(|cx| { language_server_settings_for( diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index b97429c685b..ff955fe0869 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -52,8 +52,12 @@ impl ContextProvider for JsonTaskProvider { let Some(file) = project::File::from_dyn(file).cloned() else { return Task::ready(None); }; - let is_package_json = file.path.ends_with(RelPath::unix("package.json").unwrap()); - let is_composer_json = file.path.ends_with(RelPath::unix("composer.json").unwrap()); + let is_package_json = file + .path + .ends_with(RelPath::from_unix_str("package.json").unwrap()); + let is_composer_json = file + .path + .ends_with(RelPath::from_unix_str("composer.json").unwrap()); if !is_package_json && !is_composer_json { return Task::ready(None); } diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 7159379aed6..becefed29ef 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -90,14 +90,14 @@ impl ManifestProvider for PyprojectTomlManifestProvider { let mut outermost_workspace_root = None; for path in path.ancestors().take(depth) { - let pyproject_path = path.join(RelPath::unix("pyproject.toml").unwrap()); + let pyproject_path = path.join(RelPath::from_unix_str("pyproject.toml").unwrap()); if delegate.exists(&pyproject_path, Some(false)) { if innermost_pyproject.is_none() { innermost_pyproject = Some(Arc::from(path)); } let has_lockfile = WORKSPACE_LOCKFILES.iter().any(|lockfile| { - let lockfile_path = path.join(RelPath::unix(lockfile).unwrap()); + let lockfile_path = path.join(RelPath::from_unix_str(lockfile).unwrap()); delegate.exists(&lockfile_path, Some(false)) }); if has_lockfile { @@ -1114,7 +1114,7 @@ impl PythonContextProvider { fn python_module_name_from_relative_path(relative_path: &str) -> Option { let rel_path = RelPath::new(relative_path.as_ref(), PathStyle::local()).ok()?; - let path_with_dots = rel_path.display(PathStyle::Posix).replace('/', "."); + let path_with_dots = rel_path.display(PathStyle::Unix).replace('/', "."); Some( path_with_dots .strip_suffix(".py") @@ -3265,7 +3265,7 @@ mod tests { }); let provider = PyprojectTomlManifestProvider; provider.search(ManifestQuery { - path: RelPath::unix(query_path).unwrap().into(), + path: RelPath::from_unix_str(query_path).unwrap().into(), depth: 10, delegate, }) @@ -3274,7 +3274,7 @@ mod tests { #[test] fn test_simple_project_no_lockfile() { let result = search(&["project/pyproject.toml"], "project/src/main.py"); - assert_eq!(result.as_deref(), RelPath::unix("project").ok()); + assert_eq!(result.as_deref(), RelPath::from_unix_str("project").ok()); } #[test] @@ -3287,7 +3287,7 @@ mod tests { ], "packages/subproject/src/main.py", ); - assert_eq!(result.as_deref(), RelPath::unix("").ok()); + assert_eq!(result.as_deref(), RelPath::from_unix_str("").ok()); } #[test] @@ -3296,7 +3296,7 @@ mod tests { &["pyproject.toml", "poetry.lock", "libs/mylib/pyproject.toml"], "libs/mylib/src/main.py", ); - assert_eq!(result.as_deref(), RelPath::unix("").ok()); + assert_eq!(result.as_deref(), RelPath::from_unix_str("").ok()); } #[test] @@ -3309,7 +3309,7 @@ mod tests { ], "packages/mypackage/src/main.py", ); - assert_eq!(result.as_deref(), RelPath::unix("").ok()); + assert_eq!(result.as_deref(), RelPath::from_unix_str("").ok()); } #[test] @@ -3318,13 +3318,19 @@ mod tests { &["project-a/pyproject.toml", "project-b/pyproject.toml"], "project-a/src/main.py", ); - assert_eq!(result_a.as_deref(), RelPath::unix("project-a").ok()); + assert_eq!( + result_a.as_deref(), + RelPath::from_unix_str("project-a").ok() + ); let result_b = search( &["project-a/pyproject.toml", "project-b/pyproject.toml"], "project-b/src/main.py", ); - assert_eq!(result_b.as_deref(), RelPath::unix("project-b").ok()); + assert_eq!( + result_b.as_deref(), + RelPath::from_unix_str("project-b").ok() + ); } #[test] @@ -3345,7 +3351,7 @@ mod tests { ], "packages/sub/src/main.py", ); - assert_eq!(result.as_deref(), RelPath::unix("").ok()); + assert_eq!(result.as_deref(), RelPath::from_unix_str("").ok()); } #[test] @@ -3360,11 +3366,16 @@ mod tests { // "deep/nested/src/main.py", "deep/nested/src", and "deep/nested" // It won't reach "deep" or root "" let result = provider.search(ManifestQuery { - path: RelPath::unix("deep/nested/src/main.py").unwrap().into(), + path: RelPath::from_unix_str("deep/nested/src/main.py") + .unwrap() + .into(), depth: 3, delegate, }); - assert_eq!(result.as_deref(), RelPath::unix("deep/nested").ok()); + assert_eq!( + result.as_deref(), + RelPath::from_unix_str("deep/nested").ok() + ); } } } diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 52e9a902a07..b34e92bbd93 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -292,7 +292,7 @@ impl ManifestProvider for CargoManifestProvider { ) -> Option> { let mut outermost_cargo_toml = None; for path in path.ancestors().take(depth) { - let p = path.join(RelPath::unix("Cargo.toml").unwrap()); + let p = path.join(RelPath::from_unix_str("Cargo.toml").unwrap()); if delegate.exists(&p, Some(false)) { outermost_cargo_toml = Some(Arc::from(path)); } diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index e34a0b7dbf7..a23b66021c1 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -625,7 +625,9 @@ impl TypeScriptLspAdapter { async fn tsdk_path(&self, adapter: &Arc) -> Option<&'static str> { let is_yarn = adapter - .read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap()) + .read_text_file( + RelPath::from_unix_str(".yarn/sdks/typescript/lib/typescript.js").unwrap(), + ) .await .is_ok(); diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index 18423a69608..436cd5763d2 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -49,7 +49,7 @@ impl PathKey { } else { Self { sort_prefix: None, - path: RelPath::unix(&buffer.entity_id().to_string()) + path: RelPath::from_unix_str(&buffer.entity_id().to_string()) .unwrap() .into_arc(), } diff --git a/crates/open_path_prompt/src/open_path_prompt.rs b/crates/open_path_prompt/src/open_path_prompt.rs index 6fc35e697c8..5fa29d453ac 100644 --- a/crates/open_path_prompt/src/open_path_prompt.rs +++ b/crates/open_path_prompt/src/open_path_prompt.rs @@ -68,7 +68,7 @@ impl OpenPathDelegate { cancel_flag: Arc::new(AtomicBool::new(false)), should_dismiss: true, prompt_root: match path_style { - PathStyle::Posix => "/".to_string(), + PathStyle::Unix => "/".to_string(), PathStyle::Windows => "C:\\".to_string(), }, path_style, @@ -158,7 +158,7 @@ impl OpenPathDelegate { fn current_dir(&self) -> &'static str { match self.path_style { - PathStyle::Posix => "./", + PathStyle::Unix => "./", PathStyle::Windows => ".\\", } } @@ -929,7 +929,7 @@ fn path_candidates( fn get_dir_and_suffix(query: String, path_style: PathStyle) -> (String, String) { match path_style { - PathStyle::Posix => { + PathStyle::Unix => { let (mut dir, suffix) = if let Some(index) = query.rfind('/') { (query[..index].to_string(), query[index + 1..].to_string()) } else { @@ -1028,39 +1028,39 @@ mod tests { #[test] fn test_get_dir_and_suffix_with_posix_style() { - let (dir, suffix) = get_dir_and_suffix("".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("".into(), PathStyle::Unix); assert_eq!(dir, "/"); assert_eq!(suffix, ""); - let (dir, suffix) = get_dir_and_suffix("/".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/".into(), PathStyle::Unix); assert_eq!(dir, "/"); assert_eq!(suffix, ""); - let (dir, suffix) = get_dir_and_suffix("/Use".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/Use".into(), PathStyle::Unix); assert_eq!(dir, "/"); assert_eq!(suffix, "Use"); - let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Docum".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Docum".into(), PathStyle::Unix); assert_eq!(dir, "/Users/Junkui/"); assert_eq!(suffix, "Docum"); - let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents".into(), PathStyle::Unix); assert_eq!(dir, "/Users/Junkui/"); assert_eq!(suffix, "Documents"); - let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents/".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents/".into(), PathStyle::Unix); assert_eq!(dir, "/Users/Junkui/Documents/"); assert_eq!(suffix, ""); - let (dir, suffix) = get_dir_and_suffix("/root/.".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/root/.".into(), PathStyle::Unix); assert_eq!(dir, "/root/"); assert_eq!(suffix, "."); - let (dir, suffix) = get_dir_and_suffix("/root/..".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/root/..".into(), PathStyle::Unix); assert_eq!(dir, "/root/"); assert_eq!(suffix, ".."); - let (dir, suffix) = get_dir_and_suffix("/root/.hidden".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/root/.hidden".into(), PathStyle::Unix); assert_eq!(dir, "/root/"); assert_eq!(suffix, ".hidden"); } diff --git a/crates/path/Cargo.toml b/crates/path/Cargo.toml new file mode 100644 index 00000000000..73136332b0d --- /dev/null +++ b/crates/path/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "path" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lib] +path = "src/path.rs" + +[features] +test-support = [] + +[dependencies] +anyhow.workspace = true +dunce.workspace = true +serde = { workspace = true, optional = true } + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/path/LICENSE-APACHE b/crates/path/LICENSE-APACHE new file mode 120000 index 00000000000..1cd601d0a3a --- /dev/null +++ b/crates/path/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/path/src/abs_path.rs b/crates/path/src/abs_path.rs new file mode 100644 index 00000000000..65f5c2832c5 --- /dev/null +++ b/crates/path/src/abs_path.rs @@ -0,0 +1,286 @@ +use std::{ + borrow::{Borrow, Cow}, + fmt, io, + ops::Deref, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, +}; + +use anyhow::Context; + +use crate::{PathStyle, rel_path::RelPath}; + +// An absolute path on the user's local filesystem. +// Requires paths to be valid utf-8 +#[derive(PartialEq, Eq, Hash, Debug, PartialOrd, Ord)] +#[repr(transparent)] +pub struct AbsPath(Path); + +impl AbsPath { + pub fn new(path: &Path) -> anyhow::Result<&Self> { + if !path.is_absolute() { + return Err(anyhow::anyhow!("Path is not absolute: {:?}", path)); + } + if path.to_str().is_none() { + return Err(anyhow::anyhow!("Path is not valid utf-8: {:?}", path)); + } + Ok(Self::new_unchecked(path)) + } + + fn new_unchecked(path: &Path) -> &Self { + // SAFETY: `AbsPath` is a `repr(transparent)` wrapper around `Path`. + unsafe { &*(path as *const Path as *const Self) } + } + + pub fn to_abs_path_buf(&self) -> AbsPathBuf { + AbsPathBuf(self.0.to_owned()) + } + + pub fn join(&self, name: impl AsRef) -> AbsPathBuf { + AbsPathBuf(self.0.join(name.as_ref())) + } + + pub fn join_rel_path(&self, relative_path: &RelPath) -> AbsPathBuf { + AbsPathBuf(self.0.join(relative_path.as_std_path())) + } + + pub fn parent(&self) -> Option<&AbsPath> { + let parent = self.0.parent()?; + Some(AbsPath::new_unchecked(parent)) + } + + pub fn starts_with(&self, other: &AbsPath) -> bool { + self.0.starts_with(&other.0) + } + + pub fn ends_with(&self, other: &RelPath) -> bool { + self.0.ends_with(other.as_std_path()) + } + + pub fn is_descendant_of(&self, ancestor: &Self) -> bool { + if self == ancestor { + return false; + } + self.starts_with(ancestor) + } + + pub fn file_name(&self) -> Option<&str> { + self.0.file_name()?.to_str() + } + + pub fn display(&self) -> impl fmt::Display + '_ { + self.0.display() + } + + pub fn as_std_path(&self) -> &Path { + &self.0 + } + + pub fn as_str(&self) -> &str { + self.0 + .to_str() + .expect("valid UTF-8 enforced in constructor") + } + + pub fn ancestors(&self) -> impl Iterator { + self.0.ancestors().map(|p| AbsPath::new_unchecked(p)) + } + + pub fn strip_prefix<'a>(&'a self, prefix: &AbsPath) -> Option> { + let prefix = self.0.strip_prefix(&prefix.0).ok()?; + RelPath::new(prefix, PathStyle::local()).ok() + } +} + +impl ToOwned for AbsPath { + type Owned = AbsPathBuf; + + fn to_owned(&self) -> Self::Owned { + self.to_abs_path_buf() + } +} + +impl AsRef for AbsPath { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl AsRef for AbsPath { + fn as_ref(&self) -> &AbsPath { + self + } +} + +impl From<&AbsPath> for Arc { + fn from(path: &AbsPath) -> Self { + let arc: Arc = Arc::from(&path.0); + // SAFETY: `AbsPath` is a `repr(transparent)` wrapper around `Path`. + unsafe { Arc::from_raw(Arc::into_raw(arc) as *const AbsPath) } + } +} + +impl From<&AbsPath> for Rc { + fn from(path: &AbsPath) -> Self { + let arc: Rc = Rc::from(&path.0); + // SAFETY: `AbsPath` is a `repr(transparent)` wrapper around `Path`. + unsafe { Rc::from_raw(Rc::into_raw(arc) as *const AbsPath) } + } +} + +// An absolute path on the user's local filesystem. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AbsPathBuf(PathBuf); + +impl AbsPathBuf { + pub fn new(path: impl Into) -> anyhow::Result { + let path = path.into(); + if let Err(e) = AbsPath::new(&path) { + return Err(e); + } + Ok(Self(path)) + } + + pub fn home_dir() -> anyhow::Result { + let home = std::env::home_dir().context("no home dir available")?; + Self::new(home) + } + + /// Resolves `path` to its canonical on-disk spelling: symlinks and `..` + /// are resolved, relative input is anchored on the current directory, and + /// on case-insensitive filesystems each component takes the casing stored + /// on disk. Unlike [`std::fs::canonicalize`], the result never uses + /// Windows extended-length (`\\?\`) syntax, which chokes tools the path + /// is later handed to (e.g. `git`). + /// + /// Paths act as identity in several places (lock keys, watch-target + /// comparisons, persisted repository records), so canonicalize a path + /// where it enters the system whenever it may have been spelled by a user + /// or an external tool. + pub fn canonicalize(path: impl AsRef) -> io::Result { + let canonical = dunce::canonicalize(path.as_ref())?; + Self::new(canonical).map_err(io::Error::other) + } + + pub fn push(&mut self, name: &str) { + self.0.push(name); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn new_test(path: &'static str) -> Self { + if cfg!(windows) { + Self::new(format!("C:{path}")).unwrap() + } else { + Self::new(path).unwrap() + } + } +} + +#[cfg(any(test, feature = "test-support"))] +pub fn abs_path(path: &str) -> AbsPathBuf { + if cfg!(windows) { + AbsPathBuf::new(format!("C:{path}")).unwrap() + } else { + AbsPathBuf::new(path).unwrap() + } +} + +impl Deref for AbsPathBuf { + type Target = AbsPath; + + fn deref(&self) -> &Self::Target { + AbsPath::new_unchecked(&self.0) + } +} + +impl Borrow for AbsPathBuf { + fn borrow(&self) -> &AbsPath { + self + } +} + +impl AsRef for AbsPathBuf { + fn as_ref(&self) -> &Path { + self.0.as_ref() + } +} + +impl AsRef for AbsPathBuf { + fn as_ref(&self) -> &AbsPath { + self + } +} + +impl From for PathBuf { + fn from(path: AbsPathBuf) -> PathBuf { + path.0 + } +} + +impl fmt::Display for AbsPathBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.as_str().fmt(f) + } +} + +impl PartialEq for AbsPathBuf { + fn eq(&self, other: &AbsPath) -> bool { + **self == *other + } +} + +impl PartialEq for AbsPath { + fn eq(&self, other: &AbsPathBuf) -> bool { + *self == **other + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_canonicalize_restores_on_disk_spelling() { + let temp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(temp.path()).unwrap(); + let dir = root.join("CamelCase"); + std::fs::create_dir(&dir).unwrap(); + + let canonical = AbsPathBuf::canonicalize(&dir).unwrap(); + assert_eq!(canonical.as_std_path(), dir); + assert!( + !canonical.as_str().starts_with(r"\\?\"), + "canonical paths must not use Windows extended-length syntax: {canonical}" + ); + + // A differently-cased spelling addresses the same directory only on a + // case-insensitive filesystem; when it does, canonicalization must + // restore the stored casing. + let lowercased = root.join("camelcase"); + if std::fs::metadata(&lowercased).is_ok() { + assert_eq!( + AbsPathBuf::canonicalize(&lowercased).unwrap().as_std_path(), + dir, + "canonicalization should restore the on-disk casing" + ); + } + } + + #[test] + fn test_new_test_normalizes_rooted_paths() { + if cfg!(windows) { + assert_eq!(AbsPathBuf::new_test("/").as_str(), "C:/"); + assert_eq!( + AbsPathBuf::new_test("/test/project").as_str(), + "C:/test/project" + ); + } else { + assert_eq!(AbsPathBuf::new_test("/").as_str(), "/"); + assert_eq!( + AbsPathBuf::new_test("/test/project").as_str(), + "/test/project" + ); + } + } +} diff --git a/crates/path/src/path.rs b/crates/path/src/path.rs new file mode 100644 index 00000000000..c6a063264a3 --- /dev/null +++ b/crates/path/src/path.rs @@ -0,0 +1,263 @@ +//! Relative path types for deltadb. +//! +//! Provides [`RelPath`] and [`RelPathBuf`] — path types that are guaranteed to be +//! relative, normalized, and valid unicode. Internally stored in POSIX (`/`-delimited) +//! format regardless of host platform. +//! +//! Adapted from Zed's `util::rel_path` module. + +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; + +use crate::rel_path::RelPath; + +pub mod abs_path; +pub mod rel_path; + +pub trait PathExt { + fn to_rel_path_buf(&self) -> anyhow::Result; +} + +impl + ?Sized> PathExt for T { + fn to_rel_path_buf(&self) -> anyhow::Result { + Ok(RelPath::new(self.as_ref(), PathStyle::local())?.into_owned()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PathStyle { + Unix, + Windows, +} + +impl PathStyle { + #[cfg(target_os = "windows")] + pub const fn local() -> Self { + PathStyle::Windows + } + + #[cfg(not(target_os = "windows"))] + pub const fn local() -> Self { + PathStyle::Unix + } + + #[inline] + pub fn primary_separator(&self) -> &'static str { + match self { + PathStyle::Unix => "/", + PathStyle::Windows => "\\", + } + } + + pub fn separators(&self) -> &'static [&'static str] { + match self { + PathStyle::Unix => &["/"], + PathStyle::Windows => &["\\", "/"], + } + } + + pub fn separators_ch(&self) -> &'static [char] { + match self { + PathStyle::Unix => &['/'], + PathStyle::Windows => &['\\', '/'], + } + } + + pub fn is_absolute(&self, path_like: &str) -> bool { + path_like.starts_with('/') + || *self == PathStyle::Windows + && (path_like.starts_with('\\') + || path_like + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic()) + && path_like[1..] + .strip_prefix(':') + .is_some_and(|path| path.starts_with('/') || path.starts_with('\\'))) + } + + pub fn is_windows(&self) -> bool { + *self == PathStyle::Windows + } + + pub fn is_posix(&self) -> bool { + *self == PathStyle::Unix + } + + pub fn join(self, left: impl AsRef, right: impl AsRef) -> Option { + let right = right.as_ref().to_str()?; + if is_absolute(right, self) { + return None; + } + let left = left.as_ref().to_str()?; + if left.is_empty() { + Some(right.into()) + } else { + Some(format!( + "{left}{}{right}", + if left.ends_with(self.primary_separator()) { + "" + } else { + self.primary_separator() + } + )) + } + } + + pub fn join_path( + self, + left: impl AsRef, + right: impl AsRef, + ) -> anyhow::Result { + let left = left + .as_ref() + .to_str() + .ok_or_else(|| anyhow::anyhow!("Path contains invalid UTF-8"))?; + let right = right.as_ref(); + let right_string = right + .to_str() + .ok_or_else(|| anyhow::anyhow!("Path contains invalid UTF-8"))?; + let joined = self + .join(left, right_string) + .ok_or_else(|| anyhow::anyhow!("Path must be relative: {right:?}"))?; + Ok(PathBuf::from(self.normalize(&joined))) + } + + pub fn normalize(self, path_like: &str) -> String { + match self { + PathStyle::Windows => crate::normalize_path(Path::new(path_like)) + .to_string_lossy() + .into_owned(), + PathStyle::Unix => { + let is_absolute = path_like.starts_with('/'); + let remainder = if is_absolute { + path_like.trim_start_matches('/') + } else { + path_like + }; + + let mut components = Vec::new(); + for component in remainder.split(self.separators_ch()) { + match component { + "" | "." => {} + ".." => { + if components + .last() + .is_some_and(|component| *component != "..") + { + components.pop(); + } else if !is_absolute { + components.push(component); + } + } + component => components.push(component), + } + } + + let normalized = components.join(self.primary_separator()); + if is_absolute && normalized.is_empty() { + "/".to_string() + } else if is_absolute { + format!("/{normalized}") + } else { + normalized + } + } + } + } + + pub fn split(self, path_like: &str) -> (Option<&str>, &str) { + let Some(pos) = path_like.rfind(self.primary_separator()) else { + return (None, path_like); + }; + let filename_start = pos + self.primary_separator().len(); + ( + Some(&path_like[..filename_start]), + &path_like[filename_start..], + ) + } + + pub fn strip_prefix<'a>( + &self, + child: &'a Path, + parent: &'a Path, + ) -> Option> { + let parent = parent.to_str()?; + if parent.is_empty() { + return RelPath::new(child, *self).ok(); + } + let parent = self + .separators() + .iter() + .find_map(|sep| parent.strip_suffix(sep)) + .unwrap_or(parent); + let child = child.to_str()?; + + // Match behavior of std::path::Path, which is case-insensitive for drive letters (e.g., "C:" == "c:") + let stripped = if self.is_windows() + && child.as_bytes().get(1) == Some(&b':') + && parent.as_bytes().get(1) == Some(&b':') + && child.as_bytes()[0].eq_ignore_ascii_case(&parent.as_bytes()[0]) + { + child[2..].strip_prefix(&parent[2..])? + } else { + child.strip_prefix(parent)? + }; + if let Some(relative) = self + .separators() + .iter() + .find_map(|sep| stripped.strip_prefix(sep)) + { + RelPath::new(relative.as_ref(), *self).ok() + } else if stripped.is_empty() { + Some(Cow::Borrowed(RelPath::empty())) + } else { + None + } + } +} + +fn is_absolute(path_like: &str, path_style: PathStyle) -> bool { + path_like.starts_with('/') + || path_style == PathStyle::Windows + && (path_like.starts_with('\\') + || path_like + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic()) + && path_like[1..] + .strip_prefix(':') + .is_some_and(|path| path.starts_with('/') || path.starts_with('\\'))) +} + +/// Normalizes a path by resolving `.` and `..` components without +/// requiring the path to exist on disk (unlike `canonicalize`). +pub fn normalize_path(path: &Path) -> PathBuf { + use std::path::Component; + let mut components = path.components().peekable(); + let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() { + components.next(); + PathBuf::from(c.as_os_str()) + } else { + PathBuf::new() + }; + + for component in components { + match component { + Component::Prefix(..) => unreachable!(), + Component::RootDir => { + ret.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + ret.pop(); + } + Component::Normal(c) => { + ret.push(c); + } + } + } + ret +} diff --git a/crates/util/src/rel_path.rs b/crates/path/src/rel_path.rs similarity index 62% rename from crates/util/src/rel_path.rs rename to crates/path/src/rel_path.rs index 382a4bad37b..357fcb82056 100644 --- a/crates/util/src/rel_path.rs +++ b/crates/path/src/rel_path.rs @@ -1,6 +1,4 @@ -use crate::paths::{PathStyle, is_absolute}; use anyhow::{Context as _, Result, anyhow}; -use serde::{Deserialize, Serialize}; use std::{ borrow::{Borrow, Cow}, fmt, @@ -9,6 +7,12 @@ use std::{ sync::Arc, }; +use crate::{ + PathStyle, + abs_path::{AbsPath, AbsPathBuf}, + is_absolute, +}; + /// A file system path that is guaranteed to be relative and normalized. /// /// This type can be used to represent paths in a uniform way, regardless of @@ -20,20 +24,22 @@ use std::{ /// /// Relative paths are also guaranteed to be valid unicode. #[repr(transparent)] -#[derive(PartialEq, Eq, Hash, Serialize)] +#[derive(PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] pub struct RelPath(str); /// An owned representation of a file system path that is guaranteed to be /// relative and normalized. /// /// This type is to [`RelPath`] as [`std::path::PathBuf`] is to [`std::path::Path`] -#[derive(PartialEq, Eq, Clone, Ord, PartialOrd, Serialize)] +#[derive(PartialEq, Eq, Clone, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] pub struct RelPathBuf(String); impl RelPath { /// Creates an empty [`RelPath`]. pub fn empty() -> &'static Self { - Self::new_unchecked("") + Self::from_str("") } /// Creates an empty [`RelPath`]. @@ -54,7 +60,7 @@ impl RelPath { let mut path = path.to_str().context("non utf-8 path")?; let (prefixes, suffixes): (&[_], &[_]) = match path_style { - PathStyle::Posix => (&["./"], &['/']), + PathStyle::Unix => (&["./"], &['/']), PathStyle::Windows => (&["./", ".\\"], &['/', '\\']), }; @@ -77,7 +83,7 @@ impl RelPath { } let mut result = match string { - Cow::Borrowed(string) => Cow::Borrowed(Self::new_unchecked(string)), + Cow::Borrowed(string) => Cow::Borrowed(Self::from_str(string)), Cow::Owned(string) => Cow::Owned(RelPathBuf(string)), }; @@ -95,7 +101,7 @@ impl RelPath { return Err(anyhow!("path is not relative: {result:?}")); } } - other => normalized.push(RelPath::new_unchecked(other)), + other => normalized.push(RelPath::from_str(other)), } } result = Cow::Owned(normalized) @@ -104,20 +110,25 @@ impl RelPath { Ok(result) } + #[track_caller] + pub fn new_test<'a>(path: &'a str) -> Cow<'a, Self> { + Self::new(Path::new(path), PathStyle::Unix).unwrap() + } + /// Converts a path that is already normalized and uses '/' separators /// into a [`RelPath`] . /// /// Returns an error if the path is not already in the correct format. #[track_caller] - pub fn unix + ?Sized>(path: &S) -> anyhow::Result<&Self> { + pub fn from_unix_str + ?Sized>(path: &S) -> anyhow::Result<&Self> { let path = path.as_ref(); - match Self::new(path, PathStyle::Posix)? { + match Self::new(path, PathStyle::Unix)? { Cow::Borrowed(path) => Ok(path), Cow::Owned(_) => Err(anyhow!("invalid relative path {path:?}")), } } - fn new_unchecked(s: &str) -> &Self { + fn from_str(s: &str) -> &Self { // Safety: `RelPath` is a transparent wrapper around `str`. unsafe { &*(s as *const str as *const Self) } } @@ -131,7 +142,12 @@ impl RelPath { } pub fn ancestors(&self) -> RelPathAncestors<'_> { - RelPathAncestors(Some(&self.0)) + RelPathAncestors { + full: &self.0, + front: self.0.len(), + back: 0, + done: false, + } } pub fn file_name(&self) -> Option<&str> { @@ -156,7 +172,22 @@ impl RelPath { self.strip_prefix(other).is_ok() } + /// Returns true if this path is a strict descendant of `ancestor`. + /// + /// Unlike `starts_with`, this returns false when `self == ancestor` + /// and false when `ancestor` is empty (since every path trivially + /// starts with the empty prefix). + pub fn is_descendant_of(&self, ancestor: &Self) -> bool { + if ancestor.is_empty() || self == ancestor { + return false; + } + self.starts_with(ancestor) + } + pub fn ends_with(&self, other: &Self) -> bool { + if other.is_empty() { + return true; + } if let Some(suffix) = self.0.strip_suffix(&other.0) { if suffix.ends_with('/') { return true; @@ -173,7 +204,7 @@ impl RelPath { } if let Some(suffix) = self.0.strip_prefix(&other.0) { if let Some(suffix) = suffix.strip_prefix('/') { - return Ok(Self::new_unchecked(suffix)); + return Ok(Self::from_str(suffix)); } else if suffix.is_empty() { return Ok(Self::empty()); } @@ -182,7 +213,11 @@ impl RelPath { } pub fn len(&self) -> usize { - self.0.matches('/').count() + 1 + if self.0.is_empty() { + 0 + } else { + self.0.matches('/').count() + 1 + } } pub fn last_n_components(&self, count: usize) -> Option<&Self> { @@ -198,15 +233,14 @@ impl RelPath { } } - pub fn join(&self, other: &Self) -> Arc { - let result = if self.0.is_empty() { - Cow::Borrowed(&other.0) + pub fn join(&self, other: &Self) -> RelPathBuf { + if self.0.is_empty() { + other.to_rel_path_buf() } else if other.0.is_empty() { - Cow::Borrowed(&self.0) + self.to_rel_path_buf() } else { - Cow::Owned(format!("{}/{}", &self.0, &other.0)) - }; - Arc::from(Self::new_unchecked(result.as_ref())) + RelPathBuf(format!("{}/{}", &self.0, &other.0)) + } } pub fn to_rel_path_buf(&self) -> RelPathBuf { @@ -217,23 +251,13 @@ impl RelPath { Arc::from(self) } - /// Convert the path into the wire representation. - pub fn to_proto(&self) -> String { - self.as_unix_str().to_owned() - } - - /// Load the path from its wire representation. - pub fn from_proto(path: &str) -> Result> { - Ok(Arc::from(Self::unix(path)?)) - } - /// Convert the path into a string with the given path style. /// /// Whenever a path is presented to the user, it should be converted to /// a string via this method. pub fn display(&self, style: PathStyle) -> Cow<'_, str> { match style { - PathStyle::Posix => Cow::Borrowed(&self.0), + PathStyle::Unix => Cow::Borrowed(&self.0), PathStyle::Windows if self.0.contains('/') => Cow::Owned(self.0.replace('/', "\\")), PathStyle::Windows => Cow::Borrowed(&self.0), } @@ -255,19 +279,16 @@ impl RelPath { pub fn as_std_path(&self) -> &Path { Path::new(&self.0) } + + /// Resolves this relative path against an absolute base path. + pub fn absolutize(&self, base: impl AsRef) -> AbsPathBuf { + base.as_ref().join(self.as_unix_str()) + } } #[derive(Debug)] pub struct StripPrefixError; -impl std::fmt::Display for StripPrefixError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("prefix not found") - } -} - -impl std::error::Error for StripPrefixError {} - impl ToOwned for RelPath { type Owned = RelPathBuf; @@ -324,14 +345,33 @@ impl RelPathBuf { } pub fn push(&mut self, path: &RelPath) { + if path.is_empty() { + return; + } if !self.is_empty() { self.0.push('/'); } self.0.push_str(&path.0); } + pub fn push_component(&mut self, component: &str) -> Result<()> { + anyhow::ensure!( + !component.is_empty() + && !component.contains('/') + && component != "." + && component != "..", + "invalid relative path component: {component:?}" + ); + + if !self.is_empty() { + self.0.push('/'); + } + self.0.push_str(component); + Ok(()) + } + pub fn as_rel_path(&self) -> &RelPath { - RelPath::new_unchecked(self.0.as_str()) + RelPath::from_str(self.0.as_str()) } pub fn set_extension(&mut self, extension: &str) -> bool { @@ -339,7 +379,7 @@ impl RelPathBuf { let mut filename = PathBuf::from(filename); filename.set_extension(extension); self.pop(); - self.0.push_str(filename.to_str().unwrap()); + self.push(RelPath::from_str(filename.to_str().unwrap())); true } else { false @@ -347,33 +387,21 @@ impl RelPathBuf { } } -impl<'de> Deserialize<'de> for RelPathBuf { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - let path = String::deserialize(deserializer)?; - let rel_path = - RelPath::new(Path::new(&path), PathStyle::local()).map_err(serde::de::Error::custom)?; - Ok(rel_path.into_owned()) +impl PartialOrd for RelPathBuf { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) } } -impl Into> for RelPathBuf { - fn into(self) -> Arc { - Arc::from(self.as_rel_path()) +impl Ord for RelPathBuf { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.as_rel_path().cmp(other.as_rel_path()) } } -impl AsRef for RelPathBuf { - fn as_ref(&self) -> &Path { - self.as_std_path() - } -} - -impl AsRef for RelPath { - fn as_ref(&self) -> &Path { - self.as_std_path() +impl From for Arc { + fn from(value: RelPathBuf) -> Self { + Arc::from(value.as_rel_path()) } } @@ -383,12 +411,24 @@ impl AsRef for RelPathBuf { } } +impl AsRef for RelPathBuf { + fn as_ref(&self) -> &Path { + self.as_std_path() + } +} + impl AsRef for RelPath { fn as_ref(&self) -> &RelPath { self } } +impl AsRef for RelPath { + fn as_ref(&self) -> &Path { + self.as_std_path() + } +} + impl Deref for RelPathBuf { type Target = RelPath; @@ -406,20 +446,37 @@ impl<'a> From<&'a RelPath> for Cow<'a, RelPath> { impl From<&RelPath> for Arc { fn from(rel_path: &RelPath) -> Self { let bytes: Arc = Arc::from(&rel_path.0); + // SAFETY: `AbsPath` is a `repr(transparent)` wrapper around `Path`. unsafe { Arc::from_raw(Arc::into_raw(bytes) as *const RelPath) } } } +impl<'a> TryFrom<&'a str> for &'a RelPath { + type Error = anyhow::Error; + + fn try_from(s: &'a str) -> Result { + RelPath::from_unix_str(s) + } +} + +impl TryFrom<&str> for RelPathBuf { + type Error = anyhow::Error; + + fn try_from(s: &str) -> Result { + RelPath::new(Path::new(s), PathStyle::Unix).map(|cow| cow.into_owned()) + } +} + #[cfg(any(test, feature = "test-support"))] #[track_caller] pub fn rel_path(path: &str) -> &RelPath { - RelPath::unix(path).unwrap() + RelPath::from_unix_str(path).unwrap() } #[cfg(any(test, feature = "test-support"))] #[track_caller] pub fn rel_path_buf(path: &str) -> RelPathBuf { - RelPath::unix(path).unwrap().to_rel_path_buf() + rel_path(path).to_owned() } impl PartialEq for RelPath { @@ -428,26 +485,45 @@ impl PartialEq for RelPath { } } -pub trait PathExt { - fn to_rel_path_buf(&self) -> Result; +impl PartialEq for RelPathBuf { + fn eq(&self, other: &RelPath) -> bool { + self.as_rel_path() == other + } } -impl + ?Sized> PathExt for T { - fn to_rel_path_buf(&self) -> Result { - Ok(RelPath::new(self.as_ref(), PathStyle::local())?.into_owned()) +impl PartialEq for RelPath { + fn eq(&self, other: &RelPathBuf) -> bool { + other.as_rel_path() == self + } +} + +impl fmt::Display for RelPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl fmt::Display for RelPathBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) } } #[derive(Default)] pub struct RelPathComponents<'a>(&'a str); -pub struct RelPathAncestors<'a>(Option<&'a str>); +pub struct RelPathAncestors<'a> { + full: &'a str, + front: usize, + back: usize, + done: bool, +} const SEPARATOR: char = '/'; impl<'a> RelPathComponents<'a> { pub fn rest(&self) -> &'a RelPath { - RelPath::new_unchecked(self.0) + RelPath::from_str(self.0) } } @@ -473,15 +549,35 @@ impl<'a> Iterator for RelPathAncestors<'a> { type Item = &'a RelPath; fn next(&mut self) -> Option { - let result = self.0?; - if let Some(sep_ix) = result.rfind(SEPARATOR) { - self.0 = Some(&result[..sep_ix]); - } else if !result.is_empty() { - self.0 = Some(""); - } else { - self.0 = None; + if self.done { + return None; } - Some(RelPath::new_unchecked(result)) + let result = &self.full[..self.front]; + if self.front == self.back { + self.done = true; + } else { + self.front = result.rfind(SEPARATOR).unwrap_or_default(); + } + Some(RelPath::from_str(result)) + } +} + +impl<'a> DoubleEndedIterator for RelPathAncestors<'a> { + fn next_back(&mut self) -> Option { + if self.done { + return None; + } + let result = &self.full[..self.back]; + if self.front == self.back { + self.done = true; + } else { + let search_start = if self.back == 0 { 0 } else { self.back + 1 }; + self.back = match self.full[search_start..].find(SEPARATOR) { + Some(sep_ix) => search_start + sep_ix, + None => self.full.len(), + }; + } + Some(RelPath::from_str(result)) } } @@ -501,11 +597,21 @@ impl<'a> DoubleEndedIterator for RelPathComponents<'a> { } } +impl<'de> serde::Deserialize<'de> for RelPathBuf { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let path = String::deserialize(deserializer)?; + let rel_path = + RelPath::new(Path::new(&path), PathStyle::local()).map_err(serde::de::Error::custom)?; + Ok(rel_path.into_owned()) + } +} + #[cfg(test)] mod tests { use super::*; - use itertools::Itertools; - use pretty_assertions::assert_matches; #[test] fn test_rel_path_new() { @@ -515,11 +621,11 @@ mod tests { let path = RelPath::new("foo/".as_ref(), PathStyle::local()).unwrap(); assert_eq!(path, rel_path("foo").into()); - assert_matches!(path, Cow::Borrowed(_)); + assert!(matches!(path, Cow::Borrowed(_))); let path = RelPath::new("foo\\".as_ref(), PathStyle::Windows).unwrap(); assert_eq!(path, rel_path("foo").into()); - assert_matches!(path, Cow::Borrowed(_)); + assert!(matches!(path, Cow::Borrowed(_))); assert_eq!( RelPath::new("foo/bar/../baz/./quux/".as_ref(), PathStyle::local()) @@ -528,29 +634,29 @@ mod tests { rel_path("foo/baz/quux") ); - let path = RelPath::new("./foo/bar".as_ref(), PathStyle::Posix).unwrap(); + let path = RelPath::new("./foo/bar".as_ref(), PathStyle::Unix).unwrap(); assert_eq!(path.as_ref(), rel_path("foo/bar")); - assert_matches!(path, Cow::Borrowed(_)); + assert!(matches!(path, Cow::Borrowed(_))); let path = RelPath::new(".\\foo".as_ref(), PathStyle::Windows).unwrap(); assert_eq!(path, rel_path("foo").into()); - assert_matches!(path, Cow::Borrowed(_)); + assert!(matches!(path, Cow::Borrowed(_))); let path = RelPath::new("./.\\./foo/\\/".as_ref(), PathStyle::Windows).unwrap(); assert_eq!(path, rel_path("foo").into()); - assert_matches!(path, Cow::Borrowed(_)); + assert!(matches!(path, Cow::Borrowed(_))); - let path = RelPath::new("foo/./bar".as_ref(), PathStyle::Posix).unwrap(); + let path = RelPath::new("foo/./bar".as_ref(), PathStyle::Unix).unwrap(); assert_eq!(path.as_ref(), rel_path("foo/bar")); - assert_matches!(path, Cow::Owned(_)); + assert!(matches!(path, Cow::Owned(_))); let path = RelPath::new("./foo/bar".as_ref(), PathStyle::Windows).unwrap(); assert_eq!(path.as_ref(), rel_path("foo/bar")); - assert_matches!(path, Cow::Borrowed(_)); + assert!(matches!(path, Cow::Borrowed(_))); let path = RelPath::new(".\\foo\\bar".as_ref(), PathStyle::Windows).unwrap(); assert_eq!(path.as_ref(), rel_path("foo/bar")); - assert_matches!(path, Cow::Owned(_)); + assert!(matches!(path, Cow::Owned(_))); } #[test] @@ -590,6 +696,41 @@ mod tests { let mut ancestors = path.ancestors(); assert_eq!(ancestors.next(), Some(RelPath::empty())); assert_eq!(ancestors.next(), None); + + let path = rel_path("foo/bar/baz"); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next_back(), Some(rel_path(""))); + assert_eq!(ancestors.next_back(), Some(rel_path("foo"))); + assert_eq!(ancestors.next_back(), Some(rel_path("foo/bar"))); + assert_eq!(ancestors.next_back(), Some(rel_path("foo/bar/baz"))); + assert_eq!(ancestors.next_back(), None); + + let path = rel_path("foo/bar/baz"); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next(), Some(rel_path("foo/bar/baz"))); + assert_eq!(ancestors.next_back(), Some(rel_path(""))); + assert_eq!(ancestors.next(), Some(rel_path("foo/bar"))); + assert_eq!(ancestors.next_back(), Some(rel_path("foo"))); + assert_eq!(ancestors.next(), None); + assert_eq!(ancestors.next_back(), None); + + let path = rel_path("foo"); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next_back(), Some(RelPath::empty())); + assert_eq!(ancestors.next_back(), Some(rel_path("foo"))); + assert_eq!(ancestors.next_back(), None); + + let path = RelPath::empty(); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next_back(), Some(RelPath::empty())); + assert_eq!(ancestors.next_back(), None); + + let path = rel_path("über/x"); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next_back(), Some(RelPath::empty())); + assert_eq!(ancestors.next_back(), Some(rel_path("über"))); + assert_eq!(ancestors.next_back(), Some(rel_path("über/x"))); + assert_eq!(ancestors.next_back(), None); } #[test] @@ -602,13 +743,18 @@ mod tests { #[test] fn test_rel_path_partial_ord_is_compatible_with_std() { let test_cases = ["a/b/c", "relative/path/with/dot.", "relative/path/with.dot"]; - for [lhs, rhs] in test_cases.iter().array_combinations::<2>() { - assert_eq!( - Path::new(lhs).cmp(Path::new(rhs)), - RelPath::unix(lhs) - .unwrap() - .cmp(&RelPath::unix(rhs).unwrap()) - ); + for (i, lhs) in test_cases.iter().enumerate() { + for rhs in &test_cases[i + 1..] { + assert_eq!( + Path::new(lhs).cmp(Path::new(rhs)), + RelPath::from_unix_str(lhs) + .unwrap() + .cmp(RelPath::from_unix_str(rhs).unwrap()), + "ordering mismatch for {:?} vs {:?}", + lhs, + rhs, + ); + } } } @@ -621,19 +767,28 @@ mod tests { assert_eq!(child.strip_prefix(parent).unwrap(), child); } + #[test] + fn test_ends_with() { + assert!(rel_path("foo/bar").ends_with(rel_path("bar"))); + assert!(rel_path("foo/bar").ends_with(rel_path("foo/bar"))); + assert!(rel_path("foo/bar").ends_with(RelPath::empty())); + assert!(RelPath::empty().ends_with(RelPath::empty())); + assert!(!rel_path("foobar").ends_with(rel_path("bar"))); + } + #[test] fn test_rel_path_constructors_absolute_path() { assert!(RelPath::new(Path::new("/a/b"), PathStyle::Windows).is_err()); assert!(RelPath::new(Path::new("\\a\\b"), PathStyle::Windows).is_err()); - assert!(RelPath::new(Path::new("/a/b"), PathStyle::Posix).is_err()); + assert!(RelPath::new(Path::new("/a/b"), PathStyle::Unix).is_err()); assert!(RelPath::new(Path::new("C:/a/b"), PathStyle::Windows).is_err()); assert!(RelPath::new(Path::new("C:\\a\\b"), PathStyle::Windows).is_err()); - assert!(RelPath::new(Path::new("C:/a/b"), PathStyle::Posix).is_ok()); + assert!(RelPath::new(Path::new("C:/a/b"), PathStyle::Unix).is_ok()); } #[test] fn test_pop() { - let mut path = rel_path("a/b").to_rel_path_buf(); + let mut path = rel_path_buf("a/b"); path.pop(); assert_eq!(path.as_rel_path().as_unix_str(), "a"); path.pop(); @@ -641,4 +796,27 @@ mod tests { path.pop(); assert_eq!(path.as_rel_path().as_unix_str(), ""); } + + #[test] + fn test_len() { + assert_eq!(RelPath::empty().len(), 0); + assert_eq!(rel_path("a").len(), 1); + assert_eq!(rel_path("a/b").len(), 2); + assert_eq!(rel_path("a/b/c").len(), 3); + } + + #[test] + fn test_set_extension() { + let mut path = rel_path_buf("a/b/c.txt"); + assert!(path.set_extension("rs")); + assert_eq!(path.as_rel_path().as_unix_str(), "a/b/c.rs"); + + let mut single = rel_path_buf("file.txt"); + assert!(single.set_extension("md")); + assert_eq!(single.as_rel_path().as_unix_str(), "file.md"); + + let mut no_ext = rel_path_buf("a/b/c"); + assert!(no_ext.set_extension("rs")); + assert_eq!(no_ext.as_rel_path().as_unix_str(), "a/b/c.rs"); + } } diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index d222bd1c0cd..9c16f1db289 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -68,7 +68,7 @@ static CONFIG_DIR: OnceLock = OnceLock::new(); /// Returns the relative path to the zed_server directory on the ssh host. pub fn remote_server_dir_relative() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".zed_server").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".zed_server").unwrap()); *CACHED } @@ -76,7 +76,7 @@ pub fn remote_server_dir_relative() -> &'static RelPath { /// Returns the relative path to the zed_wsl_server directory on the wsl host. pub fn remote_wsl_server_dir_relative() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".zed_wsl_server").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".zed_wsl_server").unwrap()); *CACHED } @@ -496,21 +496,21 @@ pub fn local_vscode_folder_name() -> &'static str { /// Returns the relative path to a `settings.json` file within a project. pub fn local_settings_file_relative_path() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".zed/settings.json").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".zed/settings.json").unwrap()); *CACHED } /// Returns the relative path to a `tasks.json` file within a project. pub fn local_tasks_file_relative_path() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".zed/tasks.json").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".zed/tasks.json").unwrap()); *CACHED } /// Returns the relative path to a `.vscode/tasks.json` file within a project. pub fn local_vscode_tasks_file_relative_path() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".vscode/tasks.json").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".vscode/tasks.json").unwrap()); *CACHED } @@ -526,14 +526,14 @@ pub fn task_file_name() -> &'static str { /// .zed/debug.json pub fn local_debug_file_relative_path() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".zed/debug.json").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".zed/debug.json").unwrap()); *CACHED } /// Returns the relative path to a `.vscode/launch.json` file within a project. pub fn local_vscode_launch_file_relative_path() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".vscode/launch.json").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".vscode/launch.json").unwrap()); *CACHED } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 5fd388ddaf8..b3ee5a88a22 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -37,8 +37,8 @@ test-support = [ aho-corasick.workspace = true anyhow.workspace = true askpass.workspace = true -async-trait.workspace = true async-channel.workspace = true +async-trait.workspace = true base64.workspace = true buffer_diff.workspace = true circular-buffer.workspace = true @@ -60,15 +60,17 @@ globset.workspace = true gpui.workspace = true http_client = { workspace = true, features = ["github-download"] } image.workspace = true -itertools.workspace = true indexmap.workspace = true +itertools.workspace = true language.workspace = true log.workspace = true lsp.workspace = true markdown.workspace = true node_runtime.workspace = true parking_lot.workspace = true +path.workspace = true paths.workspace = true +percent-encoding.workspace = true postage.workspace = true prettier.workspace = true rand.workspace = true @@ -93,8 +95,8 @@ tempfile.workspace = true terminal.workspace = true text.workspace = true toml.workspace = true +tracing.workspace = true url.workspace = true -percent-encoding.workspace = true util.workspace = true watch.workspace = true wax.workspace = true @@ -104,7 +106,6 @@ zed_credentials_provider.workspace = true zeroize.workspace = true zlog.workspace = true ztracing.workspace = true -tracing.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index 816cf97c865..9fa7c324dff 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -312,7 +312,7 @@ impl RemoteBufferStore { .request(proto::OpenBufferByPath { project_id, worktree_id, - path: path.to_proto(), + path: path.as_unix_str().to_owned(), }) .await?; let buffer_id = BufferId::new(response.buffer_id)?; diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 8594904a17d..26145b4a5c6 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -254,7 +254,7 @@ impl language::File for IndexTextFile { rpc::proto::File { worktree_id: self.worktree_id.to_proto(), entry_id: None, - path: self.path.as_ref().to_proto(), + path: self.path.as_ref().as_unix_str().to_owned(), mtime: None, is_deleted: false, is_historic: true, @@ -336,7 +336,7 @@ impl StatusEntry { }; proto::StatusEntry { - repo_path: self.repo_path.to_proto(), + repo_path: self.repo_path.as_unix_str().to_owned(), simple_status, status: Some(status_to_proto(self.status)), diff_stat_added: self.diff_stat.map(|ds| ds.added), @@ -3825,7 +3825,7 @@ impl GitStore { .files .into_iter() .map(|file| proto::CommitFile { - path: file.path.to_proto(), + path: file.path.as_unix_str().to_owned(), old_text: file.old_text, new_text: file.new_text, is_binary: file.is_binary, @@ -4023,7 +4023,7 @@ impl GitStore { .entries .into_iter() .map(|(path, status)| proto::TreeDiffStatus { - path: path.as_ref().to_proto(), + path: path.as_ref().as_unix_str().to_owned(), status: match status { TreeDiffStatus::Added {} => proto::tree_diff_status::Status::Added.into(), TreeDiffStatus::Modified { .. } => { @@ -5109,7 +5109,7 @@ impl RepositorySnapshot { .merge .merge_heads_by_conflicted_path .iter() - .map(|(repo_path, _)| repo_path.to_proto()) + .map(|(repo_path, _)| repo_path.as_unix_str().to_owned()) .collect(), merge_message: self.merge.message.as_ref().map(|msg| msg.to_string()), project_id, @@ -5165,13 +5165,13 @@ impl RepositorySnapshot { current_new_entry = new_statuses.next(); } Ordering::Greater => { - removed_statuses.push(old_entry.repo_path.to_proto()); + removed_statuses.push(old_entry.repo_path.as_unix_str().to_owned()); current_old_entry = old_statuses.next(); } } } (None, Some(old_entry)) => { - removed_statuses.push(old_entry.repo_path.to_proto()); + removed_statuses.push(old_entry.repo_path.as_unix_str().to_owned()); current_old_entry = old_statuses.next(); } (Some(new_entry), None) => { @@ -5196,7 +5196,7 @@ impl RepositorySnapshot { .merge .merge_heads_by_conflicted_path .iter() - .map(|(path, _)| path.to_proto()) + .map(|(path, _)| path.as_unix_str().to_owned()) .collect(), merge_message: self.merge.message.as_ref().map(|msg| msg.to_string()), project_id, @@ -6051,7 +6051,7 @@ impl Repository { commit, paths: paths .into_iter() - .map(|p| p.to_proto()) + .map(|p| p.as_unix_str().to_owned()) .collect(), }) .await?; @@ -7013,7 +7013,9 @@ impl Repository { repository_id: id.to_proto(), paths: entries .into_iter() - .map(|repo_path| repo_path.to_proto()) + .map(|repo_path| { + repo_path.as_unix_str().to_owned() + }) .collect(), }) .await @@ -7026,7 +7028,9 @@ impl Repository { repository_id: id.to_proto(), paths: entries .into_iter() - .map(|repo_path| repo_path.to_proto()) + .map(|repo_path| { + repo_path.as_unix_str().to_owned() + }) .collect(), }) .await @@ -7157,7 +7161,7 @@ impl Repository { repository_id: id.to_proto(), paths: entries .into_iter() - .map(|repo_path| repo_path.to_proto()) + .map(|repo_path| repo_path.as_unix_str().to_owned()) .collect(), }) .await?; @@ -7245,7 +7249,7 @@ impl Repository { is_dir: bool, ) -> oneshot::Receiver> { let work_dir = self.snapshot.work_directory_abs_path.clone(); - let path_display = repo_path.as_ref().display(PathStyle::Posix); + let path_display = repo_path.as_ref().display(PathStyle::Unix); let file_path_str = if is_dir { format!("{}/", path_display) } else { @@ -7279,7 +7283,7 @@ impl Repository { is_dir: bool, ) -> oneshot::Receiver> { let repository_dir = self.snapshot.repository_dir_abs_path.clone(); - let path_display = repo_path.as_ref().display(PathStyle::Posix); + let path_display = repo_path.as_ref().display(PathStyle::Unix); let file_path_str = if is_dir { format!("{}/", path_display) } else { @@ -7722,7 +7726,7 @@ impl Repository { .request(proto::SetIndexText { project_id: project_id.0, repository_id: id.to_proto(), - path: path.to_proto(), + path: path.as_unix_str().to_owned(), text: content, }) .await?; @@ -8406,7 +8410,7 @@ impl Repository { }; Some(( RepoPath::from_rel_path( - &RelPath::from_proto(&entry.path).log_err()?, + RelPath::from_unix_str(&entry.path).log_err()?, ), status, )) @@ -8735,7 +8739,7 @@ impl Repository { .into_iter() .filter_map(|path| { Some(sum_tree::Edit::Remove(PathKey( - RelPath::from_proto(&path).log_err()?, + RelPath::from_unix_str(&path).log_err()?.into(), ))) }) .chain( @@ -9326,7 +9330,7 @@ fn format_job_key(key: &GitJobKey) -> SharedString { .iter() .map(|p| { let rel: &RelPath = p; - format!("{}", AsRef::::as_ref(rel).display()) + rel.display(PathStyle::local()) }) .collect(); format!("WriteIndex({})", paths_str.join(", ")).into() @@ -9405,7 +9409,7 @@ pub fn worktrees_directory_for_repo( let resolved = if path_style.is_posix() { joined } else { - util::normalize_path(&joined) + path::normalize_path(&joined) }; let resolved = if resolved.starts_with(repository_anchor_path) { resolved @@ -9662,7 +9666,9 @@ fn log_source_to_proto(log_source: &LogSource) -> proto::GitLogSource { LogSource::All => proto::git_log_source::Source::All(proto::GitLogSourceAll {}), LogSource::Branch(branch) => proto::git_log_source::Source::Branch(branch.to_string()), LogSource::Sha(sha) => proto::git_log_source::Source::Sha(sha.to_string()), - LogSource::Path(path) => proto::git_log_source::Source::Path(path.to_proto()), + LogSource::Path(path) => { + proto::git_log_source::Source::Path(path.as_unix_str().to_owned()) + } }), } } @@ -10066,11 +10072,9 @@ mod tests { fn test_new_worktree_path_uses_posix_style_for_remote_paths() { let work_dir = Path::new("/home/user/dev/lsp-tests"); let directory = - worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Posix).unwrap(); - let directory = PathStyle::Posix - .join_path(&directory, "nimble-sky") - .unwrap(); - let path = PathStyle::Posix.join_path(&directory, "lsp-tests").unwrap(); + worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Unix).unwrap(); + let directory = PathStyle::Unix.join_path(&directory, "nimble-sky").unwrap(); + let path = PathStyle::Unix.join_path(&directory, "lsp-tests").unwrap(); assert_eq!( path, diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 0b6dcfa0078..667440a437c 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -698,7 +698,7 @@ impl ImageStoreImpl for Entity { .request(rpc::proto::OpenImageByPath { project_id, worktree_id, - path: path.to_proto(), + path: path.as_unix_str().to_owned(), }) .await?; diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index bdc41891587..038950a0450 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -1614,7 +1614,7 @@ fn edit_prediction_definitions_to_proto( .into_iter() .map(|definition| proto::EditPredictionDefinition { worktree_id: definition.path.worktree_id.to_proto(), - path: definition.path.path.as_ref().to_proto(), + path: definition.path.path.as_ref().as_unix_str().to_owned(), start: Some(proto::PointUtf16 { row: definition.range.start.0.row, column: definition.range.start.0.column, @@ -1638,7 +1638,9 @@ fn edit_prediction_definitions_from_proto( Ok(EditPredictionDefinition { path: ProjectPath { worktree_id: worktree::WorktreeId::from_proto(definition.worktree_id), - path: RelPath::from_proto(&definition.path).context("invalid path")?, + path: RelPath::from_unix_str(&definition.path) + .context("invalid path")? + .into(), }, range: Unclipped(PointUtf16::new(start.row, start.column)) ..Unclipped(PointUtf16::new(end.row, end.column)), diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 71d5b30cf88..e98b70c3d14 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -8755,7 +8755,7 @@ impl LspStore { project_id: *project_id, worktree_id: worktree_id.to_proto(), summary: Some(proto::DiagnosticSummary { - path: path.as_ref().to_proto(), + path: path.as_ref().as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: 0, warning_count: 0, @@ -9046,7 +9046,7 @@ impl LspStore { diagnostics_summary .more_summaries .push(proto::DiagnosticSummary { - path: project_path.path.as_ref().to_proto(), + path: project_path.path.as_ref().as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: new_summary.error_count, warning_count: new_summary.warning_count, @@ -9057,7 +9057,7 @@ impl LspStore { project_id, worktree_id: worktree_id.to_proto(), summary: Some(proto::DiagnosticSummary { - path: project_path.path.as_ref().to_proto(), + path: project_path.path.as_ref().as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: new_summary.error_count, warning_count: new_summary.warning_count, @@ -9141,7 +9141,7 @@ impl LspStore { Ok(ControlFlow::Continue(Some(( *project_id, proto::DiagnosticSummary { - path: path_in_worktree.to_proto(), + path: path_in_worktree.as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: new_summary.error_count as u32, warning_count: new_summary.warning_count as u32, @@ -9946,7 +9946,7 @@ impl LspStore { let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id); let new_worktree_id = WorktreeId::from_proto(envelope.payload.new_worktree_id); let new_path = - RelPath::from_proto(&envelope.payload.new_path).context("invalid relative path")?; + RelPath::from_unix_str(&envelope.payload.new_path).context("invalid relative path")?; let (worktree_store, old_worktree, new_worktree, old_entry) = this .update(&mut cx, |this, cx| { @@ -10015,7 +10015,9 @@ impl LspStore { { let project_path = ProjectPath { worktree_id, - path: RelPath::from_proto(&message_summary.path).context("invalid path")?, + path: RelPath::from_unix_str(&message_summary.path) + .context("invalid path")? + .into(), }; let path = project_path.path.clone(); let server_id = LanguageServerId(message_summary.language_server_id as usize); @@ -10050,7 +10052,7 @@ impl LspStore { diagnostics_summary .more_summaries .push(proto::DiagnosticSummary { - path: project_path.path.as_ref().to_proto(), + path: project_path.path.as_ref().as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: summary.error_count as u32, warning_count: summary.warning_count as u32, @@ -10061,7 +10063,7 @@ impl LspStore { project_id: *project_id, worktree_id: worktree_id.to_proto(), summary: Some(proto::DiagnosticSummary { - path: project_path.path.as_ref().to_proto(), + path: project_path.path.as_ref().as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: summary.error_count as u32, warning_count: summary.warning_count as u32, @@ -11539,7 +11541,7 @@ impl LspStore { project_id, worktree_id: worktree_id.to_proto(), summary: Some(proto::DiagnosticSummary { - path: path.as_ref().to_proto(), + path: path.as_ref().as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: 0, warning_count: 0, @@ -12571,7 +12573,7 @@ impl LspStore { match &symbol.path { SymbolLocation::InProject(path) => { result.worktree_id = path.worktree_id.to_proto(); - result.path = path.path.to_proto(); + result.path = path.path.as_unix_str().to_owned(); } SymbolLocation::OutsideProject { abs_path, @@ -12592,8 +12594,9 @@ impl LspStore { let path = if serialized_symbol.signature.is_empty() { SymbolLocation::InProject(ProjectPath { worktree_id, - path: RelPath::from_proto(&serialized_symbol.path) - .context("invalid symbol path")?, + path: RelPath::from_unix_str(&serialized_symbol.path) + .context("invalid symbol path")? + .into(), }) } else { SymbolLocation::OutsideProject { @@ -14840,7 +14843,7 @@ impl DiagnosticSummary { path: &RelPath, ) -> proto::DiagnosticSummary { proto::DiagnosticSummary { - path: path.to_proto(), + path: path.as_unix_str().to_owned(), language_server_id: language_server_id.0 as u64, error_count: self.error_count as u32, warning_count: self.warning_count as u32, diff --git a/crates/project/src/manifest_tree/path_trie.rs b/crates/project/src/manifest_tree/path_trie.rs index 99b4d523e0e..ba0a3619e7d 100644 --- a/crates/project/src/manifest_tree/path_trie.rs +++ b/crates/project/src/manifest_tree/path_trie.rs @@ -4,6 +4,7 @@ use std::{ sync::Arc, }; +use path::rel_path::RelPathBuf; use util::rel_path::RelPath; /// [RootPathTrie] is a workhorse of [super::ManifestTree]. It is responsible for determining the closest known entry for a given path. @@ -59,12 +60,12 @@ impl RootPathTrie