diff --git a/Cargo.lock b/Cargo.lock index a44cdb5271c..6293cb2a217 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -954,6 +954,7 @@ dependencies = [ "smol", "tempfile", "util", + "which 6.0.3", "windows 0.61.3", "zeroize", ] @@ -4347,6 +4348,7 @@ version = "0.1.0" dependencies = [ "async-process", "crash-handler", + "libc", "log", "mach2 0.5.0", "minidumper", @@ -6318,6 +6320,7 @@ dependencies = [ "log", "lsp", "parking_lot", + "path", "pretty_assertions", "proto", "semver", @@ -6907,6 +6910,7 @@ dependencies = [ "log", "notify 9.0.0-rc.4", "parking_lot", + "path", "paths", "proto", "rope", @@ -9751,15 +9755,14 @@ dependencies = [ "gpui_shared_string", "gpui_util", "log", - "lsp-types", "parking_lot", + "path", "regex", "schemars 1.0.4", "serde", "serde_json", "toml 0.8.23", "tree-sitter", - "util", ] [[package]] @@ -12704,6 +12707,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" @@ -14039,6 +14052,7 @@ dependencies = [ "markdown", "node_runtime", "parking_lot", + "path", "paths", "percent-encoding", "postage", @@ -19696,9 +19710,11 @@ dependencies = [ "num-format", "schemars 1.0.4", "serde", + "settings", "smallvec", "strum 0.27.2", "theme", + "theme_settings", "ui_macros", "windows 0.61.3", ] @@ -19951,6 +19967,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/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index b05bb2d13ab..0979b9e26bf 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -8057,7 +8057,7 @@ impl ThreadView { let tool_output_display = if is_open { match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { options, .. } => { + ToolCallStatus::WaitingForConfirmation { .. } => { let confirmation_content = v_flex() .w_full() .children(tool_call.content.iter().enumerate().map( @@ -8167,37 +8167,7 @@ impl ThreadView { ) }); - v_flex() - .w_full() - .map(|this| { - if layout == ToolCallLayout::Floating { - // Cap the content (e.g. a full plan awaiting - // approval) so the floating row can never - // consume the entire panel and squeeze the - // conversation list to zero height, while the - // permission buttons below stay visible. - this.child( - div() - .id(("floating-confirmation-content", entry_ix)) - .max_h_40() - .overflow_y_scroll() - .child(confirmation_content), - ) - } else { - this.child(confirmation_content) - } - }) - .child(self.render_permission_buttons( - self.thread.read(cx).session_id().clone(), - self.is_first_tool_call(active_session_id, &tool_call.id, cx), - options, - entry_ix, - tool_call.id.clone(), - focus_handle, - self.sandbox_confusables_block_allow(tool_call, cx), - cx, - )) - .into_any() + confirmation_content.into_any() } ToolCallStatus::Pending | ToolCallStatus::InProgress if is_edit @@ -8295,35 +8265,23 @@ impl ThreadView { None }; - v_flex() - .map(|this| { - if matches!( - layout, - ToolCallLayout::Embedded | ToolCallLayout::Floating - ) { - this - } else if use_card_layout { - this.my_1p5() - .rounded_md() - .border_1() - .when(failed_or_canceled, |this| this.border_dashed()) - .border_color(self.tool_card_border_color(cx)) - .bg(cx.theme().colors().editor_background) - .overflow_hidden() - } else { - this.my_1() - } - }) - .when(layout == ToolCallLayout::Standalone, |this| { - this.map(|this| { - if has_location && !use_card_layout { - this.ml_4() - } else { - this.ml_5() - } - }) - .mr_5() - }) + let permission_buttons = + if let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status { + Some(self.render_permission_buttons( + self.thread.read(cx).session_id().clone(), + self.is_first_tool_call(active_session_id, &tool_call.id, cx), + options, + entry_ix, + tool_call.id.clone(), + focus_handle, + self.sandbox_confusables_block_allow(tool_call, cx), + cx, + )) + } else { + None + }; + + let body = v_flex() .map(|this| { if is_terminal_tool { this.child(self.render_collapsible_command( @@ -8498,7 +8456,48 @@ impl ThreadView { ) } }) - .children(tool_output_display) + .children(tool_output_display); + + v_flex() + .map(|this| { + if matches!(layout, ToolCallLayout::Embedded | ToolCallLayout::Floating) { + this + } else if use_card_layout { + this.my_1p5() + .rounded_md() + .border_1() + .when(failed_or_canceled, |this| this.border_dashed()) + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + } else { + this.my_1() + } + }) + .when(layout == ToolCallLayout::Standalone, |this| { + this.map(|this| { + if has_location && !use_card_layout { + this.ml_4() + } else { + this.ml_5() + } + }) + .mr_5() + }) + .map(|this| { + if layout == ToolCallLayout::Floating { + this.child( + div() + .id(("floating-tool-call-body", entry_ix)) + .max_h_40() + .overflow_y_scroll() + .child(body), + ) + } else { + this.child(body) + } + }) + .children(permission_buttons) } /// A small "Learn more" link to the sandboxing docs, deep-linked to 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/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/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/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/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/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/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.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/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 3069c13b65d..d2be8f2ce6a 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)] @@ -183,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(), @@ -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/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/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/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/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" + ); + }); + } +} 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..cb2b16a48dc 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -43,7 +43,7 @@ use std::{ }; use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection, ToPoint as _}; use ui::{IconDecorationKind, prelude::*}; -use util::{ResultExt, TryFutureExt, paths::PathExt, rel_path::RelPath}; +use util::{ResultExt, TryFutureExt, debug_panic, paths::PathExt, rel_path::RelPath}; use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams}; use workspace::{ CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, @@ -2100,6 +2100,75 @@ pub fn active_match_index( } } +/// Opens a path-like target (e.g. `items.rs:100:5`) in the workspace, moving the cursor +/// to the one-based row/column if present. Returns whether the target was opened. +pub async fn open_resolved_target( + workspace: &WeakEntity, + open_target: &workspace::path_link::OpenTarget, + cx: &mut AsyncWindowContext, +) -> Result { + let path_to_open = open_target.path(); + let mut opened_items = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_paths( + vec![path_to_open.path.clone()], + workspace::OpenOptions { + visible: Some(workspace::OpenVisible::OnlyDirectories), + ..Default::default() + }, + None, + window, + cx, + ) + }) + .context("workspace update")? + .await; + if opened_items.len() != 1 { + debug_panic!( + "Received {} items for one path {path_to_open:?}", + opened_items.len(), + ); + } + let Some(opened_item) = opened_items.pop() else { + return Ok(false); + }; + + if open_target.is_file() { + let Some(opened_item) = opened_item else { + return Ok(false); + }; + let opened_item = + opened_item.with_context(|| format!("opening {:?}", path_to_open.path))?; + if let Some(row) = path_to_open.row + && let Some(editor) = opened_item.downcast::() + { + let column = path_to_open.column.unwrap_or(0); + editor + .downgrade() + .update_in(cx, |editor, window, cx| { + if let Some(buffer) = editor.buffer().read(cx).as_singleton() { + let point = buffer.read(cx).snapshot().point_from_external_input( + row.saturating_sub(1), + column.saturating_sub(1), + ); + editor.go_to_singleton_buffer_point(point, window, cx); + } + }) + .log_err(); + } + Ok(true) + } else if open_target.is_dir() { + workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |_, cx| { + cx.emit(project::Event::ActivateProjectPanel); + }) + })?; + Ok(true) + } else { + Ok(false) + } +} + pub fn entry_label_color(selected: bool) -> Color { if selected { Color::Default @@ -2221,14 +2290,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(), }) } @@ -2396,7 +2465,8 @@ mod tests { use project::FakeFs; use serde_json::json; use std::path::{Path, PathBuf}; - use util::{path, rel_path::RelPath}; + use util::{path, paths::PathWithPosition, rel_path::RelPath}; + use workspace::path_link::{OpenTarget, OpenTargetFoundBy}; #[gpui::test] fn test_path_for_file(cx: &mut App) { @@ -3038,6 +3108,62 @@ mod tests { ); } + #[gpui::test] + async fn test_open_resolved_target_at_non_ascii_column(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "src": { + "main.rs": "first\naéøbc\n", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let open_target = OpenTarget::Path( + PathWithPosition { + path: PathBuf::from(path!("/root/src/main.rs")), + row: Some(2), + column: Some(4), + }, + false, + OpenTargetFoundBy::BackgroundPathResolution, + ); + + let opened = workspace + .update_in(cx, |_, window, cx| { + cx.spawn_in(window, async move |workspace, cx| { + open_resolved_target(&workspace, &open_target, cx).await + }) + }) + .await + .expect("opening the target should succeed"); + assert!(opened, "target should open as a file"); + + let editor = workspace.read_with(cx, |workspace, cx| { + workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .expect("active item should be an editor") + }); + let cursor = editor.update_in(cx, |editor, _, cx| { + editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head() + }); + // Column 4 is the fourth character of `aéøbc` (the `b`), which starts at byte 5. + assert_eq!(cursor, language::Point::new(1, 5)); + } + #[gpui::test] fn test_compute_modified_ranges_git_diff(cx: &mut gpui::TestAppContext) { let base_text = "line0\nline1\nline2\nline3\nline4\nline5\nline6\n"; 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 94d3fac855e..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; }; @@ -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()?; @@ -3793,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)) } @@ -3803,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)) } @@ -3822,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/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/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_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 85465f98cb4..e1c7d0e830e 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; @@ -82,9 +85,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 +330,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 +344,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 +478,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 +546,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 +577,7 @@ enum GitListEntry { TreeStatus(GitTreeStatusEntry), Directory(GitTreeDirEntry), Header(GitHeaderEntry), + EmptySection(Section), } impl GitListEntry { @@ -549,6 +604,13 @@ impl GitListEntry { _ => 0, } } + + fn is_selectable(&self) -> bool { + matches!( + self, + GitListEntry::Status(_) | GitListEntry::TreeStatus(_) | GitListEntry::Directory(_) + ) + } } enum GitPanelViewMode { @@ -1239,7 +1301,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 +1628,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 +1711,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 +2480,7 @@ impl GitPanel { fn toggle_staged_for_entry( &mut self, entry: &GitListEntry, + intent: StageIntent, _window: &mut Window, cx: &mut Context, ) { @@ -2387,47 +2493,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 +2530,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 +2552,7 @@ impl GitPanel { .collect::>(); (goal_stage, entries) } + GitListEntry::EmptySection(_) => return, } }; if let Some(anchor) = clear_anchor { @@ -2622,20 +2707,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) { @@ -3147,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 { @@ -4494,7 +4579,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 +4691,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 +4712,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 +4726,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 +4757,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 +4771,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 +4852,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 +4891,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 +5190,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, } } @@ -6078,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, @@ -6238,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) @@ -6376,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| { @@ -6393,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, + )) + }), ) }) })) @@ -6432,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(); @@ -6452,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(), @@ -6654,7 +6825,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 +6925,9 @@ impl GitPanel { cx, )); } + Some(GitListEntry::EmptySection(section)) => { + items.push(this.render_empty_section(*section)); + } None => {} } } @@ -6817,25 +6991,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 +7025,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 +7068,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 +7109,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" @@ -6978,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, @@ -7059,19 +7275,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 +7364,7 @@ impl GitPanel { .id(id) .h(self.list_item_height()) .w_full() - .pl_3() + .pl_2p5() .pr_1() .gap_1p5() .border_1() @@ -7176,67 +7392,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 +7421,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 +7434,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 +7537,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 +7569,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 +7588,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 +7598,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 +7613,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 +7766,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 +7784,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 +9619,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 +9816,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 +9850,14 @@ mod tests { status: FileStatus::Unmerged(..), .. }), + Header(GitHeaderEntry { + header: Section::Staged + }), + EmptySection(Section::Staged), + Header(GitHeaderEntry { + header: Section::Unstaged + }), + EmptySection(Section::Unstaged), ] )); }); @@ -9713,12 +9880,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 +10391,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 +10440,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 +10602,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 +10670,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| { @@ -11084,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| { @@ -11412,7 +11813,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 +11850,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 +11887,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 +11923,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| { 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; 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/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 { 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( 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/diagnostic.rs b/crates/language/src/diagnostic.rs index 951feec0da1..9a468a14b86 100644 --- a/crates/language/src/diagnostic.rs +++ b/crates/language/src/diagnostic.rs @@ -1 +1,76 @@ -pub use language_core::diagnostic::{Diagnostic, DiagnosticSourceKind}; +use gpui::SharedString; +use lsp::{DiagnosticSeverity, NumberOrString}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// A diagnostic associated with a certain range of a buffer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Diagnostic { + /// The name of the service that produced this diagnostic. + pub source: Option, + /// The ID provided by the dynamic registration that produced this diagnostic. + pub registration_id: Option, + /// A machine-readable code that identifies this diagnostic. + pub code: Option, + pub code_description: Option, + /// Whether this diagnostic is a hint, warning, or error. + pub severity: DiagnosticSeverity, + /// The human-readable message associated with this diagnostic. + pub message: String, + /// The human-readable message (in markdown format) + pub markdown: Option, + /// An id that identifies the group to which this diagnostic belongs. + /// + /// When a language server produces a diagnostic with + /// one or more associated diagnostics, those diagnostics are all + /// assigned a single group ID. + pub group_id: usize, + /// Whether this diagnostic is the primary diagnostic for its group. + /// + /// In a given group, the primary diagnostic is the top-level diagnostic + /// returned by the language server. The non-primary diagnostics are the + /// associated diagnostics. + pub is_primary: bool, + /// Whether this diagnostic is considered to originate from an analysis of + /// files on disk, as opposed to any unsaved buffer contents. This is a + /// property of a given diagnostic source, and is configured for a given + /// language server via the `LspAdapter::disk_based_diagnostic_sources` method + /// for the language server. + pub is_disk_based: bool, + /// Whether this diagnostic marks unnecessary code. + pub is_unnecessary: bool, + /// Quick separation of diagnostics groups based by their source. + pub source_kind: DiagnosticSourceKind, + /// Data from language server that produced this diagnostic. Passed back to the LS when we request code actions for this diagnostic. + pub data: Option, + /// Whether to underline the corresponding text range in the editor. + pub underline: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DiagnosticSourceKind { + Pulled, + Pushed, + Other, +} + +impl Default for Diagnostic { + fn default() -> Self { + Self { + source: Default::default(), + source_kind: DiagnosticSourceKind::Other, + code: None, + code_description: None, + severity: DiagnosticSeverity::ERROR, + message: Default::default(), + markdown: None, + group_id: 0, + is_primary: false, + is_disk_based: false, + is_unnecessary: false, + underline: true, + data: None, + registration_id: None, + } + } +} diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 4c57001cd69..9a031347ec0 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -39,7 +39,10 @@ use futures::lock::OwnedMutexGuard; use gpui::{App, AsyncApp, Entity}; use http_client::HttpClient; -pub use language_core::highlight_map::{HighlightId, HighlightMap}; +pub use language_core::{ + SymbolKind, + highlight_map::{HighlightId, HighlightMap}, +}; use futures::future::FutureExt as _; pub use language_core::{ @@ -48,10 +51,10 @@ pub use language_core::{ DecreaseIndentConfig, Grammar, GrammarId, HighlightsConfig, IndentConfig, InjectionConfig, InjectionPatternConfig, JsxTagAutoCloseConfig, LanguageConfig, LanguageConfigOverride, 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, + OverrideEntry, RedactionConfig, RunnableCapture, RunnableConfig, SoftWrap, Symbol, + TaskListConfig, TextObject, TextObjectConfig, 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, @@ -216,6 +219,69 @@ pub static PLAIN_TEXT: LazyLock> = LazyLock::new(|| { )) }); +pub fn symbol_kind_to_lsp(kind: SymbolKind) -> lsp::SymbolKind { + match kind { + SymbolKind::File => lsp::SymbolKind::FILE, + SymbolKind::Module => lsp::SymbolKind::MODULE, + SymbolKind::Namespace => lsp::SymbolKind::NAMESPACE, + SymbolKind::Package => lsp::SymbolKind::PACKAGE, + SymbolKind::Class => lsp::SymbolKind::CLASS, + SymbolKind::Method => lsp::SymbolKind::METHOD, + SymbolKind::Property => lsp::SymbolKind::PROPERTY, + SymbolKind::Field => lsp::SymbolKind::FIELD, + SymbolKind::Constructor => lsp::SymbolKind::CONSTRUCTOR, + SymbolKind::Enum => lsp::SymbolKind::ENUM, + SymbolKind::Interface => lsp::SymbolKind::INTERFACE, + SymbolKind::Function => lsp::SymbolKind::FUNCTION, + SymbolKind::Variable => lsp::SymbolKind::VARIABLE, + SymbolKind::Constant => lsp::SymbolKind::CONSTANT, + SymbolKind::String => lsp::SymbolKind::STRING, + SymbolKind::Number => lsp::SymbolKind::NUMBER, + SymbolKind::Boolean => lsp::SymbolKind::BOOLEAN, + SymbolKind::Array => lsp::SymbolKind::ARRAY, + SymbolKind::Object => lsp::SymbolKind::OBJECT, + SymbolKind::Key => lsp::SymbolKind::KEY, + SymbolKind::Null => lsp::SymbolKind::NULL, + SymbolKind::EnumMember => lsp::SymbolKind::ENUM_MEMBER, + SymbolKind::Struct => lsp::SymbolKind::STRUCT, + SymbolKind::Event => lsp::SymbolKind::EVENT, + SymbolKind::Operator => lsp::SymbolKind::OPERATOR, + SymbolKind::TypeParameter => lsp::SymbolKind::TYPE_PARAMETER, + } +} + +pub fn lsp_to_symbol_kind(kind: lsp::SymbolKind) -> SymbolKind { + match kind { + lsp::SymbolKind::FILE => SymbolKind::File, + lsp::SymbolKind::MODULE => SymbolKind::Module, + lsp::SymbolKind::NAMESPACE => SymbolKind::Namespace, + lsp::SymbolKind::PACKAGE => SymbolKind::Package, + lsp::SymbolKind::CLASS => SymbolKind::Class, + lsp::SymbolKind::METHOD => SymbolKind::Method, + lsp::SymbolKind::PROPERTY => SymbolKind::Property, + lsp::SymbolKind::FIELD => SymbolKind::Field, + lsp::SymbolKind::CONSTRUCTOR => SymbolKind::Constructor, + lsp::SymbolKind::ENUM => SymbolKind::Enum, + lsp::SymbolKind::INTERFACE => SymbolKind::Interface, + lsp::SymbolKind::FUNCTION => SymbolKind::Function, + lsp::SymbolKind::VARIABLE => SymbolKind::Variable, + lsp::SymbolKind::CONSTANT => SymbolKind::Constant, + lsp::SymbolKind::STRING => SymbolKind::String, + lsp::SymbolKind::NUMBER => SymbolKind::Number, + lsp::SymbolKind::BOOLEAN => SymbolKind::Boolean, + lsp::SymbolKind::ARRAY => SymbolKind::Array, + lsp::SymbolKind::OBJECT => SymbolKind::Object, + lsp::SymbolKind::KEY => SymbolKind::Key, + lsp::SymbolKind::NULL => SymbolKind::Null, + lsp::SymbolKind::ENUM_MEMBER => SymbolKind::EnumMember, + lsp::SymbolKind::STRUCT => SymbolKind::Struct, + lsp::SymbolKind::EVENT => SymbolKind::Event, + lsp::SymbolKind::OPERATOR => SymbolKind::Operator, + lsp::SymbolKind::TYPE_PARAMETER => SymbolKind::TypeParameter, + _ => SymbolKind::Null, + } +} + /// Commands that the client (editor) handles locally rather than forwarding /// to the language server. Servers embed these in code lens and code action /// responses when they want the editor to perform a well-known UI action. @@ -233,6 +299,17 @@ pub struct Location { pub range: Range, } +/// Context provided to LSP adapters when a user responds to a ShowMessageRequest prompt. +/// This allows adapters to intercept preference selections (like "Always" or "Never") +/// and potentially persist them to Zed's settings. +#[derive(Debug, Clone)] +pub struct PromptResponseContext { + /// The original message shown to the user + pub message: String, + /// The action (button) the user selected + pub selected_action: lsp::MessageActionItem, +} + type ServerBinaryCache = futures::lock::Mutex>; type DownloadableLanguageServerBinary = LocalBoxFuture<'static, Result>; pub type LanguageServerBinaryLocations = LocalBoxFuture< diff --git a/crates/language_core/Cargo.toml b/crates/language_core/Cargo.toml index 91dca562a80..7d77847a9c6 100644 --- a/crates/language_core/Cargo.toml +++ b/crates/language_core/Cargo.toml @@ -11,17 +11,16 @@ path = "src/language_core.rs" anyhow.workspace = true collections.workspace = true gpui_shared_string.workspace = true +gpui_util.workspace = true log.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/code_label.rs b/crates/language_core/src/code_label.rs index 74e1d43e0c4..d171527478b 100644 --- a/crates/language_core/src/code_label.rs +++ b/crates/language_core/src/code_label.rs @@ -1,10 +1,74 @@ use crate::highlight_map::HighlightId; use std::ops::Range; +#[derive(Debug, Eq, PartialEq, Copy, Clone)] +pub enum SymbolKind { + File, + Module, + Namespace, + Package, + Class, + Method, + Property, + Field, + Constructor, + Enum, + Interface, + Function, + Variable, + Constant, + String, + Number, + Boolean, + Array, + Object, + Key, + Null, + EnumMember, + Struct, + Event, + Operator, + TypeParameter, +} + +impl SymbolKind { + pub fn from_proto(i32: i32) -> Self { + match i32 { + 1 => SymbolKind::File, + 2 => SymbolKind::Module, + 3 => SymbolKind::Namespace, + 4 => SymbolKind::Package, + 5 => SymbolKind::Class, + 6 => SymbolKind::Method, + 7 => SymbolKind::Property, + 8 => SymbolKind::Field, + 9 => SymbolKind::Constructor, + 10 => SymbolKind::Enum, + 11 => SymbolKind::Interface, + 12 => SymbolKind::Function, + 13 => SymbolKind::Variable, + 14 => SymbolKind::Constant, + 15 => SymbolKind::String, + 16 => SymbolKind::Number, + 17 => SymbolKind::Boolean, + 18 => SymbolKind::Array, + 19 => SymbolKind::Object, + 20 => SymbolKind::Key, + 21 => SymbolKind::Null, + 22 => SymbolKind::EnumMember, + 23 => SymbolKind::Struct, + 24 => SymbolKind::Event, + 25 => SymbolKind::Operator, + 26 => SymbolKind::TypeParameter, + _ => SymbolKind::Null, + } + } +} + #[derive(Debug, Clone)] pub struct Symbol { pub name: String, - pub kind: lsp_types::SymbolKind, + pub kind: SymbolKind, pub container_name: Option, } diff --git a/crates/language_core/src/diagnostic.rs b/crates/language_core/src/diagnostic.rs deleted file mode 100644 index b8c4fe8535d..00000000000 --- a/crates/language_core/src/diagnostic.rs +++ /dev/null @@ -1,76 +0,0 @@ -use gpui_shared_string::SharedString; -use lsp_types::{DiagnosticSeverity, NumberOrString}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// A diagnostic associated with a certain range of a buffer. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Diagnostic { - /// The name of the service that produced this diagnostic. - pub source: Option, - /// The ID provided by the dynamic registration that produced this diagnostic. - pub registration_id: Option, - /// A machine-readable code that identifies this diagnostic. - pub code: Option, - pub code_description: Option, - /// Whether this diagnostic is a hint, warning, or error. - pub severity: DiagnosticSeverity, - /// The human-readable message associated with this diagnostic. - pub message: String, - /// The human-readable message (in markdown format) - pub markdown: Option, - /// An id that identifies the group to which this diagnostic belongs. - /// - /// When a language server produces a diagnostic with - /// one or more associated diagnostics, those diagnostics are all - /// assigned a single group ID. - pub group_id: usize, - /// Whether this diagnostic is the primary diagnostic for its group. - /// - /// In a given group, the primary diagnostic is the top-level diagnostic - /// returned by the language server. The non-primary diagnostics are the - /// associated diagnostics. - pub is_primary: bool, - /// Whether this diagnostic is considered to originate from an analysis of - /// files on disk, as opposed to any unsaved buffer contents. This is a - /// property of a given diagnostic source, and is configured for a given - /// language server via the `LspAdapter::disk_based_diagnostic_sources` method - /// for the language server. - pub is_disk_based: bool, - /// Whether this diagnostic marks unnecessary code. - pub is_unnecessary: bool, - /// Quick separation of diagnostics groups based by their source. - pub source_kind: DiagnosticSourceKind, - /// Data from language server that produced this diagnostic. Passed back to the LS when we request code actions for this diagnostic. - pub data: Option, - /// Whether to underline the corresponding text range in the editor. - pub underline: bool, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum DiagnosticSourceKind { - Pulled, - Pushed, - Other, -} - -impl Default for Diagnostic { - fn default() -> Self { - Self { - source: Default::default(), - source_kind: DiagnosticSourceKind::Other, - code: None, - code_description: None, - severity: DiagnosticSeverity::ERROR, - message: Default::default(), - markdown: None, - group_id: 0, - is_primary: false, - is_disk_based: false, - is_unnecessary: false, - underline: true, - data: None, - registration_id: None, - } - } -} 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..2edbcbea9d2 100644 --- a/crates/language_core/src/language_core.rs +++ b/crates/language_core/src/language_core.rs @@ -1,12 +1,10 @@ // language_core: tree-sitter grammar infrastructure, LSP adapter traits, // language configuration, and highlight mapping. -pub mod diagnostic; pub mod grammar; pub mod highlight_map; pub mod language_config; -pub use diagnostic::{Diagnostic, DiagnosticSourceKind}; pub use grammar::{ BracketsConfig, BracketsPatternConfig, DebugVariablesConfig, DebuggerTextObject, Grammar, GrammarId, HighlightsConfig, IndentConfig, InjectionConfig, InjectionPatternConfig, @@ -17,9 +15,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; @@ -29,11 +27,9 @@ pub mod manifest; pub mod queries; pub mod toolchain; -pub use code_label::{CodeLabel, CodeLabelBuilder, Symbol}; +pub use code_label::{CodeLabel, CodeLabelBuilder, Symbol, SymbolKind}; pub use language_name::{LanguageId, LanguageName}; -pub use lsp_adapter::{ - BinaryStatus, LanguageServerStatusUpdate, PromptResponseContext, ServerHealth, ToLspPosition, -}; +pub use lsp_adapter::{BinaryStatus, LanguageServerStatusUpdate, ServerHealth}; pub use manifest::ManifestName; pub use queries::{LanguageQueries, QUERY_FILENAME_PREFIXES}; pub use toolchain::{Toolchain, ToolchainList, ToolchainMetadata, ToolchainScope}; diff --git a/crates/language_core/src/lsp_adapter.rs b/crates/language_core/src/lsp_adapter.rs index 230e1b4aa19..34fac0790b3 100644 --- a/crates/language_core/src/lsp_adapter.rs +++ b/crates/language_core/src/lsp_adapter.rs @@ -1,23 +1,6 @@ use gpui_shared_string::SharedString; use serde::{Deserialize, Serialize}; -/// Converts a value into an LSP position. -pub trait ToLspPosition { - /// Converts the value into an LSP position. - fn to_lsp_position(self) -> lsp_types::Position; -} - -/// Context provided to LSP adapters when a user responds to a ShowMessageRequest prompt. -/// This allows adapters to intercept preference selections (like "Always" or "Never") -/// and potentially persist them to Zed's settings. -#[derive(Debug, Clone)] -pub struct PromptResponseContext { - /// The original message shown to the user - pub message: String, - /// The action (button) the user selected - pub selected_action: lsp_types::MessageActionItem, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub enum LanguageServerStatusUpdate { Binary(BinaryStatus), 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/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 44092bf2639..d9f28100d80 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -476,7 +476,7 @@ impl LspAdapter for ExtensionLspAdapter { container_name, }| extension::Symbol { name, - kind: lsp_symbol_kind_to_extension(kind), + kind: symbol_kind_to_extension(kind), container_name, }, ) @@ -636,35 +636,34 @@ fn lsp_insert_text_format_to_extension( } } -fn lsp_symbol_kind_to_extension(value: lsp::SymbolKind) -> extension::SymbolKind { +fn symbol_kind_to_extension(value: language::SymbolKind) -> extension::SymbolKind { match value { - lsp::SymbolKind::FILE => extension::SymbolKind::File, - lsp::SymbolKind::MODULE => extension::SymbolKind::Module, - lsp::SymbolKind::NAMESPACE => extension::SymbolKind::Namespace, - lsp::SymbolKind::PACKAGE => extension::SymbolKind::Package, - lsp::SymbolKind::CLASS => extension::SymbolKind::Class, - lsp::SymbolKind::METHOD => extension::SymbolKind::Method, - lsp::SymbolKind::PROPERTY => extension::SymbolKind::Property, - lsp::SymbolKind::FIELD => extension::SymbolKind::Field, - lsp::SymbolKind::CONSTRUCTOR => extension::SymbolKind::Constructor, - lsp::SymbolKind::ENUM => extension::SymbolKind::Enum, - lsp::SymbolKind::INTERFACE => extension::SymbolKind::Interface, - lsp::SymbolKind::FUNCTION => extension::SymbolKind::Function, - lsp::SymbolKind::VARIABLE => extension::SymbolKind::Variable, - lsp::SymbolKind::CONSTANT => extension::SymbolKind::Constant, - lsp::SymbolKind::STRING => extension::SymbolKind::String, - lsp::SymbolKind::NUMBER => extension::SymbolKind::Number, - lsp::SymbolKind::BOOLEAN => extension::SymbolKind::Boolean, - lsp::SymbolKind::ARRAY => extension::SymbolKind::Array, - lsp::SymbolKind::OBJECT => extension::SymbolKind::Object, - lsp::SymbolKind::KEY => extension::SymbolKind::Key, - lsp::SymbolKind::NULL => extension::SymbolKind::Null, - lsp::SymbolKind::ENUM_MEMBER => extension::SymbolKind::EnumMember, - lsp::SymbolKind::STRUCT => extension::SymbolKind::Struct, - lsp::SymbolKind::EVENT => extension::SymbolKind::Event, - lsp::SymbolKind::OPERATOR => extension::SymbolKind::Operator, - lsp::SymbolKind::TYPE_PARAMETER => extension::SymbolKind::TypeParameter, - _ => extension::SymbolKind::Other(extract_int(value)), + language::SymbolKind::File => extension::SymbolKind::File, + language::SymbolKind::Module => extension::SymbolKind::Module, + language::SymbolKind::Namespace => extension::SymbolKind::Namespace, + language::SymbolKind::Package => extension::SymbolKind::Package, + language::SymbolKind::Class => extension::SymbolKind::Class, + language::SymbolKind::Method => extension::SymbolKind::Method, + language::SymbolKind::Property => extension::SymbolKind::Property, + language::SymbolKind::Field => extension::SymbolKind::Field, + language::SymbolKind::Constructor => extension::SymbolKind::Constructor, + language::SymbolKind::Enum => extension::SymbolKind::Enum, + language::SymbolKind::Interface => extension::SymbolKind::Interface, + language::SymbolKind::Function => extension::SymbolKind::Function, + language::SymbolKind::Variable => extension::SymbolKind::Variable, + language::SymbolKind::Constant => extension::SymbolKind::Constant, + language::SymbolKind::String => extension::SymbolKind::String, + language::SymbolKind::Number => extension::SymbolKind::Number, + language::SymbolKind::Boolean => extension::SymbolKind::Boolean, + language::SymbolKind::Array => extension::SymbolKind::Array, + language::SymbolKind::Object => extension::SymbolKind::Object, + language::SymbolKind::Key => extension::SymbolKind::Key, + language::SymbolKind::Null => extension::SymbolKind::Null, + language::SymbolKind::EnumMember => extension::SymbolKind::EnumMember, + language::SymbolKind::Struct => extension::SymbolKind::Struct, + language::SymbolKind::Event => extension::SymbolKind::Event, + language::SymbolKind::Operator => extension::SymbolKind::Operator, + language::SymbolKind::TypeParameter => extension::SymbolKind::TypeParameter, } } diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index eb24dea0dee..ee2d214caf6 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -300,43 +300,43 @@ impl super::LspAdapter for CLspAdapter { ) -> Option { let name = &symbol.name; let (text, filter_range, display_range) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { + language::SymbolKind::Method | language::SymbolKind::Function => { let text = format!("void {} () {{}}", name); let filter_range = 0..name.len(); let display_range = 5..5 + name.len(); (text, filter_range, display_range) } - lsp::SymbolKind::STRUCT => { + language::SymbolKind::Struct => { let text = format!("struct {} {{}}", name); let filter_range = 7..7 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::ENUM => { + language::SymbolKind::Enum => { let text = format!("enum {} {{}}", name); let filter_range = 5..5 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::INTERFACE | lsp::SymbolKind::CLASS => { + language::SymbolKind::Interface | language::SymbolKind::Class => { let text = format!("class {} {{}}", name); let filter_range = 6..6 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::CONSTANT => { + language::SymbolKind::Constant => { let text = format!("const int {} = 0;", name); let filter_range = 10..10 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::MODULE => { + language::SymbolKind::Module => { let text = format!("namespace {} {{}}", name); let filter_range = 10..10 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::TYPE_PARAMETER => { + language::SymbolKind::TypeParameter => { let text = format!("typename {} {{}};", name); let filter_range = 9..9 + name.len(); let display_range = 0..filter_range.end; 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/go.rs b/crates/languages/src/go.rs index 87efea16c85..d2e68be4f12 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -394,43 +394,43 @@ impl LspAdapter for GoLspAdapter { ) -> Option { let name = &symbol.name; let (text, filter_range, display_range) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { + language::SymbolKind::Method | language::SymbolKind::Function => { let text = format!("func {} () {{}}", name); let filter_range = 5..5 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::STRUCT => { + language::SymbolKind::Struct => { let text = format!("type {} struct {{}}", name); let filter_range = 5..5 + name.len(); let display_range = 0..text.len(); (text, filter_range, display_range) } - lsp::SymbolKind::INTERFACE => { + language::SymbolKind::Interface => { let text = format!("type {} interface {{}}", name); let filter_range = 5..5 + name.len(); let display_range = 0..text.len(); (text, filter_range, display_range) } - lsp::SymbolKind::CLASS => { + language::SymbolKind::Class => { let text = format!("type {} T", name); let filter_range = 5..5 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::CONSTANT => { + language::SymbolKind::Constant => { let text = format!("const {} = nil", name); let filter_range = 6..6 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::VARIABLE => { + language::SymbolKind::Variable => { let text = format!("var {} = nil", name); let filter_range = 4..4 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::MODULE => { + language::SymbolKind::Module => { let text = format!("package {}", name); let filter_range = 8..8 + name.len(); let display_range = 0..filter_range.end; 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..01d2b450c5b 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 { @@ -220,19 +220,19 @@ fn label_for_python_symbol( ) -> Option { let name = &symbol.name; let (text, filter_range, display_range) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { + language::SymbolKind::Method | language::SymbolKind::Function => { let text = format!("def {}():\n", name); let filter_range = 4..4 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::CLASS => { + language::SymbolKind::Class => { let text = format!("class {}:", name); let filter_range = 6..6 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::CONSTANT => { + language::SymbolKind::Constant => { let text = format!("{} = 0", name); let filter_range = 0..name.len(); let display_range = 0..filter_range.end; @@ -669,7 +669,7 @@ impl LspAdapter for PyrightLspAdapter { // If we have a detected toolchain, configure Pyright to use it - unless the user sets it themselves. let should_insert_toolchain = || { user_settings.as_object().is_none_or(|object| { - [ + ![ "venvPath", "venv", "python", @@ -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") @@ -2112,10 +2112,10 @@ impl LspAdapter for BasedPyrightLspAdapter { .and_then(|s| s.settings.clone()) .unwrap_or_default(); - // If we have a detected toolchain, configure Pyright to use it + // If we have a detected toolchain, configure BasedPyright to use it - unless the user sets it themselves. let should_insert_toolchain = || { user_settings.as_object().is_none_or(|object| { - [ + ![ "venvPath", "venv", "python", @@ -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..4091be97ed3 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)); } @@ -622,15 +622,15 @@ impl LspAdapter for RustLspAdapter { ) -> Option { let name = &symbol.name; let (prefix, suffix) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => ("fn ", "();"), - lsp::SymbolKind::STRUCT => ("struct ", ";"), - lsp::SymbolKind::ENUM => ("enum ", "{}"), - lsp::SymbolKind::INTERFACE => ("trait ", "{}"), - lsp::SymbolKind::CONSTANT => ("const ", ":()=();"), - lsp::SymbolKind::MODULE => ("mod ", ";"), - lsp::SymbolKind::PACKAGE => ("extern crate ", ";"), - lsp::SymbolKind::TYPE_PARAMETER => ("type ", "=();"), - lsp::SymbolKind::ENUM_MEMBER => { + language::SymbolKind::Method | language::SymbolKind::Function => ("fn ", "();"), + language::SymbolKind::Struct => ("struct ", ";"), + language::SymbolKind::Enum => ("enum ", "{}"), + language::SymbolKind::Interface => ("trait ", "{}"), + language::SymbolKind::Constant => ("const ", ":()=();"), + language::SymbolKind::Module => ("mod ", ";"), + language::SymbolKind::Package => ("extern crate ", ";"), + language::SymbolKind::TypeParameter => ("type ", "=();"), + language::SymbolKind::EnumMember => { let prefix = "enum E {"; return Some(CodeLabel::new( name.to_string(), @@ -1972,7 +1972,7 @@ mod tests { .label_for_symbol( &language::Symbol { name: "hello".to_string(), - kind: lsp::SymbolKind::FUNCTION, + kind: language::SymbolKind::Function, container_name: None, }, &language @@ -1990,7 +1990,7 @@ mod tests { .label_for_symbol( &language::Symbol { name: "World".to_string(), - kind: lsp::SymbolKind::TYPE_PARAMETER, + kind: language::SymbolKind::TypeParameter, container_name: None, }, &language @@ -2008,7 +2008,7 @@ mod tests { .label_for_symbol( &language::Symbol { name: "zed".to_string(), - kind: lsp::SymbolKind::PACKAGE, + kind: language::SymbolKind::Package, container_name: None, }, &language @@ -2026,7 +2026,7 @@ mod tests { .label_for_symbol( &language::Symbol { name: "Variant".to_string(), - kind: lsp::SymbolKind::ENUM_MEMBER, + kind: language::SymbolKind::EnumMember, container_name: None, }, &language 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/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(), ) } } 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 2f05aba49b5..7ed33f280d8 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}, }; @@ -19,7 +19,7 @@ use language::{ Anchor, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CharKind, CharScopeContext, OffsetRangeExt, PointUtf16, ToOffset, ToPointUtf16, Transaction, Unclipped, language_settings::{InlayHintKind, LanguageSettings}, - point_from_lsp, point_to_lsp, + lsp_to_symbol_kind, point_from_lsp, point_to_lsp, proto::{ deserialize_anchor, deserialize_anchor_range, deserialize_version, serialize_anchor, serialize_anchor_range, serialize_version, @@ -35,10 +35,9 @@ use lsp::{ use serde_json::Value; use signature_help::{lsp_to_proto_signature, proto_to_lsp_signature}; -use std::{ - cmp::Reverse, collections::hash_map, mem, ops::Range, path::Path, str::FromStr, sync::Arc, -}; +use std::{cmp::Reverse, collections::hash_map, 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 +188,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 +209,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 +707,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 +718,6 @@ impl LspCommand for GetDefinitions { &buffer.anchor_before(self.position), )), version: serialize_version(&buffer.version()), - workspace_only: self.workspace_only, } } @@ -734,7 +738,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 +767,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 +910,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 +1012,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 +1111,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 +1122,6 @@ impl LspCommand for GetTypeDefinitions { &buffer.anchor_before(self.position), )), version: serialize_version(&buffer.version()), - workspace_only: self.workspace_only, } } @@ -1040,7 +1142,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 +1171,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 +1363,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 +1410,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 +1605,48 @@ 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().as_unix_str().to_owned(), + 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_unix_str(&definition.path) + .context("invalid path")? + .into(), + }, + 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; @@ -1749,7 +2033,7 @@ impl LspCommand for GetDocumentSymbols { .into_iter() .map(|lsp_symbol| DocumentSymbol { name: lsp_symbol.name, - kind: lsp_symbol.kind, + kind: lsp_to_symbol_kind(lsp_symbol.kind), range: range_from_lsp(lsp_symbol.location.range), selection_range: range_from_lsp(lsp_symbol.location.range), children: Vec::new(), @@ -1759,7 +2043,7 @@ impl LspCommand for GetDocumentSymbols { fn convert_symbol(lsp_symbol: lsp::DocumentSymbol) -> DocumentSymbol { DocumentSymbol { name: lsp_symbol.name, - kind: lsp_symbol.kind, + kind: lsp_to_symbol_kind(lsp_symbol.kind), range: range_from_lsp(lsp_symbol.range), selection_range: range_from_lsp(lsp_symbol.selection_range), children: lsp_symbol @@ -1811,7 +2095,7 @@ impl LspCommand for GetDocumentSymbols { fn convert_symbol_to_proto(symbol: DocumentSymbol) -> proto::DocumentSymbol { proto::DocumentSymbol { name: symbol.name.clone(), - kind: unsafe { mem::transmute::(symbol.kind) }, + kind: symbol.kind as i32, start: Some(proto::PointUtf16 { row: symbol.range.start.0.row, column: symbol.range.start.0.column, @@ -1854,8 +2138,7 @@ impl LspCommand for GetDocumentSymbols { fn deserialize_symbol_with_children( serialized_symbol: proto::DocumentSymbol, ) -> Result { - let kind = - unsafe { mem::transmute::(serialized_symbol.kind) }; + let kind = language::SymbolKind::from_proto(serialized_symbol.kind); let start = serialized_symbol.start.context("invalid start")?; let end = serialized_symbol.end.context("invalid end")?; diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 0500d15c33e..38117a9731c 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -83,7 +83,7 @@ use language::{ AllLanguageSettings, FormatOnSave, Formatter, LanguageSettings, LineEndingSetting, all_language_settings, }, - modeline, point_to_lsp, + lsp_to_symbol_kind, modeline, point_to_lsp, proto::{ deserialize_anchor, deserialize_anchor_range, deserialize_version, serialize_anchor, serialize_anchor_range, serialize_version, @@ -98,7 +98,7 @@ use lsp::{ FileOperationRegistrationOptions, FileRename, FileSystemWatcher, LanguageServer, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerId, LanguageServerName, LanguageServerSelector, LspRequestFuture, MessageActionItem, MessageType, OneOf, - RenameFilesParams, SymbolKind, TextDocumentSyncSaveOptions, TextEdit, Uri, WillRenameFiles, + RenameFilesParams, TextDocumentSyncSaveOptions, TextEdit, Uri, WillRenameFiles, WorkDoneProgressCancelParams, WorkspaceFolder, notification::DidRenameFiles, }; use node_runtime::read_package_installed_version; @@ -4273,7 +4273,7 @@ struct CoreSymbol { pub source_language_server_id: LanguageServerId, pub path: SymbolLocation, pub name: String, - pub kind: lsp::SymbolKind, + pub kind: language::SymbolKind, pub range: Range>, pub container_name: Option, } @@ -4823,8 +4823,21 @@ impl LspStore { let diagnostic_updates = local .language_servers - .keys() - .cloned() + .iter() + .filter_map(|(server_id, state)| { + let supports_workspace_diagnostics = match state { + LanguageServerState::Running { + workspace_diagnostics_refresh_tasks, + .. + } => !workspace_diagnostics_refresh_tasks.is_empty(), + _ => false, + }; + if supports_workspace_diagnostics { + None + } else { + Some(*server_id) + } + }) .map(|server_id| DocumentDiagnosticsUpdate { diagnostics: DocumentDiagnostics { document_abs_path: buffer_abs_path.clone(), @@ -6171,31 +6184,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 +6211,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 +6234,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 +6250,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 +6425,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 +6450,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 +6473,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 { @@ -8117,7 +8173,7 @@ impl LspStore { server_id: LanguageServerId, lsp_adapter: Arc, worktree: WeakEntity, - lsp_symbols: Vec<(String, SymbolKind, lsp::Location, Option)>, + lsp_symbols: Vec<(String, language::SymbolKind, lsp::Location, Option)>, } let mut requests = Vec::new(); @@ -8187,7 +8243,7 @@ impl LspStore { .map(|lsp_symbol| { ( lsp_symbol.name, - lsp_symbol.kind, + lsp_to_symbol_kind(lsp_symbol.kind), lsp_symbol.location, lsp_symbol.container_name, ) @@ -8211,7 +8267,7 @@ impl LspStore { }; Some(( lsp_symbol.name, - lsp_symbol.kind, + lsp_to_symbol_kind(lsp_symbol.kind), location, lsp_symbol.container_name, )) @@ -8712,7 +8768,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, @@ -9003,7 +9059,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, @@ -9014,7 +9070,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, @@ -9098,7 +9154,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, @@ -9615,6 +9671,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 +9719,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 @@ -9871,7 +9959,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| { @@ -9940,7 +10028,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); @@ -9975,7 +10065,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, @@ -9986,7 +10076,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, @@ -11464,7 +11554,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, @@ -12479,7 +12569,7 @@ impl LspStore { source_worktree_id: symbol.source_worktree_id.to_proto(), language_server_id: symbol.source_language_server_id.to_proto(), name: symbol.name.clone(), - kind: unsafe { mem::transmute::(symbol.kind) }, + kind: symbol.kind as i32, start: Some(proto::PointUtf16 { row: symbol.range.start.0.row, column: symbol.range.start.0.column, @@ -12496,7 +12586,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, @@ -12512,13 +12602,14 @@ impl LspStore { fn deserialize_symbol(serialized_symbol: proto::Symbol) -> Result { let source_worktree_id = WorktreeId::from_proto(serialized_symbol.source_worktree_id); let worktree_id = WorktreeId::from_proto(serialized_symbol.worktree_id); - let kind = unsafe { mem::transmute::(serialized_symbol.kind) }; + let kind = language::SymbolKind::from_proto(serialized_symbol.kind); 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 { @@ -14765,7 +14856,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/lsp_store/document_symbols.rs b/crates/project/src/lsp_store/document_symbols.rs index 98f66d9fdb3..babb08ffc1d 100644 --- a/crates/project/src/lsp_store/document_symbols.rs +++ b/crates/project/src/lsp_store/document_symbols.rs @@ -328,6 +328,7 @@ fn enriched_symbol_text( mod tests { use super::*; use gpui::TestAppContext; + use language::lsp_to_symbol_kind; use text::{OffsetRangeExt, Point, Unclipped}; fn make_symbol( @@ -340,7 +341,7 @@ mod tests { use text::PointUtf16; DocumentSymbol { name: name.to_string(), - kind, + kind: lsp_to_symbol_kind(kind), range: Unclipped(PointUtf16::new(range.start.0, range.start.1)) ..Unclipped(PointUtf16::new(range.end.0, range.end.1)), selection_range: Unclipped(PointUtf16::new( 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/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