diff --git a/.github/ISSUE_TEMPLATE/10_bug_report.yml b/.github/ISSUE_TEMPLATE/10_bug_report.yml index e132eca1e52bc617f35fc2ec6e4e34fe3c796b11..1bf6c80e4073dafa90e736f995053c570f0ba2da 100644 --- a/.github/ISSUE_TEMPLATE/10_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/10_bug_report.yml @@ -14,7 +14,7 @@ body: ### Description diff --git a/Cargo.lock b/Cargo.lock index 5ceb015927208d8dda15d4fe10b8f92968a45a26..9735ada538c930289cd5b47e8cf2332881a1bb14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -191,9 +191,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.0.31" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "289eb34ee17213dadcca47eedadd386a5e7678094095414e475965d1bcca2860" +checksum = "6b91e5ec3ce05e8effb2a7a3b7b1a587daa6699b9f98bbde6a35e44b8c6c773a" dependencies = [ "anyhow", "async-broadcast", @@ -292,23 +292,21 @@ dependencies = [ "anyhow", "client", "collections", - "context_server", "env_logger 0.11.8", "fs", "futures 0.3.31", "gpui", "gpui_tokio", "indoc", - "itertools 0.14.0", "language", "language_model", "language_models", "libc", "log", "nix 0.29.0", + "node_runtime", "paths", "project", - "rand 0.8.5", "reqwest_client", "schemars", "semver", @@ -316,12 +314,10 @@ dependencies = [ "serde_json", "settings", "smol", - "strum 0.27.1", "tempfile", "thiserror 2.0.12", "ui", "util", - "uuid", "watch", "which 6.0.3", "workspace-hack", @@ -9213,6 +9209,7 @@ dependencies = [ "language", "lsp", "project", + "proto", "release_channel", "serde_json", "settings", @@ -17185,8 +17182,7 @@ dependencies = [ [[package]] name = "tree-sitter-cpp" version = "0.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +source = "git+https://github.com/tree-sitter/tree-sitter-cpp?rev=5cb9b693cfd7bfacab1d9ff4acac1a4150700609#5cb9b693cfd7bfacab1d9ff4acac1a4150700609" dependencies = [ "cc", "tree-sitter-language", @@ -20396,7 +20392,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.202.0" +version = "0.203.0" dependencies = [ "acp_tools", "activity_indicator", @@ -20590,7 +20586,7 @@ dependencies = [ [[package]] name = "zed_html" -version = "0.2.1" +version = "0.2.2" dependencies = [ "zed_extension_api 0.1.0", ] diff --git a/Cargo.toml b/Cargo.toml index 6ec243a9b9de4d2ab322e0466e804fa542a1ed35..974796a5e5ff4a3093fc8b492628e1c6d33a616a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -426,7 +426,7 @@ zlog_settings = { path = "crates/zlog_settings" } # External crates # -agent-client-protocol = "0.0.31" +agent-client-protocol = "0.1" aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } any_vec = "0.14" @@ -624,7 +624,7 @@ tower-http = "0.4.4" tree-sitter = { version = "0.25.6", features = ["wasm"] } tree-sitter-bash = "0.25.0" tree-sitter-c = "0.23" -tree-sitter-cpp = "0.23" +tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } tree-sitter-css = "0.23" tree-sitter-diff = "0.1.0" tree-sitter-elixir = "0.3" diff --git a/assets/icons/list_filter.svg b/assets/icons/list_filter.svg new file mode 100644 index 0000000000000000000000000000000000000000..82f41f5f6832a8cb35e2703e0f8ce36d148454dd --- /dev/null +++ b/assets/icons/list_filter.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/terminal_ghost.svg b/assets/icons/terminal_ghost.svg new file mode 100644 index 0000000000000000000000000000000000000000..7d0d0e068e8a6f01837e860e8223690a95541769 --- /dev/null +++ b/assets/icons/terminal_ghost.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/acp_grid.svg b/assets/images/acp_grid.svg new file mode 100644 index 0000000000000000000000000000000000000000..8ebff8e1bc87b17e536c7f97dfa2118130233258 --- /dev/null +++ b/assets/images/acp_grid.svg @@ -0,0 +1,1257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/acp_logo.svg b/assets/images/acp_logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..efaa46707be0a893917c3fc072a14b9c7b6b0c9b --- /dev/null +++ b/assets/images/acp_logo.svg @@ -0,0 +1 @@ + diff --git a/assets/images/acp_logo_serif.svg b/assets/images/acp_logo_serif.svg new file mode 100644 index 0000000000000000000000000000000000000000..6bc359cf82dde8060a66c051c8727f0e0624b938 --- /dev/null +++ b/assets/images/acp_logo_serif.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index e84f4834af58ffcac596a73264260d7d1f922d89..2610f9b7051cbce74ce6df13d49699c74e870395 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -40,7 +40,7 @@ "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-alt-z": "edit_prediction::RateCompletions", - "ctrl-shift-i": "edit_prediction::ToggleMenu", + "ctrl-alt-shift-i": "edit_prediction::ToggleMenu", "ctrl-alt-l": "lsp_tool::ToggleMenu" } }, @@ -120,7 +120,7 @@ "alt-g m": "git::OpenModifiedFiles", "menu": "editor::OpenContextMenu", "shift-f10": "editor::OpenContextMenu", - "ctrl-shift-e": "editor::ToggleEditPrediction", + "ctrl-alt-shift-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", "shift-f9": "editor::EditLogBreakpoint" } @@ -130,8 +130,8 @@ "bindings": { "shift-enter": "editor::Newline", "enter": "editor::Newline", - "ctrl-enter": "editor::NewlineAbove", - "ctrl-shift-enter": "editor::NewlineBelow", + "ctrl-enter": "editor::NewlineBelow", + "ctrl-shift-enter": "editor::NewlineAbove", "ctrl-k ctrl-z": "editor::ToggleSoftWrap", "ctrl-k z": "editor::ToggleSoftWrap", "find": "buffer_search::Deploy", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json new file mode 100644 index 0000000000000000000000000000000000000000..dbd377409f4423dd12bac06b651efd079772dbb5 --- /dev/null +++ b/assets/keymaps/default-windows.json @@ -0,0 +1,1260 @@ +[ + // Standard Windows bindings + { + "use_key_equivalents": true, + "bindings": { + "home": "menu::SelectFirst", + "shift-pageup": "menu::SelectFirst", + "pageup": "menu::SelectFirst", + "end": "menu::SelectLast", + "shift-pagedown": "menu::SelectLast", + "pagedown": "menu::SelectLast", + "ctrl-n": "menu::SelectNext", + "tab": "menu::SelectNext", + "down": "menu::SelectNext", + "ctrl-p": "menu::SelectPrevious", + "shift-tab": "menu::SelectPrevious", + "up": "menu::SelectPrevious", + "enter": "menu::Confirm", + "ctrl-enter": "menu::SecondaryConfirm", + "ctrl-escape": "menu::Cancel", + "ctrl-c": "menu::Cancel", + "escape": "menu::Cancel", + "shift-alt-enter": "menu::Restart", + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + "ctrl-alt-enter": ["picker::ConfirmInput", { "secondary": true }], + "ctrl-shift-w": "workspace::CloseWindow", + "shift-escape": "workspace::ToggleZoom", + "open": "workspace::Open", + "ctrl-o": "workspace::Open", + "ctrl-=": ["zed::IncreaseBufferFontSize", { "persist": false }], + "ctrl-shift-=": ["zed::IncreaseBufferFontSize", { "persist": false }], + "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], + "ctrl-0": ["zed::ResetBufferFontSize", { "persist": false }], + "ctrl-,": "zed::OpenSettings", + "ctrl-q": "zed::Quit", + "f4": "debugger::Start", + "shift-f5": "debugger::Stop", + "ctrl-shift-f5": "debugger::RerunSession", + "f6": "debugger::Pause", + "f7": "debugger::StepOver", + "ctrl-f11": "debugger::StepInto", + "shift-f11": "debugger::StepOut", + "f11": "zed::ToggleFullScreen", + "ctrl-shift-i": "edit_prediction::ToggleMenu", + "shift-alt-l": "lsp_tool::ToggleMenu" + } + }, + { + "context": "Picker || menu", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext" + } + }, + { + "context": "Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "editor::Cancel", + "shift-backspace": "editor::Backspace", + "backspace": "editor::Backspace", + "delete": "editor::Delete", + "tab": "editor::Tab", + "shift-tab": "editor::Backtab", + "ctrl-k": "editor::CutToEndOfLine", + "ctrl-k ctrl-q": "editor::Rewrap", + "ctrl-k q": "editor::Rewrap", + "ctrl-backspace": "editor::DeleteToPreviousWordStart", + "ctrl-delete": "editor::DeleteToNextWordEnd", + "cut": "editor::Cut", + "shift-delete": "editor::Cut", + "ctrl-x": "editor::Cut", + "copy": "editor::Copy", + "ctrl-insert": "editor::Copy", + "ctrl-c": "editor::Copy", + "paste": "editor::Paste", + "shift-insert": "editor::Paste", + "ctrl-v": "editor::Paste", + "undo": "editor::Undo", + "ctrl-z": "editor::Undo", + "redo": "editor::Redo", + "ctrl-y": "editor::Redo", + "ctrl-shift-z": "editor::Redo", + "up": "editor::MoveUp", + "ctrl-up": "editor::LineUp", + "ctrl-down": "editor::LineDown", + "pageup": "editor::MovePageUp", + "alt-pageup": "editor::PageUp", + "shift-pageup": "editor::SelectPageUp", + "home": ["editor::MoveToBeginningOfLine", { "stop_at_soft_wraps": true, "stop_at_indent": true }], + "down": "editor::MoveDown", + "pagedown": "editor::MovePageDown", + "alt-pagedown": "editor::PageDown", + "shift-pagedown": "editor::SelectPageDown", + "end": ["editor::MoveToEndOfLine", { "stop_at_soft_wraps": true }], + "left": "editor::MoveLeft", + "right": "editor::MoveRight", + "ctrl-left": "editor::MoveToPreviousWordStart", + "ctrl-right": "editor::MoveToNextWordEnd", + "ctrl-home": "editor::MoveToBeginning", + "ctrl-end": "editor::MoveToEnd", + "shift-up": "editor::SelectUp", + "shift-down": "editor::SelectDown", + "shift-left": "editor::SelectLeft", + "shift-right": "editor::SelectRight", + "ctrl-shift-left": "editor::SelectToPreviousWordStart", + "ctrl-shift-right": "editor::SelectToNextWordEnd", + "ctrl-shift-home": "editor::SelectToBeginning", + "ctrl-shift-end": "editor::SelectToEnd", + "ctrl-a": "editor::SelectAll", + "ctrl-l": "editor::SelectLine", + "shift-alt-f": "editor::Format", + "shift-alt-o": "editor::OrganizeImports", + "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true, "stop_at_indent": true }], + "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }], + "ctrl-alt-space": "editor::ShowCharacterPalette", + "ctrl-;": "editor::ToggleLineNumbers", + "ctrl-'": "editor::ToggleSelectedDiffHunks", + "ctrl-\"": "editor::ExpandAllDiffHunks", + "ctrl-i": "editor::ShowSignatureHelp", + "alt-g b": "git::Blame", + "alt-g m": "git::OpenModifiedFiles", + "menu": "editor::OpenContextMenu", + "shift-f10": "editor::OpenContextMenu", + "ctrl-shift-e": "editor::ToggleEditPrediction", + "f9": "editor::ToggleBreakpoint", + "shift-f9": "editor::EditLogBreakpoint" + } + }, + { + "context": "Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "shift-enter": "editor::Newline", + "enter": "editor::Newline", + "ctrl-enter": "editor::NewlineBelow", + "ctrl-shift-enter": "editor::NewlineAbove", + "ctrl-k ctrl-z": "editor::ToggleSoftWrap", + "ctrl-k z": "editor::ToggleSoftWrap", + "find": "buffer_search::Deploy", + "ctrl-f": "buffer_search::Deploy", + "ctrl-h": "buffer_search::DeployReplace", + "ctrl-shift-.": "assistant::QuoteSelection", + "ctrl-shift-,": "assistant::InsertIntoEditor", + "shift-alt-e": "editor::SelectEnclosingSymbol", + "ctrl-shift-backspace": "editor::GoToPreviousChange", + "ctrl-shift-alt-backspace": "editor::GoToNextChange", + "alt-enter": "editor::OpenSelectionsInMultibuffer" + } + }, + { + "context": "Editor && mode == full && edit_prediction", + "use_key_equivalents": true, + "bindings": { + "alt-]": "editor::NextEditPrediction", + "alt-[": "editor::PreviousEditPrediction" + } + }, + { + "context": "Editor && !edit_prediction", + "use_key_equivalents": true, + "bindings": { + "alt-\\": "editor::ShowEditPrediction" + } + }, + { + "context": "Editor && mode == auto_height", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "editor::Newline", + "shift-enter": "editor::Newline", + "ctrl-shift-enter": "editor::NewlineBelow" + } + }, + { + "context": "Markdown", + "use_key_equivalents": true, + "bindings": { + "copy": "markdown::Copy", + "ctrl-c": "markdown::Copy" + } + }, + { + "context": "Editor && jupyter && !ContextEditor", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-enter": "repl::Run", + "ctrl-alt-enter": "repl::RunInPlace" + } + }, + { + "context": "Editor && !agent_diff", + "use_key_equivalents": true, + "bindings": { + "ctrl-k ctrl-r": "git::Restore", + "alt-y": "git::StageAndNext", + "shift-alt-y": "git::UnstageAndNext" + } + }, + { + "context": "Editor && editor_agent_diff", + "use_key_equivalents": true, + "bindings": { + "ctrl-y": "agent::Keep", + "ctrl-n": "agent::Reject", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll", + "ctrl-shift-r": "agent::OpenAgentDiff" + } + }, + { + "context": "AgentDiff", + "use_key_equivalents": true, + "bindings": { + "ctrl-y": "agent::Keep", + "ctrl-n": "agent::Reject", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" + } + }, + { + "context": "ContextEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "assistant::Assist", + "ctrl-s": "workspace::Save", + "save": "workspace::Save", + "ctrl-shift-,": "assistant::InsertIntoEditor", + "shift-enter": "assistant::Split", + "ctrl-r": "assistant::CycleMessageRole", + "enter": "assistant::ConfirmCommand", + "alt-enter": "editor::Newline", + "ctrl-k c": "assistant::CopyCode", + "ctrl-g": "search::SelectNextMatch", + "ctrl-shift-g": "search::SelectPreviousMatch", + "ctrl-k l": "agent::OpenRulesLibrary" + } + }, + { + "context": "AgentPanel", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewThread", + "shift-alt-n": "agent::NewTextThread", + "ctrl-shift-h": "agent::OpenHistory", + "shift-alt-c": "agent::OpenSettings", + "shift-alt-p": "agent::OpenRulesLibrary", + "ctrl-i": "agent::ToggleProfileSelector", + "shift-alt-/": "agent::ToggleModelSelector", + "ctrl-shift-a": "agent::ToggleContextPicker", + "ctrl-shift-j": "agent::ToggleNavigationMenu", + "ctrl-shift-i": "agent::ToggleOptionsMenu", + // "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", + "shift-alt-escape": "agent::ExpandMessageEditor", + "ctrl-shift-.": "assistant::QuoteSelection", + "shift-alt-e": "agent::RemoveAllContext", + "ctrl-shift-e": "project_panel::ToggleFocus", + "ctrl-shift-enter": "agent::ContinueThread", + "super-ctrl-b": "agent::ToggleBurnMode", + "alt-enter": "agent::ContinueWithBurnMode" + } + }, + { + "context": "AgentPanel > NavigationMenu", + "use_key_equivalents": true, + "bindings": { + "shift-backspace": "agent::DeleteRecentlyOpenThread" + } + }, + { + "context": "AgentPanel > Markdown", + "use_key_equivalents": true, + "bindings": { + "copy": "markdown::CopyAsMarkdown", + "ctrl-c": "markdown::CopyAsMarkdown" + } + }, + { + "context": "AgentPanel && prompt_editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewTextThread", + "ctrl-alt-t": "agent::NewThread" + } + }, + { + "context": "AgentPanel && external_agent_thread", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewExternalAgentThread", + "ctrl-alt-t": "agent::NewThread" + } + }, + { + "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + "ctrl-enter": "agent::ChatWithFollow", + "ctrl-i": "agent::ToggleProfileSelector", + "ctrl-shift-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" + } + }, + { + "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "agent::Chat", + "enter": "editor::Newline", + "ctrl-i": "agent::ToggleProfileSelector", + "ctrl-shift-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" + } + }, + { + "context": "EditMessageEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "enter": "menu::Confirm", + "alt-enter": "editor::Newline" + } + }, + { + "context": "AgentFeedbackMessageEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "enter": "menu::Confirm", + "alt-enter": "editor::Newline" + } + }, + { + "context": "ContextStrip", + "use_key_equivalents": true, + "bindings": { + "up": "agent::FocusUp", + "right": "agent::FocusRight", + "left": "agent::FocusLeft", + "down": "agent::FocusDown", + "backspace": "agent::RemoveFocusedContext", + "enter": "agent::AcceptSuggestedContext" + } + }, + { + "context": "AcpThread > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + "ctrl-shift-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" + } + }, + { + "context": "ThreadHistory", + "use_key_equivalents": true, + "bindings": { + "backspace": "agent::RemoveSelectedThread" + } + }, + { + "context": "PromptLibrary", + "use_key_equivalents": true, + "bindings": { + "new": "rules_library::NewRule", + "ctrl-n": "rules_library::NewRule", + "ctrl-shift-s": "rules_library::ToggleDefaultRule" + } + }, + { + "context": "BufferSearchBar", + "use_key_equivalents": true, + "bindings": { + "escape": "buffer_search::Dismiss", + "tab": "buffer_search::FocusEditor", + "enter": "search::SelectNextMatch", + "shift-enter": "search::SelectPreviousMatch", + "alt-enter": "search::SelectAllMatches", + "find": "search::FocusSearch", + "ctrl-f": "search::FocusSearch", + "ctrl-h": "search::ToggleReplace", + "ctrl-l": "search::ToggleSelection" + } + }, + { + "context": "BufferSearchBar && in_replace > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "search::ReplaceNext", + "ctrl-enter": "search::ReplaceAll" + } + }, + { + "context": "BufferSearchBar && !in_replace > Editor", + "use_key_equivalents": true, + "bindings": { + "up": "search::PreviousHistoryQuery", + "down": "search::NextHistoryQuery" + } + }, + { + "context": "ProjectSearchBar", + "use_key_equivalents": true, + "bindings": { + "escape": "project_search::ToggleFocus", + "shift-find": "search::FocusSearch", + "ctrl-shift-f": "search::FocusSearch", + "ctrl-shift-h": "search::ToggleReplace", + "alt-r": "search::ToggleRegex" // vscode + } + }, + { + "context": "ProjectSearchBar > Editor", + "use_key_equivalents": true, + "bindings": { + "up": "search::PreviousHistoryQuery", + "down": "search::NextHistoryQuery" + } + }, + { + "context": "ProjectSearchBar && in_replace > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "search::ReplaceNext", + "ctrl-alt-enter": "search::ReplaceAll" + } + }, + { + "context": "ProjectSearchView", + "use_key_equivalents": true, + "bindings": { + "escape": "project_search::ToggleFocus", + "ctrl-shift-h": "search::ToggleReplace", + "alt-r": "search::ToggleRegex" // vscode + } + }, + { + "context": "Pane", + "use_key_equivalents": true, + "bindings": { + "alt-1": ["pane::ActivateItem", 0], + "alt-2": ["pane::ActivateItem", 1], + "alt-3": ["pane::ActivateItem", 2], + "alt-4": ["pane::ActivateItem", 3], + "alt-5": ["pane::ActivateItem", 4], + "alt-6": ["pane::ActivateItem", 5], + "alt-7": ["pane::ActivateItem", 6], + "alt-8": ["pane::ActivateItem", 7], + "alt-9": ["pane::ActivateItem", 8], + "alt-0": "pane::ActivateLastItem", + "ctrl-pageup": "pane::ActivatePreviousItem", + "ctrl-pagedown": "pane::ActivateNextItem", + "ctrl-shift-pageup": "pane::SwapItemLeft", + "ctrl-shift-pagedown": "pane::SwapItemRight", + "ctrl-f4": ["pane::CloseActiveItem", { "close_pinned": false }], + "ctrl-w": ["pane::CloseActiveItem", { "close_pinned": false }], + "ctrl-shift-alt-t": ["pane::CloseOtherItems", { "close_pinned": false }], + "ctrl-shift-alt-w": "workspace::CloseInactiveTabsAndPanes", + "ctrl-k e": ["pane::CloseItemsToTheLeft", { "close_pinned": false }], + "ctrl-k t": ["pane::CloseItemsToTheRight", { "close_pinned": false }], + "ctrl-k u": ["pane::CloseCleanItems", { "close_pinned": false }], + "ctrl-k w": ["pane::CloseAllItems", { "close_pinned": false }], + "ctrl-k ctrl-w": "workspace::CloseAllItemsAndPanes", + "back": "pane::GoBack", + "alt--": "pane::GoBack", + "alt-=": "pane::GoForward", + "forward": "pane::GoForward", + "f3": "search::SelectNextMatch", + "shift-f3": "search::SelectPreviousMatch", + "shift-find": "project_search::ToggleFocus", + "ctrl-shift-f": "project_search::ToggleFocus", + "shift-alt-h": "search::ToggleReplace", + "alt-l": "search::ToggleSelection", + "alt-enter": "search::SelectAllMatches", + "alt-c": "search::ToggleCaseSensitive", + "alt-w": "search::ToggleWholeWord", + "alt-find": "project_search::ToggleFilters", + "alt-f": "project_search::ToggleFilters", + "alt-r": "search::ToggleRegex", + // "ctrl-shift-alt-x": "search::ToggleRegex", + "ctrl-k shift-enter": "pane::TogglePinTab" + } + }, + // Bindings from VS Code + { + "context": "Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-[": "editor::Outdent", + "ctrl-]": "editor::Indent", + "ctrl-shift-alt-up": "editor::AddSelectionAbove", // Insert Cursor Above + "ctrl-shift-alt-down": "editor::AddSelectionBelow", // Insert Cursor Below + "ctrl-shift-k": "editor::DeleteLine", + "alt-up": "editor::MoveLineUp", + "alt-down": "editor::MoveLineDown", + "shift-alt-up": "editor::DuplicateLineUp", + "shift-alt-down": "editor::DuplicateLineDown", + "shift-alt-right": "editor::SelectLargerSyntaxNode", // Expand Selection + "shift-alt-left": "editor::SelectSmallerSyntaxNode", // Shrink Selection + "ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection + "ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word + "ctrl-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand + "ctrl-shift-down": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch + "ctrl-shift-up": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch + "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip + "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch + "ctrl-k ctrl-i": "editor::Hover", + "ctrl-k ctrl-b": "editor::BlameHover", + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], + "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], + "f2": "editor::Rename", + "f12": "editor::GoToDefinition", + "alt-f12": "editor::GoToDefinitionSplit", + "ctrl-shift-f10": "editor::GoToDefinitionSplit", + "ctrl-f12": "editor::GoToImplementation", + "shift-f12": "editor::GoToTypeDefinition", + "ctrl-alt-f12": "editor::GoToTypeDefinitionSplit", + "shift-alt-f12": "editor::FindAllReferences", + "ctrl-m": "editor::MoveToEnclosingBracket", // from jetbrains + "ctrl-shift-\\": "editor::MoveToEnclosingBracket", + "ctrl-shift-[": "editor::Fold", + "ctrl-shift-]": "editor::UnfoldLines", + "ctrl-k ctrl-l": "editor::ToggleFold", + "ctrl-k ctrl-[": "editor::FoldRecursive", + "ctrl-k ctrl-]": "editor::UnfoldRecursive", + "ctrl-k ctrl-1": ["editor::FoldAtLevel", 1], + "ctrl-k ctrl-2": ["editor::FoldAtLevel", 2], + "ctrl-k ctrl-3": ["editor::FoldAtLevel", 3], + "ctrl-k ctrl-4": ["editor::FoldAtLevel", 4], + "ctrl-k ctrl-5": ["editor::FoldAtLevel", 5], + "ctrl-k ctrl-6": ["editor::FoldAtLevel", 6], + "ctrl-k ctrl-7": ["editor::FoldAtLevel", 7], + "ctrl-k ctrl-8": ["editor::FoldAtLevel", 8], + "ctrl-k ctrl-9": ["editor::FoldAtLevel", 9], + "ctrl-k ctrl-0": "editor::FoldAll", + "ctrl-k ctrl-j": "editor::UnfoldAll", + "ctrl-space": "editor::ShowCompletions", + "ctrl-shift-space": "editor::ShowWordCompletions", + "ctrl-.": "editor::ToggleCodeActions", + "ctrl-k r": "editor::RevealInFileManager", + "ctrl-k p": "editor::CopyPath", + "ctrl-\\": "pane::SplitRight", + "ctrl-shift-alt-c": "editor::DisplayCursorNames", + "alt-.": "editor::GoToHunk", + "alt-,": "editor::GoToPreviousHunk" + } + }, + { + "context": "Editor && extension == md", + "use_key_equivalents": true, + "bindings": { + "ctrl-k v": "markdown::OpenPreviewToTheSide", + "ctrl-shift-v": "markdown::OpenPreview" + } + }, + { + "context": "Editor && extension == svg", + "use_key_equivalents": true, + "bindings": { + "ctrl-k v": "svg::OpenPreviewToTheSide", + "ctrl-shift-v": "svg::OpenPreview" + } + }, + { + "context": "Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-o": "outline::Toggle", + "ctrl-g": "go_to_line::Toggle" + } + }, + { + "context": "Workspace", + "use_key_equivalents": true, + "bindings": { + "alt-open": ["projects::OpenRecent", { "create_new_window": false }], + // Change the default action on `menu::Confirm` by setting the parameter + // "ctrl-alt-o": ["projects::OpenRecent", { "create_new_window": true }], + "ctrl-r": ["projects::OpenRecent", { "create_new_window": false }], + "shift-alt-open": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], + // Change to open path modal for existing remote connection by setting the parameter + // "ctrl-shift-alt-o": "["projects::OpenRemote", { "from_existing_connection": true }]", + "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], + "shift-alt-b": "branches::OpenRecent", + "shift-alt-enter": "toast::RunAction", + "ctrl-shift-`": "workspace::NewTerminal", + "save": "workspace::Save", + "ctrl-s": "workspace::Save", + "ctrl-k ctrl-shift-s": "workspace::SaveWithoutFormat", + "shift-save": "workspace::SaveAs", + "ctrl-shift-s": "workspace::SaveAs", + "new": "workspace::NewFile", + "ctrl-n": "workspace::NewFile", + "shift-new": "workspace::NewWindow", + "ctrl-shift-n": "workspace::NewWindow", + "ctrl-`": "terminal_panel::ToggleFocus", + "f10": ["app_menu::OpenApplicationMenu", "Zed"], + "alt-1": ["workspace::ActivatePane", 0], + "alt-2": ["workspace::ActivatePane", 1], + "alt-3": ["workspace::ActivatePane", 2], + "alt-4": ["workspace::ActivatePane", 3], + "alt-5": ["workspace::ActivatePane", 4], + "alt-6": ["workspace::ActivatePane", 5], + "alt-7": ["workspace::ActivatePane", 6], + "alt-8": ["workspace::ActivatePane", 7], + "alt-9": ["workspace::ActivatePane", 8], + "ctrl-alt-b": "workspace::ToggleRightDock", + "ctrl-b": "workspace::ToggleLeftDock", + "ctrl-j": "workspace::ToggleBottomDock", + "ctrl-shift-y": "workspace::CloseAllDocks", + "alt-r": "workspace::ResetActiveDockSize", + // For 0px parameter, uses UI font size value. + "shift-alt--": ["workspace::DecreaseActiveDockSize", { "px": 0 }], + "shift-alt-=": ["workspace::IncreaseActiveDockSize", { "px": 0 }], + "shift-alt-0": "workspace::ResetOpenDocksSize", + "ctrl-shift-alt--": ["workspace::DecreaseOpenDocksSize", { "px": 0 }], + "ctrl-shift-alt-=": ["workspace::IncreaseOpenDocksSize", { "px": 0 }], + "shift-find": "pane::DeploySearch", + "ctrl-shift-f": "pane::DeploySearch", + "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], + "ctrl-shift-t": "pane::ReopenClosedItem", + "ctrl-k ctrl-s": "zed::OpenKeymapEditor", + "ctrl-k ctrl-t": "theme_selector::Toggle", + "ctrl-alt-super-p": "settings_profile_selector::Toggle", + "ctrl-t": "project_symbols::Toggle", + "ctrl-p": "file_finder::Toggle", + "ctrl-tab": "tab_switcher::Toggle", + "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], + "ctrl-e": "file_finder::Toggle", + "f1": "command_palette::Toggle", + "ctrl-shift-p": "command_palette::Toggle", + "ctrl-shift-m": "diagnostics::Deploy", + "ctrl-shift-e": "project_panel::ToggleFocus", + "ctrl-shift-b": "outline_panel::ToggleFocus", + "ctrl-shift-g": "git_panel::ToggleFocus", + "ctrl-shift-d": "debug_panel::ToggleFocus", + "ctrl-shift-/": "agent::ToggleFocus", + "alt-save": "workspace::SaveAll", + "ctrl-k s": "workspace::SaveAll", + "ctrl-k m": "language_selector::Toggle", + "escape": "workspace::Unfollow", + "ctrl-k ctrl-left": "workspace::ActivatePaneLeft", + "ctrl-k ctrl-right": "workspace::ActivatePaneRight", + "ctrl-k ctrl-up": "workspace::ActivatePaneUp", + "ctrl-k ctrl-down": "workspace::ActivatePaneDown", + "ctrl-k shift-left": "workspace::SwapPaneLeft", + "ctrl-k shift-right": "workspace::SwapPaneRight", + "ctrl-k shift-up": "workspace::SwapPaneUp", + "ctrl-k shift-down": "workspace::SwapPaneDown", + "ctrl-shift-x": "zed::Extensions", + "ctrl-shift-r": "task::Rerun", + "alt-t": "task::Rerun", + "shift-alt-t": "task::Spawn", + "shift-alt-r": ["task::Spawn", { "reveal_target": "center" }], + // also possible to spawn tasks by name: + // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }] + // or by tag: + // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], + "f5": "debugger::Rerun", + "ctrl-f4": "workspace::CloseActiveDock", + "ctrl-w": "workspace::CloseActiveDock" + } + }, + { + "context": "Workspace && debugger_running", + "use_key_equivalents": true, + "bindings": { + "f5": "zed::NoAction" + } + }, + { + "context": "Workspace && debugger_stopped", + "use_key_equivalents": true, + "bindings": { + "f5": "debugger::Continue" + } + }, + { + "context": "ApplicationMenu", + "use_key_equivalents": true, + "bindings": { + "f10": "menu::Cancel", + "left": "app_menu::ActivateMenuLeft", + "right": "app_menu::ActivateMenuRight" + } + }, + // Bindings from Sublime Text + { + "context": "Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-u": "editor::UndoSelection", + "ctrl-shift-u": "editor::RedoSelection", + "ctrl-shift-j": "editor::JoinLines", + "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart", + "shift-alt-h": "editor::DeleteToPreviousSubwordStart", + "ctrl-alt-delete": "editor::DeleteToNextSubwordEnd", + "shift-alt-d": "editor::DeleteToNextSubwordEnd", + "ctrl-alt-left": "editor::MoveToPreviousSubwordStart", + "ctrl-alt-right": "editor::MoveToNextSubwordEnd", + "ctrl-shift-alt-left": "editor::SelectToPreviousSubwordStart", + "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd" + } + }, + // Bindings from Atom + { + "context": "Pane", + "use_key_equivalents": true, + "bindings": { + "ctrl-k up": "pane::SplitUp", + "ctrl-k down": "pane::SplitDown", + "ctrl-k left": "pane::SplitLeft", + "ctrl-k right": "pane::SplitRight" + } + }, + // Bindings that should be unified with bindings for more general actions + { + "context": "Editor && renaming", + "use_key_equivalents": true, + "bindings": { + "enter": "editor::ConfirmRename" + } + }, + { + "context": "Editor && showing_completions", + "use_key_equivalents": true, + "bindings": { + "enter": "editor::ConfirmCompletion", + "shift-enter": "editor::ConfirmCompletionReplace", + "tab": "editor::ComposeCompletion" + } + }, + // Bindings for accepting edit predictions + // + // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This is + // because alt-tab may not be available, as it is often used for window switching. + { + "context": "Editor && edit_prediction", + "use_key_equivalents": true, + "bindings": { + "alt-tab": "editor::AcceptEditPrediction", + "alt-l": "editor::AcceptEditPrediction", + "tab": "editor::AcceptEditPrediction", + "alt-right": "editor::AcceptPartialEditPrediction" + } + }, + { + "context": "Editor && edit_prediction_conflict", + "use_key_equivalents": true, + "bindings": { + "alt-tab": "editor::AcceptEditPrediction", + "alt-l": "editor::AcceptEditPrediction", + "alt-right": "editor::AcceptPartialEditPrediction" + } + }, + { + "context": "Editor && showing_code_actions", + "use_key_equivalents": true, + "bindings": { + "enter": "editor::ConfirmCodeAction" + } + }, + { + "context": "Editor && (showing_code_actions || showing_completions)", + "use_key_equivalents": true, + "bindings": { + "ctrl-p": "editor::ContextMenuPrevious", + "up": "editor::ContextMenuPrevious", + "ctrl-n": "editor::ContextMenuNext", + "down": "editor::ContextMenuNext", + "pageup": "editor::ContextMenuFirst", + "pagedown": "editor::ContextMenuLast" + } + }, + { + "context": "Editor && showing_signature_help && !showing_completions", + "use_key_equivalents": true, + "bindings": { + "up": "editor::SignatureHelpPrevious", + "down": "editor::SignatureHelpNext" + } + }, + // Custom bindings + { + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-alt-f": "workspace::FollowNextCollaborator", + // Only available in debug builds: opens an element inspector for development. + "shift-alt-i": "dev::ToggleInspector" + } + }, + { + "context": "!Terminal", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-c": "collab_panel::ToggleFocus" + } + }, + { + "context": "!ContextEditor > Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "alt-enter": "editor::OpenExcerpts", + "shift-enter": "editor::ExpandExcerpts", + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + "ctrl-shift-e": "pane::RevealInProjectPanel", + "ctrl-f8": "editor::GoToHunk", + "ctrl-shift-f8": "editor::GoToPreviousHunk", + "ctrl-enter": "assistant::InlineAssist", + "ctrl-shift-;": "editor::ToggleInlayHints" + } + }, + { + "context": "PromptEditor", + "use_key_equivalents": true, + "bindings": { + "ctrl-[": "agent::CyclePreviousInlineAssist", + "ctrl-]": "agent::CycleNextInlineAssist", + "shift-alt-e": "agent::RemoveAllContext" + } + }, + { + "context": "Prompt", + "use_key_equivalents": true, + "bindings": { + "left": "menu::SelectPrevious", + "right": "menu::SelectNext", + "h": "menu::SelectPrevious", + "l": "menu::SelectNext" + } + }, + { + "context": "ProjectSearchBar && !in_replace", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "project_search::SearchInNew" + } + }, + { + "context": "OutlinePanel && not_editing", + "use_key_equivalents": true, + "bindings": { + "left": "outline_panel::CollapseSelectedEntry", + "right": "outline_panel::ExpandSelectedEntry", + "alt-copy": "outline_panel::CopyPath", + "shift-alt-c": "outline_panel::CopyPath", + "shift-alt-copy": "workspace::CopyRelativePath", + "ctrl-shift-alt-c": "workspace::CopyRelativePath", + "ctrl-alt-r": "outline_panel::RevealInFileManager", + "space": "outline_panel::OpenSelectedEntry", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrevious", + "alt-enter": "editor::OpenExcerpts", + "ctrl-alt-enter": "editor::OpenExcerptsSplit" + } + }, + { + "context": "ProjectPanel", + "use_key_equivalents": true, + "bindings": { + "left": "project_panel::CollapseSelectedEntry", + "right": "project_panel::ExpandSelectedEntry", + "new": "project_panel::NewFile", + "ctrl-n": "project_panel::NewFile", + "alt-new": "project_panel::NewDirectory", + "alt-n": "project_panel::NewDirectory", + "cut": "project_panel::Cut", + "ctrl-x": "project_panel::Cut", + "copy": "project_panel::Copy", + "ctrl-insert": "project_panel::Copy", + "ctrl-c": "project_panel::Copy", + "paste": "project_panel::Paste", + "shift-insert": "project_panel::Paste", + "ctrl-v": "project_panel::Paste", + "alt-copy": "project_panel::CopyPath", + "shift-alt-c": "project_panel::CopyPath", + "shift-alt-copy": "workspace::CopyRelativePath", + "ctrl-k ctrl-shift-c": "workspace::CopyRelativePath", + "enter": "project_panel::Rename", + "f2": "project_panel::Rename", + "backspace": ["project_panel::Trash", { "skip_prompt": false }], + "delete": ["project_panel::Trash", { "skip_prompt": false }], + "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], + "ctrl-backspace": ["project_panel::Delete", { "skip_prompt": false }], + "ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }], + "ctrl-alt-r": "project_panel::RevealInFileManager", + "ctrl-shift-enter": "project_panel::OpenWithSystem", + "alt-d": "project_panel::CompareMarkedFiles", + "shift-find": "project_panel::NewSearchInDirectory", + "ctrl-k ctrl-shift-f": "project_panel::NewSearchInDirectory", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrevious", + "escape": "menu::Cancel" + } + }, + { + "context": "ProjectPanel && not_editing", + "use_key_equivalents": true, + "bindings": { + "space": "project_panel::Open" + } + }, + { + "context": "GitPanel && ChangesList", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext", + "enter": "menu::Confirm", + "alt-y": "git::StageFile", + "shift-alt-y": "git::UnstageFile", + "space": "git::ToggleStaged", + "shift-space": "git::StageRange", + "tab": "git_panel::FocusEditor", + "shift-tab": "git_panel::FocusEditor", + "escape": "git_panel::ToggleFocus", + "alt-enter": "menu::SecondaryConfirm", + "delete": ["git::RestoreFile", { "skip_prompt": false }], + "backspace": ["git::RestoreFile", { "skip_prompt": false }], + "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], + "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] + } + }, + { + "context": "GitPanel && CommitEditor", + "use_key_equivalents": true, + "bindings": { + "escape": "git::Cancel" + } + }, + { + "context": "GitCommit > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "enter": "editor::Newline", + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", + "alt-l": "git::GenerateCommitMessage" + } + }, + { + "context": "GitPanel", + "use_key_equivalents": true, + "bindings": { + "ctrl-g ctrl-g": "git::Fetch", + "ctrl-g up": "git::Push", + "ctrl-g down": "git::Pull", + "ctrl-g shift-up": "git::ForcePush", + "ctrl-g d": "git::Diff", + "ctrl-g backspace": "git::RestoreTrackedFiles", + "ctrl-g shift-backspace": "git::TrashUntrackedFiles", + "ctrl-space": "git::StageAll", + "ctrl-shift-space": "git::UnstageAll", + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend" + } + }, + { + "context": "GitDiff > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", + "ctrl-space": "git::StageAll", + "ctrl-shift-space": "git::UnstageAll" + } + }, + { + "context": "AskPass > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "menu::Confirm" + } + }, + { + "context": "CommitEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "git_panel::FocusChanges", + "tab": "git_panel::FocusChanges", + "shift-tab": "git_panel::FocusChanges", + "enter": "editor::Newline", + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", + "alt-up": "git_panel::FocusChanges", + "alt-l": "git::GenerateCommitMessage" + } + }, + { + "context": "DebugPanel", + "use_key_equivalents": true, + "bindings": { + "ctrl-t": "debugger::ToggleThreadPicker", + "ctrl-i": "debugger::ToggleSessionPicker", + "shift-alt-escape": "debugger::ToggleExpandItem" + } + }, + { + "context": "VariableList", + "use_key_equivalents": true, + "bindings": { + "left": "variable_list::CollapseSelectedEntry", + "right": "variable_list::ExpandSelectedEntry", + "enter": "variable_list::EditVariable", + "ctrl-c": "variable_list::CopyVariableValue", + "ctrl-alt-c": "variable_list::CopyVariableName", + "delete": "variable_list::RemoveWatch", + "backspace": "variable_list::RemoveWatch", + "alt-enter": "variable_list::AddWatch" + } + }, + { + "context": "BreakpointList", + "use_key_equivalents": true, + "bindings": { + "space": "debugger::ToggleEnableBreakpoint", + "backspace": "debugger::UnsetBreakpoint", + "left": "debugger::PreviousBreakpointProperty", + "right": "debugger::NextBreakpointProperty" + } + }, + { + "context": "CollabPanel && not_editing", + "use_key_equivalents": true, + "bindings": { + "ctrl-backspace": "collab_panel::Remove", + "space": "menu::Confirm" + } + }, + { + "context": "CollabPanel", + "use_key_equivalents": true, + "bindings": { + "alt-up": "collab_panel::MoveChannelUp", + "alt-down": "collab_panel::MoveChannelDown" + } + }, + { + "context": "(CollabPanel && editing) > Editor", + "use_key_equivalents": true, + "bindings": { + "space": "collab_panel::InsertSpace" + } + }, + { + "context": "ChannelModal", + "use_key_equivalents": true, + "bindings": { + "tab": "channel_modal::ToggleMode" + } + }, + { + "context": "Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "up": "menu::SelectPrevious", + "down": "menu::SelectNext", + "tab": "picker::ConfirmCompletion", + "alt-enter": ["picker::ConfirmInput", { "secondary": false }] + } + }, + { + "context": "ChannelModal > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "tab": "channel_modal::ToggleMode" + } + }, + { + "context": "FileFinder || (FileFinder > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-p": "file_finder::Toggle", + "ctrl-shift-a": "file_finder::ToggleSplitMenu", + "ctrl-shift-i": "file_finder::ToggleFilterMenu" + } + }, + { + "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-p": "file_finder::SelectPrevious", + "ctrl-j": "pane::SplitDown", + "ctrl-k": "pane::SplitUp", + "ctrl-h": "pane::SplitLeft", + "ctrl-l": "pane::SplitRight" + } + }, + { + "context": "TabSwitcher", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-tab": "menu::SelectPrevious", + "ctrl-up": "menu::SelectPrevious", + "ctrl-down": "menu::SelectNext", + "ctrl-backspace": "tab_switcher::CloseSelectedItem" + } + }, + { + "context": "Terminal", + "use_key_equivalents": true, + "bindings": { + "ctrl-alt-space": "terminal::ShowCharacterPalette", + "copy": "terminal::Copy", + "ctrl-insert": "terminal::Copy", + "ctrl-shift-c": "terminal::Copy", + "paste": "terminal::Paste", + "shift-insert": "terminal::Paste", + "ctrl-shift-v": "terminal::Paste", + "ctrl-enter": "assistant::InlineAssist", + "alt-b": ["terminal::SendText", "\u001bb"], + "alt-f": ["terminal::SendText", "\u001bf"], + "alt-.": ["terminal::SendText", "\u001b."], + "ctrl-delete": ["terminal::SendText", "\u001bd"], + // Overrides for conflicting keybindings + "ctrl-b": ["terminal::SendKeystroke", "ctrl-b"], + "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"], + "ctrl-e": ["terminal::SendKeystroke", "ctrl-e"], + "ctrl-o": ["terminal::SendKeystroke", "ctrl-o"], + "ctrl-w": ["terminal::SendKeystroke", "ctrl-w"], + "ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"], + "ctrl-shift-a": "editor::SelectAll", + "find": "buffer_search::Deploy", + "ctrl-shift-f": "buffer_search::Deploy", + "ctrl-shift-l": "terminal::Clear", + "ctrl-shift-w": "pane::CloseActiveItem", + "up": ["terminal::SendKeystroke", "up"], + "pageup": ["terminal::SendKeystroke", "pageup"], + "down": ["terminal::SendKeystroke", "down"], + "pagedown": ["terminal::SendKeystroke", "pagedown"], + "escape": ["terminal::SendKeystroke", "escape"], + "enter": ["terminal::SendKeystroke", "enter"], + "shift-pageup": "terminal::ScrollPageUp", + "shift-pagedown": "terminal::ScrollPageDown", + "shift-up": "terminal::ScrollLineUp", + "shift-down": "terminal::ScrollLineDown", + "shift-home": "terminal::ScrollToTop", + "shift-end": "terminal::ScrollToBottom", + "ctrl-shift-space": "terminal::ToggleViMode", + "ctrl-shift-r": "terminal::RerunTask", + "ctrl-alt-r": "terminal::RerunTask", + "alt-t": "terminal::RerunTask" + } + }, + { + "context": "ZedPredictModal", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel" + } + }, + { + "context": "ConfigureContextServerModal > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "enter": "editor::Newline", + "ctrl-enter": "menu::Confirm" + } + }, + { + "context": "OnboardingAiConfigurationModal", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel" + } + }, + { + "context": "Diagnostics", + "use_key_equivalents": true, + "bindings": { + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" + } + }, + { + "context": "DebugConsole > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "menu::Confirm", + "alt-enter": "console::WatchExpression" + } + }, + { + "context": "RunModal", + "use_key_equivalents": true, + "bindings": { + "ctrl-tab": "pane::ActivateNextItem", + "ctrl-shift-tab": "pane::ActivatePreviousItem" + } + }, + { + "context": "MarkdownPreview", + "use_key_equivalents": true, + "bindings": { + "pageup": "markdown::MovePageUp", + "pagedown": "markdown::MovePageDown" + } + }, + { + "context": "KeymapEditor", + "use_key_equivalents": true, + "bindings": { + "ctrl-f": "search::FocusSearch", + "alt-find": "keymap_editor::ToggleKeystrokeSearch", + "alt-f": "keymap_editor::ToggleKeystrokeSearch", + "alt-c": "keymap_editor::ToggleConflictFilter", + "enter": "keymap_editor::EditBinding", + "alt-enter": "keymap_editor::CreateBinding", + "ctrl-c": "keymap_editor::CopyAction", + "ctrl-shift-c": "keymap_editor::CopyContext", + "ctrl-t": "keymap_editor::ShowMatchingKeybinds" + } + }, + { + "context": "KeystrokeInput", + "use_key_equivalents": true, + "bindings": { + "enter": "keystroke_input::StartRecording", + "escape escape escape": "keystroke_input::StopRecording", + "delete": "keystroke_input::ClearKeystrokes" + } + }, + { + "context": "KeybindEditorModal", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "menu::Confirm", + "escape": "menu::Cancel" + } + }, + { + "context": "KeybindEditorModal > Editor", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext" + } + }, + { + "context": "Onboarding", + "use_key_equivalents": true, + "bindings": { + "ctrl-1": "onboarding::ActivateBasicsPage", + "ctrl-2": "onboarding::ActivateEditingPage", + "ctrl-3": "onboarding::ActivateAISetupPage", + "ctrl-escape": "onboarding::Finish", + "alt-tab": "onboarding::SignIn", + "shift-alt-a": "onboarding::OpenAccount" + } + } +] diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index 0ff3796f03d85affdae88d009e88e73516ba385a..62910e297bb18f52917477806ceea1b79dcb5d86 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -38,6 +38,7 @@ "alt-;": ["editor::ToggleComments", { "advance_downwards": false }], "ctrl-x ctrl-;": "editor::ToggleComments", "alt-.": "editor::GoToDefinition", // xref-find-definitions + "alt-?": "editor::FindAllReferences", // xref-find-references "alt-,": "pane::GoBack", // xref-pop-marker-stack "ctrl-x h": "editor::SelectAll", // mark-whole-buffer "ctrl-d": "editor::Delete", // delete-char diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index 0ff3796f03d85affdae88d009e88e73516ba385a..62910e297bb18f52917477806ceea1b79dcb5d86 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -38,6 +38,7 @@ "alt-;": ["editor::ToggleComments", { "advance_downwards": false }], "ctrl-x ctrl-;": "editor::ToggleComments", "alt-.": "editor::GoToDefinition", // xref-find-definitions + "alt-?": "editor::FindAllReferences", // xref-find-references "alt-,": "pane::GoBack", // xref-pop-marker-stack "ctrl-x h": "editor::SelectAll", // mark-whole-buffer "ctrl-d": "editor::Delete", // delete-char diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 62e50b3c8c21a39f6185e7e1c293c949ce6c6af8..0a88baee027a3ae4d72409f5f142ceda3f4d9717 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -428,12 +428,14 @@ "g h": "vim::StartOfLine", "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s" "g e": "vim::EndOfDocument", + "g .": "vim::HelixGotoLastModification", // go to last modification "g r": "editor::FindAllReferences", // zed specific "g t": "vim::WindowTop", "g c": "vim::WindowMiddle", "g b": "vim::WindowBottom", - "x": "editor::SelectLine", + "shift-r": "editor::Paste", + "x": "vim::HelixSelectLine", "shift-x": "editor::SelectLine", "%": "editor::SelectAll", // Window mode diff --git a/assets/settings/default.json b/assets/settings/default.json index f0b9e11e57f074ac3c50b4e830343f7a5290f965..57a5d13eab281d6bffec2f299fbb1e2d5a3a01c5 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -363,6 +363,8 @@ // Whether to show code action buttons in the editor toolbar. "code_actions": false }, + // Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). + "use_system_window_tabs": false, // Titlebar related settings "title_bar": { // Whether to show the branch icon beside branch switcher in the titlebar. @@ -653,6 +655,8 @@ // "never" "show": "always" }, + // Whether to enable drag-and-drop operations in the project panel. + "drag_and_drop": true, // Whether to hide the root entry when only one folder is open in the window. "hide_root": false }, @@ -1581,7 +1585,7 @@ "ensure_final_newline_on_save": false }, "Elixir": { - "language_servers": ["elixir-ls", "!next-ls", "!lexical", "..."] + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."] }, "Elm": { "tab_size": 4 @@ -1606,7 +1610,7 @@ } }, "HEEX": { - "language_servers": ["elixir-ls", "!next-ls", "!lexical", "..."] + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."] }, "HTML": { "prettier": { diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index a79c550671f85d7b107db5e85883caa28fe41411..5cead67b6d5bb89e878e3bfb8d250dcbbd2ce447 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -43,8 +43,8 @@ // "args": ["--login"] // } // } - "shell": "system", + "shell": "system" // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. - "tags": [] + // "tags": [] } ] diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 4ded647a746f18e030529e4c14a00c1ffd2335e3..04ff032ad40c600c80fed7cff9f48139b2307931 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -789,16 +789,12 @@ pub enum ThreadStatus { #[derive(Debug, Clone)] pub enum LoadError { - NotInstalled { - error_message: SharedString, - install_message: SharedString, - install_command: String, - }, Unsupported { - error_message: SharedString, - upgrade_message: SharedString, - upgrade_command: String, + command: SharedString, + current_version: SharedString, + minimum_version: SharedString, }, + FailedToInstall(SharedString), Exited { status: ExitStatus, }, @@ -808,12 +804,19 @@ pub enum LoadError { impl Display for LoadError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - LoadError::NotInstalled { error_message, .. } - | LoadError::Unsupported { error_message, .. } => { - write!(f, "{error_message}") + LoadError::Unsupported { + command: path, + current_version, + minimum_version, + } => { + write!( + f, + "version {current_version} from {path} is not supported (need at least {minimum_version})" + ) } + LoadError::FailedToInstall(msg) => write!(f, "Failed to install: {msg}"), LoadError::Exited { status } => write!(f, "Server exited with status {status}"), - LoadError::Other(msg) => write!(f, "{}", msg), + LoadError::Other(msg) => write!(f, "{msg}"), } } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 899e360ab01226cdb6b47b37713f99e45c0ef6b7..7b70fde56ab1e7acb6705aeace82f142dc28a9f3 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -664,7 +664,7 @@ impl Thread { } pub fn get_or_init_configured_model(&mut self, cx: &App) -> Option { - if self.configured_model.is_none() || self.messages.is_empty() { + if self.configured_model.is_none() { self.configured_model = LanguageModelRegistry::read_global(cx).default_model(); } self.configured_model.clone() @@ -2097,7 +2097,7 @@ impl Thread { } pub fn summarize(&mut self, cx: &mut Context) { - let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model(cx) else { + let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model() else { println!("No thread summary model"); return; }; @@ -2416,7 +2416,7 @@ impl Thread { } let Some(ConfiguredModel { model, provider }) = - LanguageModelRegistry::read_global(cx).thread_summary_model(cx) + LanguageModelRegistry::read_global(cx).thread_summary_model() else { return; }; @@ -5410,10 +5410,13 @@ fn main() {{ }), cx, ); - registry.set_thread_summary_model(Some(ConfiguredModel { - provider, - model: model.clone(), - })); + registry.set_thread_summary_model( + Some(ConfiguredModel { + provider, + model: model.clone(), + }), + cx, + ); }) }); diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index ecfaea4b4967a019da83edb3416cef66464c229e..ea80df8fb52cffab80c8c64307b75de7f0954a56 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -61,16 +61,19 @@ pub struct LanguageModels { model_list: acp_thread::AgentModelList, refresh_models_rx: watch::Receiver<()>, refresh_models_tx: watch::Sender<()>, + _authenticate_all_providers_task: Task<()>, } impl LanguageModels { - fn new(cx: &App) -> Self { + fn new(cx: &mut App) -> Self { let (refresh_models_tx, refresh_models_rx) = watch::channel(()); + let mut this = Self { models: HashMap::default(), model_list: acp_thread::AgentModelList::Grouped(IndexMap::default()), refresh_models_rx, refresh_models_tx, + _authenticate_all_providers_task: Self::authenticate_all_language_model_providers(cx), }; this.refresh_list(cx); this @@ -90,7 +93,7 @@ impl LanguageModels { let mut recommended = Vec::new(); for provider in &providers { for model in provider.recommended_models(cx) { - recommended_models.insert(model.id()); + recommended_models.insert((model.provider_id(), model.id())); recommended.push(Self::map_language_model_to_info(&model, provider)); } } @@ -107,7 +110,7 @@ impl LanguageModels { for model in provider.provided_models(cx) { let model_info = Self::map_language_model_to_info(&model, &provider); let model_id = model_info.id.clone(); - if !recommended_models.contains(&model.id()) { + if !recommended_models.contains(&(model.provider_id(), model.id())) { provider_models.push(model_info); } models.insert(model_id, model); @@ -150,6 +153,52 @@ impl LanguageModels { fn model_id(model: &Arc) -> acp_thread::AgentModelId { acp_thread::AgentModelId(format!("{}/{}", model.provider_id().0, model.id().0).into()) } + + fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> { + let authenticate_all_providers = LanguageModelRegistry::global(cx) + .read(cx) + .providers() + .iter() + .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) + .collect::>(); + + cx.background_spawn(async move { + for (provider_id, provider_name, authenticate_task) in authenticate_all_providers { + if let Err(err) = authenticate_task.await { + if matches!(err, language_model::AuthenticateError::CredentialsNotFound) { + // Since we're authenticating these providers in the + // background for the purposes of populating the + // language selector, we don't care about providers + // where the credentials are not found. + } else { + // Some providers have noisy failure states that we + // don't want to spam the logs with every time the + // language model selector is initialized. + // + // Ideally these should have more clear failure modes + // that we know are safe to ignore here, like what we do + // with `CredentialsNotFound` above. + match provider_id.0.as_ref() { + "lmstudio" | "ollama" => { + // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". + // + // These fail noisily, so we don't log them. + } + "copilot_chat" => { + // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. + } + _ => { + log::error!( + "Failed to authenticate provider: {}: {err}", + provider_name.0 + ); + } + } + } + } + } + }) + } } pub struct NativeAgent { @@ -228,7 +277,7 @@ impl NativeAgent { ) -> Entity { let connection = Rc::new(NativeAgentConnection(cx.entity())); let registry = LanguageModelRegistry::read_global(cx); - let summarization_model = registry.thread_summary_model(cx).map(|c| c.model); + let summarization_model = registry.thread_summary_model().map(|c| c.model); thread_handle.update(cx, |thread, cx| { thread.set_summarization_model(summarization_model, cx); @@ -524,7 +573,7 @@ impl NativeAgent { let registry = LanguageModelRegistry::read_global(cx); let default_model = registry.default_model().map(|m| m.model); - let summarization_model = registry.thread_summary_model(cx).map(|m| m.model); + let summarization_model = registry.thread_summary_model().map(|m| m.model); for session in self.sessions.values_mut() { session.thread.update(cx, |thread, cx| { diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index 9ff98ccd18dec4d9a17a1a7161cd3622dacf0d3f..030d2cce746970bd9c8a0c7f0f5e1516eb68fcaf 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -1,10 +1,9 @@ use std::{any::Any, path::Path, rc::Rc, sync::Arc}; -use agent_servers::AgentServer; +use agent_servers::{AgentServer, AgentServerDelegate}; use anyhow::Result; use fs::Fs; use gpui::{App, Entity, SharedString, Task}; -use project::Project; use prompt_store::PromptStore; use crate::{HistoryStore, NativeAgent, NativeAgentConnection, templates::Templates}; @@ -30,14 +29,6 @@ impl AgentServer for NativeAgentServer { "Zed Agent".into() } - fn empty_state_headline(&self) -> SharedString { - self.name() - } - - fn empty_state_message(&self) -> SharedString { - "".into() - } - fn logo(&self) -> ui::IconName { ui::IconName::ZedAgent } @@ -45,14 +36,14 @@ impl AgentServer for NativeAgentServer { fn connect( &self, _root_dir: &Path, - project: &Entity, + delegate: AgentServerDelegate, cx: &mut App, ) -> Task>> { log::debug!( "NativeAgentServer::connect called for path: {:?}", _root_dir ); - let project = project.clone(); + let project = delegate.project().clone(); let fs = self.fs.clone(); let history = self.history.clone(); let prompt_store = PromptStore::global(cx); diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 093b8ba971ae5509478122f93180c124bd16eab5..fbeee46a484a71742dd4ce52b537bebb5da91924 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -72,6 +72,7 @@ async fn test_echo(cx: &mut TestAppContext) { } #[gpui::test] +#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows async fn test_thinking(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); @@ -471,7 +472,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { tool_name: ToolRequiringPermission::name().into(), is_error: true, content: "Permission to run tool denied by user".into(), - output: None + output: Some("Permission to run tool denied by user".into()) }) ] ); @@ -1821,11 +1822,11 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let clock = Arc::new(clock::FakeSystemClock::new()); let client = Client::new(clock, http_client, cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - Project::init_settings(cx); - agent_settings::init(cx); language_model::init(client.clone(), cx); language_models::init(user_store, client.clone(), cx); + Project::init_settings(cx); LanguageModelRegistry::test(cx); + agent_settings::init(cx); }); cx.executor().forbid_parking(); diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 1b1c014b7930091271b541e9d7cd12281274ec14..97ea1caf1d766be0314a16cc0f518ad701564569 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -732,7 +732,17 @@ impl Thread { stream.update_tool_call_fields( &tool_use.id, acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), + status: Some( + tool_result + .as_ref() + .map_or(acp::ToolCallStatus::Failed, |result| { + if result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + } + }), + ), raw_output: output, ..Default::default() }, @@ -1557,7 +1567,7 @@ impl Thread { tool_name: tool_use.name, is_error: true, content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())), - output: None, + output: Some(error.to_string().into()), }, } })) @@ -2459,6 +2469,30 @@ impl ToolCallEventStreamReceiver { } } + pub async fn expect_update_fields(&mut self) -> acp::ToolCallUpdateFields { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( + update, + )))) = event + { + update.fields + } else { + panic!("Expected update fields but got: {:?}", event); + } + } + + pub async fn expect_diff(&mut self) -> Entity { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateDiff( + update, + )))) = event + { + update.diff + } else { + panic!("Expected diff but got: {:?}", event); + } + } + pub async fn expect_terminal(&mut self) -> Entity { let event = self.0.next().await; if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateTerminal( diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 5a68d0c70a04aea7367b8264c34d139d3602cc44..f86bfd25f74556118c827050837ec5beda37d471 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -273,6 +273,13 @@ impl AgentTool for EditFileTool { let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?; event_stream.update_diff(diff.clone()); + let _finalize_diff = util::defer({ + let diff = diff.downgrade(); + let mut cx = cx.clone(); + move || { + diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok(); + } + }); let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; let old_text = cx @@ -389,8 +396,6 @@ impl AgentTool for EditFileTool { }) .await; - diff.update(cx, |diff, cx| diff.finalize(cx)).ok(); - let input_path = input.path.display(); if unified_diff.is_empty() { anyhow::ensure!( @@ -1545,6 +1550,100 @@ mod tests { ); } + #[gpui::test] + async fn test_diff_finalization(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({"main.rs": ""})).await; + + let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await; + let languages = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry.clone(), + Templates::new(), + Some(model.clone()), + cx, + ) + }); + + // Ensure the diff is finalized after the edit completes. + { + let tool = Arc::new(EditFileTool::new(thread.downgrade(), languages.clone())); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + EditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: EditFileMode::Edit, + }, + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + cx.run_until_parked(); + model.end_last_completion_stream(); + edit.await.unwrap(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + + // Ensure the diff is finalized if an error occurs while editing. + { + model.forbid_requests(); + let tool = Arc::new(EditFileTool::new(thread.downgrade(), languages.clone())); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + EditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: EditFileMode::Edit, + }, + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + edit.await.unwrap_err(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + model.allow_requests(); + } + + // Ensure the diff is finalized if the tool call gets dropped. + { + let tool = Arc::new(EditFileTool::new(thread.downgrade(), languages.clone())); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + EditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: EditFileMode::Edit, + }, + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + drop(edit); + cx.run_until_parked(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); diff --git a/crates/agent2/src/tools/terminal_tool.rs b/crates/agent2/src/tools/terminal_tool.rs index f41b909d0b286b80bb3c9e8e8c18d0d03f3e05c7..2270a7c32f076bee774c7c8177c4276985adc0b6 100644 --- a/crates/agent2/src/tools/terminal_tool.rs +++ b/crates/agent2/src/tools/terminal_tool.rs @@ -2,7 +2,7 @@ use agent_client_protocol as acp; use anyhow::Result; use futures::{FutureExt as _, future::Shared}; use gpui::{App, AppContext, Entity, SharedString, Task}; -use project::{Project, terminals::TerminalKind}; +use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{ @@ -144,14 +144,14 @@ impl AgentTool for TerminalTool { let terminal = self .project .update(cx, |project, cx| { - project.create_terminal( - TerminalKind::Task(task::SpawnInTerminal { + project.create_terminal_task( + task::SpawnInTerminal { command: Some(program), args, cwd: working_dir.clone(), env, ..Default::default() - }), + }, cx, ) })? diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 9f90f3a78aed825c372cc8bffc67d194b7ec2027..222feb9aaa31a6ace1e13ba8943f416942e8918c 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -6,7 +6,7 @@ publish.workspace = true license = "GPL-3.0-or-later" [features] -test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "fs", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"] +test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"] e2e = [] [lints] @@ -25,21 +25,19 @@ agent_settings.workspace = true anyhow.workspace = true client = { workspace = true, optional = true } collections.workspace = true -context_server.workspace = true env_logger = { workspace = true, optional = true } -fs = { workspace = true, optional = true } +fs.workspace = true futures.workspace = true gpui.workspace = true gpui_tokio = { workspace = true, optional = true } indoc.workspace = true -itertools.workspace = true language.workspace = true language_model.workspace = true language_models.workspace = true log.workspace = true +node_runtime.workspace = true paths.workspace = true project.workspace = true -rand.workspace = true reqwest_client = { workspace = true, optional = true } schemars.workspace = true semver.workspace = true @@ -47,12 +45,10 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true -strum.workspace = true tempfile.workspace = true thiserror.workspace = true ui.workspace = true util.workspace = true -uuid.workspace = true watch.workspace = true which.workspace = true workspace-hack.workspace = true diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 9080fc1ab07d91e13708ccf0348d5df01d49e3c0..d929d1fc501fb2093f47f8bdeb4d3695b7b87ebf 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -3,6 +3,7 @@ use acp_thread::AgentConnection; use acp_tools::AcpConnectionRegistry; use action_log::ActionLog; use agent_client_protocol::{self as acp, Agent as _, ErrorCode}; +use agent_settings::AgentSettings; use anyhow::anyhow; use collections::HashMap; use futures::AsyncBufReadExt as _; @@ -10,6 +11,7 @@ use futures::channel::oneshot; use futures::io::BufReader; use project::Project; use serde::Deserialize; +use settings::Settings as _; use std::{any::Any, cell::RefCell}; use std::{path::Path, rc::Rc}; use thiserror::Error; @@ -30,6 +32,8 @@ pub struct AcpConnection { auth_methods: Vec, prompt_capabilities: acp::PromptCapabilities, _io_task: Task>, + _wait_task: Task>, + _stderr_task: Task>, } pub struct AcpSession { @@ -56,7 +60,7 @@ impl AcpConnection { root_dir: &Path, cx: &mut AsyncApp, ) -> Result { - let mut child = util::command::new_smol_command(&command.path) + let mut child = util::command::new_smol_command(command.path) .args(command.args.iter().map(|arg| arg.as_str())) .envs(command.env.iter().flatten()) .current_dir(root_dir) @@ -86,7 +90,7 @@ impl AcpConnection { let io_task = cx.background_spawn(io_task); - cx.background_spawn(async move { + let stderr_task = cx.background_spawn(async move { let mut stderr = BufReader::new(stderr); let mut line = String::new(); while let Ok(n) = stderr.read_line(&mut line).await @@ -95,10 +99,10 @@ impl AcpConnection { log::warn!("agent stderr: {}", &line); line.clear(); } - }) - .detach(); + Ok(()) + }); - cx.spawn({ + let wait_task = cx.spawn({ let sessions = sessions.clone(); async move |cx| { let status = child.status().await?; @@ -114,8 +118,7 @@ impl AcpConnection { anyhow::Ok(()) } - }) - .detach(); + }); let connection = Rc::new(connection); @@ -148,8 +151,14 @@ impl AcpConnection { sessions, prompt_capabilities: response.agent_capabilities.prompt_capabilities, _io_task: io_task, + _wait_task: wait_task, + _stderr_task: stderr_task, }) } + + pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities { + &self.prompt_capabilities + } } impl AgentConnection for AcpConnection { @@ -162,12 +171,34 @@ impl AgentConnection for AcpConnection { let conn = self.connection.clone(); let sessions = self.sessions.clone(); let cwd = cwd.to_path_buf(); + let context_server_store = project.read(cx).context_server_store().read(cx); + let mcp_servers = context_server_store + .configured_server_ids() + .iter() + .filter_map(|id| { + let configuration = context_server_store.configuration_for_server(id)?; + let command = configuration.command(); + Some(acp::McpServer { + name: id.0.to_string(), + command: command.path.clone(), + args: command.args.clone(), + env: if let Some(env) = command.env.as_ref() { + env.iter() + .map(|(name, value)| acp::EnvVariable { + name: name.clone(), + value: value.clone(), + }) + .collect() + } else { + vec![] + }, + }) + }) + .collect(); + cx.spawn(async move |cx| { let response = conn - .new_session(acp::NewSessionRequest { - mcp_servers: vec![], - cwd, - }) + .new_session(acp::NewSessionRequest { mcp_servers, cwd }) .await .map_err(|err| { if err.code == acp::ErrorCode::AUTH_REQUIRED.code { @@ -313,6 +344,28 @@ impl acp::Client for ClientDelegate { arguments: acp::RequestPermissionRequest, ) -> Result { let cx = &mut self.cx.clone(); + + // If always_allow_tool_actions is enabled, then auto-choose the first "Allow" button + if AgentSettings::try_read_global(cx, |settings| settings.always_allow_tool_actions) + .unwrap_or(false) + { + // Don't use AllowAlways, because then if you were to turn off always_allow_tool_actions, + // some tools would (incorrectly) continue to auto-accept. + if let Some(allow_once_option) = arguments.options.iter().find_map(|option| { + if matches!(option.kind, acp::PermissionOptionKind::AllowOnce) { + Some(option.id.clone()) + } else { + None + } + }) { + return Ok(acp::RequestPermissionResponse { + outcome: acp::RequestPermissionOutcome::Selected { + option_id: allow_once_option, + }, + }); + } + } + let rx = self .sessions .borrow() diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index 7c7e124ca71b684cdda7a24e02c82d1b6117a0cc..e1b4057b71b0b4aee84548df74935d9b0598f598 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -13,12 +13,19 @@ pub use gemini::*; pub use settings::*; use acp_thread::AgentConnection; +use acp_thread::LoadError; use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; use collections::HashMap; +use gpui::AppContext as _; use gpui::{App, AsyncApp, Entity, SharedString, Task}; +use node_runtime::VersionStrategy; use project::Project; use schemars::JsonSchema; +use semver::Version; use serde::{Deserialize, Serialize}; +use std::str::FromStr as _; use std::{ any::Any, path::{Path, PathBuf}, @@ -31,17 +38,108 @@ pub fn init(cx: &mut App) { settings::init(cx); } +pub struct AgentServerDelegate { + project: Entity, + status_tx: watch::Sender, +} + +impl AgentServerDelegate { + pub fn new(project: Entity, status_tx: watch::Sender) -> Self { + Self { project, status_tx } + } + + pub fn project(&self) -> &Entity { + &self.project + } + + fn get_or_npm_install_builtin_agent( + self, + binary_name: SharedString, + package_name: SharedString, + entrypoint_path: PathBuf, + ignore_system_version: bool, + minimum_version: Option, + cx: &mut App, + ) -> Task> { + let project = self.project; + let fs = project.read(cx).fs().clone(); + let Some(node_runtime) = project.read(cx).node_runtime().cloned() else { + return Task::ready(Err(anyhow!("Missing node runtime"))); + }; + let mut status_tx = self.status_tx; + + cx.spawn(async move |cx| { + if !ignore_system_version { + if let Some(bin) = find_bin_in_path(binary_name.clone(), &project, cx).await { + return Ok(AgentServerCommand { path: bin, args: Vec::new(), env: Default::default() }) + } + } + + cx.background_spawn(async move { + let node_path = node_runtime.binary_path().await?; + let dir = paths::data_dir().join("external_agents").join(binary_name.as_str()); + fs.create_dir(&dir).await?; + let local_executable_path = dir.join(entrypoint_path); + let command = AgentServerCommand { + path: node_path, + args: vec![local_executable_path.to_string_lossy().to_string()], + env: Default::default(), + }; + + let installed_version = node_runtime + .npm_package_installed_version(&dir, &package_name) + .await? + .filter(|version| { + Version::from_str(&version) + .is_ok_and(|version| Some(version) >= minimum_version) + }); + + status_tx.send("Checking for latest version…".into())?; + let latest_version = match node_runtime.npm_package_latest_version(&package_name).await + { + Ok(latest_version) => latest_version, + Err(e) => { + if let Some(installed_version) = installed_version { + log::error!("{e}"); + log::warn!("failed to fetch latest version of {package_name}, falling back to cached version {installed_version}"); + return Ok(command); + } else { + bail!(e); + } + } + }; + + let should_install = node_runtime + .should_install_npm_package( + &package_name, + &local_executable_path, + &dir, + VersionStrategy::Latest(&latest_version), + ) + .await; + + if should_install { + status_tx.send("Installing latest version…".into())?; + node_runtime + .npm_install_packages(&dir, &[(&package_name, &latest_version)]) + .await?; + } + + Ok(command) + }).await.map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into()) + }) + } +} + pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; fn name(&self) -> SharedString; - fn empty_state_headline(&self) -> SharedString; - fn empty_state_message(&self) -> SharedString; fn telemetry_id(&self) -> &'static str; fn connect( &self, root_dir: &Path, - project: &Entity, + delegate: AgentServerDelegate, cx: &mut App, ) -> Task>>; @@ -79,15 +177,6 @@ impl std::fmt::Debug for AgentServerCommand { } } -pub enum AgentServerVersion { - Supported, - Unsupported { - error_message: SharedString, - upgrade_message: SharedString, - upgrade_command: String, - }, -} - #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] pub struct AgentServerCommand { #[serde(rename = "command")] @@ -102,23 +191,16 @@ impl AgentServerCommand { path_bin_name: &'static str, extra_args: &[&'static str], fallback_path: Option<&Path>, - settings: Option, + settings: Option, project: &Entity, cx: &mut AsyncApp, ) -> Option { - if let Some(agent_settings) = settings { - Some(Self { - path: agent_settings.command.path, - args: agent_settings - .command - .args - .into_iter() - .chain(extra_args.iter().map(|arg| arg.to_string())) - .collect(), - env: agent_settings.command.env, - }) + if let Some(settings) = settings + && let Some(command) = settings.custom_command() + { + Some(command) } else { - match find_bin_in_path(path_bin_name, project, cx).await { + match find_bin_in_path(path_bin_name.into(), project, cx).await { Some(path) => Some(Self { path, args: extra_args.iter().map(|arg| arg.to_string()).collect(), @@ -141,7 +223,7 @@ impl AgentServerCommand { } async fn find_bin_in_path( - bin_name: &'static str, + bin_name: SharedString, project: &Entity, cx: &mut AsyncApp, ) -> Option { @@ -171,11 +253,11 @@ async fn find_bin_in_path( cx.background_executor() .spawn(async move { let which_result = if cfg!(windows) { - which::which(bin_name) + which::which(bin_name.as_str()) } else { let env = env_task.await.unwrap_or_default(); let shell_path = env.get("PATH").cloned(); - which::which_in(bin_name, shell_path.as_ref(), root_dir.as_ref()) + which::which_in(bin_name.as_str(), shell_path.as_ref(), root_dir.as_ref()) }; if let Err(which::Error::CannotFindBinaryPath) = which_result { diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 250e564526d5360be7a84f5cfd9511e5f73a2c1f..db8853695ec798a8b146666292cd29f2c1fc145c 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -1,47 +1,23 @@ -mod edit_tool; -mod mcp_server; -mod permission_tool; -mod read_tool; -pub mod tools; -mod write_tool; - -use action_log::ActionLog; -use collections::HashMap; -use context_server::listener::McpServerTool; use language_models::provider::anthropic::AnthropicLanguageModelProvider; -use project::Project; use settings::SettingsStore; -use smol::process::Child; use std::any::Any; -use std::cell::RefCell; -use std::fmt::Display; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::rc::Rc; -use util::command::new_smol_command; -use uuid::Uuid; -use agent_client_protocol as acp; -use anyhow::{Context as _, Result, anyhow}; -use futures::channel::oneshot; -use futures::{AsyncBufReadExt, AsyncWriteExt}; -use futures::{ - AsyncRead, AsyncWrite, FutureExt, StreamExt, - channel::mpsc::{self, UnboundedReceiver, UnboundedSender}, - io::BufReader, - select_biased, -}; -use gpui::{App, AppContext, AsyncApp, Entity, SharedString, Task, WeakEntity}; -use serde::{Deserialize, Serialize}; -use util::{ResultExt, debug_panic}; +use anyhow::Result; +use gpui::{App, AppContext as _, SharedString, Task}; -use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig}; -use crate::claude::tools::ClaudeTool; -use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings}; -use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri}; +use crate::{AgentServer, AgentServerDelegate, AllAgentServersSettings}; +use acp_thread::AgentConnection; #[derive(Clone)] pub struct ClaudeCode; +impl ClaudeCode { + const BINARY_NAME: &'static str = "claude-code-acp"; + const PACKAGE_NAME: &'static str = "@zed-industries/claude-code-acp"; +} + impl AgentServer for ClaudeCode { fn telemetry_id(&self) -> &'static str { "claude-code" @@ -51,1327 +27,55 @@ impl AgentServer for ClaudeCode { "Claude Code".into() } - fn empty_state_headline(&self) -> SharedString { - self.name() - } - - fn empty_state_message(&self) -> SharedString { - "How can I help you today?".into() - } - fn logo(&self) -> ui::IconName { ui::IconName::AiClaude } fn connect( &self, - _root_dir: &Path, - _project: &Entity, - _cx: &mut App, + root_dir: &Path, + delegate: AgentServerDelegate, + cx: &mut App, ) -> Task>> { - let connection = ClaudeAgentConnection { - sessions: Default::default(), - }; - - Task::ready(Ok(Rc::new(connection) as _)) - } - - fn into_any(self: Rc) -> Rc { - self - } -} - -struct ClaudeAgentConnection { - sessions: Rc>>, -} + let root_dir = root_dir.to_path_buf(); + let server_name = self.name(); + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).claude.clone() + }); -impl AgentConnection for ClaudeAgentConnection { - fn new_thread( - self: Rc, - project: Entity, - cwd: &Path, - cx: &mut App, - ) -> Task>> { - let cwd = cwd.to_owned(); cx.spawn(async move |cx| { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).claude.clone() - })?; - - let Some(command) = AgentServerCommand::resolve( - "claude", - &[], - Some(&util::paths::home_dir().join(".claude/local/claude")), - settings, - &project, - cx, - ) - .await - else { - return Err(LoadError::NotInstalled { - error_message: "Failed to find Claude Code binary".into(), - install_message: "Install Claude Code".into(), - install_command: "npm install -g @anthropic-ai/claude-code@latest".into(), - }.into()); - }; - - let api_key = - cx.update(AnthropicLanguageModelProvider::api_key)? - .await - .map_err(|err| { - if err.is::() { - anyhow!(AuthRequired::new().with_language_model_provider( - language_model::ANTHROPIC_PROVIDER_ID - )) - } else { - anyhow!(err) - } - })?; - - let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid()); - let fs = project.read_with(cx, |project, _cx| project.fs().clone())?; - let permission_mcp_server = ClaudeZedMcpServer::new(thread_rx.clone(), fs, cx).await?; - - let mut mcp_servers = HashMap::default(); - mcp_servers.insert( - mcp_server::SERVER_NAME.to_string(), - permission_mcp_server.server_config()?, - ); - let mcp_config = McpConfig { mcp_servers }; - - let mcp_config_file = tempfile::NamedTempFile::new()?; - let (mcp_config_file, mcp_config_path) = mcp_config_file.into_parts(); - - let mut mcp_config_file = smol::fs::File::from(mcp_config_file); - mcp_config_file - .write_all(serde_json::to_string(&mcp_config)?.as_bytes()) - .await?; - mcp_config_file.flush().await?; - - let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded(); - let (outgoing_tx, outgoing_rx) = mpsc::unbounded(); - - let session_id = acp::SessionId(Uuid::new_v4().to_string().into()); - - log::trace!("Starting session with id: {}", session_id); - - let mut child = spawn_claude( - &command, - ClaudeSessionMode::Start, - session_id.clone(), - api_key, - &mcp_config_path, - &cwd, - )?; - - let stdout = child.stdout.take().context("Failed to take stdout")?; - let stdin = child.stdin.take().context("Failed to take stdin")?; - let stderr = child.stderr.take().context("Failed to take stderr")?; - - let pid = child.id(); - log::trace!("Spawned (pid: {})", pid); - - cx.background_spawn(async move { - let mut stderr = BufReader::new(stderr); - let mut line = String::new(); - while let Ok(n) = stderr.read_line(&mut line).await - && n > 0 - { - log::warn!("agent stderr: {}", &line); - line.clear(); - } - }) - .detach(); - - cx.background_spawn(async move { - let mut outgoing_rx = Some(outgoing_rx); - - ClaudeAgentSession::handle_io( - outgoing_rx.take().unwrap(), - incoming_message_tx.clone(), - stdin, - stdout, - ) - .await?; - - log::trace!("Stopped (pid: {})", pid); - - drop(mcp_config_path); - anyhow::Ok(()) - }) - .detach(); - - let turn_state = Rc::new(RefCell::new(TurnState::None)); - - let handler_task = cx.spawn({ - let turn_state = turn_state.clone(); - let mut thread_rx = thread_rx.clone(); - async move |cx| { - while let Some(message) = incoming_message_rx.next().await { - ClaudeAgentSession::handle_message( - thread_rx.clone(), - message, - turn_state.clone(), - cx, - ) - .await - } - - if let Some(status) = child.status().await.log_err() - && let Some(thread) = thread_rx.recv().await.ok() - { - let version = claude_version(command.path.clone(), cx).await.log_err(); - let help = claude_help(command.path.clone(), cx).await.log_err(); - thread - .update(cx, |thread, cx| { - let error = if let Some(version) = version - && let Some(help) = help - && (!help.contains("--input-format") - || !help.contains("--session-id")) - { - LoadError::Unsupported { - error_message: format!( - "Your installed version of Claude Code ({}, version {}) does not have required features for use with Zed.", - command.path.to_string_lossy(), - version, - ) - .into(), - upgrade_message: "Upgrade Claude Code to latest".into(), - upgrade_command: format!( - "{} update", - command.path.to_string_lossy() - ), - } - } else { - LoadError::Exited { status } - }; - thread.emit_load_error(error, cx); - }) - .ok(); - } - } - }); - - let action_log = cx.new(|_| ActionLog::new(project.clone()))?; - let thread = cx.new(|cx| { - AcpThread::new( - "Claude Code", - self.clone(), - project, - action_log, - session_id.clone(), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: false, - embedded_context: true, - }), - cx, - ) - })?; - - thread_tx.send(thread.downgrade())?; - - let session = ClaudeAgentSession { - outgoing_tx, - turn_state, - _handler_task: handler_task, - _mcp_server: Some(permission_mcp_server), + let mut command = if let Some(settings) = settings { + settings.command + } else { + cx.update(|cx| { + delegate.get_or_npm_install_builtin_agent( + Self::BINARY_NAME.into(), + Self::PACKAGE_NAME.into(), + format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(), + true, + None, + cx, + ) + })? + .await? }; - self.sessions.borrow_mut().insert(session_id, session); + if let Some(api_key) = cx + .update(AnthropicLanguageModelProvider::api_key)? + .await + .ok() + { + command + .env + .get_or_insert_default() + .insert("ANTHROPIC_API_KEY".to_owned(), api_key.key); + } - Ok(thread) + crate::acp::connect(server_name, command.clone(), &root_dir, cx).await }) } - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] - } - - fn authenticate(&self, _: acp::AuthMethodId, _cx: &mut App) -> Task> { - Task::ready(Err(anyhow!("Authentication not supported"))) - } - - fn prompt( - &self, - _id: Option, - params: acp::PromptRequest, - cx: &mut App, - ) -> Task> { - let sessions = self.sessions.borrow(); - let Some(session) = sessions.get(¶ms.session_id) else { - return Task::ready(Err(anyhow!( - "Attempted to send message to nonexistent session {}", - params.session_id - ))); - }; - - let (end_tx, end_rx) = oneshot::channel(); - session.turn_state.replace(TurnState::InProgress { end_tx }); - - let content = acp_content_to_claude(params.prompt); - - if let Err(err) = session.outgoing_tx.unbounded_send(SdkMessage::User { - message: Message { - role: Role::User, - content: Content::Chunks(content), - id: None, - model: None, - stop_reason: None, - stop_sequence: None, - usage: None, - }, - session_id: Some(params.session_id.to_string()), - }) { - return Task::ready(Err(anyhow!(err))); - } - - cx.foreground_executor().spawn(async move { end_rx.await? }) - } - - fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) { - let sessions = self.sessions.borrow(); - let Some(session) = sessions.get(session_id) else { - log::warn!("Attempted to cancel nonexistent session {}", session_id); - return; - }; - - let request_id = new_request_id(); - - let turn_state = session.turn_state.take(); - let TurnState::InProgress { end_tx } = turn_state else { - // Already canceled or idle, put it back - session.turn_state.replace(turn_state); - return; - }; - - session.turn_state.replace(TurnState::CancelRequested { - end_tx, - request_id: request_id.clone(), - }); - - session - .outgoing_tx - .unbounded_send(SdkMessage::ControlRequest { - request_id, - request: ControlRequest::Interrupt, - }) - .log_err(); - } - fn into_any(self: Rc) -> Rc { self } } - -#[derive(Clone, Copy)] -enum ClaudeSessionMode { - Start, - #[expect(dead_code)] - Resume, -} - -fn spawn_claude( - command: &AgentServerCommand, - mode: ClaudeSessionMode, - session_id: acp::SessionId, - api_key: language_models::provider::anthropic::ApiKey, - mcp_config_path: &Path, - root_dir: &Path, -) -> Result { - let child = util::command::new_smol_command(&command.path) - .args([ - "--input-format", - "stream-json", - "--output-format", - "stream-json", - "--print", - "--verbose", - "--mcp-config", - mcp_config_path.to_string_lossy().as_ref(), - "--permission-prompt-tool", - &format!( - "mcp__{}__{}", - mcp_server::SERVER_NAME, - permission_tool::PermissionTool::NAME, - ), - "--allowedTools", - &format!( - "mcp__{}__{}", - mcp_server::SERVER_NAME, - read_tool::ReadTool::NAME - ), - "--disallowedTools", - "Read,Write,Edit,MultiEdit", - ]) - .args(match mode { - ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()], - ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()], - }) - .args(command.args.iter().map(|arg| arg.as_str())) - .envs(command.env.iter().flatten()) - .env("ANTHROPIC_API_KEY", api_key.key) - .current_dir(root_dir) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn()?; - - Ok(child) -} - -fn claude_version(path: PathBuf, cx: &mut AsyncApp) -> Task> { - cx.background_spawn(async move { - let output = new_smol_command(path).arg("--version").output().await?; - let output = String::from_utf8(output.stdout)?; - let version = output - .trim() - .strip_suffix(" (Claude Code)") - .context("parsing Claude version")?; - let version = semver::Version::parse(version)?; - anyhow::Ok(version) - }) -} - -fn claude_help(path: PathBuf, cx: &mut AsyncApp) -> Task> { - cx.background_spawn(async move { - let output = new_smol_command(path).arg("--help").output().await?; - let output = String::from_utf8(output.stdout)?; - anyhow::Ok(output) - }) -} - -struct ClaudeAgentSession { - outgoing_tx: UnboundedSender, - turn_state: Rc>, - _mcp_server: Option, - _handler_task: Task<()>, -} - -#[derive(Debug, Default)] -enum TurnState { - #[default] - None, - InProgress { - end_tx: oneshot::Sender>, - }, - CancelRequested { - end_tx: oneshot::Sender>, - request_id: String, - }, - CancelConfirmed { - end_tx: oneshot::Sender>, - }, -} - -impl TurnState { - fn is_canceled(&self) -> bool { - matches!(self, TurnState::CancelConfirmed { .. }) - } - - fn end_tx(self) -> Option>> { - match self { - TurnState::None => None, - TurnState::InProgress { end_tx, .. } => Some(end_tx), - TurnState::CancelRequested { end_tx, .. } => Some(end_tx), - TurnState::CancelConfirmed { end_tx } => Some(end_tx), - } - } - - fn confirm_cancellation(self, id: &str) -> Self { - match self { - TurnState::CancelRequested { request_id, end_tx } if request_id == id => { - TurnState::CancelConfirmed { end_tx } - } - _ => self, - } - } -} - -impl ClaudeAgentSession { - async fn handle_message( - mut thread_rx: watch::Receiver>, - message: SdkMessage, - turn_state: Rc>, - cx: &mut AsyncApp, - ) { - match message { - // we should only be sending these out, they don't need to be in the thread - SdkMessage::ControlRequest { .. } => {} - SdkMessage::User { - message, - session_id: _, - } => { - let Some(thread) = thread_rx - .recv() - .await - .log_err() - .and_then(|entity| entity.upgrade()) - else { - log::error!("Received an SDK message but thread is gone"); - return; - }; - - for chunk in message.content.chunks() { - match chunk { - ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => { - if !turn_state.borrow().is_canceled() { - thread - .update(cx, |thread, cx| { - thread.push_user_content_block(None, text.into(), cx) - }) - .log_err(); - } - } - ContentChunk::ToolResult { - content, - tool_use_id, - } => { - let content = content.to_string(); - thread - .update(cx, |thread, cx| { - let id = acp::ToolCallId(tool_use_id.into()); - let set_new_content = !content.is_empty() - && thread.tool_call(&id).is_none_or(|(_, tool_call)| { - // preserve rich diff if we have one - tool_call.diffs().next().is_none() - }); - - thread.update_tool_call( - acp::ToolCallUpdate { - id, - fields: acp::ToolCallUpdateFields { - status: if turn_state.borrow().is_canceled() { - // Do not set to completed if turn was canceled - None - } else { - Some(acp::ToolCallStatus::Completed) - }, - content: set_new_content - .then(|| vec![content.into()]), - ..Default::default() - }, - }, - cx, - ) - }) - .log_err(); - } - ContentChunk::Thinking { .. } - | ContentChunk::RedactedThinking - | ContentChunk::ToolUse { .. } => { - debug_panic!( - "Should not get {:?} with role: assistant. should we handle this?", - chunk - ); - } - ContentChunk::Image { source } => { - if !turn_state.borrow().is_canceled() { - thread - .update(cx, |thread, cx| { - thread.push_user_content_block(None, source.into(), cx) - }) - .log_err(); - } - } - - ContentChunk::Document | ContentChunk::WebSearchToolResult => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block( - format!("Unsupported content: {:?}", chunk).into(), - false, - cx, - ) - }) - .log_err(); - } - } - } - } - SdkMessage::Assistant { - message, - session_id: _, - } => { - let Some(thread) = thread_rx - .recv() - .await - .log_err() - .and_then(|entity| entity.upgrade()) - else { - log::error!("Received an SDK message but thread is gone"); - return; - }; - - for chunk in message.content.chunks() { - match chunk { - ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block(text.into(), false, cx) - }) - .log_err(); - } - ContentChunk::Thinking { thinking } => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block(thinking.into(), true, cx) - }) - .log_err(); - } - ContentChunk::RedactedThinking => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block( - "[REDACTED]".into(), - true, - cx, - ) - }) - .log_err(); - } - ContentChunk::ToolUse { id, name, input } => { - let claude_tool = ClaudeTool::infer(&name, input); - - thread - .update(cx, |thread, cx| { - if let ClaudeTool::TodoWrite(Some(params)) = claude_tool { - thread.update_plan( - acp::Plan { - entries: params - .todos - .into_iter() - .map(Into::into) - .collect(), - }, - cx, - ) - } else { - thread.upsert_tool_call( - claude_tool.as_acp(acp::ToolCallId(id.into())), - cx, - )?; - } - anyhow::Ok(()) - }) - .log_err(); - } - ContentChunk::ToolResult { .. } | ContentChunk::WebSearchToolResult => { - debug_panic!( - "Should not get tool results with role: assistant. should we handle this?" - ); - } - ContentChunk::Image { source } => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block(source.into(), false, cx) - }) - .log_err(); - } - ContentChunk::Document => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block( - format!("Unsupported content: {:?}", chunk).into(), - false, - cx, - ) - }) - .log_err(); - } - } - } - } - SdkMessage::Result { - is_error, - subtype, - result, - .. - } => { - let turn_state = turn_state.take(); - let was_canceled = turn_state.is_canceled(); - let Some(end_turn_tx) = turn_state.end_tx() else { - debug_panic!("Received `SdkMessage::Result` but there wasn't an active turn"); - return; - }; - - if is_error || (!was_canceled && subtype == ResultErrorType::ErrorDuringExecution) { - end_turn_tx - .send(Err(anyhow!( - "Error: {}", - result.unwrap_or_else(|| subtype.to_string()) - ))) - .ok(); - } else { - let stop_reason = match subtype { - ResultErrorType::Success => acp::StopReason::EndTurn, - ResultErrorType::ErrorMaxTurns => acp::StopReason::MaxTurnRequests, - ResultErrorType::ErrorDuringExecution => acp::StopReason::Cancelled, - }; - end_turn_tx - .send(Ok(acp::PromptResponse { stop_reason })) - .ok(); - } - } - SdkMessage::ControlResponse { response } => { - if matches!(response.subtype, ResultErrorType::Success) { - let new_state = turn_state.take().confirm_cancellation(&response.request_id); - turn_state.replace(new_state); - } else { - log::error!("Control response error: {:?}", response); - } - } - SdkMessage::System { .. } => {} - } - } - - async fn handle_io( - mut outgoing_rx: UnboundedReceiver, - incoming_tx: UnboundedSender, - mut outgoing_bytes: impl Unpin + AsyncWrite, - incoming_bytes: impl Unpin + AsyncRead, - ) -> Result> { - let mut output_reader = BufReader::new(incoming_bytes); - let mut outgoing_line = Vec::new(); - let mut incoming_line = String::new(); - loop { - select_biased! { - message = outgoing_rx.next() => { - if let Some(message) = message { - outgoing_line.clear(); - serde_json::to_writer(&mut outgoing_line, &message)?; - log::trace!("send: {}", String::from_utf8_lossy(&outgoing_line)); - outgoing_line.push(b'\n'); - outgoing_bytes.write_all(&outgoing_line).await.ok(); - } else { - break; - } - } - bytes_read = output_reader.read_line(&mut incoming_line).fuse() => { - if bytes_read? == 0 { - break - } - log::trace!("recv: {}", &incoming_line); - match serde_json::from_str::(&incoming_line) { - Ok(message) => { - incoming_tx.unbounded_send(message).log_err(); - } - Err(error) => { - log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}"); - } - } - incoming_line.clear(); - } - } - } - - Ok(outgoing_rx) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct Message { - role: Role, - content: Content, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stop_reason: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stop_sequence: Option, - #[serde(skip_serializing_if = "Option::is_none")] - usage: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -enum Content { - UntaggedText(String), - Chunks(Vec), -} - -impl Content { - pub fn chunks(self) -> impl Iterator { - match self { - Self::Chunks(chunks) => chunks.into_iter(), - Self::UntaggedText(text) => vec![ContentChunk::Text { text }].into_iter(), - } - } -} - -impl Display for Content { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Content::UntaggedText(txt) => write!(f, "{}", txt), - Content::Chunks(chunks) => { - for chunk in chunks { - write!(f, "{}", chunk)?; - } - Ok(()) - } - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum ContentChunk { - Text { - text: String, - }, - ToolUse { - id: String, - name: String, - input: serde_json::Value, - }, - ToolResult { - content: Content, - tool_use_id: String, - }, - Thinking { - thinking: String, - }, - RedactedThinking, - Image { - source: ImageSource, - }, - // TODO - Document, - WebSearchToolResult, - #[serde(untagged)] - UntaggedText(String), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum ImageSource { - Base64 { data: String, media_type: String }, - Url { url: String }, -} - -impl Into for ImageSource { - fn into(self) -> acp::ContentBlock { - match self { - ImageSource::Base64 { data, media_type } => { - acp::ContentBlock::Image(acp::ImageContent { - annotations: None, - data, - mime_type: media_type, - uri: None, - }) - } - ImageSource::Url { url } => acp::ContentBlock::Image(acp::ImageContent { - annotations: None, - data: "".to_string(), - mime_type: "".to_string(), - uri: Some(url), - }), - } - } -} - -impl Display for ContentChunk { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ContentChunk::Text { text } => write!(f, "{}", text), - ContentChunk::Thinking { thinking } => write!(f, "Thinking: {}", thinking), - ContentChunk::RedactedThinking => write!(f, "Thinking: [REDACTED]"), - ContentChunk::UntaggedText(text) => write!(f, "{}", text), - ContentChunk::ToolResult { content, .. } => write!(f, "{}", content), - ContentChunk::Image { .. } - | ContentChunk::Document - | ContentChunk::ToolUse { .. } - | ContentChunk::WebSearchToolResult => { - write!(f, "\n{:?}\n", &self) - } - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct Usage { - input_tokens: u32, - cache_creation_input_tokens: u32, - cache_read_input_tokens: u32, - output_tokens: u32, - service_tier: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum Role { - System, - Assistant, - User, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct MessageParam { - role: Role, - content: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum SdkMessage { - // An assistant message - Assistant { - message: Message, // from Anthropic SDK - #[serde(skip_serializing_if = "Option::is_none")] - session_id: Option, - }, - // A user message - User { - message: Message, // from Anthropic SDK - #[serde(skip_serializing_if = "Option::is_none")] - session_id: Option, - }, - // Emitted as the last message in a conversation - Result { - subtype: ResultErrorType, - duration_ms: f64, - duration_api_ms: f64, - is_error: bool, - num_turns: i32, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - session_id: String, - total_cost_usd: f64, - }, - // Emitted as the first message at the start of a conversation - System { - cwd: String, - session_id: String, - tools: Vec, - model: String, - mcp_servers: Vec, - #[serde(rename = "apiKeySource")] - api_key_source: String, - #[serde(rename = "permissionMode")] - permission_mode: PermissionMode, - }, - /// Messages used to control the conversation, outside of chat messages to the model - ControlRequest { - request_id: String, - request: ControlRequest, - }, - /// Response to a control request - ControlResponse { response: ControlResponse }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "subtype", rename_all = "snake_case")] -enum ControlRequest { - /// Cancel the current conversation - Interrupt, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ControlResponse { - request_id: String, - subtype: ResultErrorType, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "snake_case")] -enum ResultErrorType { - Success, - ErrorMaxTurns, - ErrorDuringExecution, -} - -impl Display for ResultErrorType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ResultErrorType::Success => write!(f, "success"), - ResultErrorType::ErrorMaxTurns => write!(f, "error_max_turns"), - ResultErrorType::ErrorDuringExecution => write!(f, "error_during_execution"), - } - } -} - -fn acp_content_to_claude(prompt: Vec) -> Vec { - let mut content = Vec::with_capacity(prompt.len()); - let mut context = Vec::with_capacity(prompt.len()); - - for chunk in prompt { - match chunk { - acp::ContentBlock::Text(text_content) => { - content.push(ContentChunk::Text { - text: text_content.text, - }); - } - acp::ContentBlock::ResourceLink(resource_link) => { - match MentionUri::parse(&resource_link.uri) { - Ok(uri) => { - content.push(ContentChunk::Text { - text: format!("{}", uri.as_link()), - }); - } - Err(_) => { - content.push(ContentChunk::Text { - text: resource_link.uri, - }); - } - } - } - acp::ContentBlock::Resource(resource) => match resource.resource { - acp::EmbeddedResourceResource::TextResourceContents(resource) => { - match MentionUri::parse(&resource.uri) { - Ok(uri) => { - content.push(ContentChunk::Text { - text: format!("{}", uri.as_link()), - }); - } - Err(_) => { - content.push(ContentChunk::Text { - text: resource.uri.clone(), - }); - } - } - - context.push(ContentChunk::Text { - text: format!( - "\n\n{}\n", - resource.uri, resource.text - ), - }); - } - acp::EmbeddedResourceResource::BlobResourceContents(_) => { - // Unsupported by SDK - } - }, - acp::ContentBlock::Image(acp::ImageContent { - data, mime_type, .. - }) => content.push(ContentChunk::Image { - source: ImageSource::Base64 { - data, - media_type: mime_type, - }, - }), - acp::ContentBlock::Audio(_) => { - // Unsupported by SDK - } - } - } - - content.extend(context); - content -} - -fn new_request_id() -> String { - use rand::Rng; - // In the Claude Code TS SDK they just generate a random 12 character string, - // `Math.random().toString(36).substring(2, 15)` - rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) - .take(12) - .map(char::from) - .collect() -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct McpServer { - name: String, - status: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -enum PermissionMode { - Default, - AcceptEdits, - BypassPermissions, - Plan, -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use crate::e2e_tests; - use gpui::TestAppContext; - use serde_json::json; - - crate::common_e2e_tests!(async |_, _, _| ClaudeCode, allow_option_id = "allow"); - - pub fn local_command() -> AgentServerCommand { - AgentServerCommand { - path: "claude".into(), - args: vec![], - env: None, - } - } - - #[gpui::test] - #[cfg_attr(not(feature = "e2e"), ignore)] - async fn test_todo_plan(cx: &mut TestAppContext) { - let fs = e2e_tests::init_test(cx).await; - let project = Project::test(fs, [], cx).await; - let thread = - e2e_tests::new_test_thread(ClaudeCode, project.clone(), "/private/tmp", cx).await; - - thread - .update(cx, |thread, cx| { - thread.send_raw( - "Create a todo plan for initializing a new React app. I'll follow it myself, do not execute on it.", - cx, - ) - }) - .await - .unwrap(); - - let mut entries_len = 0; - - thread.read_with(cx, |thread, _| { - entries_len = thread.plan().entries.len(); - assert!(!thread.plan().entries.is_empty(), "Empty plan"); - }); - - thread - .update(cx, |thread, cx| { - thread.send_raw( - "Mark the first entry status as in progress without acting on it.", - cx, - ) - }) - .await - .unwrap(); - - thread.read_with(cx, |thread, _| { - assert!(matches!( - thread.plan().entries[0].status, - acp::PlanEntryStatus::InProgress - )); - assert_eq!(thread.plan().entries.len(), entries_len); - }); - - thread - .update(cx, |thread, cx| { - thread.send_raw( - "Now mark the first entry as completed without acting on it.", - cx, - ) - }) - .await - .unwrap(); - - thread.read_with(cx, |thread, _| { - assert!(matches!( - thread.plan().entries[0].status, - acp::PlanEntryStatus::Completed - )); - assert_eq!(thread.plan().entries.len(), entries_len); - }); - } - - #[test] - fn test_deserialize_content_untagged_text() { - let json = json!("Hello, world!"); - let content: Content = serde_json::from_value(json).unwrap(); - match content { - Content::UntaggedText(text) => assert_eq!(text, "Hello, world!"), - _ => panic!("Expected UntaggedText variant"), - } - } - - #[test] - fn test_deserialize_content_chunks() { - let json = json!([ - { - "type": "text", - "text": "Hello" - }, - { - "type": "tool_use", - "id": "tool_123", - "name": "calculator", - "input": {"operation": "add", "a": 1, "b": 2} - } - ]); - let content: Content = serde_json::from_value(json).unwrap(); - match content { - Content::Chunks(chunks) => { - assert_eq!(chunks.len(), 2); - match &chunks[0] { - ContentChunk::Text { text } => assert_eq!(text, "Hello"), - _ => panic!("Expected Text chunk"), - } - match &chunks[1] { - ContentChunk::ToolUse { id, name, input } => { - assert_eq!(id, "tool_123"); - assert_eq!(name, "calculator"); - assert_eq!(input["operation"], "add"); - assert_eq!(input["a"], 1); - assert_eq!(input["b"], 2); - } - _ => panic!("Expected ToolUse chunk"), - } - } - _ => panic!("Expected Chunks variant"), - } - } - - #[test] - fn test_deserialize_tool_result_untagged_text() { - let json = json!({ - "type": "tool_result", - "content": "Result content", - "tool_use_id": "tool_456" - }); - let chunk: ContentChunk = serde_json::from_value(json).unwrap(); - match chunk { - ContentChunk::ToolResult { - content, - tool_use_id, - } => { - match content { - Content::UntaggedText(text) => assert_eq!(text, "Result content"), - _ => panic!("Expected UntaggedText content"), - } - assert_eq!(tool_use_id, "tool_456"); - } - _ => panic!("Expected ToolResult variant"), - } - } - - #[test] - fn test_deserialize_tool_result_chunks() { - let json = json!({ - "type": "tool_result", - "content": [ - { - "type": "text", - "text": "Processing complete" - }, - { - "type": "text", - "text": "Result: 42" - } - ], - "tool_use_id": "tool_789" - }); - let chunk: ContentChunk = serde_json::from_value(json).unwrap(); - match chunk { - ContentChunk::ToolResult { - content, - tool_use_id, - } => { - match content { - Content::Chunks(chunks) => { - assert_eq!(chunks.len(), 2); - match &chunks[0] { - ContentChunk::Text { text } => assert_eq!(text, "Processing complete"), - _ => panic!("Expected Text chunk"), - } - match &chunks[1] { - ContentChunk::Text { text } => assert_eq!(text, "Result: 42"), - _ => panic!("Expected Text chunk"), - } - } - _ => panic!("Expected Chunks content"), - } - assert_eq!(tool_use_id, "tool_789"); - } - _ => panic!("Expected ToolResult variant"), - } - } - - #[test] - fn test_acp_content_to_claude() { - let acp_content = vec![ - acp::ContentBlock::Text(acp::TextContent { - text: "Hello world".to_string(), - annotations: None, - }), - acp::ContentBlock::Image(acp::ImageContent { - data: "base64data".to_string(), - mime_type: "image/png".to_string(), - annotations: None, - uri: None, - }), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: "file:///path/to/example.rs".to_string(), - name: "example.rs".to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - }), - acp::ContentBlock::Resource(acp::EmbeddedResource { - annotations: None, - resource: acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - mime_type: None, - text: "fn main() { println!(\"Hello!\"); }".to_string(), - uri: "file:///path/to/code.rs".to_string(), - }, - ), - }), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: "invalid_uri_format".to_string(), - name: "invalid.txt".to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - }), - ]; - - let claude_content = acp_content_to_claude(acp_content); - - assert_eq!(claude_content.len(), 6); - - match &claude_content[0] { - ContentChunk::Text { text } => assert_eq!(text, "Hello world"), - _ => panic!("Expected Text chunk"), - } - - match &claude_content[1] { - ContentChunk::Image { source } => match source { - ImageSource::Base64 { data, media_type } => { - assert_eq!(data, "base64data"); - assert_eq!(media_type, "image/png"); - } - _ => panic!("Expected Base64 image source"), - }, - _ => panic!("Expected Image chunk"), - } - - match &claude_content[2] { - ContentChunk::Text { text } => { - assert!(text.contains("example.rs")); - assert!(text.contains("file:///path/to/example.rs")); - } - _ => panic!("Expected Text chunk for ResourceLink"), - } - - match &claude_content[3] { - ContentChunk::Text { text } => { - assert!(text.contains("code.rs")); - assert!(text.contains("file:///path/to/code.rs")); - } - _ => panic!("Expected Text chunk for Resource"), - } - - match &claude_content[4] { - ContentChunk::Text { text } => { - assert_eq!(text, "invalid_uri_format"); - } - _ => panic!("Expected Text chunk for invalid URI"), - } - - match &claude_content[5] { - ContentChunk::Text { text } => { - assert!(text.contains("")); - assert!(text.contains("fn main() { println!(\"Hello!\"); }")); - assert!(text.contains("")); - } - _ => panic!("Expected Text chunk for context"), - } - } -} diff --git a/crates/agent_servers/src/claude/edit_tool.rs b/crates/agent_servers/src/claude/edit_tool.rs deleted file mode 100644 index a8d93c3f3d5579173709b3ace6194059745885a7..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/edit_tool.rs +++ /dev/null @@ -1,178 +0,0 @@ -use acp_thread::AcpThread; -use anyhow::Result; -use context_server::{ - listener::{McpServerTool, ToolResponse}, - types::{ToolAnnotations, ToolResponseContent}, -}; -use gpui::{AsyncApp, WeakEntity}; -use language::unified_diff; -use util::markdown::MarkdownCodeBlock; - -use crate::tools::EditToolParams; - -#[derive(Clone)] -pub struct EditTool { - thread_rx: watch::Receiver>, -} - -impl EditTool { - pub fn new(thread_rx: watch::Receiver>) -> Self { - Self { thread_rx } - } -} - -impl McpServerTool for EditTool { - type Input = EditToolParams; - type Output = (); - - const NAME: &'static str = "Edit"; - - fn annotations(&self) -> ToolAnnotations { - ToolAnnotations { - title: Some("Edit file".to_string()), - read_only_hint: Some(false), - destructive_hint: Some(false), - open_world_hint: Some(false), - idempotent_hint: Some(false), - } - } - - async fn run( - &self, - input: Self::Input, - cx: &mut AsyncApp, - ) -> Result> { - let mut thread_rx = self.thread_rx.clone(); - let Some(thread) = thread_rx.recv().await?.upgrade() else { - anyhow::bail!("Thread closed"); - }; - - let content = thread - .update(cx, |thread, cx| { - thread.read_text_file(input.abs_path.clone(), None, None, true, cx) - })? - .await?; - - let (new_content, diff) = cx - .background_executor() - .spawn(async move { - let new_content = content.replace(&input.old_text, &input.new_text); - if new_content == content { - return Err(anyhow::anyhow!("Failed to find `old_text`",)); - } - let diff = unified_diff(&content, &new_content); - - Ok((new_content, diff)) - }) - .await?; - - thread - .update(cx, |thread, cx| { - thread.write_text_file(input.abs_path, new_content, cx) - })? - .await?; - - Ok(ToolResponse { - content: vec![ToolResponseContent::Text { - text: MarkdownCodeBlock { - tag: "diff", - text: diff.as_str().trim_end_matches('\n'), - } - .to_string(), - }], - structured_content: (), - }) - } -} - -#[cfg(test)] -mod tests { - use std::rc::Rc; - - use acp_thread::{AgentConnection, StubAgentConnection}; - use gpui::{Entity, TestAppContext}; - use indoc::indoc; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - use super::*; - - #[gpui::test] - async fn old_text_not_found(cx: &mut TestAppContext) { - let (_thread, tool) = init_test(cx).await; - - let result = tool - .run( - EditToolParams { - abs_path: path!("/root/file.txt").into(), - old_text: "hi".into(), - new_text: "bye".into(), - }, - &mut cx.to_async(), - ) - .await; - - assert_eq!(result.unwrap_err().to_string(), "Failed to find `old_text`"); - } - - #[gpui::test] - async fn found_and_replaced(cx: &mut TestAppContext) { - let (_thread, tool) = init_test(cx).await; - - let result = tool - .run( - EditToolParams { - abs_path: path!("/root/file.txt").into(), - old_text: "hello".into(), - new_text: "hi".into(), - }, - &mut cx.to_async(), - ) - .await; - - assert_eq!( - result.unwrap().content[0].text().unwrap(), - indoc! { - r" - ```diff - @@ -1,1 +1,1 @@ - -hello - +hi - ``` - " - } - ); - } - - async fn init_test(cx: &mut TestAppContext) -> (Entity, EditTool) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - - let connection = Rc::new(StubAgentConnection::new()); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "file.txt": "hello" - }), - ) - .await; - let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid()); - - let thread = cx - .update(|cx| connection.new_thread(project, path!("/test").as_ref(), cx)) - .await - .unwrap(); - - thread_tx.send(thread.downgrade()).unwrap(); - - (thread, EditTool::new(thread_rx)) - } -} diff --git a/crates/agent_servers/src/claude/mcp_server.rs b/crates/agent_servers/src/claude/mcp_server.rs deleted file mode 100644 index 6442c784b59a655def92bcae108c27c503daa630..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/mcp_server.rs +++ /dev/null @@ -1,99 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use crate::claude::edit_tool::EditTool; -use crate::claude::permission_tool::PermissionTool; -use crate::claude::read_tool::ReadTool; -use crate::claude::write_tool::WriteTool; -use acp_thread::AcpThread; -#[cfg(not(test))] -use anyhow::Context as _; -use anyhow::Result; -use collections::HashMap; -use context_server::types::{ - Implementation, InitializeParams, InitializeResponse, ProtocolVersion, ServerCapabilities, - ToolsCapabilities, requests, -}; -use gpui::{App, AsyncApp, Task, WeakEntity}; -use project::Fs; -use serde::Serialize; - -pub struct ClaudeZedMcpServer { - server: context_server::listener::McpServer, -} - -pub const SERVER_NAME: &str = "zed"; - -impl ClaudeZedMcpServer { - pub async fn new( - thread_rx: watch::Receiver>, - fs: Arc, - cx: &AsyncApp, - ) -> Result { - let mut mcp_server = context_server::listener::McpServer::new(cx).await?; - mcp_server.handle_request::(Self::handle_initialize); - - mcp_server.add_tool(PermissionTool::new(fs.clone(), thread_rx.clone())); - mcp_server.add_tool(ReadTool::new(thread_rx.clone())); - mcp_server.add_tool(EditTool::new(thread_rx.clone())); - mcp_server.add_tool(WriteTool::new(thread_rx.clone())); - - Ok(Self { server: mcp_server }) - } - - pub fn server_config(&self) -> Result { - #[cfg(not(test))] - let zed_path = std::env::current_exe() - .context("finding current executable path for use in mcp_server")?; - - #[cfg(test)] - let zed_path = crate::e2e_tests::get_zed_path(); - - Ok(McpServerConfig { - command: zed_path, - args: vec![ - "--nc".into(), - self.server.socket_path().display().to_string(), - ], - env: None, - }) - } - - fn handle_initialize(_: InitializeParams, cx: &App) -> Task> { - cx.foreground_executor().spawn(async move { - Ok(InitializeResponse { - protocol_version: ProtocolVersion("2025-06-18".into()), - capabilities: ServerCapabilities { - experimental: None, - logging: None, - completions: None, - prompts: None, - resources: None, - tools: Some(ToolsCapabilities { - list_changed: Some(false), - }), - }, - server_info: Implementation { - name: SERVER_NAME.into(), - version: "0.1.0".into(), - }, - meta: None, - }) - }) - } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct McpConfig { - pub mcp_servers: HashMap, -} - -#[derive(Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct McpServerConfig { - pub command: PathBuf, - pub args: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub env: Option>, -} diff --git a/crates/agent_servers/src/claude/permission_tool.rs b/crates/agent_servers/src/claude/permission_tool.rs deleted file mode 100644 index 96a24105e87bd99b46ec16e39dc32df57557f882..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/permission_tool.rs +++ /dev/null @@ -1,158 +0,0 @@ -use std::sync::Arc; - -use acp_thread::AcpThread; -use agent_client_protocol as acp; -use agent_settings::AgentSettings; -use anyhow::{Context as _, Result}; -use context_server::{ - listener::{McpServerTool, ToolResponse}, - types::ToolResponseContent, -}; -use gpui::{AsyncApp, WeakEntity}; -use project::Fs; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings as _, update_settings_file}; -use util::debug_panic; - -use crate::tools::ClaudeTool; - -#[derive(Clone)] -pub struct PermissionTool { - fs: Arc, - thread_rx: watch::Receiver>, -} - -/// Request permission for tool calls -#[derive(Deserialize, JsonSchema, Debug)] -pub struct PermissionToolParams { - tool_name: String, - input: serde_json::Value, - tool_use_id: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PermissionToolResponse { - behavior: PermissionToolBehavior, - updated_input: serde_json::Value, -} - -#[derive(Serialize)] -#[serde(rename_all = "snake_case")] -enum PermissionToolBehavior { - Allow, - Deny, -} - -impl PermissionTool { - pub fn new(fs: Arc, thread_rx: watch::Receiver>) -> Self { - Self { fs, thread_rx } - } -} - -impl McpServerTool for PermissionTool { - type Input = PermissionToolParams; - type Output = (); - - const NAME: &'static str = "Confirmation"; - - async fn run( - &self, - input: Self::Input, - cx: &mut AsyncApp, - ) -> Result> { - if agent_settings::AgentSettings::try_read_global(cx, |settings| { - settings.always_allow_tool_actions - }) - .unwrap_or(false) - { - let response = PermissionToolResponse { - behavior: PermissionToolBehavior::Allow, - updated_input: input.input, - }; - - return Ok(ToolResponse { - content: vec![ToolResponseContent::Text { - text: serde_json::to_string(&response)?, - }], - structured_content: (), - }); - } - - let mut thread_rx = self.thread_rx.clone(); - let Some(thread) = thread_rx.recv().await?.upgrade() else { - anyhow::bail!("Thread closed"); - }; - - let claude_tool = ClaudeTool::infer(&input.tool_name, input.input.clone()); - let tool_call_id = acp::ToolCallId(input.tool_use_id.context("Tool ID required")?.into()); - - const ALWAYS_ALLOW: &str = "always_allow"; - const ALLOW: &str = "allow"; - const REJECT: &str = "reject"; - - let chosen_option = thread - .update(cx, |thread, cx| { - thread.request_tool_call_authorization( - claude_tool.as_acp(tool_call_id).into(), - vec![ - acp::PermissionOption { - id: acp::PermissionOptionId(ALWAYS_ALLOW.into()), - name: "Always Allow".into(), - kind: acp::PermissionOptionKind::AllowAlways, - }, - acp::PermissionOption { - id: acp::PermissionOptionId(ALLOW.into()), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - }, - acp::PermissionOption { - id: acp::PermissionOptionId(REJECT.into()), - name: "Reject".into(), - kind: acp::PermissionOptionKind::RejectOnce, - }, - ], - cx, - ) - })?? - .await?; - - let response = match chosen_option.0.as_ref() { - ALWAYS_ALLOW => { - cx.update(|cx| { - update_settings_file::(self.fs.clone(), cx, |settings, _| { - settings.set_always_allow_tool_actions(true); - }); - })?; - - PermissionToolResponse { - behavior: PermissionToolBehavior::Allow, - updated_input: input.input, - } - } - ALLOW => PermissionToolResponse { - behavior: PermissionToolBehavior::Allow, - updated_input: input.input, - }, - REJECT => PermissionToolResponse { - behavior: PermissionToolBehavior::Deny, - updated_input: input.input, - }, - opt => { - debug_panic!("Unexpected option: {}", opt); - PermissionToolResponse { - behavior: PermissionToolBehavior::Deny, - updated_input: input.input, - } - } - }; - - Ok(ToolResponse { - content: vec![ToolResponseContent::Text { - text: serde_json::to_string(&response)?, - }], - structured_content: (), - }) - } -} diff --git a/crates/agent_servers/src/claude/read_tool.rs b/crates/agent_servers/src/claude/read_tool.rs deleted file mode 100644 index cbe25876b3deabc32d1d30f7d8ad4de90fb21494..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/read_tool.rs +++ /dev/null @@ -1,59 +0,0 @@ -use acp_thread::AcpThread; -use anyhow::Result; -use context_server::{ - listener::{McpServerTool, ToolResponse}, - types::{ToolAnnotations, ToolResponseContent}, -}; -use gpui::{AsyncApp, WeakEntity}; - -use crate::tools::ReadToolParams; - -#[derive(Clone)] -pub struct ReadTool { - thread_rx: watch::Receiver>, -} - -impl ReadTool { - pub fn new(thread_rx: watch::Receiver>) -> Self { - Self { thread_rx } - } -} - -impl McpServerTool for ReadTool { - type Input = ReadToolParams; - type Output = (); - - const NAME: &'static str = "Read"; - - fn annotations(&self) -> ToolAnnotations { - ToolAnnotations { - title: Some("Read file".to_string()), - read_only_hint: Some(true), - destructive_hint: Some(false), - open_world_hint: Some(false), - idempotent_hint: None, - } - } - - async fn run( - &self, - input: Self::Input, - cx: &mut AsyncApp, - ) -> Result> { - let mut thread_rx = self.thread_rx.clone(); - let Some(thread) = thread_rx.recv().await?.upgrade() else { - anyhow::bail!("Thread closed"); - }; - - let content = thread - .update(cx, |thread, cx| { - thread.read_text_file(input.abs_path, input.offset, input.limit, false, cx) - })? - .await?; - - Ok(ToolResponse { - content: vec![ToolResponseContent::Text { text: content }], - structured_content: (), - }) - } -} diff --git a/crates/agent_servers/src/claude/tools.rs b/crates/agent_servers/src/claude/tools.rs deleted file mode 100644 index 323190300131c87a443b95e4bb18424cf034e667..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/tools.rs +++ /dev/null @@ -1,688 +0,0 @@ -use std::path::PathBuf; - -use agent_client_protocol as acp; -use itertools::Itertools; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use util::ResultExt; - -pub enum ClaudeTool { - Task(Option), - NotebookRead(Option), - NotebookEdit(Option), - Edit(Option), - MultiEdit(Option), - ReadFile(Option), - Write(Option), - Ls(Option), - Glob(Option), - Grep(Option), - Terminal(Option), - WebFetch(Option), - WebSearch(Option), - TodoWrite(Option), - ExitPlanMode(Option), - Other { - name: String, - input: serde_json::Value, - }, -} - -impl ClaudeTool { - pub fn infer(tool_name: &str, input: serde_json::Value) -> Self { - match tool_name { - // Known tools - "mcp__zed__Read" => Self::ReadFile(serde_json::from_value(input).log_err()), - "mcp__zed__Edit" => Self::Edit(serde_json::from_value(input).log_err()), - "mcp__zed__Write" => Self::Write(serde_json::from_value(input).log_err()), - "MultiEdit" => Self::MultiEdit(serde_json::from_value(input).log_err()), - "Write" => Self::Write(serde_json::from_value(input).log_err()), - "LS" => Self::Ls(serde_json::from_value(input).log_err()), - "Glob" => Self::Glob(serde_json::from_value(input).log_err()), - "Grep" => Self::Grep(serde_json::from_value(input).log_err()), - "Bash" => Self::Terminal(serde_json::from_value(input).log_err()), - "WebFetch" => Self::WebFetch(serde_json::from_value(input).log_err()), - "WebSearch" => Self::WebSearch(serde_json::from_value(input).log_err()), - "TodoWrite" => Self::TodoWrite(serde_json::from_value(input).log_err()), - "exit_plan_mode" => Self::ExitPlanMode(serde_json::from_value(input).log_err()), - "Task" => Self::Task(serde_json::from_value(input).log_err()), - "NotebookRead" => Self::NotebookRead(serde_json::from_value(input).log_err()), - "NotebookEdit" => Self::NotebookEdit(serde_json::from_value(input).log_err()), - // Inferred from name - _ => { - let tool_name = tool_name.to_lowercase(); - - if tool_name.contains("edit") || tool_name.contains("write") { - Self::Edit(None) - } else if tool_name.contains("terminal") { - Self::Terminal(None) - } else { - Self::Other { - name: tool_name, - input, - } - } - } - } - } - - pub fn label(&self) -> String { - match &self { - Self::Task(Some(params)) => params.description.clone(), - Self::Task(None) => "Task".into(), - Self::NotebookRead(Some(params)) => { - format!("Read Notebook {}", params.notebook_path.display()) - } - Self::NotebookRead(None) => "Read Notebook".into(), - Self::NotebookEdit(Some(params)) => { - format!("Edit Notebook {}", params.notebook_path.display()) - } - Self::NotebookEdit(None) => "Edit Notebook".into(), - Self::Terminal(Some(params)) => format!("`{}`", params.command), - Self::Terminal(None) => "Terminal".into(), - Self::ReadFile(_) => "Read File".into(), - Self::Ls(Some(params)) => { - format!("List Directory {}", params.path.display()) - } - Self::Ls(None) => "List Directory".into(), - Self::Edit(Some(params)) => { - format!("Edit {}", params.abs_path.display()) - } - Self::Edit(None) => "Edit".into(), - Self::MultiEdit(Some(params)) => { - format!("Multi Edit {}", params.file_path.display()) - } - Self::MultiEdit(None) => "Multi Edit".into(), - Self::Write(Some(params)) => { - format!("Write {}", params.abs_path.display()) - } - Self::Write(None) => "Write".into(), - Self::Glob(Some(params)) => { - format!("Glob `{params}`") - } - Self::Glob(None) => "Glob".into(), - Self::Grep(Some(params)) => format!("`{params}`"), - Self::Grep(None) => "Grep".into(), - Self::WebFetch(Some(params)) => format!("Fetch {}", params.url), - Self::WebFetch(None) => "Fetch".into(), - Self::WebSearch(Some(params)) => format!("Web Search: {}", params), - Self::WebSearch(None) => "Web Search".into(), - Self::TodoWrite(Some(params)) => format!( - "Update TODOs: {}", - params.todos.iter().map(|todo| &todo.content).join(", ") - ), - Self::TodoWrite(None) => "Update TODOs".into(), - Self::ExitPlanMode(_) => "Exit Plan Mode".into(), - Self::Other { name, .. } => name.clone(), - } - } - pub fn content(&self) -> Vec { - match &self { - Self::Other { input, .. } => vec![ - format!( - "```json\n{}```", - serde_json::to_string_pretty(&input).unwrap_or("{}".to_string()) - ) - .into(), - ], - Self::Task(Some(params)) => vec![params.prompt.clone().into()], - Self::NotebookRead(Some(params)) => { - vec![params.notebook_path.display().to_string().into()] - } - Self::NotebookEdit(Some(params)) => vec![params.new_source.clone().into()], - Self::Terminal(Some(params)) => vec![ - format!( - "`{}`\n\n{}", - params.command, - params.description.as_deref().unwrap_or_default() - ) - .into(), - ], - Self::ReadFile(Some(params)) => vec![params.abs_path.display().to_string().into()], - Self::Ls(Some(params)) => vec![params.path.display().to_string().into()], - Self::Glob(Some(params)) => vec![params.to_string().into()], - Self::Grep(Some(params)) => vec![format!("`{params}`").into()], - Self::WebFetch(Some(params)) => vec![params.prompt.clone().into()], - Self::WebSearch(Some(params)) => vec![params.to_string().into()], - Self::ExitPlanMode(Some(params)) => vec![params.plan.clone().into()], - Self::Edit(Some(params)) => vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: params.abs_path.clone(), - old_text: Some(params.old_text.clone()), - new_text: params.new_text.clone(), - }, - }], - Self::Write(Some(params)) => vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: params.abs_path.clone(), - old_text: None, - new_text: params.content.clone(), - }, - }], - Self::MultiEdit(Some(params)) => { - // todo: show multiple edits in a multibuffer? - params - .edits - .first() - .map(|edit| { - vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: params.file_path.clone(), - old_text: Some(edit.old_string.clone()), - new_text: edit.new_string.clone(), - }, - }] - }) - .unwrap_or_default() - } - Self::TodoWrite(Some(_)) => { - // These are mapped to plan updates later - vec![] - } - Self::Task(None) - | Self::NotebookRead(None) - | Self::NotebookEdit(None) - | Self::Terminal(None) - | Self::ReadFile(None) - | Self::Ls(None) - | Self::Glob(None) - | Self::Grep(None) - | Self::WebFetch(None) - | Self::WebSearch(None) - | Self::TodoWrite(None) - | Self::ExitPlanMode(None) - | Self::Edit(None) - | Self::Write(None) - | Self::MultiEdit(None) => vec![], - } - } - - pub fn kind(&self) -> acp::ToolKind { - match self { - Self::Task(_) => acp::ToolKind::Think, - Self::NotebookRead(_) => acp::ToolKind::Read, - Self::NotebookEdit(_) => acp::ToolKind::Edit, - Self::Edit(_) => acp::ToolKind::Edit, - Self::MultiEdit(_) => acp::ToolKind::Edit, - Self::Write(_) => acp::ToolKind::Edit, - Self::ReadFile(_) => acp::ToolKind::Read, - Self::Ls(_) => acp::ToolKind::Search, - Self::Glob(_) => acp::ToolKind::Search, - Self::Grep(_) => acp::ToolKind::Search, - Self::Terminal(_) => acp::ToolKind::Execute, - Self::WebSearch(_) => acp::ToolKind::Search, - Self::WebFetch(_) => acp::ToolKind::Fetch, - Self::TodoWrite(_) => acp::ToolKind::Think, - Self::ExitPlanMode(_) => acp::ToolKind::Think, - Self::Other { .. } => acp::ToolKind::Other, - } - } - - pub fn locations(&self) -> Vec { - match &self { - Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![acp::ToolCallLocation { - path: abs_path.clone(), - line: None, - }], - Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => { - vec![acp::ToolCallLocation { - path: file_path.clone(), - line: None, - }] - } - Self::Write(Some(WriteToolParams { - abs_path: file_path, - .. - })) => { - vec![acp::ToolCallLocation { - path: file_path.clone(), - line: None, - }] - } - Self::ReadFile(Some(ReadToolParams { - abs_path, offset, .. - })) => vec![acp::ToolCallLocation { - path: abs_path.clone(), - line: *offset, - }], - Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => { - vec![acp::ToolCallLocation { - path: notebook_path.clone(), - line: None, - }] - } - Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => { - vec![acp::ToolCallLocation { - path: notebook_path.clone(), - line: None, - }] - } - Self::Glob(Some(GlobToolParams { - path: Some(path), .. - })) => vec![acp::ToolCallLocation { - path: path.clone(), - line: None, - }], - Self::Ls(Some(LsToolParams { path, .. })) => vec![acp::ToolCallLocation { - path: path.clone(), - line: None, - }], - Self::Grep(Some(GrepToolParams { - path: Some(path), .. - })) => vec![acp::ToolCallLocation { - path: PathBuf::from(path), - line: None, - }], - Self::Task(_) - | Self::NotebookRead(None) - | Self::NotebookEdit(None) - | Self::Edit(None) - | Self::MultiEdit(None) - | Self::Write(None) - | Self::ReadFile(None) - | Self::Ls(None) - | Self::Glob(_) - | Self::Grep(_) - | Self::Terminal(_) - | Self::WebFetch(_) - | Self::WebSearch(_) - | Self::TodoWrite(_) - | Self::ExitPlanMode(_) - | Self::Other { .. } => vec![], - } - } - - pub fn as_acp(&self, id: acp::ToolCallId) -> acp::ToolCall { - acp::ToolCall { - id, - kind: self.kind(), - status: acp::ToolCallStatus::InProgress, - title: self.label(), - content: self.content(), - locations: self.locations(), - raw_input: None, - raw_output: None, - } - } -} - -/// Edit a file. -/// -/// In sessions with mcp__zed__Edit always use it instead of Edit as it will -/// allow the user to conveniently review changes. -/// -/// File editing instructions: -/// - The `old_text` param must match existing file content, including indentation. -/// - The `old_text` param must come from the actual file, not an outline. -/// - The `old_text` section must not be empty. -/// - Be minimal with replacements: -/// - For unique lines, include only those lines. -/// - For non-unique lines, include enough context to identify them. -/// - Do not escape quotes, newlines, or other characters. -/// - Only edit the specified file. -#[derive(Deserialize, JsonSchema, Debug)] -pub struct EditToolParams { - /// The absolute path to the file to read. - pub abs_path: PathBuf, - /// The old text to replace (must be unique in the file) - pub old_text: String, - /// The new text. - pub new_text: String, -} - -/// Reads the content of the given file in the project. -/// -/// Never attempt to read a path that hasn't been previously mentioned. -/// -/// In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents. -#[derive(Deserialize, JsonSchema, Debug)] -pub struct ReadToolParams { - /// The absolute path to the file to read. - pub abs_path: PathBuf, - /// Which line to start reading from. Omit to start from the beginning. - #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, - /// How many lines to read. Omit for the whole file. - #[serde(skip_serializing_if = "Option::is_none")] - pub limit: Option, -} - -/// Writes content to the specified file in the project. -/// -/// In sessions with mcp__zed__Write always use it instead of Write as it will -/// allow the user to conveniently review changes. -#[derive(Deserialize, JsonSchema, Debug)] -pub struct WriteToolParams { - /// The absolute path of the file to write. - pub abs_path: PathBuf, - /// The full content to write. - pub content: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct BashToolParams { - /// Shell command to execute - pub command: String, - /// 5-10 word description of what command does - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - /// Timeout in ms (max 600000ms/10min, default 120000ms) - #[serde(skip_serializing_if = "Option::is_none")] - pub timeout: Option, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct GlobToolParams { - /// Glob pattern like **/*.js or src/**/*.ts - pub pattern: String, - /// Directory to search in (omit for current directory) - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, -} - -impl std::fmt::Display for GlobToolParams { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(path) = &self.path { - write!(f, "{}", path.display())?; - } - write!(f, "{}", self.pattern) - } -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct LsToolParams { - /// Absolute path to directory - pub path: PathBuf, - /// Array of glob patterns to ignore - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub ignore: Vec, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct GrepToolParams { - /// Regex pattern to search for - pub pattern: String, - /// File/directory to search (defaults to current directory) - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, - /// "content" (shows lines), "files_with_matches" (default), "count" - #[serde(skip_serializing_if = "Option::is_none")] - pub output_mode: Option, - /// Filter files with glob pattern like "*.js" - #[serde(skip_serializing_if = "Option::is_none")] - pub glob: Option, - /// File type filter like "js", "py", "rust" - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - pub file_type: Option, - /// Case insensitive search - #[serde(rename = "-i", default, skip_serializing_if = "is_false")] - pub case_insensitive: bool, - /// Show line numbers (content mode only) - #[serde(rename = "-n", default, skip_serializing_if = "is_false")] - pub line_numbers: bool, - /// Lines after match (content mode only) - #[serde(rename = "-A", skip_serializing_if = "Option::is_none")] - pub after_context: Option, - /// Lines before match (content mode only) - #[serde(rename = "-B", skip_serializing_if = "Option::is_none")] - pub before_context: Option, - /// Lines before and after match (content mode only) - #[serde(rename = "-C", skip_serializing_if = "Option::is_none")] - pub context: Option, - /// Enable multiline/cross-line matching - #[serde(default, skip_serializing_if = "is_false")] - pub multiline: bool, - /// Limit output to first N results - #[serde(skip_serializing_if = "Option::is_none")] - pub head_limit: Option, -} - -impl std::fmt::Display for GrepToolParams { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "grep")?; - - // Boolean flags - if self.case_insensitive { - write!(f, " -i")?; - } - if self.line_numbers { - write!(f, " -n")?; - } - - // Context options - if let Some(after) = self.after_context { - write!(f, " -A {}", after)?; - } - if let Some(before) = self.before_context { - write!(f, " -B {}", before)?; - } - if let Some(context) = self.context { - write!(f, " -C {}", context)?; - } - - // Output mode - if let Some(mode) = &self.output_mode { - match mode { - GrepOutputMode::FilesWithMatches => write!(f, " -l")?, - GrepOutputMode::Count => write!(f, " -c")?, - GrepOutputMode::Content => {} // Default mode - } - } - - // Head limit - if let Some(limit) = self.head_limit { - write!(f, " | head -{}", limit)?; - } - - // Glob pattern - if let Some(glob) = &self.glob { - write!(f, " --include=\"{}\"", glob)?; - } - - // File type - if let Some(file_type) = &self.file_type { - write!(f, " --type={}", file_type)?; - } - - // Multiline - if self.multiline { - write!(f, " -P")?; // Perl-compatible regex for multiline - } - - // Pattern (escaped if contains special characters) - write!(f, " \"{}\"", self.pattern)?; - - // Path - if let Some(path) = &self.path { - write!(f, " {}", path)?; - } - - Ok(()) - } -} - -#[derive(Default, Deserialize, Serialize, JsonSchema, strum::Display, Debug)] -#[serde(rename_all = "snake_case")] -pub enum TodoPriority { - High, - #[default] - Medium, - Low, -} - -impl Into for TodoPriority { - fn into(self) -> acp::PlanEntryPriority { - match self { - TodoPriority::High => acp::PlanEntryPriority::High, - TodoPriority::Medium => acp::PlanEntryPriority::Medium, - TodoPriority::Low => acp::PlanEntryPriority::Low, - } - } -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum TodoStatus { - Pending, - InProgress, - Completed, -} - -impl Into for TodoStatus { - fn into(self) -> acp::PlanEntryStatus { - match self { - TodoStatus::Pending => acp::PlanEntryStatus::Pending, - TodoStatus::InProgress => acp::PlanEntryStatus::InProgress, - TodoStatus::Completed => acp::PlanEntryStatus::Completed, - } - } -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -pub struct Todo { - /// Task description - pub content: String, - /// Current status of the todo - pub status: TodoStatus, - /// Priority level of the todo - #[serde(default)] - pub priority: TodoPriority, -} - -impl Into for Todo { - fn into(self) -> acp::PlanEntry { - acp::PlanEntry { - content: self.content, - priority: self.priority.into(), - status: self.status.into(), - } - } -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct TodoWriteToolParams { - pub todos: Vec, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct ExitPlanModeToolParams { - /// Implementation plan in markdown format - pub plan: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct TaskToolParams { - /// Short 3-5 word description of task - pub description: String, - /// Detailed task for agent to perform - pub prompt: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct NotebookReadToolParams { - /// Absolute path to .ipynb file - pub notebook_path: PathBuf, - /// Specific cell ID to read - #[serde(skip_serializing_if = "Option::is_none")] - pub cell_id: Option, -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum CellType { - Code, - Markdown, -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum EditMode { - Replace, - Insert, - Delete, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct NotebookEditToolParams { - /// Absolute path to .ipynb file - pub notebook_path: PathBuf, - /// New cell content - pub new_source: String, - /// Cell ID to edit - #[serde(skip_serializing_if = "Option::is_none")] - pub cell_id: Option, - /// Type of cell (code or markdown) - #[serde(skip_serializing_if = "Option::is_none")] - pub cell_type: Option, - /// Edit operation mode - #[serde(skip_serializing_if = "Option::is_none")] - pub edit_mode: Option, -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -pub struct MultiEditItem { - /// The text to search for and replace - pub old_string: String, - /// The replacement text - pub new_string: String, - /// Whether to replace all occurrences or just the first - #[serde(default, skip_serializing_if = "is_false")] - pub replace_all: bool, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct MultiEditToolParams { - /// Absolute path to file - pub file_path: PathBuf, - /// List of edits to apply - pub edits: Vec, -} - -fn is_false(v: &bool) -> bool { - !*v -} - -#[derive(Deserialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum GrepOutputMode { - Content, - FilesWithMatches, - Count, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct WebFetchToolParams { - /// Valid URL to fetch - #[serde(rename = "url")] - pub url: String, - /// What to extract from content - pub prompt: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct WebSearchToolParams { - /// Search query (min 2 chars) - pub query: String, - /// Only include these domains - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_domains: Vec, - /// Exclude these domains - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub blocked_domains: Vec, -} - -impl std::fmt::Display for WebSearchToolParams { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "\"{}\"", self.query)?; - - if !self.allowed_domains.is_empty() { - write!(f, " (allowed: {})", self.allowed_domains.join(", "))?; - } - - if !self.blocked_domains.is_empty() { - write!(f, " (blocked: {})", self.blocked_domains.join(", "))?; - } - - Ok(()) - } -} diff --git a/crates/agent_servers/src/claude/write_tool.rs b/crates/agent_servers/src/claude/write_tool.rs deleted file mode 100644 index 39479a9c38ba616b3d0f2e4197c112bcbea68261..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/write_tool.rs +++ /dev/null @@ -1,59 +0,0 @@ -use acp_thread::AcpThread; -use anyhow::Result; -use context_server::{ - listener::{McpServerTool, ToolResponse}, - types::ToolAnnotations, -}; -use gpui::{AsyncApp, WeakEntity}; - -use crate::tools::WriteToolParams; - -#[derive(Clone)] -pub struct WriteTool { - thread_rx: watch::Receiver>, -} - -impl WriteTool { - pub fn new(thread_rx: watch::Receiver>) -> Self { - Self { thread_rx } - } -} - -impl McpServerTool for WriteTool { - type Input = WriteToolParams; - type Output = (); - - const NAME: &'static str = "Write"; - - fn annotations(&self) -> ToolAnnotations { - ToolAnnotations { - title: Some("Write file".to_string()), - read_only_hint: Some(false), - destructive_hint: Some(false), - open_world_hint: Some(false), - idempotent_hint: Some(false), - } - } - - async fn run( - &self, - input: Self::Input, - cx: &mut AsyncApp, - ) -> Result> { - let mut thread_rx = self.thread_rx.clone(); - let Some(thread) = thread_rx.recv().await?.upgrade() else { - anyhow::bail!("Thread closed"); - }; - - thread - .update(cx, |thread, cx| { - thread.write_text_file(input.abs_path, input.content, cx) - })? - .await?; - - Ok(ToolResponse { - content: vec![], - structured_content: (), - }) - } -} diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 72823026d7ce353e76485fbe76783b3cc2bbeb56..8d9670473a619eea9dc6730d04a4c807937aa393 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -1,8 +1,7 @@ -use crate::{AgentServerCommand, AgentServerSettings}; +use crate::{AgentServerCommand, AgentServerDelegate}; use acp_thread::AgentConnection; use anyhow::Result; -use gpui::{App, Entity, SharedString, Task}; -use project::Project; +use gpui::{App, SharedString, Task}; use std::{path::Path, rc::Rc}; use ui::IconName; @@ -13,11 +12,8 @@ pub struct CustomAgentServer { } impl CustomAgentServer { - pub fn new(name: SharedString, settings: &AgentServerSettings) -> Self { - Self { - name, - command: settings.command.clone(), - } + pub fn new(name: SharedString, command: AgentServerCommand) -> Self { + Self { name, command } } } @@ -34,27 +30,16 @@ impl crate::AgentServer for CustomAgentServer { IconName::Terminal } - fn empty_state_headline(&self) -> SharedString { - "No conversations yet".into() - } - - fn empty_state_message(&self) -> SharedString { - format!("Start a conversation with {}", self.name).into() - } - fn connect( &self, root_dir: &Path, - _project: &Entity, + _delegate: AgentServerDelegate, cx: &mut App, ) -> Task>> { let server_name = self.name(); let command = self.command.clone(); let root_dir = root_dir.to_path_buf(); - - cx.spawn(async move |mut cx| { - crate::acp::connect(server_name, command, &root_dir, &mut cx).await - }) + cx.spawn(async move |cx| crate::acp::connect(server_name, command, &root_dir, cx).await) } fn into_any(self: Rc) -> Rc { diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 42264b4b4f747e11cab11a21f7cde2ad0c43fee3..5d2becf0ccc4b30cfeca27f4eb5ee08c2d0bb7d1 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -1,4 +1,6 @@ -use crate::AgentServer; +use crate::{AgentServer, AgentServerDelegate}; +#[cfg(test)] +use crate::{AgentServerCommand, CustomAgentServerSettings}; use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; use agent_client_protocol as acp; use futures::{FutureExt, StreamExt, channel::mpsc, select}; @@ -471,12 +473,14 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { #[cfg(test)] crate::AllAgentServersSettings::override_global( crate::AllAgentServersSettings { - claude: Some(crate::AgentServerSettings { - command: crate::claude::tests::local_command(), - }), - gemini: Some(crate::AgentServerSettings { - command: crate::gemini::tests::local_command(), + claude: Some(CustomAgentServerSettings { + command: AgentServerCommand { + path: "claude-code-acp".into(), + args: vec![], + env: None, + }, }), + gemini: Some(crate::gemini::tests::local_command().into()), custom: collections::HashMap::default(), }, cx, @@ -494,8 +498,10 @@ pub async fn new_test_thread( current_dir: impl AsRef, cx: &mut TestAppContext, ) -> Entity { + let delegate = AgentServerDelegate::new(project.clone(), watch::channel("".into()).0); + let connection = cx - .update(|cx| server.connect(current_dir.as_ref(), &project, cx)) + .update(|cx| server.connect(current_dir.as_ref(), delegate, cx)) .await .unwrap(); diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 5d6a70fa64d981b2f25aee13c9d1d3ac7f94468f..5e958f686959d78e6ceaf8b8ea7d8404ffba166a 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -1,12 +1,12 @@ use std::rc::Rc; use std::{any::Any, path::Path}; -use crate::{AgentServer, AgentServerCommand}; +use crate::acp::AcpConnection; +use crate::{AgentServer, AgentServerDelegate}; use acp_thread::{AgentConnection, LoadError}; use anyhow::Result; -use gpui::{App, Entity, SharedString, Task}; +use gpui::{App, AppContext as _, SharedString, Task}; use language_models::provider::google::GoogleLanguageModelProvider; -use project::Project; use settings::SettingsStore; use crate::AllAgentServersSettings; @@ -25,14 +25,6 @@ impl AgentServer for Gemini { "Gemini CLI".into() } - fn empty_state_headline(&self) -> SharedString { - self.name() - } - - fn empty_state_message(&self) -> SharedString { - "Ask questions, edit files, run commands".into() - } - fn logo(&self) -> ui::IconName { ui::IconName::AiGemini } @@ -40,60 +32,99 @@ impl AgentServer for Gemini { fn connect( &self, root_dir: &Path, - project: &Entity, + delegate: AgentServerDelegate, cx: &mut App, ) -> Task>> { - let project = project.clone(); let root_dir = root_dir.to_path_buf(); let server_name = self.name(); + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).gemini.clone() + }); + cx.spawn(async move |cx| { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).gemini.clone() - })?; - - let Some(mut command) = - AgentServerCommand::resolve("gemini", &[ACP_ARG], None, settings, &project, cx).await - else { - return Err(LoadError::NotInstalled { - error_message: "Failed to find Gemini CLI binary".into(), - install_message: "Install Gemini CLI".into(), - install_command: Self::install_command().into(), - }.into()); + let ignore_system_version = settings + .as_ref() + .and_then(|settings| settings.ignore_system_version) + .unwrap_or(true); + let mut command = if let Some(settings) = settings + && let Some(command) = settings.custom_command() + { + command + } else { + cx.update(|cx| { + delegate.get_or_npm_install_builtin_agent( + Self::BINARY_NAME.into(), + Self::PACKAGE_NAME.into(), + format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(), + ignore_system_version, + Some(Self::MINIMUM_VERSION.parse().unwrap()), + cx, + ) + })? + .await? }; + command.args.push("--experimental-acp".into()); - if let Some(api_key)= cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() { - command.env.get_or_insert_default().insert("GEMINI_API_KEY".to_owned(), api_key.key); + if let Some(api_key) = cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() { + command + .env + .get_or_insert_default() + .insert("GEMINI_API_KEY".to_owned(), api_key.key); } let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await; - if result.is_err() { - let version_fut = util::command::new_smol_command(&command.path) - .args(command.args.iter()) - .arg("--version") - .kill_on_drop(true) - .output(); - - let help_fut = util::command::new_smol_command(&command.path) - .args(command.args.iter()) - .arg("--help") - .kill_on_drop(true) - .output(); - - let (version_output, help_output) = futures::future::join(version_fut, help_fut).await; - - let current_version = String::from_utf8(version_output?.stdout)?; - let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG); - - if !supported { - return Err(LoadError::Unsupported { - error_message: format!( - "Your installed version of Gemini CLI ({}, version {}) doesn't support the Agentic Coding Protocol (ACP).", - command.path.to_string_lossy(), - current_version - ).into(), - upgrade_message: "Upgrade Gemini CLI to latest".into(), - upgrade_command: Self::upgrade_command().into(), - }.into()) + match &result { + Ok(connection) => { + if let Some(connection) = connection.clone().downcast::() + && !connection.prompt_capabilities().image + { + let version_output = util::command::new_smol_command(&command.path) + .args(command.args.iter()) + .arg("--version") + .kill_on_drop(true) + .output() + .await; + let current_version = + String::from_utf8(version_output?.stdout)?.trim().to_owned(); + if !connection.prompt_capabilities().image { + return Err(LoadError::Unsupported { + current_version: current_version.into(), + command: command.path.to_string_lossy().to_string().into(), + minimum_version: Self::MINIMUM_VERSION.into(), + } + .into()); + } + } + } + Err(_) => { + let version_fut = util::command::new_smol_command(&command.path) + .args(command.args.iter()) + .arg("--version") + .kill_on_drop(true) + .output(); + + let help_fut = util::command::new_smol_command(&command.path) + .args(command.args.iter()) + .arg("--help") + .kill_on_drop(true) + .output(); + + let (version_output, help_output) = + futures::future::join(version_fut, help_fut).await; + + let current_version = std::str::from_utf8(&version_output?.stdout)? + .trim() + .to_string(); + let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG); + + if !supported { + return Err(LoadError::Unsupported { + current_version: current_version.into(), + command: command.path.to_string_lossy().to_string().into(), + minimum_version: Self::MINIMUM_VERSION.into(), + } + .into()); + } } } result @@ -106,17 +137,11 @@ impl AgentServer for Gemini { } impl Gemini { - pub fn binary_name() -> &'static str { - "gemini" - } + const PACKAGE_NAME: &str = "@google/gemini-cli"; - pub fn install_command() -> &'static str { - "npm install -g @google/gemini-cli@preview" - } + const MINIMUM_VERSION: &str = "0.2.1"; - pub fn upgrade_command() -> &'static str { - "npm install -g @google/gemini-cli@preview" - } + const BINARY_NAME: &str = "gemini"; } #[cfg(test)] diff --git a/crates/agent_servers/src/settings.rs b/crates/agent_servers/src/settings.rs index 96ac6e3cbe7dcd8a03aef5c6ec79c884bf99ae67..81f80a7d7d9581b8c1862ae3393c4a5d5e6706b6 100644 --- a/crates/agent_servers/src/settings.rs +++ b/crates/agent_servers/src/settings.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use crate::AgentServerCommand; use anyhow::Result; use collections::HashMap; @@ -12,16 +14,62 @@ pub fn init(cx: &mut App) { #[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)] pub struct AllAgentServersSettings { - pub gemini: Option, - pub claude: Option, + pub gemini: Option, + pub claude: Option, /// Custom agent servers configured by the user #[serde(flatten)] - pub custom: HashMap, + pub custom: HashMap, +} + +#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)] +pub struct BuiltinAgentServerSettings { + /// Absolute path to a binary to be used when launching this agent. + /// + /// This can be used to run a specific binary without automatic downloads or searching `$PATH`. + #[serde(rename = "command")] + pub path: Option, + /// If a binary is specified in `command`, it will be passed these arguments. + pub args: Option>, + /// If a binary is specified in `command`, it will be passed these environment variables. + pub env: Option>, + /// Whether to skip searching `$PATH` for an agent server binary when + /// launching this agent. + /// + /// This has no effect if a `command` is specified. Otherwise, when this is + /// `false`, Zed will search `$PATH` for an agent server binary and, if one + /// is found, use it for threads with this agent. If no agent binary is + /// found on `$PATH`, Zed will automatically install and use its own binary. + /// When this is `true`, Zed will not search `$PATH`, and will always use + /// its own binary. + /// + /// Default: true + pub ignore_system_version: Option, +} + +impl BuiltinAgentServerSettings { + pub(crate) fn custom_command(self) -> Option { + self.path.map(|path| AgentServerCommand { + path, + args: self.args.unwrap_or_default(), + env: self.env, + }) + } +} + +impl From for BuiltinAgentServerSettings { + fn from(value: AgentServerCommand) -> Self { + BuiltinAgentServerSettings { + path: Some(value.path), + args: Some(value.args), + env: value.env, + ..Default::default() + } + } } #[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)] -pub struct AgentServerSettings { +pub struct CustomAgentServerSettings { #[serde(flatten)] pub command: AgentServerCommand, } diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 0e4080d689bd4ae4ff67bdd7c6a9beb3f220f2b9..76b3709325a0c84a72bc71db8a67a3d4bd72dd06 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -6,8 +6,8 @@ use agent2::HistoryStore; use collections::HashMap; use editor::{Editor, EditorMode, MinimapVisibility}; use gpui::{ - AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, Focusable, - TextStyleRefinement, WeakEntity, Window, + AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, + ScrollHandle, TextStyleRefinement, WeakEntity, Window, }; use language::language_settings::SoftWrap; use project::Project; @@ -154,10 +154,22 @@ impl EntryViewState { }); } } - AgentThreadEntry::AssistantMessage(_) => { - if index == self.entries.len() { - self.entries.push(Entry::empty()) - } + AgentThreadEntry::AssistantMessage(message) => { + let entry = if let Some(Entry::AssistantMessage(entry)) = + self.entries.get_mut(index) + { + entry + } else { + self.set_entry( + index, + Entry::AssistantMessage(AssistantMessageEntry::default()), + ); + let Some(Entry::AssistantMessage(entry)) = self.entries.get_mut(index) else { + unreachable!() + }; + entry + }; + entry.sync(message); } }; } @@ -177,7 +189,7 @@ impl EntryViewState { pub fn settings_changed(&mut self, cx: &mut App) { for entry in self.entries.iter() { match entry { - Entry::UserMessage { .. } => {} + Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {} Entry::Content(response_views) => { for view in response_views.values() { if let Ok(diff_editor) = view.clone().downcast::() { @@ -208,17 +220,44 @@ pub enum ViewEvent { MessageEditorEvent(Entity, MessageEditorEvent), } +#[derive(Default, Debug)] +pub struct AssistantMessageEntry { + scroll_handles_by_chunk_index: HashMap, +} + +impl AssistantMessageEntry { + pub fn scroll_handle_for_chunk(&self, ix: usize) -> Option { + self.scroll_handles_by_chunk_index.get(&ix).cloned() + } + + pub fn sync(&mut self, message: &acp_thread::AssistantMessage) { + if let Some(acp_thread::AssistantMessageChunk::Thought { .. }) = message.chunks.last() { + let ix = message.chunks.len() - 1; + let handle = self.scroll_handles_by_chunk_index.entry(ix).or_default(); + handle.scroll_to_bottom(); + } + } +} + #[derive(Debug)] pub enum Entry { UserMessage(Entity), + AssistantMessage(AssistantMessageEntry), Content(HashMap), } impl Entry { + pub fn focus_handle(&self, cx: &App) -> Option { + match self { + Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)), + Self::AssistantMessage(_) | Self::Content(_) => None, + } + } + pub fn message_editor(&self) -> Option<&Entity> { match self { Self::UserMessage(editor) => Some(editor), - Entry::Content(_) => None, + Self::AssistantMessage(_) | Self::Content(_) => None, } } @@ -239,6 +278,16 @@ impl Entry { .map(|entity| entity.downcast::().unwrap()) } + pub fn scroll_handle_for_assistant_message_chunk( + &self, + chunk_ix: usize, + ) -> Option { + match self { + Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), + Self::UserMessage(_) | Self::Content(_) => None, + } + } + fn content_map(&self) -> Option<&HashMap> { match self { Self::Content(map) => Some(map), @@ -254,7 +303,7 @@ impl Entry { pub fn has_content(&self) -> bool { match self { Self::Content(map) => !map.is_empty(), - Self::UserMessage(_) => false, + Self::UserMessage(_) | Self::AssistantMessage(_) => false, } } } diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 44403d5ffaceac466bff4c69d339e8194c6395fe..0b6a856f70b9837b146d6f89a6de60d9b0088db1 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -4,7 +4,7 @@ use crate::{ }; use acp_thread::{MentionUri, selection_name}; use agent_client_protocol as acp; -use agent_servers::AgentServer; +use agent_servers::{AgentServer, AgentServerDelegate}; use agent2::HistoryStore; use anyhow::{Result, anyhow}; use assistant_slash_commands::codeblock_fence_for_path; @@ -648,7 +648,8 @@ impl MessageEditor { self.project.read(cx).fs().clone(), self.history_store.clone(), )); - let connection = server.connect(Path::new(""), &self.project, cx); + let delegate = AgentServerDelegate::new(self.project.clone(), watch::channel("".into()).0); + let connection = server.connect(Path::new(""), delegate, cx); cx.spawn(async move |_, cx| { let agent = connection.await?; let agent = agent.downcast::().unwrap(); diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs index 77c88c461d6e6fadcefd8eb7319bbbe2ff05fef4..cbb513696d88bbfcd95e15e051fc69322fd11281 100644 --- a/crates/agent_ui/src/acp/model_selector.rs +++ b/crates/agent_ui/src/acp/model_selector.rs @@ -73,11 +73,8 @@ impl AcpModelPickerDelegate { this.update_in(cx, |this, window, cx| { this.delegate.models = models.ok(); this.delegate.selected_model = selected_model.ok(); - this.delegate.update_matches(this.query(cx), window, cx) - })? - .await; - - Ok(()) + this.refresh(window, cx) + }) } refresh(&this, &session_id, cx).await.log_err(); diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index e4f6eaf5f693306b1ff8415ee6171d59d63b8a11..ed3d9ef45d76c540c40fcf6146bab0056cf6e83a 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -6,10 +6,10 @@ use acp_thread::{ use acp_thread::{AgentConnection, Plan}; use action_log::ActionLog; use agent_client_protocol::{self as acp, PromptCapabilities}; -use agent_servers::{AgentServer, ClaudeCode}; +use agent_servers::{AgentServer, AgentServerDelegate, ClaudeCode}; use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting}; use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore}; -use anyhow::bail; +use anyhow::{Result, anyhow, bail}; use audio::{Audio, Sound}; use buffer_diff::BufferDiff; use client::zed_urls; @@ -18,13 +18,14 @@ use editor::scroll::Autoscroll; use editor::{Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects}; use file_icons::FileIcons; use fs::Fs; +use futures::FutureExt as _; use gpui::{ Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem, - EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset, - ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription, - Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, - WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, point, - prelude::*, pulsating_between, + CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, + ListOffset, ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, + Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, + Window, WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, + point, prelude::*, pulsating_between, }; use language::Buffer; @@ -39,6 +40,8 @@ use std::path::Path; use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; +use task::SpawnInTerminal; +use terminal_view::terminal_panel::TerminalPanel; use text::Anchor; use theme::ThemeSettings; use ui::{ @@ -66,7 +69,6 @@ use crate::{ KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector, }; -const RESPONSE_PADDING_X: Pixels = px(19.); pub const MIN_EDITOR_LINES: usize = 4; pub const MAX_EDITOR_LINES: usize = 8; @@ -94,6 +96,10 @@ impl ThreadError { error.downcast_ref::() { Self::ModelRequestLimitReached(error.plan) + } else if let Some(acp_error) = error.downcast_ref::() + && acp_error.code == acp::ErrorCode::AUTH_REQUIRED.code + { + Self::AuthenticationRequired(acp_error.message.clone().into()) } else { let string = error.to_string(); // TODO: we should have Gemini return better errors here. @@ -284,9 +290,7 @@ pub struct AcpThreadView { } enum ThreadState { - Loading { - _task: Task<()>, - }, + Loading(Entity), Ready { thread: Entity, title_editor: Option>, @@ -302,6 +306,12 @@ enum ThreadState { }, } +struct LoadingView { + title: SharedString, + _load_task: Task<()>, + _update_title_task: Task>, +} + impl AcpThreadView { pub fn new( agent: Rc, @@ -412,8 +422,10 @@ impl AcpThreadView { .next() .map(|worktree| worktree.read(cx).abs_path()) .unwrap_or_else(|| paths::home_dir().as_path().into()); + let (tx, mut rx) = watch::channel("Loading…".into()); + let delegate = AgentServerDelegate::new(project.clone(), tx); - let connect_task = agent.connect(&root_dir, &project, cx); + let connect_task = agent.connect(&root_dir, delegate, cx); let load_task = cx.spawn_in(window, async move |this, cx| { let connection = match connect_task.await { Ok(connection) => connection, @@ -478,11 +490,14 @@ impl AcpThreadView { .set(thread.read(cx).prompt_capabilities()); let count = thread.read(cx).entries().len(); - this.list_state.splice(0..0, count); this.entry_view_state.update(cx, |view_state, cx| { for ix in 0..count { view_state.sync_entry(ix, &thread, window, cx); } + this.list_state.splice_focusable( + 0..0, + (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)), + ); }); if let Some(resume) = resume_thread { @@ -563,7 +578,25 @@ impl AcpThreadView { .log_err(); }); - ThreadState::Loading { _task: load_task } + let loading_view = cx.new(|cx| { + let update_title_task = cx.spawn(async move |this, cx| { + loop { + let status = rx.recv().await?; + this.update(cx, |this: &mut LoadingView, cx| { + this.title = status; + cx.notify(); + })?; + } + }); + + LoadingView { + title: "Loading…".into(), + _load_task: load_task, + _update_title_task: update_title_task, + } + }); + + ThreadState::Loading(loading_view) } fn handle_auth_required( @@ -663,11 +696,18 @@ impl AcpThreadView { } } - pub fn title(&self) -> SharedString { + pub fn title(&self, cx: &App) -> SharedString { match &self.thread_state { ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(), - ThreadState::Loading { .. } => "Loading…".into(), - ThreadState::LoadError(_) => "Failed to load".into(), + ThreadState::Loading(loading_view) => loading_view.read(cx).title.clone(), + ThreadState::LoadError(error) => match error { + LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), + LoadError::FailedToInstall(_) => { + format!("Failed to Install {}", self.agent.name()).into() + } + LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(), + LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(), + }, } } @@ -889,7 +929,7 @@ impl AcpThreadView { fn send_impl( &mut self, - contents: Task, Vec>)>>, + contents: Task, Vec>)>>, window: &mut Window, cx: &mut Context, ) { @@ -899,9 +939,10 @@ impl AcpThreadView { self.editing_message.take(); self.thread_feedback.clear(); - let Some(thread) = self.thread().cloned() else { + let Some(thread) = self.thread() else { return; }; + let thread = thread.downgrade(); if self.should_be_following { self.workspace .update(cx, |workspace, cx| { @@ -1109,9 +1150,14 @@ impl AcpThreadView { let len = thread.read(cx).entries().len(); let index = len - 1; self.entry_view_state.update(cx, |view_state, cx| { - view_state.sync_entry(index, thread, window, cx) + view_state.sync_entry(index, thread, window, cx); + self.list_state.splice_focusable( + index..index, + [view_state + .entry(index) + .and_then(|entry| entry.focus_handle(cx))], + ); }); - self.list_state.splice(index..index, 1); } AcpThreadEvent::EntryUpdated(index) => { self.entry_view_state.update(cx, |view_state, cx| { @@ -1219,6 +1265,31 @@ impl AcpThreadView { }); return; } + } else if method.0.as_ref() == "anthropic-api-key" { + let registry = LanguageModelRegistry::global(cx); + let provider = registry + .read(cx) + .provider(&language_model::ANTHROPIC_PROVIDER_ID) + .unwrap(); + if !provider.is_authenticated(cx) { + let this = cx.weak_entity(); + let agent = self.agent.clone(); + let connection = connection.clone(); + window.defer(cx, |window, cx| { + Self::handle_auth_required( + this, + AuthRequired { + description: Some("ANTHROPIC_API_KEY must be set".to_owned()), + provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID), + }, + agent, + connection, + window, + cx, + ); + }); + return; + } } else if method.0.as_ref() == "vertex-ai" && std::env::var("GOOGLE_API_KEY").is_err() && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err() @@ -1250,7 +1321,15 @@ impl AcpThreadView { self.thread_error.take(); configuration_view.take(); pending_auth_method.replace(method.clone()); - let authenticate = connection.authenticate(method, cx); + let authenticate = if method.0.as_ref() == "claude-login" { + if let Some(workspace) = self.workspace.upgrade() { + Self::spawn_claude_login(&workspace, window, cx) + } else { + Task::ready(Ok(())) + } + } else { + connection.authenticate(method, cx) + }; cx.notify(); self.auth_task = Some(cx.spawn_in(window, { @@ -1274,6 +1353,13 @@ impl AcpThreadView { this.update_in(cx, |this, window, cx| { if let Err(err) = result { + if let ThreadState::Unauthenticated { + pending_auth_method, + .. + } = &mut this.thread_state + { + pending_auth_method.take(); + } this.handle_thread_error(err, cx); } else { this.thread_state = Self::initial_state( @@ -1292,6 +1378,76 @@ impl AcpThreadView { })); } + fn spawn_claude_login( + workspace: &Entity, + window: &mut Window, + cx: &mut App, + ) -> Task> { + let Some(terminal_panel) = workspace.read(cx).panel::(cx) else { + return Task::ready(Ok(())); + }; + let project = workspace.read(cx).project().read(cx); + let cwd = project.first_project_directory(cx); + let shell = project.terminal_settings(&cwd, cx).shell.clone(); + + let terminal = terminal_panel.update(cx, |terminal_panel, cx| { + terminal_panel.spawn_task( + &SpawnInTerminal { + id: task::TaskId("claude-login".into()), + full_label: "claude /login".to_owned(), + label: "claude /login".to_owned(), + command: Some("claude".to_owned()), + args: vec!["/login".to_owned()], + command_label: "claude /login".to_owned(), + cwd, + use_new_terminal: true, + allow_concurrent_runs: true, + hide: task::HideStrategy::Always, + shell, + ..Default::default() + }, + window, + cx, + ) + }); + cx.spawn(async move |cx| { + let terminal = terminal.await?; + let mut exit_status = terminal + .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? + .fuse(); + + let logged_in = cx + .spawn({ + let terminal = terminal.clone(); + async move |cx| { + loop { + cx.background_executor().timer(Duration::from_secs(1)).await; + let content = + terminal.update(cx, |terminal, _cx| terminal.get_content())?; + if content.contains("Login successful") { + return anyhow::Ok(()); + } + } + } + }) + .fuse(); + futures::pin_mut!(logged_in); + futures::select_biased! { + result = logged_in => { + if let Err(e) = result { + log::error!("{e}"); + return Err(anyhow!("exited before logging in")); + } + } + _ = exit_status => { + return Err(anyhow!("exited before logging in")); + } + } + terminal.update(cx, |terminal, _| terminal.kill_active_task())?; + Ok(()) + }) + } + fn authorize_tool_call( &mut self, tool_call_id: acp::ToolCallId, @@ -1367,14 +1523,14 @@ impl AcpThreadView { .id(("user_message", entry_ix)) .map(|this| { if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { - this.pt_4() + this.pt(rems_from_px(18.)) } else if rules_item.is_some() { this.pt_3() } else { this.pt_2() } }) - .pb_4() + .pb_3() .px_2() .gap_1p5() .w_full() @@ -1493,10 +1649,12 @@ impl AcpThreadView { .into_any() } AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { + let is_last = entry_ix + 1 == total_entries; + let style = default_markdown_style(false, false, window, cx); let message_body = v_flex() .w_full() - .gap_2p5() + .gap_3() .children(chunks.iter().enumerate().filter_map( |(chunk_ix, chunk)| match chunk { AssistantMessageChunk::Message { block } => { @@ -1523,8 +1681,8 @@ impl AcpThreadView { v_flex() .px_5() - .py_1() - .when(entry_ix + 1 == total_entries, |this| this.pb_4()) + .py_1p5() + .when(is_last, |this| this.pb_4()) .w_full() .text_ui(cx) .child(message_body) @@ -1533,7 +1691,7 @@ impl AcpThreadView { AgentThreadEntry::ToolCall(tool_call) => { let has_terminals = tool_call.terminals().next().is_some(); - div().w_full().py_1().px_5().map(|this| { + div().w_full().map(|this| { if has_terminals { this.children(tool_call.terminals().map(|terminal| { self.render_terminal_tool_call( @@ -1614,59 +1772,72 @@ impl AcpThreadView { ) -> AnyElement { let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix)); let card_header_id = SharedString::from("inner-card-header"); + let key = (entry_ix, chunk_ix); + let is_open = self.expanded_thinking_blocks.contains(&key); + let scroll_handle = self + .entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)); + + let thinking_content = { + div() + .id(("thinking-content", chunk_ix)) + .when_some(scroll_handle, |this, scroll_handle| { + this.track_scroll(&scroll_handle) + }) + .text_ui_sm(cx) + .overflow_hidden() + .child( + self.render_markdown(chunk, default_markdown_style(false, false, window, cx)), + ) + }; + v_flex() + .gap_1() .child( h_flex() .id(header_id) .group(&card_header_id) .relative() .w_full() - .gap_1p5() + .pr_1() + .justify_between() .child( h_flex() - .size_4() - .justify_center() + .h(window.line_height() - px(2.)) + .gap_1p5() + .overflow_hidden() .child( - div() - .group_hover(&card_header_id, |s| s.invisible().w_0()) - .child( - Icon::new(IconName::ToolThink) - .size(IconSize::Small) - .color(Color::Muted), - ), + Icon::new(IconName::ToolThink) + .size(IconSize::Small) + .color(Color::Muted), ) .child( - h_flex() - .absolute() - .inset_0() - .invisible() - .justify_center() - .group_hover(&card_header_id, |s| s.visible()) - .child( - Disclosure::new(("expand", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronRight) - .on_click(cx.listener({ - move |this, _event, _window, cx| { - if is_open { - this.expanded_thinking_blocks.remove(&key); - } else { - this.expanded_thinking_blocks.insert(key); - } - cx.notify(); - } - })), - ), + div() + .text_size(self.tool_name_font_size()) + .text_color(cx.theme().colors().text_muted) + .child("Thinking"), ), ) .child( - div() - .text_size(self.tool_name_font_size()) - .text_color(cx.theme().colors().text_muted) - .child("Thinking"), + Disclosure::new(("expand", entry_ix), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&card_header_id) + .on_click(cx.listener({ + move |this, _event, _window, cx| { + if is_open { + this.expanded_thinking_blocks.remove(&key); + } else { + this.expanded_thinking_blocks.insert(key); + } + cx.notify(); + } + })), ) .on_click(cx.listener({ move |this, _event, _window, cx| { @@ -1682,17 +1853,11 @@ impl AcpThreadView { .when(is_open, |this| { this.child( div() - .relative() - .mt_1p5() - .ml(px(7.)) - .pl_4() + .ml_1p5() + .pl_3p5() .border_l_1() .border_color(self.tool_card_border_color(cx)) - .text_ui_sm(cx) - .child(self.render_markdown( - chunk, - default_markdown_style(false, false, window, cx), - )), + .child(thinking_content), ) }) .into_any_element() @@ -1705,7 +1870,6 @@ impl AcpThreadView { window: &Window, cx: &Context, ) -> Div { - let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix)); let card_header_id = SharedString::from("inner-tool-call-header"); let tool_icon = @@ -1734,11 +1898,7 @@ impl AcpThreadView { _ => false, }; - let failed_tool_call = matches!( - tool_call.status, - ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed - ); - + let has_location = tool_call.locations.len() == 1; let needs_confirmation = matches!( tool_call.status, ToolCallStatus::WaitingForConfirmation { .. } @@ -1751,46 +1911,56 @@ impl AcpThreadView { let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id); - let gradient_overlay = |color: Hsla| { + let gradient_overlay = { div() .absolute() .top_0() .right_0() .w_12() .h_full() - .bg(linear_gradient( - 90., - linear_color_stop(color, 1.), - linear_color_stop(color.opacity(0.2), 0.), - )) - }; - let gradient_color = if use_card_layout { - self.tool_card_header_bg(cx) - } else { - cx.theme().colors().panel_background + .map(|this| { + if use_card_layout { + this.bg(linear_gradient( + 90., + linear_color_stop(self.tool_card_header_bg(cx), 1.), + linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.), + )) + } else { + this.bg(linear_gradient( + 90., + linear_color_stop(cx.theme().colors().panel_background, 1.), + linear_color_stop( + cx.theme().colors().panel_background.opacity(0.2), + 0., + ), + )) + } + }) }; let tool_output_display = if is_open { match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { options, .. } => { - v_flex() - .w_full() - .children(tool_call.content.iter().map(|content| { - div() - .child(self.render_tool_call_content( - entry_ix, content, tool_call, window, cx, - )) - .into_any_element() - })) - .child(self.render_permission_buttons( - options, - entry_ix, - tool_call.id.clone(), - tool_call.content.is_empty(), - cx, - )) - .into_any() - } + ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex() + .w_full() + .children(tool_call.content.iter().map(|content| { + div() + .child(self.render_tool_call_content( + entry_ix, + content, + tool_call, + use_card_layout, + window, + cx, + )) + .into_any_element() + })) + .child(self.render_permission_buttons( + options, + entry_ix, + tool_call.id.clone(), + cx, + )) + .into_any(), ToolCallStatus::Pending | ToolCallStatus::InProgress if is_edit && tool_call.content.is_empty() @@ -1805,9 +1975,14 @@ impl AcpThreadView { | ToolCallStatus::Canceled => v_flex() .w_full() .children(tool_call.content.iter().map(|content| { - div().child( - self.render_tool_call_content(entry_ix, content, tool_call, window, cx), - ) + div().child(self.render_tool_call_content( + entry_ix, + content, + tool_call, + use_card_layout, + window, + cx, + )) })) .into_any(), ToolCallStatus::Rejected => Empty.into_any(), @@ -1818,30 +1993,36 @@ impl AcpThreadView { }; v_flex() - .when(use_card_layout, |this| { - this.rounded_md() - .border_1() - .border_color(self.tool_card_border_color(cx)) - .bg(cx.theme().colors().editor_background) - .overflow_hidden() + .map(|this| { + if use_card_layout { + this.my_1p5() + .rounded_md() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + } else { + this.my_1() + } + }) + .map(|this| { + if has_location && !use_card_layout { + this.ml_4() + } else { + this.ml_5() + } }) + .mr_5() .child( h_flex() - .id(header_id) .group(&card_header_id) .relative() .w_full() - .max_w_full() .gap_1() + .justify_between() .when(use_card_layout, |this| { - this.pl_1p5() - .pr_1() - .py_0p5() - .rounded_t_md() - .when(is_open && !failed_tool_call, |this| { - this.border_b_1() - .border_color(self.tool_card_border_color(cx)) - }) + this.p_0p5() + .rounded_t(rems_from_px(5.)) .bg(self.tool_card_header_bg(cx)) }) .child( @@ -1850,8 +2031,16 @@ impl AcpThreadView { .w_full() .h(window.line_height() - px(2.)) .text_size(self.tool_name_font_size()) + .gap_1p5() + .when(has_location || use_card_layout, |this| this.px_1()) + .when(has_location, |this| { + this.cursor(CursorStyle::PointingHand) + .rounded(rems_from_px(3.)) // Concentric border radius + .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5))) + }) + .overflow_hidden() .child(tool_icon) - .child(if tool_call.locations.len() == 1 { + .child(if has_location { let name = tool_call.locations[0] .path .file_name() @@ -1862,13 +2051,6 @@ impl AcpThreadView { h_flex() .id(("open-tool-call-location", entry_ix)) .w_full() - .max_w_full() - .px_1p5() - .rounded_sm() - .overflow_x_scroll() - .hover(|label| { - label.bg(cx.theme().colors().element_hover.opacity(0.5)) - }) .map(|this| { if use_card_layout { this.text_color(cx.theme().colors().text) @@ -1878,31 +2060,28 @@ impl AcpThreadView { }) .child(name) .tooltip(Tooltip::text("Jump to File")) - .cursor(gpui::CursorStyle::PointingHand) .on_click(cx.listener(move |this, _, window, cx| { this.open_tool_call_location(entry_ix, 0, window, cx); })) .into_any_element() } else { h_flex() - .relative() .w_full() - .max_w_full() - .ml_1p5() - .overflow_hidden() - .child(h_flex().pr_8().child(self.render_markdown( + .child(self.render_markdown( tool_call.label.clone(), default_markdown_style(false, true, window, cx), - ))) - .child(gradient_overlay(gradient_color)) + )) .into_any() - }), + }) + .when(!has_location, |this| this.child(gradient_overlay)), ) - .child( - h_flex() - .gap_px() - .when(is_collapsible, |this| { - this.child( + .when(is_collapsible || failed_or_canceled, |this| { + this.child( + h_flex() + .px_1() + .gap_px() + .when(is_collapsible, |this| { + this.child( Disclosure::new(("expand", entry_ix), is_open) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) @@ -1919,15 +2098,16 @@ impl AcpThreadView { } })), ) - }) - .when(failed_or_canceled, |this| { - this.child( - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::Small), - ) - }), - ), + }) + .when(failed_or_canceled, |this| { + this.child( + Icon::new(IconName::Close) + .color(Color::Error) + .size(IconSize::Small), + ) + }), + ) + }), ) .children(tool_output_display) } @@ -1937,6 +2117,7 @@ impl AcpThreadView { entry_ix: usize, content: &ToolCallContent, tool_call: &ToolCall, + card_layout: bool, window: &Window, cx: &Context, ) -> AnyElement { @@ -1945,7 +2126,13 @@ impl AcpThreadView { if let Some(resource_link) = content.resource_link() { self.render_resource_link(resource_link, cx) } else if let Some(markdown) = content.markdown() { - self.render_markdown_output(markdown.clone(), tool_call.id.clone(), window, cx) + self.render_markdown_output( + markdown.clone(), + tool_call.id.clone(), + card_layout, + window, + cx, + ) } else { Empty.into_any_element() } @@ -1961,6 +2148,7 @@ impl AcpThreadView { &self, markdown: Entity, tool_call_id: acp::ToolCallId, + card_layout: bool, window: &Window, cx: &Context, ) -> AnyElement { @@ -1968,26 +2156,35 @@ impl AcpThreadView { v_flex() .mt_1p5() - .ml(px(7.)) - .px_3p5() .gap_2() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) + .when(!card_layout, |this| { + this.ml(rems(0.4)) + .px_3p5() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + }) + .when(card_layout, |this| { + this.p_2() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + }) .text_sm() .text_color(cx.theme().colors().text_muted) .child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx))) - .child( - IconButton::new(button_id, IconName::ChevronUp) - .full_width() - .style(ButtonStyle::Outlined) - .icon_color(Color::Muted) - .on_click(cx.listener({ - move |this: &mut Self, _, _, cx: &mut Context| { - this.expanded_tool_calls.remove(&tool_call_id); - cx.notify(); - } - })), - ) + .when(!card_layout, |this| { + this.child( + IconButton::new(button_id, IconName::ChevronUp) + .full_width() + .style(ButtonStyle::Outlined) + .icon_color(Color::Muted) + .on_click(cx.listener({ + move |this: &mut Self, _, _, cx: &mut Context| { + this.expanded_tool_calls.remove(&tool_call_id); + cx.notify(); + } + })), + ) + }) .into_any_element() } @@ -2025,7 +2222,7 @@ impl AcpThreadView { let button_id = SharedString::from(format!("item-{}", uri)); div() - .ml(px(7.)) + .ml(rems(0.4)) .pl_2p5() .border_l_1() .border_color(self.tool_card_border_color(cx)) @@ -2055,7 +2252,6 @@ impl AcpThreadView { options: &[acp::PermissionOption], entry_ix: usize, tool_call_id: acp::ToolCallId, - empty_content: bool, cx: &Context, ) -> Div { h_flex() @@ -2065,10 +2261,8 @@ impl AcpThreadView { .gap_1() .justify_between() .flex_wrap() - .when(!empty_content, |this| { - this.border_t_1() - .border_color(self.tool_card_border_color(cx)) - }) + .border_t_1() + .border_color(self.tool_card_border_color(cx)) .child( div() .min_w(rems_from_px(145.)) @@ -2166,6 +2360,8 @@ impl AcpThreadView { v_flex() .h_full() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) .child( if let Some(entry) = self.entry_view_state.read(cx).entry(entry_ix) && let Some(editor) = entry.editor_for_diff(diff) @@ -2213,6 +2409,12 @@ impl AcpThreadView { started_at.elapsed() }; + let header_id = + SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id())); + let header_group = SharedString::from(format!( + "terminal-tool-header-group-{}", + terminal.entity_id() + )); let header_bg = cx .theme() .colors() @@ -2228,10 +2430,7 @@ impl AcpThreadView { let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); let header = h_flex() - .id(SharedString::from(format!( - "terminal-tool-header-{}", - terminal.entity_id() - ))) + .id(header_id) .flex_none() .gap_1() .justify_between() @@ -2295,23 +2494,28 @@ impl AcpThreadView { ), ) }) - .when(tool_failed || command_failed, |header| { - header.child( - div() - .id(("terminal-tool-error-code-indicator", terminal.entity_id())) - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .when_some(output.and_then(|o| o.exit_status), |this, status| { - this.tooltip(Tooltip::text(format!( - "Exited with code {}", - status.code().unwrap_or(-1), - ))) - }), + .child( + Disclosure::new( + SharedString::from(format!( + "terminal-tool-disclosure-{}", + terminal.entity_id() + )), + is_expanded, ) - }) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&header_group) + .on_click(cx.listener({ + let id = tool_call.id.clone(); + move |this, _event, _window, _cx| { + if is_expanded { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id.clone()); + } + } + })), + ) .when(truncated_output, |header| { let tooltip = if let Some(output) = output { if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { @@ -2354,26 +2558,23 @@ impl AcpThreadView { .size(LabelSize::XSmall), ) }) - .child( - Disclosure::new( - SharedString::from(format!( - "terminal-tool-disclosure-{}", - terminal.entity_id() - )), - is_expanded, + .when(tool_failed || command_failed, |header| { + header.child( + div() + .id(("terminal-tool-error-code-indicator", terminal.entity_id())) + .child( + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error), + ) + .when_some(output.and_then(|o| o.exit_status), |this, status| { + this.tooltip(Tooltip::text(format!( + "Exited with code {}", + status.code().unwrap_or(-1), + ))) + }), ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this, _event, _window, _cx| { - if is_expanded { - this.expanded_tool_calls.remove(&id); - } else { - this.expanded_tool_calls.insert(id.clone()); - } - }})), - ); + }); let terminal_view = self .entry_view_state @@ -2383,7 +2584,8 @@ impl AcpThreadView { let show_output = is_expanded && terminal_view.is_some(); v_flex() - .mb_2() + .my_1p5() + .mx_5() .border_1() .when(tool_failed || command_failed, |card| card.border_dashed()) .border_color(border_color) @@ -2391,9 +2593,10 @@ impl AcpThreadView { .overflow_hidden() .child( v_flex() + .group(&header_group) .py_1p5() - .pl_2() .pr_1p5() + .pl_2() .gap_0p5() .bg(header_bg) .text_xs() @@ -2765,128 +2968,32 @@ impl AcpThreadView { ) } - fn render_load_error(&self, e: &LoadError, cx: &Context) -> AnyElement { - let (message, action_slot) = match e { - LoadError::NotInstalled { - error_message, - install_message, - install_command, - } => { - let install_command = install_command.clone(); - let button = Button::new("install", install_message) - .tooltip(Tooltip::text(install_command.clone())) - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .icon(IconName::Download) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(move |this, _, window, cx| { - telemetry::event!("Agent Install CLI", agent = this.agent.telemetry_id()); - - let task = this - .workspace - .update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - let cwd = project.first_project_directory(cx); - let shell = project.terminal_settings(&cwd, cx).shell.clone(); - let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId(install_command.clone()), - full_label: install_command.clone(), - label: install_command.clone(), - command: Some(install_command.clone()), - args: Vec::new(), - command_label: install_command.clone(), - cwd, - env: Default::default(), - use_new_terminal: true, - allow_concurrent_runs: true, - reveal: Default::default(), - reveal_target: Default::default(), - hide: Default::default(), - shell, - show_summary: true, - show_command: true, - show_rerun: false, - }; - workspace.spawn_in_terminal(spawn_in_terminal, window, cx) - }) - .ok(); - let Some(task) = task else { return }; - cx.spawn_in(window, async move |this, cx| { - if let Some(Ok(_)) = task.await { - this.update_in(cx, |this, window, cx| { - this.reset(window, cx); - }) - .ok(); - } - }) - .detach() - })); - - (error_message.clone(), Some(button.into_any_element())) - } + fn render_load_error( + &self, + e: &LoadError, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (title, message, action_slot): (_, SharedString, _) = match e { LoadError::Unsupported { - error_message, - upgrade_message, - upgrade_command, + command: path, + current_version, + minimum_version, } => { - let upgrade_command = upgrade_command.clone(); - let button = Button::new("upgrade", upgrade_message) - .tooltip(Tooltip::text(upgrade_command.clone())) - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .icon(IconName::Download) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(move |this, _, window, cx| { - telemetry::event!("Agent Upgrade CLI", agent = this.agent.telemetry_id()); - - let task = this - .workspace - .update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - let cwd = project.first_project_directory(cx); - let shell = project.terminal_settings(&cwd, cx).shell.clone(); - let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId(upgrade_command.to_string()), - full_label: upgrade_command.clone(), - label: upgrade_command.clone(), - command: Some(upgrade_command.clone()), - args: Vec::new(), - command_label: upgrade_command.clone(), - cwd, - env: Default::default(), - use_new_terminal: true, - allow_concurrent_runs: true, - reveal: Default::default(), - reveal_target: Default::default(), - hide: Default::default(), - shell, - show_summary: true, - show_command: true, - show_rerun: false, - }; - workspace.spawn_in_terminal(spawn_in_terminal, window, cx) - }) - .ok(); - let Some(task) = task else { return }; - cx.spawn_in(window, async move |this, cx| { - if let Some(Ok(_)) = task.await { - this.update_in(cx, |this, window, cx| { - this.reset(window, cx); - }) - .ok(); - } - }) - .detach() - })); - - (error_message.clone(), Some(button.into_any_element())) + return self.render_unsupported(path, current_version, minimum_version, window, cx); } - LoadError::Exited { .. } => ("Server exited with status {status}".into(), None), + LoadError::FailedToInstall(msg) => ( + "Failed to Install", + msg.into(), + Some(self.create_copy_button(msg.to_string()).into_any_element()), + ), + LoadError::Exited { status } => ( + "Failed to Launch", + format!("Server exited with status {status}").into(), + None, + ), LoadError::Other(msg) => ( + "Failed to Launch", msg.into(), Some(self.create_copy_button(msg.to_string()).into_any_element()), ), @@ -2895,12 +3002,56 @@ impl AcpThreadView { Callout::new() .severity(Severity::Error) .icon(IconName::XCircleFilled) - .title("Failed to Launch") + .title(title) .description(message) .actions_slot(div().children(action_slot)) .into_any_element() } + fn render_unsupported( + &self, + path: &SharedString, + version: &SharedString, + minimum_version: &SharedString, + _window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (heading_label, description_label) = ( + format!("Upgrade {} to work with Zed", self.agent.name()), + if version.is_empty() { + format!( + "Currently using {}, which does not report a valid --version", + path, + ) + } else { + format!( + "Currently using {}, which is only version {} (need at least {minimum_version})", + path, version + ) + }, + ); + + v_flex() + .w_full() + .p_3p5() + .gap_2p5() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(linear_gradient( + 180., + linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.), + linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.), + )) + .child( + v_flex().gap_0p5().child(Label::new(heading_label)).child( + Label::new(description_label) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element() + } + fn render_activity_bar( &self, thread_entity: &Entity, @@ -2920,7 +3071,12 @@ impl AcpThreadView { let active_color = cx.theme().colors().element_selected; let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3)); - let pending_edits = thread.has_pending_edit_tool_calls(); + // Temporarily always enable ACP edit controls. This is temporary, to lessen the + // impact of a nasty bug that causes them to sometimes be disabled when they shouldn't + // be, which blocks you from being able to accept or reject edits. This switches the + // bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't + // block you from using the panel. + let pending_edits = false; v_flex() .mt_1() @@ -3283,7 +3439,6 @@ impl AcpThreadView { let element = h_flex() .group("edited-code") .id(("file-container", index)) - .relative() .py_1() .pl_2() .pr_1() @@ -3295,6 +3450,7 @@ impl AcpThreadView { }) .child( h_flex() + .relative() .id(("file-name", index)) .pr_8() .gap_1p5() @@ -3302,6 +3458,16 @@ impl AcpThreadView { .overflow_x_scroll() .child(file_icon) .child(h_flex().gap_0p5().children(file_name).children(file_path)) + .child( + div() + .absolute() + .h_full() + .w_12() + .top_0() + .bottom_0() + .right_0() + .bg(overlay_gradient), + ) .on_click({ let buffer = buffer.clone(); cx.listener(move |this, _, window, cx| { @@ -3362,17 +3528,6 @@ impl AcpThreadView { } }), ), - ) - .child( - div() - .id("gradient-overlay") - .absolute() - .h_full() - .w_12() - .top_0() - .bottom_0() - .right(px(152.)) - .bg(overlay_gradient), ); Some(element) @@ -3932,7 +4087,7 @@ impl AcpThreadView { workspace: Entity, window: &mut Window, cx: &mut App, - ) -> Task> { + ) -> Task> { let markdown_language_task = workspace .read(cx) .app_state() @@ -4150,13 +4305,14 @@ impl AcpThreadView { ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); if is_generating { - return h_flex().id("thread-controls-container").ml_1().child( + return h_flex().id("thread-controls-container").child( div() .py_2() .px(rems_from_px(22.)) .child(SpinnerLabel::new().size(LabelSize::Small)), ); } + let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) @@ -4182,12 +4338,10 @@ impl AcpThreadView { .id("thread-controls-container") .group("thread-controls-container") .w_full() - .mr_1() - .pt_1() - .pb_2() - .px(RESPONSE_PADDING_X) + .py_2() + .px_5() .gap_px() - .opacity(0.4) + .opacity(0.6) .hover(|style| style.opacity(1.)) .flex_wrap() .justify_end(); @@ -4198,56 +4352,50 @@ impl AcpThreadView { .is_some_and(|thread| thread.read(cx).connection().telemetry().is_some()) { let feedback = self.thread_feedback.feedback; - container = container.child( - div().visible_on_hover("thread-controls-container").child( - Label::new( - match feedback { + + container = container + .child( + div().visible_on_hover("thread-controls-container").child( + Label::new(match feedback { Some(ThreadFeedback::Positive) => "Thanks for your feedback!", - Some(ThreadFeedback::Negative) => "We appreciate your feedback and will use it to improve.", - None => "Rating the thread sends all of your current conversation to the Zed team.", - } - ) - .color(Color::Muted) - .size(LabelSize::XSmall) - .truncate(), - ), - ).child( - h_flex() - .child( - IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Positive) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(Tooltip::text("Helpful Response")) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click( - ThreadFeedback::Positive, - window, - cx, - ); - })), - ) - .child( - IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Negative) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(Tooltip::text("Not Helpful")) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click( - ThreadFeedback::Negative, - window, - cx, - ); - })), - ) - ) + Some(ThreadFeedback::Negative) => { + "We appreciate your feedback and will use it to improve." + } + None => { + "Rating the thread sends all of your current conversation to the Zed team." + } + }) + .color(Color::Muted) + .size(LabelSize::XSmall) + .truncate(), + ), + ) + .child( + IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Positive) => Color::Accent, + _ => Color::Ignored, + }) + .tooltip(Tooltip::text("Helpful Response")) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Positive, window, cx); + })), + ) + .child( + IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Negative) => Color::Accent, + _ => Color::Ignored, + }) + .tooltip(Tooltip::text("Not Helpful")) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Negative, window, cx); + })), + ); } container.child(open_as_markdown).child(scroll_to_top) @@ -4792,18 +4940,6 @@ impl AcpThreadView { })) } - fn reset(&mut self, window: &mut Window, cx: &mut Context) { - self.thread_state = Self::initial_state( - self.agent.clone(), - None, - self.workspace.clone(), - self.project.clone(), - window, - cx, - ); - cx.notify(); - } - pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context) { let task = match entry { HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| { @@ -4879,7 +5015,7 @@ impl Render for AcpThreadView { .size_full() .items_center() .justify_end() - .child(self.render_load_error(e, cx)), + .child(self.render_load_error(e, window, cx)), ThreadState::Ready { .. } => v_flex().flex_1().map(|this| { if has_messages { this.child( @@ -5332,18 +5468,10 @@ pub(crate) mod tests { "Test".into() } - fn empty_state_headline(&self) -> SharedString { - "Test".into() - } - - fn empty_state_message(&self) -> SharedString { - "Test".into() - } - fn connect( &self, _root_dir: &Path, - _project: &Entity, + _delegate: AgentServerDelegate, _cx: &mut App, ) -> Task>> { Task::ready(Ok(Rc::new(self.connection.clone()))) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 52fb7eed4b96e2ff92097f415276ffeceb4fa11d..23b6e69a56886ca2e5d7c4bdbd27ee8fb1307629 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -3,20 +3,23 @@ mod configure_context_server_modal; mod manage_profiles_modal; mod tool_picker; -use std::{sync::Arc, time::Duration}; +use std::{ops::Range, sync::Arc, time::Duration}; -use agent_servers::{AgentServerCommand, AllAgentServersSettings, Gemini}; +use agent_servers::{AgentServerCommand, AllAgentServersSettings, CustomAgentServerSettings}; use agent_settings::AgentSettings; +use anyhow::Result; use assistant_tool::{ToolSource, ToolWorkingSet}; use cloud_llm_client::Plan; use collections::HashMap; use context_server::ContextServerId; +use editor::{Editor, SelectionEffects, scroll::Autoscroll}; use extension::ExtensionManifest; use extension_host::ExtensionStore; use fs::Fs; use gpui::{ - Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle, - Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage, + Action, Animation, AnimationExt as _, AnyView, App, AsyncWindowContext, Corner, Entity, + EventEmitter, FocusHandle, Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation, + WeakEntity, percentage, }; use language::LanguageRegistry; use language_model::{ @@ -24,7 +27,6 @@ use language_model::{ }; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ - Project, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, project_settings::{ContextServerSettings, ProjectSettings}, }; @@ -34,7 +36,7 @@ use ui::{ Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*, }; use util::ResultExt as _; -use workspace::Workspace; +use workspace::{Workspace, create_and_open_local_file}; use zed_actions::ExtensionCategoryFilter; pub(crate) use configure_context_server_modal::ConfigureContextServerModal; @@ -49,7 +51,6 @@ pub struct AgentConfiguration { fs: Arc, language_registry: Arc, workspace: WeakEntity, - project: WeakEntity, focus_handle: FocusHandle, configuration_views_by_provider: HashMap, context_server_store: Entity, @@ -59,7 +60,6 @@ pub struct AgentConfiguration { _registry_subscription: Subscription, scroll_handle: ScrollHandle, scrollbar_state: ScrollbarState, - gemini_is_installed: bool, _check_for_gemini: Task<()>, } @@ -70,7 +70,6 @@ impl AgentConfiguration { tools: Entity, language_registry: Arc, workspace: WeakEntity, - project: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -95,11 +94,6 @@ impl AgentConfiguration { cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()) .detach(); - cx.observe_global_in::(window, |this, _, cx| { - this.check_for_gemini(cx); - cx.notify(); - }) - .detach(); let scroll_handle = ScrollHandle::new(); let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); @@ -108,7 +102,6 @@ impl AgentConfiguration { fs, language_registry, workspace, - project, focus_handle, configuration_views_by_provider: HashMap::default(), context_server_store, @@ -118,11 +111,9 @@ impl AgentConfiguration { _registry_subscription: registry_subscription, scroll_handle, scrollbar_state, - gemini_is_installed: false, _check_for_gemini: Task::ready(()), }; this.build_provider_configuration_views(window, cx); - this.check_for_gemini(cx); this } @@ -152,34 +143,6 @@ impl AgentConfiguration { self.configuration_views_by_provider .insert(provider.id(), configuration_view); } - - fn check_for_gemini(&mut self, cx: &mut Context) { - let project = self.project.clone(); - let settings = AllAgentServersSettings::get_global(cx).clone(); - self._check_for_gemini = cx.spawn({ - async move |this, cx| { - let Some(project) = project.upgrade() else { - return; - }; - let gemini_is_installed = AgentServerCommand::resolve( - Gemini::binary_name(), - &[], - // TODO expose fallback path from the Gemini/CC types so we don't have to hardcode it again here - None, - settings.gemini, - &project, - cx, - ) - .await - .is_some(); - this.update(cx, |this, cx| { - this.gemini_is_installed = gemini_is_installed; - cx.notify(); - }) - .ok(); - } - }); - } } impl Focusable for AgentConfiguration { @@ -1038,9 +1001,8 @@ impl AgentConfiguration { name.clone(), ExternalAgent::Custom { name: name.clone(), - settings: settings.clone(), + command: settings.command.clone(), }, - None, cx, ) .into_any_element() @@ -1058,10 +1020,39 @@ impl AgentConfiguration { .child( v_flex() .gap_0p5() - .child(Headline::new("External Agents")) + .child( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child(Headline::new("External Agents")) + .child( + Button::new("add-agent", "Add Agent") + .icon_position(IconPosition::Start) + .icon(IconName::Plus) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .label_size(LabelSize::Small) + .on_click( + move |_, window, cx| { + if let Some(workspace) = window.root().flatten() { + let workspace = workspace.downgrade(); + window + .spawn(cx, async |cx| { + open_new_agent_servers_entry_in_settings_editor( + workspace, + cx, + ).await + }) + .detach_and_log_err(cx); + } + } + ), + ) + ) .child( Label::new( - "Use the full power of Zed's UI with your favorite agent, connected via the Agent Client Protocol.", + "Bring the agent of your choice to Zed via our new Agent Client Protocol.", ) .color(Color::Muted), ), @@ -1070,7 +1061,6 @@ impl AgentConfiguration { IconName::AiGemini, "Gemini CLI", ExternalAgent::Gemini, - (!self.gemini_is_installed).then_some(Gemini::install_command().into()), cx, )) // TODO add CC @@ -1083,7 +1073,6 @@ impl AgentConfiguration { icon: IconName, name: impl Into, agent: ExternalAgent, - install_command: Option, cx: &mut Context, ) -> impl IntoElement { let name = name.into(); @@ -1103,88 +1092,28 @@ impl AgentConfiguration { .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) .child(Label::new(name.clone())), ) - .map(|this| { - if let Some(install_command) = install_command { - this.child( - Button::new( - SharedString::from(format!("install_external_agent-{name}")), - "Install Agent", - ) - .label_size(LabelSize::Small) - .icon(IconName::Plus) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(Tooltip::text(install_command.clone())) - .on_click(cx.listener( - move |this, _, window, cx| { - let Some(project) = this.project.upgrade() else { - return; - }; - let Some(workspace) = this.workspace.upgrade() else { - return; - }; - let cwd = project.read(cx).first_project_directory(cx); - let shell = - project.read(cx).terminal_settings(&cwd, cx).shell.clone(); - let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId(install_command.to_string()), - full_label: install_command.to_string(), - label: install_command.to_string(), - command: Some(install_command.to_string()), - args: Vec::new(), - command_label: install_command.to_string(), - cwd, - env: Default::default(), - use_new_terminal: true, - allow_concurrent_runs: true, - reveal: Default::default(), - reveal_target: Default::default(), - hide: Default::default(), - shell, - show_summary: true, - show_command: true, - show_rerun: false, - }; - let task = workspace.update(cx, |workspace, cx| { - workspace.spawn_in_terminal(spawn_in_terminal, window, cx) - }); - cx.spawn(async move |this, cx| { - task.await; - this.update(cx, |this, cx| { - this.check_for_gemini(cx); - }) - .ok(); - }) - .detach(); - }, - )), - ) - } else { - this.child( - h_flex().gap_1().child( - Button::new( - SharedString::from(format!("start_acp_thread-{name}")), - "Start New Thread", - ) - .label_size(LabelSize::Small) - .icon(IconName::Thread) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .on_click(move |_, window, cx| { - window.dispatch_action( - NewExternalAgentThread { - agent: Some(agent.clone()), - } - .boxed_clone(), - cx, - ); - }), - ), + .child( + h_flex().gap_1().child( + Button::new( + SharedString::from(format!("start_acp_thread-{name}")), + "Start New Thread", ) - } - }) + .label_size(LabelSize::Small) + .icon(IconName::Thread) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .on_click(move |_, window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some(agent.clone()), + } + .boxed_clone(), + cx, + ); + }), + ), + ) } } @@ -1324,3 +1253,109 @@ fn show_unable_to_uninstall_extension_with_context_server( workspace.toggle_status_toast(status_toast, cx); } + +async fn open_new_agent_servers_entry_in_settings_editor( + workspace: WeakEntity, + cx: &mut AsyncWindowContext, +) -> Result<()> { + let settings_editor = workspace + .update_in(cx, |_, window, cx| { + create_and_open_local_file(paths::settings_file(), window, cx, || { + settings::initial_user_settings_content().as_ref().into() + }) + })? + .await? + .downcast::() + .unwrap(); + + settings_editor + .downgrade() + .update_in(cx, |item, window, cx| { + let text = item.buffer().read(cx).snapshot(cx).text(); + + let settings = cx.global::(); + + let mut unique_server_name = None; + let edits = settings.edits_for_update::(&text, |file| { + let server_name: Option = (0..u8::MAX) + .map(|i| { + if i == 0 { + "your_agent".into() + } else { + format!("your_agent_{}", i).into() + } + }) + .find(|name| !file.custom.contains_key(name)); + if let Some(server_name) = server_name { + unique_server_name = Some(server_name.clone()); + file.custom.insert( + server_name, + CustomAgentServerSettings { + command: AgentServerCommand { + path: "path_to_executable".into(), + args: vec![], + env: Some(HashMap::default()), + }, + }, + ); + } + }); + + if edits.is_empty() { + return; + } + + let ranges = edits + .iter() + .map(|(range, _)| range.clone()) + .collect::>(); + + item.edit(edits, cx); + if let Some((unique_server_name, buffer)) = + unique_server_name.zip(item.buffer().read(cx).as_singleton()) + { + let snapshot = buffer.read(cx).snapshot(); + if let Some(range) = + find_text_in_buffer(&unique_server_name, ranges[0].start, &snapshot) + { + item.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_ranges(vec![range]); + }, + ); + } + } + }) +} + +fn find_text_in_buffer( + text: &str, + start: usize, + snapshot: &language::BufferSnapshot, +) -> Option> { + let chars = text.chars().collect::>(); + + let mut offset = start; + let mut char_offset = 0; + for c in snapshot.chars_at(start) { + if char_offset >= chars.len() { + break; + } + offset += 1; + + if c == chars[char_offset] { + char_offset += 1; + } else { + char_offset = 0; + } + } + + if char_offset == chars.len() { + Some(offset.saturating_sub(chars.len())..offset) + } else { + None + } +} diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 269aec33659a5c39c87137b79f4c32d9da930d4a..232311c5b02cdaa9edad4c0e9053163f450378e8 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::time::Duration; use acp_thread::AcpThread; -use agent_servers::AgentServerSettings; +use agent_servers::AgentServerCommand; use agent2::{DbThreadMetadata, HistoryEntry}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; @@ -14,6 +14,7 @@ use zed_actions::agent::ReauthenticateAgent; use crate::acp::{AcpThreadHistory, ThreadHistoryEvent}; use crate::agent_diff::AgentDiffThread; +use crate::ui::AcpOnboardingModal; use crate::{ AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, @@ -28,7 +29,6 @@ use crate::{ slash_command::SlashCommandCompletionProvider, text_thread_editor::{ AgentPanelDelegate, TextThreadEditor, humanize_token_count, make_lsp_adapter_delegate, - render_remaining_tokens, }, thread_history::{HistoryEntryElement, ThreadHistory}, ui::{AgentOnboardingModal, EndTrialUpsell}, @@ -77,7 +77,10 @@ use workspace::{ }; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, - agent::{OpenOnboardingModal, OpenSettings, ResetOnboarding, ToggleModelSelector}, + agent::{ + OpenAcpOnboardingModal, OpenOnboardingModal, OpenSettings, ResetOnboarding, + ToggleModelSelector, + }, assistant::{OpenRulesLibrary, ToggleFocus}, }; @@ -201,6 +204,9 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenOnboardingModal, window, cx| { AgentOnboardingModal::toggle(workspace, window, cx) }) + .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| { + AcpOnboardingModal::toggle(workspace, window, cx) + }) .register_action(|_workspace, _: &ResetOnboarding, window, cx| { window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx); window.refresh(); @@ -253,7 +259,7 @@ pub enum AgentType { NativeAgent, Custom { name: SharedString, - settings: AgentServerSettings, + command: AgentServerCommand, }, } @@ -591,17 +597,6 @@ impl AgentPanel { None }; - // Wait for the Gemini/Native feature flag to be available. - let client = workspace.read_with(cx, |workspace, _| workspace.client().clone())?; - if !client.status().borrow().is_signed_out() { - cx.update(|_, cx| { - cx.wait_for_flag_or_timeout::( - Duration::from_secs(2), - ) - })? - .await; - } - let panel = workspace.update_in(cx, |workspace, window, cx| { let panel = cx.new(|cx| { Self::new( @@ -622,6 +617,10 @@ impl AgentPanel { } cx.notify(); }); + } else { + panel.update(cx, |panel, cx| { + panel.new_agent_thread(AgentType::NativeAgent, window, cx); + }); } panel })?; @@ -1480,7 +1479,6 @@ impl AgentPanel { tools, self.language_registry.clone(), self.workspace.clone(), - self.project.downgrade(), window, cx, ) @@ -1852,19 +1850,6 @@ impl AgentPanel { menu } - pub fn set_selected_agent( - &mut self, - agent: AgentType, - window: &mut Window, - cx: &mut Context, - ) { - if self.selected_agent != agent { - self.selected_agent = agent.clone(); - self.serialize(cx); - } - self.new_agent_thread(agent, window, cx); - } - pub fn selected_agent(&self) -> AgentType { self.selected_agent.clone() } @@ -1875,6 +1860,11 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + if self.selected_agent != agent { + self.selected_agent = agent.clone(); + self.serialize(cx); + } + match agent { AgentType::Zed => { window.dispatch_action( @@ -1905,8 +1895,8 @@ impl AgentPanel { window, cx, ), - AgentType::Custom { name, settings } => self.external_thread( - Some(crate::ExternalAgent::Custom { name, settings }), + AgentType::Custom { name, command } => self.external_thread( + Some(crate::ExternalAgent::Custom { name, command }), None, None, window, @@ -2124,7 +2114,7 @@ impl AgentPanel { .child(title_editor) .into_any_element() } else { - Label::new(thread_view.read(cx).title()) + Label::new(thread_view.read(cx).title(cx)) .color(Color::Muted) .truncate() .into_any_element() @@ -2555,7 +2545,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::NativeAgent, window, cx, @@ -2581,7 +2571,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::TextThread, window, cx, @@ -2609,7 +2599,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::Gemini, window, cx, @@ -2636,7 +2626,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::ClaudeCode, window, cx, @@ -2669,13 +2659,13 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::Custom { name: agent_name .clone(), - settings: - agent_settings - .clone(), + command: agent_settings + .command + .clone(), }, window, cx, @@ -2693,9 +2683,9 @@ impl AgentPanel { }) .when(cx.has_flag::(), |menu| { menu.separator().link( - "Add Your Own Agent", + "Add Other Agents", OpenBrowser { - url: "https://agentclientprotocol.com/".into(), + url: zed_urls::external_agents_docs(cx), } .boxed_clone(), ) @@ -2883,12 +2873,8 @@ impl AgentPanel { Some(token_count) } - ActiveView::TextThread { context_editor, .. } => { - let element = render_remaining_tokens(context_editor, cx)?; - - Some(element.into_any_element()) - } ActiveView::ExternalAgentThread { .. } + | ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 110c432df3932f902b6ffcdac505a86e88550b28..93a4a8f748eefc933f809669af841f443888f7ed 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -28,7 +28,7 @@ use std::rc::Rc; use std::sync::Arc; use agent::{Thread, ThreadId}; -use agent_servers::AgentServerSettings; +use agent_servers::AgentServerCommand; use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection}; use assistant_slash_command::SlashCommandRegistry; use client::Client; @@ -170,7 +170,7 @@ enum ExternalAgent { NativeAgent, Custom { name: SharedString, - settings: AgentServerSettings, + command: AgentServerCommand, }, } @@ -193,9 +193,9 @@ impl ExternalAgent { Self::Gemini => Rc::new(agent_servers::Gemini), Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode), Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)), - Self::Custom { name, settings } => Rc::new(agent_servers::CustomAgentServer::new( + Self::Custom { name, command } => Rc::new(agent_servers::CustomAgentServer::new( name.clone(), - settings, + command.clone(), )), } } diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index a626122769f656cf6627d104d00f4fa3a368e7db..3abefac8e8964ffdddc1397132541d0056f33ea8 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -334,7 +334,7 @@ impl PromptEditor { EditorEvent::Edited { .. } => { if let Some(workspace) = window.root::().flatten() { workspace.update(cx, |workspace, cx| { - let is_via_ssh = workspace.project().read(cx).is_via_ssh(); + let is_via_ssh = workspace.project().read(cx).is_via_remote_server(); workspace .client() diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index aceca79dbf95cd64bbf68b89907d0903f4aba9ff..3633e533da97b2b80e5c8d62c271da7121d3582b 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -6,7 +6,8 @@ use feature_flags::ZedProFeatureFlag; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task}; use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelProviderId, LanguageModelRegistry, + AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId, + LanguageModelRegistry, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; @@ -76,6 +77,7 @@ pub struct LanguageModelPickerDelegate { all_models: Arc, filtered_entries: Vec, selected_index: usize, + _authenticate_all_providers_task: Task<()>, _subscriptions: Vec, } @@ -96,6 +98,7 @@ impl LanguageModelPickerDelegate { selected_index: Self::get_active_model_index(&entries, get_active_model(cx)), filtered_entries: entries, get_active_model: Arc::new(get_active_model), + _authenticate_all_providers_task: Self::authenticate_all_providers(cx), _subscriptions: vec![cx.subscribe_in( &LanguageModelRegistry::global(cx), window, @@ -139,6 +142,56 @@ impl LanguageModelPickerDelegate { .unwrap_or(0) } + /// Authenticates all providers in the [`LanguageModelRegistry`]. + /// + /// We do this so that we can populate the language selector with all of the + /// models from the configured providers. + fn authenticate_all_providers(cx: &mut App) -> Task<()> { + let authenticate_all_providers = LanguageModelRegistry::global(cx) + .read(cx) + .providers() + .iter() + .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) + .collect::>(); + + cx.spawn(async move |_cx| { + for (provider_id, provider_name, authenticate_task) in authenticate_all_providers { + if let Err(err) = authenticate_task.await { + if matches!(err, AuthenticateError::CredentialsNotFound) { + // Since we're authenticating these providers in the + // background for the purposes of populating the + // language selector, we don't care about providers + // where the credentials are not found. + } else { + // Some providers have noisy failure states that we + // don't want to spam the logs with every time the + // language model selector is initialized. + // + // Ideally these should have more clear failure modes + // that we know are safe to ignore here, like what we do + // with `CredentialsNotFound` above. + match provider_id.0.as_ref() { + "lmstudio" | "ollama" => { + // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". + // + // These fail noisily, so we don't log them. + } + "copilot_chat" => { + // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. + } + _ => { + log::error!( + "Failed to authenticate provider: {}: {err}", + provider_name.0 + ); + } + } + } + } + } + }) + } + pub fn active_model(&self, cx: &App) -> Option { (self.get_active_model)(cx) } diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index e9e7eba4b668fd09eb98a45b43bea6eb72b15277..70ec94beeadb1ae84839bab6747715223f2540c9 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -1857,6 +1857,53 @@ impl TextThreadEditor { .update(cx, |context, cx| context.summarize(true, cx)); } + fn render_remaining_tokens(&self, cx: &App) -> Option> { + let (token_count_color, token_count, max_token_count, tooltip) = + match token_state(&self.context, cx)? { + TokenState::NoTokensLeft { + max_token_count, + token_count, + } => ( + Color::Error, + token_count, + max_token_count, + Some("Token Limit Reached"), + ), + TokenState::HasMoreTokens { + max_token_count, + token_count, + over_warn_threshold, + } => { + let (color, tooltip) = if over_warn_threshold { + (Color::Warning, Some("Token Limit is Close to Exhaustion")) + } else { + (Color::Muted, None) + }; + (color, token_count, max_token_count, tooltip) + } + }; + + Some( + h_flex() + .id("token-count") + .gap_0p5() + .child( + Label::new(humanize_token_count(token_count)) + .size(LabelSize::Small) + .color(token_count_color), + ) + .child(Label::new("/").size(LabelSize::Small).color(Color::Muted)) + .child( + Label::new(humanize_token_count(max_token_count)) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .when_some(tooltip, |element, tooltip| { + element.tooltip(Tooltip::text(tooltip)) + }), + ) + } + fn render_send_button(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); @@ -2420,9 +2467,14 @@ impl Render for TextThreadEditor { ) .child( h_flex() - .gap_1() - .child(self.render_language_model_selector(window, cx)) - .child(self.render_send_button(window, cx)), + .gap_2p5() + .children(self.render_remaining_tokens(cx)) + .child( + h_flex() + .gap_1() + .child(self.render_language_model_selector(window, cx)) + .child(self.render_send_button(window, cx)), + ), ), ) } @@ -2710,58 +2762,6 @@ impl FollowableItem for TextThreadEditor { } } -pub fn render_remaining_tokens( - context_editor: &Entity, - cx: &App, -) -> Option> { - let context = &context_editor.read(cx).context; - - let (token_count_color, token_count, max_token_count, tooltip) = match token_state(context, cx)? - { - TokenState::NoTokensLeft { - max_token_count, - token_count, - } => ( - Color::Error, - token_count, - max_token_count, - Some("Token Limit Reached"), - ), - TokenState::HasMoreTokens { - max_token_count, - token_count, - over_warn_threshold, - } => { - let (color, tooltip) = if over_warn_threshold { - (Color::Warning, Some("Token Limit is Close to Exhaustion")) - } else { - (Color::Muted, None) - }; - (color, token_count, max_token_count, tooltip) - } - }; - - Some( - h_flex() - .id("token-count") - .gap_0p5() - .child( - Label::new(humanize_token_count(token_count)) - .size(LabelSize::Small) - .color(token_count_color), - ) - .child(Label::new("/").size(LabelSize::Small).color(Color::Muted)) - .child( - Label::new(humanize_token_count(max_token_count)) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .when_some(tooltip, |element, tooltip| { - element.tooltip(Tooltip::text(tooltip)) - }), - ) -} - enum PendingSlashCommand {} fn invoked_slash_command_fold_placeholder( diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index ada973cddfc847c67b805ee053fb50e6d9cd99d7..600698b07e1e2bf43d78c5c225838476f04a5c76 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -1,3 +1,4 @@ +mod acp_onboarding_modal; mod agent_notification; mod burn_mode_tooltip; mod context_pill; @@ -6,6 +7,7 @@ mod onboarding_modal; pub mod preview; mod unavailable_editing_tooltip; +pub use acp_onboarding_modal::*; pub use agent_notification::*; pub use burn_mode_tooltip::*; pub use context_pill::*; diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs new file mode 100644 index 0000000000000000000000000000000000000000..0ed9de7221014476f21c0406e6be8ac3592fca7c --- /dev/null +++ b/crates/agent_ui/src/ui/acp_onboarding_modal.rs @@ -0,0 +1,254 @@ +use client::zed_urls; +use gpui::{ + ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, + linear_color_stop, linear_gradient, +}; +use ui::{TintColor, Vector, VectorName, prelude::*}; +use workspace::{ModalView, Workspace}; + +use crate::agent_panel::{AgentPanel, AgentType}; + +macro_rules! acp_onboarding_event { + ($name:expr) => { + telemetry::event!($name, source = "ACP Onboarding"); + }; + ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => { + telemetry::event!($name, source = "ACP Onboarding", $($key $(= $value)?),+); + }; +} + +pub struct AcpOnboardingModal { + focus_handle: FocusHandle, + workspace: Entity, +} + +impl AcpOnboardingModal { + pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + let workspace_entity = cx.entity(); + workspace.toggle_modal(window, cx, |_window, cx| Self { + workspace: workspace_entity, + focus_handle: cx.focus_handle(), + }); + } + + fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + self.workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.new_agent_thread(AgentType::Gemini, window, cx); + }); + } + }); + + cx.emit(DismissEvent); + + acp_onboarding_event!("Open Panel Clicked"); + } + + fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { + cx.open_url(&zed_urls::external_agents_docs(cx)); + cx.notify(); + + acp_onboarding_event!("Documentation Link Clicked"); + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } +} + +impl EventEmitter for AcpOnboardingModal {} + +impl Focusable for AcpOnboardingModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for AcpOnboardingModal {} + +impl Render for AcpOnboardingModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let illustration_element = |label: bool, opacity: f32| { + h_flex() + .px_1() + .py_0p5() + .gap_1() + .rounded_sm() + .bg(cx.theme().colors().element_active.opacity(0.05)) + .border_1() + .border_color(cx.theme().colors().border) + .border_dashed() + .child( + Icon::new(IconName::Stop) + .size(IconSize::Small) + .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))), + ) + .map(|this| { + if label { + this.child( + Label::new("Your Agent Here") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else { + this.child( + div().w_16().h_1().rounded_full().bg(cx + .theme() + .colors() + .element_active + .opacity(0.6)), + ) + } + }) + .opacity(opacity) + }; + + let illustration = h_flex() + .relative() + .h(rems_from_px(126.)) + .bg(cx.theme().colors().editor_background) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .justify_center() + .gap_8() + .rounded_t_md() + .overflow_hidden() + .child( + div().absolute().inset_0().w(px(515.)).h(px(126.)).child( + Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.)) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))), + ), + ) + .child(div().absolute().inset_0().size_full().bg(linear_gradient( + 0., + linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.1), + 0.9, + ), + linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.), + 0., + ), + ))) + .child( + div() + .absolute() + .inset_0() + .size_full() + .bg(gpui::black().opacity(0.15)), + ) + .child( + h_flex() + .gap_4() + .child( + Vector::new(VectorName::AcpLogo, rems_from_px(106.), rems_from_px(40.)) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))), + ) + .child( + Vector::new( + VectorName::AcpLogoSerif, + rems_from_px(111.), + rems_from_px(41.), + ) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))), + ), + ) + .child( + v_flex() + .gap_1p5() + .child(illustration_element(false, 0.15)) + .child(illustration_element(true, 0.3)) + .child( + h_flex() + .pl_1() + .pr_2() + .py_0p5() + .gap_1() + .rounded_sm() + .bg(cx.theme().colors().element_active.opacity(0.2)) + .border_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::AiGemini) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(Label::new("New Gemini CLI Thread").size(LabelSize::Small)), + ) + .child(illustration_element(true, 0.3)) + .child(illustration_element(false, 0.15)), + ); + + let heading = v_flex() + .w_full() + .gap_1() + .child( + Label::new("Now Available") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Headline::new("Bring Your Own Agent to Zed").size(HeadlineSize::Large)); + + let copy = "Bring the agent of your choice to Zed via our new Agent Client Protocol (ACP), starting with Google's Gemini CLI integration."; + + let open_panel_button = Button::new("open-panel", "Start with Gemini CLI") + .icon_size(IconSize::Indicator) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .full_width() + .on_click(cx.listener(Self::open_panel)); + + let docs_button = Button::new("add-other-agents", "Add Other Agents") + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::Indicator) + .icon_color(Color::Muted) + .full_width() + .on_click(cx.listener(Self::view_docs)); + + let close_button = h_flex().absolute().top_2().right_2().child( + IconButton::new("cancel", IconName::Close).on_click(cx.listener( + |_, _: &ClickEvent, _window, cx| { + acp_onboarding_event!("Canceled", trigger = "X click"); + cx.emit(DismissEvent); + }, + )), + ); + + v_flex() + .id("acp-onboarding") + .key_context("AcpOnboardingModal") + .relative() + .w(rems(34.)) + .h_full() + .elevation_3(cx) + .track_focus(&self.focus_handle(cx)) + .overflow_hidden() + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| { + acp_onboarding_event!("Canceled", trigger = "Action"); + cx.emit(DismissEvent); + })) + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { + this.focus_handle.focus(window); + })) + .child(illustration) + .child( + v_flex() + .p_4() + .gap_2() + .child(heading) + .child(Label::new(copy).color(Color::Muted)) + .child( + v_flex() + .w_full() + .mt_2() + .gap_1() + .child(open_panel_button) + .child(docs_button), + ), + ) + .child(close_button) + } +} diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs index 68480c047f9cab4cd72f1998422bc727993e1f5e..af2a022f147b79a0a299c17dd26c7e9a8b62aeb9 100644 --- a/crates/agent_ui/src/ui/agent_notification.rs +++ b/crates/agent_ui/src/ui/agent_notification.rs @@ -62,6 +62,8 @@ impl AgentNotification { app_id: Some(app_id.to_owned()), window_min_size: None, window_decorations: Some(WindowDecorations::Client), + tabbing_identifier: None, + ..Default::default() } } } diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 3ff1666755d439cf52a14ea635a06a7c3414d9f6..773bb557de1895e57bdeb5612e01e2839af3244b 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -373,7 +373,7 @@ pub async fn complete( .uri(uri) .header("Anthropic-Version", "2023-06-01") .header("Anthropic-Beta", beta_headers) - .header("X-Api-Key", api_key) + .header("X-Api-Key", api_key.trim()) .header("Content-Type", "application/json"); let serialized_request = @@ -526,7 +526,7 @@ pub async fn stream_completion_with_rate_limit_info( .uri(uri) .header("Anthropic-Version", "2023-06-01") .header("Anthropic-Beta", beta_headers) - .header("X-Api-Key", api_key) + .header("X-Api-Key", api_key.trim()) .header("Content-Type", "application/json"); let serialized_request = serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; diff --git a/crates/assistant_slash_commands/src/file_command.rs b/crates/assistant_slash_commands/src/file_command.rs index a973d653e4527be808618f76d60af59e4a891947..261e15bc0ae8b9e886d4d146696db78e5c0c831d 100644 --- a/crates/assistant_slash_commands/src/file_command.rs +++ b/crates/assistant_slash_commands/src/file_command.rs @@ -492,7 +492,7 @@ mod custom_path_matcher { pub fn new(globs: &[String]) -> Result { let globs = globs .iter() - .map(|glob| Glob::new(&SanitizedPath::from(glob).to_glob_string())) + .map(|glob| Glob::new(&SanitizedPath::new(glob).to_glob_string())) .collect::, _>>()?; let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect(); let sources_with_trailing_slash = globs diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index b28e55e78aef40554a8ebe60108bd81da3f9d95a..774f32426540e077e5bde72081db789329f86262 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -15,7 +15,7 @@ use language::LineEnding; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; -use project::{Project, terminals::TerminalKind}; +use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; @@ -213,17 +213,16 @@ impl Tool for TerminalTool { async move |cx| { let program = program.await; let env = env.await; - project .update(cx, |project, cx| { - project.create_terminal( - TerminalKind::Task(task::SpawnInTerminal { + project.create_terminal_task( + task::SpawnInTerminal { command: Some(program), args, cwd, env, ..Default::default() - }), + }, cx, ) })? diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index ffe4c6c25191dc8f9087ccfcc77252b8e5a25a13..c31a458c64124c266c56a7004746d7b6a0a4adc6 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -1161,7 +1161,7 @@ impl Room { let request = self.client.request(proto::ShareProject { room_id: self.id(), worktrees: project.read(cx).worktree_metadata_protos(cx), - is_ssh_project: project.read(cx).is_via_ssh(), + is_ssh_project: project.read(cx).is_via_remote_server(), }); cx.spawn(async move |this, cx| { diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 2bbe7dd1b5a838c1f4e3bace2d91c396692983f4..bdbf049b75ef1e0de351c65be7382a94d73448e6 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -287,6 +287,7 @@ pub enum Status { }, ConnectionLost, Reauthenticating, + Reauthenticated, Reconnecting, ReconnectionError { next_reconnection: Instant, @@ -298,6 +299,21 @@ impl Status { matches!(self, Self::Connected { .. }) } + pub fn was_connected(&self) -> bool { + matches!( + self, + Self::ConnectionLost + | Self::Reauthenticating + | Self::Reauthenticated + | Self::Reconnecting + ) + } + + /// Returns whether the client is currently connected or was connected at some point. + pub fn is_or_was_connected(&self) -> bool { + self.is_connected() || self.was_connected() + } + pub fn is_signing_in(&self) -> bool { matches!( self, @@ -857,11 +873,13 @@ impl Client { try_provider: bool, cx: &AsyncApp, ) -> Result { - if self.status().borrow().is_signed_out() { + let is_reauthenticating = if self.status().borrow().is_signed_out() { self.set_status(Status::Authenticating, cx); + false } else { self.set_status(Status::Reauthenticating, cx); - } + true + }; let mut credentials = None; @@ -919,7 +937,14 @@ impl Client { self.cloud_client .set_credentials(credentials.user_id as u32, credentials.access_token.clone()); self.state.write().credentials = Some(credentials.clone()); - self.set_status(Status::Authenticated, cx); + self.set_status( + if is_reauthenticating { + Status::Reauthenticated + } else { + Status::Authenticated + }, + cx, + ); Ok(credentials) } @@ -1034,6 +1059,7 @@ impl Client { | Status::Authenticating | Status::AuthenticationError | Status::Reauthenticating + | Status::Reauthenticated | Status::ReconnectionError { .. } => false, Status::Connected { .. } | Status::Connecting | Status::Reconnecting => { return ConnectionResult::Result(Ok(())); diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index d23eb37519c00c2567683e5417aa2d82f10a2f58..a4c66e582c34e468432747d580e13c86b3ec33c8 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -216,7 +216,9 @@ impl UserStore { return Ok(()); }; match status { - Status::Authenticated | Status::Connected { .. } => { + Status::Authenticated + | Status::Reauthenticated + | Status::Connected { .. } => { if let Some(user_id) = client.user_id() { let response = client .cloud_client() diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 9df41906d79b4d43234a28dde19bd6862469de8c..7193c099473c95794796c2fc4d3eaaf2f06eb1ac 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -43,3 +43,11 @@ pub fn ai_privacy_and_security(cx: &App) -> String { server_url = server_url(cx) ) } + +/// Returns the URL to Zed AI's external agents documentation. +pub fn external_agents_docs(cx: &App) -> String { + format!( + "{server_url}/docs/ai/external-agents", + server_url = server_url(cx) + ) +} diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 43581fd9421e5a8d10460a9ed15c565bd66a6e5e..b2e25458ef98b295b4d056a7f59521f4fa896f1a 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -175,6 +175,7 @@ CREATE TABLE "language_servers" ( "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, "name" VARCHAR NOT NULL, "capabilities" TEXT NOT NULL, + "worktree_id" BIGINT, PRIMARY KEY (project_id, id) ); diff --git a/crates/collab/migrations/20250827084812_worktree_in_servers.sql b/crates/collab/migrations/20250827084812_worktree_in_servers.sql new file mode 100644 index 0000000000000000000000000000000000000000..d4c6ffbbcccb2d2f23654cfc287b45bb8ea20508 --- /dev/null +++ b/crates/collab/migrations/20250827084812_worktree_in_servers.sql @@ -0,0 +1,2 @@ +ALTER TABLE language_servers + ADD COLUMN worktree_id BIGINT; diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 393f2c80f8e733aa2d2b3b5f4b811c9868e0a620..a3f0ea6cbc6e762e365f82e74b886234e62da109 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -694,6 +694,7 @@ impl Database { project_id: ActiveValue::set(project_id), id: ActiveValue::set(server.id as i64), name: ActiveValue::set(server.name.clone()), + worktree_id: ActiveValue::set(server.worktree_id.map(|id| id as i64)), capabilities: ActiveValue::set(update.capabilities.clone()), }) .on_conflict( @@ -704,6 +705,7 @@ impl Database { .update_columns([ language_server::Column::Name, language_server::Column::Capabilities, + language_server::Column::WorktreeId, ]) .to_owned(), ) @@ -1065,7 +1067,7 @@ impl Database { server: proto::LanguageServer { id: language_server.id as u64, name: language_server.name, - worktree_id: None, + worktree_id: language_server.worktree_id.map(|id| id as u64), }, capabilities: language_server.capabilities, }) diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 9e7cabf9b29c91d7e486f42d5e6b12020b0f514e..0713ac2cb2810797b319b53583bc8c0e1756fe68 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -809,7 +809,7 @@ impl Database { server: proto::LanguageServer { id: language_server.id as u64, name: language_server.name, - worktree_id: None, + worktree_id: language_server.worktree_id.map(|id| id as u64), }, capabilities: language_server.capabilities, }) diff --git a/crates/collab/src/db/tables/language_server.rs b/crates/collab/src/db/tables/language_server.rs index 34c7514d917b313990521acf8542c31394d009fc..705aae292ba456622e9808f033a348f60c3835a4 100644 --- a/crates/collab/src/db/tables/language_server.rs +++ b/crates/collab/src/db/tables/language_server.rs @@ -10,6 +10,7 @@ pub struct Model { pub id: i64, pub name: String, pub capabilities: String, + pub worktree_id: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 73f327166a3f1fb40a1f232ea2fabcdedd3fb129..9e4dfd4854b4de67de522bfbbd1160fe880a05cb 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -476,7 +476,9 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) - .add_message_handler(update_context); + .add_message_handler(update_context) + .add_request_handler(forward_mutating_project_request::) + .add_message_handler(broadcast_project_message_from_host::); Arc::new(server) } diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index 8ab6e6910c88880bc8b6451d972e39b5c2315812..6b46459a59b16717d965b42c4e19820f6d1dc062 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -26,7 +26,7 @@ use project::{ debugger::session::ThreadId, lsp_store::{FormatTrigger, LspFormatTarget}, }; -use remote::SshRemoteClient; +use remote::RemoteClient; use remote_server::{HeadlessAppState, HeadlessProject}; use rpc::proto; use serde_json::json; @@ -59,7 +59,7 @@ async fn test_sharing_an_ssh_remote_project( .await; // Set up project on remote FS - let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx); + let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); let remote_fs = FakeFs::new(server_cx.executor()); remote_fs .insert_tree( @@ -101,7 +101,7 @@ async fn test_sharing_an_ssh_remote_project( ) }); - let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; + let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let (project_a, worktree_id) = client_a .build_ssh_project(path!("/code/project1"), client_ssh, cx_a) .await; @@ -235,7 +235,7 @@ async fn test_ssh_collaboration_git_branches( .await; // Set up project on remote FS - let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx); + let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); let remote_fs = FakeFs::new(server_cx.executor()); remote_fs .insert_tree("/project", serde_json::json!({ ".git":{} })) @@ -268,7 +268,7 @@ async fn test_ssh_collaboration_git_branches( ) }); - let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; + let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let (project_a, _) = client_a .build_ssh_project("/project", client_ssh, cx_a) .await; @@ -420,7 +420,7 @@ async fn test_ssh_collaboration_formatting_with_prettier( .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; - let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx); + let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); let remote_fs = FakeFs::new(server_cx.executor()); let buffer_text = "let one = \"two\""; let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX; @@ -473,7 +473,7 @@ async fn test_ssh_collaboration_formatting_with_prettier( ) }); - let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; + let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let (project_a, worktree_id) = client_a .build_ssh_project(path!("/project"), client_ssh, cx_a) .await; @@ -602,7 +602,7 @@ async fn test_remote_server_debugger( release_channel::init(SemanticVersion::default(), cx); dap_adapters::init(cx); }); - let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx); + let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); let remote_fs = FakeFs::new(server_cx.executor()); remote_fs .insert_tree( @@ -633,7 +633,7 @@ async fn test_remote_server_debugger( ) }); - let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; + let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let mut server = TestServer::start(server_cx.executor()).await; let client_a = server.create_client(cx_a, "user_a").await; cx_a.update(|cx| { @@ -711,7 +711,7 @@ async fn test_slow_adapter_startup_retries( release_channel::init(SemanticVersion::default(), cx); dap_adapters::init(cx); }); - let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx); + let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); let remote_fs = FakeFs::new(server_cx.executor()); remote_fs .insert_tree( @@ -742,7 +742,7 @@ async fn test_slow_adapter_startup_retries( ) }); - let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; + let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let mut server = TestServer::start(server_cx.executor()).await; let client_a = server.create_client(cx_a, "user_a").await; cx_a.update(|cx| { diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index fd5e3eefc158034e8b15dc3fd7e401b1041fe08e..eb7df28478158a10a0c2d52c3560cad391937383 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -26,7 +26,7 @@ use node_runtime::NodeRuntime; use notifications::NotificationStore; use parking_lot::Mutex; use project::{Project, WorktreeId}; -use remote::SshRemoteClient; +use remote::RemoteClient; use rpc::{ RECEIVE_TIMEOUT, proto::{self, ChannelRole}, @@ -765,11 +765,11 @@ impl TestClient { pub async fn build_ssh_project( &self, root_path: impl AsRef, - ssh: Entity, + ssh: Entity, cx: &mut TestAppContext, ) -> (Entity, WorktreeId) { let project = cx.update(|cx| { - Project::ssh( + Project::remote( ssh, self.client().clone(), self.app_state.node_runtime.clone(), diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index d85a6610a5b2fadde46f27be2602f62c6b8b7d62..90096542942e18ff9a0355d6319e5dcf590a870c 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -3047,7 +3047,7 @@ impl Render for CollabPanel { .on_action(cx.listener(CollabPanel::move_channel_down)) .track_focus(&self.focus_handle) .size_full() - .child(if !self.client.status().borrow().is_connected() { + .child(if !self.client.status().borrow().is_or_was_connected() { self.render_signed_out(cx) } else { self.render_signed_in(window, cx) diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index f9a2fa492562a89f66459510b1c4aa99edf57080..b369d324adb617907d80b773e0982c1723b1bae6 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -66,5 +66,7 @@ fn notification_window_options( app_id: Some(app_id.to_owned()), window_min_size: None, window_decorations: Some(WindowDecorations::Client), + tabbing_identifier: None, + ..Default::default() } } diff --git a/crates/command_palette/src/persistence.rs b/crates/command_palette/src/persistence.rs index 5be97c36bc57cea59b51272270fd39ae1a9ab70d..01cf403083b2de4ed7919801ab33e4aae947007e 100644 --- a/crates/command_palette/src/persistence.rs +++ b/crates/command_palette/src/persistence.rs @@ -1,7 +1,10 @@ use anyhow::Result; use db::{ - define_connection, query, - sqlez::{bindable::Column, statement::Statement}, + query, + sqlez::{ + bindable::Column, domain::Domain, statement::Statement, + thread_safe_connection::ThreadSafeConnection, + }, sqlez_macros::sql, }; use serde::{Deserialize, Serialize}; @@ -50,8 +53,11 @@ impl Column for SerializedCommandInvocation { } } -define_connection!(pub static ref COMMAND_PALETTE_HISTORY: CommandPaletteDB<()> = - &[sql!( +pub struct CommandPaletteDB(ThreadSafeConnection); + +impl Domain for CommandPaletteDB { + const NAME: &str = stringify!(CommandPaletteDB); + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE IF NOT EXISTS command_invocations( id INTEGER PRIMARY KEY AUTOINCREMENT, command_name TEXT NOT NULL, @@ -59,7 +65,9 @@ define_connection!(pub static ref COMMAND_PALETTE_HISTORY: CommandPaletteDB<()> last_invoked INTEGER DEFAULT (unixepoch()) NOT NULL ) STRICT; )]; -); +} + +db::static_connection!(COMMAND_PALETTE_HISTORY, CommandPaletteDB, []); impl CommandPaletteDB { pub async fn write_command_invocation( diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index e8e2251648e4b941fe616b4524337fe565513950..bfddba0e2f8a41e3ed234b21ee52454d104c9dd2 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -62,12 +62,6 @@ impl CopilotChatConfiguration { } } -// Copilot's base model; defined by Microsoft in premium requests table -// This will be moved to the front of the Copilot model list, and will be used for -// 'fast' requests (e.g. title generation) -// https://docs.github.com/en/copilot/managing-copilot/monitoring-usage-and-entitlements/about-premium-requests -const DEFAULT_MODEL_ID: &str = "gpt-4.1"; - #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Role { @@ -101,22 +95,39 @@ where Ok(models) } -#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct Model { + billing: ModelBilling, capabilities: ModelCapabilities, id: String, name: String, policy: Option, vendor: ModelVendor, + is_chat_default: bool, + // The model with this value true is selected by VSCode copilot if a premium request limit is + // reached. Zed does not currently implement this behaviour + is_chat_fallback: bool, model_picker_enabled: bool, } +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +struct ModelBilling { + is_premium: bool, + multiplier: f64, + // List of plans a model is restricted to + // Field is not present if a model is available for all plans + #[serde(default)] + restricted_to: Option>, +} + #[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] struct ModelCapabilities { family: String, #[serde(default)] limits: ModelLimits, supports: ModelSupportedFeatures, + #[serde(rename = "type")] + model_type: String, } #[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -602,6 +613,7 @@ async fn get_models( .into_iter() .filter(|model| { model.model_picker_enabled + && model.capabilities.model_type.as_str() == "chat" && model .policy .as_ref() @@ -610,9 +622,7 @@ async fn get_models( .dedup_by(|a, b| a.capabilities.family == b.capabilities.family) .collect(); - if let Some(default_model_position) = - models.iter().position(|model| model.id == DEFAULT_MODEL_ID) - { + if let Some(default_model_position) = models.iter().position(|model| model.is_chat_default) { let default_model = models.remove(default_model_position); models.insert(0, default_model); } @@ -630,7 +640,9 @@ async fn request_models( .uri(models_url.as_ref()) .header("Authorization", format!("Bearer {}", api_token)) .header("Content-Type", "application/json") - .header("Copilot-Integration-Id", "vscode-chat"); + .header("Copilot-Integration-Id", "vscode-chat") + .header("Editor-Version", "vscode/1.103.2") + .header("x-github-api-version", "2025-05-01"); let request = request_builder.body(AsyncBody::empty())?; @@ -801,6 +813,10 @@ mod tests { let json = r#"{ "data": [ { + "billing": { + "is_premium": false, + "multiplier": 0 + }, "capabilities": { "family": "gpt-4", "limits": { @@ -814,6 +830,8 @@ mod tests { "type": "chat" }, "id": "gpt-4", + "is_chat_default": false, + "is_chat_fallback": false, "model_picker_enabled": false, "name": "GPT 4", "object": "model", @@ -825,6 +843,16 @@ mod tests { "some-unknown-field": 123 }, { + "billing": { + "is_premium": true, + "multiplier": 1, + "restricted_to": [ + "pro", + "pro_plus", + "business", + "enterprise" + ] + }, "capabilities": { "family": "claude-3.7-sonnet", "limits": { @@ -848,6 +876,8 @@ mod tests { "type": "chat" }, "id": "claude-3.7-sonnet", + "is_chat_default": false, + "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude 3.7 Sonnet", "object": "model", diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs index 614cd0e05d1821539c74eb4e78321fb1e0c29445..6781e5cbd62d1abc9abfa58223b0771f26cc0c88 100644 --- a/crates/dap_adapters/src/python.rs +++ b/crates/dap_adapters/src/python.rs @@ -234,6 +234,7 @@ impl PythonDebugAdapter { .await .map_err(|e| format!("{e:#?}"))? .success(); + if !did_succeed { return Err("Failed to create base virtual environment".into()); } diff --git a/crates/db/src/db.rs b/crates/db/src/db.rs index 8b790cbec8498c1c3f83d55b25c14042b04b9424..0802bd8bb7ec738b948d0dbf14c24863833e3ba1 100644 --- a/crates/db/src/db.rs +++ b/crates/db/src/db.rs @@ -110,11 +110,14 @@ pub async fn open_test_db(db_name: &str) -> ThreadSafeConnection { } /// Implements a basic DB wrapper for a given domain +/// +/// Arguments: +/// - static variable name for connection +/// - type of connection wrapper +/// - dependencies, whose migrations should be run prior to this domain's migrations #[macro_export] -macro_rules! define_connection { - (pub static ref $id:ident: $t:ident<()> = $migrations:expr; $($global:ident)?) => { - pub struct $t($crate::sqlez::thread_safe_connection::ThreadSafeConnection); - +macro_rules! static_connection { + ($id:ident, $t:ident, [ $($d:ty),* ] $(, $global:ident)?) => { impl ::std::ops::Deref for $t { type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection; @@ -123,16 +126,6 @@ macro_rules! define_connection { } } - impl $crate::sqlez::domain::Domain for $t { - fn name() -> &'static str { - stringify!($t) - } - - fn migrations() -> &'static [&'static str] { - $migrations - } - } - impl $t { #[cfg(any(test, feature = "test-support"))] pub async fn open_test_db(name: &'static str) -> Self { @@ -142,44 +135,8 @@ macro_rules! define_connection { #[cfg(any(test, feature = "test-support"))] pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| { - $t($crate::smol::block_on($crate::open_test_db::<$t>(stringify!($id)))) - }); - - #[cfg(not(any(test, feature = "test-support")))] - pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| { - let db_dir = $crate::database_dir(); - let scope = if false $(|| stringify!($global) == "global")? { - "global" - } else { - $crate::RELEASE_CHANNEL.dev_name() - }; - $t($crate::smol::block_on($crate::open_db::<$t>(db_dir, scope))) - }); - }; - (pub static ref $id:ident: $t:ident<$($d:ty),+> = $migrations:expr; $($global:ident)?) => { - pub struct $t($crate::sqlez::thread_safe_connection::ThreadSafeConnection); - - impl ::std::ops::Deref for $t { - type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection; - - fn deref(&self) -> &Self::Target { - &self.0 - } - } - - impl $crate::sqlez::domain::Domain for $t { - fn name() -> &'static str { - stringify!($t) - } - - fn migrations() -> &'static [&'static str] { - $migrations - } - } - - #[cfg(any(test, feature = "test-support"))] - pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| { - $t($crate::smol::block_on($crate::open_test_db::<($($d),+, $t)>(stringify!($id)))) + #[allow(unused_parens)] + $t($crate::smol::block_on($crate::open_test_db::<($($d,)* $t)>(stringify!($id)))) }); #[cfg(not(any(test, feature = "test-support")))] @@ -190,9 +147,10 @@ macro_rules! define_connection { } else { $crate::RELEASE_CHANNEL.dev_name() }; - $t($crate::smol::block_on($crate::open_db::<($($d),+, $t)>(db_dir, scope))) + #[allow(unused_parens)] + $t($crate::smol::block_on($crate::open_db::<($($d,)* $t)>(db_dir, scope))) }); - }; + } } pub fn write_and_log(cx: &App, db_write: impl FnOnce() -> F + Send + 'static) @@ -219,17 +177,12 @@ mod tests { enum BadDB {} impl Domain for BadDB { - fn name() -> &'static str { - "db_tests" - } - - fn migrations() -> &'static [&'static str] { - &[ - sql!(CREATE TABLE test(value);), - // failure because test already exists - sql!(CREATE TABLE test(value);), - ] - } + const NAME: &str = "db_tests"; + const MIGRATIONS: &[&str] = &[ + sql!(CREATE TABLE test(value);), + // failure because test already exists + sql!(CREATE TABLE test(value);), + ]; } let tempdir = tempfile::Builder::new() @@ -251,25 +204,15 @@ mod tests { enum CorruptedDB {} impl Domain for CorruptedDB { - fn name() -> &'static str { - "db_tests" - } - - fn migrations() -> &'static [&'static str] { - &[sql!(CREATE TABLE test(value);)] - } + const NAME: &str = "db_tests"; + const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test(value);)]; } enum GoodDB {} impl Domain for GoodDB { - fn name() -> &'static str { - "db_tests" //Notice same name - } - - fn migrations() -> &'static [&'static str] { - &[sql!(CREATE TABLE test2(value);)] //But different migration - } + const NAME: &str = "db_tests"; //Notice same name + const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test2(value);)]; } let tempdir = tempfile::Builder::new() @@ -305,25 +248,16 @@ mod tests { enum CorruptedDB {} impl Domain for CorruptedDB { - fn name() -> &'static str { - "db_tests" - } + const NAME: &str = "db_tests"; - fn migrations() -> &'static [&'static str] { - &[sql!(CREATE TABLE test(value);)] - } + const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test(value);)]; } enum GoodDB {} impl Domain for GoodDB { - fn name() -> &'static str { - "db_tests" //Notice same name - } - - fn migrations() -> &'static [&'static str] { - &[sql!(CREATE TABLE test2(value);)] //But different migration - } + const NAME: &str = "db_tests"; //Notice same name + const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test2(value);)]; // But different migration } let tempdir = tempfile::Builder::new() diff --git a/crates/db/src/kvp.rs b/crates/db/src/kvp.rs index 256b789c9b2f2909ec5b12f6dc9dd60c04555e51..8ea877b35bfaf57bb258e7e179fa5b71f2b518ea 100644 --- a/crates/db/src/kvp.rs +++ b/crates/db/src/kvp.rs @@ -2,16 +2,26 @@ use gpui::App; use sqlez_macros::sql; use util::ResultExt as _; -use crate::{define_connection, query, write_and_log}; +use crate::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + write_and_log, +}; -define_connection!(pub static ref KEY_VALUE_STORE: KeyValueStore<()> = - &[sql!( +pub struct KeyValueStore(crate::sqlez::thread_safe_connection::ThreadSafeConnection); + +impl Domain for KeyValueStore { + const NAME: &str = stringify!(KeyValueStore); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE IF NOT EXISTS kv_store( key TEXT PRIMARY KEY, value TEXT NOT NULL ) STRICT; )]; -); +} + +crate::static_connection!(KEY_VALUE_STORE, KeyValueStore, []); pub trait Dismissable { const KEY: &'static str; @@ -91,15 +101,19 @@ mod tests { } } -define_connection!(pub static ref GLOBAL_KEY_VALUE_STORE: GlobalKeyValueStore<()> = - &[sql!( +pub struct GlobalKeyValueStore(ThreadSafeConnection); + +impl Domain for GlobalKeyValueStore { + const NAME: &str = stringify!(GlobalKeyValueStore); + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE IF NOT EXISTS kv_store( key TEXT PRIMARY KEY, value TEXT NOT NULL ) STRICT; )]; - global -); +} + +crate::static_connection!(GLOBAL_KEY_VALUE_STORE, GlobalKeyValueStore, [], global); impl GlobalKeyValueStore { query! { diff --git a/crates/debugger_ui/src/debugger_ui.rs b/crates/debugger_ui/src/debugger_ui.rs index 581cc16ff4c9a41e24036bcd3ff000ad47d3a076..689e3cd878b574d31963231df9bcff317ea6d64c 100644 --- a/crates/debugger_ui/src/debugger_ui.rs +++ b/crates/debugger_ui/src/debugger_ui.rs @@ -85,6 +85,10 @@ actions!( Rerun, /// Toggles expansion of the selected item in the debugger UI. ToggleExpandItem, + /// Toggle the user frame filter in the stack frame list + /// When toggled on, only frames from the user's code are shown + /// When toggled off, all frames are shown + ToggleUserFrames, ] ); @@ -272,12 +276,25 @@ pub fn init(cx: &mut App) { } }) .on_action({ + let active_item = active_item.clone(); move |_: &ToggleIgnoreBreakpoints, _, cx| { active_item .update(cx, |item, cx| item.toggle_ignore_breakpoints(cx)) .ok(); } }) + .on_action(move |_: &ToggleUserFrames, _, cx| { + if let Some((thread_status, stack_frame_list)) = active_item + .read_with(cx, |item, cx| { + (item.thread_status(cx), item.stack_frame_list().clone()) + }) + .ok() + { + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list.toggle_frame_filter(thread_status, cx); + }) + } + }) }); }) .detach(); diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index b30e3995ffdb994fdb9c936821b360ef7e6eff04..68770bc8b15fbf95824de167dbc8d7fada2b5075 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -1383,14 +1383,28 @@ impl PickerDelegate for DebugDelegate { .border_color(cx.theme().colors().border_variant) .children({ let action = menu::SecondaryConfirm.boxed_clone(); - KeyBinding::for_action(&*action, window, cx).map(|keybind| { - Button::new("edit-debug-task", "Edit in debug.json") - .label_size(LabelSize::Small) - .key_binding(keybind) - .on_click(move |_, window, cx| { - window.dispatch_action(action.boxed_clone(), cx) - }) - }) + if self.matches.is_empty() { + Some( + Button::new("edit-debug-json", "Edit debug.json") + .label_size(LabelSize::Small) + .on_click(cx.listener(|_picker, _, window, cx| { + window.dispatch_action( + zed_actions::OpenProjectDebugTasks.boxed_clone(), + cx, + ); + cx.emit(DismissEvent); + })), + ) + } else { + KeyBinding::for_action(&*action, window, cx).map(|keybind| { + Button::new("edit-debug-task", "Edit in debug.json") + .label_size(LabelSize::Small) + .key_binding(keybind) + .on_click(move |_, window, cx| { + window.dispatch_action(action.boxed_clone(), cx) + }) + }) + } }) .map(|this| { if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty() { diff --git a/crates/debugger_ui/src/persistence.rs b/crates/debugger_ui/src/persistence.rs index cff2ba83355208d702cf7936c46ea5b167c7c649..ab68fea1154182fe266bb150d762f8be0995d733 100644 --- a/crates/debugger_ui/src/persistence.rs +++ b/crates/debugger_ui/src/persistence.rs @@ -270,12 +270,9 @@ pub(crate) fn deserialize_pane_layout( .children .iter() .map(|child| match child { - DebuggerPaneItem::Frames => Box::new(SubView::new( - stack_frame_list.focus_handle(cx), - stack_frame_list.clone().into(), - DebuggerPaneItem::Frames, - cx, - )), + DebuggerPaneItem::Frames => { + Box::new(SubView::stack_frame_list(stack_frame_list.clone(), cx)) + } DebuggerPaneItem::Variables => Box::new(SubView::new( variable_list.focus_handle(cx), variable_list.clone().into(), diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index 9991395f351dd3cd6d6a7f6d95ded11024ba6a4e..46e5f35aecb0ad13e55ceb8d2dd12e7ae791a2c5 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -36,7 +36,6 @@ use module_list::ModuleList; use project::{ DebugScenarioContext, Project, WorktreeId, debugger::session::{self, Session, SessionEvent, SessionStateEvent, ThreadId, ThreadStatus}, - terminals::TerminalKind, }; use rpc::proto::ViewId; use serde_json::Value; @@ -158,6 +157,29 @@ impl SubView { }) } + pub(crate) fn stack_frame_list( + stack_frame_list: Entity, + cx: &mut App, + ) -> Entity { + let weak_list = stack_frame_list.downgrade(); + let this = Self::new( + stack_frame_list.focus_handle(cx), + stack_frame_list.into(), + DebuggerPaneItem::Frames, + cx, + ); + + this.update(cx, |this, _| { + this.with_actions(Box::new(move |_, cx| { + weak_list + .update(cx, |this, _| this.render_control_strip()) + .unwrap_or_else(|_| div().into_any_element()) + })); + }); + + this + } + pub(crate) fn console(console: Entity, cx: &mut App) -> Entity { let weak_console = console.downgrade(); let this = Self::new( @@ -916,10 +938,11 @@ impl RunningState { let task_store = project.read(cx).task_store().downgrade(); let weak_project = project.downgrade(); let weak_workspace = workspace.downgrade(); - let ssh_info = project + let remote_shell = project .read(cx) - .ssh_client() - .and_then(|it| it.read(cx).ssh_info()); + .remote_client() + .as_ref() + .and_then(|remote| remote.read(cx).shell()); cx.spawn_in(window, async move |this, cx| { let DebugScenario { @@ -1003,7 +1026,7 @@ impl RunningState { None }; - let builder = ShellBuilder::new(ssh_info.as_ref().map(|info| &*info.shell), &task.resolved.shell); + let builder = ShellBuilder::new(remote_shell.as_deref(), &task.resolved.shell); let command_label = builder.command_label(&task.resolved.command_label); let (command, args) = builder.build(task.resolved.command.clone(), &task.resolved.args); @@ -1016,12 +1039,11 @@ impl RunningState { }; let terminal = project .update(cx, |project, cx| { - project.create_terminal( - TerminalKind::Task(task_with_shell.clone()), + project.create_terminal_task( + task_with_shell.clone(), cx, ) - })? - .await?; + })?.await?; let terminal_view = cx.new_window_entity(|window, cx| { TerminalView::new( @@ -1165,7 +1187,7 @@ impl RunningState { .filter(|title| !title.is_empty()) .or_else(|| command.clone()) .unwrap_or_else(|| "Debug terminal".to_string()); - let kind = TerminalKind::Task(task::SpawnInTerminal { + let kind = task::SpawnInTerminal { id: task::TaskId("debug".to_string()), full_label: title.clone(), label: title.clone(), @@ -1183,12 +1205,13 @@ impl RunningState { show_summary: false, show_command: false, show_rerun: false, - }); + }; let workspace = self.workspace.clone(); let weak_project = project.downgrade(); - let terminal_task = project.update(cx, |project, cx| project.create_terminal(kind, cx)); + let terminal_task = + project.update(cx, |project, cx| project.create_terminal_task(kind, cx)); let terminal_task = cx.spawn_in(window, async move |_, cx| { let terminal = terminal_task.await?; 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 a4ea4ab654929f00b05e9146bfd662aad2f8bd6d..f80173c365a047da39733c94964c473bef579e1c 100644 --- a/crates/debugger_ui/src/session/running/stack_frame_list.rs +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -4,16 +4,17 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow}; use dap::StackFrameId; +use db::kvp::KEY_VALUE_STORE; use gpui::{ - AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState, MouseButton, - Stateful, Subscription, Task, WeakEntity, list, + Action, AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState, + MouseButton, Stateful, Subscription, Task, WeakEntity, list, }; use util::debug_panic; -use crate::StackTraceView; +use crate::{StackTraceView, ToggleUserFrames}; use language::PointUtf16; use project::debugger::breakpoint_store::ActiveStackFrame; -use project::debugger::session::{Session, SessionEvent, StackFrame}; +use project::debugger::session::{Session, SessionEvent, StackFrame, ThreadStatus}; use project::{ProjectItem, ProjectPath}; use ui::{Scrollbar, ScrollbarState, Tooltip, prelude::*}; use workspace::{ItemHandle, Workspace}; @@ -26,6 +27,34 @@ pub enum StackFrameListEvent { BuiltEntries, } +/// Represents the filter applied to the stack frame list +#[derive(PartialEq, Eq, Copy, Clone)] +enum StackFrameFilter { + /// Show all frames + All, + /// Show only frames from the user's code + OnlyUserFrames, +} + +impl StackFrameFilter { + fn from_str_or_default(s: impl AsRef) -> Self { + match s.as_ref() { + "user" => StackFrameFilter::OnlyUserFrames, + "all" => StackFrameFilter::All, + _ => StackFrameFilter::All, + } + } +} + +impl From for String { + fn from(filter: StackFrameFilter) -> Self { + match filter { + StackFrameFilter::All => "all".to_string(), + StackFrameFilter::OnlyUserFrames => "user".to_string(), + } + } +} + pub struct StackFrameList { focus_handle: FocusHandle, _subscription: Subscription, @@ -37,6 +66,8 @@ pub struct StackFrameList { opened_stack_frame_id: Option, scrollbar_state: ScrollbarState, list_state: ListState, + list_filter: StackFrameFilter, + filter_entries_indices: Vec, error: Option, _refresh_task: Task<()>, } @@ -73,6 +104,16 @@ impl StackFrameList { let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.)); let scrollbar_state = ScrollbarState::new(list_state.clone()); + let list_filter = KEY_VALUE_STORE + .read_kvp(&format!( + "stack-frame-list-filter-{}", + session.read(cx).adapter().0 + )) + .ok() + .flatten() + .map(StackFrameFilter::from_str_or_default) + .unwrap_or(StackFrameFilter::All); + let mut this = Self { session, workspace, @@ -80,9 +121,11 @@ impl StackFrameList { state, _subscription, entries: Default::default(), + filter_entries_indices: Vec::default(), error: None, selected_ix: None, opened_stack_frame_id: None, + list_filter, list_state, scrollbar_state, _refresh_task: Task::ready(()), @@ -103,7 +146,15 @@ impl StackFrameList { ) -> Vec { self.entries .iter() - .flat_map(|frame| match frame { + .enumerate() + .filter(|(ix, _)| { + self.list_filter == StackFrameFilter::All + || self + .filter_entries_indices + .binary_search_by_key(&ix, |ix| ix) + .is_ok() + }) + .flat_map(|(_, frame)| match frame { StackFrameEntry::Normal(frame) => vec![frame.clone()], StackFrameEntry::Label(frame) if show_labels => vec![frame.clone()], StackFrameEntry::Collapsed(frames) if show_collapsed => frames.clone(), @@ -126,7 +177,15 @@ impl StackFrameList { self.stack_frames(cx) .unwrap_or_default() .into_iter() - .map(|stack_frame| stack_frame.dap) + .enumerate() + .filter(|(ix, _)| { + self.list_filter == StackFrameFilter::All + || self + .filter_entries_indices + .binary_search_by_key(&ix, |ix| ix) + .is_ok() + }) + .map(|(_, stack_frame)| stack_frame.dap) .collect() } @@ -192,7 +251,32 @@ impl StackFrameList { return; } }; - for stack_frame in &stack_frames { + + let worktree_prefixes: Vec<_> = self + .workspace + .read_with(cx, |workspace, cx| { + workspace + .visible_worktrees(cx) + .map(|tree| tree.read(cx).abs_path()) + .collect() + }) + .unwrap_or_default(); + + let mut filter_entries_indices = Vec::default(); + for (ix, stack_frame) in stack_frames.iter().enumerate() { + let frame_in_visible_worktree = stack_frame.dap.source.as_ref().is_some_and(|source| { + source.path.as_ref().is_some_and(|path| { + worktree_prefixes + .iter() + .filter_map(|tree| tree.to_str()) + .any(|tree| path.starts_with(tree)) + }) + }); + + if frame_in_visible_worktree { + filter_entries_indices.push(ix); + } + match stack_frame.dap.presentation_hint { Some(dap::StackFramePresentationHint::Deemphasize) | Some(dap::StackFramePresentationHint::Subtle) => { @@ -225,8 +309,10 @@ impl StackFrameList { let collapsed_entries = std::mem::take(&mut collapsed_entries); if !collapsed_entries.is_empty() { entries.push(StackFrameEntry::Collapsed(collapsed_entries)); + self.filter_entries_indices.push(entries.len() - 1); } self.entries = entries; + self.filter_entries_indices = filter_entries_indices; if let Some(ix) = first_stack_frame_with_path .or(first_stack_frame) @@ -242,7 +328,14 @@ impl StackFrameList { self.selected_ix = ix; } - self.list_state.reset(self.entries.len()); + match self.list_filter { + StackFrameFilter::All => { + self.list_state.reset(self.entries.len()); + } + StackFrameFilter::OnlyUserFrames => { + self.list_state.reset(self.filter_entries_indices.len()); + } + } cx.emit(StackFrameListEvent::BuiltEntries); cx.notify(); } @@ -572,6 +665,11 @@ impl StackFrameList { } fn render_entry(&self, ix: usize, cx: &mut Context) -> AnyElement { + let ix = match self.list_filter { + StackFrameFilter::All => ix, + StackFrameFilter::OnlyUserFrames => self.filter_entries_indices[ix], + }; + match &self.entries[ix] { StackFrameEntry::Label(stack_frame) => self.render_label_entry(stack_frame, cx), StackFrameEntry::Normal(stack_frame) => self.render_normal_entry(ix, stack_frame, cx), @@ -702,6 +800,67 @@ impl StackFrameList { self.activate_selected_entry(window, cx); } + pub(crate) fn toggle_frame_filter( + &mut self, + thread_status: Option, + cx: &mut Context, + ) { + self.list_filter = match self.list_filter { + StackFrameFilter::All => StackFrameFilter::OnlyUserFrames, + StackFrameFilter::OnlyUserFrames => StackFrameFilter::All, + }; + + if let Some(database_id) = self + .workspace + .read_with(cx, |workspace, _| workspace.database_id()) + .ok() + .flatten() + { + let database_id: i64 = database_id.into(); + let save_task = KEY_VALUE_STORE.write_kvp( + format!( + "stack-frame-list-filter-{}-{}", + self.session.read(cx).adapter().0, + database_id, + ), + self.list_filter.into(), + ); + cx.background_spawn(save_task).detach(); + } + + if let Some(ThreadStatus::Stopped) = thread_status { + match self.list_filter { + StackFrameFilter::All => { + self.list_state.reset(self.entries.len()); + } + StackFrameFilter::OnlyUserFrames => { + self.list_state.reset(self.filter_entries_indices.len()); + if !self + .selected_ix + .map(|ix| self.filter_entries_indices.contains(&ix)) + .unwrap_or_default() + { + self.selected_ix = None; + } + } + } + + if let Some(ix) = self.selected_ix { + let scroll_to = match self.list_filter { + StackFrameFilter::All => ix, + StackFrameFilter::OnlyUserFrames => self + .filter_entries_indices + .binary_search_by_key(&ix, |ix| *ix) + .expect("This index will always exist"), + }; + self.list_state.scroll_to_reveal_item(scroll_to); + } + + cx.emit(StackFrameListEvent::BuiltEntries); + cx.notify(); + } + } + fn render_list(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div().p_1().size_full().child( list( @@ -711,6 +870,30 @@ impl StackFrameList { .size_full(), ) } + + pub(crate) fn render_control_strip(&self) -> AnyElement { + let tooltip_title = match self.list_filter { + StackFrameFilter::All => "Show stack frames from your project", + StackFrameFilter::OnlyUserFrames => "Show all stack frames", + }; + + h_flex() + .child( + IconButton::new( + "filter-by-visible-worktree-stack-frame-list", + IconName::ListFilter, + ) + .tooltip(move |window, cx| { + Tooltip::for_action(tooltip_title, &ToggleUserFrames, window, cx) + }) + .toggle_state(self.list_filter == StackFrameFilter::OnlyUserFrames) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleUserFrames.boxed_clone(), cx) + }), + ) + .into_any_element() + } } impl Render for StackFrameList { diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index 95a6903c14a1cbd5f750d6e11437cb0bf92887c7..023056224e177bb053f5188ced59c059c9c8ad32 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -752,3 +752,288 @@ async fn test_collapsed_entries(executor: BackgroundExecutor, cx: &mut TestAppCo }); }); } + +#[gpui::test] +async fn test_stack_frame_filter(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + function main() { + doSomething(); + } + + function doSomething() { + console.log('doing something'); + } + "# + .unindent(); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": test_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let session = start_debug_session(&workspace, cx, |_| {}).unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client.on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }); + + client.on_request::(move |_, _| Ok(dap::ScopesResponse { scopes: vec![] })); + + let stack_frames = vec![ + StackFrame { + id: 1, + name: "main".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 2, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 2, + name: "node:internal/modules/cjs/loader".into(), + source: Some(dap::Source { + name: Some("loader.js".into()), + path: Some(path!("/usr/lib/node/internal/modules/cjs/loader.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 100, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize), + }, + StackFrame { + id: 3, + name: "node:internal/modules/run_main".into(), + source: Some(dap::Source { + name: Some("run_main.js".into()), + path: Some(path!("/usr/lib/node/internal/modules/run_main.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 50, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize), + }, + StackFrame { + id: 4, + name: "doSomething".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + ]; + + // Store a copy for assertions + let stack_frames_for_assertions = stack_frames.clone(); + + client.on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + // trigger threads to load + active_debug_session_panel(workspace, cx).update(cx, |session, cx| { + session.running_state().update(cx, |running_state, cx| { + running_state + .session() + .update(cx, |session, cx| session.threads(cx)); + }); + }); + + cx.run_until_parked(); + + // select first thread + active_debug_session_panel(workspace, cx).update_in(cx, |session, window, cx| { + session.running_state().update(cx, |running_state, cx| { + running_state.select_current_thread( + &running_state + .session() + .update(cx, |session, cx| session.threads(cx)), + window, + cx, + ); + }); + }); + + cx.run_until_parked(); + + // trigger stack frames to load + active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| { + let stack_frame_list = debug_panel_item + .running_state() + .update(cx, |state, _| state.stack_frame_list().clone()); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list.dap_stack_frames(cx); + }); + }); + + cx.run_until_parked(); + + active_debug_session_panel(workspace, cx).update_in(cx, |debug_panel_item, window, cx| { + let stack_frame_list = debug_panel_item + .running_state() + .update(cx, |state, _| state.stack_frame_list().clone()); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list.build_entries(true, window, cx); + + // Verify we have the expected collapsed structure + assert_eq!( + stack_frame_list.entries(), + &vec![ + StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()), + StackFrameEntry::Collapsed(vec![ + stack_frames_for_assertions[1].clone(), + stack_frames_for_assertions[2].clone() + ]), + StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()), + ] + ); + + // Test 1: Verify filtering works + let all_frames = stack_frame_list.flatten_entries(true, false); + assert_eq!(all_frames.len(), 4, "Should see all 4 frames initially"); + + // Toggle to user frames only + stack_frame_list + .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx); + + let user_frames = stack_frame_list.dap_stack_frames(cx); + assert_eq!(user_frames.len(), 2, "Should only see 2 user frames"); + assert_eq!(user_frames[0].name, "main"); + assert_eq!(user_frames[1].name, "doSomething"); + + // Test 2: Verify filtering toggles correctly + // Check we can toggle back and see all frames again + + // Toggle back to all frames + stack_frame_list + .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx); + + let all_frames_again = stack_frame_list.flatten_entries(true, false); + assert_eq!( + all_frames_again.len(), + 4, + "Should see all 4 frames after toggling back" + ); + + // Test 3: Verify collapsed entries stay expanded + stack_frame_list.expand_collapsed_entry(1, cx); + assert_eq!( + stack_frame_list.entries(), + &vec![ + StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[1].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[2].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()), + ] + ); + + // Toggle filter twice + stack_frame_list + .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx); + stack_frame_list + .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx); + + // Verify entries remain expanded + assert_eq!( + stack_frame_list.entries(), + &vec![ + StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[1].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[2].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()), + ], + "Expanded entries should remain expanded after toggling filter" + ); + }); + }); +} diff --git a/crates/deepseek/src/deepseek.rs b/crates/deepseek/src/deepseek.rs index c49270febe3b2b3702b808e2219f6e45d7252267..c2554c67e93b4c1d3772e60a62063fdae0511f05 100644 --- a/crates/deepseek/src/deepseek.rs +++ b/crates/deepseek/src/deepseek.rs @@ -268,7 +268,7 @@ pub async fn stream_completion( .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_key)); + .header("Authorization", format!("Bearer {}", api_key.trim())); let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; let mut response = client.send(request).await?; diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index c900eb692aee34b13f13f4fb67061b577b28be1d..c8c3dc54b76085707c0491eab683ff954a483bf9 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -19,6 +19,10 @@ static KEYMAP_LINUX: LazyLock = LazyLock::new(|| { load_keymap("keymaps/default-linux.json").expect("Failed to load Linux keymap") }); +static KEYMAP_WINDOWS: LazyLock = LazyLock::new(|| { + load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap") +}); + static ALL_ACTIONS: LazyLock> = LazyLock::new(dump_all_gpui_actions); const FRONT_MATTER_COMMENT: &str = ""; @@ -216,6 +220,7 @@ fn find_binding(os: &str, action: &str) -> Option { let keymap = match os { "macos" => &KEYMAP_MACOS, "linux" | "freebsd" => &KEYMAP_LINUX, + "windows" => &KEYMAP_WINDOWS, _ => unreachable!("Not a valid OS: {}", os), }; diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index ce02c4d2bf39c6bc5513280a1d81b071a9e6cd6a..3cc6c28464449907abbd19235f9123e44cca78ba 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -753,6 +753,8 @@ actions!( UniqueLinesCaseInsensitive, /// Removes duplicate lines (case-sensitive). UniqueLinesCaseSensitive, - UnwrapSyntaxNode + UnwrapSyntaxNode, + /// Wraps selections in tag specified by language. + WrapSelectionsInTag ] ); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 29e009fdf8d5a8c06d12e36253db59886dd0b9be..ea7cce5d8b741268fe0d4182b66638c0495bb211 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2588,7 +2588,7 @@ impl Editor { || binding .keystrokes() .first() - .is_some_and(|keystroke| keystroke.modifiers.modified()) + .is_some_and(|keystroke| keystroke.modifiers().modified()) })) } @@ -7686,16 +7686,16 @@ impl Editor { .keystroke() { modifiers_held = modifiers_held - || (&accept_keystroke.modifiers == modifiers - && accept_keystroke.modifiers.modified()); + || (accept_keystroke.modifiers() == modifiers + && accept_keystroke.modifiers().modified()); }; if let Some(accept_partial_keystroke) = self .accept_edit_prediction_keybind(true, window, cx) .keystroke() { modifiers_held = modifiers_held - || (&accept_partial_keystroke.modifiers == modifiers - && accept_partial_keystroke.modifiers.modified()); + || (accept_partial_keystroke.modifiers() == modifiers + && accept_partial_keystroke.modifiers().modified()); } if modifiers_held { @@ -9044,7 +9044,7 @@ impl Editor { let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac; - let modifiers_color = if accept_keystroke.modifiers == window.modifiers() { + let modifiers_color = if *accept_keystroke.modifiers() == window.modifiers() { Color::Accent } else { Color::Muted @@ -9056,19 +9056,19 @@ impl Editor { .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) .text_size(TextSize::XSmall.rems(cx)) .child(h_flex().children(ui::render_modifiers( - &accept_keystroke.modifiers, + accept_keystroke.modifiers(), PlatformStyle::platform(), Some(modifiers_color), Some(IconSize::XSmall.rems().into()), true, ))) .when(is_platform_style_mac, |parent| { - parent.child(accept_keystroke.key.clone()) + parent.child(accept_keystroke.key().to_string()) }) .when(!is_platform_style_mac, |parent| { parent.child( Key::new( - util::capitalize(&accept_keystroke.key), + util::capitalize(accept_keystroke.key()), Some(Color::Default), ) .size(Some(IconSize::XSmall.rems().into())), @@ -9171,7 +9171,7 @@ impl Editor { max_width: Pixels, cursor_point: Point, style: &EditorStyle, - accept_keystroke: Option<&gpui::Keystroke>, + accept_keystroke: Option<&gpui::KeybindingKeystroke>, _window: &Window, cx: &mut Context, ) -> Option { @@ -9249,7 +9249,7 @@ impl Editor { accept_keystroke.as_ref(), |el, accept_keystroke| { el.child(h_flex().children(ui::render_modifiers( - &accept_keystroke.modifiers, + accept_keystroke.modifiers(), PlatformStyle::platform(), Some(Color::Default), Some(IconSize::XSmall.rems().into()), @@ -9319,7 +9319,7 @@ impl Editor { .child(completion), ) .when_some(accept_keystroke, |el, accept_keystroke| { - if !accept_keystroke.modifiers.modified() { + if !accept_keystroke.modifiers().modified() { return el; } @@ -9338,7 +9338,7 @@ impl Editor { .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) .when(is_platform_style_mac, |parent| parent.gap_1()) .child(h_flex().children(ui::render_modifiers( - &accept_keystroke.modifiers, + accept_keystroke.modifiers(), PlatformStyle::platform(), Some(if !has_completion { Color::Muted @@ -10447,6 +10447,86 @@ impl Editor { }) } + fn enable_wrap_selections_in_tag(&self, cx: &App) -> bool { + let snapshot = self.buffer.read(cx).snapshot(cx); + for selection in self.selections.disjoint_anchors().iter() { + if snapshot + .language_at(selection.start) + .and_then(|lang| lang.config().wrap_characters.as_ref()) + .is_some() + { + return true; + } + } + false + } + + fn wrap_selections_in_tag( + &mut self, + _: &WrapSelectionsInTag, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + + let snapshot = self.buffer.read(cx).snapshot(cx); + + let mut edits = Vec::new(); + let mut boundaries = Vec::new(); + + for selection in self.selections.all::(cx).iter() { + let Some(wrap_config) = snapshot + .language_at(selection.start) + .and_then(|lang| lang.config().wrap_characters.clone()) + else { + continue; + }; + + let open_tag = format!("{}{}", wrap_config.start_prefix, wrap_config.start_suffix); + let close_tag = format!("{}{}", wrap_config.end_prefix, wrap_config.end_suffix); + + let start_before = snapshot.anchor_before(selection.start); + let end_after = snapshot.anchor_after(selection.end); + + edits.push((start_before..start_before, open_tag)); + edits.push((end_after..end_after, close_tag)); + + boundaries.push(( + start_before, + end_after, + wrap_config.start_prefix.len(), + wrap_config.end_suffix.len(), + )); + } + + if edits.is_empty() { + return; + } + + self.transact(window, cx, |this, window, cx| { + let buffer = this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + buffer.snapshot(cx) + }); + + let mut new_selections = Vec::with_capacity(boundaries.len() * 2); + for (start_before, end_after, start_prefix_len, end_suffix_len) in + boundaries.into_iter() + { + let open_offset = start_before.to_offset(&buffer) + start_prefix_len; + let close_offset = end_after.to_offset(&buffer).saturating_sub(end_suffix_len); + new_selections.push(open_offset..open_offset); + new_selections.push(close_offset..close_offset); + } + + this.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(new_selections); + }); + + this.request_autoscroll(Autoscroll::fit(), cx); + }); + } + pub fn reload_file(&mut self, _: &ReloadFile, window: &mut Window, cx: &mut Context) { let Some(project) = self.project.clone() else { return; @@ -20074,7 +20154,7 @@ impl Editor { let (telemetry, is_via_ssh) = { let project = project.read(cx); let telemetry = project.client().telemetry().clone(); - let is_via_ssh = project.is_via_ssh(); + let is_via_ssh = project.is_via_remote_server(); (telemetry, is_via_ssh) }; refresh_linked_ranges(self, window, cx); @@ -20642,7 +20722,7 @@ impl Editor { copilot_enabled, copilot_enabled_for_language, edit_predictions_provider, - is_via_ssh = project.is_via_ssh(), + is_via_ssh = project.is_via_remote_server(), ); } else { telemetry::event!( @@ -20652,7 +20732,7 @@ impl Editor { copilot_enabled, copilot_enabled_for_language, edit_predictions_provider, - is_via_ssh = project.is_via_ssh(), + is_via_ssh = project.is_via_remote_server(), ); }; } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 2cfdb92593e2250a5615eb4d4d545c1552d13ecc..85471c7ce96e172f7bd5ade399ed0ba1cd6d4a02 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -4403,6 +4403,129 @@ async fn test_unique_lines_single_selection(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_wrap_in_tag_single_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let js_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + wrap_characters: Some(language::WrapCharactersConfig { + start_prefix: "<".into(), + start_suffix: ">".into(), + end_prefix: "".into(), + }), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx)); + + cx.set_state(indoc! {" + «testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + "}); + + cx.set_state(indoc! {" + «test + testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + test + "}); + + cx.set_state(indoc! {" + teˇst + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + te<«ˇ»>st + "}); +} + +#[gpui::test] +async fn test_wrap_in_tag_multi_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let js_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + wrap_characters: Some(language::WrapCharactersConfig { + start_prefix: "<".into(), + start_suffix: ">".into(), + end_prefix: "".into(), + }), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx)); + + cx.set_state(indoc! {" + «testˇ» + «testˇ» «testˇ» + «testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + <«ˇ»>test <«ˇ»>test + <«ˇ»>test + "}); + + cx.set_state(indoc! {" + «test + testˇ» + «test + testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + test + <«ˇ»>test + test + "}); +} + +#[gpui::test] +async fn test_wrap_in_tag_does_nothing_in_unsupported_languages(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let plaintext_language = Arc::new(Language::new( + LanguageConfig { + name: "Plain Text".into(), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(plaintext_language), cx)); + + cx.set_state(indoc! {" + «testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + «testˇ» + "}); +} + #[gpui::test] async fn test_manipulate_immutable_lines_with_multi_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 4f3580da07750db5241fed9a4f313c5a191b36e6..a63c18e003907f16a1383bbfb12085e1044d9eb9 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -43,10 +43,10 @@ use gpui::{ Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, - Keystroke, Length, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollHandle, - ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, - TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, + KeybindingKeystroke, Length, ModifiersChangedEvent, MouseButton, MouseClickEvent, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, + ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, + Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background, transparent_black, }; @@ -585,6 +585,9 @@ impl EditorElement { register_action(editor, window, Editor::edit_log_breakpoint); register_action(editor, window, Editor::enable_breakpoint); register_action(editor, window, Editor::disable_breakpoint); + if editor.read(cx).enable_wrap_selections_in_tag(cx) { + register_action(editor, window, Editor::wrap_selections_in_tag); + } } fn register_key_listeners(&self, window: &mut Window, _: &mut App, layout: &EditorLayout) { @@ -7150,7 +7153,7 @@ fn header_jump_data( pub struct AcceptEditPredictionBinding(pub(crate) Option); impl AcceptEditPredictionBinding { - pub fn keystroke(&self) -> Option<&Keystroke> { + pub fn keystroke(&self) -> Option<&KeybindingKeystroke> { if let Some(binding) = self.0.as_ref() { match &binding.keystrokes() { [keystroke, ..] => Some(keystroke), diff --git a/crates/editor/src/persistence.rs b/crates/editor/src/persistence.rs index 88fde539479b3159a2fbcb7e3b0473d4b4b91e76..ec7c149b4e107600c35e70ef3dffcdb2e8f8bcb7 100644 --- a/crates/editor/src/persistence.rs +++ b/crates/editor/src/persistence.rs @@ -1,13 +1,17 @@ use anyhow::Result; -use db::sqlez::bindable::{Bind, Column, StaticColumnCount}; -use db::sqlez::statement::Statement; +use db::{ + query, + sqlez::{ + bindable::{Bind, Column, StaticColumnCount}, + domain::Domain, + statement::Statement, + }, + sqlez_macros::sql, +}; use fs::MTime; use itertools::Itertools as _; use std::path::PathBuf; -use db::sqlez_macros::sql; -use db::{define_connection, query}; - use workspace::{ItemId, WorkspaceDb, WorkspaceId}; #[derive(Clone, Debug, PartialEq, Default)] @@ -83,7 +87,11 @@ impl Column for SerializedEditor { } } -define_connection!( +pub struct EditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection); + +impl Domain for EditorDb { + const NAME: &str = stringify!(EditorDb); + // Current schema shape using pseudo-rust syntax: // editors( // item_id: usize, @@ -113,7 +121,8 @@ define_connection!( // start: usize, // end: usize, // ) - pub static ref DB: EditorDb = &[ + + const MIGRATIONS: &[&str] = &[ sql! ( CREATE TABLE editors( item_id INTEGER NOT NULL, @@ -189,7 +198,9 @@ define_connection!( ) STRICT; ), ]; -); +} + +db::static_connection!(DB, EditorDb, [WorkspaceDb]); // https://www.sqlite.org/limits.html // > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER, diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index fde0aeac9405d114f9cee89ca054d4503a35d482..b8189c36511a03f136e5e215549453947e888bb1 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -43,7 +43,7 @@ use language::{ use node_runtime::NodeRuntime; use project::ContextProviderWithTasks; use release_channel::ReleaseChannel; -use remote::SshRemoteClient; +use remote::RemoteClient; use semantic_version::SemanticVersion; use serde::{Deserialize, Serialize}; use settings::Settings; @@ -117,7 +117,7 @@ pub struct ExtensionStore { pub wasm_host: Arc, pub wasm_extensions: Vec<(Arc, WasmExtension)>, pub tasks: Vec>, - pub ssh_clients: HashMap>, + pub remote_clients: HashMap>, pub ssh_registered_tx: UnboundedSender<()>, } @@ -270,7 +270,7 @@ impl ExtensionStore { reload_tx, tasks: Vec::new(), - ssh_clients: HashMap::default(), + remote_clients: HashMap::default(), ssh_registered_tx: connection_registered_tx, }; @@ -1693,7 +1693,7 @@ impl ExtensionStore { async fn sync_extensions_over_ssh( this: &WeakEntity, - client: WeakEntity, + client: WeakEntity, cx: &mut AsyncApp, ) -> Result<()> { let extensions = this.update(cx, |this, _cx| { @@ -1765,8 +1765,8 @@ impl ExtensionStore { pub async fn update_ssh_clients(this: &WeakEntity, cx: &mut AsyncApp) -> Result<()> { let clients = this.update(cx, |this, _cx| { - this.ssh_clients.retain(|_k, v| v.upgrade().is_some()); - this.ssh_clients.values().cloned().collect::>() + this.remote_clients.retain(|_k, v| v.upgrade().is_some()); + this.remote_clients.values().cloned().collect::>() })?; for client in clients { @@ -1778,17 +1778,17 @@ impl ExtensionStore { anyhow::Ok(()) } - pub fn register_ssh_client(&mut self, client: Entity, cx: &mut Context) { + pub fn register_remote_client(&mut self, client: Entity, cx: &mut Context) { let connection_options = client.read(cx).connection_options(); let ssh_url = connection_options.ssh_url(); - if let Some(existing_client) = self.ssh_clients.get(&ssh_url) + if let Some(existing_client) = self.remote_clients.get(&ssh_url) && existing_client.upgrade().is_some() { return; } - self.ssh_clients.insert(ssh_url, client.downgrade()); + self.remote_clients.insert(ssh_url, client.downgrade()); self.ssh_registered_tx.unbounded_send(()).ok(); } } diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 422979c4297cc72fdf2bf1d14cfba433d155c80e..f5f7fc42b35eba2ccd437c1e4cc4add0b4091773 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -98,6 +98,10 @@ impl FeatureFlag for GeminiAndNativeFeatureFlag { // integration too, and we'd like to turn Gemini/Native on in new builds // without enabling Claude Code in old builds. const NAME: &'static str = "gemini-and-native"; + + fn enabled_for_all() -> bool { + true + } } pub struct ClaudeCodeFeatureFlag; @@ -201,7 +205,7 @@ impl FeatureFlagAppExt for App { fn has_flag(&self) -> bool { self.try_global::() .map(|flags| flags.has_flag::()) - .unwrap_or(false) + .unwrap_or(T::enabled_for_all()) } fn is_staff(&self) -> bool { diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 75121523245cc9233fa4c66c9021d8a958524f6e..e2f8d55cf2a0c8f21a1ed6b3eab870d267173bba 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1381,7 +1381,7 @@ impl PickerDelegate for FileFinderDelegate { project .worktree_for_id(history_item.project.worktree_id, cx) .is_some() - || ((project.is_local() || project.is_via_ssh()) + || ((project.is_local() || project.is_via_remote_server()) && history_item.absolute.is_some()) }), self.currently_opened_path.as_ref(), diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 75312c5c0cec4a4b285ff0320d90eeda1c0a4c6a..a5cf9b88254deff5b9a07402207f19875827d7f0 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -495,7 +495,8 @@ impl Fs for RealFs { }; // todo(windows) // When new version of `windows-rs` release, make this operation `async` - let path = SanitizedPath::from(path.canonicalize()?); + let path = path.canonicalize()?; + let path = SanitizedPath::new(&path); let path_string = path.to_string(); let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path_string))?.get()?; file.DeleteAsync(StorageDeleteOption::Default)?.get()?; @@ -522,7 +523,8 @@ impl Fs for RealFs { // todo(windows) // When new version of `windows-rs` release, make this operation `async` - let path = SanitizedPath::from(path.canonicalize()?); + let path = path.canonicalize()?; + let path = SanitizedPath::new(&path); let path_string = path.to_string(); let folder = StorageFolder::GetFolderFromPathAsync(&HSTRING::from(path_string))?.get()?; folder.DeleteAsync(StorageDeleteOption::Default)?.get()?; @@ -783,7 +785,7 @@ impl Fs for RealFs { { target = parent.join(target); if let Ok(canonical) = self.canonicalize(&target).await { - target = SanitizedPath::from(canonical).as_path().to_path_buf(); + target = SanitizedPath::new(&canonical).as_path().to_path_buf(); } } watcher.add(&target).ok(); diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 6ad03ba6dfa2003b1642cbb542e3a9cf0bf13ec9..07374b7f40455f09cf52d31ddd1a1f64ab6abcd3 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -42,7 +42,7 @@ impl Drop for FsWatcher { impl Watcher for FsWatcher { fn add(&self, path: &std::path::Path) -> anyhow::Result<()> { - let root_path = SanitizedPath::from(path); + let root_path = SanitizedPath::new_arc(path); let tx = self.tx.clone(); let pending_paths = self.pending_path_events.clone(); @@ -70,7 +70,7 @@ impl Watcher for FsWatcher { .paths .iter() .filter_map(|event_path| { - let event_path = SanitizedPath::from(event_path); + let event_path = SanitizedPath::new(event_path); event_path.starts_with(&root_path).then(|| PathEvent { path: event_path.as_path().to_path_buf(), kind, diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 958a609a096173eef379d4788d9e0dc64bbfbe5a..4ecb4a8829659ca9a25152db8d1eff529cfff2b1 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4466,7 +4466,7 @@ fn current_language_model(cx: &Context<'_, GitPanel>) -> Option Result>> { + let api_key = api_key.trim(); validate_generate_content_request(&request)?; // The `model` field is emptied as it is provided as a path parameter. diff --git a/crates/gpui/examples/window.rs b/crates/gpui/examples/window.rs index 30f3697b223d6d85a9db573eb3659e9689af60a5..4445f24e4ec0f2809109964fd34610cad1299e90 100644 --- a/crates/gpui/examples/window.rs +++ b/crates/gpui/examples/window.rs @@ -152,6 +152,36 @@ impl Render for WindowDemo { ) .unwrap(); })) + .child(button("Unresizable", move |_, cx| { + cx.open_window( + WindowOptions { + is_resizable: false, + window_bounds: Some(window_bounds), + ..Default::default() + }, + |_, cx| { + cx.new(|_| SubWindow { + custom_titlebar: false, + }) + }, + ) + .unwrap(); + })) + .child(button("Unminimizable", move |_, cx| { + cx.open_window( + WindowOptions { + is_minimizable: false, + window_bounds: Some(window_bounds), + ..Default::default() + }, + |_, cx| { + cx.new(|_| SubWindow { + custom_titlebar: false, + }) + }, + ) + .unwrap(); + })) .child(button("Hide Application", |window, cx| { cx.hide(); diff --git a/crates/gpui/examples/window_positioning.rs b/crates/gpui/examples/window_positioning.rs index 0f0bb8ac288d7117867df9b12a104e4272903378..ca6cd535d67aa8b2e700b2d0bc632056e928e0e7 100644 --- a/crates/gpui/examples/window_positioning.rs +++ b/crates/gpui/examples/window_positioning.rs @@ -62,6 +62,8 @@ fn build_window_options(display_id: DisplayId, bounds: Bounds) -> Window app_id: None, window_min_size: None, window_decorations: None, + tabbing_identifier: None, + ..Default::default() } } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index bbd59fa7bc1276bedac8a17e9fe947a7211172eb..669a95bd91577577fc460ba30bdacc867e3f3e60 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -7,7 +7,7 @@ use std::{ path::{Path, PathBuf}, rc::{Rc, Weak}, sync::{Arc, atomic::Ordering::SeqCst}, - time::Duration, + time::{Duration, Instant}, }; use anyhow::{Context as _, Result, anyhow}; @@ -17,6 +17,7 @@ use futures::{ channel::oneshot, future::{LocalBoxFuture, Shared}, }; +use itertools::Itertools; use parking_lot::RwLock; use slotmap::SlotMap; @@ -37,10 +38,10 @@ use crate::{ AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DispatchPhase, DisplayId, EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, - PlatformDisplay, PlatformKeyboardLayout, Point, PromptBuilder, PromptButton, PromptHandle, - PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, - SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, Window, WindowAppearance, - WindowHandle, WindowId, WindowInvalidator, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, PromptBuilder, + PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, + Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, + TextSystem, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, colors::{Colors, GlobalColors}, current_platform, hash, init_app_menus, }; @@ -237,6 +238,303 @@ type WindowClosedHandler = Box; type ReleaseListener = Box; type NewEntityListener = Box, &mut App) + 'static>; +#[doc(hidden)] +#[derive(Clone, PartialEq, Eq)] +pub struct SystemWindowTab { + pub id: WindowId, + pub title: SharedString, + pub handle: AnyWindowHandle, + pub last_active_at: Instant, +} + +impl SystemWindowTab { + /// Create a new instance of the window tab. + pub fn new(title: SharedString, handle: AnyWindowHandle) -> Self { + Self { + id: handle.id, + title, + handle, + last_active_at: Instant::now(), + } + } +} + +/// A controller for managing window tabs. +#[derive(Default)] +pub struct SystemWindowTabController { + visible: Option, + tab_groups: FxHashMap>, +} + +impl Global for SystemWindowTabController {} + +impl SystemWindowTabController { + /// Create a new instance of the window tab controller. + pub fn new() -> Self { + Self { + visible: None, + tab_groups: FxHashMap::default(), + } + } + + /// Initialize the global window tab controller. + pub fn init(cx: &mut App) { + cx.set_global(SystemWindowTabController::new()); + } + + /// Get all tab groups. + pub fn tab_groups(&self) -> &FxHashMap> { + &self.tab_groups + } + + /// Get the next tab group window handle. + pub fn get_next_tab_group_window(cx: &mut App, id: WindowId) -> Option<&AnyWindowHandle> { + let controller = cx.global::(); + let current_group = controller + .tab_groups + .iter() + .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group)); + + let current_group = current_group?; + let mut group_ids: Vec<_> = controller.tab_groups.keys().collect(); + let idx = group_ids.iter().position(|g| *g == current_group)?; + let next_idx = (idx + 1) % group_ids.len(); + + controller + .tab_groups + .get(group_ids[next_idx]) + .and_then(|tabs| { + tabs.iter() + .max_by_key(|tab| tab.last_active_at) + .or_else(|| tabs.first()) + .map(|tab| &tab.handle) + }) + } + + /// Get the previous tab group window handle. + pub fn get_prev_tab_group_window(cx: &mut App, id: WindowId) -> Option<&AnyWindowHandle> { + let controller = cx.global::(); + let current_group = controller + .tab_groups + .iter() + .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group)); + + let current_group = current_group?; + let mut group_ids: Vec<_> = controller.tab_groups.keys().collect(); + let idx = group_ids.iter().position(|g| *g == current_group)?; + let prev_idx = if idx == 0 { + group_ids.len() - 1 + } else { + idx - 1 + }; + + controller + .tab_groups + .get(group_ids[prev_idx]) + .and_then(|tabs| { + tabs.iter() + .max_by_key(|tab| tab.last_active_at) + .or_else(|| tabs.first()) + .map(|tab| &tab.handle) + }) + } + + /// Get all tabs in the same window. + pub fn tabs(&self, id: WindowId) -> Option<&Vec> { + let tab_group = self + .tab_groups + .iter() + .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group)); + + if let Some(tab_group) = tab_group { + self.tab_groups.get(&tab_group) + } else { + None + } + } + + /// Initialize the visibility of the system window tab controller. + pub fn init_visible(cx: &mut App, visible: bool) { + let mut controller = cx.global_mut::(); + if controller.visible.is_none() { + controller.visible = Some(visible); + } + } + + /// Get the visibility of the system window tab controller. + pub fn is_visible(&self) -> bool { + self.visible.unwrap_or(false) + } + + /// Set the visibility of the system window tab controller. + pub fn set_visible(cx: &mut App, visible: bool) { + let mut controller = cx.global_mut::(); + controller.visible = Some(visible); + } + + /// Update the last active of a window. + pub fn update_last_active(cx: &mut App, id: WindowId) { + let mut controller = cx.global_mut::(); + for windows in controller.tab_groups.values_mut() { + for tab in windows.iter_mut() { + if tab.id == id { + tab.last_active_at = Instant::now(); + } + } + } + } + + /// Update the position of a tab within its group. + pub fn update_tab_position(cx: &mut App, id: WindowId, ix: usize) { + let mut controller = cx.global_mut::(); + for (_, windows) in controller.tab_groups.iter_mut() { + if let Some(current_pos) = windows.iter().position(|tab| tab.id == id) { + if ix < windows.len() && current_pos != ix { + let window_tab = windows.remove(current_pos); + windows.insert(ix, window_tab); + } + break; + } + } + } + + /// Update the title of a tab. + pub fn update_tab_title(cx: &mut App, id: WindowId, title: SharedString) { + let controller = cx.global::(); + let tab = controller + .tab_groups + .values() + .flat_map(|windows| windows.iter()) + .find(|tab| tab.id == id); + + if tab.map_or(true, |t| t.title == title) { + return; + } + + let mut controller = cx.global_mut::(); + for windows in controller.tab_groups.values_mut() { + for tab in windows.iter_mut() { + if tab.id == id { + tab.title = title.clone(); + } + } + } + } + + /// Insert a tab into a tab group. + pub fn add_tab(cx: &mut App, id: WindowId, tabs: Vec) { + let mut controller = cx.global_mut::(); + let Some(tab) = tabs.clone().into_iter().find(|tab| tab.id == id) else { + return; + }; + + let mut expected_tab_ids: Vec<_> = tabs + .iter() + .filter(|tab| tab.id != id) + .map(|tab| tab.id) + .sorted() + .collect(); + + let mut tab_group_id = None; + for (group_id, group_tabs) in &controller.tab_groups { + let tab_ids: Vec<_> = group_tabs.iter().map(|tab| tab.id).sorted().collect(); + if tab_ids == expected_tab_ids { + tab_group_id = Some(*group_id); + break; + } + } + + if let Some(tab_group_id) = tab_group_id { + if let Some(tabs) = controller.tab_groups.get_mut(&tab_group_id) { + tabs.push(tab); + } + } else { + let new_group_id = controller.tab_groups.len(); + controller.tab_groups.insert(new_group_id, tabs); + } + } + + /// Remove a tab from a tab group. + pub fn remove_tab(cx: &mut App, id: WindowId) -> Option { + let mut controller = cx.global_mut::(); + let mut removed_tab = None; + + controller.tab_groups.retain(|_, tabs| { + if let Some(pos) = tabs.iter().position(|tab| tab.id == id) { + removed_tab = Some(tabs.remove(pos)); + } + !tabs.is_empty() + }); + + removed_tab + } + + /// Move a tab to a new tab group. + pub fn move_tab_to_new_window(cx: &mut App, id: WindowId) { + let mut removed_tab = Self::remove_tab(cx, id); + let mut controller = cx.global_mut::(); + + if let Some(tab) = removed_tab { + let new_group_id = controller.tab_groups.keys().max().map_or(0, |k| k + 1); + controller.tab_groups.insert(new_group_id, vec![tab]); + } + } + + /// Merge all tab groups into a single group. + pub fn merge_all_windows(cx: &mut App, id: WindowId) { + let mut controller = cx.global_mut::(); + let Some(initial_tabs) = controller.tabs(id) else { + return; + }; + + let mut all_tabs = initial_tabs.clone(); + for tabs in controller.tab_groups.values() { + all_tabs.extend( + tabs.iter() + .filter(|tab| !initial_tabs.contains(tab)) + .cloned(), + ); + } + + controller.tab_groups.clear(); + controller.tab_groups.insert(0, all_tabs); + } + + /// Selects the next tab in the tab group in the trailing direction. + pub fn select_next_tab(cx: &mut App, id: WindowId) { + let mut controller = cx.global_mut::(); + let Some(tabs) = controller.tabs(id) else { + return; + }; + + let current_index = tabs.iter().position(|tab| tab.id == id).unwrap(); + let next_index = (current_index + 1) % tabs.len(); + + let _ = &tabs[next_index].handle.update(cx, |_, window, _| { + window.activate_window(); + }); + } + + /// Selects the previous tab in the tab group in the leading direction. + pub fn select_previous_tab(cx: &mut App, id: WindowId) { + let mut controller = cx.global_mut::(); + let Some(tabs) = controller.tabs(id) else { + return; + }; + + let current_index = tabs.iter().position(|tab| tab.id == id).unwrap(); + let previous_index = if current_index == 0 { + tabs.len() - 1 + } else { + current_index - 1 + }; + + let _ = &tabs[previous_index].handle.update(cx, |_, window, _| { + window.activate_window(); + }); + } +} + /// Contains the state of the full application, and passed as a reference to a variety of callbacks. /// Other [Context] derefs to this type. /// You need a reference to an `App` to access the state of a [Entity]. @@ -263,6 +561,7 @@ pub struct App { pub(crate) focus_handles: Arc, pub(crate) keymap: Rc>, pub(crate) keyboard_layout: Box, + pub(crate) keyboard_mapper: Rc, pub(crate) global_action_listeners: FxHashMap>>, pending_effects: VecDeque, @@ -312,6 +611,7 @@ impl App { let text_system = Arc::new(TextSystem::new(platform.text_system())); let entities = EntityMap::new(); let keyboard_layout = platform.keyboard_layout(); + let keyboard_mapper = platform.keyboard_mapper(); let app = Rc::new_cyclic(|this| AppCell { app: RefCell::new(App { @@ -337,6 +637,7 @@ impl App { focus_handles: Arc::new(RwLock::new(SlotMap::with_key())), keymap: Rc::new(RefCell::new(Keymap::default())), keyboard_layout, + keyboard_mapper, global_action_listeners: FxHashMap::default(), pending_effects: VecDeque::new(), pending_notifications: FxHashSet::default(), @@ -369,6 +670,7 @@ impl App { }); init_app_menus(platform.as_ref(), &app.borrow()); + SystemWindowTabController::init(&mut app.borrow_mut()); platform.on_keyboard_layout_change(Box::new({ let app = Rc::downgrade(&app); @@ -376,6 +678,7 @@ impl App { if let Some(app) = app.upgrade() { let cx = &mut app.borrow_mut(); cx.keyboard_layout = cx.platform.keyboard_layout(); + cx.keyboard_mapper = cx.platform.keyboard_mapper(); cx.keyboard_layout_observers .clone() .retain(&(), move |callback| (callback)(cx)); @@ -424,6 +727,11 @@ impl App { self.keyboard_layout.as_ref() } + /// Get the current keyboard mapper. + pub fn keyboard_mapper(&self) -> &Rc { + &self.keyboard_mapper + } + /// Invokes a handler when the current keyboard layout changes pub fn on_keyboard_layout_change(&self, mut callback: F) -> Subscription where diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index 757205fcc3d2a886744582769951b94abf754352..12f082eb60799bdf9a0cdfaf7d546fa2bdf13e04 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -4,7 +4,7 @@ mod context; pub use binding::*; pub use context::*; -use crate::{Action, Keystroke, is_no_action}; +use crate::{Action, AsKeystroke, Keystroke, is_no_action}; use collections::{HashMap, HashSet}; use smallvec::SmallVec; use std::any::TypeId; @@ -141,7 +141,7 @@ impl Keymap { /// only. pub fn bindings_for_input( &self, - input: &[Keystroke], + input: &[impl AsKeystroke], context_stack: &[KeyContext], ) -> (SmallVec<[KeyBinding; 1]>, bool) { let mut matched_bindings = SmallVec::<[(usize, BindingIndex, &KeyBinding); 1]>::new(); @@ -192,7 +192,6 @@ impl Keymap { (bindings, !pending.is_empty()) } - /// Check if the given binding is enabled, given a certain key context. /// Returns the deepest depth at which the binding matches, or None if it doesn't match. fn binding_enabled(&self, binding: &KeyBinding, contexts: &[KeyContext]) -> Option { @@ -639,7 +638,7 @@ mod tests { fn assert_bindings(keymap: &Keymap, action: &dyn Action, expected: &[&str]) { let actual = keymap .bindings_for_action(action) - .map(|binding| binding.keystrokes[0].unparse()) + .map(|binding| binding.keystrokes[0].inner().unparse()) .collect::>(); assert_eq!(actual, expected, "{:?}", action); } diff --git a/crates/gpui/src/keymap/binding.rs b/crates/gpui/src/keymap/binding.rs index 729498d153b62b3e250421c82b4bdc05e6c0030f..fc4b32941b85f4cdea31aaba7198d3e7043ee481 100644 --- a/crates/gpui/src/keymap/binding.rs +++ b/crates/gpui/src/keymap/binding.rs @@ -1,14 +1,15 @@ use std::rc::Rc; -use collections::HashMap; - -use crate::{Action, InvalidKeystrokeError, KeyBindingContextPredicate, Keystroke, SharedString}; +use crate::{ + Action, AsKeystroke, DummyKeyboardMapper, InvalidKeystrokeError, KeyBindingContextPredicate, + KeybindingKeystroke, Keystroke, PlatformKeyboardMapper, SharedString, +}; use smallvec::SmallVec; /// A keybinding and its associated metadata, from the keymap. pub struct KeyBinding { pub(crate) action: Box, - pub(crate) keystrokes: SmallVec<[Keystroke; 2]>, + pub(crate) keystrokes: SmallVec<[KeybindingKeystroke; 2]>, pub(crate) context_predicate: Option>, pub(crate) meta: Option, /// The json input string used when building the keybinding, if any @@ -32,7 +33,15 @@ impl KeyBinding { pub fn new(keystrokes: &str, action: A, context: Option<&str>) -> Self { let context_predicate = context.map(|context| KeyBindingContextPredicate::parse(context).unwrap().into()); - Self::load(keystrokes, Box::new(action), context_predicate, None, None).unwrap() + Self::load( + keystrokes, + Box::new(action), + context_predicate, + false, + None, + &DummyKeyboardMapper, + ) + .unwrap() } /// Load a keybinding from the given raw data. @@ -40,24 +49,22 @@ impl KeyBinding { keystrokes: &str, action: Box, context_predicate: Option>, - key_equivalents: Option<&HashMap>, + use_key_equivalents: bool, action_input: Option, + keyboard_mapper: &dyn PlatformKeyboardMapper, ) -> std::result::Result { - let mut keystrokes: SmallVec<[Keystroke; 2]> = keystrokes + let keystrokes: SmallVec<[KeybindingKeystroke; 2]> = keystrokes .split_whitespace() - .map(Keystroke::parse) + .map(|source| { + let keystroke = Keystroke::parse(source)?; + Ok(KeybindingKeystroke::new_with_mapper( + keystroke, + use_key_equivalents, + keyboard_mapper, + )) + }) .collect::>()?; - if let Some(equivalents) = key_equivalents { - for keystroke in keystrokes.iter_mut() { - if keystroke.key.chars().count() == 1 - && let Some(key) = equivalents.get(&keystroke.key.chars().next().unwrap()) - { - keystroke.key = key.to_string(); - } - } - } - Ok(Self { keystrokes, action, @@ -79,13 +86,13 @@ impl KeyBinding { } /// Check if the given keystrokes match this binding. - pub fn match_keystrokes(&self, typed: &[Keystroke]) -> Option { + pub fn match_keystrokes(&self, typed: &[impl AsKeystroke]) -> Option { if self.keystrokes.len() < typed.len() { return None; } for (target, typed) in self.keystrokes.iter().zip(typed.iter()) { - if !typed.should_match(target) { + if !typed.as_keystroke().should_match(target) { return None; } } @@ -94,7 +101,7 @@ impl KeyBinding { } /// Get the keystrokes associated with this binding - pub fn keystrokes(&self) -> &[Keystroke] { + pub fn keystrokes(&self) -> &[KeybindingKeystroke] { self.keystrokes.as_slice() } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 4d2feeaf1d041245b110bf25674fd18145a9a7ee..eb1d73814388a26503e9ada782bc358dc712b53c 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -40,8 +40,8 @@ use crate::{ DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun, ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput, Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, ScaledPixels, Scene, - ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SvgSize, Task, TaskLabel, Window, - WindowControlArea, hash, point, px, size, + ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SvgSize, SystemWindowTab, Task, + TaskLabel, Window, WindowControlArea, hash, point, px, size, }; use anyhow::Result; use async_task::Runnable; @@ -231,7 +231,6 @@ pub(crate) trait Platform: 'static { fn on_quit(&self, callback: Box); fn on_reopen(&self, callback: Box); - fn on_keyboard_layout_change(&self, callback: Box); fn set_menus(&self, menus: Vec, keymap: &Keymap); fn get_menus(&self) -> Option> { @@ -251,7 +250,6 @@ pub(crate) trait Platform: 'static { fn on_app_menu_action(&self, callback: Box); fn on_will_open_app_menu(&self, callback: Box); fn on_validate_app_menu_command(&self, callback: Box bool>); - fn keyboard_layout(&self) -> Box; fn compositor_name(&self) -> &'static str { "" @@ -272,6 +270,10 @@ pub(crate) trait Platform: 'static { fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task>; fn read_credentials(&self, url: &str) -> Task)>>>; fn delete_credentials(&self, url: &str) -> Task>; + + fn keyboard_layout(&self) -> Box; + fn keyboard_mapper(&self) -> Rc; + fn on_keyboard_layout_change(&self, callback: Box); } /// A handle to a platform's display, e.g. a monitor or laptop screen. @@ -500,9 +502,26 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn sprite_atlas(&self) -> Arc; // macOS specific methods + fn get_title(&self) -> String { + String::new() + } + fn tabbed_windows(&self) -> Option> { + None + } + fn tab_bar_visible(&self) -> bool { + false + } fn set_edited(&mut self, _edited: bool) {} fn show_character_palette(&self) {} fn titlebar_double_click(&self) {} + fn on_move_tab_to_new_window(&self, _callback: Box) {} + fn on_merge_all_windows(&self, _callback: Box) {} + fn on_select_previous_tab(&self, _callback: Box) {} + fn on_select_next_tab(&self, _callback: Box) {} + fn on_toggle_tab_bar(&self, _callback: Box) {} + fn merge_all_windows(&self) {} + fn move_tab_to_new_window(&self) {} + fn toggle_window_tab_overview(&self) {} #[cfg(target_os = "windows")] fn get_raw_handle(&self) -> windows::HWND; @@ -1089,6 +1108,12 @@ pub struct WindowOptions { /// Whether the window should be movable by the user pub is_movable: bool, + /// Whether the window should be resizable by the user + pub is_resizable: bool, + + /// Whether the window should be minimized by the user + pub is_minimizable: bool, + /// The display to create the window on, if this is None, /// the window will be created on the main display pub display_id: Option, @@ -1105,6 +1130,9 @@ pub struct WindowOptions { /// Whether to use client or server side decorations. Wayland only /// Note that this may be ignored. pub window_decorations: Option, + + /// Tab group name, allows opening the window as a native tab on macOS 10.12+. Windows with the same tabbing identifier will be grouped together. + pub tabbing_identifier: Option, } /// The variables that can be configured when creating a new window @@ -1131,6 +1159,14 @@ pub(crate) struct WindowParams { #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] pub is_movable: bool, + /// Whether the window should be resizable by the user + #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] + pub is_resizable: bool, + + /// Whether the window should be minimized by the user + #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] + pub is_minimizable: bool, + #[cfg_attr( any(target_os = "linux", target_os = "freebsd", target_os = "windows"), allow(dead_code) @@ -1144,6 +1180,8 @@ pub(crate) struct WindowParams { pub display_id: Option, pub window_min_size: Option>, + #[cfg(target_os = "macos")] + pub tabbing_identifier: Option, } /// Represents the status of how a window should be opened. @@ -1189,11 +1227,14 @@ impl Default for WindowOptions { show: true, kind: WindowKind::Normal, is_movable: true, + is_resizable: true, + is_minimizable: true, display_id: None, window_background: WindowBackgroundAppearance::default(), app_id: None, window_min_size: None, window_decorations: None, + tabbing_identifier: None, } } } diff --git a/crates/gpui/src/platform/keyboard.rs b/crates/gpui/src/platform/keyboard.rs index e28d7815200800b7e3950c6819e6ef3fc42f0306..10b8620258ecffd41e8018fc539c47812df0fe05 100644 --- a/crates/gpui/src/platform/keyboard.rs +++ b/crates/gpui/src/platform/keyboard.rs @@ -1,3 +1,7 @@ +use collections::HashMap; + +use crate::{KeybindingKeystroke, Keystroke}; + /// A trait for platform-specific keyboard layouts pub trait PlatformKeyboardLayout { /// Get the keyboard layout ID, which should be unique to the layout @@ -5,3 +9,33 @@ pub trait PlatformKeyboardLayout { /// Get the keyboard layout display name fn name(&self) -> &str; } + +/// A trait for platform-specific keyboard mappings +pub trait PlatformKeyboardMapper { + /// Map a key equivalent to its platform-specific representation + fn map_key_equivalent( + &self, + keystroke: Keystroke, + use_key_equivalents: bool, + ) -> KeybindingKeystroke; + /// Get the key equivalents for the current keyboard layout, + /// only used on macOS + fn get_key_equivalents(&self) -> Option<&HashMap>; +} + +/// A dummy implementation of the platform keyboard mapper +pub struct DummyKeyboardMapper; + +impl PlatformKeyboardMapper for DummyKeyboardMapper { + fn map_key_equivalent( + &self, + keystroke: Keystroke, + _use_key_equivalents: bool, + ) -> KeybindingKeystroke { + KeybindingKeystroke::from_keystroke(keystroke) + } + + fn get_key_equivalents(&self) -> Option<&HashMap> { + None + } +} diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 24601eefd6de450622247caaca5ff680c60a3257..4a2bfc785e3eb7e13a845bb67b4524255affb3bb 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/crates/gpui/src/platform/keystroke.rs @@ -5,6 +5,14 @@ use std::{ fmt::{Display, Write}, }; +use crate::PlatformKeyboardMapper; + +/// This is a helper trait so that we can simplify the implementation of some functions +pub trait AsKeystroke { + /// Returns the GPUI representation of the keystroke. + fn as_keystroke(&self) -> &Keystroke; +} + /// A keystroke and associated metadata generated by the platform #[derive(Clone, Debug, Eq, PartialEq, Default, Deserialize, Hash)] pub struct Keystroke { @@ -24,6 +32,19 @@ pub struct Keystroke { pub key_char: Option, } +/// Represents a keystroke that can be used in keybindings and displayed to the user. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct KeybindingKeystroke { + /// The GPUI representation of the keystroke. + inner: Keystroke, + /// The modifiers to display. + #[cfg(target_os = "windows")] + display_modifiers: Modifiers, + /// The key to display. + #[cfg(target_os = "windows")] + display_key: String, +} + /// Error type for `Keystroke::parse`. This is used instead of `anyhow::Error` so that Zed can use /// markdown to display it. #[derive(Debug)] @@ -58,7 +79,7 @@ impl Keystroke { /// /// This method assumes that `self` was typed and `target' is in the keymap, and checks /// both possibilities for self against the target. - pub fn should_match(&self, target: &Keystroke) -> bool { + pub fn should_match(&self, target: &KeybindingKeystroke) -> bool { #[cfg(not(target_os = "windows"))] if let Some(key_char) = self .key_char @@ -71,7 +92,7 @@ impl Keystroke { ..Default::default() }; - if &target.key == key_char && target.modifiers == ime_modifiers { + if &target.inner.key == key_char && target.inner.modifiers == ime_modifiers { return true; } } @@ -83,12 +104,12 @@ impl Keystroke { .filter(|key_char| key_char != &&self.key) { // On Windows, if key_char is set, then the typed keystroke produced the key_char - if &target.key == key_char && target.modifiers == Modifiers::none() { + if &target.inner.key == key_char && target.inner.modifiers == Modifiers::none() { return true; } } - target.modifiers == self.modifiers && target.key == self.key + target.inner.modifiers == self.modifiers && target.inner.key == self.key } /// key syntax is: @@ -200,31 +221,7 @@ impl Keystroke { /// Produces a representation of this key that Parse can understand. pub fn unparse(&self) -> String { - let mut str = String::new(); - if self.modifiers.function { - str.push_str("fn-"); - } - if self.modifiers.control { - str.push_str("ctrl-"); - } - if self.modifiers.alt { - str.push_str("alt-"); - } - if self.modifiers.platform { - #[cfg(target_os = "macos")] - str.push_str("cmd-"); - - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - str.push_str("super-"); - - #[cfg(target_os = "windows")] - str.push_str("win-"); - } - if self.modifiers.shift { - str.push_str("shift-"); - } - str.push_str(&self.key); - str + unparse(&self.modifiers, &self.key) } /// Returns true if this keystroke left @@ -266,6 +263,117 @@ impl Keystroke { } } +impl KeybindingKeystroke { + #[cfg(target_os = "windows")] + pub(crate) fn new(inner: Keystroke, display_modifiers: Modifiers, display_key: String) -> Self { + KeybindingKeystroke { + inner, + display_modifiers, + display_key, + } + } + + /// Create a new keybinding keystroke from the given keystroke using the given keyboard mapper. + pub fn new_with_mapper( + inner: Keystroke, + use_key_equivalents: bool, + keyboard_mapper: &dyn PlatformKeyboardMapper, + ) -> Self { + keyboard_mapper.map_key_equivalent(inner, use_key_equivalents) + } + + /// Create a new keybinding keystroke from the given keystroke, without any platform-specific mapping. + pub fn from_keystroke(keystroke: Keystroke) -> Self { + #[cfg(target_os = "windows")] + { + let key = keystroke.key.clone(); + let modifiers = keystroke.modifiers; + KeybindingKeystroke { + inner: keystroke, + display_modifiers: modifiers, + display_key: key, + } + } + #[cfg(not(target_os = "windows"))] + { + KeybindingKeystroke { inner: keystroke } + } + } + + /// Returns the GPUI representation of the keystroke. + pub fn inner(&self) -> &Keystroke { + &self.inner + } + + /// Returns the modifiers. + /// + /// Platform-specific behavior: + /// - On macOS and Linux, this modifiers is the same as `inner.modifiers`, which is the GPUI representation of the keystroke. + /// - On Windows, this modifiers is the display modifiers, for example, a `ctrl-@` keystroke will have `inner.modifiers` as + /// `Modifiers::control()` and `display_modifiers` as `Modifiers::control_shift()`. + pub fn modifiers(&self) -> &Modifiers { + #[cfg(target_os = "windows")] + { + &self.display_modifiers + } + #[cfg(not(target_os = "windows"))] + { + &self.inner.modifiers + } + } + + /// Returns the key. + /// + /// Platform-specific behavior: + /// - On macOS and Linux, this key is the same as `inner.key`, which is the GPUI representation of the keystroke. + /// - On Windows, this key is the display key, for example, a `ctrl-@` keystroke will have `inner.key` as `@` and `display_key` as `2`. + pub fn key(&self) -> &str { + #[cfg(target_os = "windows")] + { + &self.display_key + } + #[cfg(not(target_os = "windows"))] + { + &self.inner.key + } + } + + /// Sets the modifiers. On Windows this modifies both `inner.modifiers` and `display_modifiers`. + pub fn set_modifiers(&mut self, modifiers: Modifiers) { + self.inner.modifiers = modifiers; + #[cfg(target_os = "windows")] + { + self.display_modifiers = modifiers; + } + } + + /// Sets the key. On Windows this modifies both `inner.key` and `display_key`. + pub fn set_key(&mut self, key: String) { + #[cfg(target_os = "windows")] + { + self.display_key = key.clone(); + } + self.inner.key = key; + } + + /// Produces a representation of this key that Parse can understand. + pub fn unparse(&self) -> String { + #[cfg(target_os = "windows")] + { + unparse(&self.display_modifiers, &self.display_key) + } + #[cfg(not(target_os = "windows"))] + { + unparse(&self.inner.modifiers, &self.inner.key) + } + } + + /// Removes the key_char + pub fn remove_key_char(&mut self) { + self.inner.key_char = None; + } +} + fn is_printable_key(key: &str) -> bool { !matches!( key, @@ -322,65 +430,15 @@ fn is_printable_key(key: &str) -> bool { impl std::fmt::Display for Keystroke { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.modifiers.control { - #[cfg(target_os = "macos")] - f.write_char('^')?; - - #[cfg(not(target_os = "macos"))] - write!(f, "ctrl-")?; - } - if self.modifiers.alt { - #[cfg(target_os = "macos")] - f.write_char('⌥')?; - - #[cfg(not(target_os = "macos"))] - write!(f, "alt-")?; - } - if self.modifiers.platform { - #[cfg(target_os = "macos")] - f.write_char('⌘')?; - - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - f.write_char('❖')?; - - #[cfg(target_os = "windows")] - f.write_char('⊞')?; - } - if self.modifiers.shift { - #[cfg(target_os = "macos")] - f.write_char('⇧')?; + display_modifiers(&self.modifiers, f)?; + display_key(&self.key, f) + } +} - #[cfg(not(target_os = "macos"))] - write!(f, "shift-")?; - } - let key = match self.key.as_str() { - #[cfg(target_os = "macos")] - "backspace" => '⌫', - #[cfg(target_os = "macos")] - "up" => '↑', - #[cfg(target_os = "macos")] - "down" => '↓', - #[cfg(target_os = "macos")] - "left" => '←', - #[cfg(target_os = "macos")] - "right" => '→', - #[cfg(target_os = "macos")] - "tab" => '⇥', - #[cfg(target_os = "macos")] - "escape" => '⎋', - #[cfg(target_os = "macos")] - "shift" => '⇧', - #[cfg(target_os = "macos")] - "control" => '⌃', - #[cfg(target_os = "macos")] - "alt" => '⌥', - #[cfg(target_os = "macos")] - "platform" => '⌘', - - key if key.len() == 1 => key.chars().next().unwrap().to_ascii_uppercase(), - key => return f.write_str(key), - }; - f.write_char(key) +impl std::fmt::Display for KeybindingKeystroke { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + display_modifiers(self.modifiers(), f)?; + display_key(self.key(), f) } } @@ -600,3 +658,110 @@ pub struct Capslock { #[serde(default)] pub on: bool, } + +impl AsKeystroke for Keystroke { + fn as_keystroke(&self) -> &Keystroke { + self + } +} + +impl AsKeystroke for KeybindingKeystroke { + fn as_keystroke(&self) -> &Keystroke { + &self.inner + } +} + +fn display_modifiers(modifiers: &Modifiers, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if modifiers.control { + #[cfg(target_os = "macos")] + f.write_char('^')?; + + #[cfg(not(target_os = "macos"))] + write!(f, "ctrl-")?; + } + if modifiers.alt { + #[cfg(target_os = "macos")] + f.write_char('⌥')?; + + #[cfg(not(target_os = "macos"))] + write!(f, "alt-")?; + } + if modifiers.platform { + #[cfg(target_os = "macos")] + f.write_char('⌘')?; + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + f.write_char('❖')?; + + #[cfg(target_os = "windows")] + f.write_char('⊞')?; + } + if modifiers.shift { + #[cfg(target_os = "macos")] + f.write_char('⇧')?; + + #[cfg(not(target_os = "macos"))] + write!(f, "shift-")?; + } + Ok(()) +} + +fn display_key(key: &str, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let key = match key { + #[cfg(target_os = "macos")] + "backspace" => '⌫', + #[cfg(target_os = "macos")] + "up" => '↑', + #[cfg(target_os = "macos")] + "down" => '↓', + #[cfg(target_os = "macos")] + "left" => '←', + #[cfg(target_os = "macos")] + "right" => '→', + #[cfg(target_os = "macos")] + "tab" => '⇥', + #[cfg(target_os = "macos")] + "escape" => '⎋', + #[cfg(target_os = "macos")] + "shift" => '⇧', + #[cfg(target_os = "macos")] + "control" => '⌃', + #[cfg(target_os = "macos")] + "alt" => '⌥', + #[cfg(target_os = "macos")] + "platform" => '⌘', + + key if key.len() == 1 => key.chars().next().unwrap().to_ascii_uppercase(), + key => return f.write_str(key), + }; + f.write_char(key) +} + +#[inline] +fn unparse(modifiers: &Modifiers, key: &str) -> String { + let mut result = String::new(); + if modifiers.function { + result.push_str("fn-"); + } + if modifiers.control { + result.push_str("ctrl-"); + } + if modifiers.alt { + result.push_str("alt-"); + } + if modifiers.platform { + #[cfg(target_os = "macos")] + result.push_str("cmd-"); + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + result.push_str("super-"); + + #[cfg(target_os = "windows")] + result.push_str("win-"); + } + if modifiers.shift { + result.push_str("shift-"); + } + result.push_str(&key); + result +} diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index 3fb1ef45729e1e79f339aa19ad11554e7fce3772..8bd89fc399cb8215748467090b973f3f4ee00759 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -25,8 +25,8 @@ use xkbcommon::xkb::{self, Keycode, Keysym, State}; use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, Keymap, LinuxDispatcher, Menu, MenuItem, OwnedMenu, PathPromptOptions, - Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, - Point, Result, Task, WindowAppearance, WindowParams, px, + Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, + PlatformTextSystem, PlatformWindow, Point, Result, Task, WindowAppearance, WindowParams, px, }; #[cfg(any(feature = "wayland", feature = "x11"))] @@ -144,6 +144,10 @@ impl Platform for P { self.keyboard_layout() } + fn keyboard_mapper(&self) -> Rc { + Rc::new(crate::DummyKeyboardMapper) + } + fn on_keyboard_layout_change(&self, callback: Box) { self.with_common(|common| common.callbacks.keyboard_layout_change = Some(callback)); } diff --git a/crates/gpui/src/platform/mac/keyboard.rs b/crates/gpui/src/platform/mac/keyboard.rs index a9f6af3edb584157b72b0df25f6389472410883b..14097312468cbb732b46f004dbb0970c26f6e821 100644 --- a/crates/gpui/src/platform/mac/keyboard.rs +++ b/crates/gpui/src/platform/mac/keyboard.rs @@ -1,8 +1,9 @@ +use collections::HashMap; use std::ffi::{CStr, c_void}; use objc::{msg_send, runtime::Object, sel, sel_impl}; -use crate::PlatformKeyboardLayout; +use crate::{KeybindingKeystroke, Keystroke, PlatformKeyboardLayout, PlatformKeyboardMapper}; use super::{ TISCopyCurrentKeyboardLayoutInputSource, TISGetInputSourceProperty, kTISPropertyInputSourceID, @@ -14,6 +15,10 @@ pub(crate) struct MacKeyboardLayout { name: String, } +pub(crate) struct MacKeyboardMapper { + key_equivalents: Option>, +} + impl PlatformKeyboardLayout for MacKeyboardLayout { fn id(&self) -> &str { &self.id @@ -24,6 +29,27 @@ impl PlatformKeyboardLayout for MacKeyboardLayout { } } +impl PlatformKeyboardMapper for MacKeyboardMapper { + fn map_key_equivalent( + &self, + mut keystroke: Keystroke, + use_key_equivalents: bool, + ) -> KeybindingKeystroke { + if use_key_equivalents && let Some(key_equivalents) = &self.key_equivalents { + if keystroke.key.chars().count() == 1 + && let Some(key) = key_equivalents.get(&keystroke.key.chars().next().unwrap()) + { + keystroke.key = key.to_string(); + } + } + KeybindingKeystroke::from_keystroke(keystroke) + } + + fn get_key_equivalents(&self) -> Option<&HashMap> { + self.key_equivalents.as_ref() + } +} + impl MacKeyboardLayout { pub(crate) fn new() -> Self { unsafe { @@ -47,3 +73,1428 @@ impl MacKeyboardLayout { } } } + +impl MacKeyboardMapper { + pub(crate) fn new(layout_id: &str) -> Self { + let key_equivalents = get_key_equivalents(layout_id); + + Self { key_equivalents } + } +} + +// On some keyboards (e.g. German QWERTZ) it is not possible to type the full ASCII range +// without using option. This means that some of our built in keyboard shortcuts do not work +// for those users. +// +// The way macOS solves this problem is to move shortcuts around so that they are all reachable, +// even if the mnemonic changes. https://developer.apple.com/documentation/swiftui/keyboardshortcut/localization-swift.struct +// +// For example, cmd-> is the "switch window" shortcut because the > key is right above tab. +// To ensure this doesn't cause problems for shortcuts defined for a QWERTY layout, apple moves +// any shortcuts defined as cmd-> to cmd-:. Coincidentally this s also the same keyboard position +// as cmd-> on a QWERTY layout. +// +// Another example is cmd-[ and cmd-], as they cannot be typed without option, those keys are remapped to cmd-ö +// and cmd-ä. These shortcuts are not in the same position as a QWERTY keyboard, because on a QWERTZ keyboard +// the + key is in the way; and shortcuts bound to cmd-+ are still typed as cmd-+ on either keyboard (though the +// specific key moves) +// +// As far as I can tell, there's no way to query the mappings Apple uses except by rendering a menu with every +// possible key combination, and inspecting the UI to see what it rendered. So that's what we did... +// +// These mappings were generated by running https://github.com/ConradIrwin/keyboard-inspector, tidying up the +// output to remove languages with no mappings and other oddities, and converting it to a less verbose representation with: +// jq -s 'map(to_entries | map({key: .key, value: [(.value | to_entries | map(.key) | join("")), (.value | to_entries | map(.value) | join(""))]}) | from_entries) | add' +// From there I used multi-cursor to produce this match statement. +fn get_key_equivalents(layout_id: &str) -> Option> { + let mappings: &[(char, char)] = match layout_id { + "com.apple.keylayout.ABC-AZERTY" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '§'), + ('7', 'è'), + ('8', '!'), + ('9', 'ç'), + (':', '°'), + (';', ')'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '`'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '¨'), + ('|', '£'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.ABC-QWERTZ" => &[ + ('"', '`'), + ('#', '§'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', 'ß'), + (':', 'Ü'), + (';', 'ü'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '´'), + ('\\', '#'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '\''), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.Albanian" => &[ + ('"', '\''), + (':', 'Ç'), + (';', 'ç'), + ('<', ';'), + ('>', ':'), + ('@', '"'), + ('\'', '@'), + ('\\', 'ë'), + ('`', '<'), + ('|', 'Ë'), + ('~', '>'), + ], + "com.apple.keylayout.Austrian" => &[ + ('"', '`'), + ('#', '§'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', 'ß'), + (':', 'Ü'), + (';', 'ü'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '´'), + ('\\', '#'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '\''), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.Azeri" => &[ + ('"', 'Ə'), + (',', 'ç'), + ('.', 'ş'), + ('/', '.'), + (':', 'I'), + (';', 'ı'), + ('<', 'Ç'), + ('>', 'Ş'), + ('?', ','), + ('W', 'Ü'), + ('[', 'ö'), + ('\'', 'ə'), + (']', 'ğ'), + ('w', 'ü'), + ('{', 'Ö'), + ('|', '/'), + ('}', 'Ğ'), + ], + "com.apple.keylayout.Belgian" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '§'), + ('7', 'è'), + ('8', '!'), + ('9', 'ç'), + (':', '°'), + (';', ')'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '`'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '¨'), + ('|', '£'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.Brazilian-ABNT2" => &[ + ('"', '`'), + ('/', 'ç'), + ('?', 'Ç'), + ('\'', '´'), + ('\\', '~'), + ('^', '¨'), + ('`', '\''), + ('|', '^'), + ('~', '"'), + ], + "com.apple.keylayout.Brazilian-Pro" => &[('^', 'ˆ'), ('~', '˜')], + "com.apple.keylayout.British" => &[('#', '£')], + "com.apple.keylayout.Canadian-CSA" => &[ + ('"', 'È'), + ('/', 'é'), + ('<', '\''), + ('>', '"'), + ('?', 'É'), + ('[', '^'), + ('\'', 'è'), + ('\\', 'à'), + (']', 'ç'), + ('`', 'ù'), + ('{', '¨'), + ('|', 'À'), + ('}', 'Ç'), + ('~', 'Ù'), + ], + "com.apple.keylayout.Croatian" => &[ + ('"', 'Ć'), + ('&', '\''), + ('(', ')'), + (')', '='), + ('*', '('), + (':', 'Č'), + (';', 'č'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'š'), + ('\'', 'ć'), + ('\\', 'ž'), + (']', 'đ'), + ('^', '&'), + ('`', '<'), + ('{', 'Š'), + ('|', 'Ž'), + ('}', 'Đ'), + ('~', '>'), + ], + "com.apple.keylayout.Croatian-PC" => &[ + ('"', 'Ć'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'Č'), + (';', 'č'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'š'), + ('\'', 'ć'), + ('\\', 'ž'), + (']', 'đ'), + ('^', '&'), + ('`', '<'), + ('{', 'Š'), + ('|', 'Ž'), + ('}', 'Đ'), + ('~', '>'), + ], + "com.apple.keylayout.Czech" => &[ + ('!', '1'), + ('"', '!'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('+', '%'), + ('/', '\''), + ('0', 'é'), + ('1', '+'), + ('2', 'ě'), + ('3', 'š'), + ('4', 'č'), + ('5', 'ř'), + ('6', 'ž'), + ('7', 'ý'), + ('8', 'á'), + ('9', 'í'), + (':', '"'), + (';', 'ů'), + ('<', '?'), + ('>', ':'), + ('?', 'ˇ'), + ('@', '2'), + ('[', 'ú'), + ('\'', '§'), + (']', ')'), + ('^', '6'), + ('`', '¨'), + ('{', 'Ú'), + ('}', '('), + ('~', '`'), + ], + "com.apple.keylayout.Czech-QWERTY" => &[ + ('!', '1'), + ('"', '!'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('+', '%'), + ('/', '\''), + ('0', 'é'), + ('1', '+'), + ('2', 'ě'), + ('3', 'š'), + ('4', 'č'), + ('5', 'ř'), + ('6', 'ž'), + ('7', 'ý'), + ('8', 'á'), + ('9', 'í'), + (':', '"'), + (';', 'ů'), + ('<', '?'), + ('>', ':'), + ('?', 'ˇ'), + ('@', '2'), + ('[', 'ú'), + ('\'', '§'), + (']', ')'), + ('^', '6'), + ('`', '¨'), + ('{', 'Ú'), + ('}', '('), + ('~', '`'), + ], + "com.apple.keylayout.Danish" => &[ + ('"', '^'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'æ'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ø'), + ('^', '&'), + ('`', '<'), + ('{', 'Æ'), + ('|', '*'), + ('}', 'Ø'), + ('~', '>'), + ], + "com.apple.keylayout.Faroese" => &[ + ('"', 'Ø'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Æ'), + (';', 'æ'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'å'), + ('\'', 'ø'), + ('\\', '\''), + (']', 'ð'), + ('^', '&'), + ('`', '<'), + ('{', 'Å'), + ('|', '*'), + ('}', 'Ð'), + ('~', '>'), + ], + "com.apple.keylayout.Finnish" => &[ + ('"', '^'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.FinnishExtended" => &[ + ('"', 'ˆ'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.FinnishSami-PC" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '@'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.French" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '§'), + ('7', 'è'), + ('8', '!'), + ('9', 'ç'), + (':', '°'), + (';', ')'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '`'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '¨'), + ('|', '£'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.French-PC" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('-', ')'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '-'), + ('7', 'è'), + ('8', '_'), + ('9', 'ç'), + (':', '§'), + (';', '!'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '*'), + (']', '$'), + ('^', '6'), + ('_', '°'), + ('`', '<'), + ('{', '¨'), + ('|', 'μ'), + ('}', '£'), + ('~', '>'), + ], + "com.apple.keylayout.French-numerical" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '§'), + ('7', 'è'), + ('8', '!'), + ('9', 'ç'), + (':', '°'), + (';', ')'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '`'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '¨'), + ('|', '£'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.German" => &[ + ('"', '`'), + ('#', '§'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', 'ß'), + (':', 'Ü'), + (';', 'ü'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '´'), + ('\\', '#'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '\''), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.German-DIN-2137" => &[ + ('"', '`'), + ('#', '§'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', 'ß'), + (':', 'Ü'), + (';', 'ü'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '´'), + ('\\', '#'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '\''), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.Hawaiian" => &[('\'', 'ʻ')], + "com.apple.keylayout.Hungarian" => &[ + ('!', '\''), + ('"', 'Á'), + ('#', '+'), + ('$', '!'), + ('&', '='), + ('(', ')'), + (')', 'Ö'), + ('*', '('), + ('+', 'Ó'), + ('/', 'ü'), + ('0', 'ö'), + (':', 'É'), + (';', 'é'), + ('<', 'Ü'), + ('=', 'ó'), + ('>', ':'), + ('@', '"'), + ('[', 'ő'), + ('\'', 'á'), + ('\\', 'ű'), + (']', 'ú'), + ('^', '/'), + ('`', 'í'), + ('{', 'Ő'), + ('|', 'Ű'), + ('}', 'Ú'), + ('~', 'Í'), + ], + "com.apple.keylayout.Hungarian-QWERTY" => &[ + ('!', '\''), + ('"', 'Á'), + ('#', '+'), + ('$', '!'), + ('&', '='), + ('(', ')'), + (')', 'Ö'), + ('*', '('), + ('+', 'Ó'), + ('/', 'ü'), + ('0', 'ö'), + (':', 'É'), + (';', 'é'), + ('<', 'Ü'), + ('=', 'ó'), + ('>', ':'), + ('@', '"'), + ('[', 'ő'), + ('\'', 'á'), + ('\\', 'ű'), + (']', 'ú'), + ('^', '/'), + ('`', 'í'), + ('{', 'Ő'), + ('|', 'Ű'), + ('}', 'Ú'), + ('~', 'Í'), + ], + "com.apple.keylayout.Icelandic" => &[ + ('"', 'Ö'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'Ð'), + (';', 'ð'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'æ'), + ('\'', 'ö'), + ('\\', 'þ'), + (']', '´'), + ('^', '&'), + ('`', '<'), + ('{', 'Æ'), + ('|', 'Þ'), + ('}', '´'), + ('~', '>'), + ], + "com.apple.keylayout.Irish" => &[('#', '£')], + "com.apple.keylayout.IrishExtended" => &[('#', '£')], + "com.apple.keylayout.Italian" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + (',', ';'), + ('.', ':'), + ('/', ','), + ('0', 'é'), + ('1', '&'), + ('2', '"'), + ('3', '\''), + ('4', '('), + ('5', 'ç'), + ('6', 'è'), + ('7', ')'), + ('8', '£'), + ('9', 'à'), + (':', '!'), + (';', 'ò'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', 'ì'), + ('\'', 'ù'), + ('\\', '§'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '^'), + ('|', '°'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.Italian-Pro" => &[ + ('"', '^'), + ('#', '£'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'é'), + (';', 'è'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ò'), + ('\'', 'ì'), + ('\\', 'ù'), + (']', 'à'), + ('^', '&'), + ('`', '<'), + ('{', 'ç'), + ('|', '§'), + ('}', '°'), + ('~', '>'), + ], + "com.apple.keylayout.LatinAmerican" => &[ + ('"', '¨'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'Ñ'), + (';', 'ñ'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', '{'), + ('\'', '´'), + ('\\', '¿'), + (']', '}'), + ('^', '&'), + ('`', '<'), + ('{', '['), + ('|', '¡'), + ('}', ']'), + ('~', '>'), + ], + "com.apple.keylayout.Lithuanian" => &[ + ('!', 'Ą'), + ('#', 'Ę'), + ('$', 'Ė'), + ('%', 'Į'), + ('&', 'Ų'), + ('*', 'Ū'), + ('+', 'Ž'), + ('1', 'ą'), + ('2', 'č'), + ('3', 'ę'), + ('4', 'ė'), + ('5', 'į'), + ('6', 'š'), + ('7', 'ų'), + ('8', 'ū'), + ('=', 'ž'), + ('@', 'Č'), + ('^', 'Š'), + ], + "com.apple.keylayout.Maltese" => &[ + ('#', '£'), + ('[', 'ġ'), + (']', 'ħ'), + ('`', 'ż'), + ('{', 'Ġ'), + ('}', 'Ħ'), + ('~', 'Ż'), + ], + "com.apple.keylayout.NorthernSami" => &[ + ('"', 'Ŋ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('Q', 'Á'), + ('W', 'Š'), + ('X', 'Č'), + ('[', 'ø'), + ('\'', 'ŋ'), + ('\\', 'đ'), + (']', 'æ'), + ('^', '&'), + ('`', 'ž'), + ('q', 'á'), + ('w', 'š'), + ('x', 'č'), + ('{', 'Ø'), + ('|', 'Đ'), + ('}', 'Æ'), + ('~', 'Ž'), + ], + "com.apple.keylayout.Norwegian" => &[ + ('"', '^'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ø'), + ('\'', '¨'), + ('\\', '@'), + (']', 'æ'), + ('^', '&'), + ('`', '<'), + ('{', 'Ø'), + ('|', '*'), + ('}', 'Æ'), + ('~', '>'), + ], + "com.apple.keylayout.NorwegianExtended" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ø'), + ('\\', '@'), + (']', 'æ'), + ('`', '<'), + ('}', 'Æ'), + ('~', '>'), + ], + "com.apple.keylayout.NorwegianSami-PC" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ø'), + ('\'', '¨'), + ('\\', '@'), + (']', 'æ'), + ('^', '&'), + ('`', '<'), + ('{', 'Ø'), + ('|', '*'), + ('}', 'Æ'), + ('~', '>'), + ], + "com.apple.keylayout.Polish" => &[ + ('!', '§'), + ('"', 'ę'), + ('#', '!'), + ('$', '?'), + ('%', '+'), + ('&', ':'), + ('(', '/'), + (')', '"'), + ('*', '_'), + ('+', ']'), + (',', '.'), + ('.', ','), + ('/', 'ż'), + (':', 'Ł'), + (';', 'ł'), + ('<', 'ś'), + ('=', '['), + ('>', 'ń'), + ('?', 'Ż'), + ('@', '%'), + ('[', 'ó'), + ('\'', 'ą'), + ('\\', ';'), + (']', '('), + ('^', '='), + ('_', 'ć'), + ('`', '<'), + ('{', 'ź'), + ('|', '$'), + ('}', ')'), + ('~', '>'), + ], + "com.apple.keylayout.Portuguese" => &[ + ('"', '`'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'ª'), + (';', 'º'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ç'), + ('\'', '´'), + (']', '~'), + ('^', '&'), + ('`', '<'), + ('{', 'Ç'), + ('}', '^'), + ('~', '>'), + ], + "com.apple.keylayout.Sami-PC" => &[ + ('"', 'Ŋ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('Q', 'Á'), + ('W', 'Š'), + ('X', 'Č'), + ('[', 'ø'), + ('\'', 'ŋ'), + ('\\', 'đ'), + (']', 'æ'), + ('^', '&'), + ('`', 'ž'), + ('q', 'á'), + ('w', 'š'), + ('x', 'č'), + ('{', 'Ø'), + ('|', 'Đ'), + ('}', 'Æ'), + ('~', 'Ž'), + ], + "com.apple.keylayout.Serbian-Latin" => &[ + ('"', 'Ć'), + ('&', '\''), + ('(', ')'), + (')', '='), + ('*', '('), + (':', 'Č'), + (';', 'č'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'š'), + ('\'', 'ć'), + ('\\', 'ž'), + (']', 'đ'), + ('^', '&'), + ('`', '<'), + ('{', 'Š'), + ('|', 'Ž'), + ('}', 'Đ'), + ('~', '>'), + ], + "com.apple.keylayout.Slovak" => &[ + ('!', '1'), + ('"', '!'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('+', '%'), + ('/', '\''), + ('0', 'é'), + ('1', '+'), + ('2', 'ľ'), + ('3', 'š'), + ('4', 'č'), + ('5', 'ť'), + ('6', 'ž'), + ('7', 'ý'), + ('8', 'á'), + ('9', 'í'), + (':', '"'), + (';', 'ô'), + ('<', '?'), + ('>', ':'), + ('?', 'ˇ'), + ('@', '2'), + ('[', 'ú'), + ('\'', '§'), + (']', 'ä'), + ('^', '6'), + ('`', 'ň'), + ('{', 'Ú'), + ('}', 'Ä'), + ('~', 'Ň'), + ], + "com.apple.keylayout.Slovak-QWERTY" => &[ + ('!', '1'), + ('"', '!'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('+', '%'), + ('/', '\''), + ('0', 'é'), + ('1', '+'), + ('2', 'ľ'), + ('3', 'š'), + ('4', 'č'), + ('5', 'ť'), + ('6', 'ž'), + ('7', 'ý'), + ('8', 'á'), + ('9', 'í'), + (':', '"'), + (';', 'ô'), + ('<', '?'), + ('>', ':'), + ('?', 'ˇ'), + ('@', '2'), + ('[', 'ú'), + ('\'', '§'), + (']', 'ä'), + ('^', '6'), + ('`', 'ň'), + ('{', 'Ú'), + ('}', 'Ä'), + ('~', 'Ň'), + ], + "com.apple.keylayout.Slovenian" => &[ + ('"', 'Ć'), + ('&', '\''), + ('(', ')'), + (')', '='), + ('*', '('), + (':', 'Č'), + (';', 'č'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'š'), + ('\'', 'ć'), + ('\\', 'ž'), + (']', 'đ'), + ('^', '&'), + ('`', '<'), + ('{', 'Š'), + ('|', 'Ž'), + ('}', 'Đ'), + ('~', '>'), + ], + "com.apple.keylayout.Spanish" => &[ + ('!', '¡'), + ('"', '¨'), + ('.', 'ç'), + ('/', '.'), + (':', 'º'), + (';', '´'), + ('<', '¿'), + ('>', 'Ç'), + ('@', '!'), + ('[', 'ñ'), + ('\'', '`'), + ('\\', '\''), + (']', ';'), + ('^', '/'), + ('`', '<'), + ('{', 'Ñ'), + ('|', '"'), + ('}', ':'), + ('~', '>'), + ], + "com.apple.keylayout.Spanish-ISO" => &[ + ('"', '¨'), + ('#', '·'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('.', 'ç'), + ('/', '.'), + (':', 'º'), + (';', '´'), + ('<', '¿'), + ('>', 'Ç'), + ('@', '"'), + ('[', 'ñ'), + ('\'', '`'), + ('\\', '\''), + (']', ';'), + ('^', '&'), + ('`', '<'), + ('{', 'Ñ'), + ('|', '"'), + ('}', '`'), + ('~', '>'), + ], + "com.apple.keylayout.Swedish" => &[ + ('"', '^'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.Swedish-Pro" => &[ + ('"', '^'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.SwedishSami-PC" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '@'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.SwissFrench" => &[ + ('!', '+'), + ('"', '`'), + ('#', '*'), + ('$', 'ç'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('+', '!'), + ('/', '\''), + (':', 'ü'), + (';', 'è'), + ('<', ';'), + ('=', '¨'), + ('>', ':'), + ('@', '"'), + ('[', 'é'), + ('\'', '^'), + ('\\', '$'), + (']', 'à'), + ('^', '&'), + ('`', '<'), + ('{', 'ö'), + ('|', '£'), + ('}', 'ä'), + ('~', '>'), + ], + "com.apple.keylayout.SwissGerman" => &[ + ('!', '+'), + ('"', '`'), + ('#', '*'), + ('$', 'ç'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('+', '!'), + ('/', '\''), + (':', 'è'), + (';', 'ü'), + ('<', ';'), + ('=', '¨'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '^'), + ('\\', '$'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'é'), + ('|', '£'), + ('}', 'à'), + ('~', '>'), + ], + "com.apple.keylayout.Turkish" => &[ + ('"', '-'), + ('#', '"'), + ('$', '\''), + ('%', '('), + ('&', ')'), + ('(', '%'), + (')', ':'), + ('*', '_'), + (',', 'ö'), + ('-', 'ş'), + ('.', 'ç'), + ('/', '.'), + (':', '$'), + ('<', 'Ö'), + ('>', 'Ç'), + ('@', '*'), + ('[', 'ğ'), + ('\'', ','), + ('\\', 'ü'), + (']', 'ı'), + ('^', '/'), + ('_', 'Ş'), + ('`', '<'), + ('{', 'Ğ'), + ('|', 'Ü'), + ('}', 'I'), + ('~', '>'), + ], + "com.apple.keylayout.Turkish-QWERTY-PC" => &[ + ('"', 'I'), + ('#', '^'), + ('$', '+'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('+', ':'), + (',', 'ö'), + ('.', 'ç'), + ('/', '*'), + (':', 'Ş'), + (';', 'ş'), + ('<', 'Ö'), + ('=', '.'), + ('>', 'Ç'), + ('@', '\''), + ('[', 'ğ'), + ('\'', 'ı'), + ('\\', ','), + (']', 'ü'), + ('^', '&'), + ('`', '<'), + ('{', 'Ğ'), + ('|', ';'), + ('}', 'Ü'), + ('~', '>'), + ], + "com.apple.keylayout.Turkish-Standard" => &[ + ('"', 'Ş'), + ('#', '^'), + ('&', '\''), + ('(', ')'), + (')', '='), + ('*', '('), + (',', '.'), + ('.', ','), + (':', 'Ç'), + (';', 'ç'), + ('<', ':'), + ('=', '*'), + ('>', ';'), + ('@', '"'), + ('[', 'ğ'), + ('\'', 'ş'), + ('\\', 'ü'), + (']', 'ı'), + ('^', '&'), + ('`', 'ö'), + ('{', 'Ğ'), + ('|', 'Ü'), + ('}', 'I'), + ('~', 'Ö'), + ], + "com.apple.keylayout.Turkmen" => &[ + ('C', 'Ç'), + ('Q', 'Ä'), + ('V', 'Ý'), + ('X', 'Ü'), + ('[', 'ň'), + ('\\', 'ş'), + (']', 'ö'), + ('^', '№'), + ('`', 'ž'), + ('c', 'ç'), + ('q', 'ä'), + ('v', 'ý'), + ('x', 'ü'), + ('{', 'Ň'), + ('|', 'Ş'), + ('}', 'Ö'), + ('~', 'Ž'), + ], + "com.apple.keylayout.USInternational-PC" => &[('^', 'ˆ'), ('~', '˜')], + "com.apple.keylayout.Welsh" => &[('#', '£')], + + _ => return None, + }; + + Some(HashMap::from_iter(mappings.iter().cloned())) +} diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 832550dc46281c56749aa8f6bc4d59a041c9a00d..dea04d89a06acac526a8b033681829fdc1e148fd 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -1,5 +1,5 @@ use super::{ - BoolExt, MacKeyboardLayout, + BoolExt, MacKeyboardLayout, MacKeyboardMapper, attributed_string::{NSAttributedString, NSMutableAttributedString}, events::key_to_native, renderer, @@ -8,8 +8,9 @@ use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, Result, - SemanticVersion, SystemMenuType, Task, WindowAppearance, WindowParams, hash, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PlatformWindow, Result, SemanticVersion, SystemMenuType, Task, WindowAppearance, WindowParams, + hash, }; use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; @@ -171,6 +172,7 @@ pub(crate) struct MacPlatformState { finish_launching: Option>, dock_menu: Option, menus: Option>, + keyboard_mapper: Rc, } impl Default for MacPlatform { @@ -189,6 +191,9 @@ impl MacPlatform { #[cfg(not(feature = "font-kit"))] let text_system = Arc::new(crate::NoopTextSystem::new()); + let keyboard_layout = MacKeyboardLayout::new(); + let keyboard_mapper = Rc::new(MacKeyboardMapper::new(keyboard_layout.id())); + Self(Mutex::new(MacPlatformState { headless, text_system, @@ -209,6 +214,7 @@ impl MacPlatform { dock_menu: None, on_keyboard_layout_change: None, menus: None, + keyboard_mapper, })) } @@ -348,19 +354,19 @@ impl MacPlatform { let mut mask = NSEventModifierFlags::empty(); for (modifier, flag) in &[ ( - keystroke.modifiers.platform, + keystroke.modifiers().platform, NSEventModifierFlags::NSCommandKeyMask, ), ( - keystroke.modifiers.control, + keystroke.modifiers().control, NSEventModifierFlags::NSControlKeyMask, ), ( - keystroke.modifiers.alt, + keystroke.modifiers().alt, NSEventModifierFlags::NSAlternateKeyMask, ), ( - keystroke.modifiers.shift, + keystroke.modifiers().shift, NSEventModifierFlags::NSShiftKeyMask, ), ] { @@ -373,7 +379,7 @@ impl MacPlatform { .initWithTitle_action_keyEquivalent_( ns_string(name), selector, - ns_string(key_to_native(&keystroke.key).as_ref()), + ns_string(key_to_native(keystroke.key()).as_ref()), ) .autorelease(); if Self::os_version() >= SemanticVersion::new(12, 0, 0) { @@ -882,6 +888,10 @@ impl Platform for MacPlatform { Box::new(MacKeyboardLayout::new()) } + fn keyboard_mapper(&self) -> Rc { + self.0.lock().keyboard_mapper.clone() + } + fn app_path(&self) -> Result { unsafe { let bundle: id = NSBundle::mainBundle(); @@ -1393,6 +1403,8 @@ extern "C" fn will_terminate(this: &mut Object, _: Sel, _: id) { extern "C" fn on_keyboard_layout_change(this: &mut Object, _: Sel, _: id) { let platform = unsafe { get_mac_platform(this) }; let mut lock = platform.0.lock(); + let keyboard_layout = MacKeyboardLayout::new(); + lock.keyboard_mapper = Rc::new(MacKeyboardMapper::new(keyboard_layout.id())); if let Some(mut callback) = lock.on_keyboard_layout_change.take() { drop(lock); callback(); diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 4425d4fe24c91a0bcf840b59de139ecf4f8187b0..0262cbb1213ca670cece780959c740f292764630 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -4,8 +4,10 @@ use crate::{ ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, - ScaledPixels, Size, Timer, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowKind, WindowParams, platform::PlatformInputHandler, point, px, size, + ScaledPixels, SharedString, Size, SystemWindowTab, Timer, WindowAppearance, + WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowParams, + dispatch_get_main_queue, dispatch_sys::dispatch_async_f, platform::PlatformInputHandler, point, + px, size, }; use block::ConcreteBlock; use cocoa::{ @@ -24,6 +26,7 @@ use cocoa::{ NSUserDefaults, }, }; + use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect}; use ctor::ctor; use futures::channel::oneshot; @@ -82,6 +85,12 @@ type NSDragOperation = NSUInteger; const NSDragOperationNone: NSDragOperation = 0; #[allow(non_upper_case_globals)] const NSDragOperationCopy: NSDragOperation = 1; +#[derive(PartialEq)] +pub enum UserTabbingPreference { + Never, + Always, + InFullScreen, +} #[link(name = "CoreGraphics", kind = "framework")] unsafe extern "C" { @@ -343,6 +352,36 @@ unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const C conclude_drag_operation as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(addTitlebarAccessoryViewController:), + add_titlebar_accessory_view_controller as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(moveTabToNewWindow:), + move_tab_to_new_window as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(mergeAllWindows:), + merge_all_windows as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(selectNextTab:), + select_next_tab as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(selectPreviousTab:), + select_previous_tab as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(toggleTabBar:), + toggle_tab_bar as extern "C" fn(&Object, Sel, id), + ); + decl.register() } } @@ -375,6 +414,11 @@ struct MacWindowState { // Whether the next left-mouse click is also the focusing click. first_mouse: bool, fullscreen_restore_bounds: Bounds, + move_tab_to_new_window_callback: Option>, + merge_all_windows_callback: Option>, + select_next_tab_callback: Option>, + select_previous_tab_callback: Option>, + toggle_tab_bar_callback: Option>, } impl MacWindowState { @@ -530,10 +574,13 @@ impl MacWindow { titlebar, kind, is_movable, + is_resizable, + is_minimizable, focus, show, display_id, window_min_size, + tabbing_identifier, }: WindowParams, executor: ForegroundExecutor, renderer_context: renderer::Context, @@ -541,14 +588,25 @@ impl MacWindow { unsafe { let pool = NSAutoreleasePool::new(nil); - let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: NO]; + let allows_automatic_window_tabbing = tabbing_identifier.is_some(); + if allows_automatic_window_tabbing { + let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: YES]; + } else { + let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: NO]; + } let mut style_mask; if let Some(titlebar) = titlebar.as_ref() { - style_mask = NSWindowStyleMask::NSClosableWindowMask - | NSWindowStyleMask::NSMiniaturizableWindowMask - | NSWindowStyleMask::NSResizableWindowMask - | NSWindowStyleMask::NSTitledWindowMask; + style_mask = + NSWindowStyleMask::NSClosableWindowMask | NSWindowStyleMask::NSTitledWindowMask; + + if is_resizable { + style_mask |= NSWindowStyleMask::NSResizableWindowMask; + } + + if is_minimizable { + style_mask |= NSWindowStyleMask::NSMiniaturizableWindowMask; + } if titlebar.appears_transparent { style_mask |= NSWindowStyleMask::NSFullSizeContentViewWindowMask; @@ -660,6 +718,11 @@ impl MacWindow { external_files_dragged: false, first_mouse: false, fullscreen_restore_bounds: Bounds::default(), + move_tab_to_new_window_callback: None, + merge_all_windows_callback: None, + select_next_tab_callback: None, + select_previous_tab_callback: None, + toggle_tab_bar_callback: None, }))); (*native_window).set_ivar( @@ -714,6 +777,11 @@ impl MacWindow { WindowKind::Normal => { native_window.setLevel_(NSNormalWindowLevel); native_window.setAcceptsMouseMovedEvents_(YES); + + if let Some(tabbing_identifier) = tabbing_identifier { + let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; + } } WindowKind::PopUp => { // Use a tracking area to allow receiving MouseMoved events even when @@ -742,6 +810,38 @@ impl MacWindow { } } + let app = NSApplication::sharedApplication(nil); + let main_window: id = msg_send![app, mainWindow]; + if allows_automatic_window_tabbing + && !main_window.is_null() + && main_window != native_window + { + let main_window_is_fullscreen = main_window + .styleMask() + .contains(NSWindowStyleMask::NSFullScreenWindowMask); + let user_tabbing_preference = Self::get_user_tabbing_preference() + .unwrap_or(UserTabbingPreference::InFullScreen); + let should_add_as_tab = user_tabbing_preference == UserTabbingPreference::Always + || user_tabbing_preference == UserTabbingPreference::InFullScreen + && main_window_is_fullscreen; + + if should_add_as_tab { + let main_window_can_tab: BOOL = + msg_send![main_window, respondsToSelector: sel!(addTabbedWindow:ordered:)]; + let main_window_visible: BOOL = msg_send![main_window, isVisible]; + + if main_window_can_tab == YES && main_window_visible == YES { + let _: () = msg_send![main_window, addTabbedWindow: native_window ordered: NSWindowOrderingMode::NSWindowAbove]; + + // Ensure the window is visible immediately after adding the tab, since the tab bar is updated with a new entry at this point. + // Note: Calling orderFront here can break fullscreen mode (makes fullscreen windows exit fullscreen), so only do this if the main window is not fullscreen. + if !main_window_is_fullscreen { + let _: () = msg_send![native_window, orderFront: nil]; + } + } + } + } + if focus && show { native_window.makeKeyAndOrderFront_(nil); } else if show { @@ -796,6 +896,33 @@ impl MacWindow { window_handles } } + + pub fn get_user_tabbing_preference() -> Option { + unsafe { + let defaults: id = NSUserDefaults::standardUserDefaults(); + let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); + let key = NSString::alloc(nil).init_str("AppleWindowTabbingMode"); + + let dict: id = msg_send![defaults, persistentDomainForName: domain]; + let value: id = if !dict.is_null() { + msg_send![dict, objectForKey: key] + } else { + nil + }; + + let value_str = if !value.is_null() { + CStr::from_ptr(NSString::UTF8String(value)).to_string_lossy() + } else { + "".into() + }; + + match value_str.as_ref() { + "manual" => Some(UserTabbingPreference::Never), + "always" => Some(UserTabbingPreference::Always), + _ => Some(UserTabbingPreference::InFullScreen), + } + } + } } impl Drop for MacWindow { @@ -851,6 +978,46 @@ impl PlatformWindow for MacWindow { .detach(); } + fn merge_all_windows(&self) { + let native_window = self.0.lock().native_window; + unsafe extern "C" fn merge_windows_async(context: *mut std::ffi::c_void) { + let native_window = context as id; + let _: () = msg_send![native_window, mergeAllWindows:nil]; + } + + unsafe { + dispatch_async_f( + dispatch_get_main_queue(), + native_window as *mut std::ffi::c_void, + Some(merge_windows_async), + ); + } + } + + fn move_tab_to_new_window(&self) { + let native_window = self.0.lock().native_window; + unsafe extern "C" fn move_tab_async(context: *mut std::ffi::c_void) { + let native_window = context as id; + let _: () = msg_send![native_window, moveTabToNewWindow:nil]; + let _: () = msg_send![native_window, makeKeyAndOrderFront: nil]; + } + + unsafe { + dispatch_async_f( + dispatch_get_main_queue(), + native_window as *mut std::ffi::c_void, + Some(move_tab_async), + ); + } + } + + fn toggle_window_tab_overview(&self) { + let native_window = self.0.lock().native_window; + unsafe { + let _: () = msg_send![native_window, toggleTabOverview:nil]; + } + } + fn scale_factor(&self) -> f32 { self.0.as_ref().lock().scale_factor() } @@ -1051,6 +1218,17 @@ impl PlatformWindow for MacWindow { } } + fn get_title(&self) -> String { + unsafe { + let title: id = msg_send![self.0.lock().native_window, title]; + if title.is_null() { + "".to_string() + } else { + title.to_str().to_string() + } + } + } + fn set_app_id(&mut self, _app_id: &str) {} fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) { @@ -1212,6 +1390,62 @@ impl PlatformWindow for MacWindow { self.0.lock().appearance_changed_callback = Some(callback); } + fn tabbed_windows(&self) -> Option> { + unsafe { + let windows: id = msg_send![self.0.lock().native_window, tabbedWindows]; + if windows.is_null() { + return None; + } + + let count: NSUInteger = msg_send![windows, count]; + let mut result = Vec::new(); + for i in 0..count { + let window: id = msg_send![windows, objectAtIndex:i]; + if msg_send![window, isKindOfClass: WINDOW_CLASS] { + let handle = get_window_state(&*window).lock().handle; + let title: id = msg_send![window, title]; + let title = SharedString::from(title.to_str().to_string()); + + result.push(SystemWindowTab::new(title, handle)); + } + } + + Some(result) + } + } + + fn tab_bar_visible(&self) -> bool { + unsafe { + let tab_group: id = msg_send![self.0.lock().native_window, tabGroup]; + if tab_group.is_null() { + false + } else { + let tab_bar_visible: BOOL = msg_send![tab_group, isTabBarVisible]; + tab_bar_visible == YES + } + } + } + + fn on_move_tab_to_new_window(&self, callback: Box) { + self.0.as_ref().lock().move_tab_to_new_window_callback = Some(callback); + } + + fn on_merge_all_windows(&self, callback: Box) { + self.0.as_ref().lock().merge_all_windows_callback = Some(callback); + } + + fn on_select_next_tab(&self, callback: Box) { + self.0.as_ref().lock().select_next_tab_callback = Some(callback); + } + + fn on_select_previous_tab(&self, callback: Box) { + self.0.as_ref().lock().select_previous_tab_callback = Some(callback); + } + + fn on_toggle_tab_bar(&self, callback: Box) { + self.0.as_ref().lock().toggle_tab_bar_callback = Some(callback); + } + fn draw(&self, scene: &crate::Scene) { let mut this = self.0.lock(); this.renderer.draw(scene); @@ -1653,6 +1887,7 @@ extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) { .occlusionState() .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible) { + lock.move_traffic_light(); lock.start_display_link(); } else { lock.stop_display_link(); @@ -1714,7 +1949,7 @@ extern "C" fn window_did_change_screen(this: &Object, _: Sel, _: id) { extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; - let lock = window_state.lock(); + let mut lock = window_state.lock(); let is_active = unsafe { lock.native_window.isKeyWindow() == YES }; // When opening a pop-up while the application isn't active, Cocoa sends a spurious @@ -1735,9 +1970,34 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let executor = lock.executor.clone(); drop(lock); + + // If window is becoming active, trigger immediate synchronous frame request. + if selector == sel!(windowDidBecomeKey:) && is_active { + let window_state = unsafe { get_window_state(this) }; + let mut lock = window_state.lock(); + + if let Some(mut callback) = lock.request_frame_callback.take() { + #[cfg(not(feature = "macos-blade"))] + lock.renderer.set_presents_with_transaction(true); + lock.stop_display_link(); + drop(lock); + callback(Default::default()); + + let mut lock = window_state.lock(); + lock.request_frame_callback = Some(callback); + #[cfg(not(feature = "macos-blade"))] + lock.renderer.set_presents_with_transaction(false); + lock.start_display_link(); + } + } + executor .spawn(async move { let mut lock = window_state.as_ref().lock(); + if is_active { + lock.move_traffic_light(); + } + if let Some(mut callback) = lock.activate_callback.take() { drop(lock); callback(is_active); @@ -2273,3 +2533,80 @@ unsafe fn remove_layer_background(layer: id) { } } } + +extern "C" fn add_titlebar_accessory_view_controller(this: &Object, _: Sel, view_controller: id) { + unsafe { + let _: () = msg_send![super(this, class!(NSWindow)), addTitlebarAccessoryViewController: view_controller]; + + // Hide the native tab bar and set its height to 0, since we render our own. + let accessory_view: id = msg_send![view_controller, view]; + let _: () = msg_send![accessory_view, setHidden: YES]; + let mut frame: NSRect = msg_send![accessory_view, frame]; + frame.size.height = 0.0; + let _: () = msg_send![accessory_view, setFrame: frame]; + } +} + +extern "C" fn move_tab_to_new_window(this: &Object, _: Sel, _: id) { + unsafe { + let _: () = msg_send![super(this, class!(NSWindow)), moveTabToNewWindow:nil]; + + let window_state = get_window_state(this); + let mut lock = window_state.as_ref().lock(); + if let Some(mut callback) = lock.move_tab_to_new_window_callback.take() { + drop(lock); + callback(); + window_state.lock().move_tab_to_new_window_callback = Some(callback); + } + } +} + +extern "C" fn merge_all_windows(this: &Object, _: Sel, _: id) { + unsafe { + let _: () = msg_send![super(this, class!(NSWindow)), mergeAllWindows:nil]; + + let window_state = get_window_state(this); + let mut lock = window_state.as_ref().lock(); + if let Some(mut callback) = lock.merge_all_windows_callback.take() { + drop(lock); + callback(); + window_state.lock().merge_all_windows_callback = Some(callback); + } + } +} + +extern "C" fn select_next_tab(this: &Object, _sel: Sel, _id: id) { + let window_state = unsafe { get_window_state(this) }; + let mut lock = window_state.as_ref().lock(); + if let Some(mut callback) = lock.select_next_tab_callback.take() { + drop(lock); + callback(); + window_state.lock().select_next_tab_callback = Some(callback); + } +} + +extern "C" fn select_previous_tab(this: &Object, _sel: Sel, _id: id) { + let window_state = unsafe { get_window_state(this) }; + let mut lock = window_state.as_ref().lock(); + if let Some(mut callback) = lock.select_previous_tab_callback.take() { + drop(lock); + callback(); + window_state.lock().select_previous_tab_callback = Some(callback); + } +} + +extern "C" fn toggle_tab_bar(this: &Object, _sel: Sel, _id: id) { + unsafe { + let _: () = msg_send![super(this, class!(NSWindow)), toggleTabBar:nil]; + + let window_state = get_window_state(this); + let mut lock = window_state.as_ref().lock(); + lock.move_traffic_light(); + + if let Some(mut callback) = lock.toggle_tab_bar_callback.take() { + drop(lock); + callback(); + window_state.lock().toggle_tab_bar_callback = Some(callback); + } + } +} diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 00afcd81b599cc53f12005062e4b87abd9c30e38..15b909199fbd53b974e6a140f3223641dc0ac6ae 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,8 +1,9 @@ use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, PlatformKeyboardLayout, - PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, - SourceMetadata, Task, TestDisplay, TestWindow, WindowAppearance, WindowParams, size, + DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, + PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton, + ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, Task, + TestDisplay, TestWindow, WindowAppearance, WindowParams, size, }; use anyhow::Result; use collections::VecDeque; @@ -237,6 +238,10 @@ impl Platform for TestPlatform { Box::new(TestKeyboardLayout) } + fn keyboard_mapper(&self) -> Rc { + Rc::new(DummyKeyboardMapper) + } + fn on_keyboard_layout_change(&self, _: Box) {} fn run(&self, _on_finish_launching: Box) { diff --git a/crates/gpui/src/platform/windows/keyboard.rs b/crates/gpui/src/platform/windows/keyboard.rs index 371feb70c25ab593ce612c7a90381a4cffdeff7d..259ebaebff794d4ed7203420c8c66188998c5fa4 100644 --- a/crates/gpui/src/platform/windows/keyboard.rs +++ b/crates/gpui/src/platform/windows/keyboard.rs @@ -1,22 +1,31 @@ use anyhow::Result; +use collections::HashMap; use windows::Win32::UI::{ Input::KeyboardAndMouse::{ - GetKeyboardLayoutNameW, MAPVK_VK_TO_CHAR, MapVirtualKeyW, ToUnicode, VIRTUAL_KEY, VK_0, - VK_1, VK_2, VK_3, VK_4, VK_5, VK_6, VK_7, VK_8, VK_9, VK_ABNT_C1, VK_CONTROL, VK_MENU, - VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7, VK_OEM_8, VK_OEM_102, - VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_SHIFT, + GetKeyboardLayoutNameW, MAPVK_VK_TO_CHAR, MAPVK_VK_TO_VSC, MapVirtualKeyW, ToUnicode, + VIRTUAL_KEY, VK_0, VK_1, VK_2, VK_3, VK_4, VK_5, VK_6, VK_7, VK_8, VK_9, VK_ABNT_C1, + VK_CONTROL, VK_MENU, VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7, + VK_OEM_8, VK_OEM_102, VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_SHIFT, }, WindowsAndMessaging::KL_NAMELENGTH, }; use windows_core::HSTRING; -use crate::{Modifiers, PlatformKeyboardLayout}; +use crate::{ + KeybindingKeystroke, Keystroke, Modifiers, PlatformKeyboardLayout, PlatformKeyboardMapper, +}; pub(crate) struct WindowsKeyboardLayout { id: String, name: String, } +pub(crate) struct WindowsKeyboardMapper { + key_to_vkey: HashMap, + vkey_to_key: HashMap, + vkey_to_shifted: HashMap, +} + impl PlatformKeyboardLayout for WindowsKeyboardLayout { fn id(&self) -> &str { &self.id @@ -27,6 +36,61 @@ impl PlatformKeyboardLayout for WindowsKeyboardLayout { } } +impl PlatformKeyboardMapper for WindowsKeyboardMapper { + fn map_key_equivalent( + &self, + mut keystroke: Keystroke, + use_key_equivalents: bool, + ) -> KeybindingKeystroke { + let Some((vkey, shifted_key)) = self.get_vkey_from_key(&keystroke.key, use_key_equivalents) + else { + return KeybindingKeystroke::from_keystroke(keystroke); + }; + if shifted_key && keystroke.modifiers.shift { + log::warn!( + "Keystroke '{}' has both shift and a shifted key, this is likely a bug", + keystroke.key + ); + } + + let shift = shifted_key || keystroke.modifiers.shift; + keystroke.modifiers.shift = false; + + let Some(key) = self.vkey_to_key.get(&vkey).cloned() else { + log::error!( + "Failed to map key equivalent '{:?}' to a valid key", + keystroke + ); + return KeybindingKeystroke::from_keystroke(keystroke); + }; + + keystroke.key = if shift { + let Some(shifted_key) = self.vkey_to_shifted.get(&vkey).cloned() else { + log::error!( + "Failed to map keystroke {:?} with virtual key '{:?}' to a shifted key", + keystroke, + vkey + ); + return KeybindingKeystroke::from_keystroke(keystroke); + }; + shifted_key + } else { + key.clone() + }; + + let modifiers = Modifiers { + shift, + ..keystroke.modifiers + }; + + KeybindingKeystroke::new(keystroke, modifiers, key) + } + + fn get_key_equivalents(&self) -> Option<&HashMap> { + None + } +} + impl WindowsKeyboardLayout { pub(crate) fn new() -> Result { let mut buffer = [0u16; KL_NAMELENGTH as usize]; @@ -48,6 +112,41 @@ impl WindowsKeyboardLayout { } } +impl WindowsKeyboardMapper { + pub(crate) fn new() -> Self { + let mut key_to_vkey = HashMap::default(); + let mut vkey_to_key = HashMap::default(); + let mut vkey_to_shifted = HashMap::default(); + for vkey in CANDIDATE_VKEYS { + if let Some(key) = get_key_from_vkey(*vkey) { + key_to_vkey.insert(key.clone(), (vkey.0, false)); + vkey_to_key.insert(vkey.0, key); + } + let scan_code = unsafe { MapVirtualKeyW(vkey.0 as u32, MAPVK_VK_TO_VSC) }; + if scan_code == 0 { + continue; + } + if let Some(shifted_key) = get_shifted_key(*vkey, scan_code) { + key_to_vkey.insert(shifted_key.clone(), (vkey.0, true)); + vkey_to_shifted.insert(vkey.0, shifted_key); + } + } + Self { + key_to_vkey, + vkey_to_key, + vkey_to_shifted, + } + } + + fn get_vkey_from_key(&self, key: &str, use_key_equivalents: bool) -> Option<(u16, bool)> { + if use_key_equivalents { + get_vkey_from_key_with_us_layout(key) + } else { + self.key_to_vkey.get(key).cloned() + } + } +} + pub(crate) fn get_keystroke_key( vkey: VIRTUAL_KEY, scan_code: u32, @@ -140,3 +239,134 @@ pub(crate) fn generate_key_char( _ => None, } } + +fn get_vkey_from_key_with_us_layout(key: &str) -> Option<(u16, bool)> { + match key { + // ` => VK_OEM_3 + "`" => Some((VK_OEM_3.0, false)), + "~" => Some((VK_OEM_3.0, true)), + "1" => Some((VK_1.0, false)), + "!" => Some((VK_1.0, true)), + "2" => Some((VK_2.0, false)), + "@" => Some((VK_2.0, true)), + "3" => Some((VK_3.0, false)), + "#" => Some((VK_3.0, true)), + "4" => Some((VK_4.0, false)), + "$" => Some((VK_4.0, true)), + "5" => Some((VK_5.0, false)), + "%" => Some((VK_5.0, true)), + "6" => Some((VK_6.0, false)), + "^" => Some((VK_6.0, true)), + "7" => Some((VK_7.0, false)), + "&" => Some((VK_7.0, true)), + "8" => Some((VK_8.0, false)), + "*" => Some((VK_8.0, true)), + "9" => Some((VK_9.0, false)), + "(" => Some((VK_9.0, true)), + "0" => Some((VK_0.0, false)), + ")" => Some((VK_0.0, true)), + "-" => Some((VK_OEM_MINUS.0, false)), + "_" => Some((VK_OEM_MINUS.0, true)), + "=" => Some((VK_OEM_PLUS.0, false)), + "+" => Some((VK_OEM_PLUS.0, true)), + "[" => Some((VK_OEM_4.0, false)), + "{" => Some((VK_OEM_4.0, true)), + "]" => Some((VK_OEM_6.0, false)), + "}" => Some((VK_OEM_6.0, true)), + "\\" => Some((VK_OEM_5.0, false)), + "|" => Some((VK_OEM_5.0, true)), + ";" => Some((VK_OEM_1.0, false)), + ":" => Some((VK_OEM_1.0, true)), + "'" => Some((VK_OEM_7.0, false)), + "\"" => Some((VK_OEM_7.0, true)), + "," => Some((VK_OEM_COMMA.0, false)), + "<" => Some((VK_OEM_COMMA.0, true)), + "." => Some((VK_OEM_PERIOD.0, false)), + ">" => Some((VK_OEM_PERIOD.0, true)), + "/" => Some((VK_OEM_2.0, false)), + "?" => Some((VK_OEM_2.0, true)), + _ => None, + } +} + +const CANDIDATE_VKEYS: &[VIRTUAL_KEY] = &[ + VK_OEM_3, + VK_OEM_MINUS, + VK_OEM_PLUS, + VK_OEM_4, + VK_OEM_5, + VK_OEM_6, + VK_OEM_1, + VK_OEM_7, + VK_OEM_COMMA, + VK_OEM_PERIOD, + VK_OEM_2, + VK_OEM_102, + VK_OEM_8, + VK_ABNT_C1, + VK_0, + VK_1, + VK_2, + VK_3, + VK_4, + VK_5, + VK_6, + VK_7, + VK_8, + VK_9, +]; + +#[cfg(test)] +mod tests { + use crate::{Keystroke, Modifiers, PlatformKeyboardMapper, WindowsKeyboardMapper}; + + #[test] + fn test_keyboard_mapper() { + let mapper = WindowsKeyboardMapper::new(); + + // Normal case + let keystroke = Keystroke { + modifiers: Modifiers::control(), + key: "a".to_string(), + key_char: None, + }; + let mapped = mapper.map_key_equivalent(keystroke.clone(), true); + assert_eq!(*mapped.inner(), keystroke); + assert_eq!(mapped.key(), "a"); + assert_eq!(*mapped.modifiers(), Modifiers::control()); + + // Shifted case, ctrl-$ + let keystroke = Keystroke { + modifiers: Modifiers::control(), + key: "$".to_string(), + key_char: None, + }; + let mapped = mapper.map_key_equivalent(keystroke.clone(), true); + assert_eq!(*mapped.inner(), keystroke); + assert_eq!(mapped.key(), "4"); + assert_eq!(*mapped.modifiers(), Modifiers::control_shift()); + + // Shifted case, but shift is true + let keystroke = Keystroke { + modifiers: Modifiers::control_shift(), + key: "$".to_string(), + key_char: None, + }; + let mapped = mapper.map_key_equivalent(keystroke, true); + assert_eq!(mapped.inner().modifiers, Modifiers::control()); + assert_eq!(mapped.key(), "4"); + assert_eq!(*mapped.modifiers(), Modifiers::control_shift()); + + // Windows style + let keystroke = Keystroke { + modifiers: Modifiers::control_shift(), + key: "4".to_string(), + key_char: None, + }; + let mapped = mapper.map_key_equivalent(keystroke, true); + assert_eq!(mapped.inner().modifiers, Modifiers::control()); + assert_eq!(mapped.inner().key, "$"); + assert_eq!(mapped.key(), "4"); + assert_eq!(*mapped.modifiers(), Modifiers::control_shift()); + } +} diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index 6202e05fb3b26f10ba8fdf365a185dccbc6ae2ed..3a6ccff90f06156345a71482fe723c76d4c2ca39 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -351,6 +351,10 @@ impl Platform for WindowsPlatform { ) } + fn keyboard_mapper(&self) -> Rc { + Rc::new(WindowsKeyboardMapper::new()) + } + fn on_keyboard_layout_change(&self, callback: Box) { self.state.borrow_mut().callbacks.keyboard_layout_change = Some(callback); } @@ -847,7 +851,7 @@ fn file_save_dialog( if !directory.to_string_lossy().is_empty() && let Some(full_path) = directory.canonicalize().log_err() { - let full_path = SanitizedPath::from(full_path); + let full_path = SanitizedPath::new(&full_path); let full_path_string = full_path.to_string(); let path_item: IShellItem = unsafe { SHCreateItemFromParsingName(&HSTRING::from(full_path_string), None)? }; diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index 99e50733714ba3e280508585f479a90e7edd76d7..e3711d1a26b04a5fccbff3530c240f7a5fadd7ad 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -382,10 +382,17 @@ impl WindowsWindow { let (mut dwexstyle, dwstyle) = if params.kind == WindowKind::PopUp { (WS_EX_TOOLWINDOW, WINDOW_STYLE(0x0)) } else { - ( - WS_EX_APPWINDOW, - WS_THICKFRAME | WS_SYSMENU | WS_MAXIMIZEBOX | WS_MINIMIZEBOX, - ) + let mut dwstyle = WS_SYSMENU; + + if params.is_resizable { + dwstyle |= WS_THICKFRAME | WS_MAXIMIZEBOX; + } + + if params.is_minimizable { + dwstyle |= WS_MINIMIZEBOX; + } + + (WS_EX_APPWINDOW, dwstyle) }; if !disable_direct_composition { dwexstyle |= WS_EX_NOREDIRECTIONBITMAP; diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 93ec6c854c31d3f312006f61d6994a4eee4b88ef..d499d78551a5e0e268b575496bbdac5ddf59369c 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -181,7 +181,7 @@ impl LineWrapper { matches!(c, '\u{0400}'..='\u{04FF}') || // Some other known special characters that should be treated as word characters, // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, `2^3`, `a~b`, etc. - matches!(c, '-' | '_' | '.' | '\'' | '$' | '%' | '@' | '#' | '^' | '~' | ',') || + matches!(c, '-' | '_' | '.' | '\'' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '!' | ';' | '*') || // Characters that used in URL, e.g. `https://github.com/zed-industries/zed?a=1&b=2` for better wrapping a long URL. matches!(c, '/' | ':' | '?' | '&' | '=') || // `⋯` character is special used in Zed, to keep this at the end of the line. diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 0791dcc621a8a71ef350ad32f8cf9ab87fad8db2..4504f512551b678b9304a4c180f54b15c34af956 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -12,11 +12,11 @@ use crate::{ PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS, ScaledPixels, Scene, Shadow, SharedString, Size, - StrikethroughStyle, Style, SubscriberSet, Subscription, TabHandles, TaffyLayoutEngine, Task, - TextStyle, TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, - WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, - transparent_black, + StrikethroughStyle, Style, SubscriberSet, Subscription, SystemWindowTab, + SystemWindowTabController, TabHandles, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement, + TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, + WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, + point, prelude::*, px, rems, size, transparent_black, }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; @@ -939,11 +939,15 @@ impl Window { show, kind, is_movable, + is_resizable, + is_minimizable, display_id, window_background, app_id, window_min_size, window_decorations, + #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] + tabbing_identifier, } = options; let bounds = window_bounds @@ -956,12 +960,23 @@ impl Window { titlebar, kind, is_movable, + is_resizable, + is_minimizable, focus, show, display_id, window_min_size, + #[cfg(target_os = "macos")] + tabbing_identifier, }, )?; + + let tab_bar_visible = platform_window.tab_bar_visible(); + SystemWindowTabController::init_visible(cx, tab_bar_visible); + if let Some(tabs) = platform_window.tabbed_windows() { + SystemWindowTabController::add_tab(cx, handle.window_id(), tabs); + } + let display_id = platform_window.display().map(|display| display.id()); let sprite_atlas = platform_window.sprite_atlas(); let mouse_position = platform_window.mouse_position(); @@ -991,9 +1006,13 @@ impl Window { } platform_window.on_close(Box::new({ + let window_id = handle.window_id(); let mut cx = cx.to_async(); move || { let _ = handle.update(&mut cx, |_, window, _| window.remove_window()); + let _ = cx.update(|cx| { + SystemWindowTabController::remove_tab(cx, window_id); + }); } })); platform_window.on_request_frame(Box::new({ @@ -1082,7 +1101,11 @@ impl Window { .activation_observers .clone() .retain(&(), |callback| callback(window, cx)); + + window.bounds_changed(cx); window.refresh(); + + SystemWindowTabController::update_last_active(cx, window.handle.id); }) .log_err(); } @@ -1123,6 +1146,57 @@ impl Window { .unwrap_or(None) }) }); + platform_window.on_move_tab_to_new_window({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, _window, cx| { + SystemWindowTabController::move_tab_to_new_window(cx, handle.window_id()); + }) + .log_err(); + }) + }); + platform_window.on_merge_all_windows({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, _window, cx| { + SystemWindowTabController::merge_all_windows(cx, handle.window_id()); + }) + .log_err(); + }) + }); + platform_window.on_select_next_tab({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, _window, cx| { + SystemWindowTabController::select_next_tab(cx, handle.window_id()); + }) + .log_err(); + }) + }); + platform_window.on_select_previous_tab({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, _window, cx| { + SystemWindowTabController::select_previous_tab(cx, handle.window_id()) + }) + .log_err(); + }) + }); + platform_window.on_toggle_tab_bar({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, window, cx| { + let tab_bar_visible = window.platform_window.tab_bar_visible(); + SystemWindowTabController::set_visible(cx, tab_bar_visible); + }) + .log_err(); + }) + }); if let Some(app_id) = app_id { platform_window.set_app_id(&app_id); @@ -4275,11 +4349,47 @@ impl Window { } /// Perform titlebar double-click action. - /// This is MacOS specific. + /// This is macOS specific. pub fn titlebar_double_click(&self) { self.platform_window.titlebar_double_click(); } + /// Gets the window's title at the platform level. + /// This is macOS specific. + pub fn window_title(&self) -> String { + self.platform_window.get_title() + } + + /// Returns a list of all tabbed windows and their titles. + /// This is macOS specific. + pub fn tabbed_windows(&self) -> Option> { + self.platform_window.tabbed_windows() + } + + /// Returns the tab bar visibility. + /// This is macOS specific. + pub fn tab_bar_visible(&self) -> bool { + self.platform_window.tab_bar_visible() + } + + /// Merges all open windows into a single tabbed window. + /// This is macOS specific. + pub fn merge_all_windows(&self) { + self.platform_window.merge_all_windows() + } + + /// Moves the tab to a new containing window. + /// This is macOS specific. + pub fn move_tab_to_new_window(&self) { + self.platform_window.move_tab_to_new_window() + } + + /// Shows or hides the window tab overview. + /// This is macOS specific. + pub fn toggle_window_tab_overview(&self) { + self.platform_window.toggle_window_tab_overview() + } + /// Toggles the inspector mode on this window. #[cfg(any(feature = "inspector", debug_assertions))] pub fn toggle_inspector(&mut self, cx: &mut App) { @@ -4468,6 +4578,13 @@ impl Window { } None } + + /// For testing: set the current modifier keys state. + /// This does not generate any events. + #[cfg(any(test, feature = "test-support"))] + pub fn set_modifiers(&mut self, modifiers: Modifiers) { + self.modifiers = modifiers; + } } // #[derive(Clone, Copy, Eq, PartialEq, Hash)] diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 4fc6039fd76e753b5a515d8917c22b27c97737fc..f3609f7ea8706f33eb07eaaf456731e14c85555a 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -146,6 +146,7 @@ pub enum IconName { Library, LineHeight, ListCollapse, + ListFilter, ListTodo, ListTree, ListX, @@ -215,6 +216,7 @@ pub enum IconName { Tab, Terminal, TerminalAlt, + TerminalGhost, TextSnippet, TextThread, Thread, diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index b96557b391f5941283b67b7b798ee177ab383cb2..2dca57424b86e2221acc271efac19cdf39a3f79f 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -401,12 +401,19 @@ pub fn init(cx: &mut App) { mod persistence { use std::path::PathBuf; - use db::{define_connection, query, sqlez_macros::sql}; + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; use workspace::{ItemId, WorkspaceDb, WorkspaceId}; - define_connection! { - pub static ref IMAGE_VIEWER: ImageViewerDb = - &[sql!( + pub struct ImageViewerDb(ThreadSafeConnection); + + impl Domain for ImageViewerDb { + const NAME: &str = stringify!(ImageViewerDb); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE image_viewers ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -417,9 +424,11 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - )]; + )]; } + db::static_connection!(IMAGE_VIEWER, ImageViewerDb, [WorkspaceDb]); + impl ImageViewerDb { query! { pub async fn save_image_path( diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 7ae77c9141d35363975f07b91b45f032da62d21f..b349122193f1f31b323e03ff0421dfc3705c92fa 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -720,6 +720,9 @@ pub struct LanguageConfig { /// How to soft-wrap long lines of text. #[serde(default)] pub soft_wrap: Option, + /// When set, selections can be wrapped using prefix/suffix pairs on both sides. + #[serde(default)] + pub wrap_characters: Option, /// The name of a Prettier parser that will be used for this language when no file path is available. /// If there's a parser name in the language settings, that will be used instead. #[serde(default)] @@ -923,6 +926,7 @@ impl Default for LanguageConfig { hard_tabs: None, tab_size: None, soft_wrap: None, + wrap_characters: None, prettier_parser_name: None, hidden: false, jsx_tag_auto_close: None, @@ -932,6 +936,18 @@ impl Default for LanguageConfig { } } +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct WrapCharactersConfig { + /// Opening token split into a prefix and suffix. The first caret goes + /// after the prefix (i.e., between prefix and suffix). + pub start_prefix: String, + pub start_suffix: String, + /// Closing token split into a prefix and suffix. The second caret goes + /// after the prefix (i.e., between prefix and suffix). + pub end_prefix: String, + pub end_suffix: String, +} + fn auto_indent_using_last_non_empty_line_default() -> bool { true } diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index 73c142c8ca02c986b0602c1f19a8c479c041f6f7..2a8dfd58418812b94c625845dce9724e145c7388 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -11,13 +11,14 @@ use std::{ use async_trait::async_trait; use collections::HashMap; +use fs::Fs; use gpui::{AsyncApp, SharedString}; use settings::WorktreeId; use crate::{LanguageName, ManifestName}; /// Represents a single toolchain. -#[derive(Clone, Debug, Eq)] +#[derive(Clone, Eq, Debug)] pub struct Toolchain { /// User-facing label pub name: SharedString, @@ -29,21 +30,29 @@ pub struct Toolchain { impl std::hash::Hash for Toolchain { fn hash(&self, state: &mut H) { - self.name.hash(state); - self.path.hash(state); - self.language_name.hash(state); + let Self { + name, + path, + language_name, + as_json: _, + } = self; + name.hash(state); + path.hash(state); + language_name.hash(state); } } impl PartialEq for Toolchain { fn eq(&self, other: &Self) -> bool { + let Self { + name, + path, + language_name, + as_json: _, + } = self; // Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced. // Thus, there could be multiple entries that look the same in the UI. - (&self.name, &self.path, &self.language_name).eq(&( - &other.name, - &other.path, - &other.language_name, - )) + (name, path, language_name).eq(&(&other.name, &other.path, &other.language_name)) } } @@ -52,13 +61,14 @@ pub trait ToolchainLister: Send + Sync { async fn list( &self, worktree_root: PathBuf, - subroot_relative_path: Option>, + subroot_relative_path: Arc, project_env: Option>, ) -> ToolchainList; // Returns a term which we should use in UI to refer to a toolchain. fn term(&self) -> SharedString; /// Returns the name of the manifest file for this toolchain. fn manifest_name(&self) -> ManifestName; + async fn activation_script(&self, toolchain: &Toolchain, fs: &dyn Fs) -> Option; } #[async_trait(?Send)] @@ -82,7 +92,7 @@ pub trait LocalLanguageToolchainStore: Send + Sync + 'static { ) -> Option; } -#[async_trait(?Send )] +#[async_trait(?Send)] impl LanguageToolchainStore for T { async fn active_toolchain( self: Arc, diff --git a/crates/language_model/src/fake_provider.rs b/crates/language_model/src/fake_provider.rs index ebfd37d16cf622a622047b7f5babedebd541ad57..b06a475f9385012e5b88466c80fbb14e0ed744ac 100644 --- a/crates/language_model/src/fake_provider.rs +++ b/crates/language_model/src/fake_provider.rs @@ -4,12 +4,16 @@ use crate::{ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, }; +use anyhow::anyhow; use futures::{FutureExt, channel::mpsc, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AsyncApp, Entity, Task, Window}; use http_client::Result; use parking_lot::Mutex; use smol::stream::StreamExt; -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering::SeqCst}, +}; #[derive(Clone)] pub struct FakeLanguageModelProvider { @@ -106,6 +110,7 @@ pub struct FakeLanguageModel { >, )>, >, + forbid_requests: AtomicBool, } impl Default for FakeLanguageModel { @@ -114,11 +119,20 @@ impl Default for FakeLanguageModel { provider_id: LanguageModelProviderId::from("fake".to_string()), provider_name: LanguageModelProviderName::from("Fake".to_string()), current_completion_txs: Mutex::new(Vec::new()), + forbid_requests: AtomicBool::new(false), } } } impl FakeLanguageModel { + pub fn allow_requests(&self) { + self.forbid_requests.store(false, SeqCst); + } + + pub fn forbid_requests(&self) { + self.forbid_requests.store(true, SeqCst); + } + pub fn pending_completions(&self) -> Vec { self.current_completion_txs .lock() @@ -251,9 +265,18 @@ impl LanguageModel for FakeLanguageModel { LanguageModelCompletionError, >, > { - let (tx, rx) = mpsc::unbounded(); - self.current_completion_txs.lock().push((request, tx)); - async move { Ok(rx.boxed()) }.boxed() + if self.forbid_requests.load(SeqCst) { + async move { + Err(LanguageModelCompletionError::Other(anyhow!( + "requests are forbidden" + ))) + } + .boxed() + } else { + let (tx, rx) = mpsc::unbounded(); + self.current_completion_txs.lock().push((request, tx)); + async move { Ok(rx.boxed()) }.boxed() + } } fn as_fake(&self) -> &Self { diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index c7693a64c75efe63ec12df6a15ba5ad1cc9b9eba..bab258bca1728ac45f5ef5c0397149b93f0d6031 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -6,6 +6,7 @@ use collections::BTreeMap; use gpui::{App, Context, Entity, EventEmitter, Global, prelude::*}; use std::{str::FromStr, sync::Arc}; use thiserror::Error; +use util::maybe; pub fn init(cx: &mut App) { let registry = cx.new(|_cx| LanguageModelRegistry::default()); @@ -41,9 +42,7 @@ impl std::fmt::Debug for ConfigurationError { #[derive(Default)] pub struct LanguageModelRegistry { default_model: Option, - /// This model is automatically configured by a user's environment after - /// authenticating all providers. It's only used when default_model is not available. - environment_fallback_model: Option, + default_fast_model: Option, inline_assistant_model: Option, commit_message_model: Option, thread_summary_model: Option, @@ -99,6 +98,9 @@ impl ConfiguredModel { pub enum Event { DefaultModelChanged, + InlineAssistantModelChanged, + CommitMessageModelChanged, + ThreadSummaryModelChanged, ProviderStateChanged(LanguageModelProviderId), AddedProvider(LanguageModelProviderId), RemovedProvider(LanguageModelProviderId), @@ -206,6 +208,7 @@ impl LanguageModelRegistry { ) -> impl Iterator> + 'a { self.providers .values() + .filter(|provider| provider.is_authenticated(cx)) .flat_map(|provider| provider.provided_models(cx)) } @@ -224,7 +227,7 @@ impl LanguageModelRegistry { cx: &mut Context, ) { let configured_model = model.and_then(|model| self.select_model(model, cx)); - self.set_inline_assistant_model(configured_model); + self.set_inline_assistant_model(configured_model, cx); } pub fn select_commit_message_model( @@ -233,7 +236,7 @@ impl LanguageModelRegistry { cx: &mut Context, ) { let configured_model = model.and_then(|model| self.select_model(model, cx)); - self.set_commit_message_model(configured_model); + self.set_commit_message_model(configured_model, cx); } pub fn select_thread_summary_model( @@ -242,7 +245,7 @@ impl LanguageModelRegistry { cx: &mut Context, ) { let configured_model = model.and_then(|model| self.select_model(model, cx)); - self.set_thread_summary_model(configured_model); + self.set_thread_summary_model(configured_model, cx); } /// Selects and sets the inline alternatives for language models based on @@ -276,60 +279,68 @@ impl LanguageModelRegistry { } pub fn set_default_model(&mut self, model: Option, cx: &mut Context) { - match (self.default_model(), model.as_ref()) { + match (self.default_model.as_ref(), model.as_ref()) { (Some(old), Some(new)) if old.is_same_as(new) => {} (None, None) => {} _ => cx.emit(Event::DefaultModelChanged), } + self.default_fast_model = maybe!({ + let provider = &model.as_ref()?.provider; + let fast_model = provider.default_fast_model(cx)?; + Some(ConfiguredModel { + provider: provider.clone(), + model: fast_model, + }) + }); self.default_model = model; } - pub fn set_environment_fallback_model( + pub fn set_inline_assistant_model( &mut self, model: Option, cx: &mut Context, ) { - if self.default_model.is_none() { - match (self.environment_fallback_model.as_ref(), model.as_ref()) { - (Some(old), Some(new)) if old.is_same_as(new) => {} - (None, None) => {} - _ => cx.emit(Event::DefaultModelChanged), - } + match (self.inline_assistant_model.as_ref(), model.as_ref()) { + (Some(old), Some(new)) if old.is_same_as(new) => {} + (None, None) => {} + _ => cx.emit(Event::InlineAssistantModelChanged), } - self.environment_fallback_model = model; - } - - pub fn set_inline_assistant_model(&mut self, model: Option) { self.inline_assistant_model = model; } - pub fn set_commit_message_model(&mut self, model: Option) { + pub fn set_commit_message_model( + &mut self, + model: Option, + cx: &mut Context, + ) { + match (self.commit_message_model.as_ref(), model.as_ref()) { + (Some(old), Some(new)) if old.is_same_as(new) => {} + (None, None) => {} + _ => cx.emit(Event::CommitMessageModelChanged), + } self.commit_message_model = model; } - pub fn set_thread_summary_model(&mut self, model: Option) { + pub fn set_thread_summary_model( + &mut self, + model: Option, + cx: &mut Context, + ) { + match (self.thread_summary_model.as_ref(), model.as_ref()) { + (Some(old), Some(new)) if old.is_same_as(new) => {} + (None, None) => {} + _ => cx.emit(Event::ThreadSummaryModelChanged), + } self.thread_summary_model = model; } - #[track_caller] pub fn default_model(&self) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; } - self.default_model - .clone() - .or_else(|| self.environment_fallback_model.clone()) - } - - pub fn default_fast_model(&self, cx: &App) -> Option { - let provider = self.default_model()?.provider; - let fast_model = provider.default_fast_model(cx)?; - Some(ConfiguredModel { - provider, - model: fast_model, - }) + self.default_model.clone() } pub fn inline_assistant_model(&self) -> Option { @@ -343,7 +354,7 @@ impl LanguageModelRegistry { .or_else(|| self.default_model.clone()) } - pub fn commit_message_model(&self, cx: &App) -> Option { + pub fn commit_message_model(&self) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; @@ -351,11 +362,11 @@ impl LanguageModelRegistry { self.commit_message_model .clone() - .or_else(|| self.default_fast_model(cx)) + .or_else(|| self.default_fast_model.clone()) .or_else(|| self.default_model.clone()) } - pub fn thread_summary_model(&self, cx: &App) -> Option { + pub fn thread_summary_model(&self) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; @@ -363,7 +374,7 @@ impl LanguageModelRegistry { self.thread_summary_model .clone() - .or_else(|| self.default_fast_model(cx)) + .or_else(|| self.default_fast_model.clone()) .or_else(|| self.default_model.clone()) } @@ -400,34 +411,4 @@ mod tests { let providers = registry.read(cx).providers(); assert!(providers.is_empty()); } - - #[gpui::test] - async fn test_configure_environment_fallback_model(cx: &mut gpui::TestAppContext) { - let registry = cx.new(|_| LanguageModelRegistry::default()); - - let provider = FakeLanguageModelProvider::default(); - registry.update(cx, |registry, cx| { - registry.register_provider(provider.clone(), cx); - }); - - cx.update(|cx| provider.authenticate(cx)).await.unwrap(); - - registry.update(cx, |registry, cx| { - let provider = registry.provider(&provider.id()).unwrap(); - - registry.set_environment_fallback_model( - Some(ConfiguredModel { - provider: provider.clone(), - model: provider.default_model(cx).unwrap(), - }), - cx, - ); - - let default_model = registry.default_model().unwrap(); - let fallback_model = registry.environment_fallback_model.clone().unwrap(); - - assert_eq!(default_model.model.id(), fallback_model.model.id()); - assert_eq!(default_model.provider.id(), fallback_model.provider.id()); - }); - } } diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index cd41478668b17e1c680127625bf47a03604eec60..b5bfb870f643452bd5be248c9910d99f16a8101e 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -44,7 +44,6 @@ ollama = { workspace = true, features = ["schemars"] } open_ai = { workspace = true, features = ["schemars"] } open_router = { workspace = true, features = ["schemars"] } partial-json-fixer.workspace = true -project.workspace = true release_channel.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index beed306e740bfdc8170776e187b9ea59d3a59fbb..738b72b0c9a6dbb7c9606cc72707b27e66abf09c 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -3,12 +3,8 @@ use std::sync::Arc; use ::settings::{Settings, SettingsStore}; use client::{Client, UserStore}; use collections::HashSet; -use futures::future; -use gpui::{App, AppContext as _, Context, Entity}; -use language_model::{ - AuthenticateError, ConfiguredModel, LanguageModelProviderId, LanguageModelRegistry, -}; -use project::DisableAiSettings; +use gpui::{App, Context, Entity}; +use language_model::{LanguageModelProviderId, LanguageModelRegistry}; use provider::deepseek::DeepSeekLanguageModelProvider; pub mod provider; @@ -17,7 +13,7 @@ pub mod ui; use crate::provider::anthropic::AnthropicLanguageModelProvider; use crate::provider::bedrock::BedrockLanguageModelProvider; -use crate::provider::cloud::{self, CloudLanguageModelProvider}; +use crate::provider::cloud::CloudLanguageModelProvider; use crate::provider::copilot_chat::CopilotChatLanguageModelProvider; use crate::provider::google::GoogleLanguageModelProvider; use crate::provider::lmstudio::LmStudioLanguageModelProvider; @@ -52,13 +48,6 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { cx, ); }); - - let mut already_authenticated = false; - if !DisableAiSettings::get_global(cx).disable_ai { - authenticate_all_providers(registry.clone(), cx); - already_authenticated = true; - } - cx.observe_global::(move |cx| { let openai_compatible_providers_new = AllLanguageModelSettings::get_global(cx) .openai_compatible @@ -76,12 +65,6 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { ); }); openai_compatible_providers = openai_compatible_providers_new; - already_authenticated = false; - } - - if !DisableAiSettings::get_global(cx).disable_ai && !already_authenticated { - authenticate_all_providers(registry.clone(), cx); - already_authenticated = true; } }) .detach(); @@ -168,83 +151,3 @@ fn register_language_model_providers( registry.register_provider(XAiLanguageModelProvider::new(client.http_client(), cx), cx); registry.register_provider(CopilotChatLanguageModelProvider::new(cx), cx); } - -/// Authenticates all providers in the [`LanguageModelRegistry`]. -/// -/// We do this so that we can populate the language selector with all of the -/// models from the configured providers. -/// -/// This function won't do anything if AI is disabled. -fn authenticate_all_providers(registry: Entity, cx: &mut App) { - let providers_to_authenticate = registry - .read(cx) - .providers() - .iter() - .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) - .collect::>(); - - let mut tasks = Vec::with_capacity(providers_to_authenticate.len()); - - for (provider_id, provider_name, authenticate_task) in providers_to_authenticate { - tasks.push(cx.background_spawn(async move { - if let Err(err) = authenticate_task.await { - if matches!(err, AuthenticateError::CredentialsNotFound) { - // Since we're authenticating these providers in the - // background for the purposes of populating the - // language selector, we don't care about providers - // where the credentials are not found. - } else { - // Some providers have noisy failure states that we - // don't want to spam the logs with every time the - // language model selector is initialized. - // - // Ideally these should have more clear failure modes - // that we know are safe to ignore here, like what we do - // with `CredentialsNotFound` above. - match provider_id.0.as_ref() { - "lmstudio" | "ollama" => { - // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". - // - // These fail noisily, so we don't log them. - } - "copilot_chat" => { - // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. - } - _ => { - log::error!( - "Failed to authenticate provider: {}: {err}", - provider_name.0 - ); - } - } - } - } - })); - } - - let all_authenticated_future = future::join_all(tasks); - - cx.spawn(async move |cx| { - all_authenticated_future.await; - - registry - .update(cx, |registry, cx| { - let cloud_provider = registry.provider(&cloud::PROVIDER_ID); - let fallback_model = cloud_provider - .iter() - .chain(registry.providers().iter()) - .find(|provider| provider.is_authenticated(cx)) - .and_then(|provider| { - Some(ConfiguredModel { - provider: provider.clone(), - model: provider - .default_model(cx) - .or_else(|| provider.recommended_models(cx).first().cloned())?, - }) - }); - registry.set_environment_fallback_model(fallback_model, cx); - }) - .ok(); - }) - .detach(); -} diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index fb6e2fb1e463ffa6d06591e90d11674b1cb091a8..b473d06357a7ac4a60749ef796642bee41a5a511 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -44,8 +44,8 @@ use crate::provider::anthropic::{AnthropicEventMapper, count_anthropic_tokens, i use crate::provider::google::{GoogleEventMapper, into_google}; use crate::provider::open_ai::{OpenAiEventMapper, count_open_ai_tokens, into_open_ai}; -pub const PROVIDER_ID: LanguageModelProviderId = language_model::ZED_CLOUD_PROVIDER_ID; -pub const PROVIDER_NAME: LanguageModelProviderName = language_model::ZED_CLOUD_PROVIDER_NAME; +const PROVIDER_ID: LanguageModelProviderId = language_model::ZED_CLOUD_PROVIDER_ID; +const PROVIDER_NAME: LanguageModelProviderName = language_model::ZED_CLOUD_PROVIDER_NAME; #[derive(Default, Clone, Debug, PartialEq)] pub struct ZedDotDevSettings { @@ -146,7 +146,7 @@ impl State { default_fast_model: None, recommended_models: Vec::new(), _fetch_models_task: cx.spawn(async move |this, cx| { - maybe!(async { + maybe!(async move { let (client, llm_api_token) = this .read_with(cx, |this, _cx| (client.clone(), this.llm_api_token.clone()))?; diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 8f2abfce35852c617ddee22de18432908660fe95..aaa0bd620ccf1a961b8c97c0c9fe3ba348b51cca 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -381,7 +381,7 @@ impl LanguageModel for OpenRouterLanguageModel { fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { let model_id = self.model.id().trim().to_lowercase(); - if model_id.contains("gemini") || model_id.contains("grok-4") { + if model_id.contains("gemini") || model_id.contains("grok") { LanguageModelToolSchemaFormat::JsonSchemaSubset } else { LanguageModelToolSchemaFormat::JsonSchema diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index b37a55e19f389bcf7e5ccd09b52e2b3f6b7ff094..bb17f22c7f3fdbb0296b1e0bb290fbce9a979ddf 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -319,7 +319,7 @@ impl LanguageModel for XAiLanguageModel { } fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { let model_id = self.model.id().trim().to_lowercase(); - if model_id.eq(x_ai::Model::Grok4.id()) { + if model_id.eq(x_ai::Model::Grok4.id()) || model_id.eq(x_ai::Model::GrokCodeFast1.id()) { LanguageModelToolSchemaFormat::JsonSchemaSubset } else { LanguageModelToolSchemaFormat::JsonSchema diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index 5aa914311a6eccc1cb68efa37e878ad12249d6fd..b8f85d8d90068be9ad6849528f28522a96206cc8 100644 --- a/crates/language_tools/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -24,6 +24,7 @@ itertools.workspace = true language.workspace = true lsp.workspace = true project.workspace = true +proto.workspace = true serde_json.workspace = true settings.workspace = true theme.workspace = true diff --git a/crates/language_tools/src/key_context_view.rs b/crates/language_tools/src/key_context_view.rs index 057259d114f88f785c1a016d82f443b2ee2be644..4140713544ed2b22413f909ac45989de8df4e706 100644 --- a/crates/language_tools/src/key_context_view.rs +++ b/crates/language_tools/src/key_context_view.rs @@ -4,7 +4,6 @@ use gpui::{ }; use itertools::Itertools; use serde_json::json; -use settings::get_key_equivalents; use ui::{Button, ButtonStyle}; use ui::{ ButtonCommon, Clickable, Context, FluentBuilder, InteractiveElement, Label, LabelCommon, @@ -169,7 +168,8 @@ impl Item for KeyContextView { impl Render for KeyContextView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl ui::IntoElement { use itertools::Itertools; - let key_equivalents = get_key_equivalents(cx.keyboard_layout().id()); + + let key_equivalents = cx.keyboard_mapper().get_key_equivalents(); v_flex() .id("key-context-view") .overflow_scroll() diff --git a/crates/language_tools/src/language_tools.rs b/crates/language_tools/src/language_tools.rs index cbf5756875f723b52fabbfe877c32265dd6f0aef..c784a67313a904df34c9f2ae071ed5b0e4c11751 100644 --- a/crates/language_tools/src/language_tools.rs +++ b/crates/language_tools/src/language_tools.rs @@ -1,20 +1,20 @@ mod key_context_view; -mod lsp_log; -pub mod lsp_tool; +pub mod lsp_button; +pub mod lsp_log_view; mod syntax_tree_view; #[cfg(test)] -mod lsp_log_tests; +mod lsp_log_view_tests; use gpui::{App, AppContext, Entity}; -pub use lsp_log::{LogStore, LspLogToolbarItemView, LspLogView}; +pub use lsp_log_view::LspLogView; pub use syntax_tree_view::{SyntaxTreeToolbarItemView, SyntaxTreeView}; use ui::{Context, Window}; use workspace::{Item, ItemHandle, SplitDirection, Workspace}; pub fn init(cx: &mut App) { - lsp_log::init(cx); + lsp_log_view::init(true, cx); syntax_tree_view::init(cx); key_context_view::init(cx); } diff --git a/crates/language_tools/src/lsp_tool.rs b/crates/language_tools/src/lsp_button.rs similarity index 90% rename from crates/language_tools/src/lsp_tool.rs rename to crates/language_tools/src/lsp_button.rs index dd3e80212fda08f43718a664d2cfd6d377182273..f91c4cc61c7e56dc75ad36aa91a4582598995e15 100644 --- a/crates/language_tools/src/lsp_tool.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -11,7 +11,10 @@ use editor::{Editor, EditorEvent}; use gpui::{Corner, Entity, Subscription, Task, WeakEntity, actions}; use language::{BinaryStatus, BufferId, ServerHealth}; use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector}; -use project::{LspStore, LspStoreEvent, Worktree, project_settings::ProjectSettings}; +use project::{ + LspStore, LspStoreEvent, Worktree, lsp_store::log_store::GlobalLogStore, + project_settings::ProjectSettings, +}; use settings::{Settings as _, SettingsStore}; use ui::{ Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationSide, @@ -20,7 +23,7 @@ use ui::{ use workspace::{StatusItemView, Workspace}; -use crate::lsp_log::GlobalLogStore; +use crate::lsp_log_view; actions!( lsp_tool, @@ -30,7 +33,7 @@ actions!( ] ); -pub struct LspTool { +pub struct LspButton { server_state: Entity, popover_menu_handle: PopoverMenuHandle, lsp_menu: Option>, @@ -121,9 +124,8 @@ impl LanguageServerState { menu = menu.align_popover_bottom(); let lsp_logs = cx .try_global::() - .and_then(|lsp_logs| lsp_logs.0.upgrade()); - let lsp_store = self.lsp_store.upgrade(); - let Some((lsp_logs, lsp_store)) = lsp_logs.zip(lsp_store) else { + .map(|lsp_logs| lsp_logs.0.clone()); + let Some(lsp_logs) = lsp_logs else { return menu; }; @@ -210,10 +212,11 @@ impl LanguageServerState { }; let server_selector = server_info.server_selector(); - // TODO currently, Zed remote does not work well with the LSP logs - // https://github.com/zed-industries/zed/issues/28557 - let has_logs = lsp_store.read(cx).as_local().is_some() - && lsp_logs.read(cx).has_server_logs(&server_selector); + let is_remote = self + .lsp_store + .update(cx, |lsp_store, _| lsp_store.as_remote().is_some()) + .unwrap_or(false); + let has_logs = is_remote || lsp_logs.read(cx).has_server_logs(&server_selector); let status_color = server_info .binary_status @@ -241,10 +244,10 @@ impl LanguageServerState { .as_ref() .or_else(|| server_info.binary_status.as_ref()?.message.as_ref()) .cloned(); - let hover_label = if has_logs { - Some("View Logs") - } else if message.is_some() { + let hover_label = if message.is_some() { Some("View Message") + } else if has_logs { + Some("View Logs") } else { None }; @@ -288,16 +291,7 @@ impl LanguageServerState { let server_name = server_info.name.clone(); let workspace = self.workspace.clone(); move |window, cx| { - if has_logs { - lsp_logs.update(cx, |lsp_logs, cx| { - lsp_logs.open_server_trace( - workspace.clone(), - server_selector.clone(), - window, - cx, - ); - }); - } else if let Some(message) = &message { + if let Some(message) = &message { let Some(create_buffer) = workspace .update(cx, |workspace, cx| { workspace @@ -347,6 +341,14 @@ impl LanguageServerState { anyhow::Ok(()) }) .detach(); + } else if has_logs { + lsp_log_view::open_server_trace( + &lsp_logs, + workspace.clone(), + server_selector.clone(), + window, + cx, + ); } else { cx.propagate(); } @@ -510,7 +512,7 @@ impl ServerData<'_> { } } -impl LspTool { +impl LspButton { pub fn new( workspace: &Workspace, popover_menu_handle: PopoverMenuHandle, @@ -518,37 +520,59 @@ impl LspTool { cx: &mut Context, ) -> Self { let settings_subscription = - cx.observe_global_in::(window, move |lsp_tool, window, cx| { + cx.observe_global_in::(window, move |lsp_button, window, cx| { if ProjectSettings::get_global(cx).global_lsp_settings.button { - if lsp_tool.lsp_menu.is_none() { - lsp_tool.refresh_lsp_menu(true, window, cx); + if lsp_button.lsp_menu.is_none() { + lsp_button.refresh_lsp_menu(true, window, cx); } - } else if lsp_tool.lsp_menu.take().is_some() { + } else if lsp_button.lsp_menu.take().is_some() { cx.notify(); } }); let lsp_store = workspace.project().read(cx).lsp_store(); + let mut language_servers = LanguageServers::default(); + for (_, status) in lsp_store.read(cx).language_server_statuses() { + language_servers.binary_statuses.insert( + status.name.clone(), + LanguageServerBinaryStatus { + status: BinaryStatus::None, + message: None, + }, + ); + } + let lsp_store_subscription = - cx.subscribe_in(&lsp_store, window, |lsp_tool, _, e, window, cx| { - lsp_tool.on_lsp_store_event(e, window, cx) + cx.subscribe_in(&lsp_store, window, |lsp_button, _, e, window, cx| { + lsp_button.on_lsp_store_event(e, window, cx) }); - let state = cx.new(|_| LanguageServerState { + let server_state = cx.new(|_| LanguageServerState { workspace: workspace.weak_handle(), items: Vec::new(), lsp_store: lsp_store.downgrade(), active_editor: None, - language_servers: LanguageServers::default(), + language_servers, }); - Self { - server_state: state, + let mut lsp_button = Self { + server_state, popover_menu_handle, lsp_menu: None, lsp_menu_refresh: Task::ready(()), _subscriptions: vec![settings_subscription, lsp_store_subscription], + }; + if !lsp_button + .server_state + .read(cx) + .language_servers + .binary_statuses + .is_empty() + { + lsp_button.refresh_lsp_menu(true, window, cx); } + + lsp_button } fn on_lsp_store_event( @@ -708,6 +732,25 @@ impl LspTool { } } } + state + .lsp_store + .update(cx, |lsp_store, cx| { + for (server_id, status) in lsp_store.language_server_statuses() { + if let Some(worktree) = status.worktree.and_then(|worktree_id| { + lsp_store + .worktree_store() + .read(cx) + .worktree_for_id(worktree_id, cx) + }) { + server_ids_to_worktrees.insert(server_id, worktree.clone()); + server_names_to_worktrees + .entry(status.name.clone()) + .or_default() + .insert((worktree, server_id)); + } + } + }) + .ok(); let mut servers_per_worktree = BTreeMap::>::new(); let mut servers_without_worktree = Vec::::new(); @@ -852,18 +895,18 @@ impl LspTool { ) { if create_if_empty || self.lsp_menu.is_some() { let state = self.server_state.clone(); - self.lsp_menu_refresh = cx.spawn_in(window, async move |lsp_tool, cx| { + self.lsp_menu_refresh = cx.spawn_in(window, async move |lsp_button, cx| { cx.background_executor() .timer(Duration::from_millis(30)) .await; - lsp_tool - .update_in(cx, |lsp_tool, window, cx| { - lsp_tool.regenerate_items(cx); + lsp_button + .update_in(cx, |lsp_button, window, cx| { + lsp_button.regenerate_items(cx); let menu = ContextMenu::build(window, cx, |menu, _, cx| { state.update(cx, |state, cx| state.fill_menu(menu, cx)) }); - lsp_tool.lsp_menu = Some(menu.clone()); - lsp_tool.popover_menu_handle.refresh_menu( + lsp_button.lsp_menu = Some(menu.clone()); + lsp_button.popover_menu_handle.refresh_menu( window, cx, Rc::new(move |_, _| Some(menu.clone())), @@ -876,7 +919,7 @@ impl LspTool { } } -impl StatusItemView for LspTool { +impl StatusItemView for LspButton { fn set_active_pane_item( &mut self, active_pane_item: Option<&dyn workspace::ItemHandle>, @@ -899,9 +942,9 @@ impl StatusItemView for LspTool { let _editor_subscription = cx.subscribe_in( &editor, window, - |lsp_tool, _, e: &EditorEvent, window, cx| match e { + |lsp_button, _, e: &EditorEvent, window, cx| match e { EditorEvent::ExcerptsAdded { buffer, .. } => { - let updated = lsp_tool.server_state.update(cx, |state, cx| { + let updated = lsp_button.server_state.update(cx, |state, cx| { if let Some(active_editor) = state.active_editor.as_mut() { let buffer_id = buffer.read(cx).remote_id(); active_editor.editor_buffers.insert(buffer_id) @@ -910,13 +953,13 @@ impl StatusItemView for LspTool { } }); if updated { - lsp_tool.refresh_lsp_menu(false, window, cx); + lsp_button.refresh_lsp_menu(false, window, cx); } } EditorEvent::ExcerptsRemoved { removed_buffer_ids, .. } => { - let removed = lsp_tool.server_state.update(cx, |state, _| { + let removed = lsp_button.server_state.update(cx, |state, _| { let mut removed = false; if let Some(active_editor) = state.active_editor.as_mut() { for id in removed_buffer_ids { @@ -930,7 +973,7 @@ impl StatusItemView for LspTool { removed }); if removed { - lsp_tool.refresh_lsp_menu(false, window, cx); + lsp_button.refresh_lsp_menu(false, window, cx); } } _ => {} @@ -960,7 +1003,7 @@ impl StatusItemView for LspTool { } } -impl Render for LspTool { +impl Render for LspButton { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl ui::IntoElement { if self.server_state.read(cx).language_servers.is_empty() || self.lsp_menu.is_none() { return div(); @@ -1005,11 +1048,11 @@ impl Render for LspTool { (None, "All Servers Operational") }; - let lsp_tool = cx.entity(); + let lsp_button = cx.entity(); div().child( PopoverMenu::new("lsp-tool") - .menu(move |_, cx| lsp_tool.read(cx).lsp_menu.clone()) + .menu(move |_, cx| lsp_button.read(cx).lsp_menu.clone()) .anchor(Corner::BottomLeft) .with_handle(self.popover_menu_handle.clone()) .trigger_with_tooltip( diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log_view.rs similarity index 65% rename from crates/language_tools/src/lsp_log.rs rename to crates/language_tools/src/lsp_log_view.rs index d5206c1f264b3b49f504090154f2df6e4ddf55be..e54411f1d43a6e99352b8ef4dfc48cca423badb6 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -1,20 +1,24 @@ -use collections::{HashMap, VecDeque}; +use collections::VecDeque; use copilot::Copilot; use editor::{Editor, EditorEvent, actions::MoveToEnd, scroll::Autoscroll}; -use futures::{StreamExt, channel::mpsc}; use gpui::{ - AnyView, App, Context, Corner, Entity, EventEmitter, FocusHandle, Focusable, Global, - IntoElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, actions, div, + AnyView, App, Context, Corner, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, + ParentElement, Render, Styled, Subscription, WeakEntity, Window, actions, div, }; use itertools::Itertools; use language::{LanguageServerId, language_settings::SoftWrap}; use lsp::{ - IoKind, LanguageServer, LanguageServerName, LanguageServerSelector, MessageType, + LanguageServer, LanguageServerBinary, LanguageServerName, LanguageServerSelector, MessageType, SetTraceParams, TraceValue, notification::SetTrace, }; -use project::{Project, WorktreeId, search::SearchQuery}; +use project::{ + Project, + lsp_store::log_store::{self, Event, LanguageServerKind, LogKind, LogStore, Message}, + search::SearchQuery, +}; use std::{any::TypeId, borrow::Cow, sync::Arc}; use ui::{Button, Checkbox, ContextMenu, Label, PopoverMenu, ToggleState, prelude::*}; +use util::ResultExt as _; use workspace::{ SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, item::{Item, ItemHandle}, @@ -23,132 +27,53 @@ use workspace::{ use crate::get_or_create_tool; -const SEND_LINE: &str = "\n// Send:"; -const RECEIVE_LINE: &str = "\n// Receive:"; -const MAX_STORED_LOG_ENTRIES: usize = 2000; - -pub struct LogStore { - projects: HashMap, ProjectState>, - language_servers: HashMap, - copilot_log_subscription: Option, - _copilot_subscription: Option, - io_tx: mpsc::UnboundedSender<(LanguageServerId, IoKind, String)>, -} - -struct ProjectState { - _subscriptions: [gpui::Subscription; 2], -} - -trait Message: AsRef { - type Level: Copy + std::fmt::Debug; - fn should_include(&self, _: Self::Level) -> bool { - true - } -} - -pub(super) struct LogMessage { - message: String, - typ: MessageType, -} - -impl AsRef for LogMessage { - fn as_ref(&self) -> &str { - &self.message - } -} - -impl Message for LogMessage { - type Level = MessageType; - - fn should_include(&self, level: Self::Level) -> bool { - match (self.typ, level) { - (MessageType::ERROR, _) => true, - (_, MessageType::ERROR) => false, - (MessageType::WARNING, _) => true, - (_, MessageType::WARNING) => false, - (MessageType::INFO, _) => true, - (_, MessageType::INFO) => false, - _ => true, - } - } -} - -pub(super) struct TraceMessage { - message: String, -} - -impl AsRef for TraceMessage { - fn as_ref(&self) -> &str { - &self.message - } -} - -impl Message for TraceMessage { - type Level = (); -} - -struct RpcMessage { - message: String, -} - -impl AsRef for RpcMessage { - fn as_ref(&self) -> &str { - &self.message - } -} - -impl Message for RpcMessage { - type Level = (); -} - -pub(super) struct LanguageServerState { - name: Option, - worktree_id: Option, - kind: LanguageServerKind, - log_messages: VecDeque, - trace_messages: VecDeque, - rpc_state: Option, - trace_level: TraceValue, - log_level: MessageType, - io_logs_subscription: Option, -} - -#[derive(PartialEq, Clone)] -pub enum LanguageServerKind { - Local { project: WeakEntity }, - Remote { project: WeakEntity }, - Global, -} - -impl LanguageServerKind { - fn is_remote(&self) -> bool { - matches!(self, LanguageServerKind::Remote { .. }) - } -} - -impl std::fmt::Debug for LanguageServerKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - LanguageServerKind::Local { .. } => write!(f, "LanguageServerKind::Local"), - LanguageServerKind::Remote { .. } => write!(f, "LanguageServerKind::Remote"), - LanguageServerKind::Global => write!(f, "LanguageServerKind::Global"), - } - } -} - -impl LanguageServerKind { - fn project(&self) -> Option<&WeakEntity> { - match self { - Self::Local { project } => Some(project), - Self::Remote { project } => Some(project), - Self::Global { .. } => None, - } - } -} - -struct LanguageServerRpcState { - rpc_messages: VecDeque, - last_message_kind: Option, +pub fn open_server_trace( + log_store: &Entity, + workspace: WeakEntity, + server: LanguageServerSelector, + window: &mut Window, + cx: &mut App, +) { + log_store.update(cx, |_, cx| { + cx.spawn_in(window, async move |log_store, cx| { + let Some(log_store) = log_store.upgrade() else { + return; + }; + workspace + .update_in(cx, |workspace, window, cx| { + let project = workspace.project().clone(); + let tool_log_store = log_store.clone(); + let log_view = get_or_create_tool( + workspace, + SplitDirection::Right, + window, + cx, + move |window, cx| LspLogView::new(project, tool_log_store, window, cx), + ); + log_view.update(cx, |log_view, cx| { + let server_id = match server { + LanguageServerSelector::Id(id) => Some(id), + LanguageServerSelector::Name(name) => { + log_store.read(cx).language_servers.iter().find_map( + |(id, state)| { + if state.name.as_ref() == Some(&name) { + Some(*id) + } else { + None + } + }, + ) + } + }; + if let Some(server_id) = server_id { + log_view.show_rpc_trace_for_server(server_id, window, cx); + } + }); + }) + .ok(); + }) + .detach(); + }) } pub struct LspLogView { @@ -167,32 +92,6 @@ pub struct LspLogToolbarItemView { _log_view_subscription: Option, } -#[derive(Copy, Clone, PartialEq, Eq)] -enum MessageKind { - Send, - Receive, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum LogKind { - Rpc, - Trace, - #[default] - Logs, - ServerInfo, -} - -impl LogKind { - fn label(&self) -> &'static str { - match self { - LogKind::Rpc => RPC_MESSAGES, - LogKind::Trace => SERVER_TRACE, - LogKind::Logs => SERVER_LOGS, - LogKind::ServerInfo => SERVER_INFO, - } - } -} - #[derive(Clone, Debug, PartialEq)] pub(crate) struct LogMenuItem { pub server_id: LanguageServerId, @@ -212,59 +111,24 @@ actions!( ] ); -pub(super) struct GlobalLogStore(pub WeakEntity); - -impl Global for GlobalLogStore {} - -pub fn init(cx: &mut App) { - let log_store = cx.new(LogStore::new); - cx.set_global(GlobalLogStore(log_store.downgrade())); - - cx.observe_new(move |workspace: &mut Workspace, _, cx| { - let project = workspace.project(); - if project.read(cx).is_local() || project.read(cx).is_via_ssh() { - log_store.update(cx, |store, cx| { - store.add_project(project, cx); - }); - } - - let log_store = log_store.clone(); - workspace.register_action(move |workspace, _: &OpenLanguageServerLogs, window, cx| { - let project = workspace.project().read(cx); - if project.is_local() || project.is_via_ssh() { - let project = workspace.project().clone(); - let log_store = log_store.clone(); - get_or_create_tool( - workspace, - SplitDirection::Right, - window, - cx, - move |window, cx| LspLogView::new(project, log_store, window, cx), - ); - } - }); - }) - .detach(); -} - -impl LogStore { - pub fn new(cx: &mut Context) -> Self { - let (io_tx, mut io_rx) = mpsc::unbounded(); +pub fn init(store_logs: bool, cx: &mut App) { + let log_store = log_store::init(store_logs, cx); - let copilot_subscription = Copilot::global(cx).map(|copilot| { + log_store.update(cx, |_, cx| { + Copilot::global(cx).map(|copilot| { let copilot = &copilot; - cx.subscribe(copilot, |this, copilot, edit_prediction_event, cx| { + cx.subscribe(copilot, |log_store, copilot, edit_prediction_event, cx| { if let copilot::Event::CopilotLanguageServerStarted = edit_prediction_event && let Some(server) = copilot.read(cx).language_server() { let server_id = server.server_id(); - let weak_this = cx.weak_entity(); - this.copilot_log_subscription = + let weak_lsp_store = cx.weak_entity(); + log_store.copilot_log_subscription = Some(server.on_notification::( move |params, cx| { - weak_this - .update(cx, |this, cx| { - this.add_language_server_log( + weak_lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.add_language_server_log( server_id, MessageType::LOG, ¶ms.message, @@ -274,8 +138,9 @@ impl LogStore { .ok(); }, )); + let name = LanguageServerName::new_static("copilot"); - this.add_language_server( + log_store.add_language_server( LanguageServerKind::Global, server.server_id(), Some(name), @@ -285,429 +150,29 @@ impl LogStore { ); } }) - }); - - let this = Self { - copilot_log_subscription: None, - _copilot_subscription: copilot_subscription, - projects: HashMap::default(), - language_servers: HashMap::default(), - io_tx, - }; - - cx.spawn(async move |this, cx| { - while let Some((server_id, io_kind, message)) = io_rx.next().await { - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| { - this.on_io(server_id, io_kind, &message, cx); - })?; - } - } - anyhow::Ok(()) + .detach(); }) - .detach_and_log_err(cx); - this - } - - pub fn add_project(&mut self, project: &Entity, cx: &mut Context) { - let weak_project = project.downgrade(); - self.projects.insert( - project.downgrade(), - ProjectState { - _subscriptions: [ - cx.observe_release(project, move |this, _, _| { - this.projects.remove(&weak_project); - this.language_servers - .retain(|_, state| state.kind.project() != Some(&weak_project)); - }), - cx.subscribe(project, |this, project, event, cx| { - let server_kind = if project.read(cx).is_via_ssh() { - LanguageServerKind::Remote { - project: project.downgrade(), - } - } else { - LanguageServerKind::Local { - project: project.downgrade(), - } - }; + }); - match event { - project::Event::LanguageServerAdded(id, name, worktree_id) => { - this.add_language_server( - server_kind, - *id, - Some(name.clone()), - *worktree_id, - project - .read(cx) - .lsp_store() - .read(cx) - .language_server_for_id(*id), - cx, - ); - } - project::Event::LanguageServerRemoved(id) => { - this.remove_language_server(*id, cx); - } - project::Event::LanguageServerLog(id, typ, message) => { - this.add_language_server(server_kind, *id, None, None, None, cx); - match typ { - project::LanguageServerLogType::Log(typ) => { - this.add_language_server_log(*id, *typ, message, cx); - } - project::LanguageServerLogType::Trace(_) => { - this.add_language_server_trace(*id, message, cx); - } - } - } - _ => {} - } - }), - ], - }, - ); - } - - pub(super) fn get_language_server_state( - &mut self, - id: LanguageServerId, - ) -> Option<&mut LanguageServerState> { - self.language_servers.get_mut(&id) - } - - fn add_language_server( - &mut self, - kind: LanguageServerKind, - server_id: LanguageServerId, - name: Option, - worktree_id: Option, - server: Option>, - cx: &mut Context, - ) -> Option<&mut LanguageServerState> { - let server_state = self.language_servers.entry(server_id).or_insert_with(|| { - cx.notify(); - LanguageServerState { - name: None, - worktree_id: None, - kind, - rpc_state: None, - log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), - trace_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), - trace_level: TraceValue::Off, - log_level: MessageType::LOG, - io_logs_subscription: None, - } + cx.observe_new(move |workspace: &mut Workspace, _, cx| { + log_store.update(cx, |store, cx| { + store.add_project(workspace.project(), cx); }); - if let Some(name) = name { - server_state.name = Some(name); - } - if let Some(worktree_id) = worktree_id { - server_state.worktree_id = Some(worktree_id); - } - - if let Some(server) = server.filter(|_| server_state.io_logs_subscription.is_none()) { - let io_tx = self.io_tx.clone(); - let server_id = server.server_id(); - server_state.io_logs_subscription = Some(server.on_io(move |io_kind, message| { - io_tx - .unbounded_send((server_id, io_kind, message.to_string())) - .ok(); - })); - } - - Some(server_state) - } - - fn add_language_server_log( - &mut self, - id: LanguageServerId, - typ: MessageType, - message: &str, - cx: &mut Context, - ) -> Option<()> { - let language_server_state = self.get_language_server_state(id)?; - - let log_lines = &mut language_server_state.log_messages; - Self::add_language_server_message( - log_lines, - id, - LogMessage { - message: message.trim_end().to_string(), - typ, - }, - language_server_state.log_level, - LogKind::Logs, - cx, - ); - Some(()) - } - - fn add_language_server_trace( - &mut self, - id: LanguageServerId, - message: &str, - cx: &mut Context, - ) -> Option<()> { - let language_server_state = self.get_language_server_state(id)?; - - let log_lines = &mut language_server_state.trace_messages; - Self::add_language_server_message( - log_lines, - id, - TraceMessage { - message: message.trim().to_string(), - }, - (), - LogKind::Trace, - cx, - ); - Some(()) - } - - fn add_language_server_message( - log_lines: &mut VecDeque, - id: LanguageServerId, - message: T, - current_severity: ::Level, - kind: LogKind, - cx: &mut Context, - ) { - while log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES { - log_lines.pop_front(); - } - let text = message.as_ref().to_string(); - let visible = message.should_include(current_severity); - log_lines.push_back(message); - - if visible { - cx.emit(Event::NewServerLogEntry { id, kind, text }); - cx.notify(); - } - } - - fn remove_language_server(&mut self, id: LanguageServerId, cx: &mut Context) { - self.language_servers.remove(&id); - cx.notify(); - } - - pub(super) fn server_logs(&self, server_id: LanguageServerId) -> Option<&VecDeque> { - Some(&self.language_servers.get(&server_id)?.log_messages) - } - - pub(super) fn server_trace( - &self, - server_id: LanguageServerId, - ) -> Option<&VecDeque> { - Some(&self.language_servers.get(&server_id)?.trace_messages) - } - - fn server_ids_for_project<'a>( - &'a self, - lookup_project: &'a WeakEntity, - ) -> impl Iterator + 'a { - self.language_servers - .iter() - .filter_map(move |(id, state)| match &state.kind { - LanguageServerKind::Local { project } | LanguageServerKind::Remote { project } => { - if project == lookup_project { - Some(*id) - } else { - None - } - } - LanguageServerKind::Global => Some(*id), - }) - } - - fn enable_rpc_trace_for_language_server( - &mut self, - server_id: LanguageServerId, - ) -> Option<&mut LanguageServerRpcState> { - let rpc_state = self - .language_servers - .get_mut(&server_id)? - .rpc_state - .get_or_insert_with(|| LanguageServerRpcState { - rpc_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), - last_message_kind: None, - }); - Some(rpc_state) - } - - pub fn disable_rpc_trace_for_language_server( - &mut self, - server_id: LanguageServerId, - ) -> Option<()> { - self.language_servers.get_mut(&server_id)?.rpc_state.take(); - Some(()) - } - - pub fn has_server_logs(&self, server: &LanguageServerSelector) -> bool { - match server { - LanguageServerSelector::Id(id) => self.language_servers.contains_key(id), - LanguageServerSelector::Name(name) => self - .language_servers - .iter() - .any(|(_, state)| state.name.as_ref() == Some(name)), - } - } - - pub fn open_server_log( - &mut self, - workspace: WeakEntity, - server: LanguageServerSelector, - window: &mut Window, - cx: &mut Context, - ) { - cx.spawn_in(window, async move |log_store, cx| { - let Some(log_store) = log_store.upgrade() else { - return; - }; - workspace - .update_in(cx, |workspace, window, cx| { - let project = workspace.project().clone(); - let tool_log_store = log_store.clone(); - let log_view = get_or_create_tool( - workspace, - SplitDirection::Right, - window, - cx, - move |window, cx| LspLogView::new(project, tool_log_store, window, cx), - ); - log_view.update(cx, |log_view, cx| { - let server_id = match server { - LanguageServerSelector::Id(id) => Some(id), - LanguageServerSelector::Name(name) => { - log_store.read(cx).language_servers.iter().find_map( - |(id, state)| { - if state.name.as_ref() == Some(&name) { - Some(*id) - } else { - None - } - }, - ) - } - }; - if let Some(server_id) = server_id { - log_view.show_logs_for_server(server_id, window, cx); - } - }); - }) - .ok(); - }) - .detach(); - } - - pub fn open_server_trace( - &mut self, - workspace: WeakEntity, - server: LanguageServerSelector, - window: &mut Window, - cx: &mut Context, - ) { - cx.spawn_in(window, async move |log_store, cx| { - let Some(log_store) = log_store.upgrade() else { - return; - }; - workspace - .update_in(cx, |workspace, window, cx| { - let project = workspace.project().clone(); - let tool_log_store = log_store.clone(); - let log_view = get_or_create_tool( - workspace, - SplitDirection::Right, - window, - cx, - move |window, cx| LspLogView::new(project, tool_log_store, window, cx), - ); - log_view.update(cx, |log_view, cx| { - let server_id = match server { - LanguageServerSelector::Id(id) => Some(id), - LanguageServerSelector::Name(name) => { - log_store.read(cx).language_servers.iter().find_map( - |(id, state)| { - if state.name.as_ref() == Some(&name) { - Some(*id) - } else { - None - } - }, - ) - } - }; - if let Some(server_id) = server_id { - log_view.show_rpc_trace_for_server(server_id, window, cx); - } - }); - }) - .ok(); - }) - .detach(); - } - - fn on_io( - &mut self, - language_server_id: LanguageServerId, - io_kind: IoKind, - message: &str, - cx: &mut Context, - ) -> Option<()> { - let is_received = match io_kind { - IoKind::StdOut => true, - IoKind::StdIn => false, - IoKind::StdErr => { - self.add_language_server_log(language_server_id, MessageType::LOG, message, cx); - return Some(()); - } - }; - - let state = self - .get_language_server_state(language_server_id)? - .rpc_state - .as_mut()?; - let kind = if is_received { - MessageKind::Receive - } else { - MessageKind::Send - }; - - let rpc_log_lines = &mut state.rpc_messages; - if state.last_message_kind != Some(kind) { - while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES { - rpc_log_lines.pop_front(); - } - let line_before_message = match kind { - MessageKind::Send => SEND_LINE, - MessageKind::Receive => RECEIVE_LINE, - }; - rpc_log_lines.push_back(RpcMessage { - message: line_before_message.to_string(), - }); - cx.emit(Event::NewServerLogEntry { - id: language_server_id, - kind: LogKind::Rpc, - text: line_before_message.to_string(), - }); - } - - while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES { - rpc_log_lines.pop_front(); - } - - let message = message.trim(); - rpc_log_lines.push_back(RpcMessage { - message: message.to_string(), - }); - cx.emit(Event::NewServerLogEntry { - id: language_server_id, - kind: LogKind::Rpc, - text: message.to_string(), + let log_store = log_store.clone(); + workspace.register_action(move |workspace, _: &OpenLanguageServerLogs, window, cx| { + let log_store = log_store.clone(); + let project = workspace.project().clone(); + get_or_create_tool( + workspace, + SplitDirection::Right, + window, + cx, + move |window, cx| LspLogView::new(project, log_store, window, cx), + ); }); - cx.notify(); - Some(()) - } + }) + .detach(); } impl LspLogView { @@ -751,13 +216,14 @@ impl LspLogView { cx.notify(); }); + let events_subscriptions = cx.subscribe_in( &log_store, window, move |log_view, _, e, window, cx| match e { Event::NewServerLogEntry { id, kind, text } => { if log_view.current_server_id == Some(*id) - && *kind == log_view.active_entry_kind + && LogKind::from_server_log_type(kind) == log_view.active_entry_kind { log_view.editor.update(cx, |editor, cx| { editor.set_read_only(false); @@ -800,7 +266,7 @@ impl LspLogView { window.focus(&log_view.editor.focus_handle(cx)); }); - let mut this = Self { + let mut lsp_log_view = Self { focus_handle, editor, editor_subscriptions, @@ -815,9 +281,9 @@ impl LspLogView { ], }; if let Some(server_id) = server_id { - this.show_logs_for_server(server_id, window, cx); + lsp_log_view.show_logs_for_server(server_id, window, cx); } - this + lsp_log_view } fn editor_for_logs( @@ -838,7 +304,7 @@ impl LspLogView { } fn editor_for_server_info( - server: &LanguageServer, + info: ServerInfo, window: &mut Window, cx: &mut Context, ) -> (Entity, Vec) { @@ -853,22 +319,21 @@ impl LspLogView { * Capabilities: {CAPABILITIES} * Configuration: {CONFIGURATION}", - NAME = server.name(), - ID = server.server_id(), - BINARY = server.binary(), - WORKSPACE_FOLDERS = server - .workspace_folders() - .into_iter() - .filter_map(|path| path - .to_file_path() - .ok() - .map(|path| path.to_string_lossy().into_owned())) - .collect::>() - .join(", "), - CAPABILITIES = serde_json::to_string_pretty(&server.capabilities()) + NAME = info.name, + ID = info.id, + BINARY = info.binary.as_ref().map_or_else( + || "Unknown".to_string(), + |bin| bin.path.as_path().to_string_lossy().to_string() + ), + WORKSPACE_FOLDERS = info.workspace_folders.join(", "), + CAPABILITIES = serde_json::to_string_pretty(&info.capabilities) .unwrap_or_else(|e| format!("Failed to serialize capabilities: {e}")), - CONFIGURATION = serde_json::to_string_pretty(server.configuration()) - .unwrap_or_else(|e| format!("Failed to serialize configuration: {e}")), + CONFIGURATION = info + .configuration + .map(|configuration| serde_json::to_string_pretty(&configuration)) + .transpose() + .unwrap_or_else(|e| Some(format!("Failed to serialize configuration: {e}"))) + .unwrap_or_else(|| "Unknown".to_string()), ); let editor = initialize_new_editor(server_info, false, window, cx); let editor_subscription = cx.subscribe( @@ -891,7 +356,9 @@ impl LspLogView { .language_servers .iter() .map(|(server_id, state)| match &state.kind { - LanguageServerKind::Local { .. } | LanguageServerKind::Remote { .. } => { + LanguageServerKind::Local { .. } + | LanguageServerKind::Remote { .. } + | LanguageServerKind::LocalSsh { .. } => { let worktree_root_name = state .worktree_id .and_then(|id| self.project.read(cx).worktree_for_id(id, cx)) @@ -1003,11 +470,17 @@ impl LspLogView { window: &mut Window, cx: &mut Context, ) { + let trace_level = self + .log_store + .update(cx, |this, _| { + Some(this.get_language_server_state(server_id)?.trace_level) + }) + .unwrap_or(TraceValue::Messages); let log_contents = self .log_store .read(cx) .server_trace(server_id) - .map(|v| log_contents(v, ())); + .map(|v| log_contents(v, trace_level)); if let Some(log_contents) = log_contents { self.current_server_id = Some(server_id); self.active_entry_kind = LogKind::Trace; @@ -1025,6 +498,7 @@ impl LspLogView { window: &mut Window, cx: &mut Context, ) { + self.toggle_rpc_trace_for_server(server_id, true, window, cx); let rpc_log = self.log_store.update(cx, |log_store, _| { log_store .enable_rpc_trace_for_language_server(server_id) @@ -1069,12 +543,33 @@ impl LspLogView { window: &mut Window, cx: &mut Context, ) { - self.log_store.update(cx, |log_store, _| { + self.log_store.update(cx, |log_store, cx| { if enabled { log_store.enable_rpc_trace_for_language_server(server_id); } else { log_store.disable_rpc_trace_for_language_server(server_id); } + + if let Some(server_state) = log_store.language_servers.get(&server_id) { + if let LanguageServerKind::Remote { project } = &server_state.kind { + project + .update(cx, |project, cx| { + if let Some((client, project_id)) = + project.lsp_store().read(cx).upstream_client() + { + client + .send(proto::ToggleLspLogs { + project_id, + log_type: proto::toggle_lsp_logs::LogType::Rpc as i32, + server_id: server_id.to_proto(), + enabled, + }) + .log_err(); + } + }) + .ok(); + } + }; }); if !enabled && Some(server_id) == self.current_server_id { self.show_logs_for_server(server_id, window, cx); @@ -1113,13 +608,38 @@ impl LspLogView { window: &mut Window, cx: &mut Context, ) { - let lsp_store = self.project.read(cx).lsp_store(); - let Some(server) = lsp_store.read(cx).language_server_for_id(server_id) else { + let Some(server_info) = self + .project + .read(cx) + .lsp_store() + .update(cx, |lsp_store, _| { + lsp_store + .language_server_for_id(server_id) + .as_ref() + .map(|language_server| ServerInfo::new(language_server)) + .or_else(move || { + let capabilities = + lsp_store.lsp_server_capabilities.get(&server_id)?.clone(); + let name = lsp_store + .language_server_statuses + .get(&server_id) + .map(|status| status.name.clone())?; + Some(ServerInfo { + id: server_id, + capabilities, + binary: None, + name, + workspace_folders: Vec::new(), + configuration: None, + }) + }) + }) + else { return; }; self.current_server_id = Some(server_id); self.active_entry_kind = LogKind::ServerInfo; - let (editor, editor_subscriptions) = Self::editor_for_server_info(&server, window, cx); + let (editor, editor_subscriptions) = Self::editor_for_server_info(server_info, window, cx); self.editor = editor; self.editor_subscriptions = editor_subscriptions; cx.notify(); @@ -1416,7 +936,6 @@ impl Render for LspLogToolbarItemView { let view_selector = current_server.map(|server| { let server_id = server.server_id; - let is_remote = server.server_kind.is_remote(); let rpc_trace_enabled = server.rpc_trace_enabled; let log_view = log_view.clone(); PopoverMenu::new("LspViewSelector") @@ -1438,55 +957,53 @@ impl Render for LspLogToolbarItemView { view.show_logs_for_server(server_id, window, cx); }), ) - .when(!is_remote, |this| { - this.entry( - SERVER_TRACE, - None, - window.handler_for(&log_view, move |view, window, cx| { - view.show_trace_for_server(server_id, window, cx); - }), - ) - .custom_entry( - { - let log_toolbar_view = log_toolbar_view.clone(); - move |window, _| { - h_flex() - .w_full() - .justify_between() - .child(Label::new(RPC_MESSAGES)) - .child( - div().child( - Checkbox::new( - "LspLogEnableRpcTrace", - if rpc_trace_enabled { + .entry( + SERVER_TRACE, + None, + window.handler_for(&log_view, move |view, window, cx| { + view.show_trace_for_server(server_id, window, cx); + }), + ) + .custom_entry( + { + let log_toolbar_view = log_toolbar_view.clone(); + move |window, _| { + h_flex() + .w_full() + .justify_between() + .child(Label::new(RPC_MESSAGES)) + .child( + div().child( + Checkbox::new( + "LspLogEnableRpcTrace", + if rpc_trace_enabled { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .on_click(window.listener_for( + &log_toolbar_view, + move |view, selection, window, cx| { + let enabled = matches!( + selection, ToggleState::Selected - } else { - ToggleState::Unselected - }, - ) - .on_click(window.listener_for( - &log_toolbar_view, - move |view, selection, window, cx| { - let enabled = matches!( - selection, - ToggleState::Selected - ); - view.toggle_rpc_logging_for_server( - server_id, enabled, window, cx, - ); - cx.stop_propagation(); - }, - )), - ), - ) - .into_any_element() - } - }, - window.handler_for(&log_view, move |view, window, cx| { - view.show_rpc_trace_for_server(server_id, window, cx); - }), - ) - }) + ); + view.toggle_rpc_logging_for_server( + server_id, enabled, window, cx, + ); + cx.stop_propagation(); + }, + )), + ), + ) + .into_any_element() + } + }, + window.handler_for(&log_view, move |view, window, cx| { + view.show_rpc_trace_for_server(server_id, window, cx); + }), + ) .entry( SERVER_INFO, None, @@ -1696,12 +1213,6 @@ const SERVER_LOGS: &str = "Server Logs"; const SERVER_TRACE: &str = "Server Trace"; const SERVER_INFO: &str = "Server Info"; -impl Default for LspLogToolbarItemView { - fn default() -> Self { - Self::new() - } -} - impl LspLogToolbarItemView { pub fn new() -> Self { Self { @@ -1734,14 +1245,35 @@ impl LspLogToolbarItemView { } } -pub enum Event { - NewServerLogEntry { - id: LanguageServerId, - kind: LogKind, - text: String, - }, +struct ServerInfo { + id: LanguageServerId, + capabilities: lsp::ServerCapabilities, + binary: Option, + name: LanguageServerName, + workspace_folders: Vec, + configuration: Option, +} + +impl ServerInfo { + fn new(server: &LanguageServer) -> Self { + Self { + id: server.server_id(), + capabilities: server.capabilities(), + binary: Some(server.binary().clone()), + name: server.name(), + workspace_folders: server + .workspace_folders() + .into_iter() + .filter_map(|path| { + path.to_file_path() + .ok() + .map(|path| path.to_string_lossy().into_owned()) + }) + .collect::>(), + configuration: Some(server.configuration().clone()), + } + } } -impl EventEmitter for LogStore {} impl EventEmitter for LspLogView {} impl EventEmitter for LspLogView {} diff --git a/crates/language_tools/src/lsp_log_tests.rs b/crates/language_tools/src/lsp_log_view_tests.rs similarity index 91% rename from crates/language_tools/src/lsp_log_tests.rs rename to crates/language_tools/src/lsp_log_view_tests.rs index ad2b653fdcfd4dc228cac58da7ed15f844b4bb26..bfd093e3db1c1bc0dc04b111d2072339f1314b8e 100644 --- a/crates/language_tools/src/lsp_log_tests.rs +++ b/crates/language_tools/src/lsp_log_view_tests.rs @@ -1,20 +1,22 @@ use std::sync::Arc; -use crate::lsp_log::LogMenuItem; +use crate::lsp_log_view::LogMenuItem; use super::*; use futures::StreamExt; use gpui::{AppContext as _, SemanticVersion, TestAppContext, VisualTestContext}; use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; use lsp::LanguageServerName; -use lsp_log::LogKind; -use project::{FakeFs, Project}; +use project::{ + FakeFs, Project, + lsp_store::log_store::{LanguageServerKind, LogKind, LogStore}, +}; use serde_json::json; use settings::SettingsStore; use util::path; #[gpui::test] -async fn test_lsp_logs(cx: &mut TestAppContext) { +async fn test_lsp_log_view(cx: &mut TestAppContext) { zlog::init_test(); init_test(cx); @@ -51,7 +53,7 @@ async fn test_lsp_logs(cx: &mut TestAppContext) { }, ); - let log_store = cx.new(LogStore::new); + let log_store = cx.new(|cx| LogStore::new(true, cx)); log_store.update(cx, |store, cx| store.add_project(&project, cx)); let _rust_buffer = project @@ -94,7 +96,7 @@ async fn test_lsp_logs(cx: &mut TestAppContext) { rpc_trace_enabled: false, selected_entry: LogKind::Logs, trace_level: lsp::TraceValue::Off, - server_kind: lsp_log::LanguageServerKind::Local { + server_kind: LanguageServerKind::Local { project: project.downgrade() } }] diff --git a/crates/languages/src/cpp/highlights.scm b/crates/languages/src/cpp/highlights.scm index 6fa8bd7b0858d3a1844ce2d322564ce9c39babea..bd988445bb155e8851ffa8bc3771bdd235fc7dff 100644 --- a/crates/languages/src/cpp/highlights.scm +++ b/crates/languages/src/cpp/highlights.scm @@ -3,8 +3,27 @@ (namespace_identifier) @namespace (concept_definition - (identifier) @concept) + name: (identifier) @concept) +(requires_clause + constraint: (template_type + name: (type_identifier) @concept)) + +(module_name + (identifier) @module) + +(module_declaration + name: (module_name + (identifier) @module)) + +(import_declaration + name: (module_name + (identifier) @module)) + +(import_declaration + partition: (module_partition + (module_name + (identifier) @module))) (call_expression function: (qualified_identifier @@ -61,6 +80,9 @@ (operator_name (identifier)? @operator) @function +(operator_name + "<=>" @operator.spaceship) + (destructor_name (identifier) @function) ((namespace_identifier) @type @@ -68,21 +90,17 @@ (auto) @type (type_identifier) @type -type :(primitive_type) @type.primitive -(sized_type_specifier) @type.primitive - -(requires_clause - constraint: (template_type - name: (type_identifier) @concept)) +type: (primitive_type) @type.builtin +(sized_type_specifier) @type.builtin (attribute - name: (identifier) @keyword) + name: (identifier) @attribute) -((identifier) @constant - (#match? @constant "^_*[A-Z][A-Z\\d_]*$")) +((identifier) @constant.builtin + (#match? @constant.builtin "^_*[A-Z][A-Z\\d_]*$")) (statement_identifier) @label -(this) @variable.special +(this) @variable.builtin ("static_assert") @function.builtin [ @@ -96,7 +114,9 @@ type :(primitive_type) @type.primitive "co_return" "co_yield" "concept" + "consteval" "constexpr" + "constinit" "continue" "decltype" "default" @@ -105,15 +125,20 @@ type :(primitive_type) @type.primitive "else" "enum" "explicit" + "export" "extern" "final" "for" "friend" + "goto" "if" + "import" "inline" + "module" "namespace" "new" "noexcept" + "operator" "override" "private" "protected" @@ -124,6 +149,7 @@ type :(primitive_type) @type.primitive "struct" "switch" "template" + "thread_local" "throw" "try" "typedef" @@ -146,7 +172,7 @@ type :(primitive_type) @type.primitive "#ifndef" "#include" (preproc_directive) -] @keyword +] @keyword.directive (comment) @comment @@ -224,10 +250,24 @@ type :(primitive_type) @type.primitive ">" "<=" ">=" - "<=>" - "||" "?" + "and" + "and_eq" + "bitand" + "bitor" + "compl" + "not" + "not_eq" + "or" + "or_eq" + "xor" + "xor_eq" ] @operator +"<=>" @operator.spaceship + +(binary_expression + operator: "<=>" @operator.spaceship) + (conditional_expression ":" @operator) (user_defined_literal (literal_suffix) @operator) diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 24e2ca2f56fff5ba1a3d92ca5e0bf16ac1a9463b..86f8e1faaa969449f45f38ee5cf8e8cde9ccff29 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -764,6 +764,7 @@ mod tests { let highlight_type = grammar.highlight_id_for_name("type").unwrap(); let highlight_keyword = grammar.highlight_id_for_name("keyword").unwrap(); let highlight_number = grammar.highlight_id_for_name("number").unwrap(); + let highlight_field = grammar.highlight_id_for_name("property").unwrap(); assert_eq!( adapter @@ -828,7 +829,7 @@ mod tests { Some(CodeLabel { text: "two.Three a.Bcd".to_string(), filter_range: 0..9, - runs: vec![(12..15, highlight_type)], + runs: vec![(4..9, highlight_field), (12..15, highlight_type)], }) ); } diff --git a/crates/languages/src/go/highlights.scm b/crates/languages/src/go/highlights.scm index 5aa23fca90b7e0295fc08af6a75a038e3abb0e3a..bb0eaab88a1c0c79a04496d453831cf396d706b6 100644 --- a/crates/languages/src/go/highlights.scm +++ b/crates/languages/src/go/highlights.scm @@ -1,13 +1,13 @@ (identifier) @variable (type_identifier) @type -(field_identifier) @variable.member +(field_identifier) @property (package_identifier) @namespace (keyed_element . (literal_element - (identifier) @variable.member)) + (identifier) @property)) (call_expression function: (identifier) @function) diff --git a/crates/languages/src/javascript/config.toml b/crates/languages/src/javascript/config.toml index 0df57d985e82595bdabb97517f56e79591343e7b..128eac0e4dda2b5b437c494e862970c23a8df3a1 100644 --- a/crates/languages/src/javascript/config.toml +++ b/crates/languages/src/javascript/config.toml @@ -6,6 +6,7 @@ first_line_pattern = '^#!.*\b(?:[/ ]node|deno run.*--ext[= ]js)\b' line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } +wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "" } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index d21b5dabd34c311d6e08140b2bc7ed363f79f273..37d38de9dab6bb5968b446e7009a42c5f2e86e86 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -2,6 +2,7 @@ use anyhow::{Context as _, ensure}; use anyhow::{Result, anyhow}; use async_trait::async_trait; use collections::HashMap; +use futures::AsyncBufReadExt; use gpui::{App, Task}; use gpui::{AsyncApp, SharedString}; use language::Toolchain; @@ -30,8 +31,6 @@ use std::{ borrow::Cow, ffi::OsString, fmt::Write, - fs, - io::{self, BufRead}, path::{Path, PathBuf}, sync::Arc, }; @@ -741,14 +740,16 @@ fn env_priority(kind: Option) -> usize { /// Return the name of environment declared in Option { - fs::File::open(worktree_root.join(".venv")) - .and_then(|file| { - let mut venv_name = String::new(); - io::BufReader::new(file).read_line(&mut venv_name)?; - Ok(venv_name.trim().to_string()) - }) - .ok() +async fn get_worktree_venv_declaration(worktree_root: &Path) -> Option { + let file = async_fs::File::open(worktree_root.join(".venv")) + .await + .ok()?; + let mut venv_name = String::new(); + smol::io::BufReader::new(file) + .read_line(&mut venv_name) + .await + .ok()?; + Some(venv_name.trim().to_string()) } #[async_trait] @@ -759,7 +760,7 @@ impl ToolchainLister for PythonToolchainProvider { async fn list( &self, worktree_root: PathBuf, - subroot_relative_path: Option>, + subroot_relative_path: Arc, project_env: Option>, ) -> ToolchainList { let env = project_env.unwrap_or_default(); @@ -771,13 +772,15 @@ impl ToolchainLister for PythonToolchainProvider { ); let mut config = Configuration::default(); - let mut directories = vec![worktree_root.clone()]; - if let Some(subroot_relative_path) = subroot_relative_path { - debug_assert!(subroot_relative_path.is_relative()); - directories.push(worktree_root.join(subroot_relative_path)); - } - - config.workspace_directories = Some(directories); + debug_assert!(subroot_relative_path.is_relative()); + // `.ancestors()` will yield at least one path, so in case of empty `subroot_relative_path`, we'll just use + // worktree root as the workspace directory. + config.workspace_directories = Some( + subroot_relative_path + .ancestors() + .map(|ancestor| worktree_root.join(ancestor)) + .collect(), + ); for locator in locators.iter() { locator.configure(&config); } @@ -791,7 +794,7 @@ impl ToolchainLister for PythonToolchainProvider { .map_or(Vec::new(), |mut guard| std::mem::take(&mut guard)); let wr = worktree_root; - let wr_venv = get_worktree_venv_declaration(&wr); + let wr_venv = get_worktree_venv_declaration(&wr).await; // Sort detected environments by: // environment name matching activation file (/.venv) // environment project dir matching worktree_root @@ -856,7 +859,7 @@ impl ToolchainLister for PythonToolchainProvider { .into_iter() .filter_map(|toolchain| { let mut name = String::from("Python"); - if let Some(ref version) = toolchain.version { + if let Some(version) = &toolchain.version { _ = write!(name, " {version}"); } @@ -877,7 +880,7 @@ impl ToolchainLister for PythonToolchainProvider { name: name.into(), path: toolchain.executable.as_ref()?.to_str()?.to_owned().into(), language_name: LanguageName::new("Python"), - as_json: serde_json::to_value(toolchain).ok()?, + as_json: serde_json::to_value(toolchain.clone()).ok()?, }) }) .collect(); @@ -891,6 +894,23 @@ impl ToolchainLister for PythonToolchainProvider { fn term(&self) -> SharedString { self.term.clone() } + async fn activation_script(&self, toolchain: &Toolchain, fs: &dyn Fs) -> Option { + let toolchain = serde_json::from_value::( + toolchain.as_json.clone(), + ) + .ok()?; + let mut activation_script = None; + if let Some(prefix) = &toolchain.prefix { + #[cfg(not(target_os = "windows"))] + let path = prefix.join(BINARY_DIR).join("activate"); + #[cfg(target_os = "windows")] + let path = prefix.join(BINARY_DIR).join("activate.ps1"); + if fs.is_file(&path).await { + activation_script = Some(format!(". {}", path.display())); + } + } + activation_script + } } pub struct EnvironmentApi<'a> { diff --git a/crates/languages/src/rust/highlights.scm b/crates/languages/src/rust/highlights.scm index 1c46061827cd504df669aadacd0a489172d1ce5a..3f44c5fc0e46d280f63d0b212cc237ba4cbb0e8b 100644 --- a/crates/languages/src/rust/highlights.scm +++ b/crates/languages/src/rust/highlights.scm @@ -195,12 +195,13 @@ operator: "/" @operator (attribute_item (attribute [ (identifier) @attribute (scoped_identifier name: (identifier) @attribute) + (token_tree (identifier) @attribute (#match? @attribute "^[a-z\\d_]*$")) + (token_tree (identifier) @variable "::" (identifier) @type (#match? @type "^[A-Z]")) ])) + (inner_attribute_item (attribute [ (identifier) @attribute (scoped_identifier name: (identifier) @attribute) + (token_tree (identifier) @attribute (#match? @attribute "^[a-z\\d_]*$")) + (token_tree (identifier) @variable "::" (identifier) @type (#match? @type "^[A-Z]")) ])) -; Match nested snake case identifiers in attribute items. -(token_tree (identifier) @attribute (#match? @attribute "^[a-z\\d_]*$")) -; Override the attribute match for paths in scoped type/enum identifiers. -(token_tree (identifier) @variable "::" (identifier) @type (#match? @type "^[A-Z]")) diff --git a/crates/languages/src/tsx/config.toml b/crates/languages/src/tsx/config.toml index 5849b9842fd7f3483f89bbedbdb7b74b3fc1572d..b5ef5bd56df2097bc920f02b87d07e4118d7b0d1 100644 --- a/crates/languages/src/tsx/config.toml +++ b/crates/languages/src/tsx/config.toml @@ -4,6 +4,7 @@ path_suffixes = ["tsx"] line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } +wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "" } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, diff --git a/crates/languages/src/typescript/config.toml b/crates/languages/src/typescript/config.toml index d7e3e4bd3d1569f96636b7f7572deea306b46df7..2344f6209da7756049438669ee55d5376fdb47f8 100644 --- a/crates/languages/src/typescript/config.toml +++ b/crates/languages/src/typescript/config.toml @@ -5,6 +5,7 @@ first_line_pattern = '^#!.*\b(?:deno run|ts-node|bun|tsx|[/ ]node)\b' line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } +wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "" } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 942225d09837c206f54aa324f9b58ec214f92ba2..1ad89db017bc9a0c6f9009cba8ad22f94a31c65d 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -1383,7 +1383,8 @@ impl LanguageServer { self.notify::(¶ms).ok(); } } - /// Add new workspace folder to the list. + + /// Remove existing workspace folder from the list. pub fn remove_workspace_folder(&self, uri: Url) { if self .capabilities() diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index f16da45d799fc9d3b988e76d51b26f89223ef596..1f607a033ae08b67f1c2cb66d5ed9d9efd316971 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1323,7 +1323,7 @@ fn render_copy_code_block_button( .icon_size(IconSize::Small) .style(ButtonStyle::Filled) .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::text("Copy Code")) + .tooltip(Tooltip::text("Copy")) .on_click({ let markdown = markdown; move |_event, _window, cx| { diff --git a/crates/mistral/src/mistral.rs b/crates/mistral/src/mistral.rs index 5b4d05377c7132f47828aa6afafbb5c850e940a8..55986e7e5bfd69ec91f11089753562e9e1984fcc 100644 --- a/crates/mistral/src/mistral.rs +++ b/crates/mistral/src/mistral.rs @@ -482,7 +482,7 @@ pub async fn stream_completion( .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_key)); + .header("Authorization", format!("Bearer {}", api_key.trim())); let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; let mut response = client.send(request).await?; diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 884374a72fe8b71bc55803b800c9429c19a96d5e..873dd63201423bba8995136e2fde82551966b3dd 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -850,13 +850,19 @@ impl workspace::SerializableItem for Onboarding { } mod persistence { - use db::{define_connection, query, sqlez_macros::sql}; + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; use workspace::WorkspaceDb; - define_connection! { - pub static ref ONBOARDING_PAGES: OnboardingPagesDb = - &[ - sql!( + pub struct OnboardingPagesDb(ThreadSafeConnection); + + impl Domain for OnboardingPagesDb { + const NAME: &str = stringify!(OnboardingPagesDb); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE onboarding_pages ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -866,10 +872,11 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - ), - ]; + )]; } + db::static_connection!(ONBOARDING_PAGES, OnboardingPagesDb, [WorkspaceDb]); + impl OnboardingPagesDb { query! { pub async fn save_onboarding_page( diff --git a/crates/onboarding/src/welcome.rs b/crates/onboarding/src/welcome.rs index 3fe9c32a48c4f6ee9cb3756e08b1eb9a836657dc..8ff55d812b007d1b210781ec747b30cd1f505f35 100644 --- a/crates/onboarding/src/welcome.rs +++ b/crates/onboarding/src/welcome.rs @@ -414,13 +414,19 @@ impl workspace::SerializableItem for WelcomePage { } mod persistence { - use db::{define_connection, query, sqlez_macros::sql}; + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; use workspace::WorkspaceDb; - define_connection! { - pub static ref WELCOME_PAGES: WelcomePagesDb = - &[ - sql!( + pub struct WelcomePagesDb(ThreadSafeConnection); + + impl Domain for WelcomePagesDb { + const NAME: &str = stringify!(WelcomePagesDb); + + const MIGRATIONS: &[&str] = (&[sql!( CREATE TABLE welcome_pages ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -430,10 +436,11 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - ), - ]; + )]); } + db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]); + impl WelcomePagesDb { query! { pub async fn save_welcome_page( diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 08be82b8303291f9fa7795b2f04cbe8392d6d581..f9a983b433b9d918424f9696269dd0bbd72adefd 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -461,7 +461,7 @@ pub async fn stream_completion( .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_key)); + .header("Authorization", format!("Bearer {}", api_key.trim())); let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; let mut response = client.send(request).await?; @@ -565,7 +565,7 @@ pub fn embed<'a>( .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_key)) + .header("Authorization", format!("Bearer {}", api_key.trim())) .body(body) .map(|request| client.send(request)); diff --git a/crates/open_router/src/open_router.rs b/crates/open_router/src/open_router.rs index b7e6d69d8fbe7c342e833cd13ad069399fb44a26..65ef519d2c887e57e67d68ca6fcaea64ad67ee3e 100644 --- a/crates/open_router/src/open_router.rs +++ b/crates/open_router/src/open_router.rs @@ -424,7 +424,7 @@ pub async fn complete( .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_key)) + .header("Authorization", format!("Bearer {}", api_key.trim())) .header("HTTP-Referer", "https://zed.dev") .header("X-Title", "Zed Editor"); diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 822c8920816523d85aef16e56ab015e28d0ec465..c2d7c837b57956631de37946f9894807b0fd1a26 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -5100,9 +5100,9 @@ impl EventEmitter for OutlinePanel {} impl Render for OutlinePanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let (is_local, is_via_ssh) = self - .project - .read_with(cx, |project, _| (project.is_local(), project.is_via_ssh())); + let (is_local, is_via_ssh) = self.project.read_with(cx, |project, _| { + (project.is_local(), project.is_via_remote_server()) + }); let query = self.query(cx); let pinned = self.pinned; let settings = OutlinePanelSettings::get_global(cx); diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index aab0354c9696f8bcdde5fd4bb00bd3651ac4b888..c2c3c89305939bc32c635549c23d64d565f8fbb0 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -63,7 +63,7 @@ pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf { let abs_path = path .canonicalize() .expect("failed to canonicalize custom data directory's path to an absolute path"); - path = PathBuf::from(util::paths::SanitizedPath::from(abs_path)) + path = util::paths::SanitizedPath::new(&abs_path).into() } std::fs::create_dir_all(&path).expect("failed to create custom data directory"); path diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs index 2906c32ff4f67ba733d4f0faf8f511d0c433ec91..859574c82a5b4470d477df555b314498cbfcd0e0 100644 --- a/crates/project/src/debugger/dap_store.rs +++ b/crates/project/src/debugger/dap_store.rs @@ -5,11 +5,8 @@ use super::{ session::{self, Session, SessionStateEvent}, }; use crate::{ - InlayHint, InlayHintLabel, ProjectEnvironment, ResolveState, - debugger::session::SessionQuirks, - project_settings::ProjectSettings, - terminals::{SshCommand, wrap_for_ssh}, - worktree_store::WorktreeStore, + InlayHint, InlayHintLabel, ProjectEnvironment, ResolveState, debugger::session::SessionQuirks, + project_settings::ProjectSettings, worktree_store::WorktreeStore, }; use anyhow::{Context as _, Result, anyhow}; use async_trait::async_trait; @@ -34,7 +31,7 @@ use http_client::HttpClient; use language::{Buffer, LanguageToolchainStore, language_settings::InlayHintKind}; use node_runtime::NodeRuntime; -use remote::{SshInfo, SshRemoteClient, ssh_session::SshArgs}; +use remote::RemoteClient; use rpc::{ AnyProtoClient, TypedEnvelope, proto::{self}, @@ -68,7 +65,7 @@ pub enum DapStoreEvent { enum DapStoreMode { Local(LocalDapStore), - Ssh(SshDapStore), + Remote(RemoteDapStore), Collab, } @@ -80,8 +77,8 @@ pub struct LocalDapStore { toolchain_store: Arc, } -pub struct SshDapStore { - ssh_client: Entity, +pub struct RemoteDapStore { + remote_client: Entity, upstream_client: AnyProtoClient, upstream_project_id: u64, } @@ -147,16 +144,16 @@ impl DapStore { Self::new(mode, breakpoint_store, worktree_store, cx) } - pub fn new_ssh( + pub fn new_remote( project_id: u64, - ssh_client: Entity, + remote_client: Entity, breakpoint_store: Entity, worktree_store: Entity, cx: &mut Context, ) -> Self { - let mode = DapStoreMode::Ssh(SshDapStore { - upstream_client: ssh_client.read(cx).proto_client(), - ssh_client, + let mode = DapStoreMode::Remote(RemoteDapStore { + upstream_client: remote_client.read(cx).proto_client(), + remote_client, upstream_project_id: project_id, }); @@ -242,64 +239,52 @@ impl DapStore { Ok(binary) }) } - DapStoreMode::Ssh(ssh) => { - let request = ssh.upstream_client.request(proto::GetDebugAdapterBinary { - session_id: session_id.to_proto(), - project_id: ssh.upstream_project_id, - worktree_id: worktree.read(cx).id().to_proto(), - definition: Some(definition.to_proto()), - }); - let ssh_client = ssh.ssh_client.clone(); + DapStoreMode::Remote(remote) => { + let request = remote + .upstream_client + .request(proto::GetDebugAdapterBinary { + session_id: session_id.to_proto(), + project_id: remote.upstream_project_id, + worktree_id: worktree.read(cx).id().to_proto(), + definition: Some(definition.to_proto()), + }); + let remote = remote.remote_client.clone(); cx.spawn(async move |_, cx| { let response = request.await?; let binary = DebugAdapterBinary::from_proto(response)?; - let (mut ssh_command, envs, path_style, ssh_shell) = - ssh_client.read_with(cx, |ssh, _| { - let SshInfo { - args: SshArgs { arguments, envs }, - path_style, - shell, - } = ssh.ssh_info().context("SSH arguments not found")?; - anyhow::Ok(( - SshCommand { arguments }, - envs.unwrap_or_default(), - path_style, - shell, - )) - })??; - - let mut connection = None; - if let Some(c) = binary.connection { - let local_bind_addr = Ipv4Addr::LOCALHOST; - let port = - dap::transport::TcpTransport::unused_port(local_bind_addr).await?; - ssh_command.add_port_forwarding(port, c.host.to_string(), c.port); + let port_forwarding; + let connection; + if let Some(c) = binary.connection { + let host = Ipv4Addr::LOCALHOST; + let port = dap::transport::TcpTransport::unused_port(host).await?; + port_forwarding = Some((port, c.host.to_string(), c.port)); connection = Some(TcpArguments { port, - host: local_bind_addr, + host, timeout: c.timeout, }) + } else { + port_forwarding = None; + connection = None; } - let (program, args) = wrap_for_ssh( - &ssh_shell, - &ssh_command, - binary - .command - .as_ref() - .map(|command| (command, &binary.arguments)), - binary.cwd.as_deref(), - binary.envs, - None, - path_style, - ); + let command = remote.read_with(cx, |remote, _cx| { + remote.build_command( + binary.command, + &binary.arguments, + &binary.envs, + binary.cwd.map(|path| path.display().to_string()), + None, + port_forwarding, + ) + })??; Ok(DebugAdapterBinary { - command: Some(program), - arguments: args, - envs, + command: Some(command.program), + arguments: command.args, + envs: command.env, cwd: None, connection, request_args: binary.request_args, @@ -365,9 +350,9 @@ impl DapStore { ))) } } - DapStoreMode::Ssh(ssh) => { - let request = ssh.upstream_client.request(proto::RunDebugLocators { - project_id: ssh.upstream_project_id, + DapStoreMode::Remote(remote) => { + let request = remote.upstream_client.request(proto::RunDebugLocators { + project_id: remote.upstream_project_id, build_command: Some(build_command.to_proto()), locator: locator_name.to_owned(), }); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 51ab3252cbec04db5cef93f97dba7c77a22daee9..db42f1d95c9db88136c4c7aa9e4b5d450a90ccd6 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -44,7 +44,7 @@ use parking_lot::Mutex; use postage::stream::Stream as _; use rpc::{ AnyProtoClient, TypedEnvelope, - proto::{self, FromProto, SSH_PROJECT_ID, ToProto, git_reset, split_repository_update}, + proto::{self, FromProto, ToProto, git_reset, split_repository_update}, }; use serde::Deserialize; use std::{ @@ -62,7 +62,7 @@ use std::{ }; use sum_tree::{Edit, SumTree, TreeSet}; use text::{Bias, BufferId}; -use util::{ResultExt, debug_panic, post_inc}; +use util::{ResultExt, debug_panic, paths::SanitizedPath, post_inc}; use worktree::{ File, PathChange, PathKey, PathProgress, PathSummary, PathTarget, ProjectEntryId, UpdatedGitRepositoriesSet, UpdatedGitRepository, Worktree, @@ -141,14 +141,10 @@ enum GitStoreState { project_environment: Entity, fs: Arc, }, - Ssh { - upstream_client: AnyProtoClient, - upstream_project_id: ProjectId, - downstream: Option<(AnyProtoClient, ProjectId)>, - }, Remote { upstream_client: AnyProtoClient, - upstream_project_id: ProjectId, + upstream_project_id: u64, + downstream: Option<(AnyProtoClient, ProjectId)>, }, } @@ -355,7 +351,7 @@ impl GitStore { worktree_store: &Entity, buffer_store: Entity, upstream_client: AnyProtoClient, - project_id: ProjectId, + project_id: u64, cx: &mut Context, ) -> Self { Self::new( @@ -364,23 +360,6 @@ impl GitStore { GitStoreState::Remote { upstream_client, upstream_project_id: project_id, - }, - cx, - ) - } - - pub fn ssh( - worktree_store: &Entity, - buffer_store: Entity, - upstream_client: AnyProtoClient, - cx: &mut Context, - ) -> Self { - Self::new( - worktree_store.clone(), - buffer_store, - GitStoreState::Ssh { - upstream_client, - upstream_project_id: ProjectId(SSH_PROJECT_ID), downstream: None, }, cx, @@ -451,7 +430,7 @@ impl GitStore { pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context) { match &mut self.state { - GitStoreState::Ssh { + GitStoreState::Remote { downstream: downstream_client, .. } => { @@ -527,9 +506,6 @@ impl GitStore { }), }); } - GitStoreState::Remote { .. } => { - debug_panic!("shared called on remote store"); - } } } @@ -541,15 +517,12 @@ impl GitStore { } => { downstream_client.take(); } - GitStoreState::Ssh { + GitStoreState::Remote { downstream: downstream_client, .. } => { downstream_client.take(); } - GitStoreState::Remote { .. } => { - debug_panic!("unshared called on remote store"); - } } self.shared_diffs.clear(); } @@ -1047,21 +1020,17 @@ impl GitStore { } => downstream_client .as_ref() .map(|state| (state.client.clone(), state.project_id)), - GitStoreState::Ssh { + GitStoreState::Remote { downstream: downstream_client, .. } => downstream_client.clone(), - GitStoreState::Remote { .. } => None, } } fn upstream_client(&self) -> Option { match &self.state { GitStoreState::Local { .. } => None, - GitStoreState::Ssh { - upstream_client, .. - } - | GitStoreState::Remote { + GitStoreState::Remote { upstream_client, .. } => Some(upstream_client.clone()), } @@ -1431,12 +1400,7 @@ impl GitStore { cx.background_executor() .spawn(async move { fs.git_init(&path, fallback_branch_name) }) } - GitStoreState::Ssh { - upstream_client, - upstream_project_id: project_id, - .. - } - | GitStoreState::Remote { + GitStoreState::Remote { upstream_client, upstream_project_id: project_id, .. @@ -1446,7 +1410,7 @@ impl GitStore { cx.background_executor().spawn(async move { client .request(proto::GitInit { - project_id: project_id.0, + project_id: project_id, abs_path: path.to_string_lossy().to_string(), fallback_branch_name, }) @@ -1470,13 +1434,18 @@ impl GitStore { cx.background_executor() .spawn(async move { fs.git_clone(&repo, &path).await }) } - GitStoreState::Ssh { + GitStoreState::Remote { upstream_client, upstream_project_id, .. } => { + if upstream_client.is_via_collab() { + return Task::ready(Err(anyhow!( + "Git Clone isn't supported for project guests" + ))); + } let request = upstream_client.request(proto::GitClone { - project_id: upstream_project_id.0, + project_id: *upstream_project_id, abs_path: path.to_string_lossy().to_string(), remote_repo: repo, }); @@ -1490,9 +1459,6 @@ impl GitStore { } }) } - GitStoreState::Remote { .. } => { - Task::ready(Err(anyhow!("Git Clone isn't supported for remote users"))) - } } } @@ -3267,6 +3233,7 @@ impl Repository { let git_store = self.git_store.upgrade()?; let worktree_store = git_store.read(cx).worktree_store.read(cx); let abs_path = self.snapshot.work_directory_abs_path.join(&path.0); + let abs_path = SanitizedPath::new(&abs_path); let (worktree, relative_path) = worktree_store.find_worktree(abs_path, cx)?; Some(ProjectPath { worktree_id: worktree.read(cx).id(), diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index deebaedd74a9a56bba27632a640443e03d5f5517..b4c7c0bc37fc0409570ece3c5e3df00b1b1cd89f 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -11,18 +11,22 @@ //! Most of the interesting work happens at the local layer, as bulk of the complexity is with managing the lifecycle of language servers. The actual implementation of the LSP protocol is handled by [`lsp`] crate. pub mod clangd_ext; pub mod json_language_server_ext; +pub mod log_store; pub mod lsp_ext_command; pub mod rust_analyzer_ext; use crate::{ CodeAction, ColorPresentation, Completion, CompletionResponse, CompletionSource, CoreCompletion, DocumentColor, Hover, InlayHint, LocationLink, LspAction, LspPullDiagnostics, - ManifestProvidersStore, ProjectItem, ProjectPath, ProjectTransaction, PulledDiagnostics, - ResolveState, Symbol, + ManifestProvidersStore, Project, ProjectItem, ProjectPath, ProjectTransaction, + PulledDiagnostics, ResolveState, Symbol, buffer_store::{BufferStore, BufferStoreEvent}, environment::ProjectEnvironment, lsp_command::{self, *}, - lsp_store, + lsp_store::{ + self, + log_store::{GlobalLogStore, LanguageServerKind}, + }, manifest_tree::{ LanguageServerTree, LanguageServerTreeNode, LaunchDisposition, ManifestQueryDelegate, ManifestTree, @@ -977,7 +981,9 @@ impl LocalLspStore { this.update(&mut cx, |_, cx| { cx.emit(LspStoreEvent::LanguageServerLog( server_id, - LanguageServerLogType::Trace(params.verbose), + LanguageServerLogType::Trace { + verbose_info: params.verbose, + }, params.message, )); }) @@ -3180,7 +3186,7 @@ impl LocalLspStore { } else { let (path, pattern) = match &watcher.glob_pattern { lsp::GlobPattern::String(s) => { - let watcher_path = SanitizedPath::from(s); + let watcher_path = SanitizedPath::new(s); let path = glob_literal_prefix(watcher_path.as_path()); let pattern = watcher_path .as_path() @@ -3272,7 +3278,7 @@ impl LocalLspStore { let worktree_root_path = tree.abs_path(); match &watcher.glob_pattern { lsp::GlobPattern::String(s) => { - let watcher_path = SanitizedPath::from(s); + let watcher_path = SanitizedPath::new(s); let relative = watcher_path .as_path() .strip_prefix(&worktree_root_path) @@ -3482,13 +3488,13 @@ pub struct LspStore { buffer_store: Entity, worktree_store: Entity, pub languages: Arc, - language_server_statuses: BTreeMap, + pub language_server_statuses: BTreeMap, active_entry: Option, _maintain_workspace_config: (Task>, watch::Sender<()>), _maintain_buffer_languages: Task<()>, diagnostic_summaries: HashMap, HashMap>>, - pub(super) lsp_server_capabilities: HashMap, + pub lsp_server_capabilities: HashMap, lsp_document_colors: HashMap, lsp_code_lens: HashMap, running_lsp_requests: HashMap>)>, @@ -3565,6 +3571,7 @@ pub struct LanguageServerStatus { pub pending_work: BTreeMap, pub has_pending_diagnostic_updates: bool, progress_tokens: HashSet, + pub worktree: Option, } #[derive(Clone, Debug)] @@ -7483,7 +7490,7 @@ impl LspStore { server: Some(proto::LanguageServer { id: server_id.to_proto(), name: status.name.to_string(), - worktree_id: None, + worktree_id: status.worktree.map(|id| id.to_proto()), }), capabilities: serde_json::to_string(&server.capabilities()) .expect("serializing server LSP capabilities"), @@ -7508,9 +7515,15 @@ impl LspStore { pub(crate) fn set_language_server_statuses_from_proto( &mut self, + project: WeakEntity, language_servers: Vec, server_capabilities: Vec, + cx: &mut Context, ) { + let lsp_logs = cx + .try_global::() + .map(|lsp_store| lsp_store.0.clone()); + self.language_server_statuses = language_servers .into_iter() .zip(server_capabilities) @@ -7520,13 +7533,34 @@ impl LspStore { self.lsp_server_capabilities .insert(server_id, server_capabilities); } + + let name = LanguageServerName::from_proto(server.name); + let worktree = server.worktree_id.map(WorktreeId::from_proto); + + if let Some(lsp_logs) = &lsp_logs { + lsp_logs.update(cx, |lsp_logs, cx| { + lsp_logs.add_language_server( + // Only remote clients get their language servers set from proto + LanguageServerKind::Remote { + project: project.clone(), + }, + server_id, + Some(name.clone()), + worktree, + None, + cx, + ); + }); + } + ( server_id, LanguageServerStatus { - name: LanguageServerName::from_proto(server.name), + name, pending_work: Default::default(), has_pending_diagnostic_updates: false, progress_tokens: Default::default(), + worktree, }, ) }) @@ -8892,6 +8926,7 @@ impl LspStore { pending_work: Default::default(), has_pending_diagnostic_updates: false, progress_tokens: Default::default(), + worktree: server.worktree_id.map(WorktreeId::from_proto), }, ); cx.emit(LspStoreEvent::LanguageServerAdded( @@ -10905,6 +10940,7 @@ impl LspStore { pending_work: Default::default(), has_pending_diagnostic_updates: false, progress_tokens: Default::default(), + worktree: Some(key.worktree_id), }, ); @@ -11702,6 +11738,20 @@ impl LspStore { "workspace/didChangeConfiguration" => { // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings. } + "workspace/didChangeWorkspaceFolders" => { + // In this case register options is an empty object, we can ignore it + let caps = lsp::WorkspaceFoldersServerCapabilities { + supported: Some(true), + change_notifications: Some(OneOf::Right(reg.id)), + }; + server.update_capabilities(|capabilities| { + capabilities + .workspace + .get_or_insert_default() + .workspace_folders = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } "workspace/symbol" => { let options = parse_register_capabilities(reg)?; server.update_capabilities(|capabilities| { @@ -11778,17 +11828,15 @@ impl LspStore { notify_server_capabilities_updated(&server, cx); } "textDocument/codeAction" => { - if let Some(options) = reg - .register_options - .map(serde_json::from_value) - .transpose()? - { - server.update_capabilities(|capabilities| { - capabilities.code_action_provider = - Some(lsp::CodeActionProviderCapability::Options(options)); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + let provider = match options { + OneOf::Left(value) => lsp::CodeActionProviderCapability::Simple(value), + OneOf::Right(caps) => caps, + }; + server.update_capabilities(|capabilities| { + capabilities.code_action_provider = Some(provider); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/definition" => { let options = parse_register_capabilities(reg)?; @@ -11810,16 +11858,15 @@ impl LspStore { } } "textDocument/hover" => { - if let Some(caps) = reg - .register_options - .map(serde_json::from_value) - .transpose()? - { - server.update_capabilities(|capabilities| { - capabilities.hover_provider = Some(caps); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + let provider = match options { + OneOf::Left(value) => lsp::HoverProviderCapability::Simple(value), + OneOf::Right(caps) => caps, + }; + server.update_capabilities(|capabilities| { + capabilities.hover_provider = Some(provider); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/signatureHelp" => { if let Some(caps) = reg @@ -11904,16 +11951,15 @@ impl LspStore { } } "textDocument/documentColor" => { - if let Some(caps) = reg - .register_options - .map(serde_json::from_value) - .transpose()? - { - server.update_capabilities(|capabilities| { - capabilities.color_provider = Some(caps); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + let provider = match options { + OneOf::Left(value) => lsp::ColorProviderCapability::Simple(value), + OneOf::Right(caps) => caps, + }; + server.update_capabilities(|capabilities| { + capabilities.color_provider = Some(provider); + }); + notify_server_capabilities_updated(&server, cx); } _ => log::warn!("unhandled capability registration: {reg:?}"), } @@ -11948,6 +11994,18 @@ impl LspStore { "workspace/didChangeConfiguration" => { // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings. } + "workspace/didChangeWorkspaceFolders" => { + server.update_capabilities(|capabilities| { + capabilities + .workspace + .get_or_insert_with(|| lsp::WorkspaceServerCapabilities { + workspace_folders: None, + file_operations: None, + }) + .workspace_folders = None; + }); + notify_server_capabilities_updated(&server, cx); + } "workspace/symbol" => { server.update_capabilities(|capabilities| { capabilities.workspace_symbol_provider = None @@ -12168,6 +12226,14 @@ impl LspStore { let data = self.lsp_code_lens.get_mut(&buffer_id)?; Some(data.update.take()?.1) } + + pub fn downstream_client(&self) -> Option<(AnyProtoClient, u64)> { + self.downstream_client.clone() + } + + pub fn worktree_store(&self) -> Entity { + self.worktree_store.clone() + } } // Registration with registerOptions as null, should fallback to true. @@ -12677,45 +12743,69 @@ impl PartialEq for LanguageServerPromptRequest { #[derive(Clone, Debug, PartialEq)] pub enum LanguageServerLogType { Log(MessageType), - Trace(Option), + Trace { verbose_info: Option }, + Rpc { received: bool }, } impl LanguageServerLogType { pub fn to_proto(&self) -> proto::language_server_log::LogType { match self { Self::Log(log_type) => { - let message_type = match *log_type { - MessageType::ERROR => 1, - MessageType::WARNING => 2, - MessageType::INFO => 3, - MessageType::LOG => 4, + use proto::log_message::LogLevel; + let level = match *log_type { + MessageType::ERROR => LogLevel::Error, + MessageType::WARNING => LogLevel::Warning, + MessageType::INFO => LogLevel::Info, + MessageType::LOG => LogLevel::Log, other => { - log::warn!("Unknown lsp log message type: {:?}", other); - 4 + log::warn!("Unknown lsp log message type: {other:?}"); + LogLevel::Log } }; - proto::language_server_log::LogType::LogMessageType(message_type) + proto::language_server_log::LogType::Log(proto::LogMessage { + level: level as i32, + }) } - Self::Trace(message) => { - proto::language_server_log::LogType::LogTrace(proto::LspLogTrace { - message: message.clone(), + Self::Trace { verbose_info } => { + proto::language_server_log::LogType::Trace(proto::TraceMessage { + verbose_info: verbose_info.to_owned(), }) } + Self::Rpc { received } => { + let kind = if *received { + proto::rpc_message::Kind::Received + } else { + proto::rpc_message::Kind::Sent + }; + let kind = kind as i32; + proto::language_server_log::LogType::Rpc(proto::RpcMessage { kind }) + } } } pub fn from_proto(log_type: proto::language_server_log::LogType) -> Self { + use proto::log_message::LogLevel; + use proto::rpc_message; match log_type { - proto::language_server_log::LogType::LogMessageType(message_type) => { - Self::Log(match message_type { - 1 => MessageType::ERROR, - 2 => MessageType::WARNING, - 3 => MessageType::INFO, - 4 => MessageType::LOG, - _ => MessageType::LOG, - }) - } - proto::language_server_log::LogType::LogTrace(trace) => Self::Trace(trace.message), + proto::language_server_log::LogType::Log(message_type) => Self::Log( + match LogLevel::from_i32(message_type.level).unwrap_or(LogLevel::Log) { + LogLevel::Error => MessageType::ERROR, + LogLevel::Warning => MessageType::WARNING, + LogLevel::Info => MessageType::INFO, + LogLevel::Log => MessageType::LOG, + }, + ), + proto::language_server_log::LogType::Trace(trace_message) => Self::Trace { + verbose_info: trace_message.verbose_info, + }, + proto::language_server_log::LogType::Rpc(message) => Self::Rpc { + received: match rpc_message::Kind::from_i32(message.kind) + .unwrap_or(rpc_message::Kind::Received) + { + rpc_message::Kind::Received => true, + rpc_message::Kind::Sent => false, + }, + }, } } } diff --git a/crates/project/src/lsp_store/log_store.rs b/crates/project/src/lsp_store/log_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..1fbdb494a303b47bea181c5046e51f3c0b21c5c1 --- /dev/null +++ b/crates/project/src/lsp_store/log_store.rs @@ -0,0 +1,704 @@ +use std::{collections::VecDeque, sync::Arc}; + +use collections::HashMap; +use futures::{StreamExt, channel::mpsc}; +use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, Subscription, WeakEntity}; +use lsp::{ + IoKind, LanguageServer, LanguageServerId, LanguageServerName, LanguageServerSelector, + MessageType, TraceValue, +}; +use rpc::proto; +use settings::WorktreeId; + +use crate::{LanguageServerLogType, LspStore, Project, ProjectItem as _}; + +const SEND_LINE: &str = "\n// Send:"; +const RECEIVE_LINE: &str = "\n// Receive:"; +const MAX_STORED_LOG_ENTRIES: usize = 2000; + +const RPC_MESSAGES: &str = "RPC Messages"; +const SERVER_LOGS: &str = "Server Logs"; +const SERVER_TRACE: &str = "Server Trace"; +const SERVER_INFO: &str = "Server Info"; + +pub fn init(store_logs: bool, cx: &mut App) -> Entity { + let log_store = cx.new(|cx| LogStore::new(store_logs, cx)); + cx.set_global(GlobalLogStore(log_store.clone())); + log_store +} + +pub struct GlobalLogStore(pub Entity); + +impl Global for GlobalLogStore {} + +#[derive(Debug)] +pub enum Event { + NewServerLogEntry { + id: LanguageServerId, + kind: LanguageServerLogType, + text: String, + }, +} + +impl EventEmitter for LogStore {} + +pub struct LogStore { + store_logs: bool, + projects: HashMap, ProjectState>, + pub copilot_log_subscription: Option, + pub language_servers: HashMap, + io_tx: mpsc::UnboundedSender<(LanguageServerId, IoKind, String)>, +} + +struct ProjectState { + _subscriptions: [Subscription; 2], +} + +pub trait Message: AsRef { + type Level: Copy + std::fmt::Debug; + fn should_include(&self, _: Self::Level) -> bool { + true + } +} + +#[derive(Debug)] +pub struct LogMessage { + message: String, + typ: MessageType, +} + +impl AsRef for LogMessage { + fn as_ref(&self) -> &str { + &self.message + } +} + +impl Message for LogMessage { + type Level = MessageType; + + fn should_include(&self, level: Self::Level) -> bool { + match (self.typ, level) { + (MessageType::ERROR, _) => true, + (_, MessageType::ERROR) => false, + (MessageType::WARNING, _) => true, + (_, MessageType::WARNING) => false, + (MessageType::INFO, _) => true, + (_, MessageType::INFO) => false, + _ => true, + } + } +} + +#[derive(Debug)] +pub struct TraceMessage { + message: String, + is_verbose: bool, +} + +impl AsRef for TraceMessage { + fn as_ref(&self) -> &str { + &self.message + } +} + +impl Message for TraceMessage { + type Level = TraceValue; + + fn should_include(&self, level: Self::Level) -> bool { + match level { + TraceValue::Off => false, + TraceValue::Messages => !self.is_verbose, + TraceValue::Verbose => true, + } + } +} + +#[derive(Debug)] +pub struct RpcMessage { + message: String, +} + +impl AsRef for RpcMessage { + fn as_ref(&self) -> &str { + &self.message + } +} + +impl Message for RpcMessage { + type Level = (); +} + +pub struct LanguageServerState { + pub name: Option, + pub worktree_id: Option, + pub kind: LanguageServerKind, + log_messages: VecDeque, + trace_messages: VecDeque, + pub rpc_state: Option, + pub trace_level: TraceValue, + pub log_level: MessageType, + io_logs_subscription: Option, +} + +impl std::fmt::Debug for LanguageServerState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LanguageServerState") + .field("name", &self.name) + .field("worktree_id", &self.worktree_id) + .field("kind", &self.kind) + .field("log_messages", &self.log_messages) + .field("trace_messages", &self.trace_messages) + .field("rpc_state", &self.rpc_state) + .field("trace_level", &self.trace_level) + .field("log_level", &self.log_level) + .finish_non_exhaustive() + } +} + +#[derive(PartialEq, Clone)] +pub enum LanguageServerKind { + Local { project: WeakEntity }, + Remote { project: WeakEntity }, + LocalSsh { lsp_store: WeakEntity }, + Global, +} + +impl std::fmt::Debug for LanguageServerKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LanguageServerKind::Local { .. } => write!(f, "LanguageServerKind::Local"), + LanguageServerKind::Remote { .. } => write!(f, "LanguageServerKind::Remote"), + LanguageServerKind::LocalSsh { .. } => write!(f, "LanguageServerKind::LocalSsh"), + LanguageServerKind::Global => write!(f, "LanguageServerKind::Global"), + } + } +} + +impl LanguageServerKind { + pub fn project(&self) -> Option<&WeakEntity> { + match self { + Self::Local { project } => Some(project), + Self::Remote { project } => Some(project), + Self::LocalSsh { .. } => None, + Self::Global { .. } => None, + } + } +} + +#[derive(Debug)] +pub struct LanguageServerRpcState { + pub rpc_messages: VecDeque, + last_message_kind: Option, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum MessageKind { + Send, + Receive, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum LogKind { + Rpc, + Trace, + #[default] + Logs, + ServerInfo, +} + +impl LogKind { + pub fn from_server_log_type(log_type: &LanguageServerLogType) -> Self { + match log_type { + LanguageServerLogType::Log(_) => Self::Logs, + LanguageServerLogType::Trace { .. } => Self::Trace, + LanguageServerLogType::Rpc { .. } => Self::Rpc, + } + } + + pub fn label(&self) -> &'static str { + match self { + LogKind::Rpc => RPC_MESSAGES, + LogKind::Trace => SERVER_TRACE, + LogKind::Logs => SERVER_LOGS, + LogKind::ServerInfo => SERVER_INFO, + } + } +} + +impl LogStore { + pub fn new(store_logs: bool, cx: &mut Context) -> Self { + let (io_tx, mut io_rx) = mpsc::unbounded(); + + let log_store = Self { + projects: HashMap::default(), + language_servers: HashMap::default(), + copilot_log_subscription: None, + store_logs, + io_tx, + }; + cx.spawn(async move |log_store, cx| { + while let Some((server_id, io_kind, message)) = io_rx.next().await { + if let Some(log_store) = log_store.upgrade() { + log_store.update(cx, |log_store, cx| { + log_store.on_io(server_id, io_kind, &message, cx); + })?; + } + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + log_store + } + + pub fn add_project(&mut self, project: &Entity, cx: &mut Context) { + let weak_project = project.downgrade(); + self.projects.insert( + project.downgrade(), + ProjectState { + _subscriptions: [ + cx.observe_release(project, move |this, _, _| { + this.projects.remove(&weak_project); + this.language_servers + .retain(|_, state| state.kind.project() != Some(&weak_project)); + }), + cx.subscribe(project, move |log_store, project, event, cx| { + let server_kind = if project.read(cx).is_local() { + LanguageServerKind::Local { + project: project.downgrade(), + } + } else { + LanguageServerKind::Remote { + project: project.downgrade(), + } + }; + match event { + crate::Event::LanguageServerAdded(id, name, worktree_id) => { + log_store.add_language_server( + server_kind, + *id, + Some(name.clone()), + *worktree_id, + project + .read(cx) + .lsp_store() + .read(cx) + .language_server_for_id(*id), + cx, + ); + } + crate::Event::LanguageServerBufferRegistered { + server_id, + buffer_id, + name, + .. + } => { + let worktree_id = project + .read(cx) + .buffer_for_id(*buffer_id, cx) + .and_then(|buffer| { + Some(buffer.read(cx).project_path(cx)?.worktree_id) + }); + let name = name.clone().or_else(|| { + project + .read(cx) + .lsp_store() + .read(cx) + .language_server_statuses + .get(server_id) + .map(|status| status.name.clone()) + }); + log_store.add_language_server( + server_kind, + *server_id, + name, + worktree_id, + None, + cx, + ); + } + crate::Event::LanguageServerRemoved(id) => { + log_store.remove_language_server(*id, cx); + } + crate::Event::LanguageServerLog(id, typ, message) => { + log_store.add_language_server( + server_kind, + *id, + None, + None, + None, + cx, + ); + match typ { + crate::LanguageServerLogType::Log(typ) => { + log_store.add_language_server_log(*id, *typ, message, cx); + } + crate::LanguageServerLogType::Trace { verbose_info } => { + log_store.add_language_server_trace( + *id, + message, + verbose_info.clone(), + cx, + ); + } + crate::LanguageServerLogType::Rpc { received } => { + let kind = if *received { + MessageKind::Receive + } else { + MessageKind::Send + }; + log_store.add_language_server_rpc(*id, kind, message, cx); + } + } + } + crate::Event::ToggleLspLogs { server_id, enabled } => { + // we do not support any other log toggling yet + if *enabled { + log_store.enable_rpc_trace_for_language_server(*server_id); + } else { + log_store.disable_rpc_trace_for_language_server(*server_id); + } + } + _ => {} + } + }), + ], + }, + ); + } + + pub fn get_language_server_state( + &mut self, + id: LanguageServerId, + ) -> Option<&mut LanguageServerState> { + self.language_servers.get_mut(&id) + } + + pub fn add_language_server( + &mut self, + kind: LanguageServerKind, + server_id: LanguageServerId, + name: Option, + worktree_id: Option, + server: Option>, + cx: &mut Context, + ) -> Option<&mut LanguageServerState> { + let server_state = self.language_servers.entry(server_id).or_insert_with(|| { + cx.notify(); + LanguageServerState { + name: None, + worktree_id: None, + kind, + rpc_state: None, + log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), + trace_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), + trace_level: TraceValue::Off, + log_level: MessageType::LOG, + io_logs_subscription: None, + } + }); + + if let Some(name) = name { + server_state.name = Some(name); + } + if let Some(worktree_id) = worktree_id { + server_state.worktree_id = Some(worktree_id); + } + + if let Some(server) = server.filter(|_| server_state.io_logs_subscription.is_none()) { + let io_tx = self.io_tx.clone(); + let server_id = server.server_id(); + server_state.io_logs_subscription = Some(server.on_io(move |io_kind, message| { + io_tx + .unbounded_send((server_id, io_kind, message.to_string())) + .ok(); + })); + } + + Some(server_state) + } + + pub fn add_language_server_log( + &mut self, + id: LanguageServerId, + typ: MessageType, + message: &str, + cx: &mut Context, + ) -> Option<()> { + let store_logs = self.store_logs; + let language_server_state = self.get_language_server_state(id)?; + + let log_lines = &mut language_server_state.log_messages; + let message = message.trim_end().to_string(); + if !store_logs { + // Send all messages regardless of the visibility in case of not storing, to notify the receiver anyway + self.emit_event( + Event::NewServerLogEntry { + id, + kind: LanguageServerLogType::Log(typ), + text: message, + }, + cx, + ); + } else if let Some(new_message) = Self::push_new_message( + log_lines, + LogMessage { message, typ }, + language_server_state.log_level, + ) { + self.emit_event( + Event::NewServerLogEntry { + id, + kind: LanguageServerLogType::Log(typ), + text: new_message, + }, + cx, + ); + } + Some(()) + } + + fn add_language_server_trace( + &mut self, + id: LanguageServerId, + message: &str, + verbose_info: Option, + cx: &mut Context, + ) -> Option<()> { + let store_logs = self.store_logs; + let language_server_state = self.get_language_server_state(id)?; + + let log_lines = &mut language_server_state.trace_messages; + if !store_logs { + // Send all messages regardless of the visibility in case of not storing, to notify the receiver anyway + self.emit_event( + Event::NewServerLogEntry { + id, + kind: LanguageServerLogType::Trace { verbose_info }, + text: message.trim().to_string(), + }, + cx, + ); + } else if let Some(new_message) = Self::push_new_message( + log_lines, + TraceMessage { + message: message.trim().to_string(), + is_verbose: false, + }, + TraceValue::Messages, + ) { + if let Some(verbose_message) = verbose_info.as_ref() { + Self::push_new_message( + log_lines, + TraceMessage { + message: verbose_message.clone(), + is_verbose: true, + }, + TraceValue::Verbose, + ); + } + self.emit_event( + Event::NewServerLogEntry { + id, + kind: LanguageServerLogType::Trace { verbose_info }, + text: new_message, + }, + cx, + ); + } + Some(()) + } + + fn push_new_message( + log_lines: &mut VecDeque, + message: T, + current_severity: ::Level, + ) -> Option { + while log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES { + log_lines.pop_front(); + } + let visible = message.should_include(current_severity); + + let visible_message = visible.then(|| message.as_ref().to_string()); + log_lines.push_back(message); + visible_message + } + + fn add_language_server_rpc( + &mut self, + language_server_id: LanguageServerId, + kind: MessageKind, + message: &str, + cx: &mut Context<'_, Self>, + ) { + let store_logs = self.store_logs; + let Some(state) = self + .get_language_server_state(language_server_id) + .and_then(|state| state.rpc_state.as_mut()) + else { + return; + }; + + let received = kind == MessageKind::Receive; + let rpc_log_lines = &mut state.rpc_messages; + if state.last_message_kind != Some(kind) { + while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES { + rpc_log_lines.pop_front(); + } + let line_before_message = match kind { + MessageKind::Send => SEND_LINE, + MessageKind::Receive => RECEIVE_LINE, + }; + if store_logs { + rpc_log_lines.push_back(RpcMessage { + message: line_before_message.to_string(), + }); + } + // Do not send a synthetic message over the wire, it will be derived from the actual RPC message + cx.emit(Event::NewServerLogEntry { + id: language_server_id, + kind: LanguageServerLogType::Rpc { received }, + text: line_before_message.to_string(), + }); + } + + while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES { + rpc_log_lines.pop_front(); + } + + if store_logs { + rpc_log_lines.push_back(RpcMessage { + message: message.trim().to_owned(), + }); + } + + self.emit_event( + Event::NewServerLogEntry { + id: language_server_id, + kind: LanguageServerLogType::Rpc { received }, + text: message.to_owned(), + }, + cx, + ); + } + + pub fn remove_language_server(&mut self, id: LanguageServerId, cx: &mut Context) { + self.language_servers.remove(&id); + cx.notify(); + } + + pub fn server_logs(&self, server_id: LanguageServerId) -> Option<&VecDeque> { + Some(&self.language_servers.get(&server_id)?.log_messages) + } + + pub fn server_trace(&self, server_id: LanguageServerId) -> Option<&VecDeque> { + Some(&self.language_servers.get(&server_id)?.trace_messages) + } + + pub fn server_ids_for_project<'a>( + &'a self, + lookup_project: &'a WeakEntity, + ) -> impl Iterator + 'a { + self.language_servers + .iter() + .filter_map(move |(id, state)| match &state.kind { + LanguageServerKind::Local { project } | LanguageServerKind::Remote { project } => { + if project == lookup_project { + Some(*id) + } else { + None + } + } + LanguageServerKind::Global | LanguageServerKind::LocalSsh { .. } => Some(*id), + }) + } + + pub fn enable_rpc_trace_for_language_server( + &mut self, + server_id: LanguageServerId, + ) -> Option<&mut LanguageServerRpcState> { + let rpc_state = self + .language_servers + .get_mut(&server_id)? + .rpc_state + .get_or_insert_with(|| LanguageServerRpcState { + rpc_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), + last_message_kind: None, + }); + Some(rpc_state) + } + + pub fn disable_rpc_trace_for_language_server( + &mut self, + server_id: LanguageServerId, + ) -> Option<()> { + self.language_servers.get_mut(&server_id)?.rpc_state.take(); + Some(()) + } + + pub fn has_server_logs(&self, server: &LanguageServerSelector) -> bool { + match server { + LanguageServerSelector::Id(id) => self.language_servers.contains_key(id), + LanguageServerSelector::Name(name) => self + .language_servers + .iter() + .any(|(_, state)| state.name.as_ref() == Some(name)), + } + } + + fn on_io( + &mut self, + language_server_id: LanguageServerId, + io_kind: IoKind, + message: &str, + cx: &mut Context, + ) -> Option<()> { + let is_received = match io_kind { + IoKind::StdOut => true, + IoKind::StdIn => false, + IoKind::StdErr => { + self.add_language_server_log(language_server_id, MessageType::LOG, message, cx); + return Some(()); + } + }; + + let kind = if is_received { + MessageKind::Receive + } else { + MessageKind::Send + }; + + self.add_language_server_rpc(language_server_id, kind, message, cx); + cx.notify(); + Some(()) + } + + fn emit_event(&mut self, e: Event, cx: &mut Context) { + match &e { + Event::NewServerLogEntry { id, kind, text } => { + if let Some(state) = self.get_language_server_state(*id) { + let downstream_client = match &state.kind { + LanguageServerKind::Remote { project } + | LanguageServerKind::Local { project } => project + .upgrade() + .map(|project| project.read(cx).lsp_store()), + LanguageServerKind::LocalSsh { lsp_store } => lsp_store.upgrade(), + LanguageServerKind::Global => None, + } + .and_then(|lsp_store| lsp_store.read(cx).downstream_client()); + if let Some((client, project_id)) = downstream_client { + client + .send(proto::LanguageServerLog { + project_id, + language_server_id: id.to_proto(), + message: text.clone(), + log_type: Some(kind.to_proto()), + }) + .ok(); + } + } + } + } + + cx.emit(e); + } +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9a1e9cd94d08f88b887af0cfb531d18cf55827fa..ab76dd9b17d52ef90c2469507b83fb29d3ada121 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -42,9 +42,7 @@ pub use manifest_tree::ManifestTree; use anyhow::{Context as _, Result, anyhow}; use buffer_store::{BufferStore, BufferStoreEvent}; -use client::{ - Client, Collaborator, PendingEntitySubscription, ProjectId, TypedEnvelope, UserStore, proto, -}; +use client::{Client, Collaborator, PendingEntitySubscription, TypedEnvelope, UserStore, proto}; use clock::ReplicaId; use dap::client::DebugAdapterClient; @@ -89,10 +87,10 @@ use node_runtime::NodeRuntime; use parking_lot::Mutex; pub use prettier_store::PrettierStore; use project_settings::{ProjectSettings, SettingsObserver, SettingsObserverEvent}; -use remote::{SshConnectionOptions, SshRemoteClient}; +use remote::{RemoteClient, SshConnectionOptions}; use rpc::{ AnyProtoClient, ErrorCode, - proto::{FromProto, LanguageServerPromptResponse, SSH_PROJECT_ID, ToProto}, + proto::{FromProto, LanguageServerPromptResponse, REMOTE_SERVER_PROJECT_ID, ToProto}, }; use search::{SearchInputKind, SearchQuery, SearchResult}; use search_history::SearchHistory; @@ -177,12 +175,12 @@ pub struct Project { dap_store: Entity, breakpoint_store: Entity, - client: Arc, + collab_client: Arc, join_project_response_message_id: u32, task_store: Entity, user_store: Entity, fs: Arc, - ssh_client: Option>, + remote_client: Option>, client_state: ProjectClientState, git_store: Entity, collaborators: HashMap, @@ -282,6 +280,11 @@ pub enum Event { server_id: LanguageServerId, buffer_id: BufferId, buffer_abs_path: PathBuf, + name: Option, + }, + ToggleLspLogs { + server_id: LanguageServerId, + enabled: bool, }, Toast { notification_id: SharedString, @@ -1003,6 +1006,7 @@ impl Project { client.add_entity_request_handler(Self::handle_open_buffer_by_path); client.add_entity_request_handler(Self::handle_open_new_buffer); client.add_entity_message_handler(Self::handle_create_buffer_for_peer); + client.add_entity_message_handler(Self::handle_toggle_lsp_logs); WorktreeStore::init(&client); BufferStore::init(&client); @@ -1154,12 +1158,12 @@ impl Project { active_entry: None, snippets, languages, - client, + collab_client: client, task_store, user_store, settings_observer, fs, - ssh_client: None, + remote_client: None, breakpoint_store, dap_store, @@ -1183,8 +1187,8 @@ impl Project { }) } - pub fn ssh( - ssh: Entity, + pub fn remote( + remote: Entity, client: Arc, node: NodeRuntime, user_store: Entity, @@ -1200,10 +1204,15 @@ impl Project { let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx); - let (ssh_proto, path_style) = - ssh.read_with(cx, |ssh, _| (ssh.proto_client(), ssh.path_style())); + let (remote_proto, path_style) = + remote.read_with(cx, |remote, _| (remote.proto_client(), remote.path_style())); let worktree_store = cx.new(|_| { - WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID, path_style) + WorktreeStore::remote( + false, + remote_proto.clone(), + REMOTE_SERVER_PROJECT_ID, + path_style, + ) }); cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); @@ -1215,31 +1224,32 @@ impl Project { let buffer_store = cx.new(|cx| { BufferStore::remote( worktree_store.clone(), - ssh.read(cx).proto_client(), - SSH_PROJECT_ID, + remote.read(cx).proto_client(), + REMOTE_SERVER_PROJECT_ID, cx, ) }); let image_store = cx.new(|cx| { ImageStore::remote( worktree_store.clone(), - ssh.read(cx).proto_client(), - SSH_PROJECT_ID, + remote.read(cx).proto_client(), + REMOTE_SERVER_PROJECT_ID, cx, ) }); cx.subscribe(&buffer_store, Self::on_buffer_store_event) .detach(); - let toolchain_store = cx - .new(|cx| ToolchainStore::remote(SSH_PROJECT_ID, ssh.read(cx).proto_client(), cx)); + let toolchain_store = cx.new(|cx| { + ToolchainStore::remote(REMOTE_SERVER_PROJECT_ID, remote.read(cx).proto_client(), cx) + }); let task_store = cx.new(|cx| { TaskStore::remote( fs.clone(), buffer_store.downgrade(), worktree_store.clone(), toolchain_store.read(cx).as_language_toolchain_store(), - ssh.read(cx).proto_client(), - SSH_PROJECT_ID, + remote.read(cx).proto_client(), + REMOTE_SERVER_PROJECT_ID, cx, ) }); @@ -1262,8 +1272,8 @@ impl Project { buffer_store.clone(), worktree_store.clone(), languages.clone(), - ssh_proto.clone(), - SSH_PROJECT_ID, + remote_proto.clone(), + REMOTE_SERVER_PROJECT_ID, fs.clone(), cx, ) @@ -1271,12 +1281,12 @@ impl Project { cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach(); let breakpoint_store = - cx.new(|_| BreakpointStore::remote(SSH_PROJECT_ID, ssh_proto.clone())); + cx.new(|_| BreakpointStore::remote(REMOTE_SERVER_PROJECT_ID, remote_proto.clone())); let dap_store = cx.new(|cx| { - DapStore::new_ssh( - SSH_PROJECT_ID, - ssh.clone(), + DapStore::new_remote( + REMOTE_SERVER_PROJECT_ID, + remote.clone(), breakpoint_store.clone(), worktree_store.clone(), cx, @@ -1284,10 +1294,16 @@ impl Project { }); let git_store = cx.new(|cx| { - GitStore::ssh(&worktree_store, buffer_store.clone(), ssh_proto.clone(), cx) + GitStore::remote( + &worktree_store, + buffer_store.clone(), + remote_proto.clone(), + REMOTE_SERVER_PROJECT_ID, + cx, + ) }); - cx.subscribe(&ssh, Self::on_ssh_event).detach(); + cx.subscribe(&remote, Self::on_remote_client_event).detach(); let this = Self { buffer_ordered_messages_tx: tx, @@ -1306,11 +1322,13 @@ impl Project { _subscriptions: vec![ cx.on_release(Self::release), cx.on_app_quit(|this, cx| { - let shutdown = this.ssh_client.take().and_then(|client| { - client.read(cx).shutdown_processes( - Some(proto::ShutdownRemoteServer {}), - cx.background_executor().clone(), - ) + let shutdown = this.remote_client.take().and_then(|client| { + client.update(cx, |client, cx| { + client.shutdown_processes( + Some(proto::ShutdownRemoteServer {}), + cx.background_executor().clone(), + ) + }) }); cx.background_executor().spawn(async move { @@ -1323,12 +1341,12 @@ impl Project { active_entry: None, snippets, languages, - client, + collab_client: client, task_store, user_store, settings_observer, fs, - ssh_client: Some(ssh.clone()), + remote_client: Some(remote.clone()), buffers_needing_diff: Default::default(), git_diff_debouncer: DebouncedDelay::new(), terminals: Terminals { @@ -1346,52 +1364,34 @@ impl Project { agent_location: None, }; - // ssh -> local machine handlers - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &cx.entity()); - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.buffer_store); - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.worktree_store); - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.lsp_store); - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.dap_store); - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.settings_observer); - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.git_store); - - ssh_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer); - ssh_proto.add_entity_message_handler(Self::handle_update_worktree); - ssh_proto.add_entity_message_handler(Self::handle_update_project); - ssh_proto.add_entity_message_handler(Self::handle_toast); - ssh_proto.add_entity_request_handler(Self::handle_language_server_prompt_request); - ssh_proto.add_entity_message_handler(Self::handle_hide_toast); - ssh_proto.add_entity_request_handler(Self::handle_update_buffer_from_ssh); - BufferStore::init(&ssh_proto); - LspStore::init(&ssh_proto); - SettingsObserver::init(&ssh_proto); - TaskStore::init(Some(&ssh_proto)); - ToolchainStore::init(&ssh_proto); - DapStore::init(&ssh_proto, cx); - GitStore::init(&ssh_proto); + // remote server -> local machine handlers + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &cx.entity()); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.buffer_store); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.worktree_store); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.lsp_store); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.dap_store); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.settings_observer); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.git_store); + + remote_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer); + remote_proto.add_entity_message_handler(Self::handle_update_worktree); + remote_proto.add_entity_message_handler(Self::handle_update_project); + remote_proto.add_entity_message_handler(Self::handle_toast); + remote_proto.add_entity_request_handler(Self::handle_language_server_prompt_request); + remote_proto.add_entity_message_handler(Self::handle_hide_toast); + remote_proto.add_entity_request_handler(Self::handle_update_buffer_from_remote_server); + BufferStore::init(&remote_proto); + LspStore::init(&remote_proto); + SettingsObserver::init(&remote_proto); + TaskStore::init(Some(&remote_proto)); + ToolchainStore::init(&remote_proto); + DapStore::init(&remote_proto, cx); + GitStore::init(&remote_proto); this }) } - pub async fn remote( - remote_id: u64, - client: Arc, - user_store: Entity, - languages: Arc, - fs: Arc, - cx: AsyncApp, - ) -> Result> { - let project = - Self::in_room(remote_id, client, user_store, languages, fs, cx.clone()).await?; - cx.update(|cx| { - connection_manager::Manager::global(cx).update(cx, |manager, cx| { - manager.maintain_project_connection(&project, cx) - }) - })?; - Ok(project) - } - pub async fn in_room( remote_id: u64, client: Arc, @@ -1481,7 +1481,7 @@ impl Project { })?; let lsp_store = cx.new(|cx| { - let mut lsp_store = LspStore::new_remote( + LspStore::new_remote( buffer_store.clone(), worktree_store.clone(), languages.clone(), @@ -1489,12 +1489,7 @@ impl Project { remote_id, fs.clone(), cx, - ); - lsp_store.set_language_server_statuses_from_proto( - response.payload.language_servers, - response.payload.language_server_capabilities, - ); - lsp_store + ) })?; let task_store = cx.new(|cx| { @@ -1523,12 +1518,12 @@ impl Project { &worktree_store, buffer_store.clone(), client.clone().into(), - ProjectId(remote_id), + remote_id, cx, ) })?; - let this = cx.new(|cx| { + let project = cx.new(|cx| { let replica_id = response.payload.replica_id as ReplicaId; let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx); @@ -1559,7 +1554,7 @@ impl Project { cx.subscribe(&dap_store, Self::on_dap_store_event).detach(); - let mut this = Self { + let mut project = Self { buffer_ordered_messages_tx: tx, buffer_store: buffer_store.clone(), image_store, @@ -1574,11 +1569,11 @@ impl Project { task_store, snippets, fs, - ssh_client: None, + remote_client: None, settings_observer: settings_observer.clone(), client_subscriptions: Default::default(), _subscriptions: vec![cx.on_release(Self::release)], - client: client.clone(), + collab_client: client.clone(), client_state: ProjectClientState::Remote { sharing_has_stopped: false, capability: Capability::ReadWrite, @@ -1602,13 +1597,25 @@ impl Project { toolchain_store: None, agent_location: None, }; - this.set_role(role, cx); + project.set_role(role, cx); for worktree in worktrees { - this.add_worktree(&worktree, cx); + project.add_worktree(&worktree, cx); } - this + project })?; + let weak_project = project.downgrade(); + lsp_store + .update(&mut cx, |lsp_store, cx| { + lsp_store.set_language_server_statuses_from_proto( + weak_project, + response.payload.language_servers, + response.payload.language_server_capabilities, + cx, + ); + }) + .ok(); + let subscriptions = subscriptions .into_iter() .map(|s| match s { @@ -1624,7 +1631,7 @@ impl Project { EntitySubscription::SettingsObserver(subscription) => { subscription.set_entity(&settings_observer, &cx) } - EntitySubscription::Project(subscription) => subscription.set_entity(&this, &cx), + EntitySubscription::Project(subscription) => subscription.set_entity(&project, &cx), EntitySubscription::LspStore(subscription) => { subscription.set_entity(&lsp_store, &cx) } @@ -1644,13 +1651,13 @@ impl Project { .update(&mut cx, |user_store, cx| user_store.get_users(user_ids, cx))? .await?; - this.update(&mut cx, |this, cx| { + project.update(&mut cx, |this, cx| { this.set_collaborators_from_proto(response.payload.collaborators, cx)?; this.client_subscriptions.extend(subscriptions); anyhow::Ok(()) })??; - Ok(this) + Ok(project) } fn new_search_history() -> SearchHistory { @@ -1661,11 +1668,13 @@ impl Project { } fn release(&mut self, cx: &mut App) { - if let Some(client) = self.ssh_client.take() { - let shutdown = client.read(cx).shutdown_processes( - Some(proto::ShutdownRemoteServer {}), - cx.background_executor().clone(), - ); + if let Some(client) = self.remote_client.take() { + let shutdown = client.update(cx, |client, cx| { + client.shutdown_processes( + Some(proto::ShutdownRemoteServer {}), + cx.background_executor().clone(), + ) + }); cx.background_spawn(async move { if let Some(shutdown) = shutdown { @@ -1681,7 +1690,7 @@ impl Project { let _ = self.unshare_internal(cx); } ProjectClientState::Remote { remote_id, .. } => { - let _ = self.client.send(proto::LeaveProject { + let _ = self.collab_client.send(proto::LeaveProject { project_id: *remote_id, }); self.disconnected_from_host_internal(cx); @@ -1808,11 +1817,11 @@ impl Project { } pub fn client(&self) -> Arc { - self.client.clone() + self.collab_client.clone() } - pub fn ssh_client(&self) -> Option> { - self.ssh_client.clone() + pub fn remote_client(&self) -> Option> { + self.remote_client.clone() } pub fn user_store(&self) -> Entity { @@ -1893,30 +1902,30 @@ impl Project { if self.is_local() { return true; } - if self.is_via_ssh() { + if self.is_via_remote_server() { return true; } false } - pub fn ssh_connection_state(&self, cx: &App) -> Option { - self.ssh_client + pub fn remote_connection_state(&self, cx: &App) -> Option { + self.remote_client .as_ref() - .map(|ssh| ssh.read(cx).connection_state()) + .map(|remote| remote.read(cx).connection_state()) } - pub fn ssh_connection_options(&self, cx: &App) -> Option { - self.ssh_client + pub fn remote_connection_options(&self, cx: &App) -> Option { + self.remote_client .as_ref() - .map(|ssh| ssh.read(cx).connection_options()) + .map(|remote| remote.read(cx).connection_options()) } pub fn replica_id(&self) -> ReplicaId { match self.client_state { ProjectClientState::Remote { replica_id, .. } => replica_id, _ => { - if self.ssh_client.is_some() { + if self.remote_client.is_some() { 1 } else { 0 @@ -2052,13 +2061,12 @@ impl Project { exclude_sub_dirs: bool, cx: &App, ) -> Option { - let sanitized_path = SanitizedPath::from(path); - let path = sanitized_path.as_path(); + let path = SanitizedPath::new(path).as_path(); self.worktrees(cx) .filter_map(|worktree| { let worktree = worktree.read(cx); let abs_path = worktree.as_local()?.abs_path(); - let contains = path == abs_path + let contains = path == abs_path.as_ref() || (path.starts_with(abs_path) && (!exclude_sub_dirs || !metadata.is_dir)); contains.then(|| worktree.is_visible()) }) @@ -2220,55 +2228,55 @@ impl Project { ); self.client_subscriptions.extend([ - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&cx.entity(), &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.worktree_store, &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.buffer_store, &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.lsp_store, &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.settings_observer, &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.dap_store, &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.breakpoint_store, &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.git_store, &cx.to_async()), ]); self.buffer_store.update(cx, |buffer_store, cx| { - buffer_store.shared(project_id, self.client.clone().into(), cx) + buffer_store.shared(project_id, self.collab_client.clone().into(), cx) }); self.worktree_store.update(cx, |worktree_store, cx| { - worktree_store.shared(project_id, self.client.clone().into(), cx); + worktree_store.shared(project_id, self.collab_client.clone().into(), cx); }); self.lsp_store.update(cx, |lsp_store, cx| { - lsp_store.shared(project_id, self.client.clone().into(), cx) + lsp_store.shared(project_id, self.collab_client.clone().into(), cx) }); self.breakpoint_store.update(cx, |breakpoint_store, _| { - breakpoint_store.shared(project_id, self.client.clone().into()) + breakpoint_store.shared(project_id, self.collab_client.clone().into()) }); self.dap_store.update(cx, |dap_store, cx| { - dap_store.shared(project_id, self.client.clone().into(), cx); + dap_store.shared(project_id, self.collab_client.clone().into(), cx); }); self.task_store.update(cx, |task_store, cx| { - task_store.shared(project_id, self.client.clone().into(), cx); + task_store.shared(project_id, self.collab_client.clone().into(), cx); }); self.settings_observer.update(cx, |settings_observer, cx| { - settings_observer.shared(project_id, self.client.clone().into(), cx) + settings_observer.shared(project_id, self.collab_client.clone().into(), cx) }); self.git_store.update(cx, |git_store, cx| { - git_store.shared(project_id, self.client.clone().into(), cx) + git_store.shared(project_id, self.collab_client.clone().into(), cx) }); self.client_state = ProjectClientState::Shared { @@ -2293,7 +2301,7 @@ impl Project { }); if let Some(remote_id) = self.remote_id() { self.git_store.update(cx, |git_store, cx| { - git_store.shared(remote_id, self.client.clone().into(), cx) + git_store.shared(remote_id, self.collab_client.clone().into(), cx) }); } cx.emit(Event::Reshared); @@ -2319,10 +2327,14 @@ impl Project { self.join_project_response_message_id = message_id; self.set_worktrees_from_proto(message.worktrees, cx)?; self.set_collaborators_from_proto(message.collaborators, cx)?; - self.lsp_store.update(cx, |lsp_store, _| { + + let project = cx.weak_entity(); + self.lsp_store.update(cx, |lsp_store, cx| { lsp_store.set_language_server_statuses_from_proto( + project, message.language_servers, message.language_server_capabilities, + cx, ) }); self.enqueue_buffer_ordered_message(BufferOrderedMessage::Resync) @@ -2370,7 +2382,7 @@ impl Project { git_store.unshared(cx); }); - self.client + self.collab_client .send(proto::UnshareProject { project_id: remote_id, }) @@ -2437,15 +2449,17 @@ impl Project { sharing_has_stopped, .. } => *sharing_has_stopped, - ProjectClientState::Local if self.is_via_ssh() => self.ssh_is_disconnected(cx), + ProjectClientState::Local if self.is_via_remote_server() => { + self.remote_client_is_disconnected(cx) + } _ => false, } } - fn ssh_is_disconnected(&self, cx: &App) -> bool { - self.ssh_client + fn remote_client_is_disconnected(&self, cx: &App) -> bool { + self.remote_client .as_ref() - .map(|ssh| ssh.read(cx).is_disconnected()) + .map(|remote| remote.read(cx).is_disconnected()) .unwrap_or(false) } @@ -2463,16 +2477,16 @@ impl Project { pub fn is_local(&self) -> bool { match &self.client_state { ProjectClientState::Local | ProjectClientState::Shared { .. } => { - self.ssh_client.is_none() + self.remote_client.is_none() } ProjectClientState::Remote { .. } => false, } } - pub fn is_via_ssh(&self) -> bool { + pub fn is_via_remote_server(&self) -> bool { match &self.client_state { ProjectClientState::Local | ProjectClientState::Shared { .. } => { - self.ssh_client.is_some() + self.remote_client.is_some() } ProjectClientState::Remote { .. } => false, } @@ -2496,7 +2510,7 @@ impl Project { language: Option>, cx: &mut Context, ) -> Entity { - if self.is_via_collab() || self.is_via_ssh() { + if self.is_via_collab() || self.is_via_remote_server() { panic!("called create_local_buffer on a remote project") } self.buffer_store.update(cx, |buffer_store, cx| { @@ -2620,10 +2634,10 @@ impl Project { ) -> Task>> { if let Some(buffer) = self.buffer_for_id(id, cx) { Task::ready(Ok(buffer)) - } else if self.is_local() || self.is_via_ssh() { + } else if self.is_local() || self.is_via_remote_server() { Task::ready(Err(anyhow!("buffer {id} does not exist"))) } else if let Some(project_id) = self.remote_id() { - let request = self.client.request(proto::OpenBufferById { + let request = self.collab_client.request(proto::OpenBufferById { project_id, id: id.into(), }); @@ -2741,7 +2755,7 @@ impl Project { for (buffer_id, operations) in operations_by_buffer_id.drain() { let request = this.read_with(cx, |this, _| { let project_id = this.remote_id()?; - Some(this.client.request(proto::UpdateBuffer { + Some(this.collab_client.request(proto::UpdateBuffer { buffer_id: buffer_id.into(), project_id, operations, @@ -2808,7 +2822,7 @@ impl Project { project.read_with(cx, |project, _| { if let Some(project_id) = project.remote_id() { project - .client + .collab_client .send(proto::UpdateLanguageServer { project_id, server_name: name.map(|name| String::from(name.0)), @@ -2846,8 +2860,8 @@ impl Project { self.register_buffer(buffer, cx).log_err(); } BufferStoreEvent::BufferDropped(buffer_id) => { - if let Some(ref ssh_client) = self.ssh_client { - ssh_client + if let Some(ref remote_client) = self.remote_client { + remote_client .read(cx) .proto_client() .send(proto::CloseBuffer { @@ -2973,6 +2987,7 @@ impl Project { buffer_id, server_id: *language_server_id, buffer_abs_path: PathBuf::from(&update.buffer_abs_path), + name: name.clone(), }); } } @@ -2995,16 +3010,14 @@ impl Project { } } - fn on_ssh_event( + fn on_remote_client_event( &mut self, - _: Entity, - event: &remote::SshRemoteEvent, + _: Entity, + event: &remote::RemoteClientEvent, cx: &mut Context, ) { match event { - remote::SshRemoteEvent::Disconnected => { - // if self.is_via_ssh() { - // self.collaborators.clear(); + remote::RemoteClientEvent::Disconnected => { self.worktree_store.update(cx, |store, cx| { store.disconnected_from_host(cx); }); @@ -3110,8 +3123,9 @@ impl Project { } fn on_worktree_released(&mut self, id_to_remove: WorktreeId, cx: &mut Context) { - if let Some(ssh) = &self.ssh_client { - ssh.read(cx) + if let Some(remote) = &self.remote_client { + remote + .read(cx) .proto_client() .send(proto::RemoveWorktree { worktree_id: id_to_remove.to_proto(), @@ -3144,8 +3158,9 @@ impl Project { } => { let operation = language::proto::serialize_operation(operation); - if let Some(ssh) = &self.ssh_client { - ssh.read(cx) + if let Some(remote) = &self.remote_client { + remote + .read(cx) .proto_client() .send(proto::UpdateBuffer { project_id: 0, @@ -3552,16 +3567,16 @@ impl Project { pub fn open_server_settings(&mut self, cx: &mut Context) -> Task>> { let guard = self.retain_remotely_created_models(cx); - let Some(ssh_client) = self.ssh_client.as_ref() else { + let Some(remote) = self.remote_client.as_ref() else { return Task::ready(Err(anyhow!("not an ssh project"))); }; - let proto_client = ssh_client.read(cx).proto_client(); + let proto_client = remote.read(cx).proto_client(); cx.spawn(async move |project, cx| { let buffer = proto_client .request(proto::OpenServerSettings { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, }) .await?; @@ -3948,10 +3963,11 @@ impl Project { ) -> Receiver> { let (tx, rx) = smol::channel::unbounded(); - let (client, remote_id): (AnyProtoClient, _) = if let Some(ssh_client) = &self.ssh_client { + let (client, remote_id): (AnyProtoClient, _) = if let Some(ssh_client) = &self.remote_client + { (ssh_client.read(cx).proto_client(), 0) } else if let Some(remote_id) = self.remote_id() { - (self.client.clone().into(), remote_id) + (self.collab_client.clone().into(), remote_id) } else { return rx; }; @@ -4095,14 +4111,14 @@ impl Project { is_dir: metadata.is_dir, }) }) - } else if let Some(ssh_client) = self.ssh_client.as_ref() { + } else if let Some(ssh_client) = self.remote_client.as_ref() { let path_style = ssh_client.read(cx).path_style(); let request_path = RemotePathBuf::from_str(path, path_style); let request = ssh_client .read(cx) .proto_client() .request(proto::GetPathMetadata { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, path: request_path.to_proto(), }); cx.background_spawn(async move { @@ -4202,10 +4218,10 @@ impl Project { ) -> Task>> { if self.is_local() { DirectoryLister::Local(cx.entity(), self.fs.clone()).list_directory(query, cx) - } else if let Some(session) = self.ssh_client.as_ref() { + } else if let Some(session) = self.remote_client.as_ref() { let path_buf = PathBuf::from(query); let request = proto::ListRemoteDirectory { - dev_server_id: SSH_PROJECT_ID, + dev_server_id: REMOTE_SERVER_PROJECT_ID, path: path_buf.to_proto(), config: Some(proto::ListRemoteDirectoryConfig { is_dir: true }), }; @@ -4420,7 +4436,7 @@ impl Project { mut cx: AsyncApp, ) -> Result<()> { this.update(&mut cx, |this, cx| { - if this.is_local() || this.is_via_ssh() { + if this.is_local() || this.is_via_remote_server() { this.unshare(cx)?; } else { this.disconnected_from_host(cx); @@ -4629,7 +4645,7 @@ impl Project { })? } - async fn handle_update_buffer_from_ssh( + async fn handle_update_buffer_from_remote_server( this: Entity, envelope: TypedEnvelope, cx: AsyncApp, @@ -4638,7 +4654,7 @@ impl Project { if let Some(remote_id) = this.remote_id() { let mut payload = envelope.payload.clone(); payload.project_id = remote_id; - cx.background_spawn(this.client.request(payload)) + cx.background_spawn(this.collab_client.request(payload)) .detach_and_log_err(cx); } this.buffer_store.clone() @@ -4652,9 +4668,9 @@ impl Project { cx: AsyncApp, ) -> Result { let buffer_store = this.read_with(&cx, |this, cx| { - if let Some(ssh) = &this.ssh_client { + if let Some(ssh) = &this.remote_client { let mut payload = envelope.payload.clone(); - payload.project_id = SSH_PROJECT_ID; + payload.project_id = REMOTE_SERVER_PROJECT_ID; cx.background_spawn(ssh.read(cx).proto_client().request(payload)) .detach_and_log_err(cx); } @@ -4698,13 +4714,27 @@ impl Project { })? } + async fn handle_toggle_lsp_logs( + project: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + project.update(&mut cx, |_, cx| { + cx.emit(Event::ToggleLspLogs { + server_id: LanguageServerId::from_proto(envelope.payload.server_id), + enabled: envelope.payload.enabled, + }) + })?; + Ok(()) + } + async fn handle_synchronize_buffers( this: Entity, envelope: TypedEnvelope, mut cx: AsyncApp, ) -> Result { let response = this.update(&mut cx, |this, cx| { - let client = this.client.clone(); + let client = this.collab_client.clone(); this.buffer_store.update(cx, |this, cx| { this.handle_synchronize_buffers(envelope, cx, client) }) @@ -4841,7 +4871,7 @@ impl Project { } }; - let client = self.client.clone(); + let client = self.collab_client.clone(); cx.spawn(async move |this, cx| { let (buffers, incomplete_buffer_ids) = this.update(cx, |this, cx| { this.buffer_store.read(cx).buffer_version_info(cx) diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 084eae8af12100a22aaae3448c00f09df14ea6eb..757b08b35e2f14b6dd4cc27c9ba391c90af52d11 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -1951,6 +1951,7 @@ async fn test_restarting_server_with_diagnostics_running(cx: &mut gpui::TestAppC server_id: LanguageServerId(1), buffer_id, buffer_abs_path: PathBuf::from(path!("/dir/a.rs")), + name: Some(fake_server.server.name()) } ); assert_eq!( @@ -9182,13 +9183,14 @@ fn python_lang(fs: Arc) -> Arc { async fn list( &self, worktree_root: PathBuf, - subroot_relative_path: Option>, + subroot_relative_path: Arc, _: Option>, ) -> ToolchainList { // This lister will always return a path .venv directories within ancestors let ancestors = subroot_relative_path - .into_iter() - .flat_map(|path| path.ancestors().map(ToOwned::to_owned).collect::>()); + .ancestors() + .map(ToOwned::to_owned) + .collect::>(); let mut toolchains = vec![]; for ancestor in ancestors { let venv_path = worktree_root.join(ancestor).join(".venv"); @@ -9214,6 +9216,9 @@ fn python_lang(fs: Arc) -> Arc { fn manifest_name(&self) -> ManifestName { SharedString::new_static("pyproject.toml").into() } + async fn activation_script(&self, _: &Toolchain, _: &dyn Fs) -> Option { + None + } } Arc::new( Language::new( diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index ee216a99763282d8d30a0b17c3df3b8da3213db7..f2c6091e0cb00b8da1a752e3d25afe3389e8c818 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -143,7 +143,7 @@ impl SearchQuery { pub fn regex( query: impl ToString, whole_word: bool, - case_sensitive: bool, + mut case_sensitive: bool, include_ignored: bool, one_match_per_line: bool, files_to_include: PathMatcher, @@ -153,6 +153,14 @@ impl SearchQuery { ) -> Result { let mut query = query.to_string(); let initial_query = Arc::from(query.as_str()); + + if let Some((case_sensitive_from_pattern, new_query)) = + Self::case_sensitive_from_pattern(&query) + { + case_sensitive = case_sensitive_from_pattern; + query = new_query + } + if whole_word { let mut word_query = String::new(); if let Some(first) = query.get(0..1) @@ -192,6 +200,45 @@ impl SearchQuery { }) } + /// Extracts case sensitivity settings from pattern items in the provided + /// query and returns the same query, with the pattern items removed. + /// + /// The following pattern modifiers are supported: + /// + /// - `\c` (case_sensitive: false) + /// - `\C` (case_sensitive: true) + /// + /// If no pattern item were found, `None` will be returned. + fn case_sensitive_from_pattern(query: &str) -> Option<(bool, String)> { + if !(query.contains("\\c") || query.contains("\\C")) { + return None; + } + + let mut was_escaped = false; + let mut new_query = String::new(); + let mut is_case_sensitive = None; + + for c in query.chars() { + if was_escaped { + if c == 'c' { + is_case_sensitive = Some(false); + } else if c == 'C' { + is_case_sensitive = Some(true); + } else { + new_query.push('\\'); + new_query.push(c); + } + was_escaped = false + } else if c == '\\' { + was_escaped = true + } else { + new_query.push(c); + } + } + + is_case_sensitive.map(|c| (c, new_query)) + } + pub fn from_proto(message: proto::SearchQuery) -> Result { let files_to_include = if message.files_to_include.is_empty() { message @@ -596,4 +643,87 @@ mod tests { } } } + + #[test] + fn test_case_sensitive_pattern_items() { + let case_sensitive = false; + let search_query = SearchQuery::regex( + "test\\C", + false, + case_sensitive, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .expect("Should be able to create a regex SearchQuery"); + + assert_eq!( + search_query.case_sensitive(), + true, + "Case sensitivity should be enabled when \\C pattern item is present in the query." + ); + + let case_sensitive = true; + let search_query = SearchQuery::regex( + "test\\c", + true, + case_sensitive, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .expect("Should be able to create a regex SearchQuery"); + + assert_eq!( + search_query.case_sensitive(), + false, + "Case sensitivity should be disabled when \\c pattern item is present, even if initially set to true." + ); + + let case_sensitive = false; + let search_query = SearchQuery::regex( + "test\\c\\C", + false, + case_sensitive, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .expect("Should be able to create a regex SearchQuery"); + + assert_eq!( + search_query.case_sensitive(), + true, + "Case sensitivity should be enabled when \\C is the last pattern item, even after a \\c." + ); + + let case_sensitive = false; + let search_query = SearchQuery::regex( + "tests\\\\C", + false, + case_sensitive, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .expect("Should be able to create a regex SearchQuery"); + + assert_eq!( + search_query.case_sensitive(), + false, + "Case sensitivity should not be enabled when \\C pattern item is preceded by a backslash." + ); + } } diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index b009b357fe8eef1a7df61117857251178b437659..aad5ce941125c2c747df3a76473a9dbffba0b80e 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -1,72 +1,28 @@ -use crate::{Project, ProjectPath}; -use anyhow::{Context as _, Result}; +use anyhow::Result; use collections::HashMap; use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity}; use itertools::Itertools; use language::LanguageName; -use remote::{SshInfo, ssh_session::SshArgs}; +use remote::RemoteClient; use settings::{Settings, SettingsLocation}; use smol::channel::bounded; use std::{ borrow::Cow, - env::{self}, path::{Path, PathBuf}, sync::Arc, }; use task::{Shell, ShellBuilder, SpawnInTerminal}; use terminal::{ - TaskState, TaskStatus, Terminal, TerminalBuilder, - terminal_settings::{self, ActivateScript, TerminalSettings, VenvSettings}, -}; -use util::{ - ResultExt, - paths::{PathStyle, RemotePathBuf}, + TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings, }; +use util::{get_default_system_shell, get_system_shell, maybe}; -/// The directory inside a Python virtual environment that contains executables -const PYTHON_VENV_BIN_DIR: &str = if cfg!(target_os = "windows") { - "Scripts" -} else { - "bin" -}; +use crate::{Project, ProjectPath}; pub struct Terminals { pub(crate) local_handles: Vec>, } -/// Terminals are opened either for the users shell, or to run a task. - -#[derive(Debug)] -pub enum TerminalKind { - /// Run a shell at the given path (or $HOME if None) - Shell(Option), - /// Run a task. - Task(SpawnInTerminal), -} - -/// SshCommand describes how to connect to a remote server -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SshCommand { - pub arguments: Vec, -} - -impl SshCommand { - pub fn add_port_forwarding(&mut self, local_port: u16, host: String, remote_port: u16) { - self.arguments.push("-L".to_string()); - self.arguments - .push(format!("{}:{}:{}", local_port, host, remote_port)); - } -} - -#[derive(Debug)] -pub struct SshDetails { - pub host: String, - pub ssh_command: SshCommand, - pub envs: Option>, - pub path_style: PathStyle, - pub shell: String, -} - impl Project { pub fn active_project_directory(&self, cx: &App) -> Option> { self.active_entry() @@ -86,76 +42,33 @@ impl Project { } } - pub fn ssh_details(&self, cx: &App) -> Option { - if let Some(ssh_client) = &self.ssh_client { - let ssh_client = ssh_client.read(cx); - if let Some(SshInfo { - args: SshArgs { arguments, envs }, - path_style, - shell, - }) = ssh_client.ssh_info() - { - return Some(SshDetails { - host: ssh_client.connection_options().host, - ssh_command: SshCommand { arguments }, - envs, - path_style, - shell, - }); - } - } - - None - } - - pub fn create_terminal( + pub fn create_terminal_task( &mut self, - kind: TerminalKind, + spawn_task: SpawnInTerminal, cx: &mut Context, ) -> Task>> { - let path: Option> = match &kind { - TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())), - TerminalKind::Task(spawn_task) => { - if let Some(cwd) = &spawn_task.cwd { - Some(Arc::from(cwd.as_ref())) - } else { - self.active_project_directory(cx) - } - } - }; - - let mut settings_location = None; - if let Some(path) = path.as_ref() - && let Some((worktree, _)) = self.find_worktree(path, cx) - { - settings_location = Some(SettingsLocation { - worktree_id: worktree.read(cx).id(), - path, + let is_via_remote = self.remote_client.is_some(); + let project_path_context = self + .active_entry() + .and_then(|entry_id| self.worktree_id_for_entry(entry_id, cx)) + .or_else(|| self.visible_worktrees(cx).next().map(|wt| wt.read(cx).id())) + .map(|worktree_id| ProjectPath { + worktree_id, + path: Arc::from(Path::new("")), }); - } - let venv = TerminalSettings::get(settings_location, cx) - .detect_venv - .clone(); - cx.spawn(async move |project, cx| { - let python_venv_directory = if let Some(path) = path { - project - .update(cx, |this, cx| this.python_venv_directory(path, venv, cx))? - .await + let path: Option> = if let Some(cwd) = &spawn_task.cwd { + if is_via_remote { + Some(Arc::from(cwd.as_ref())) } else { - None - }; - project.update(cx, |project, cx| { - project.create_terminal_with_venv(kind, python_venv_directory, cx) - })? - }) - } + let cwd = cwd.to_string_lossy(); + let tilde_substituted = shellexpand::tilde(&cwd); + Some(Arc::from(Path::new(tilde_substituted.as_ref()))) + } + } else { + self.active_project_directory(cx) + }; - pub fn terminal_settings<'a>( - &'a self, - path: &'a Option, - cx: &'a App, - ) -> &'a TerminalSettings { let mut settings_location = None; if let Some(path) = path.as_ref() && let Some((worktree, _)) = self.find_worktree(path, cx) @@ -165,92 +78,179 @@ impl Project { path, }); } - TerminalSettings::get(settings_location, cx) - } - - pub fn exec_in_shell(&self, command: String, cx: &App) -> std::process::Command { - let path = self.first_project_directory(cx); - let ssh_details = self.ssh_details(cx); - let settings = self.terminal_settings(&path, cx).clone(); + let settings = TerminalSettings::get(settings_location, cx).clone(); + let detect_venv = settings.detect_venv.as_option().is_some(); - let builder = - ShellBuilder::new(ssh_details.as_ref().map(|ssh| &*ssh.shell), &settings.shell) - .non_interactive(); - let (command, args) = builder.build(Some(command), &Vec::new()); + let (completion_tx, completion_rx) = bounded(1); + // Start with the environment that we might have inherited from the Zed CLI. let mut env = self .environment .read(cx) .get_cli_environment() .unwrap_or_default(); + // Then extend it with the explicit env variables from the settings, so they take + // precedence. env.extend(settings.env); - match self.ssh_details(cx) { - Some(SshDetails { - ssh_command, - envs, - path_style, - shell, - .. - }) => { - let (command, args) = wrap_for_ssh( - &shell, - &ssh_command, - Some((&command, &args)), - path.as_deref(), + let local_path = if is_via_remote { None } else { path.clone() }; + let task_state = Some(TaskState { + id: spawn_task.id, + full_label: spawn_task.full_label, + label: spawn_task.label, + command_label: spawn_task.command_label, + hide: spawn_task.hide, + status: TaskStatus::Running, + show_summary: spawn_task.show_summary, + show_command: spawn_task.show_command, + show_rerun: spawn_task.show_rerun, + completion_rx, + }); + let remote_client = self.remote_client.clone(); + let shell = match &remote_client { + Some(remote_client) => remote_client + .read(cx) + .shell() + .unwrap_or_else(get_default_system_shell), + None => match &settings.shell { + Shell::Program(program) => program.clone(), + Shell::WithArguments { + program, + args: _, + title_override: _, + } => program.clone(), + Shell::System => get_system_shell(), + }, + }; + + let toolchain = project_path_context + .filter(|_| detect_venv) + .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx)); + let lang_registry = self.languages.clone(); + let fs = self.fs.clone(); + cx.spawn(async move |project, cx| { + let activation_script = maybe!(async { + let toolchain = toolchain?.await?; + lang_registry + .language_for_name(&toolchain.language_name.0) + .await + .ok()? + .toolchain_lister()? + .activation_script(&toolchain, fs.as_ref()) + .await + }) + .await; + + project.update(cx, move |this, cx| { + let shell = { + env.extend(spawn_task.env); + match remote_client { + Some(remote_client) => create_remote_shell( + spawn_task + .command + .as_ref() + .map(|command| (command, &spawn_task.args)), + &mut env, + path, + remote_client, + activation_script.clone(), + cx, + )?, + None => match activation_script.clone() { + Some(activation_script) => { + let to_run = if let Some(command) = spawn_task.command { + let command: Option> = shlex::try_quote(&command).ok(); + let args = spawn_task + .args + .iter() + .filter_map(|arg| shlex::try_quote(arg).ok()); + command.into_iter().chain(args).join(" ") + } else { + format!("exec {shell} -l") + }; + Shell::WithArguments { + program: get_default_system_shell(), + args: vec![ + "-c".to_owned(), + format!("{activation_script}; {to_run}",), + ], + title_override: None, + } + } + None => { + if let Some(program) = spawn_task.command { + Shell::WithArguments { + program, + args: spawn_task.args, + title_override: None, + } + } else { + Shell::System + } + } + }, + } + }; + TerminalBuilder::new( + local_path.map(|path| path.to_path_buf()), + task_state, + shell, env, - None, - path_style, - ); - let mut command = std::process::Command::new(command); - command.args(args); - if let Some(envs) = envs { - command.envs(envs); - } - command - } - None => { - let mut command = std::process::Command::new(command); - command.args(args); - command.envs(env); - if let Some(path) = path { - command.current_dir(path); - } - command - } - } + settings.cursor_shape.unwrap_or_default(), + settings.alternate_scroll, + settings.max_scroll_history_lines, + is_via_remote, + cx.entity_id().as_u64(), + Some(completion_tx), + cx, + activation_script, + ) + .map(|builder| { + let terminal_handle = cx.new(|cx| builder.subscribe(cx)); + + this.terminals + .local_handles + .push(terminal_handle.downgrade()); + + let id = terminal_handle.entity_id(); + cx.observe_release(&terminal_handle, move |project, _terminal, cx| { + let handles = &mut project.terminals.local_handles; + + if let Some(index) = handles + .iter() + .position(|terminal| terminal.entity_id() == id) + { + handles.remove(index); + cx.notify(); + } + }) + .detach(); + + terminal_handle + }) + })? + }) } - pub fn create_terminal_with_venv( + pub fn create_terminal_shell( &mut self, - kind: TerminalKind, - python_venv_directory: Option, + cwd: Option, cx: &mut Context, - ) -> Result> { - let this = &mut *self; - let ssh_details = this.ssh_details(cx); - let path: Option> = match &kind { - TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())), - TerminalKind::Task(spawn_task) => { - if let Some(cwd) = &spawn_task.cwd { - if ssh_details.is_some() { - Some(Arc::from(cwd.as_ref())) - } else { - let cwd = cwd.to_string_lossy(); - let tilde_substituted = shellexpand::tilde(&cwd); - Some(Arc::from(Path::new(tilde_substituted.as_ref()))) - } - } else { - this.active_project_directory(cx) - } - } - }; - - let is_ssh_terminal = ssh_details.is_some(); + ) -> Task>> { + let project_path_context = self + .active_entry() + .and_then(|entry_id| self.worktree_id_for_entry(entry_id, cx)) + .or_else(|| self.visible_worktrees(cx).next().map(|wt| wt.read(cx).id())) + .map(|worktree_id| ProjectPath { + worktree_id, + path: Arc::from(Path::new("")), + }); + let path = cwd.map(|p| Arc::from(&*p)); + let is_via_remote = self.remote_client.is_some(); let mut settings_location = None; if let Some(path) = path.as_ref() - && let Some((worktree, _)) = this.find_worktree(path, cx) + && let Some((worktree, _)) = self.find_worktree(path, cx) { settings_location = Some(SettingsLocation { worktree_id: worktree.read(cx).id(), @@ -258,11 +258,10 @@ impl Project { }); } let settings = TerminalSettings::get(settings_location, cx).clone(); - - let (completion_tx, completion_rx) = bounded(1); + let detect_venv = settings.detect_venv.as_option().is_some(); // Start with the environment that we might have inherited from the Zed CLI. - let mut env = this + let mut env = self .environment .read(cx) .get_cli_environment() @@ -271,160 +270,116 @@ impl Project { // precedence. env.extend(settings.env); - let local_path = if is_ssh_terminal { None } else { path.clone() }; - - let mut python_venv_activate_command = Task::ready(None); - - let (spawn_task, shell) = match kind { - TerminalKind::Shell(_) => { - if let Some(python_venv_directory) = &python_venv_directory { - python_venv_activate_command = this.python_activate_command( - python_venv_directory, - &settings.detect_venv, - &settings.shell, - cx, - ); - } + let local_path = if is_via_remote { None } else { path.clone() }; + + let toolchain = project_path_context + .filter(|_| detect_venv) + .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx)); + let remote_client = self.remote_client.clone(); + let shell = match &remote_client { + Some(remote_client) => remote_client + .read(cx) + .shell() + .unwrap_or_else(get_default_system_shell), + None => match &settings.shell { + Shell::Program(program) => program.clone(), + Shell::WithArguments { + program, + args: _, + title_override: _, + } => program.clone(), + Shell::System => get_system_shell(), + }, + }; - match ssh_details { - Some(SshDetails { - host, - ssh_command, - envs, - path_style, - shell, - }) => { - log::debug!("Connecting to a remote server: {ssh_command:?}"); - - // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed - // to properly display colors. - // We do not have the luxury of assuming the host has it installed, - // so we set it to a default that does not break the highlighting via ssh. - env.entry("TERM".to_string()) - .or_insert_with(|| "xterm-256color".to_string()); - - let (program, args) = wrap_for_ssh( - &shell, - &ssh_command, - None, - path.as_deref(), - env, + let lang_registry = self.languages.clone(); + let fs = self.fs.clone(); + cx.spawn(async move |project, cx| { + let activation_script = maybe!(async { + let toolchain = toolchain?.await?; + let language = lang_registry + .language_for_name(&toolchain.language_name.0) + .await + .ok(); + let lister = language?.toolchain_lister(); + lister?.activation_script(&toolchain, fs.as_ref()).await + }) + .await; + project.update(cx, move |this, cx| { + let shell = { + match remote_client { + Some(remote_client) => create_remote_shell( None, - path_style, - ); - env = HashMap::default(); - if let Some(envs) = envs { - env.extend(envs); - } - ( - Option::::None, - Shell::WithArguments { - program, - args, - title_override: Some(format!("{} — Terminal", host).into()), - }, - ) - } - None => (None, settings.shell), - } - } - TerminalKind::Task(spawn_task) => { - let task_state = Some(TaskState { - id: spawn_task.id, - full_label: spawn_task.full_label, - label: spawn_task.label, - command_label: spawn_task.command_label, - hide: spawn_task.hide, - status: TaskStatus::Running, - show_summary: spawn_task.show_summary, - show_command: spawn_task.show_command, - show_rerun: spawn_task.show_rerun, - completion_rx, - }); - - env.extend(spawn_task.env); - - if let Some(venv_path) = &python_venv_directory { - env.insert( - "VIRTUAL_ENV".to_string(), - venv_path.to_string_lossy().to_string(), - ); - } - - match ssh_details { - Some(SshDetails { - host, - ssh_command, - envs, - path_style, - shell, - }) => { - log::debug!("Connecting to a remote server: {ssh_command:?}"); - env.entry("TERM".to_string()) - .or_insert_with(|| "xterm-256color".to_string()); - let (program, args) = wrap_for_ssh( - &shell, - &ssh_command, - spawn_task - .command - .as_ref() - .map(|command| (command, &spawn_task.args)), - path.as_deref(), - env, - python_venv_directory.as_deref(), - path_style, - ); - env = HashMap::default(); - if let Some(envs) = envs { - env.extend(envs); - } - ( - task_state, - Shell::WithArguments { - program, - args, - title_override: Some(format!("{} — Terminal", host).into()), + &mut env, + path, + remote_client, + activation_script.clone(), + cx, + )?, + None => match activation_script.clone() { + Some(activation_script) => Shell::WithArguments { + program: get_default_system_shell(), + args: vec![ + "-c".to_owned(), + format!("{activation_script}; exec {shell} -l",), + ], + title_override: Some(shell.into()), }, - ) + None => settings.shell, + }, } - None => { - if let Some(venv_path) = &python_venv_directory { - add_environment_path(&mut env, &venv_path.join(PYTHON_VENV_BIN_DIR)) - .log_err(); + }; + TerminalBuilder::new( + local_path.map(|path| path.to_path_buf()), + None, + shell, + env, + settings.cursor_shape.unwrap_or_default(), + settings.alternate_scroll, + settings.max_scroll_history_lines, + is_via_remote, + cx.entity_id().as_u64(), + None, + cx, + activation_script, + ) + .map(|builder| { + let terminal_handle = cx.new(|cx| builder.subscribe(cx)); + + this.terminals + .local_handles + .push(terminal_handle.downgrade()); + + let id = terminal_handle.entity_id(); + cx.observe_release(&terminal_handle, move |project, _terminal, cx| { + let handles = &mut project.terminals.local_handles; + + if let Some(index) = handles + .iter() + .position(|terminal| terminal.entity_id() == id) + { + handles.remove(index); + cx.notify(); } + }) + .detach(); - let shell = if let Some(program) = spawn_task.command { - Shell::WithArguments { - program, - args: spawn_task.args, - title_override: None, - } - } else { - Shell::System - }; - (task_state, shell) - } - } - } - }; - TerminalBuilder::new( - local_path.map(|path| path.to_path_buf()), - python_venv_directory, - spawn_task, - shell, - env, - settings.cursor_shape.unwrap_or_default(), - settings.alternate_scroll, - settings.max_scroll_history_lines, - is_ssh_terminal, - cx.entity_id().as_u64(), - completion_tx, - cx, - ) - .map(|builder| { + terminal_handle + }) + })? + }) + } + + pub fn clone_terminal( + &mut self, + terminal: &Entity, + cx: &mut Context<'_, Project>, + cwd: impl FnOnce() -> Option, + ) -> Result> { + terminal.read(cx).clone_builder(cx, cwd).map(|builder| { let terminal_handle = cx.new(|cx| builder.subscribe(cx)); - this.terminals + self.terminals .local_handles .push(terminal_handle.downgrade()); @@ -442,324 +397,113 @@ impl Project { }) .detach(); - this.activate_python_virtual_environment( - python_venv_activate_command, - &terminal_handle, - cx, - ); - terminal_handle }) } - fn python_venv_directory( - &self, - abs_path: Arc, - venv_settings: VenvSettings, - cx: &Context, - ) -> Task> { - cx.spawn(async move |this, cx| { - if let Some((worktree, relative_path)) = this - .update(cx, |this, cx| this.find_worktree(&abs_path, cx)) - .ok()? - { - let toolchain = this - .update(cx, |this, cx| { - this.active_toolchain( - ProjectPath { - worktree_id: worktree.read(cx).id(), - path: relative_path.into(), - }, - LanguageName::new("Python"), - cx, - ) - }) - .ok()? - .await; - - if let Some(toolchain) = toolchain { - let toolchain_path = Path::new(toolchain.path.as_ref()); - return Some(toolchain_path.parent()?.parent()?.to_path_buf()); - } - } - let venv_settings = venv_settings.as_option()?; - this.update(cx, move |this, cx| { - if let Some(path) = this.find_venv_in_worktree(&abs_path, &venv_settings, cx) { - return Some(path); - } - this.find_venv_on_filesystem(&abs_path, &venv_settings, cx) - }) - .ok() - .flatten() - }) - } - - fn find_venv_in_worktree( - &self, - abs_path: &Path, - venv_settings: &terminal_settings::VenvSettingsContent, - cx: &App, - ) -> Option { - venv_settings - .directories - .iter() - .map(|name| abs_path.join(name)) - .find(|venv_path| { - let bin_path = venv_path.join(PYTHON_VENV_BIN_DIR); - self.find_worktree(&bin_path, cx) - .and_then(|(worktree, relative_path)| { - worktree.read(cx).entry_for_path(&relative_path) - }) - .is_some_and(|entry| entry.is_dir()) - }) - } - - fn find_venv_on_filesystem( - &self, - abs_path: &Path, - venv_settings: &terminal_settings::VenvSettingsContent, - cx: &App, - ) -> Option { - let (worktree, _) = self.find_worktree(abs_path, cx)?; - let fs = worktree.read(cx).as_local()?.fs(); - venv_settings - .directories - .iter() - .map(|name| abs_path.join(name)) - .find(|venv_path| { - let bin_path = venv_path.join(PYTHON_VENV_BIN_DIR); - // One-time synchronous check is acceptable for terminal/task initialization - smol::block_on(fs.metadata(&bin_path)) - .ok() - .flatten() - .is_some_and(|meta| meta.is_dir) - }) - } - - fn activate_script_kind(shell: Option<&str>) -> ActivateScript { - let shell_env = std::env::var("SHELL").ok(); - let shell_path = shell.or_else(|| shell_env.as_deref()); - let shell = std::path::Path::new(shell_path.unwrap_or("")) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(""); - match shell { - "fish" => ActivateScript::Fish, - "tcsh" => ActivateScript::Csh, - "nu" => ActivateScript::Nushell, - "powershell" | "pwsh" => ActivateScript::PowerShell, - _ => ActivateScript::Default, + pub fn terminal_settings<'a>( + &'a self, + path: &'a Option, + cx: &'a App, + ) -> &'a TerminalSettings { + let mut settings_location = None; + if let Some(path) = path.as_ref() + && let Some((worktree, _)) = self.find_worktree(path, cx) + { + settings_location = Some(SettingsLocation { + worktree_id: worktree.read(cx).id(), + path, + }); } + TerminalSettings::get(settings_location, cx) } - fn python_activate_command( - &self, - venv_base_directory: &Path, - venv_settings: &VenvSettings, - shell: &Shell, - cx: &mut App, - ) -> Task> { - let Some(venv_settings) = venv_settings.as_option() else { - return Task::ready(None); - }; - let activate_keyword = match venv_settings.activate_script { - terminal_settings::ActivateScript::Default => match std::env::consts::OS { - "windows" => ".", - _ => ".", - }, - terminal_settings::ActivateScript::Nushell => "overlay use", - terminal_settings::ActivateScript::PowerShell => ".", - terminal_settings::ActivateScript::Pyenv => "pyenv", - _ => "source", - }; - let script_kind = - if venv_settings.activate_script == terminal_settings::ActivateScript::Default { - match shell { - Shell::Program(program) => Self::activate_script_kind(Some(program)), - Shell::WithArguments { - program, - args: _, - title_override: _, - } => Self::activate_script_kind(Some(program)), - Shell::System => Self::activate_script_kind(None), - } - } else { - venv_settings.activate_script - }; - - let activate_script_name = match script_kind { - terminal_settings::ActivateScript::Default - | terminal_settings::ActivateScript::Pyenv => "activate", - terminal_settings::ActivateScript::Csh => "activate.csh", - terminal_settings::ActivateScript::Fish => "activate.fish", - terminal_settings::ActivateScript::Nushell => "activate.nu", - terminal_settings::ActivateScript::PowerShell => "activate.ps1", - }; + pub fn exec_in_shell(&self, command: String, cx: &App) -> Result { + let path = self.first_project_directory(cx); + let remote_client = self.remote_client.as_ref(); + let settings = self.terminal_settings(&path, cx).clone(); + let remote_shell = remote_client + .as_ref() + .and_then(|remote_client| remote_client.read(cx).shell()); + let builder = ShellBuilder::new(remote_shell.as_deref(), &settings.shell).non_interactive(); + let (command, args) = builder.build(Some(command), &Vec::new()); - let line_ending = match std::env::consts::OS { - "windows" => "\r", - _ => "\n", - }; + let mut env = self + .environment + .read(cx) + .get_cli_environment() + .unwrap_or_default(); + env.extend(settings.env); - if venv_settings.venv_name.is_empty() { - let path = venv_base_directory - .join(PYTHON_VENV_BIN_DIR) - .join(activate_script_name) - .to_string_lossy() - .to_string(); - - let is_valid_path = self.resolve_abs_path(path.as_ref(), cx); - cx.background_spawn(async move { - let quoted = shlex::try_quote(&path).ok()?; - if is_valid_path.await.is_some_and(|meta| meta.is_file()) { - Some(format!( - "{} {} ; clear{}", - activate_keyword, quoted, line_ending - )) - } else { - None + match remote_client { + Some(remote_client) => { + let command_template = remote_client.read(cx).build_command( + Some(command), + &args, + &env, + None, + // todo + None, + None, + )?; + let mut command = std::process::Command::new(command_template.program); + command.args(command_template.args); + command.envs(command_template.env); + Ok(command) + } + None => { + let mut command = std::process::Command::new(command); + command.args(args); + command.envs(env); + if let Some(path) = path { + command.current_dir(path); } - }) - } else { - Task::ready(Some(format!( - "{activate_keyword} {activate_script_name} {name}; clear{line_ending}", - name = venv_settings.venv_name - ))) + Ok(command) + } } } - fn activate_python_virtual_environment( - &self, - command: Task>, - terminal_handle: &Entity, - cx: &mut App, - ) { - terminal_handle.update(cx, |_, cx| { - cx.spawn(async move |this, cx| { - if let Some(command) = command.await { - this.update(cx, |this, _| { - this.input(command.into_bytes()); - }) - .ok(); - } - }) - .detach() - }); - } - pub fn local_terminal_handles(&self) -> &Vec> { &self.terminals.local_handles } } -pub fn wrap_for_ssh( - shell: &str, - ssh_command: &SshCommand, - command: Option<(&String, &Vec)>, - path: Option<&Path>, - env: HashMap, - venv_directory: Option<&Path>, - path_style: PathStyle, -) -> (String, Vec) { - let to_run = if let Some((command, args)) = command { - let command: Option> = shlex::try_quote(command).ok(); - let args = args.iter().filter_map(|arg| shlex::try_quote(arg).ok()); - command.into_iter().chain(args).join(" ") - } else { - format!("exec {shell} -l") +fn create_remote_shell( + spawn_command: Option<(&String, &Vec)>, + env: &mut HashMap, + working_directory: Option>, + remote_client: Entity, + activation_script: Option, + cx: &mut App, +) -> Result { + // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed + // to properly display colors. + // We do not have the luxury of assuming the host has it installed, + // so we set it to a default that does not break the highlighting via ssh. + env.entry("TERM".to_string()) + .or_insert_with(|| "xterm-256color".to_string()); + + let (program, args) = match spawn_command { + Some((program, args)) => (Some(program.clone()), args), + None => (None, &Vec::new()), }; - let mut env_changes = String::new(); - for (k, v) in env.iter() { - if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) { - env_changes.push_str(&format!("{}={} ", k, v)); - } - } - if let Some(venv_directory) = venv_directory - && let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref()) - { - let path = RemotePathBuf::new(PathBuf::from(str.to_string()), path_style).to_string(); - env_changes.push_str(&format!("PATH={}:$PATH ", path)); - } - - let commands = if let Some(path) = path { - let path = RemotePathBuf::new(path.to_path_buf(), path_style).to_string(); - // shlex will wrap the command in single quotes (''), disabling ~ expansion, - // replace ith with something that works - let tilde_prefix = "~/"; - if path.starts_with(tilde_prefix) { - let trimmed_path = path - .trim_start_matches("/") - .trim_start_matches("~") - .trim_start_matches("/"); - - format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}") - } else { - format!("cd \"{path}\"; {env_changes} {to_run}") - } - } else { - format!("cd; {env_changes} {to_run}") - }; - let shell_invocation = format!("{shell} -c {}", shlex::try_quote(&commands).unwrap()); - - let program = "ssh".to_string(); - let mut args = ssh_command.arguments.clone(); - - args.push("-t".to_string()); - args.push(shell_invocation); - (program, args) -} - -fn add_environment_path(env: &mut HashMap, new_path: &Path) -> Result<()> { - let mut env_paths = vec![new_path.to_path_buf()]; - if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) { - let mut paths = std::env::split_paths(&path).collect::>(); - env_paths.append(&mut paths); - } - - let paths = std::env::join_paths(env_paths).context("failed to create PATH env variable")?; - env.insert("PATH".to_string(), paths.to_string_lossy().to_string()); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use collections::HashMap; - - #[test] - fn test_add_environment_path_with_existing_path() { - let tmp_path = std::path::PathBuf::from("/tmp/new"); - let mut env = HashMap::default(); - let old_path = if cfg!(windows) { - "/usr/bin;/usr/local/bin" - } else { - "/usr/bin:/usr/local/bin" - }; - env.insert("PATH".to_string(), old_path.to_string()); - env.insert("OTHER".to_string(), "aaa".to_string()); - - super::add_environment_path(&mut env, &tmp_path).unwrap(); - if cfg!(windows) { - assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", old_path)); - } else { - assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", old_path)); - } - assert_eq!(env.get("OTHER").unwrap(), "aaa"); - } - - #[test] - fn test_add_environment_path_with_empty_path() { - let tmp_path = std::path::PathBuf::from("/tmp/new"); - let mut env = HashMap::default(); - env.insert("OTHER".to_string(), "aaa".to_string()); - let os_path = std::env::var("PATH").unwrap(); - super::add_environment_path(&mut env, &tmp_path).unwrap(); - if cfg!(windows) { - assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", os_path)); - } else { - assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", os_path)); - } - assert_eq!(env.get("OTHER").unwrap(), "aaa"); - } + let command = remote_client.read(cx).build_command( + program, + args.as_slice(), + env, + working_directory.map(|path| path.display().to_string()), + activation_script, + None, + )?; + *env = command.env; + + log::debug!("Connecting to a remote server: {:?}", command.program); + let host = remote_client.read(cx).connection_options().host; + + Ok(Shell::WithArguments { + program: command.program, + args: command.args, + title_override: Some(format!("{} — Terminal", host).into()), + }) } diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index ac87e6424821a5d28dbf48b92b077183a21d8608..57d492e26fc7b59df02df0128ed6b9ade132c6d9 100644 --- a/crates/project/src/toolchain_store.rs +++ b/crates/project/src/toolchain_store.rs @@ -389,12 +389,7 @@ impl LocalToolchainStore { cx.background_spawn(async move { Some(( toolchains - .list( - worktree_root, - Some(relative_path.path.clone()) - .filter(|_| *relative_path.path != *Path::new("")), - project_env, - ) + .list(worktree_root, relative_path.path.clone(), project_env) .await, relative_path.path, )) diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 7dd0274facc92ebe3f7d135489ddafd89ce6e1fa..99c0a04d4f4b1d3d46705a2001d718c5e9d882c8 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -18,7 +18,7 @@ use gpui::{ use postage::oneshot; use rpc::{ AnyProtoClient, ErrorExt, TypedEnvelope, - proto::{self, FromProto, SSH_PROJECT_ID, ToProto}, + proto::{self, FromProto, REMOTE_SERVER_PROJECT_ID, ToProto}, }; use smol::{ channel::{Receiver, Sender}, @@ -61,7 +61,7 @@ pub struct WorktreeStore { worktrees_reordered: bool, #[allow(clippy::type_complexity)] loading_worktrees: - HashMap, Arc>>>>, + HashMap, Shared, Arc>>>>, state: WorktreeStoreState, } @@ -153,10 +153,10 @@ impl WorktreeStore { pub fn find_worktree( &self, - abs_path: impl Into, + abs_path: impl AsRef, cx: &App, ) -> Option<(Entity, PathBuf)> { - let abs_path: SanitizedPath = abs_path.into(); + let abs_path = SanitizedPath::new(&abs_path); for tree in self.worktrees() { if let Ok(relative_path) = abs_path.as_path().strip_prefix(tree.read(cx).abs_path()) { return Some((tree.clone(), relative_path.into())); @@ -211,11 +211,11 @@ impl WorktreeStore { pub fn create_worktree( &mut self, - abs_path: impl Into, + abs_path: impl AsRef, visible: bool, cx: &mut Context, ) -> Task>> { - let abs_path: SanitizedPath = abs_path.into(); + let abs_path: Arc = SanitizedPath::new_arc(&abs_path); if !self.loading_worktrees.contains_key(&abs_path) { let task = match &self.state { WorktreeStoreState::Remote { @@ -226,8 +226,7 @@ impl WorktreeStore { if upstream_client.is_via_collab() { Task::ready(Err(Arc::new(anyhow!("cannot create worktrees via collab")))) } else { - let abs_path = - RemotePathBuf::new(abs_path.as_path().to_path_buf(), *path_style); + let abs_path = RemotePathBuf::new(abs_path.to_path_buf(), *path_style); self.create_ssh_worktree(upstream_client.clone(), abs_path, visible, cx) } } @@ -277,7 +276,7 @@ impl WorktreeStore { let path = RemotePathBuf::new(abs_path.into(), path_style); let response = client .request(proto::AddWorktree { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, path: path.to_proto(), visible, }) @@ -297,7 +296,7 @@ impl WorktreeStore { let worktree = cx.update(|cx| { Worktree::remote( - SSH_PROJECT_ID, + REMOTE_SERVER_PROJECT_ID, 0, proto::WorktreeMetadata { id: response.worktree_id, @@ -320,15 +319,21 @@ impl WorktreeStore { fn create_local_worktree( &mut self, fs: Arc, - abs_path: impl Into, + abs_path: Arc, visible: bool, cx: &mut Context, ) -> Task, Arc>> { let next_entry_id = self.next_entry_id.clone(); - let path: SanitizedPath = abs_path.into(); cx.spawn(async move |this, cx| { - let worktree = Worktree::local(path.clone(), visible, fs, next_entry_id, cx).await; + let worktree = Worktree::local( + SanitizedPath::cast_arc(abs_path.clone()), + visible, + fs, + next_entry_id, + cx, + ) + .await; let worktree = worktree?; @@ -336,7 +341,7 @@ impl WorktreeStore { if visible { cx.update(|cx| { - cx.add_recent_document(path.as_path()); + cx.add_recent_document(abs_path.as_path()); }) .log_err(); } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index c99f5f8172b0fe51c49d23e7cbc5c6f9e714d7f0..eeb2f7a49b82ce35199c8a773d70cf5fd358c034 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -653,7 +653,7 @@ impl ProjectPanel { let file_path = entry.path.clone(); let worktree_id = worktree.read(cx).id(); let entry_id = entry.id; - let is_via_ssh = project.read(cx).is_via_ssh(); + let is_via_ssh = project.read(cx).is_via_remote_server(); workspace .open_path_preview( @@ -4089,6 +4089,7 @@ impl ProjectPanel { .when(!is_sticky, |this| { this .when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over)) + .when(settings.drag_and_drop, |this| this .on_drag_move::(cx.listener( move |this, event: &DragMoveEvent, _, cx| { let is_current_target = this.drag_target_entry.as_ref() @@ -4222,7 +4223,7 @@ impl ProjectPanel { } this.drag_onto(selections, entry_id, kind.is_file(), window, cx); }), - ) + )) }) .on_mouse_down( MouseButton::Left, @@ -4433,6 +4434,7 @@ impl ProjectPanel { div() .when(!is_sticky, |div| { div + .when(settings.drag_and_drop, |div| div .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| { this.hover_scroll_task.take(); this.drag_target_entry = None; @@ -4464,7 +4466,7 @@ impl ProjectPanel { } }, - )) + ))) }) .child( Label::new(DELIMITER.clone()) @@ -4484,6 +4486,7 @@ impl ProjectPanel { .when(index != components_len - 1, |div|{ let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned(); div + .when(settings.drag_and_drop, |div| div .on_drag_move(cx.listener( move |this, event: &DragMoveEvent, _, _| { if event.bounds.contains(&event.event.position) { @@ -4521,7 +4524,7 @@ impl ProjectPanel { target.index == index ), |this| { this.bg(item_colors.drag_over) - }) + })) }) }) .on_click(cx.listener(move |this, _, _, cx| { @@ -5029,7 +5032,8 @@ impl ProjectPanel { sticky_parents.reverse(); - let git_status_enabled = ProjectPanelSettings::get_global(cx).git_status; + let panel_settings = ProjectPanelSettings::get_global(cx); + let git_status_enabled = panel_settings.git_status; let root_name = OsStr::new(worktree.root_name()); let git_summaries_by_id = if git_status_enabled { @@ -5113,11 +5117,11 @@ impl Render for ProjectPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let has_worktree = !self.visible_entries.is_empty(); let project = self.project.read(cx); - let indent_size = ProjectPanelSettings::get_global(cx).indent_size; - let show_indent_guides = - ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always; + let panel_settings = ProjectPanelSettings::get_global(cx); + let indent_size = panel_settings.indent_size; + let show_indent_guides = panel_settings.indent_guides.show == ShowIndentGuides::Always; let show_sticky_entries = { - if ProjectPanelSettings::get_global(cx).sticky_scroll { + if panel_settings.sticky_scroll { let is_scrollable = self.scroll_handle.is_scrollable(); let is_scrolled = self.scroll_handle.offset().y < px(0.); is_scrollable && is_scrolled @@ -5205,8 +5209,10 @@ impl Render for ProjectPanel { h_flex() .id("project-panel") .group("project-panel") - .on_drag_move(cx.listener(handle_drag_move::)) - .on_drag_move(cx.listener(handle_drag_move::)) + .when(panel_settings.drag_and_drop, |this| { + this.on_drag_move(cx.listener(handle_drag_move::)) + .on_drag_move(cx.listener(handle_drag_move::)) + }) .size_full() .relative() .on_modifiers_changed(cx.listener( @@ -5295,7 +5301,7 @@ impl Render for ProjectPanel { .on_action(cx.listener(Self::open_system)) .on_action(cx.listener(Self::open_in_terminal)) }) - .when(project.is_via_ssh(), |el| { + .when(project.is_via_remote_server(), |el| { el.on_action(cx.listener(Self::open_in_terminal)) }) .on_mouse_down( @@ -5544,30 +5550,32 @@ impl Render for ProjectPanel { })), ) .when(is_local, |div| { - div.drag_over::(|style, _, _, cx| { - style.bg(cx.theme().colors().drop_target_background) + div.when(panel_settings.drag_and_drop, |div| { + div.drag_over::(|style, _, _, cx| { + style.bg(cx.theme().colors().drop_target_background) + }) + .on_drop(cx.listener( + move |this, external_paths: &ExternalPaths, window, cx| { + this.drag_target_entry = None; + this.hover_scroll_task.take(); + if let Some(task) = this + .workspace + .update(cx, |workspace, cx| { + workspace.open_workspace_for_paths( + true, + external_paths.paths().to_owned(), + window, + cx, + ) + }) + .log_err() + { + task.detach_and_log_err(cx); + } + cx.stop_propagation(); + }, + )) }) - .on_drop(cx.listener( - move |this, external_paths: &ExternalPaths, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); - if let Some(task) = this - .workspace - .update(cx, |workspace, cx| { - workspace.open_workspace_for_paths( - true, - external_paths.paths().to_owned(), - window, - cx, - ) - }) - .log_err() - { - task.detach_and_log_err(cx); - } - cx.stop_propagation(); - }, - )) }) } } diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 8a243589ed76f6a3518fc7c0722f5fb5e8604c73..fc399d66a7b78e75a9e43a3e7bf0404624123685 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -47,6 +47,7 @@ pub struct ProjectPanelSettings { pub scrollbar: ScrollbarSettings, pub show_diagnostics: ShowDiagnostics, pub hide_root: bool, + pub drag_and_drop: bool, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -160,6 +161,10 @@ pub struct ProjectPanelSettingsContent { /// /// Default: true pub sticky_scroll: Option, + /// Whether to enable drag-and-drop operations in the project panel. + /// + /// Default: true + pub drag_and_drop: Option, } impl Settings for ProjectPanelSettings { diff --git a/crates/proto/build.rs b/crates/proto/build.rs index 2997e302b6e62348eef6d65f158b22f00c992f7c..184d0e53d57bf06f5cd3667adea86ec4a2dde282 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -1,4 +1,5 @@ fn main() { + println!("cargo:rerun-if-changed=proto"); let mut build = prost_build::Config::new(); build .type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]") diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index 473ef5c38cc6f401a05556c1f02271e83bd8fa97..16f6217b29d50a4a2eb9198565f688335c218802 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -610,11 +610,36 @@ message ServerMetadataUpdated { message LanguageServerLog { uint64 project_id = 1; uint64 language_server_id = 2; + string message = 3; oneof log_type { - uint32 log_message_type = 3; - LspLogTrace log_trace = 4; + LogMessage log = 4; + TraceMessage trace = 5; + RpcMessage rpc = 6; + } +} + +message LogMessage { + LogLevel level = 1; + + enum LogLevel { + LOG = 0; + INFO = 1; + WARNING = 2; + ERROR = 3; + } +} + +message TraceMessage { + optional string verbose_info = 1; +} + +message RpcMessage { + Kind kind = 1; + + enum Kind { + RECEIVED = 0; + SENT = 1; } - string message = 5; } message LspLogTrace { @@ -932,3 +957,16 @@ message MultiLspQuery { message MultiLspQueryResponse { repeated LspResponse responses = 1; } + +message ToggleLspLogs { + uint64 project_id = 1; + LogType log_type = 2; + uint64 server_id = 3; + bool enabled = 4; + + enum LogType { + LOG = 0; + TRACE = 1; + RPC = 2; + } +} diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 70689bcd6306195fce0d5c6449bf3dd9f5d43539..2222bdec082759cb75ffcdb2c7a95435f36eba11 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -396,7 +396,8 @@ message Envelope { GitCloneResponse git_clone_response = 364; LspQuery lsp_query = 365; - LspQueryResponse lsp_query_response = 366; // current max + LspQueryResponse lsp_query_response = 366; + ToggleLspLogs toggle_lsp_logs = 367; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index d38e54685ffb78fe8621b12a0dd25bb6d1ab3f6e..04495fb898b1d9bdbf229bb69e1e44b8afa6d1fb 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -16,8 +16,8 @@ pub use typed_envelope::*; include!(concat!(env!("OUT_DIR"), "/zed.messages.rs")); -pub const SSH_PEER_ID: PeerId = PeerId { owner_id: 0, id: 0 }; -pub const SSH_PROJECT_ID: u64 = 0; +pub const REMOTE_SERVER_PEER_ID: PeerId = PeerId { owner_id: 0, id: 0 }; +pub const REMOTE_SERVER_PROJECT_ID: u64 = 0; messages!( (Ack, Foreground), @@ -312,7 +312,8 @@ messages!( (GetDefaultBranch, Background), (GetDefaultBranchResponse, Background), (GitClone, Background), - (GitCloneResponse, Background) + (GitCloneResponse, Background), + (ToggleLspLogs, Background), ); request_messages!( @@ -481,7 +482,8 @@ request_messages!( (GetDocumentDiagnostics, GetDocumentDiagnosticsResponse), (PullWorkspaceDiagnostics, Ack), (GetDefaultBranch, GetDefaultBranchResponse), - (GitClone, GitCloneResponse) + (GitClone, GitCloneResponse), + (ToggleLspLogs, Ack), ); lsp_messages!( @@ -612,6 +614,7 @@ entity_messages!( GitReset, GitCheckoutFiles, SetIndexText, + ToggleLspLogs, Push, Fetch, diff --git a/crates/recent_projects/src/disconnected_overlay.rs b/crates/recent_projects/src/disconnected_overlay.rs index 8ffe0ef07cf2e0c635794383ae08203c87a33f44..36da6897b92e4bc183aa7c0f51d5100e8836931e 100644 --- a/crates/recent_projects/src/disconnected_overlay.rs +++ b/crates/recent_projects/src/disconnected_overlay.rs @@ -64,8 +64,8 @@ impl DisconnectedOverlay { } let handle = cx.entity().downgrade(); - let ssh_connection_options = project.read(cx).ssh_connection_options(cx); - let host = if let Some(ssh_connection_options) = ssh_connection_options { + let remote_connection_options = project.read(cx).remote_connection_options(cx); + let host = if let Some(ssh_connection_options) = remote_connection_options { Host::SshRemoteProject(ssh_connection_options) } else { Host::RemoteProject diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index a9c3284d0bd5d9dc5e279ce317f2280914bd623f..f4fd1f1c1bbb12e2fbf11088baf859b08bfbf310 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -28,8 +28,8 @@ use paths::user_ssh_config_file; use picker::Picker; use project::Fs; use project::Project; -use remote::ssh_session::ConnectionIdentifier; -use remote::{SshConnectionOptions, SshRemoteClient}; +use remote::remote_client::ConnectionIdentifier; +use remote::{RemoteClient, SshConnectionOptions}; use settings::Settings; use settings::SettingsStore; use settings::update_settings_file; @@ -69,7 +69,7 @@ pub struct RemoteServerProjects { mode: Mode, focus_handle: FocusHandle, workspace: WeakEntity, - retained_connections: Vec>, + retained_connections: Vec>, ssh_config_updates: Task<()>, ssh_config_servers: BTreeSet, create_new_window: bool, @@ -597,7 +597,7 @@ impl RemoteServerProjects { let (path_style, project) = cx.update(|_, cx| { ( session.read(cx).path_style(), - project::Project::ssh( + project::Project::remote( session, app_state.client.clone(), app_state.node_runtime.clone(), diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs index d07ea48c7e439651d6612bbea046f7711a6a124a..e3fb249d1632a35d888996da2665d00ea98b2c26 100644 --- a/crates/recent_projects/src/ssh_connections.rs +++ b/crates/recent_projects/src/ssh_connections.rs @@ -15,8 +15,9 @@ use gpui::{ use language::CursorShape; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use release_channel::ReleaseChannel; -use remote::ssh_session::{ConnectionIdentifier, SshPortForwardOption}; -use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient}; +use remote::{ + ConnectionIdentifier, RemoteClient, RemotePlatform, SshConnectionOptions, SshPortForwardOption, +}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsSources}; @@ -451,7 +452,7 @@ pub struct SshClientDelegate { known_password: Option, } -impl remote::SshClientDelegate for SshClientDelegate { +impl remote::RemoteClientDelegate for SshClientDelegate { fn ask_password(&self, prompt: String, tx: oneshot::Sender, cx: &mut AsyncApp) { let mut known_password = self.known_password.clone(); if let Some(password) = known_password.take() { @@ -473,7 +474,7 @@ impl remote::SshClientDelegate for SshClientDelegate { fn download_server_binary_locally( &self, - platform: SshPlatform, + platform: RemotePlatform, release_channel: ReleaseChannel, version: Option, cx: &mut AsyncApp, @@ -503,7 +504,7 @@ impl remote::SshClientDelegate for SshClientDelegate { fn get_download_params( &self, - platform: SshPlatform, + platform: RemotePlatform, release_channel: ReleaseChannel, version: Option, cx: &mut AsyncApp, @@ -543,13 +544,13 @@ pub fn connect_over_ssh( ui: Entity, window: &mut Window, cx: &mut App, -) -> Task>>> { +) -> Task>>> { let window = window.window_handle(); let known_password = connection_options.password.clone(); let (tx, rx) = oneshot::channel(); ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx)); - remote::SshRemoteClient::new( + remote::RemoteClient::ssh( unique_identifier, connection_options, rx, @@ -681,9 +682,9 @@ pub async fn open_ssh_project( window .update(cx, |workspace, _, cx| { - if let Some(client) = workspace.project().read(cx).ssh_client() { + if let Some(client) = workspace.project().read(cx).remote_client() { ExtensionStore::global(cx) - .update(cx, |store, cx| store.register_ssh_client(client, cx)); + .update(cx, |store, cx| store.register_remote_client(client, cx)); } }) .ok(); diff --git a/crates/remote/src/protocol.rs b/crates/remote/src/protocol.rs index e5a9c5b7a55bf7a49d720ba3d761c04cc597e4fd..867a31b1645980ad050d6e9b75fd6cfadb9dc50d 100644 --- a/crates/remote/src/protocol.rs +++ b/crates/remote/src/protocol.rs @@ -51,6 +51,16 @@ pub async fn write_message( Ok(()) } +pub async fn write_size_prefixed_buffer( + stream: &mut S, + buffer: &mut Vec, +) -> Result<()> { + let len = buffer.len() as u32; + stream.write_all(len.to_le_bytes().as_slice()).await?; + stream.write_all(buffer).await?; + Ok(()) +} + pub async fn read_message_raw( stream: &mut S, buffer: &mut Vec, diff --git a/crates/remote/src/remote.rs b/crates/remote/src/remote.rs index 71895f1678c5e71819218f62d3831708c2e4a2bc..c698353d9edfc0d48c7039f321a2c88890e8c098 100644 --- a/crates/remote/src/remote.rs +++ b/crates/remote/src/remote.rs @@ -1,9 +1,11 @@ pub mod json_log; pub mod protocol; pub mod proxy; -pub mod ssh_session; +pub mod remote_client; +mod transport; -pub use ssh_session::{ - ConnectionState, SshClientDelegate, SshConnectionOptions, SshInfo, SshPlatform, - SshRemoteClient, SshRemoteEvent, +pub use remote_client::{ + ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent, + RemotePlatform, }; +pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption}; diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs new file mode 100644 index 0000000000000000000000000000000000000000..2b8d9e4a94fb9988e801c5ef9202ee603959d36b --- /dev/null +++ b/crates/remote/src/remote_client.rs @@ -0,0 +1,1488 @@ +use crate::{ + SshConnectionOptions, protocol::MessageId, proxy::ProxyLaunchError, + transport::ssh::SshRemoteConnection, +}; +use anyhow::{Context as _, Result, anyhow}; +use async_trait::async_trait; +use collections::HashMap; +use futures::{ + Future, FutureExt as _, StreamExt as _, + channel::{ + mpsc::{self, Sender, UnboundedReceiver, UnboundedSender}, + oneshot, + }, + future::{BoxFuture, Shared}, + select, select_biased, +}; +use gpui::{ + App, AppContext as _, AsyncApp, BackgroundExecutor, BorrowAppContext, Context, Entity, + EventEmitter, Global, SemanticVersion, Task, WeakEntity, +}; +use parking_lot::Mutex; + +use release_channel::ReleaseChannel; +use rpc::{ + AnyProtoClient, ErrorExt, ProtoClient, ProtoMessageHandlerSet, RpcError, + proto::{self, Envelope, EnvelopedMessage, PeerId, RequestMessage, build_typed_envelope}, +}; +use std::{ + collections::VecDeque, + fmt, + ops::ControlFlow, + path::PathBuf, + sync::{ + Arc, Weak, + atomic::{AtomicU32, AtomicU64, Ordering::SeqCst}, + }, + time::{Duration, Instant}, +}; +use util::{ + ResultExt, + paths::{PathStyle, RemotePathBuf}, +}; + +#[derive(Copy, Clone, Debug)] +pub struct RemotePlatform { + pub os: &'static str, + pub arch: &'static str, +} + +#[derive(Clone, Debug)] +pub struct CommandTemplate { + pub program: String, + pub args: Vec, + pub env: HashMap, +} + +pub trait RemoteClientDelegate: Send + Sync { + fn ask_password(&self, prompt: String, tx: oneshot::Sender, cx: &mut AsyncApp); + fn get_download_params( + &self, + platform: RemotePlatform, + release_channel: ReleaseChannel, + version: Option, + cx: &mut AsyncApp, + ) -> Task>>; + fn download_server_binary_locally( + &self, + platform: RemotePlatform, + release_channel: ReleaseChannel, + version: Option, + cx: &mut AsyncApp, + ) -> Task>; + fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp); +} + +const MAX_MISSED_HEARTBEATS: usize = 5; +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); +const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5); + +const MAX_RECONNECT_ATTEMPTS: usize = 3; + +enum State { + Connecting, + Connected { + ssh_connection: Arc, + delegate: Arc, + + multiplex_task: Task>, + heartbeat_task: Task>, + }, + HeartbeatMissed { + missed_heartbeats: usize, + + ssh_connection: Arc, + delegate: Arc, + + multiplex_task: Task>, + heartbeat_task: Task>, + }, + Reconnecting, + ReconnectFailed { + ssh_connection: Arc, + delegate: Arc, + + error: anyhow::Error, + attempts: usize, + }, + ReconnectExhausted, + ServerNotRunning, +} + +impl fmt::Display for State { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Connecting => write!(f, "connecting"), + Self::Connected { .. } => write!(f, "connected"), + Self::Reconnecting => write!(f, "reconnecting"), + Self::ReconnectFailed { .. } => write!(f, "reconnect failed"), + Self::ReconnectExhausted => write!(f, "reconnect exhausted"), + Self::HeartbeatMissed { .. } => write!(f, "heartbeat missed"), + Self::ServerNotRunning { .. } => write!(f, "server not running"), + } + } +} + +impl State { + fn remote_connection(&self) -> Option> { + match self { + Self::Connected { ssh_connection, .. } => Some(ssh_connection.clone()), + Self::HeartbeatMissed { ssh_connection, .. } => Some(ssh_connection.clone()), + Self::ReconnectFailed { ssh_connection, .. } => Some(ssh_connection.clone()), + _ => None, + } + } + + fn can_reconnect(&self) -> bool { + match self { + Self::Connected { .. } + | Self::HeartbeatMissed { .. } + | Self::ReconnectFailed { .. } => true, + State::Connecting + | State::Reconnecting + | State::ReconnectExhausted + | State::ServerNotRunning => false, + } + } + + fn is_reconnect_failed(&self) -> bool { + matches!(self, Self::ReconnectFailed { .. }) + } + + fn is_reconnect_exhausted(&self) -> bool { + matches!(self, Self::ReconnectExhausted { .. }) + } + + fn is_server_not_running(&self) -> bool { + matches!(self, Self::ServerNotRunning) + } + + fn is_reconnecting(&self) -> bool { + matches!(self, Self::Reconnecting { .. }) + } + + fn heartbeat_recovered(self) -> Self { + match self { + Self::HeartbeatMissed { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + .. + } => Self::Connected { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + }, + _ => self, + } + } + + fn heartbeat_missed(self) -> Self { + match self { + Self::Connected { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + } => Self::HeartbeatMissed { + missed_heartbeats: 1, + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + }, + Self::HeartbeatMissed { + missed_heartbeats, + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + } => Self::HeartbeatMissed { + missed_heartbeats: missed_heartbeats + 1, + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + }, + _ => self, + } + } +} + +/// The state of the ssh connection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConnectionState { + Connecting, + Connected, + HeartbeatMissed, + Reconnecting, + Disconnected, +} + +impl From<&State> for ConnectionState { + fn from(value: &State) -> Self { + match value { + State::Connecting => Self::Connecting, + State::Connected { .. } => Self::Connected, + State::Reconnecting | State::ReconnectFailed { .. } => Self::Reconnecting, + State::HeartbeatMissed { .. } => Self::HeartbeatMissed, + State::ReconnectExhausted => Self::Disconnected, + State::ServerNotRunning => Self::Disconnected, + } + } +} + +pub struct RemoteClient { + client: Arc, + unique_identifier: String, + connection_options: SshConnectionOptions, + path_style: PathStyle, + state: Option, +} + +#[derive(Debug)] +pub enum RemoteClientEvent { + Disconnected, +} + +impl EventEmitter for RemoteClient {} + +// Identifies the socket on the remote server so that reconnects +// can re-join the same project. +pub enum ConnectionIdentifier { + Setup(u64), + Workspace(i64), +} + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +impl ConnectionIdentifier { + pub fn setup() -> Self { + Self::Setup(NEXT_ID.fetch_add(1, SeqCst)) + } + + // This string gets used in a socket name, and so must be relatively short. + // The total length of: + // /home/{username}/.local/share/zed/server_state/{name}/stdout.sock + // Must be less than about 100 characters + // https://unix.stackexchange.com/questions/367008/why-is-socket-path-length-limited-to-a-hundred-chars + // So our strings should be at most 20 characters or so. + fn to_string(&self, cx: &App) -> String { + let identifier_prefix = match ReleaseChannel::global(cx) { + ReleaseChannel::Stable => "".to_string(), + release_channel => format!("{}-", release_channel.dev_name()), + }; + match self { + Self::Setup(setup_id) => format!("{identifier_prefix}setup-{setup_id}"), + Self::Workspace(workspace_id) => { + format!("{identifier_prefix}workspace-{workspace_id}",) + } + } + } +} + +impl RemoteClient { + pub fn ssh( + unique_identifier: ConnectionIdentifier, + connection_options: SshConnectionOptions, + cancellation: oneshot::Receiver<()>, + delegate: Arc, + cx: &mut App, + ) -> Task>>> { + let unique_identifier = unique_identifier.to_string(cx); + cx.spawn(async move |cx| { + let success = Box::pin(async move { + let (outgoing_tx, outgoing_rx) = mpsc::unbounded::(); + let (incoming_tx, incoming_rx) = mpsc::unbounded::(); + let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1); + + let client = + cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?; + + let ssh_connection = cx + .update(|cx| { + cx.update_default_global(|pool: &mut ConnectionPool, cx| { + pool.connect(connection_options.clone(), &delegate, cx) + }) + })? + .await + .map_err(|e| e.cloned())?; + + let path_style = ssh_connection.path_style(); + let this = cx.new(|_| Self { + client: client.clone(), + unique_identifier: unique_identifier.clone(), + connection_options, + path_style, + state: Some(State::Connecting), + })?; + + let io_task = ssh_connection.start_proxy( + unique_identifier, + false, + incoming_tx, + outgoing_rx, + connection_activity_tx, + delegate.clone(), + cx, + ); + + let multiplex_task = Self::monitor(this.downgrade(), io_task, cx); + + if let Err(error) = client.ping(HEARTBEAT_TIMEOUT).await { + log::error!("failed to establish connection: {}", error); + return Err(error); + } + + let heartbeat_task = Self::heartbeat(this.downgrade(), connection_activity_rx, cx); + + this.update(cx, |this, _| { + this.state = Some(State::Connected { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + }); + })?; + + Ok(Some(this)) + }); + + select! { + _ = cancellation.fuse() => { + Ok(None) + } + result = success.fuse() => result + } + }) + } + + pub fn proto_client_from_channels( + incoming_rx: mpsc::UnboundedReceiver, + outgoing_tx: mpsc::UnboundedSender, + cx: &App, + name: &'static str, + ) -> AnyProtoClient { + ChannelClient::new(incoming_rx, outgoing_tx, cx, name).into() + } + + pub fn shutdown_processes( + &mut self, + shutdown_request: Option, + executor: BackgroundExecutor, + ) -> Option + use> { + let state = self.state.take()?; + log::info!("shutting down ssh processes"); + + let State::Connected { + multiplex_task, + heartbeat_task, + ssh_connection, + delegate, + } = state + else { + return None; + }; + + let client = self.client.clone(); + + Some(async move { + if let Some(shutdown_request) = shutdown_request { + client.send(shutdown_request).log_err(); + // We wait 50ms instead of waiting for a response, because + // waiting for a response would require us to wait on the main thread + // which we want to avoid in an `on_app_quit` callback. + executor.timer(Duration::from_millis(50)).await; + } + + // Drop `multiplex_task` because it owns our ssh_proxy_process, which is a + // child of master_process. + drop(multiplex_task); + // Now drop the rest of state, which kills master process. + drop(heartbeat_task); + drop(ssh_connection); + drop(delegate); + }) + } + + fn reconnect(&mut self, cx: &mut Context) -> Result<()> { + let can_reconnect = self + .state + .as_ref() + .map(|state| state.can_reconnect()) + .unwrap_or(false); + if !can_reconnect { + log::info!("aborting reconnect, because not in state that allows reconnecting"); + let error = if let Some(state) = self.state.as_ref() { + format!("invalid state, cannot reconnect while in state {state}") + } else { + "no state set".to_string() + }; + anyhow::bail!(error); + } + + let state = self.state.take().unwrap(); + let (attempts, ssh_connection, delegate) = match state { + State::Connected { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + } + | State::HeartbeatMissed { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + .. + } => { + drop(multiplex_task); + drop(heartbeat_task); + (0, ssh_connection, delegate) + } + State::ReconnectFailed { + attempts, + ssh_connection, + delegate, + .. + } => (attempts, ssh_connection, delegate), + State::Connecting + | State::Reconnecting + | State::ReconnectExhausted + | State::ServerNotRunning => unreachable!(), + }; + + let attempts = attempts + 1; + if attempts > MAX_RECONNECT_ATTEMPTS { + log::error!( + "Failed to reconnect to after {} attempts, giving up", + MAX_RECONNECT_ATTEMPTS + ); + self.set_state(State::ReconnectExhausted, cx); + return Ok(()); + } + + self.set_state(State::Reconnecting, cx); + + log::info!("Trying to reconnect to ssh server... Attempt {}", attempts); + + let unique_identifier = self.unique_identifier.clone(); + let client = self.client.clone(); + let reconnect_task = cx.spawn(async move |this, cx| { + macro_rules! failed { + ($error:expr, $attempts:expr, $ssh_connection:expr, $delegate:expr) => { + return State::ReconnectFailed { + error: anyhow!($error), + attempts: $attempts, + ssh_connection: $ssh_connection, + delegate: $delegate, + }; + }; + } + + if let Err(error) = ssh_connection + .kill() + .await + .context("Failed to kill ssh process") + { + failed!(error, attempts, ssh_connection, delegate); + }; + + let connection_options = ssh_connection.connection_options(); + + let (outgoing_tx, outgoing_rx) = mpsc::unbounded::(); + let (incoming_tx, incoming_rx) = mpsc::unbounded::(); + let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1); + + let (ssh_connection, io_task) = match async { + let ssh_connection = cx + .update_global(|pool: &mut ConnectionPool, cx| { + pool.connect(connection_options, &delegate, cx) + })? + .await + .map_err(|error| error.cloned())?; + + let io_task = ssh_connection.start_proxy( + unique_identifier, + true, + incoming_tx, + outgoing_rx, + connection_activity_tx, + delegate.clone(), + cx, + ); + anyhow::Ok((ssh_connection, io_task)) + } + .await + { + Ok((ssh_connection, io_task)) => (ssh_connection, io_task), + Err(error) => { + failed!(error, attempts, ssh_connection, delegate); + } + }; + + let multiplex_task = Self::monitor(this.clone(), io_task, cx); + client.reconnect(incoming_rx, outgoing_tx, cx); + + if let Err(error) = client.resync(HEARTBEAT_TIMEOUT).await { + failed!(error, attempts, ssh_connection, delegate); + }; + + State::Connected { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task: Self::heartbeat(this.clone(), connection_activity_rx, cx), + } + }); + + cx.spawn(async move |this, cx| { + let new_state = reconnect_task.await; + this.update(cx, |this, cx| { + this.try_set_state(cx, |old_state| { + if old_state.is_reconnecting() { + match &new_state { + State::Connecting + | State::Reconnecting + | State::HeartbeatMissed { .. } + | State::ServerNotRunning => {} + State::Connected { .. } => { + log::info!("Successfully reconnected"); + } + State::ReconnectFailed { + error, attempts, .. + } => { + log::error!( + "Reconnect attempt {} failed: {:?}. Starting new attempt...", + attempts, + error + ); + } + State::ReconnectExhausted => { + log::error!("Reconnect attempt failed and all attempts exhausted"); + } + } + Some(new_state) + } else { + None + } + }); + + if this.state_is(State::is_reconnect_failed) { + this.reconnect(cx) + } else if this.state_is(State::is_reconnect_exhausted) { + Ok(()) + } else { + log::debug!("State has transition from Reconnecting into new state while attempting reconnect."); + Ok(()) + } + }) + }) + .detach_and_log_err(cx); + + Ok(()) + } + + fn heartbeat( + this: WeakEntity, + mut connection_activity_rx: mpsc::Receiver<()>, + cx: &mut AsyncApp, + ) -> Task> { + let Ok(client) = this.read_with(cx, |this, _| this.client.clone()) else { + return Task::ready(Err(anyhow!("SshRemoteClient lost"))); + }; + + cx.spawn(async move |cx| { + let mut missed_heartbeats = 0; + + let keepalive_timer = cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse(); + futures::pin_mut!(keepalive_timer); + + loop { + select_biased! { + result = connection_activity_rx.next().fuse() => { + if result.is_none() { + log::warn!("ssh heartbeat: connection activity channel has been dropped. stopping."); + return Ok(()); + } + + if missed_heartbeats != 0 { + missed_heartbeats = 0; + let _ =this.update(cx, |this, cx| { + this.handle_heartbeat_result(missed_heartbeats, cx) + })?; + } + } + _ = keepalive_timer => { + log::debug!("Sending heartbeat to server..."); + + let result = select_biased! { + _ = connection_activity_rx.next().fuse() => { + Ok(()) + } + ping_result = client.ping(HEARTBEAT_TIMEOUT).fuse() => { + ping_result + } + }; + + if result.is_err() { + missed_heartbeats += 1; + log::warn!( + "No heartbeat from server after {:?}. Missed heartbeat {} out of {}.", + HEARTBEAT_TIMEOUT, + missed_heartbeats, + MAX_MISSED_HEARTBEATS + ); + } else if missed_heartbeats != 0 { + missed_heartbeats = 0; + } else { + continue; + } + + let result = this.update(cx, |this, cx| { + this.handle_heartbeat_result(missed_heartbeats, cx) + })?; + if result.is_break() { + return Ok(()); + } + } + } + + keepalive_timer.set(cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse()); + } + }) + } + + fn handle_heartbeat_result( + &mut self, + missed_heartbeats: usize, + cx: &mut Context, + ) -> ControlFlow<()> { + let state = self.state.take().unwrap(); + let next_state = if missed_heartbeats > 0 { + state.heartbeat_missed() + } else { + state.heartbeat_recovered() + }; + + self.set_state(next_state, cx); + + if missed_heartbeats >= MAX_MISSED_HEARTBEATS { + log::error!( + "Missed last {} heartbeats. Reconnecting...", + missed_heartbeats + ); + + self.reconnect(cx) + .context("failed to start reconnect process after missing heartbeats") + .log_err(); + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } + + fn monitor( + this: WeakEntity, + io_task: Task>, + cx: &AsyncApp, + ) -> Task> { + cx.spawn(async move |cx| { + let result = io_task.await; + + match result { + Ok(exit_code) => { + if let Some(error) = ProxyLaunchError::from_exit_code(exit_code) { + match error { + ProxyLaunchError::ServerNotRunning => { + log::error!("failed to reconnect because server is not running"); + this.update(cx, |this, cx| { + this.set_state(State::ServerNotRunning, cx); + })?; + } + } + } else if exit_code > 0 { + log::error!("proxy process terminated unexpectedly"); + this.update(cx, |this, cx| { + this.reconnect(cx).ok(); + })?; + } + } + Err(error) => { + log::warn!("ssh io task died with error: {:?}. reconnecting...", error); + this.update(cx, |this, cx| { + this.reconnect(cx).ok(); + })?; + } + } + + Ok(()) + }) + } + + fn state_is(&self, check: impl FnOnce(&State) -> bool) -> bool { + self.state.as_ref().is_some_and(check) + } + + fn try_set_state(&mut self, cx: &mut Context, map: impl FnOnce(&State) -> Option) { + let new_state = self.state.as_ref().and_then(map); + if let Some(new_state) = new_state { + self.state.replace(new_state); + cx.notify(); + } + } + + fn set_state(&mut self, state: State, cx: &mut Context) { + log::info!("setting state to '{}'", &state); + + let is_reconnect_exhausted = state.is_reconnect_exhausted(); + let is_server_not_running = state.is_server_not_running(); + self.state.replace(state); + + if is_reconnect_exhausted || is_server_not_running { + cx.emit(RemoteClientEvent::Disconnected); + } + cx.notify(); + } + + pub fn shell(&self) -> Option { + Some(self.state.as_ref()?.remote_connection()?.shell()) + } + + pub fn build_command( + &self, + program: Option, + args: &[String], + env: &HashMap, + working_dir: Option, + activation_script: Option, + port_forward: Option<(u16, String, u16)>, + ) -> Result { + let Some(connection) = self + .state + .as_ref() + .and_then(|state| state.remote_connection()) + else { + return Err(anyhow!("no connection")); + }; + connection.build_command( + program, + args, + env, + working_dir, + activation_script, + port_forward, + ) + } + + pub fn upload_directory( + &self, + src_path: PathBuf, + dest_path: RemotePathBuf, + cx: &App, + ) -> Task> { + let Some(connection) = self + .state + .as_ref() + .and_then(|state| state.remote_connection()) + else { + return Task::ready(Err(anyhow!("no ssh connection"))); + }; + connection.upload_directory(src_path, dest_path, cx) + } + + pub fn proto_client(&self) -> AnyProtoClient { + self.client.clone().into() + } + + pub fn host(&self) -> String { + self.connection_options.host.clone() + } + + pub fn connection_options(&self) -> SshConnectionOptions { + self.connection_options.clone() + } + + pub fn connection_state(&self) -> ConnectionState { + self.state + .as_ref() + .map(ConnectionState::from) + .unwrap_or(ConnectionState::Disconnected) + } + + pub fn is_disconnected(&self) -> bool { + self.connection_state() == ConnectionState::Disconnected + } + + pub fn path_style(&self) -> PathStyle { + self.path_style + } + + #[cfg(any(test, feature = "test-support"))] + pub fn simulate_disconnect(&self, client_cx: &mut App) -> Task<()> { + let opts = self.connection_options(); + client_cx.spawn(async move |cx| { + let connection = cx + .update_global(|c: &mut ConnectionPool, _| { + if let Some(ConnectionPoolEntry::Connecting(c)) = c.connections.get(&opts) { + c.clone() + } else { + panic!("missing test connection") + } + }) + .unwrap() + .await + .unwrap(); + + connection.simulate_disconnect(cx); + }) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn fake_server( + client_cx: &mut gpui::TestAppContext, + server_cx: &mut gpui::TestAppContext, + ) -> (SshConnectionOptions, AnyProtoClient) { + let port = client_cx + .update(|cx| cx.default_global::().connections.len() as u16 + 1); + let opts = SshConnectionOptions { + host: "".to_string(), + port: Some(port), + ..Default::default() + }; + let (outgoing_tx, _) = mpsc::unbounded::(); + let (_, incoming_rx) = mpsc::unbounded::(); + let server_client = + server_cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server")); + let connection: Arc = Arc::new(fake::FakeRemoteConnection { + connection_options: opts.clone(), + server_cx: fake::SendableCx::new(server_cx), + server_channel: server_client.clone(), + }); + + client_cx.update(|cx| { + cx.update_default_global(|c: &mut ConnectionPool, cx| { + c.connections.insert( + opts.clone(), + ConnectionPoolEntry::Connecting( + cx.background_spawn({ + let connection = connection.clone(); + async move { Ok(connection.clone()) } + }) + .shared(), + ), + ); + }) + }); + + (opts, server_client.into()) + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn fake_client( + opts: SshConnectionOptions, + client_cx: &mut gpui::TestAppContext, + ) -> Entity { + let (_tx, rx) = oneshot::channel(); + client_cx + .update(|cx| { + Self::ssh( + ConnectionIdentifier::setup(), + opts, + rx, + Arc::new(fake::Delegate), + cx, + ) + }) + .await + .unwrap() + .unwrap() + } +} + +enum ConnectionPoolEntry { + Connecting(Shared, Arc>>>), + Connected(Weak), +} + +#[derive(Default)] +struct ConnectionPool { + connections: HashMap, +} + +impl Global for ConnectionPool {} + +impl ConnectionPool { + pub fn connect( + &mut self, + opts: SshConnectionOptions, + delegate: &Arc, + cx: &mut App, + ) -> Shared, Arc>>> { + let connection = self.connections.get(&opts); + match connection { + Some(ConnectionPoolEntry::Connecting(task)) => { + let delegate = delegate.clone(); + cx.spawn(async move |cx| { + delegate.set_status(Some("Waiting for existing connection attempt"), cx); + }) + .detach(); + return task.clone(); + } + Some(ConnectionPoolEntry::Connected(ssh)) => { + if let Some(ssh) = ssh.upgrade() + && !ssh.has_been_killed() + { + return Task::ready(Ok(ssh)).shared(); + } + self.connections.remove(&opts); + } + None => {} + } + + let task = cx + .spawn({ + let opts = opts.clone(); + let delegate = delegate.clone(); + async move |cx| { + let connection = SshRemoteConnection::new(opts.clone(), delegate, cx) + .await + .map(|connection| Arc::new(connection) as Arc); + + cx.update_global(|pool: &mut Self, _| { + debug_assert!(matches!( + pool.connections.get(&opts), + Some(ConnectionPoolEntry::Connecting(_)) + )); + match connection { + Ok(connection) => { + pool.connections.insert( + opts.clone(), + ConnectionPoolEntry::Connected(Arc::downgrade(&connection)), + ); + Ok(connection) + } + Err(error) => { + pool.connections.remove(&opts); + Err(Arc::new(error)) + } + } + })? + } + }) + .shared(); + + self.connections + .insert(opts.clone(), ConnectionPoolEntry::Connecting(task.clone())); + task + } +} + +#[async_trait(?Send)] +pub(crate) trait RemoteConnection: Send + Sync { + fn start_proxy( + &self, + unique_identifier: String, + reconnect: bool, + incoming_tx: UnboundedSender, + outgoing_rx: UnboundedReceiver, + connection_activity_tx: Sender<()>, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Task>; + fn upload_directory( + &self, + src_path: PathBuf, + dest_path: RemotePathBuf, + cx: &App, + ) -> Task>; + async fn kill(&self) -> Result<()>; + fn has_been_killed(&self) -> bool; + fn build_command( + &self, + program: Option, + args: &[String], + env: &HashMap, + working_dir: Option, + activation_script: Option, + port_forward: Option<(u16, String, u16)>, + ) -> Result; + fn connection_options(&self) -> SshConnectionOptions; + fn path_style(&self) -> PathStyle; + fn shell(&self) -> String; + + #[cfg(any(test, feature = "test-support"))] + fn simulate_disconnect(&self, _: &AsyncApp) {} +} + +type ResponseChannels = Mutex)>>>; + +struct ChannelClient { + next_message_id: AtomicU32, + outgoing_tx: Mutex>, + buffer: Mutex>, + response_channels: ResponseChannels, + message_handlers: Mutex, + max_received: AtomicU32, + name: &'static str, + task: Mutex>>, +} + +impl ChannelClient { + fn new( + incoming_rx: mpsc::UnboundedReceiver, + outgoing_tx: mpsc::UnboundedSender, + cx: &App, + name: &'static str, + ) -> Arc { + Arc::new_cyclic(|this| Self { + outgoing_tx: Mutex::new(outgoing_tx), + next_message_id: AtomicU32::new(0), + max_received: AtomicU32::new(0), + response_channels: ResponseChannels::default(), + message_handlers: Default::default(), + buffer: Mutex::new(VecDeque::new()), + name, + task: Mutex::new(Self::start_handling_messages( + this.clone(), + incoming_rx, + &cx.to_async(), + )), + }) + } + + fn start_handling_messages( + this: Weak, + mut incoming_rx: mpsc::UnboundedReceiver, + cx: &AsyncApp, + ) -> Task> { + cx.spawn(async move |cx| { + let peer_id = PeerId { owner_id: 0, id: 0 }; + while let Some(incoming) = incoming_rx.next().await { + let Some(this) = this.upgrade() else { + return anyhow::Ok(()); + }; + if let Some(ack_id) = incoming.ack_id { + let mut buffer = this.buffer.lock(); + while buffer.front().is_some_and(|msg| msg.id <= ack_id) { + buffer.pop_front(); + } + } + if let Some(proto::envelope::Payload::FlushBufferedMessages(_)) = &incoming.payload + { + log::debug!( + "{}:ssh message received. name:FlushBufferedMessages", + this.name + ); + { + let buffer = this.buffer.lock(); + for envelope in buffer.iter() { + this.outgoing_tx + .lock() + .unbounded_send(envelope.clone()) + .ok(); + } + } + let mut envelope = proto::Ack {}.into_envelope(0, Some(incoming.id), None); + envelope.id = this.next_message_id.fetch_add(1, SeqCst); + this.outgoing_tx.lock().unbounded_send(envelope).ok(); + continue; + } + + this.max_received.store(incoming.id, SeqCst); + + if let Some(request_id) = incoming.responding_to { + let request_id = MessageId(request_id); + let sender = this.response_channels.lock().remove(&request_id); + if let Some(sender) = sender { + let (tx, rx) = oneshot::channel(); + if incoming.payload.is_some() { + sender.send((incoming, tx)).ok(); + } + rx.await.ok(); + } + } else if let Some(envelope) = + build_typed_envelope(peer_id, Instant::now(), incoming) + { + let type_name = envelope.payload_type_name(); + let message_id = envelope.message_id(); + if let Some(future) = ProtoMessageHandlerSet::handle_message( + &this.message_handlers, + envelope, + this.clone().into(), + cx.clone(), + ) { + log::debug!("{}:ssh message received. name:{type_name}", this.name); + cx.foreground_executor() + .spawn(async move { + match future.await { + Ok(_) => { + log::debug!( + "{}:ssh message handled. name:{type_name}", + this.name + ); + } + Err(error) => { + log::error!( + "{}:error handling message. type:{}, error:{}", + this.name, + type_name, + format!("{error:#}").lines().fold( + String::new(), + |mut message, line| { + if !message.is_empty() { + message.push(' '); + } + message.push_str(line); + message + } + ) + ); + } + } + }) + .detach() + } else { + log::error!("{}:unhandled ssh message name:{type_name}", this.name); + if let Err(e) = AnyProtoClient::from(this.clone()).send_response( + message_id, + anyhow::anyhow!("no handler registered for {type_name}").to_proto(), + ) { + log::error!( + "{}:error sending error response for {type_name}:{e:#}", + this.name + ); + } + } + } + } + anyhow::Ok(()) + }) + } + + fn reconnect( + self: &Arc, + incoming_rx: UnboundedReceiver, + outgoing_tx: UnboundedSender, + cx: &AsyncApp, + ) { + *self.outgoing_tx.lock() = outgoing_tx; + *self.task.lock() = Self::start_handling_messages(Arc::downgrade(self), incoming_rx, cx); + } + + fn request( + &self, + payload: T, + ) -> impl 'static + Future> { + self.request_internal(payload, true) + } + + fn request_internal( + &self, + payload: T, + use_buffer: bool, + ) -> impl 'static + Future> { + log::debug!("ssh request start. name:{}", T::NAME); + let response = + self.request_dynamic(payload.into_envelope(0, None, None), T::NAME, use_buffer); + async move { + let response = response.await?; + log::debug!("ssh request finish. name:{}", T::NAME); + T::Response::from_envelope(response).context("received a response of the wrong type") + } + } + + async fn resync(&self, timeout: Duration) -> Result<()> { + smol::future::or( + async { + self.request_internal(proto::FlushBufferedMessages {}, false) + .await?; + + for envelope in self.buffer.lock().iter() { + self.outgoing_tx + .lock() + .unbounded_send(envelope.clone()) + .ok(); + } + Ok(()) + }, + async { + smol::Timer::after(timeout).await; + anyhow::bail!("Timed out resyncing remote client") + }, + ) + .await + } + + async fn ping(&self, timeout: Duration) -> Result<()> { + smol::future::or( + async { + self.request(proto::Ping {}).await?; + Ok(()) + }, + async { + smol::Timer::after(timeout).await; + anyhow::bail!("Timed out pinging remote client") + }, + ) + .await + } + + fn send(&self, payload: T) -> Result<()> { + log::debug!("ssh send name:{}", T::NAME); + self.send_dynamic(payload.into_envelope(0, None, None)) + } + + fn request_dynamic( + &self, + mut envelope: proto::Envelope, + type_name: &'static str, + use_buffer: bool, + ) -> impl 'static + Future> { + envelope.id = self.next_message_id.fetch_add(1, SeqCst); + let (tx, rx) = oneshot::channel(); + let mut response_channels_lock = self.response_channels.lock(); + response_channels_lock.insert(MessageId(envelope.id), tx); + drop(response_channels_lock); + + let result = if use_buffer { + self.send_buffered(envelope) + } else { + self.send_unbuffered(envelope) + }; + async move { + if let Err(error) = &result { + log::error!("failed to send message: {error}"); + anyhow::bail!("failed to send message: {error}"); + } + + let response = rx.await.context("connection lost")?.0; + if let Some(proto::envelope::Payload::Error(error)) = &response.payload { + return Err(RpcError::from_proto(error, type_name)); + } + Ok(response) + } + } + + pub fn send_dynamic(&self, mut envelope: proto::Envelope) -> Result<()> { + envelope.id = self.next_message_id.fetch_add(1, SeqCst); + self.send_buffered(envelope) + } + + fn send_buffered(&self, mut envelope: proto::Envelope) -> Result<()> { + envelope.ack_id = Some(self.max_received.load(SeqCst)); + self.buffer.lock().push_back(envelope.clone()); + // ignore errors on send (happen while we're reconnecting) + // assume that the global "disconnected" overlay is sufficient. + self.outgoing_tx.lock().unbounded_send(envelope).ok(); + Ok(()) + } + + fn send_unbuffered(&self, mut envelope: proto::Envelope) -> Result<()> { + envelope.ack_id = Some(self.max_received.load(SeqCst)); + self.outgoing_tx.lock().unbounded_send(envelope).ok(); + Ok(()) + } +} + +impl ProtoClient for ChannelClient { + fn request( + &self, + envelope: proto::Envelope, + request_type: &'static str, + ) -> BoxFuture<'static, Result> { + self.request_dynamic(envelope, request_type, true).boxed() + } + + fn send(&self, envelope: proto::Envelope, _message_type: &'static str) -> Result<()> { + self.send_dynamic(envelope) + } + + fn send_response(&self, envelope: Envelope, _message_type: &'static str) -> anyhow::Result<()> { + self.send_dynamic(envelope) + } + + fn message_handler_set(&self) -> &Mutex { + &self.message_handlers + } + + fn is_via_collab(&self) -> bool { + false + } +} + +#[cfg(any(test, feature = "test-support"))] +mod fake { + use super::{ChannelClient, RemoteClientDelegate, RemoteConnection, RemotePlatform}; + use crate::{SshConnectionOptions, remote_client::CommandTemplate}; + use anyhow::Result; + use async_trait::async_trait; + use collections::HashMap; + use futures::{ + FutureExt, SinkExt, StreamExt, + channel::{ + mpsc::{self, Sender}, + oneshot, + }, + select_biased, + }; + use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task, TestAppContext}; + use release_channel::ReleaseChannel; + use rpc::proto::Envelope; + use std::{path::PathBuf, sync::Arc}; + use util::paths::{PathStyle, RemotePathBuf}; + + pub(super) struct FakeRemoteConnection { + pub(super) connection_options: SshConnectionOptions, + pub(super) server_channel: Arc, + pub(super) server_cx: SendableCx, + } + + pub(super) struct SendableCx(AsyncApp); + impl SendableCx { + // SAFETY: When run in test mode, GPUI is always single threaded. + pub(super) fn new(cx: &TestAppContext) -> Self { + Self(cx.to_async()) + } + + // SAFETY: Enforce that we're on the main thread by requiring a valid AsyncApp + fn get(&self, _: &AsyncApp) -> AsyncApp { + self.0.clone() + } + } + + // SAFETY: There is no way to access a SendableCx from a different thread, see [`SendableCx::new`] and [`SendableCx::get`] + unsafe impl Send for SendableCx {} + unsafe impl Sync for SendableCx {} + + #[async_trait(?Send)] + impl RemoteConnection for FakeRemoteConnection { + async fn kill(&self) -> Result<()> { + Ok(()) + } + + fn has_been_killed(&self) -> bool { + false + } + + fn build_command( + &self, + program: Option, + args: &[String], + env: &HashMap, + _: Option, + _: Option, + _: Option<(u16, String, u16)>, + ) -> Result { + let ssh_program = program.unwrap_or_else(|| "sh".to_string()); + let mut ssh_args = Vec::new(); + ssh_args.push(ssh_program); + ssh_args.extend(args.iter().cloned()); + Ok(CommandTemplate { + program: "ssh".into(), + args: ssh_args, + env: env.clone(), + }) + } + + fn upload_directory( + &self, + _src_path: PathBuf, + _dest_path: RemotePathBuf, + _cx: &App, + ) -> Task> { + unreachable!() + } + + fn connection_options(&self) -> SshConnectionOptions { + self.connection_options.clone() + } + + fn simulate_disconnect(&self, cx: &AsyncApp) { + let (outgoing_tx, _) = mpsc::unbounded::(); + let (_, incoming_rx) = mpsc::unbounded::(); + self.server_channel + .reconnect(incoming_rx, outgoing_tx, &self.server_cx.get(cx)); + } + + fn start_proxy( + &self, + _unique_identifier: String, + _reconnect: bool, + mut client_incoming_tx: mpsc::UnboundedSender, + mut client_outgoing_rx: mpsc::UnboundedReceiver, + mut connection_activity_tx: Sender<()>, + _delegate: Arc, + cx: &mut AsyncApp, + ) -> Task> { + let (mut server_incoming_tx, server_incoming_rx) = mpsc::unbounded::(); + let (server_outgoing_tx, mut server_outgoing_rx) = mpsc::unbounded::(); + + self.server_channel.reconnect( + server_incoming_rx, + server_outgoing_tx, + &self.server_cx.get(cx), + ); + + cx.background_spawn(async move { + loop { + select_biased! { + server_to_client = server_outgoing_rx.next().fuse() => { + let Some(server_to_client) = server_to_client else { + return Ok(1) + }; + connection_activity_tx.try_send(()).ok(); + client_incoming_tx.send(server_to_client).await.ok(); + } + client_to_server = client_outgoing_rx.next().fuse() => { + let Some(client_to_server) = client_to_server else { + return Ok(1) + }; + server_incoming_tx.send(client_to_server).await.ok(); + } + } + } + }) + } + + fn path_style(&self) -> PathStyle { + PathStyle::current() + } + + fn shell(&self) -> String { + "sh".to_owned() + } + } + + pub(super) struct Delegate; + + impl RemoteClientDelegate for Delegate { + fn ask_password(&self, _: String, _: oneshot::Sender, _: &mut AsyncApp) { + unreachable!() + } + + fn download_server_binary_locally( + &self, + _: RemotePlatform, + _: ReleaseChannel, + _: Option, + _: &mut AsyncApp, + ) -> Task> { + unreachable!() + } + + fn get_download_params( + &self, + _platform: RemotePlatform, + _release_channel: ReleaseChannel, + _version: Option, + _cx: &mut AsyncApp, + ) -> Task>> { + unreachable!() + } + + fn set_status(&self, _: Option<&str>, _: &mut AsyncApp) {} + } +} diff --git a/crates/remote/src/ssh_session.rs b/crates/remote/src/ssh_session.rs deleted file mode 100644 index b9af5286439e9f757d53062b8c003eb85e69fbee..0000000000000000000000000000000000000000 --- a/crates/remote/src/ssh_session.rs +++ /dev/null @@ -1,2749 +0,0 @@ -use crate::{ - json_log::LogRecord, - protocol::{ - MESSAGE_LEN_SIZE, MessageId, message_len_from_buffer, read_message_with_len, write_message, - }, - proxy::ProxyLaunchError, -}; -use anyhow::{Context as _, Result, anyhow}; -use async_trait::async_trait; -use collections::HashMap; -use futures::{ - AsyncReadExt as _, Future, FutureExt as _, StreamExt as _, - channel::{ - mpsc::{self, Sender, UnboundedReceiver, UnboundedSender}, - oneshot, - }, - future::{BoxFuture, Shared}, - select, select_biased, -}; -use gpui::{ - App, AppContext as _, AsyncApp, BackgroundExecutor, BorrowAppContext, Context, Entity, - EventEmitter, Global, SemanticVersion, Task, WeakEntity, -}; -use itertools::Itertools; -use parking_lot::Mutex; - -use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; -use rpc::{ - AnyProtoClient, ErrorExt, ProtoClient, ProtoMessageHandlerSet, RpcError, - proto::{self, Envelope, EnvelopedMessage, PeerId, RequestMessage, build_typed_envelope}, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use smol::{ - fs, - process::{self, Child, Stdio}, -}; -use std::{ - collections::VecDeque, - fmt, iter, - ops::ControlFlow, - path::{Path, PathBuf}, - sync::{ - Arc, Weak, - atomic::{AtomicU32, AtomicU64, Ordering::SeqCst}, - }, - time::{Duration, Instant}, -}; -use tempfile::TempDir; -use util::{ - ResultExt, - paths::{PathStyle, RemotePathBuf}, -}; - -#[derive(Clone)] -pub struct SshSocket { - connection_options: SshConnectionOptions, - #[cfg(not(target_os = "windows"))] - socket_path: PathBuf, - #[cfg(target_os = "windows")] - envs: HashMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)] -pub struct SshPortForwardOption { - #[serde(skip_serializing_if = "Option::is_none")] - pub local_host: Option, - pub local_port: u16, - #[serde(skip_serializing_if = "Option::is_none")] - pub remote_host: Option, - pub remote_port: u16, -} - -#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] -pub struct SshConnectionOptions { - pub host: String, - pub username: Option, - pub port: Option, - pub password: Option, - pub args: Option>, - pub port_forwards: Option>, - - pub nickname: Option, - pub upload_binary_over_ssh: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SshArgs { - pub arguments: Vec, - pub envs: Option>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SshInfo { - pub args: SshArgs, - pub path_style: PathStyle, - pub shell: String, -} - -#[macro_export] -macro_rules! shell_script { - ($fmt:expr, $($name:ident = $arg:expr),+ $(,)?) => {{ - format!( - $fmt, - $( - $name = shlex::try_quote($arg).unwrap() - ),+ - ) - }}; -} - -fn parse_port_number(port_str: &str) -> Result { - port_str - .parse() - .with_context(|| format!("parsing port number: {port_str}")) -} - -fn parse_port_forward_spec(spec: &str) -> Result { - let parts: Vec<&str> = spec.split(':').collect(); - - match parts.len() { - 4 => { - let local_port = parse_port_number(parts[1])?; - let remote_port = parse_port_number(parts[3])?; - - Ok(SshPortForwardOption { - local_host: Some(parts[0].to_string()), - local_port, - remote_host: Some(parts[2].to_string()), - remote_port, - }) - } - 3 => { - let local_port = parse_port_number(parts[0])?; - let remote_port = parse_port_number(parts[2])?; - - Ok(SshPortForwardOption { - local_host: None, - local_port, - remote_host: Some(parts[1].to_string()), - remote_port, - }) - } - _ => anyhow::bail!("Invalid port forward format"), - } -} - -impl SshConnectionOptions { - pub fn parse_command_line(input: &str) -> Result { - let input = input.trim_start_matches("ssh "); - let mut hostname: Option = None; - let mut username: Option = None; - let mut port: Option = None; - let mut args = Vec::new(); - let mut port_forwards: Vec = Vec::new(); - - // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W - const ALLOWED_OPTS: &[&str] = &[ - "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y", - ]; - const ALLOWED_ARGS: &[&str] = &[ - "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R", - "-w", - ]; - - let mut tokens = shlex::split(input).context("invalid input")?.into_iter(); - - 'outer: while let Some(arg) = tokens.next() { - if ALLOWED_OPTS.contains(&(&arg as &str)) { - args.push(arg.to_string()); - continue; - } - if arg == "-p" { - port = tokens.next().and_then(|arg| arg.parse().ok()); - continue; - } else if let Some(p) = arg.strip_prefix("-p") { - port = p.parse().ok(); - continue; - } - if arg == "-l" { - username = tokens.next(); - continue; - } else if let Some(l) = arg.strip_prefix("-l") { - username = Some(l.to_string()); - continue; - } - if arg == "-L" || arg.starts_with("-L") { - let forward_spec = if arg == "-L" { - tokens.next() - } else { - Some(arg.strip_prefix("-L").unwrap().to_string()) - }; - - if let Some(spec) = forward_spec { - port_forwards.push(parse_port_forward_spec(&spec)?); - } else { - anyhow::bail!("Missing port forward format"); - } - } - - for a in ALLOWED_ARGS { - if arg == *a { - args.push(arg); - if let Some(next) = tokens.next() { - args.push(next); - } - continue 'outer; - } else if arg.starts_with(a) { - args.push(arg); - continue 'outer; - } - } - if arg.starts_with("-") || hostname.is_some() { - anyhow::bail!("unsupported argument: {:?}", arg); - } - let mut input = &arg as &str; - // Destination might be: username1@username2@ip2@ip1 - if let Some((u, rest)) = input.rsplit_once('@') { - input = rest; - username = Some(u.to_string()); - } - if let Some((rest, p)) = input.split_once(':') { - input = rest; - port = p.parse().ok() - } - hostname = Some(input.to_string()) - } - - let Some(hostname) = hostname else { - anyhow::bail!("missing hostname"); - }; - - let port_forwards = match port_forwards.len() { - 0 => None, - _ => Some(port_forwards), - }; - - Ok(Self { - host: hostname, - username, - port, - port_forwards, - args: Some(args), - password: None, - nickname: None, - upload_binary_over_ssh: false, - }) - } - - pub fn ssh_url(&self) -> String { - let mut result = String::from("ssh://"); - if let Some(username) = &self.username { - // Username might be: username1@username2@ip2 - let username = urlencoding::encode(username); - result.push_str(&username); - result.push('@'); - } - result.push_str(&self.host); - if let Some(port) = self.port { - result.push(':'); - result.push_str(&port.to_string()); - } - result - } - - pub fn additional_args(&self) -> Vec { - let mut args = self.args.iter().flatten().cloned().collect::>(); - - if let Some(forwards) = &self.port_forwards { - args.extend(forwards.iter().map(|pf| { - let local_host = match &pf.local_host { - Some(host) => host, - None => "localhost", - }; - let remote_host = match &pf.remote_host { - Some(host) => host, - None => "localhost", - }; - - format!( - "-L{}:{}:{}:{}", - local_host, pf.local_port, remote_host, pf.remote_port - ) - })); - } - - args - } - - fn scp_url(&self) -> String { - if let Some(username) = &self.username { - format!("{}@{}", username, self.host) - } else { - self.host.clone() - } - } - - pub fn connection_string(&self) -> String { - let host = if let Some(username) = &self.username { - format!("{}@{}", username, self.host) - } else { - self.host.clone() - }; - if let Some(port) = &self.port { - format!("{}:{}", host, port) - } else { - host - } - } -} - -#[derive(Copy, Clone, Debug)] -pub struct SshPlatform { - pub os: &'static str, - pub arch: &'static str, -} - -pub trait SshClientDelegate: Send + Sync { - fn ask_password(&self, prompt: String, tx: oneshot::Sender, cx: &mut AsyncApp); - fn get_download_params( - &self, - platform: SshPlatform, - release_channel: ReleaseChannel, - version: Option, - cx: &mut AsyncApp, - ) -> Task>>; - - fn download_server_binary_locally( - &self, - platform: SshPlatform, - release_channel: ReleaseChannel, - version: Option, - cx: &mut AsyncApp, - ) -> Task>; - fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp); -} - -impl SshSocket { - #[cfg(not(target_os = "windows"))] - fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result { - Ok(Self { - connection_options: options, - socket_path, - }) - } - - #[cfg(target_os = "windows")] - fn new(options: SshConnectionOptions, temp_dir: &TempDir, secret: String) -> Result { - let askpass_script = temp_dir.path().join("askpass.bat"); - std::fs::write(&askpass_script, "@ECHO OFF\necho %ZED_SSH_ASKPASS%")?; - let mut envs = HashMap::default(); - envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into()); - envs.insert("SSH_ASKPASS".into(), askpass_script.display().to_string()); - envs.insert("ZED_SSH_ASKPASS".into(), secret); - Ok(Self { - connection_options: options, - envs, - }) - } - - // :WARNING: ssh unquotes arguments when executing on the remote :WARNING: - // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l - // and passes -l as an argument to sh, not to ls. - // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing - // into a machine. You must use `cd` to get back to $HOME. - // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'" - fn ssh_command(&self, program: &str, args: &[&str]) -> process::Command { - let mut command = util::command::new_smol_command("ssh"); - let to_run = iter::once(&program) - .chain(args.iter()) - .map(|token| { - // We're trying to work with: sh, bash, zsh, fish, tcsh, ...? - debug_assert!( - !token.contains('\n'), - "multiline arguments do not work in all shells" - ); - shlex::try_quote(token).unwrap() - }) - .join(" "); - let to_run = format!("cd; {to_run}"); - log::debug!("ssh {} {:?}", self.connection_options.ssh_url(), to_run); - self.ssh_options(&mut command) - .arg(self.connection_options.ssh_url()) - .arg(to_run); - command - } - - async fn run_command(&self, program: &str, args: &[&str]) -> Result { - let output = self.ssh_command(program, args).output().await?; - anyhow::ensure!( - output.status.success(), - "failed to run command: {}", - String::from_utf8_lossy(&output.stderr) - ); - Ok(String::from_utf8_lossy(&output.stdout).to_string()) - } - - #[cfg(not(target_os = "windows"))] - fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command { - command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .args(self.connection_options.additional_args()) - .args(["-o", "ControlMaster=no", "-o"]) - .arg(format!("ControlPath={}", self.socket_path.display())) - } - - #[cfg(target_os = "windows")] - fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command { - command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .args(self.connection_options.additional_args()) - .envs(self.envs.clone()) - } - - // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh. - // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to - #[cfg(not(target_os = "windows"))] - fn ssh_args(&self) -> SshArgs { - let mut arguments = self.connection_options.additional_args(); - arguments.extend(vec![ - "-o".to_string(), - "ControlMaster=no".to_string(), - "-o".to_string(), - format!("ControlPath={}", self.socket_path.display()), - self.connection_options.ssh_url(), - ]); - SshArgs { - arguments, - envs: None, - } - } - - #[cfg(target_os = "windows")] - fn ssh_args(&self) -> SshArgs { - let mut arguments = self.connection_options.additional_args(); - arguments.push(self.connection_options.ssh_url()); - SshArgs { - arguments, - envs: Some(self.envs.clone()), - } - } - - async fn platform(&self) -> Result { - let uname = self.run_command("sh", &["-c", "uname -sm"]).await?; - let Some((os, arch)) = uname.split_once(" ") else { - anyhow::bail!("unknown uname: {uname:?}") - }; - - let os = match os.trim() { - "Darwin" => "macos", - "Linux" => "linux", - _ => anyhow::bail!( - "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development" - ), - }; - // exclude armv5,6,7 as they are 32-bit. - let arch = if arch.starts_with("armv8") - || arch.starts_with("armv9") - || arch.starts_with("arm64") - || arch.starts_with("aarch64") - { - "aarch64" - } else if arch.starts_with("x86") { - "x86_64" - } else { - anyhow::bail!( - "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development" - ) - }; - - Ok(SshPlatform { os, arch }) - } - - async fn shell(&self) -> String { - match self.run_command("sh", &["-c", "echo $SHELL"]).await { - Ok(shell) => shell.trim().to_owned(), - Err(e) => { - log::error!("Failed to get shell: {e}"); - "sh".to_owned() - } - } - } -} - -const MAX_MISSED_HEARTBEATS: usize = 5; -const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); -const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5); - -const MAX_RECONNECT_ATTEMPTS: usize = 3; - -enum State { - Connecting, - Connected { - ssh_connection: Arc, - delegate: Arc, - - multiplex_task: Task>, - heartbeat_task: Task>, - }, - HeartbeatMissed { - missed_heartbeats: usize, - - ssh_connection: Arc, - delegate: Arc, - - multiplex_task: Task>, - heartbeat_task: Task>, - }, - Reconnecting, - ReconnectFailed { - ssh_connection: Arc, - delegate: Arc, - - error: anyhow::Error, - attempts: usize, - }, - ReconnectExhausted, - ServerNotRunning, -} - -impl fmt::Display for State { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Connecting => write!(f, "connecting"), - Self::Connected { .. } => write!(f, "connected"), - Self::Reconnecting => write!(f, "reconnecting"), - Self::ReconnectFailed { .. } => write!(f, "reconnect failed"), - Self::ReconnectExhausted => write!(f, "reconnect exhausted"), - Self::HeartbeatMissed { .. } => write!(f, "heartbeat missed"), - Self::ServerNotRunning { .. } => write!(f, "server not running"), - } - } -} - -impl State { - fn ssh_connection(&self) -> Option<&dyn RemoteConnection> { - match self { - Self::Connected { ssh_connection, .. } => Some(ssh_connection.as_ref()), - Self::HeartbeatMissed { ssh_connection, .. } => Some(ssh_connection.as_ref()), - Self::ReconnectFailed { ssh_connection, .. } => Some(ssh_connection.as_ref()), - _ => None, - } - } - - fn can_reconnect(&self) -> bool { - match self { - Self::Connected { .. } - | Self::HeartbeatMissed { .. } - | Self::ReconnectFailed { .. } => true, - State::Connecting - | State::Reconnecting - | State::ReconnectExhausted - | State::ServerNotRunning => false, - } - } - - fn is_reconnect_failed(&self) -> bool { - matches!(self, Self::ReconnectFailed { .. }) - } - - fn is_reconnect_exhausted(&self) -> bool { - matches!(self, Self::ReconnectExhausted { .. }) - } - - fn is_server_not_running(&self) -> bool { - matches!(self, Self::ServerNotRunning) - } - - fn is_reconnecting(&self) -> bool { - matches!(self, Self::Reconnecting { .. }) - } - - fn heartbeat_recovered(self) -> Self { - match self { - Self::HeartbeatMissed { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - .. - } => Self::Connected { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - }, - _ => self, - } - } - - fn heartbeat_missed(self) -> Self { - match self { - Self::Connected { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - } => Self::HeartbeatMissed { - missed_heartbeats: 1, - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - }, - Self::HeartbeatMissed { - missed_heartbeats, - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - } => Self::HeartbeatMissed { - missed_heartbeats: missed_heartbeats + 1, - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - }, - _ => self, - } - } -} - -/// The state of the ssh connection. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ConnectionState { - Connecting, - Connected, - HeartbeatMissed, - Reconnecting, - Disconnected, -} - -impl From<&State> for ConnectionState { - fn from(value: &State) -> Self { - match value { - State::Connecting => Self::Connecting, - State::Connected { .. } => Self::Connected, - State::Reconnecting | State::ReconnectFailed { .. } => Self::Reconnecting, - State::HeartbeatMissed { .. } => Self::HeartbeatMissed, - State::ReconnectExhausted => Self::Disconnected, - State::ServerNotRunning => Self::Disconnected, - } - } -} - -pub struct SshRemoteClient { - client: Arc, - unique_identifier: String, - connection_options: SshConnectionOptions, - path_style: PathStyle, - state: Arc>>, -} - -#[derive(Debug)] -pub enum SshRemoteEvent { - Disconnected, -} - -impl EventEmitter for SshRemoteClient {} - -// Identifies the socket on the remote server so that reconnects -// can re-join the same project. -pub enum ConnectionIdentifier { - Setup(u64), - Workspace(i64), -} - -static NEXT_ID: AtomicU64 = AtomicU64::new(1); - -impl ConnectionIdentifier { - pub fn setup() -> Self { - Self::Setup(NEXT_ID.fetch_add(1, SeqCst)) - } - - // This string gets used in a socket name, and so must be relatively short. - // The total length of: - // /home/{username}/.local/share/zed/server_state/{name}/stdout.sock - // Must be less than about 100 characters - // https://unix.stackexchange.com/questions/367008/why-is-socket-path-length-limited-to-a-hundred-chars - // So our strings should be at most 20 characters or so. - fn to_string(&self, cx: &App) -> String { - let identifier_prefix = match ReleaseChannel::global(cx) { - ReleaseChannel::Stable => "".to_string(), - release_channel => format!("{}-", release_channel.dev_name()), - }; - match self { - Self::Setup(setup_id) => format!("{identifier_prefix}setup-{setup_id}"), - Self::Workspace(workspace_id) => { - format!("{identifier_prefix}workspace-{workspace_id}",) - } - } - } -} - -impl SshRemoteClient { - pub fn new( - unique_identifier: ConnectionIdentifier, - connection_options: SshConnectionOptions, - cancellation: oneshot::Receiver<()>, - delegate: Arc, - cx: &mut App, - ) -> Task>>> { - let unique_identifier = unique_identifier.to_string(cx); - cx.spawn(async move |cx| { - let success = Box::pin(async move { - let (outgoing_tx, outgoing_rx) = mpsc::unbounded::(); - let (incoming_tx, incoming_rx) = mpsc::unbounded::(); - let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1); - - let client = - cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?; - - let ssh_connection = cx - .update(|cx| { - cx.update_default_global(|pool: &mut ConnectionPool, cx| { - pool.connect(connection_options.clone(), &delegate, cx) - }) - })? - .await - .map_err(|e| e.cloned())?; - - let path_style = ssh_connection.path_style(); - let this = cx.new(|_| Self { - client: client.clone(), - unique_identifier: unique_identifier.clone(), - connection_options, - path_style, - state: Arc::new(Mutex::new(Some(State::Connecting))), - })?; - - let io_task = ssh_connection.start_proxy( - unique_identifier, - false, - incoming_tx, - outgoing_rx, - connection_activity_tx, - delegate.clone(), - cx, - ); - - let multiplex_task = Self::monitor(this.downgrade(), io_task, cx); - - if let Err(error) = client.ping(HEARTBEAT_TIMEOUT).await { - log::error!("failed to establish connection: {}", error); - return Err(error); - } - - let heartbeat_task = Self::heartbeat(this.downgrade(), connection_activity_rx, cx); - - this.update(cx, |this, _| { - *this.state.lock() = Some(State::Connected { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - }); - })?; - - Ok(Some(this)) - }); - - select! { - _ = cancellation.fuse() => { - Ok(None) - } - result = success.fuse() => result - } - }) - } - - pub fn proto_client_from_channels( - incoming_rx: mpsc::UnboundedReceiver, - outgoing_tx: mpsc::UnboundedSender, - cx: &App, - name: &'static str, - ) -> AnyProtoClient { - ChannelClient::new(incoming_rx, outgoing_tx, cx, name).into() - } - - pub fn shutdown_processes( - &self, - shutdown_request: Option, - executor: BackgroundExecutor, - ) -> Option + use> { - let state = self.state.lock().take()?; - log::info!("shutting down ssh processes"); - - let State::Connected { - multiplex_task, - heartbeat_task, - ssh_connection, - delegate, - } = state - else { - return None; - }; - - let client = self.client.clone(); - - Some(async move { - if let Some(shutdown_request) = shutdown_request { - client.send(shutdown_request).log_err(); - // We wait 50ms instead of waiting for a response, because - // waiting for a response would require us to wait on the main thread - // which we want to avoid in an `on_app_quit` callback. - executor.timer(Duration::from_millis(50)).await; - } - - // Drop `multiplex_task` because it owns our ssh_proxy_process, which is a - // child of master_process. - drop(multiplex_task); - // Now drop the rest of state, which kills master process. - drop(heartbeat_task); - drop(ssh_connection); - drop(delegate); - }) - } - - fn reconnect(&mut self, cx: &mut Context) -> Result<()> { - let mut lock = self.state.lock(); - - let can_reconnect = lock - .as_ref() - .map(|state| state.can_reconnect()) - .unwrap_or(false); - if !can_reconnect { - log::info!("aborting reconnect, because not in state that allows reconnecting"); - let error = if let Some(state) = lock.as_ref() { - format!("invalid state, cannot reconnect while in state {state}") - } else { - "no state set".to_string() - }; - anyhow::bail!(error); - } - - let state = lock.take().unwrap(); - let (attempts, ssh_connection, delegate) = match state { - State::Connected { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - } - | State::HeartbeatMissed { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - .. - } => { - drop(multiplex_task); - drop(heartbeat_task); - (0, ssh_connection, delegate) - } - State::ReconnectFailed { - attempts, - ssh_connection, - delegate, - .. - } => (attempts, ssh_connection, delegate), - State::Connecting - | State::Reconnecting - | State::ReconnectExhausted - | State::ServerNotRunning => unreachable!(), - }; - - let attempts = attempts + 1; - if attempts > MAX_RECONNECT_ATTEMPTS { - log::error!( - "Failed to reconnect to after {} attempts, giving up", - MAX_RECONNECT_ATTEMPTS - ); - drop(lock); - self.set_state(State::ReconnectExhausted, cx); - return Ok(()); - } - drop(lock); - - self.set_state(State::Reconnecting, cx); - - log::info!("Trying to reconnect to ssh server... Attempt {}", attempts); - - let unique_identifier = self.unique_identifier.clone(); - let client = self.client.clone(); - let reconnect_task = cx.spawn(async move |this, cx| { - macro_rules! failed { - ($error:expr, $attempts:expr, $ssh_connection:expr, $delegate:expr) => { - return State::ReconnectFailed { - error: anyhow!($error), - attempts: $attempts, - ssh_connection: $ssh_connection, - delegate: $delegate, - }; - }; - } - - if let Err(error) = ssh_connection - .kill() - .await - .context("Failed to kill ssh process") - { - failed!(error, attempts, ssh_connection, delegate); - }; - - let connection_options = ssh_connection.connection_options(); - - let (outgoing_tx, outgoing_rx) = mpsc::unbounded::(); - let (incoming_tx, incoming_rx) = mpsc::unbounded::(); - let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1); - - let (ssh_connection, io_task) = match async { - let ssh_connection = cx - .update_global(|pool: &mut ConnectionPool, cx| { - pool.connect(connection_options, &delegate, cx) - })? - .await - .map_err(|error| error.cloned())?; - - let io_task = ssh_connection.start_proxy( - unique_identifier, - true, - incoming_tx, - outgoing_rx, - connection_activity_tx, - delegate.clone(), - cx, - ); - anyhow::Ok((ssh_connection, io_task)) - } - .await - { - Ok((ssh_connection, io_task)) => (ssh_connection, io_task), - Err(error) => { - failed!(error, attempts, ssh_connection, delegate); - } - }; - - let multiplex_task = Self::monitor(this.clone(), io_task, cx); - client.reconnect(incoming_rx, outgoing_tx, cx); - - if let Err(error) = client.resync(HEARTBEAT_TIMEOUT).await { - failed!(error, attempts, ssh_connection, delegate); - }; - - State::Connected { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task: Self::heartbeat(this.clone(), connection_activity_rx, cx), - } - }); - - cx.spawn(async move |this, cx| { - let new_state = reconnect_task.await; - this.update(cx, |this, cx| { - this.try_set_state(cx, |old_state| { - if old_state.is_reconnecting() { - match &new_state { - State::Connecting - | State::Reconnecting - | State::HeartbeatMissed { .. } - | State::ServerNotRunning => {} - State::Connected { .. } => { - log::info!("Successfully reconnected"); - } - State::ReconnectFailed { - error, attempts, .. - } => { - log::error!( - "Reconnect attempt {} failed: {:?}. Starting new attempt...", - attempts, - error - ); - } - State::ReconnectExhausted => { - log::error!("Reconnect attempt failed and all attempts exhausted"); - } - } - Some(new_state) - } else { - None - } - }); - - if this.state_is(State::is_reconnect_failed) { - this.reconnect(cx) - } else if this.state_is(State::is_reconnect_exhausted) { - Ok(()) - } else { - log::debug!("State has transition from Reconnecting into new state while attempting reconnect."); - Ok(()) - } - }) - }) - .detach_and_log_err(cx); - - Ok(()) - } - - fn heartbeat( - this: WeakEntity, - mut connection_activity_rx: mpsc::Receiver<()>, - cx: &mut AsyncApp, - ) -> Task> { - let Ok(client) = this.read_with(cx, |this, _| this.client.clone()) else { - return Task::ready(Err(anyhow!("SshRemoteClient lost"))); - }; - - cx.spawn(async move |cx| { - let mut missed_heartbeats = 0; - - let keepalive_timer = cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse(); - futures::pin_mut!(keepalive_timer); - - loop { - select_biased! { - result = connection_activity_rx.next().fuse() => { - if result.is_none() { - log::warn!("ssh heartbeat: connection activity channel has been dropped. stopping."); - return Ok(()); - } - - if missed_heartbeats != 0 { - missed_heartbeats = 0; - let _ =this.update(cx, |this, cx| { - this.handle_heartbeat_result(missed_heartbeats, cx) - })?; - } - } - _ = keepalive_timer => { - log::debug!("Sending heartbeat to server..."); - - let result = select_biased! { - _ = connection_activity_rx.next().fuse() => { - Ok(()) - } - ping_result = client.ping(HEARTBEAT_TIMEOUT).fuse() => { - ping_result - } - }; - - if result.is_err() { - missed_heartbeats += 1; - log::warn!( - "No heartbeat from server after {:?}. Missed heartbeat {} out of {}.", - HEARTBEAT_TIMEOUT, - missed_heartbeats, - MAX_MISSED_HEARTBEATS - ); - } else if missed_heartbeats != 0 { - missed_heartbeats = 0; - } else { - continue; - } - - let result = this.update(cx, |this, cx| { - this.handle_heartbeat_result(missed_heartbeats, cx) - })?; - if result.is_break() { - return Ok(()); - } - } - } - - keepalive_timer.set(cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse()); - } - }) - } - - fn handle_heartbeat_result( - &mut self, - missed_heartbeats: usize, - cx: &mut Context, - ) -> ControlFlow<()> { - let state = self.state.lock().take().unwrap(); - let next_state = if missed_heartbeats > 0 { - state.heartbeat_missed() - } else { - state.heartbeat_recovered() - }; - - self.set_state(next_state, cx); - - if missed_heartbeats >= MAX_MISSED_HEARTBEATS { - log::error!( - "Missed last {} heartbeats. Reconnecting...", - missed_heartbeats - ); - - self.reconnect(cx) - .context("failed to start reconnect process after missing heartbeats") - .log_err(); - ControlFlow::Break(()) - } else { - ControlFlow::Continue(()) - } - } - - fn monitor( - this: WeakEntity, - io_task: Task>, - cx: &AsyncApp, - ) -> Task> { - cx.spawn(async move |cx| { - let result = io_task.await; - - match result { - Ok(exit_code) => { - if let Some(error) = ProxyLaunchError::from_exit_code(exit_code) { - match error { - ProxyLaunchError::ServerNotRunning => { - log::error!("failed to reconnect because server is not running"); - this.update(cx, |this, cx| { - this.set_state(State::ServerNotRunning, cx); - })?; - } - } - } else if exit_code > 0 { - log::error!("proxy process terminated unexpectedly"); - this.update(cx, |this, cx| { - this.reconnect(cx).ok(); - })?; - } - } - Err(error) => { - log::warn!("ssh io task died with error: {:?}. reconnecting...", error); - this.update(cx, |this, cx| { - this.reconnect(cx).ok(); - })?; - } - } - - Ok(()) - }) - } - - fn state_is(&self, check: impl FnOnce(&State) -> bool) -> bool { - self.state.lock().as_ref().is_some_and(check) - } - - fn try_set_state(&self, cx: &mut Context, map: impl FnOnce(&State) -> Option) { - let mut lock = self.state.lock(); - let new_state = lock.as_ref().and_then(map); - - if let Some(new_state) = new_state { - lock.replace(new_state); - cx.notify(); - } - } - - fn set_state(&self, state: State, cx: &mut Context) { - log::info!("setting state to '{}'", &state); - - let is_reconnect_exhausted = state.is_reconnect_exhausted(); - let is_server_not_running = state.is_server_not_running(); - self.state.lock().replace(state); - - if is_reconnect_exhausted || is_server_not_running { - cx.emit(SshRemoteEvent::Disconnected); - } - cx.notify(); - } - - pub fn ssh_info(&self) -> Option { - self.state - .lock() - .as_ref() - .and_then(|state| state.ssh_connection()) - .map(|ssh_connection| SshInfo { - args: ssh_connection.ssh_args(), - path_style: ssh_connection.path_style(), - shell: ssh_connection.shell(), - }) - } - - pub fn upload_directory( - &self, - src_path: PathBuf, - dest_path: RemotePathBuf, - cx: &App, - ) -> Task> { - let state = self.state.lock(); - let Some(connection) = state.as_ref().and_then(|state| state.ssh_connection()) else { - return Task::ready(Err(anyhow!("no ssh connection"))); - }; - connection.upload_directory(src_path, dest_path, cx) - } - - pub fn proto_client(&self) -> AnyProtoClient { - self.client.clone().into() - } - - pub fn connection_string(&self) -> String { - self.connection_options.connection_string() - } - - pub fn connection_options(&self) -> SshConnectionOptions { - self.connection_options.clone() - } - - pub fn connection_state(&self) -> ConnectionState { - self.state - .lock() - .as_ref() - .map(ConnectionState::from) - .unwrap_or(ConnectionState::Disconnected) - } - - pub fn is_disconnected(&self) -> bool { - self.connection_state() == ConnectionState::Disconnected - } - - pub fn path_style(&self) -> PathStyle { - self.path_style - } - - #[cfg(any(test, feature = "test-support"))] - pub fn simulate_disconnect(&self, client_cx: &mut App) -> Task<()> { - let opts = self.connection_options(); - client_cx.spawn(async move |cx| { - let connection = cx - .update_global(|c: &mut ConnectionPool, _| { - if let Some(ConnectionPoolEntry::Connecting(c)) = c.connections.get(&opts) { - c.clone() - } else { - panic!("missing test connection") - } - }) - .unwrap() - .await - .unwrap(); - - connection.simulate_disconnect(cx); - }) - } - - #[cfg(any(test, feature = "test-support"))] - pub fn fake_server( - client_cx: &mut gpui::TestAppContext, - server_cx: &mut gpui::TestAppContext, - ) -> (SshConnectionOptions, AnyProtoClient) { - let port = client_cx - .update(|cx| cx.default_global::().connections.len() as u16 + 1); - let opts = SshConnectionOptions { - host: "".to_string(), - port: Some(port), - ..Default::default() - }; - let (outgoing_tx, _) = mpsc::unbounded::(); - let (_, incoming_rx) = mpsc::unbounded::(); - let server_client = - server_cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server")); - let connection: Arc = Arc::new(fake::FakeRemoteConnection { - connection_options: opts.clone(), - server_cx: fake::SendableCx::new(server_cx), - server_channel: server_client.clone(), - }); - - client_cx.update(|cx| { - cx.update_default_global(|c: &mut ConnectionPool, cx| { - c.connections.insert( - opts.clone(), - ConnectionPoolEntry::Connecting( - cx.background_spawn({ - let connection = connection.clone(); - async move { Ok(connection.clone()) } - }) - .shared(), - ), - ); - }) - }); - - (opts, server_client.into()) - } - - #[cfg(any(test, feature = "test-support"))] - pub async fn fake_client( - opts: SshConnectionOptions, - client_cx: &mut gpui::TestAppContext, - ) -> Entity { - let (_tx, rx) = oneshot::channel(); - client_cx - .update(|cx| { - Self::new( - ConnectionIdentifier::setup(), - opts, - rx, - Arc::new(fake::Delegate), - cx, - ) - }) - .await - .unwrap() - .unwrap() - } -} - -enum ConnectionPoolEntry { - Connecting(Shared, Arc>>>), - Connected(Weak), -} - -#[derive(Default)] -struct ConnectionPool { - connections: HashMap, -} - -impl Global for ConnectionPool {} - -impl ConnectionPool { - pub fn connect( - &mut self, - opts: SshConnectionOptions, - delegate: &Arc, - cx: &mut App, - ) -> Shared, Arc>>> { - let connection = self.connections.get(&opts); - match connection { - Some(ConnectionPoolEntry::Connecting(task)) => { - let delegate = delegate.clone(); - cx.spawn(async move |cx| { - delegate.set_status(Some("Waiting for existing connection attempt"), cx); - }) - .detach(); - return task.clone(); - } - Some(ConnectionPoolEntry::Connected(ssh)) => { - if let Some(ssh) = ssh.upgrade() - && !ssh.has_been_killed() - { - return Task::ready(Ok(ssh)).shared(); - } - self.connections.remove(&opts); - } - None => {} - } - - let task = cx - .spawn({ - let opts = opts.clone(); - let delegate = delegate.clone(); - async move |cx| { - let connection = SshRemoteConnection::new(opts.clone(), delegate, cx) - .await - .map(|connection| Arc::new(connection) as Arc); - - cx.update_global(|pool: &mut Self, _| { - debug_assert!(matches!( - pool.connections.get(&opts), - Some(ConnectionPoolEntry::Connecting(_)) - )); - match connection { - Ok(connection) => { - pool.connections.insert( - opts.clone(), - ConnectionPoolEntry::Connected(Arc::downgrade(&connection)), - ); - Ok(connection) - } - Err(error) => { - pool.connections.remove(&opts); - Err(Arc::new(error)) - } - } - })? - } - }) - .shared(); - - self.connections - .insert(opts.clone(), ConnectionPoolEntry::Connecting(task.clone())); - task - } -} - -impl From for AnyProtoClient { - fn from(client: SshRemoteClient) -> Self { - AnyProtoClient::new(client.client) - } -} - -#[async_trait(?Send)] -trait RemoteConnection: Send + Sync { - fn start_proxy( - &self, - unique_identifier: String, - reconnect: bool, - incoming_tx: UnboundedSender, - outgoing_rx: UnboundedReceiver, - connection_activity_tx: Sender<()>, - delegate: Arc, - cx: &mut AsyncApp, - ) -> Task>; - fn upload_directory( - &self, - src_path: PathBuf, - dest_path: RemotePathBuf, - cx: &App, - ) -> Task>; - async fn kill(&self) -> Result<()>; - fn has_been_killed(&self) -> bool; - /// On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh. - /// On Linux, we use the `ControlPath` option to create a socket file that ssh can use to - fn ssh_args(&self) -> SshArgs; - fn connection_options(&self) -> SshConnectionOptions; - fn path_style(&self) -> PathStyle; - fn shell(&self) -> String; - - #[cfg(any(test, feature = "test-support"))] - fn simulate_disconnect(&self, _: &AsyncApp) {} -} - -struct SshRemoteConnection { - socket: SshSocket, - master_process: Mutex>, - remote_binary_path: Option, - ssh_platform: SshPlatform, - ssh_path_style: PathStyle, - ssh_shell: String, - _temp_dir: TempDir, -} - -#[async_trait(?Send)] -impl RemoteConnection for SshRemoteConnection { - async fn kill(&self) -> Result<()> { - let Some(mut process) = self.master_process.lock().take() else { - return Ok(()); - }; - process.kill().ok(); - process.status().await?; - Ok(()) - } - - fn has_been_killed(&self) -> bool { - self.master_process.lock().is_none() - } - - fn ssh_args(&self) -> SshArgs { - self.socket.ssh_args() - } - - fn connection_options(&self) -> SshConnectionOptions { - self.socket.connection_options.clone() - } - - fn shell(&self) -> String { - self.ssh_shell.clone() - } - - fn upload_directory( - &self, - src_path: PathBuf, - dest_path: RemotePathBuf, - cx: &App, - ) -> Task> { - let mut command = util::command::new_smol_command("scp"); - let output = self - .socket - .ssh_options(&mut command) - .args( - self.socket - .connection_options - .port - .map(|port| vec!["-P".to_string(), port.to_string()]) - .unwrap_or_default(), - ) - .arg("-C") - .arg("-r") - .arg(&src_path) - .arg(format!( - "{}:{}", - self.socket.connection_options.scp_url(), - dest_path - )) - .output(); - - cx.background_spawn(async move { - let output = output.await?; - - anyhow::ensure!( - output.status.success(), - "failed to upload directory {} -> {}: {}", - src_path.display(), - dest_path.to_string(), - String::from_utf8_lossy(&output.stderr) - ); - - Ok(()) - }) - } - - fn start_proxy( - &self, - unique_identifier: String, - reconnect: bool, - incoming_tx: UnboundedSender, - outgoing_rx: UnboundedReceiver, - connection_activity_tx: Sender<()>, - delegate: Arc, - cx: &mut AsyncApp, - ) -> Task> { - delegate.set_status(Some("Starting proxy"), cx); - - let Some(remote_binary_path) = self.remote_binary_path.clone() else { - return Task::ready(Err(anyhow!("Remote binary path not set"))); - }; - - let mut start_proxy_command = shell_script!( - "exec {binary_path} proxy --identifier {identifier}", - binary_path = &remote_binary_path.to_string(), - identifier = &unique_identifier, - ); - - for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] { - if let Some(value) = std::env::var(env_var).ok() { - start_proxy_command = format!( - "{}={} {} ", - env_var, - shlex::try_quote(&value).unwrap(), - start_proxy_command, - ); - } - } - - if reconnect { - start_proxy_command.push_str(" --reconnect"); - } - - let ssh_proxy_process = match self - .socket - .ssh_command("sh", &["-c", &start_proxy_command]) - // IMPORTANT: we kill this process when we drop the task that uses it. - .kill_on_drop(true) - .spawn() - { - Ok(process) => process, - Err(error) => { - return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error))); - } - }; - - Self::multiplex( - ssh_proxy_process, - incoming_tx, - outgoing_rx, - connection_activity_tx, - cx, - ) - } - - fn path_style(&self) -> PathStyle { - self.ssh_path_style - } -} - -impl SshRemoteConnection { - async fn new( - connection_options: SshConnectionOptions, - delegate: Arc, - cx: &mut AsyncApp, - ) -> Result { - use askpass::AskPassResult; - - delegate.set_status(Some("Connecting"), cx); - - let url = connection_options.ssh_url(); - - let temp_dir = tempfile::Builder::new() - .prefix("zed-ssh-session") - .tempdir()?; - let askpass_delegate = askpass::AskPassDelegate::new(cx, { - let delegate = delegate.clone(); - move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx) - }); - - let mut askpass = - askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?; - - // Start the master SSH process, which does not do anything except for establish - // the connection and keep it open, allowing other ssh commands to reuse it - // via a control socket. - #[cfg(not(target_os = "windows"))] - let socket_path = temp_dir.path().join("ssh.sock"); - - let mut master_process = { - #[cfg(not(target_os = "windows"))] - let args = [ - "-N", - "-o", - "ControlPersist=no", - "-o", - "ControlMaster=yes", - "-o", - ]; - // On Windows, `ControlMaster` and `ControlPath` are not supported: - // https://github.com/PowerShell/Win32-OpenSSH/issues/405 - // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope - #[cfg(target_os = "windows")] - let args = ["-N"]; - let mut master_process = util::command::new_smol_command("ssh"); - master_process - .kill_on_drop(true) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .env("SSH_ASKPASS_REQUIRE", "force") - .env("SSH_ASKPASS", askpass.script_path()) - .args(connection_options.additional_args()) - .args(args); - #[cfg(not(target_os = "windows"))] - master_process.arg(format!("ControlPath={}", socket_path.display())); - master_process.arg(&url).spawn()? - }; - // Wait for this ssh process to close its stdout, indicating that authentication - // has completed. - let mut stdout = master_process.stdout.take().unwrap(); - let mut output = Vec::new(); - - let result = select_biased! { - result = askpass.run().fuse() => { - match result { - AskPassResult::CancelledByUser => { - master_process.kill().ok(); - anyhow::bail!("SSH connection canceled") - } - AskPassResult::Timedout => { - anyhow::bail!("connecting to host timed out") - } - } - } - _ = stdout.read_to_end(&mut output).fuse() => { - anyhow::Ok(()) - } - }; - - if let Err(e) = result { - return Err(e.context("Failed to connect to host")); - } - - if master_process.try_status()?.is_some() { - output.clear(); - let mut stderr = master_process.stderr.take().unwrap(); - stderr.read_to_end(&mut output).await?; - - let error_message = format!( - "failed to connect: {}", - String::from_utf8_lossy(&output).trim() - ); - anyhow::bail!(error_message); - } - - #[cfg(not(target_os = "windows"))] - let socket = SshSocket::new(connection_options, socket_path)?; - #[cfg(target_os = "windows")] - let socket = SshSocket::new(connection_options, &temp_dir, askpass.get_password())?; - drop(askpass); - - let ssh_platform = socket.platform().await?; - let ssh_path_style = match ssh_platform.os { - "windows" => PathStyle::Windows, - _ => PathStyle::Posix, - }; - let ssh_shell = socket.shell().await; - - let mut this = Self { - socket, - master_process: Mutex::new(Some(master_process)), - _temp_dir: temp_dir, - remote_binary_path: None, - ssh_path_style, - ssh_platform, - ssh_shell, - }; - - let (release_channel, version, commit) = cx.update(|cx| { - ( - ReleaseChannel::global(cx), - AppVersion::global(cx), - AppCommitSha::try_global(cx), - ) - })?; - this.remote_binary_path = Some( - this.ensure_server_binary(&delegate, release_channel, version, commit, cx) - .await?, - ); - - Ok(this) - } - - fn multiplex( - mut ssh_proxy_process: Child, - incoming_tx: UnboundedSender, - mut outgoing_rx: UnboundedReceiver, - mut connection_activity_tx: Sender<()>, - cx: &AsyncApp, - ) -> Task> { - let mut child_stderr = ssh_proxy_process.stderr.take().unwrap(); - let mut child_stdout = ssh_proxy_process.stdout.take().unwrap(); - let mut child_stdin = ssh_proxy_process.stdin.take().unwrap(); - - let mut stdin_buffer = Vec::new(); - let mut stdout_buffer = Vec::new(); - let mut stderr_buffer = Vec::new(); - let mut stderr_offset = 0; - - let stdin_task = cx.background_spawn(async move { - while let Some(outgoing) = outgoing_rx.next().await { - write_message(&mut child_stdin, &mut stdin_buffer, outgoing).await?; - } - anyhow::Ok(()) - }); - - let stdout_task = cx.background_spawn({ - let mut connection_activity_tx = connection_activity_tx.clone(); - async move { - loop { - stdout_buffer.resize(MESSAGE_LEN_SIZE, 0); - let len = child_stdout.read(&mut stdout_buffer).await?; - - if len == 0 { - return anyhow::Ok(()); - } - - if len < MESSAGE_LEN_SIZE { - child_stdout.read_exact(&mut stdout_buffer[len..]).await?; - } - - let message_len = message_len_from_buffer(&stdout_buffer); - let envelope = - read_message_with_len(&mut child_stdout, &mut stdout_buffer, message_len) - .await?; - connection_activity_tx.try_send(()).ok(); - incoming_tx.unbounded_send(envelope).ok(); - } - } - }); - - let stderr_task: Task> = cx.background_spawn(async move { - loop { - stderr_buffer.resize(stderr_offset + 1024, 0); - - let len = child_stderr - .read(&mut stderr_buffer[stderr_offset..]) - .await?; - if len == 0 { - return anyhow::Ok(()); - } - - stderr_offset += len; - let mut start_ix = 0; - while let Some(ix) = stderr_buffer[start_ix..stderr_offset] - .iter() - .position(|b| b == &b'\n') - { - let line_ix = start_ix + ix; - let content = &stderr_buffer[start_ix..line_ix]; - start_ix = line_ix + 1; - if let Ok(record) = serde_json::from_slice::(content) { - record.log(log::logger()) - } else { - eprintln!("(remote) {}", String::from_utf8_lossy(content)); - } - } - stderr_buffer.drain(0..start_ix); - stderr_offset -= start_ix; - - connection_activity_tx.try_send(()).ok(); - } - }); - - cx.background_spawn(async move { - let result = futures::select! { - result = stdin_task.fuse() => { - result.context("stdin") - } - result = stdout_task.fuse() => { - result.context("stdout") - } - result = stderr_task.fuse() => { - result.context("stderr") - } - }; - - let status = ssh_proxy_process.status().await?.code().unwrap_or(1); - match result { - Ok(_) => Ok(status), - Err(error) => Err(error), - } - }) - } - - #[allow(unused)] - async fn ensure_server_binary( - &self, - delegate: &Arc, - release_channel: ReleaseChannel, - version: SemanticVersion, - commit: Option, - cx: &mut AsyncApp, - ) -> Result { - let version_str = match release_channel { - ReleaseChannel::Nightly => { - let commit = commit.map(|s| s.full()).unwrap_or_default(); - format!("{}-{}", version, commit) - } - ReleaseChannel::Dev => "build".to_string(), - _ => version.to_string(), - }; - let binary_name = format!( - "zed-remote-server-{}-{}", - release_channel.dev_name(), - version_str - ); - let dst_path = RemotePathBuf::new( - paths::remote_server_dir_relative().join(binary_name), - self.ssh_path_style, - ); - - let build_remote_server = std::env::var("ZED_BUILD_REMOTE_SERVER").ok(); - #[cfg(debug_assertions)] - if let Some(build_remote_server) = build_remote_server { - let src_path = self.build_local(build_remote_server, delegate, cx).await?; - let tmp_path = RemotePathBuf::new( - paths::remote_server_dir_relative().join(format!( - "download-{}-{}", - std::process::id(), - src_path.file_name().unwrap().to_string_lossy() - )), - self.ssh_path_style, - ); - self.upload_local_server_binary(&src_path, &tmp_path, delegate, cx) - .await?; - self.extract_server_binary(&dst_path, &tmp_path, delegate, cx) - .await?; - return Ok(dst_path); - } - - if self - .socket - .run_command(&dst_path.to_string(), &["version"]) - .await - .is_ok() - { - return Ok(dst_path); - } - - let wanted_version = cx.update(|cx| match release_channel { - ReleaseChannel::Nightly => Ok(None), - ReleaseChannel::Dev => { - anyhow::bail!( - "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})", - dst_path - ) - } - _ => Ok(Some(AppVersion::global(cx))), - })??; - - let tmp_path_gz = RemotePathBuf::new( - PathBuf::from(format!("{}-download-{}.gz", dst_path, std::process::id())), - self.ssh_path_style, - ); - if !self.socket.connection_options.upload_binary_over_ssh - && let Some((url, body)) = delegate - .get_download_params(self.ssh_platform, release_channel, wanted_version, cx) - .await? - { - match self - .download_binary_on_server(&url, &body, &tmp_path_gz, delegate, cx) - .await - { - Ok(_) => { - self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx) - .await?; - return Ok(dst_path); - } - Err(e) => { - log::error!( - "Failed to download binary on server, attempting to upload server: {}", - e - ) - } - } - } - - let src_path = delegate - .download_server_binary_locally(self.ssh_platform, release_channel, wanted_version, cx) - .await?; - self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx) - .await?; - self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx) - .await?; - Ok(dst_path) - } - - async fn download_binary_on_server( - &self, - url: &str, - body: &str, - tmp_path_gz: &RemotePathBuf, - delegate: &Arc, - cx: &mut AsyncApp, - ) -> Result<()> { - if let Some(parent) = tmp_path_gz.parent() { - self.socket - .run_command( - "sh", - &[ - "-c", - &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), - ], - ) - .await?; - } - - delegate.set_status(Some("Downloading remote development server on host"), cx); - - match self - .socket - .run_command( - "curl", - &[ - "-f", - "-L", - "-X", - "GET", - "-H", - "Content-Type: application/json", - "-d", - body, - url, - "-o", - &tmp_path_gz.to_string(), - ], - ) - .await - { - Ok(_) => {} - Err(e) => { - if self.socket.run_command("which", &["curl"]).await.is_ok() { - return Err(e); - } - - match self - .socket - .run_command( - "wget", - &[ - "--method=GET", - "--header=Content-Type: application/json", - "--body-data", - body, - url, - "-O", - &tmp_path_gz.to_string(), - ], - ) - .await - { - Ok(_) => {} - Err(e) => { - if self.socket.run_command("which", &["wget"]).await.is_ok() { - return Err(e); - } else { - anyhow::bail!("Neither curl nor wget is available"); - } - } - } - } - } - - Ok(()) - } - - async fn upload_local_server_binary( - &self, - src_path: &Path, - tmp_path_gz: &RemotePathBuf, - delegate: &Arc, - cx: &mut AsyncApp, - ) -> Result<()> { - if let Some(parent) = tmp_path_gz.parent() { - self.socket - .run_command( - "sh", - &[ - "-c", - &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), - ], - ) - .await?; - } - - let src_stat = fs::metadata(&src_path).await?; - let size = src_stat.len(); - - let t0 = Instant::now(); - delegate.set_status(Some("Uploading remote development server"), cx); - log::info!( - "uploading remote development server to {:?} ({}kb)", - tmp_path_gz, - size / 1024 - ); - self.upload_file(src_path, tmp_path_gz) - .await - .context("failed to upload server binary")?; - log::info!("uploaded remote development server in {:?}", t0.elapsed()); - Ok(()) - } - - async fn extract_server_binary( - &self, - dst_path: &RemotePathBuf, - tmp_path: &RemotePathBuf, - delegate: &Arc, - cx: &mut AsyncApp, - ) -> Result<()> { - delegate.set_status(Some("Extracting remote development server"), cx); - let server_mode = 0o755; - - let orig_tmp_path = tmp_path.to_string(); - let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") { - shell_script!( - "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}", - server_mode = &format!("{:o}", server_mode), - dst_path = &dst_path.to_string(), - ) - } else { - shell_script!( - "chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}", - server_mode = &format!("{:o}", server_mode), - dst_path = &dst_path.to_string() - ) - }; - self.socket.run_command("sh", &["-c", &script]).await?; - Ok(()) - } - - async fn upload_file(&self, src_path: &Path, dest_path: &RemotePathBuf) -> Result<()> { - log::debug!("uploading file {:?} to {:?}", src_path, dest_path); - let mut command = util::command::new_smol_command("scp"); - let output = self - .socket - .ssh_options(&mut command) - .args( - self.socket - .connection_options - .port - .map(|port| vec!["-P".to_string(), port.to_string()]) - .unwrap_or_default(), - ) - .arg(src_path) - .arg(format!( - "{}:{}", - self.socket.connection_options.scp_url(), - dest_path - )) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "failed to upload file {} -> {}: {}", - src_path.display(), - dest_path.to_string(), - String::from_utf8_lossy(&output.stderr) - ); - Ok(()) - } - - #[cfg(debug_assertions)] - async fn build_local( - &self, - build_remote_server: String, - delegate: &Arc, - cx: &mut AsyncApp, - ) -> Result { - use smol::process::{Command, Stdio}; - use std::env::VarError; - - async fn run_cmd(command: &mut Command) -> Result<()> { - let output = command - .kill_on_drop(true) - .stderr(Stdio::inherit()) - .output() - .await?; - anyhow::ensure!( - output.status.success(), - "Failed to run command: {command:?}" - ); - Ok(()) - } - - let use_musl = !build_remote_server.contains("nomusl"); - let triple = format!( - "{}-{}", - self.ssh_platform.arch, - match self.ssh_platform.os { - "linux" => - if use_musl { - "unknown-linux-musl" - } else { - "unknown-linux-gnu" - }, - "macos" => "apple-darwin", - _ => anyhow::bail!("can't cross compile for: {:?}", self.ssh_platform), - } - ); - let mut rust_flags = match std::env::var("RUSTFLAGS") { - Ok(val) => val, - Err(VarError::NotPresent) => String::new(), - Err(e) => { - log::error!("Failed to get env var `RUSTFLAGS` value: {e}"); - String::new() - } - }; - if self.ssh_platform.os == "linux" && use_musl { - rust_flags.push_str(" -C target-feature=+crt-static"); - } - if build_remote_server.contains("mold") { - rust_flags.push_str(" -C link-arg=-fuse-ld=mold"); - } - - if self.ssh_platform.arch == std::env::consts::ARCH - && self.ssh_platform.os == std::env::consts::OS - { - delegate.set_status(Some("Building remote server binary from source"), cx); - log::info!("building remote server binary from source"); - run_cmd( - Command::new("cargo") - .args([ - "build", - "--package", - "remote_server", - "--features", - "debug-embed", - "--target-dir", - "target/remote_server", - "--target", - &triple, - ]) - .env("RUSTFLAGS", &rust_flags), - ) - .await?; - } else if build_remote_server.contains("cross") { - #[cfg(target_os = "windows")] - use util::paths::SanitizedPath; - - delegate.set_status(Some("Installing cross.rs for cross-compilation"), cx); - log::info!("installing cross"); - run_cmd(Command::new("cargo").args([ - "install", - "cross", - "--git", - "https://github.com/cross-rs/cross", - ])) - .await?; - - delegate.set_status( - Some(&format!( - "Building remote server binary from source for {} with Docker", - &triple - )), - cx, - ); - log::info!("building remote server binary from source for {}", &triple); - - // On Windows, the binding needs to be set to the canonical path - #[cfg(target_os = "windows")] - let src = - SanitizedPath::from(smol::fs::canonicalize("./target").await?).to_glob_string(); - #[cfg(not(target_os = "windows"))] - let src = "./target"; - run_cmd( - Command::new("cross") - .args([ - "build", - "--package", - "remote_server", - "--features", - "debug-embed", - "--target-dir", - "target/remote_server", - "--target", - &triple, - ]) - .env( - "CROSS_CONTAINER_OPTS", - format!("--mount type=bind,src={src},dst=/app/target"), - ) - .env("RUSTFLAGS", &rust_flags), - ) - .await?; - } else { - let which = cx - .background_spawn(async move { which::which("zig") }) - .await; - - if which.is_err() { - #[cfg(not(target_os = "windows"))] - { - anyhow::bail!( - "zig not found on $PATH, install zig (see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross" - ) - } - #[cfg(target_os = "windows")] - { - anyhow::bail!( - "zig not found on $PATH, install zig (use `winget install -e --id zig.zig` or see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross" - ) - } - } - - delegate.set_status(Some("Adding rustup target for cross-compilation"), cx); - log::info!("adding rustup target"); - run_cmd(Command::new("rustup").args(["target", "add"]).arg(&triple)).await?; - - delegate.set_status(Some("Installing cargo-zigbuild for cross-compilation"), cx); - log::info!("installing cargo-zigbuild"); - run_cmd(Command::new("cargo").args(["install", "--locked", "cargo-zigbuild"])).await?; - - delegate.set_status( - Some(&format!( - "Building remote binary from source for {triple} with Zig" - )), - cx, - ); - log::info!("building remote binary from source for {triple} with Zig"); - run_cmd( - Command::new("cargo") - .args([ - "zigbuild", - "--package", - "remote_server", - "--features", - "debug-embed", - "--target-dir", - "target/remote_server", - "--target", - &triple, - ]) - .env("RUSTFLAGS", &rust_flags), - ) - .await?; - }; - let bin_path = Path::new("target") - .join("remote_server") - .join(&triple) - .join("debug") - .join("remote_server"); - - let path = if !build_remote_server.contains("nocompress") { - delegate.set_status(Some("Compressing binary"), cx); - - #[cfg(not(target_os = "windows"))] - { - run_cmd(Command::new("gzip").args(["-f", &bin_path.to_string_lossy()])).await?; - } - #[cfg(target_os = "windows")] - { - // On Windows, we use 7z to compress the binary - let seven_zip = which::which("7z.exe").context("7z.exe not found on $PATH, install it (e.g. with `winget install -e --id 7zip.7zip`) or, if you don't want this behaviour, set $env:ZED_BUILD_REMOTE_SERVER=\"nocompress\"")?; - let gz_path = format!("target/remote_server/{}/debug/remote_server.gz", triple); - if smol::fs::metadata(&gz_path).await.is_ok() { - smol::fs::remove_file(&gz_path).await?; - } - run_cmd(Command::new(seven_zip).args([ - "a", - "-tgzip", - &gz_path, - &bin_path.to_string_lossy(), - ])) - .await?; - } - - let mut archive_path = bin_path; - archive_path.set_extension("gz"); - std::env::current_dir()?.join(archive_path) - } else { - bin_path - }; - - Ok(path) - } -} - -type ResponseChannels = Mutex)>>>; - -struct ChannelClient { - next_message_id: AtomicU32, - outgoing_tx: Mutex>, - buffer: Mutex>, - response_channels: ResponseChannels, - message_handlers: Mutex, - max_received: AtomicU32, - name: &'static str, - task: Mutex>>, -} - -impl ChannelClient { - fn new( - incoming_rx: mpsc::UnboundedReceiver, - outgoing_tx: mpsc::UnboundedSender, - cx: &App, - name: &'static str, - ) -> Arc { - Arc::new_cyclic(|this| Self { - outgoing_tx: Mutex::new(outgoing_tx), - next_message_id: AtomicU32::new(0), - max_received: AtomicU32::new(0), - response_channels: ResponseChannels::default(), - message_handlers: Default::default(), - buffer: Mutex::new(VecDeque::new()), - name, - task: Mutex::new(Self::start_handling_messages( - this.clone(), - incoming_rx, - &cx.to_async(), - )), - }) - } - - fn start_handling_messages( - this: Weak, - mut incoming_rx: mpsc::UnboundedReceiver, - cx: &AsyncApp, - ) -> Task> { - cx.spawn(async move |cx| { - let peer_id = PeerId { owner_id: 0, id: 0 }; - while let Some(incoming) = incoming_rx.next().await { - let Some(this) = this.upgrade() else { - return anyhow::Ok(()); - }; - if let Some(ack_id) = incoming.ack_id { - let mut buffer = this.buffer.lock(); - while buffer.front().is_some_and(|msg| msg.id <= ack_id) { - buffer.pop_front(); - } - } - if let Some(proto::envelope::Payload::FlushBufferedMessages(_)) = &incoming.payload - { - log::debug!( - "{}:ssh message received. name:FlushBufferedMessages", - this.name - ); - { - let buffer = this.buffer.lock(); - for envelope in buffer.iter() { - this.outgoing_tx - .lock() - .unbounded_send(envelope.clone()) - .ok(); - } - } - let mut envelope = proto::Ack {}.into_envelope(0, Some(incoming.id), None); - envelope.id = this.next_message_id.fetch_add(1, SeqCst); - this.outgoing_tx.lock().unbounded_send(envelope).ok(); - continue; - } - - this.max_received.store(incoming.id, SeqCst); - - if let Some(request_id) = incoming.responding_to { - let request_id = MessageId(request_id); - let sender = this.response_channels.lock().remove(&request_id); - if let Some(sender) = sender { - let (tx, rx) = oneshot::channel(); - if incoming.payload.is_some() { - sender.send((incoming, tx)).ok(); - } - rx.await.ok(); - } - } else if let Some(envelope) = - build_typed_envelope(peer_id, Instant::now(), incoming) - { - let type_name = envelope.payload_type_name(); - let message_id = envelope.message_id(); - if let Some(future) = ProtoMessageHandlerSet::handle_message( - &this.message_handlers, - envelope, - this.clone().into(), - cx.clone(), - ) { - log::debug!("{}:ssh message received. name:{type_name}", this.name); - cx.foreground_executor() - .spawn(async move { - match future.await { - Ok(_) => { - log::debug!( - "{}:ssh message handled. name:{type_name}", - this.name - ); - } - Err(error) => { - log::error!( - "{}:error handling message. type:{}, error:{}", - this.name, - type_name, - format!("{error:#}").lines().fold( - String::new(), - |mut message, line| { - if !message.is_empty() { - message.push(' '); - } - message.push_str(line); - message - } - ) - ); - } - } - }) - .detach() - } else { - log::error!("{}:unhandled ssh message name:{type_name}", this.name); - if let Err(e) = AnyProtoClient::from(this.clone()).send_response( - message_id, - anyhow::anyhow!("no handler registered for {type_name}").to_proto(), - ) { - log::error!( - "{}:error sending error response for {type_name}:{e:#}", - this.name - ); - } - } - } - } - anyhow::Ok(()) - }) - } - - fn reconnect( - self: &Arc, - incoming_rx: UnboundedReceiver, - outgoing_tx: UnboundedSender, - cx: &AsyncApp, - ) { - *self.outgoing_tx.lock() = outgoing_tx; - *self.task.lock() = Self::start_handling_messages(Arc::downgrade(self), incoming_rx, cx); - } - - fn request( - &self, - payload: T, - ) -> impl 'static + Future> { - self.request_internal(payload, true) - } - - fn request_internal( - &self, - payload: T, - use_buffer: bool, - ) -> impl 'static + Future> { - log::debug!("ssh request start. name:{}", T::NAME); - let response = - self.request_dynamic(payload.into_envelope(0, None, None), T::NAME, use_buffer); - async move { - let response = response.await?; - log::debug!("ssh request finish. name:{}", T::NAME); - T::Response::from_envelope(response).context("received a response of the wrong type") - } - } - - async fn resync(&self, timeout: Duration) -> Result<()> { - smol::future::or( - async { - self.request_internal(proto::FlushBufferedMessages {}, false) - .await?; - - for envelope in self.buffer.lock().iter() { - self.outgoing_tx - .lock() - .unbounded_send(envelope.clone()) - .ok(); - } - Ok(()) - }, - async { - smol::Timer::after(timeout).await; - anyhow::bail!("Timed out resyncing remote client") - }, - ) - .await - } - - async fn ping(&self, timeout: Duration) -> Result<()> { - smol::future::or( - async { - self.request(proto::Ping {}).await?; - Ok(()) - }, - async { - smol::Timer::after(timeout).await; - anyhow::bail!("Timed out pinging remote client") - }, - ) - .await - } - - pub fn send(&self, payload: T) -> Result<()> { - log::debug!("ssh send name:{}", T::NAME); - self.send_dynamic(payload.into_envelope(0, None, None)) - } - - fn request_dynamic( - &self, - mut envelope: proto::Envelope, - type_name: &'static str, - use_buffer: bool, - ) -> impl 'static + Future> { - envelope.id = self.next_message_id.fetch_add(1, SeqCst); - let (tx, rx) = oneshot::channel(); - let mut response_channels_lock = self.response_channels.lock(); - response_channels_lock.insert(MessageId(envelope.id), tx); - drop(response_channels_lock); - - let result = if use_buffer { - self.send_buffered(envelope) - } else { - self.send_unbuffered(envelope) - }; - async move { - if let Err(error) = &result { - log::error!("failed to send message: {error}"); - anyhow::bail!("failed to send message: {error}"); - } - - let response = rx.await.context("connection lost")?.0; - if let Some(proto::envelope::Payload::Error(error)) = &response.payload { - return Err(RpcError::from_proto(error, type_name)); - } - Ok(response) - } - } - - pub fn send_dynamic(&self, mut envelope: proto::Envelope) -> Result<()> { - envelope.id = self.next_message_id.fetch_add(1, SeqCst); - self.send_buffered(envelope) - } - - fn send_buffered(&self, mut envelope: proto::Envelope) -> Result<()> { - envelope.ack_id = Some(self.max_received.load(SeqCst)); - self.buffer.lock().push_back(envelope.clone()); - // ignore errors on send (happen while we're reconnecting) - // assume that the global "disconnected" overlay is sufficient. - self.outgoing_tx.lock().unbounded_send(envelope).ok(); - Ok(()) - } - - fn send_unbuffered(&self, mut envelope: proto::Envelope) -> Result<()> { - envelope.ack_id = Some(self.max_received.load(SeqCst)); - self.outgoing_tx.lock().unbounded_send(envelope).ok(); - Ok(()) - } -} - -impl ProtoClient for ChannelClient { - fn request( - &self, - envelope: proto::Envelope, - request_type: &'static str, - ) -> BoxFuture<'static, Result> { - self.request_dynamic(envelope, request_type, true).boxed() - } - - fn send(&self, envelope: proto::Envelope, _message_type: &'static str) -> Result<()> { - self.send_dynamic(envelope) - } - - fn send_response(&self, envelope: Envelope, _message_type: &'static str) -> anyhow::Result<()> { - self.send_dynamic(envelope) - } - - fn message_handler_set(&self) -> &Mutex { - &self.message_handlers - } - - fn is_via_collab(&self) -> bool { - false - } -} - -#[cfg(any(test, feature = "test-support"))] -mod fake { - use std::{path::PathBuf, sync::Arc}; - - use anyhow::Result; - use async_trait::async_trait; - use futures::{ - FutureExt, SinkExt, StreamExt, - channel::{ - mpsc::{self, Sender}, - oneshot, - }, - select_biased, - }; - use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task, TestAppContext}; - use release_channel::ReleaseChannel; - use rpc::proto::Envelope; - use util::paths::{PathStyle, RemotePathBuf}; - - use super::{ - ChannelClient, RemoteConnection, SshArgs, SshClientDelegate, SshConnectionOptions, - SshPlatform, - }; - - pub(super) struct FakeRemoteConnection { - pub(super) connection_options: SshConnectionOptions, - pub(super) server_channel: Arc, - pub(super) server_cx: SendableCx, - } - - pub(super) struct SendableCx(AsyncApp); - impl SendableCx { - // SAFETY: When run in test mode, GPUI is always single threaded. - pub(super) fn new(cx: &TestAppContext) -> Self { - Self(cx.to_async()) - } - - // SAFETY: Enforce that we're on the main thread by requiring a valid AsyncApp - fn get(&self, _: &AsyncApp) -> AsyncApp { - self.0.clone() - } - } - - // SAFETY: There is no way to access a SendableCx from a different thread, see [`SendableCx::new`] and [`SendableCx::get`] - unsafe impl Send for SendableCx {} - unsafe impl Sync for SendableCx {} - - #[async_trait(?Send)] - impl RemoteConnection for FakeRemoteConnection { - async fn kill(&self) -> Result<()> { - Ok(()) - } - - fn has_been_killed(&self) -> bool { - false - } - - fn ssh_args(&self) -> SshArgs { - SshArgs { - arguments: Vec::new(), - envs: None, - } - } - - fn upload_directory( - &self, - _src_path: PathBuf, - _dest_path: RemotePathBuf, - _cx: &App, - ) -> Task> { - unreachable!() - } - - fn connection_options(&self) -> SshConnectionOptions { - self.connection_options.clone() - } - - fn simulate_disconnect(&self, cx: &AsyncApp) { - let (outgoing_tx, _) = mpsc::unbounded::(); - let (_, incoming_rx) = mpsc::unbounded::(); - self.server_channel - .reconnect(incoming_rx, outgoing_tx, &self.server_cx.get(cx)); - } - - fn start_proxy( - &self, - _unique_identifier: String, - _reconnect: bool, - mut client_incoming_tx: mpsc::UnboundedSender, - mut client_outgoing_rx: mpsc::UnboundedReceiver, - mut connection_activity_tx: Sender<()>, - _delegate: Arc, - cx: &mut AsyncApp, - ) -> Task> { - let (mut server_incoming_tx, server_incoming_rx) = mpsc::unbounded::(); - let (server_outgoing_tx, mut server_outgoing_rx) = mpsc::unbounded::(); - - self.server_channel.reconnect( - server_incoming_rx, - server_outgoing_tx, - &self.server_cx.get(cx), - ); - - cx.background_spawn(async move { - loop { - select_biased! { - server_to_client = server_outgoing_rx.next().fuse() => { - let Some(server_to_client) = server_to_client else { - return Ok(1) - }; - connection_activity_tx.try_send(()).ok(); - client_incoming_tx.send(server_to_client).await.ok(); - } - client_to_server = client_outgoing_rx.next().fuse() => { - let Some(client_to_server) = client_to_server else { - return Ok(1) - }; - server_incoming_tx.send(client_to_server).await.ok(); - } - } - } - }) - } - - fn path_style(&self) -> PathStyle { - PathStyle::current() - } - - fn shell(&self) -> String { - "sh".to_owned() - } - } - - pub(super) struct Delegate; - - impl SshClientDelegate for Delegate { - fn ask_password(&self, _: String, _: oneshot::Sender, _: &mut AsyncApp) { - unreachable!() - } - - fn download_server_binary_locally( - &self, - _: SshPlatform, - _: ReleaseChannel, - _: Option, - _: &mut AsyncApp, - ) -> Task> { - unreachable!() - } - - fn get_download_params( - &self, - _platform: SshPlatform, - _release_channel: ReleaseChannel, - _version: Option, - _cx: &mut AsyncApp, - ) -> Task>> { - unreachable!() - } - - fn set_status(&self, _: Option<&str>, _: &mut AsyncApp) {} - } -} diff --git a/crates/remote/src/transport.rs b/crates/remote/src/transport.rs new file mode 100644 index 0000000000000000000000000000000000000000..aa086fd3f56196e71224ef346c9810e8638c5c47 --- /dev/null +++ b/crates/remote/src/transport.rs @@ -0,0 +1 @@ +pub mod ssh; diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs new file mode 100644 index 0000000000000000000000000000000000000000..0036a687a6f73b57723e8c3c9fcffc56cab626c2 --- /dev/null +++ b/crates/remote/src/transport/ssh.rs @@ -0,0 +1,1365 @@ +use crate::{ + RemoteClientDelegate, RemotePlatform, + json_log::LogRecord, + protocol::{MESSAGE_LEN_SIZE, message_len_from_buffer, read_message_with_len, write_message}, + remote_client::{CommandTemplate, RemoteConnection}, +}; +use anyhow::{Context as _, Result, anyhow}; +use async_trait::async_trait; +use collections::HashMap; +use futures::{ + AsyncReadExt as _, FutureExt as _, StreamExt as _, + channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender}, + select_biased, +}; +use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task}; +use itertools::Itertools; +use parking_lot::Mutex; +use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; +use rpc::proto::Envelope; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use smol::{ + fs, + process::{self, Child, Stdio}, +}; +use std::{ + iter, + path::{Path, PathBuf}, + sync::Arc, + time::Instant, +}; +use tempfile::TempDir; +use util::{ + get_default_system_shell, + paths::{PathStyle, RemotePathBuf}, +}; + +pub(crate) struct SshRemoteConnection { + socket: SshSocket, + master_process: Mutex>, + remote_binary_path: Option, + ssh_platform: RemotePlatform, + ssh_path_style: PathStyle, + ssh_shell: String, + _temp_dir: TempDir, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct SshConnectionOptions { + pub host: String, + pub username: Option, + pub port: Option, + pub password: Option, + pub args: Option>, + pub port_forwards: Option>, + + pub nickname: Option, + pub upload_binary_over_ssh: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)] +pub struct SshPortForwardOption { + #[serde(skip_serializing_if = "Option::is_none")] + pub local_host: Option, + pub local_port: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_host: Option, + pub remote_port: u16, +} + +#[derive(Clone)] +struct SshSocket { + connection_options: SshConnectionOptions, + #[cfg(not(target_os = "windows"))] + socket_path: PathBuf, + envs: HashMap, +} + +macro_rules! shell_script { + ($fmt:expr, $($name:ident = $arg:expr),+ $(,)?) => {{ + format!( + $fmt, + $( + $name = shlex::try_quote($arg).unwrap() + ),+ + ) + }}; +} + +#[async_trait(?Send)] +impl RemoteConnection for SshRemoteConnection { + async fn kill(&self) -> Result<()> { + let Some(mut process) = self.master_process.lock().take() else { + return Ok(()); + }; + process.kill().ok(); + process.status().await?; + Ok(()) + } + + fn has_been_killed(&self) -> bool { + self.master_process.lock().is_none() + } + + fn connection_options(&self) -> SshConnectionOptions { + self.socket.connection_options.clone() + } + + fn shell(&self) -> String { + self.ssh_shell.clone() + } + + fn build_command( + &self, + input_program: Option, + input_args: &[String], + input_env: &HashMap, + working_dir: Option, + activation_script: Option, + port_forward: Option<(u16, String, u16)>, + ) -> Result { + use std::fmt::Write as _; + + let mut script = String::new(); + if let Some(working_dir) = working_dir { + let working_dir = + RemotePathBuf::new(working_dir.into(), self.ssh_path_style).to_string(); + + // shlex will wrap the command in single quotes (''), disabling ~ expansion, + // replace ith with something that works + const TILDE_PREFIX: &'static str = "~/"; + if working_dir.starts_with(TILDE_PREFIX) { + let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/"); + write!(&mut script, "cd \"$HOME/{working_dir}\"; ").unwrap(); + } else { + write!(&mut script, "cd \"{working_dir}\"; ").unwrap(); + } + } else { + write!(&mut script, "cd; ").unwrap(); + }; + if let Some(activation_script) = activation_script { + write!(&mut script, " {activation_script};").unwrap(); + } + + for (k, v) in input_env.iter() { + if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) { + write!(&mut script, "{}={} ", k, v).unwrap(); + } + } + + let shell = &self.ssh_shell; + + if let Some(input_program) = input_program { + let command = shlex::try_quote(&input_program)?; + script.push_str(&command); + for arg in input_args { + let arg = shlex::try_quote(&arg)?; + script.push_str(" "); + script.push_str(&arg); + } + } else { + write!(&mut script, "exec {shell} -l").unwrap(); + }; + + let sys_shell = get_default_system_shell(); + let shell_invocation = format!("{sys_shell} -c {}", shlex::try_quote(&script).unwrap()); + + let mut args = Vec::new(); + args.extend(self.socket.ssh_args()); + + if let Some((local_port, host, remote_port)) = port_forward { + args.push("-L".into()); + args.push(format!("{local_port}:{host}:{remote_port}")); + } + + args.push("-t".into()); + args.push(shell_invocation); + Ok(CommandTemplate { + program: "ssh".into(), + args, + env: self.socket.envs.clone(), + }) + } + + fn upload_directory( + &self, + src_path: PathBuf, + dest_path: RemotePathBuf, + cx: &App, + ) -> Task> { + let mut command = util::command::new_smol_command("scp"); + let output = self + .socket + .ssh_options(&mut command) + .args( + self.socket + .connection_options + .port + .map(|port| vec!["-P".to_string(), port.to_string()]) + .unwrap_or_default(), + ) + .arg("-C") + .arg("-r") + .arg(&src_path) + .arg(format!( + "{}:{}", + self.socket.connection_options.scp_url(), + dest_path + )) + .output(); + + cx.background_spawn(async move { + let output = output.await?; + + anyhow::ensure!( + output.status.success(), + "failed to upload directory {} -> {}: {}", + src_path.display(), + dest_path.to_string(), + String::from_utf8_lossy(&output.stderr) + ); + + Ok(()) + }) + } + + fn start_proxy( + &self, + unique_identifier: String, + reconnect: bool, + incoming_tx: UnboundedSender, + outgoing_rx: UnboundedReceiver, + connection_activity_tx: Sender<()>, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Task> { + delegate.set_status(Some("Starting proxy"), cx); + + let Some(remote_binary_path) = self.remote_binary_path.clone() else { + return Task::ready(Err(anyhow!("Remote binary path not set"))); + }; + + let mut start_proxy_command = shell_script!( + "exec {binary_path} proxy --identifier {identifier}", + binary_path = &remote_binary_path.to_string(), + identifier = &unique_identifier, + ); + + for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] { + if let Some(value) = std::env::var(env_var).ok() { + start_proxy_command = format!( + "{}={} {} ", + env_var, + shlex::try_quote(&value).unwrap(), + start_proxy_command, + ); + } + } + + if reconnect { + start_proxy_command.push_str(" --reconnect"); + } + + let ssh_proxy_process = match self + .socket + .ssh_command("sh", &["-lc", &start_proxy_command]) + // IMPORTANT: we kill this process when we drop the task that uses it. + .kill_on_drop(true) + .spawn() + { + Ok(process) => process, + Err(error) => { + return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error))); + } + }; + + Self::multiplex( + ssh_proxy_process, + incoming_tx, + outgoing_rx, + connection_activity_tx, + cx, + ) + } + + fn path_style(&self) -> PathStyle { + self.ssh_path_style + } +} + +impl SshRemoteConnection { + pub(crate) async fn new( + connection_options: SshConnectionOptions, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Result { + use askpass::AskPassResult; + + delegate.set_status(Some("Connecting"), cx); + + let url = connection_options.ssh_url(); + + let temp_dir = tempfile::Builder::new() + .prefix("zed-ssh-session") + .tempdir()?; + let askpass_delegate = askpass::AskPassDelegate::new(cx, { + let delegate = delegate.clone(); + move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx) + }); + + let mut askpass = + askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?; + + // Start the master SSH process, which does not do anything except for establish + // the connection and keep it open, allowing other ssh commands to reuse it + // via a control socket. + #[cfg(not(target_os = "windows"))] + let socket_path = temp_dir.path().join("ssh.sock"); + + let mut master_process = { + #[cfg(not(target_os = "windows"))] + let args = [ + "-N", + "-o", + "ControlPersist=no", + "-o", + "ControlMaster=yes", + "-o", + ]; + // On Windows, `ControlMaster` and `ControlPath` are not supported: + // https://github.com/PowerShell/Win32-OpenSSH/issues/405 + // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope + #[cfg(target_os = "windows")] + let args = ["-N"]; + let mut master_process = util::command::new_smol_command("ssh"); + master_process + .kill_on_drop(true) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("SSH_ASKPASS_REQUIRE", "force") + .env("SSH_ASKPASS", askpass.script_path()) + .args(connection_options.additional_args()) + .args(args); + #[cfg(not(target_os = "windows"))] + master_process.arg(format!("ControlPath={}", socket_path.display())); + master_process.arg(&url).spawn()? + }; + // Wait for this ssh process to close its stdout, indicating that authentication + // has completed. + let mut stdout = master_process.stdout.take().unwrap(); + let mut output = Vec::new(); + + let result = select_biased! { + result = askpass.run().fuse() => { + match result { + AskPassResult::CancelledByUser => { + master_process.kill().ok(); + anyhow::bail!("SSH connection canceled") + } + AskPassResult::Timedout => { + anyhow::bail!("connecting to host timed out") + } + } + } + _ = stdout.read_to_end(&mut output).fuse() => { + anyhow::Ok(()) + } + }; + + if let Err(e) = result { + return Err(e.context("Failed to connect to host")); + } + + if master_process.try_status()?.is_some() { + output.clear(); + let mut stderr = master_process.stderr.take().unwrap(); + stderr.read_to_end(&mut output).await?; + + let error_message = format!( + "failed to connect: {}", + String::from_utf8_lossy(&output).trim() + ); + anyhow::bail!(error_message); + } + + #[cfg(not(target_os = "windows"))] + let socket = SshSocket::new(connection_options, socket_path)?; + #[cfg(target_os = "windows")] + let socket = SshSocket::new(connection_options, &temp_dir, askpass.get_password())?; + drop(askpass); + + let ssh_platform = socket.platform().await?; + let ssh_path_style = match ssh_platform.os { + "windows" => PathStyle::Windows, + _ => PathStyle::Posix, + }; + let ssh_shell = socket.shell().await; + + let mut this = Self { + socket, + master_process: Mutex::new(Some(master_process)), + _temp_dir: temp_dir, + remote_binary_path: None, + ssh_path_style, + ssh_platform, + ssh_shell, + }; + + let (release_channel, version, commit) = cx.update(|cx| { + ( + ReleaseChannel::global(cx), + AppVersion::global(cx), + AppCommitSha::try_global(cx), + ) + })?; + this.remote_binary_path = Some( + this.ensure_server_binary(&delegate, release_channel, version, commit, cx) + .await?, + ); + + Ok(this) + } + + fn multiplex( + mut ssh_proxy_process: Child, + incoming_tx: UnboundedSender, + mut outgoing_rx: UnboundedReceiver, + mut connection_activity_tx: Sender<()>, + cx: &AsyncApp, + ) -> Task> { + let mut child_stderr = ssh_proxy_process.stderr.take().unwrap(); + let mut child_stdout = ssh_proxy_process.stdout.take().unwrap(); + let mut child_stdin = ssh_proxy_process.stdin.take().unwrap(); + + let mut stdin_buffer = Vec::new(); + let mut stdout_buffer = Vec::new(); + let mut stderr_buffer = Vec::new(); + let mut stderr_offset = 0; + + let stdin_task = cx.background_spawn(async move { + while let Some(outgoing) = outgoing_rx.next().await { + write_message(&mut child_stdin, &mut stdin_buffer, outgoing).await?; + } + anyhow::Ok(()) + }); + + let stdout_task = cx.background_spawn({ + let mut connection_activity_tx = connection_activity_tx.clone(); + async move { + loop { + stdout_buffer.resize(MESSAGE_LEN_SIZE, 0); + let len = child_stdout.read(&mut stdout_buffer).await?; + + if len == 0 { + return anyhow::Ok(()); + } + + if len < MESSAGE_LEN_SIZE { + child_stdout.read_exact(&mut stdout_buffer[len..]).await?; + } + + let message_len = message_len_from_buffer(&stdout_buffer); + let envelope = + read_message_with_len(&mut child_stdout, &mut stdout_buffer, message_len) + .await?; + connection_activity_tx.try_send(()).ok(); + incoming_tx.unbounded_send(envelope).ok(); + } + } + }); + + let stderr_task: Task> = cx.background_spawn(async move { + loop { + stderr_buffer.resize(stderr_offset + 1024, 0); + + let len = child_stderr + .read(&mut stderr_buffer[stderr_offset..]) + .await?; + if len == 0 { + return anyhow::Ok(()); + } + + stderr_offset += len; + let mut start_ix = 0; + while let Some(ix) = stderr_buffer[start_ix..stderr_offset] + .iter() + .position(|b| b == &b'\n') + { + let line_ix = start_ix + ix; + let content = &stderr_buffer[start_ix..line_ix]; + start_ix = line_ix + 1; + if let Ok(record) = serde_json::from_slice::(content) { + record.log(log::logger()) + } else { + eprintln!("(remote) {}", String::from_utf8_lossy(content)); + } + } + stderr_buffer.drain(0..start_ix); + stderr_offset -= start_ix; + + connection_activity_tx.try_send(()).ok(); + } + }); + + cx.background_spawn(async move { + let result = futures::select! { + result = stdin_task.fuse() => { + result.context("stdin") + } + result = stdout_task.fuse() => { + result.context("stdout") + } + result = stderr_task.fuse() => { + result.context("stderr") + } + }; + + let status = ssh_proxy_process.status().await?.code().unwrap_or(1); + match result { + Ok(_) => Ok(status), + Err(error) => Err(error), + } + }) + } + + #[allow(unused)] + async fn ensure_server_binary( + &self, + delegate: &Arc, + release_channel: ReleaseChannel, + version: SemanticVersion, + commit: Option, + cx: &mut AsyncApp, + ) -> Result { + let version_str = match release_channel { + ReleaseChannel::Nightly => { + let commit = commit.map(|s| s.full()).unwrap_or_default(); + format!("{}-{}", version, commit) + } + ReleaseChannel::Dev => "build".to_string(), + _ => version.to_string(), + }; + let binary_name = format!( + "zed-remote-server-{}-{}", + release_channel.dev_name(), + version_str + ); + let dst_path = RemotePathBuf::new( + paths::remote_server_dir_relative().join(binary_name), + self.ssh_path_style, + ); + + let build_remote_server = std::env::var("ZED_BUILD_REMOTE_SERVER").ok(); + #[cfg(debug_assertions)] + if let Some(build_remote_server) = build_remote_server { + let src_path = self.build_local(build_remote_server, delegate, cx).await?; + let tmp_path = RemotePathBuf::new( + paths::remote_server_dir_relative().join(format!( + "download-{}-{}", + std::process::id(), + src_path.file_name().unwrap().to_string_lossy() + )), + self.ssh_path_style, + ); + self.upload_local_server_binary(&src_path, &tmp_path, delegate, cx) + .await?; + self.extract_server_binary(&dst_path, &tmp_path, delegate, cx) + .await?; + return Ok(dst_path); + } + + if self + .socket + .run_command(&dst_path.to_string(), &["version"]) + .await + .is_ok() + { + return Ok(dst_path); + } + + let wanted_version = cx.update(|cx| match release_channel { + ReleaseChannel::Nightly => Ok(None), + ReleaseChannel::Dev => { + anyhow::bail!( + "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})", + dst_path + ) + } + _ => Ok(Some(AppVersion::global(cx))), + })??; + + let tmp_path_gz = RemotePathBuf::new( + PathBuf::from(format!("{}-download-{}.gz", dst_path, std::process::id())), + self.ssh_path_style, + ); + if !self.socket.connection_options.upload_binary_over_ssh + && let Some((url, body)) = delegate + .get_download_params(self.ssh_platform, release_channel, wanted_version, cx) + .await? + { + match self + .download_binary_on_server(&url, &body, &tmp_path_gz, delegate, cx) + .await + { + Ok(_) => { + self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx) + .await?; + return Ok(dst_path); + } + Err(e) => { + log::error!( + "Failed to download binary on server, attempting to upload server: {}", + e + ) + } + } + } + + let src_path = delegate + .download_server_binary_locally(self.ssh_platform, release_channel, wanted_version, cx) + .await?; + self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx) + .await?; + self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx) + .await?; + Ok(dst_path) + } + + async fn download_binary_on_server( + &self, + url: &str, + body: &str, + tmp_path_gz: &RemotePathBuf, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + if let Some(parent) = tmp_path_gz.parent() { + self.socket + .run_command( + "sh", + &[ + "-lc", + &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), + ], + ) + .await?; + } + + delegate.set_status(Some("Downloading remote development server on host"), cx); + + match self + .socket + .run_command( + "curl", + &[ + "-f", + "-L", + "-X", + "GET", + "-H", + "Content-Type: application/json", + "-d", + body, + url, + "-o", + &tmp_path_gz.to_string(), + ], + ) + .await + { + Ok(_) => {} + Err(e) => { + if self.socket.run_command("which", &["curl"]).await.is_ok() { + return Err(e); + } + + match self + .socket + .run_command( + "wget", + &[ + "--method=GET", + "--header=Content-Type: application/json", + "--body-data", + body, + url, + "-O", + &tmp_path_gz.to_string(), + ], + ) + .await + { + Ok(_) => {} + Err(e) => { + if self.socket.run_command("which", &["wget"]).await.is_ok() { + return Err(e); + } else { + anyhow::bail!("Neither curl nor wget is available"); + } + } + } + } + } + + Ok(()) + } + + async fn upload_local_server_binary( + &self, + src_path: &Path, + tmp_path_gz: &RemotePathBuf, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + if let Some(parent) = tmp_path_gz.parent() { + self.socket + .run_command( + "sh", + &[ + "-lc", + &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), + ], + ) + .await?; + } + + let src_stat = fs::metadata(&src_path).await?; + let size = src_stat.len(); + + let t0 = Instant::now(); + delegate.set_status(Some("Uploading remote development server"), cx); + log::info!( + "uploading remote development server to {:?} ({}kb)", + tmp_path_gz, + size / 1024 + ); + self.upload_file(src_path, tmp_path_gz) + .await + .context("failed to upload server binary")?; + log::info!("uploaded remote development server in {:?}", t0.elapsed()); + Ok(()) + } + + async fn extract_server_binary( + &self, + dst_path: &RemotePathBuf, + tmp_path: &RemotePathBuf, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + delegate.set_status(Some("Extracting remote development server"), cx); + let server_mode = 0o755; + + let orig_tmp_path = tmp_path.to_string(); + let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") { + shell_script!( + "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}", + server_mode = &format!("{:o}", server_mode), + dst_path = &dst_path.to_string(), + ) + } else { + shell_script!( + "chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}", + server_mode = &format!("{:o}", server_mode), + dst_path = &dst_path.to_string() + ) + }; + self.socket.run_command("sh", &["-lc", &script]).await?; + Ok(()) + } + + async fn upload_file(&self, src_path: &Path, dest_path: &RemotePathBuf) -> Result<()> { + log::debug!("uploading file {:?} to {:?}", src_path, dest_path); + let mut command = util::command::new_smol_command("scp"); + let output = self + .socket + .ssh_options(&mut command) + .args( + self.socket + .connection_options + .port + .map(|port| vec!["-P".to_string(), port.to_string()]) + .unwrap_or_default(), + ) + .arg(src_path) + .arg(format!( + "{}:{}", + self.socket.connection_options.scp_url(), + dest_path + )) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "failed to upload file {} -> {}: {}", + src_path.display(), + dest_path.to_string(), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + + #[cfg(debug_assertions)] + async fn build_local( + &self, + build_remote_server: String, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result { + use smol::process::{Command, Stdio}; + use std::env::VarError; + + async fn run_cmd(command: &mut Command) -> Result<()> { + let output = command + .kill_on_drop(true) + .stderr(Stdio::inherit()) + .output() + .await?; + anyhow::ensure!( + output.status.success(), + "Failed to run command: {command:?}" + ); + Ok(()) + } + + let use_musl = !build_remote_server.contains("nomusl"); + let triple = format!( + "{}-{}", + self.ssh_platform.arch, + match self.ssh_platform.os { + "linux" => + if use_musl { + "unknown-linux-musl" + } else { + "unknown-linux-gnu" + }, + "macos" => "apple-darwin", + _ => anyhow::bail!("can't cross compile for: {:?}", self.ssh_platform), + } + ); + let mut rust_flags = match std::env::var("RUSTFLAGS") { + Ok(val) => val, + Err(VarError::NotPresent) => String::new(), + Err(e) => { + log::error!("Failed to get env var `RUSTFLAGS` value: {e}"); + String::new() + } + }; + if self.ssh_platform.os == "linux" && use_musl { + rust_flags.push_str(" -C target-feature=+crt-static"); + } + if build_remote_server.contains("mold") { + rust_flags.push_str(" -C link-arg=-fuse-ld=mold"); + } + + if self.ssh_platform.arch == std::env::consts::ARCH + && self.ssh_platform.os == std::env::consts::OS + { + delegate.set_status(Some("Building remote server binary from source"), cx); + log::info!("building remote server binary from source"); + run_cmd( + Command::new("cargo") + .args([ + "build", + "--package", + "remote_server", + "--features", + "debug-embed", + "--target-dir", + "target/remote_server", + "--target", + &triple, + ]) + .env("RUSTFLAGS", &rust_flags), + ) + .await?; + } else if build_remote_server.contains("cross") { + #[cfg(target_os = "windows")] + use util::paths::SanitizedPath; + + delegate.set_status(Some("Installing cross.rs for cross-compilation"), cx); + log::info!("installing cross"); + run_cmd(Command::new("cargo").args([ + "install", + "cross", + "--git", + "https://github.com/cross-rs/cross", + ])) + .await?; + + delegate.set_status( + Some(&format!( + "Building remote server binary from source for {} with Docker", + &triple + )), + cx, + ); + log::info!("building remote server binary from source for {}", &triple); + + // On Windows, the binding needs to be set to the canonical path + #[cfg(target_os = "windows")] + let src = + SanitizedPath::new(&smol::fs::canonicalize("./target").await?).to_glob_string(); + #[cfg(not(target_os = "windows"))] + let src = "./target"; + run_cmd( + Command::new("cross") + .args([ + "build", + "--package", + "remote_server", + "--features", + "debug-embed", + "--target-dir", + "target/remote_server", + "--target", + &triple, + ]) + .env( + "CROSS_CONTAINER_OPTS", + format!("--mount type=bind,src={src},dst=/app/target"), + ) + .env("RUSTFLAGS", &rust_flags), + ) + .await?; + } else { + let which = cx + .background_spawn(async move { which::which("zig") }) + .await; + + if which.is_err() { + #[cfg(not(target_os = "windows"))] + { + anyhow::bail!( + "zig not found on $PATH, install zig (see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross" + ) + } + #[cfg(target_os = "windows")] + { + anyhow::bail!( + "zig not found on $PATH, install zig (use `winget install -e --id zig.zig` or see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross" + ) + } + } + + delegate.set_status(Some("Adding rustup target for cross-compilation"), cx); + log::info!("adding rustup target"); + run_cmd(Command::new("rustup").args(["target", "add"]).arg(&triple)).await?; + + delegate.set_status(Some("Installing cargo-zigbuild for cross-compilation"), cx); + log::info!("installing cargo-zigbuild"); + run_cmd(Command::new("cargo").args(["install", "--locked", "cargo-zigbuild"])).await?; + + delegate.set_status( + Some(&format!( + "Building remote binary from source for {triple} with Zig" + )), + cx, + ); + log::info!("building remote binary from source for {triple} with Zig"); + run_cmd( + Command::new("cargo") + .args([ + "zigbuild", + "--package", + "remote_server", + "--features", + "debug-embed", + "--target-dir", + "target/remote_server", + "--target", + &triple, + ]) + .env("RUSTFLAGS", &rust_flags), + ) + .await?; + }; + let bin_path = Path::new("target") + .join("remote_server") + .join(&triple) + .join("debug") + .join("remote_server"); + + let path = if !build_remote_server.contains("nocompress") { + delegate.set_status(Some("Compressing binary"), cx); + + #[cfg(not(target_os = "windows"))] + { + run_cmd(Command::new("gzip").args(["-f", &bin_path.to_string_lossy()])).await?; + } + #[cfg(target_os = "windows")] + { + // On Windows, we use 7z to compress the binary + let seven_zip = which::which("7z.exe").context("7z.exe not found on $PATH, install it (e.g. with `winget install -e --id 7zip.7zip`) or, if you don't want this behaviour, set $env:ZED_BUILD_REMOTE_SERVER=\"nocompress\"")?; + let gz_path = format!("target/remote_server/{}/debug/remote_server.gz", triple); + if smol::fs::metadata(&gz_path).await.is_ok() { + smol::fs::remove_file(&gz_path).await?; + } + run_cmd(Command::new(seven_zip).args([ + "a", + "-tgzip", + &gz_path, + &bin_path.to_string_lossy(), + ])) + .await?; + } + + let mut archive_path = bin_path; + archive_path.set_extension("gz"); + std::env::current_dir()?.join(archive_path) + } else { + bin_path + }; + + Ok(path) + } +} + +impl SshSocket { + #[cfg(not(target_os = "windows"))] + fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result { + Ok(Self { + connection_options: options, + envs: HashMap::default(), + socket_path, + }) + } + + #[cfg(target_os = "windows")] + fn new(options: SshConnectionOptions, temp_dir: &TempDir, secret: String) -> Result { + let askpass_script = temp_dir.path().join("askpass.bat"); + std::fs::write(&askpass_script, "@ECHO OFF\necho %ZED_SSH_ASKPASS%")?; + let mut envs = HashMap::default(); + envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into()); + envs.insert("SSH_ASKPASS".into(), askpass_script.display().to_string()); + envs.insert("ZED_SSH_ASKPASS".into(), secret); + Ok(Self { + connection_options: options, + envs, + }) + } + + // :WARNING: ssh unquotes arguments when executing on the remote :WARNING: + // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l + // and passes -l as an argument to sh, not to ls. + // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing + // into a machine. You must use `cd` to get back to $HOME. + // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'" + fn ssh_command(&self, program: &str, args: &[&str]) -> process::Command { + let mut command = util::command::new_smol_command("ssh"); + let to_run = iter::once(&program) + .chain(args.iter()) + .map(|token| { + // We're trying to work with: sh, bash, zsh, fish, tcsh, ...? + debug_assert!( + !token.contains('\n'), + "multiline arguments do not work in all shells" + ); + shlex::try_quote(token).unwrap() + }) + .join(" "); + let to_run = format!("cd; {to_run}"); + log::debug!("ssh {} {:?}", self.connection_options.ssh_url(), to_run); + self.ssh_options(&mut command) + .arg(self.connection_options.ssh_url()) + .arg(to_run); + command + } + + async fn run_command(&self, program: &str, args: &[&str]) -> Result { + let output = self.ssh_command(program, args).output().await?; + anyhow::ensure!( + output.status.success(), + "failed to run command: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + #[cfg(not(target_os = "windows"))] + fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command { + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .args(self.connection_options.additional_args()) + .args(["-o", "ControlMaster=no", "-o"]) + .arg(format!("ControlPath={}", self.socket_path.display())) + } + + #[cfg(target_os = "windows")] + fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command { + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .args(self.connection_options.additional_args()) + .envs(self.envs.clone()) + } + + // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh. + // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to + #[cfg(not(target_os = "windows"))] + fn ssh_args(&self) -> Vec { + let mut arguments = self.connection_options.additional_args(); + arguments.extend(vec![ + "-o".to_string(), + "ControlMaster=no".to_string(), + "-o".to_string(), + format!("ControlPath={}", self.socket_path.display()), + self.connection_options.ssh_url(), + ]); + arguments + } + + #[cfg(target_os = "windows")] + fn ssh_args(&self) -> Vec { + let mut arguments = self.connection_options.additional_args(); + arguments.push(self.connection_options.ssh_url()); + arguments + } + + async fn platform(&self) -> Result { + let uname = self.run_command("sh", &["-lc", "uname -sm"]).await?; + let Some((os, arch)) = uname.split_once(" ") else { + anyhow::bail!("unknown uname: {uname:?}") + }; + + let os = match os.trim() { + "Darwin" => "macos", + "Linux" => "linux", + _ => anyhow::bail!( + "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development" + ), + }; + // exclude armv5,6,7 as they are 32-bit. + let arch = if arch.starts_with("armv8") + || arch.starts_with("armv9") + || arch.starts_with("arm64") + || arch.starts_with("aarch64") + { + "aarch64" + } else if arch.starts_with("x86") { + "x86_64" + } else { + anyhow::bail!( + "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development" + ) + }; + + Ok(RemotePlatform { os, arch }) + } + + async fn shell(&self) -> String { + match self.run_command("sh", &["-lc", "echo $SHELL"]).await { + Ok(shell) => shell.trim().to_owned(), + Err(e) => { + log::error!("Failed to get shell: {e}"); + "sh".to_owned() + } + } + } +} + +fn parse_port_number(port_str: &str) -> Result { + port_str + .parse() + .with_context(|| format!("parsing port number: {port_str}")) +} + +fn parse_port_forward_spec(spec: &str) -> Result { + let parts: Vec<&str> = spec.split(':').collect(); + + match parts.len() { + 4 => { + let local_port = parse_port_number(parts[1])?; + let remote_port = parse_port_number(parts[3])?; + + Ok(SshPortForwardOption { + local_host: Some(parts[0].to_string()), + local_port, + remote_host: Some(parts[2].to_string()), + remote_port, + }) + } + 3 => { + let local_port = parse_port_number(parts[0])?; + let remote_port = parse_port_number(parts[2])?; + + Ok(SshPortForwardOption { + local_host: None, + local_port, + remote_host: Some(parts[1].to_string()), + remote_port, + }) + } + _ => anyhow::bail!("Invalid port forward format"), + } +} + +impl SshConnectionOptions { + pub fn parse_command_line(input: &str) -> Result { + let input = input.trim_start_matches("ssh "); + let mut hostname: Option = None; + let mut username: Option = None; + let mut port: Option = None; + let mut args = Vec::new(); + let mut port_forwards: Vec = Vec::new(); + + // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W + const ALLOWED_OPTS: &[&str] = &[ + "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y", + ]; + const ALLOWED_ARGS: &[&str] = &[ + "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R", + "-w", + ]; + + let mut tokens = shlex::split(input).context("invalid input")?.into_iter(); + + 'outer: while let Some(arg) = tokens.next() { + if ALLOWED_OPTS.contains(&(&arg as &str)) { + args.push(arg.to_string()); + continue; + } + if arg == "-p" { + port = tokens.next().and_then(|arg| arg.parse().ok()); + continue; + } else if let Some(p) = arg.strip_prefix("-p") { + port = p.parse().ok(); + continue; + } + if arg == "-l" { + username = tokens.next(); + continue; + } else if let Some(l) = arg.strip_prefix("-l") { + username = Some(l.to_string()); + continue; + } + if arg == "-L" || arg.starts_with("-L") { + let forward_spec = if arg == "-L" { + tokens.next() + } else { + Some(arg.strip_prefix("-L").unwrap().to_string()) + }; + + if let Some(spec) = forward_spec { + port_forwards.push(parse_port_forward_spec(&spec)?); + } else { + anyhow::bail!("Missing port forward format"); + } + } + + for a in ALLOWED_ARGS { + if arg == *a { + args.push(arg); + if let Some(next) = tokens.next() { + args.push(next); + } + continue 'outer; + } else if arg.starts_with(a) { + args.push(arg); + continue 'outer; + } + } + if arg.starts_with("-") || hostname.is_some() { + anyhow::bail!("unsupported argument: {:?}", arg); + } + let mut input = &arg as &str; + // Destination might be: username1@username2@ip2@ip1 + if let Some((u, rest)) = input.rsplit_once('@') { + input = rest; + username = Some(u.to_string()); + } + if let Some((rest, p)) = input.split_once(':') { + input = rest; + port = p.parse().ok() + } + hostname = Some(input.to_string()) + } + + let Some(hostname) = hostname else { + anyhow::bail!("missing hostname"); + }; + + let port_forwards = match port_forwards.len() { + 0 => None, + _ => Some(port_forwards), + }; + + Ok(Self { + host: hostname, + username, + port, + port_forwards, + args: Some(args), + password: None, + nickname: None, + upload_binary_over_ssh: false, + }) + } + + pub fn ssh_url(&self) -> String { + let mut result = String::from("ssh://"); + if let Some(username) = &self.username { + // Username might be: username1@username2@ip2 + let username = urlencoding::encode(username); + result.push_str(&username); + result.push('@'); + } + result.push_str(&self.host); + if let Some(port) = self.port { + result.push(':'); + result.push_str(&port.to_string()); + } + result + } + + pub fn additional_args(&self) -> Vec { + let mut args = self.args.iter().flatten().cloned().collect::>(); + + if let Some(forwards) = &self.port_forwards { + args.extend(forwards.iter().map(|pf| { + let local_host = match &pf.local_host { + Some(host) => host, + None => "localhost", + }; + let remote_host = match &pf.remote_host { + Some(host) => host, + None => "localhost", + }; + + format!( + "-L{}:{}:{}:{}", + local_host, pf.local_port, remote_host, pf.remote_port + ) + })); + } + + args + } + + fn scp_url(&self) -> String { + if let Some(username) = &self.username { + format!("{}@{}", username, self.host) + } else { + self.host.clone() + } + } + + pub fn connection_string(&self) -> String { + let host = if let Some(username) = &self.username { + format!("{}@{}", username, self.host) + } else { + self.host.clone() + }; + if let Some(port) = &self.port { + format!("{}:{}", host, port) + } else { + host + } + } +} diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 6216ff77288938f7e4d424101c572f5bea13b69a..c81a69c2b308d2623ada78b1f38df80f96f8fe14 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -1,5 +1,6 @@ use ::proto::{FromProto, ToProto}; use anyhow::{Context as _, Result, anyhow}; +use lsp::LanguageServerId; use extension::ExtensionHostProxy; use extension_host::headless_host::HeadlessExtensionStore; @@ -14,6 +15,7 @@ use project::{ buffer_store::{BufferStore, BufferStoreEvent}, debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore}, git_store::GitStore, + lsp_store::log_store::{self, GlobalLogStore, LanguageServerKind}, project_settings::SettingsObserver, search::SearchQuery, task_store::TaskStore, @@ -21,7 +23,7 @@ use project::{ }; use rpc::{ AnyProtoClient, TypedEnvelope, - proto::{self, SSH_PEER_ID, SSH_PROJECT_ID}, + proto::{self, REMOTE_SERVER_PEER_ID, REMOTE_SERVER_PROJECT_ID}, }; use settings::initial_server_settings_content; @@ -65,6 +67,7 @@ impl HeadlessProject { settings::init(cx); language::init(cx); project::Project::init_settings(cx); + log_store::init(false, cx); } pub fn new( @@ -83,7 +86,7 @@ impl HeadlessProject { let worktree_store = cx.new(|cx| { let mut store = WorktreeStore::local(true, fs.clone()); - store.shared(SSH_PROJECT_ID, session.clone(), cx); + store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); store }); @@ -101,7 +104,7 @@ impl HeadlessProject { let buffer_store = cx.new(|cx| { let mut buffer_store = BufferStore::local(worktree_store.clone(), cx); - buffer_store.shared(SSH_PROJECT_ID, session.clone(), cx); + buffer_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); buffer_store }); @@ -119,7 +122,7 @@ impl HeadlessProject { breakpoint_store.clone(), cx, ); - dap_store.shared(SSH_PROJECT_ID, session.clone(), cx); + dap_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); dap_store }); @@ -131,7 +134,7 @@ impl HeadlessProject { fs.clone(), cx, ); - store.shared(SSH_PROJECT_ID, session.clone(), cx); + store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); store }); @@ -154,7 +157,7 @@ impl HeadlessProject { environment.clone(), cx, ); - task_store.shared(SSH_PROJECT_ID, session.clone(), cx); + task_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); task_store }); let settings_observer = cx.new(|cx| { @@ -164,7 +167,7 @@ impl HeadlessProject { task_store.clone(), cx, ); - observer.shared(SSH_PROJECT_ID, session.clone(), cx); + observer.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); observer }); @@ -185,7 +188,7 @@ impl HeadlessProject { fs.clone(), cx, ); - lsp_store.shared(SSH_PROJECT_ID, session.clone(), cx); + lsp_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); lsp_store }); @@ -213,15 +216,15 @@ impl HeadlessProject { ); // local_machine -> ssh handlers - session.subscribe_to_entity(SSH_PROJECT_ID, &worktree_store); - session.subscribe_to_entity(SSH_PROJECT_ID, &buffer_store); - session.subscribe_to_entity(SSH_PROJECT_ID, &cx.entity()); - session.subscribe_to_entity(SSH_PROJECT_ID, &lsp_store); - session.subscribe_to_entity(SSH_PROJECT_ID, &task_store); - session.subscribe_to_entity(SSH_PROJECT_ID, &toolchain_store); - session.subscribe_to_entity(SSH_PROJECT_ID, &dap_store); - session.subscribe_to_entity(SSH_PROJECT_ID, &settings_observer); - session.subscribe_to_entity(SSH_PROJECT_ID, &git_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &worktree_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &buffer_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &cx.entity()); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &lsp_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &task_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &toolchain_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &dap_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &settings_observer); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &git_store); session.add_request_handler(cx.weak_entity(), Self::handle_list_remote_directory); session.add_request_handler(cx.weak_entity(), Self::handle_get_path_metadata); @@ -235,6 +238,7 @@ impl HeadlessProject { session.add_entity_request_handler(Self::handle_open_new_buffer); session.add_entity_request_handler(Self::handle_find_search_candidates); session.add_entity_request_handler(Self::handle_open_server_settings); + session.add_entity_message_handler(Self::handle_toggle_lsp_logs); session.add_entity_request_handler(BufferStore::handle_update_buffer); session.add_entity_message_handler(BufferStore::handle_close_buffer); @@ -288,7 +292,7 @@ impl HeadlessProject { } = event { cx.background_spawn(self.session.request(proto::UpdateBuffer { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, buffer_id: buffer.read(cx).remote_id().to_proto(), operations: vec![serialize_operation(operation)], })) @@ -298,11 +302,40 @@ impl HeadlessProject { fn on_lsp_store_event( &mut self, - _lsp_store: Entity, + lsp_store: Entity, event: &LspStoreEvent, cx: &mut Context, ) { match event { + LspStoreEvent::LanguageServerAdded(id, name, worktree_id) => { + let log_store = cx + .try_global::() + .map(|lsp_logs| lsp_logs.0.clone()); + if let Some(log_store) = log_store { + log_store.update(cx, |log_store, cx| { + log_store.add_language_server( + LanguageServerKind::LocalSsh { + lsp_store: self.lsp_store.downgrade(), + }, + *id, + Some(name.clone()), + *worktree_id, + lsp_store.read(cx).language_server_for_id(*id), + cx, + ); + }); + } + } + LspStoreEvent::LanguageServerRemoved(id) => { + let log_store = cx + .try_global::() + .map(|lsp_logs| lsp_logs.0.clone()); + if let Some(log_store) = log_store { + log_store.update(cx, |log_store, cx| { + log_store.remove_language_server(*id, cx); + }); + } + } LspStoreEvent::LanguageServerUpdate { language_server_id, name, @@ -310,7 +343,7 @@ impl HeadlessProject { } => { self.session .send(proto::UpdateLanguageServer { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, server_name: name.as_ref().map(|name| name.to_string()), language_server_id: language_server_id.to_proto(), variant: Some(message.clone()), @@ -320,25 +353,15 @@ impl HeadlessProject { LspStoreEvent::Notification(message) => { self.session .send(proto::Toast { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, notification_id: "lsp".to_string(), message: message.clone(), }) .log_err(); } - LspStoreEvent::LanguageServerLog(language_server_id, log_type, message) => { - self.session - .send(proto::LanguageServerLog { - project_id: SSH_PROJECT_ID, - language_server_id: language_server_id.to_proto(), - message: message.clone(), - log_type: Some(log_type.to_proto()), - }) - .log_err(); - } LspStoreEvent::LanguageServerPrompt(prompt) => { let request = self.session.request(proto::LanguageServerPromptRequest { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, actions: prompt .actions .iter() @@ -474,7 +497,7 @@ impl HeadlessProject { let buffer_id = buffer.read_with(&cx, |b, _| b.remote_id())?; buffer_store.update(&mut cx, |buffer_store, cx| { buffer_store - .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx) + .create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx) .detach_and_log_err(cx); })?; @@ -500,7 +523,7 @@ impl HeadlessProject { let buffer_id = buffer.read_with(&cx, |b, _| b.remote_id())?; buffer_store.update(&mut cx, |buffer_store, cx| { buffer_store - .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx) + .create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx) .detach_and_log_err(cx); })?; @@ -509,7 +532,31 @@ impl HeadlessProject { }) } - pub async fn handle_open_server_settings( + async fn handle_toggle_lsp_logs( + _: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + let server_id = LanguageServerId::from_proto(envelope.payload.server_id); + let lsp_logs = cx + .update(|cx| { + cx.try_global::() + .map(|lsp_logs| lsp_logs.0.clone()) + })? + .context("lsp logs store is missing")?; + + lsp_logs.update(&mut cx, |lsp_logs, _| { + // we do not support any other log toggling yet + if envelope.payload.enabled { + lsp_logs.enable_rpc_trace_for_language_server(server_id); + } else { + lsp_logs.disable_rpc_trace_for_language_server(server_id); + } + })?; + Ok(()) + } + + async fn handle_open_server_settings( this: Entity, _: TypedEnvelope, mut cx: AsyncApp, @@ -550,7 +597,7 @@ impl HeadlessProject { buffer_store.update(cx, |buffer_store, cx| { buffer_store - .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx) + .create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx) .detach_and_log_err(cx); }); @@ -562,7 +609,7 @@ impl HeadlessProject { }) } - pub async fn handle_find_search_candidates( + async fn handle_find_search_candidates( this: Entity, envelope: TypedEnvelope, mut cx: AsyncApp, @@ -586,7 +633,7 @@ impl HeadlessProject { response.buffer_ids.push(buffer_id.to_proto()); buffer_store .update(&mut cx, |buffer_store, cx| { - buffer_store.create_buffer_for_peer(&buffer, SSH_PEER_ID, cx) + buffer_store.create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx) })? .await?; } @@ -594,7 +641,7 @@ impl HeadlessProject { Ok(response) } - pub async fn handle_list_remote_directory( + async fn handle_list_remote_directory( this: Entity, envelope: TypedEnvelope, cx: AsyncApp, @@ -626,7 +673,7 @@ impl HeadlessProject { }) } - pub async fn handle_get_path_metadata( + async fn handle_get_path_metadata( this: Entity, envelope: TypedEnvelope, cx: AsyncApp, @@ -644,7 +691,7 @@ impl HeadlessProject { }) } - pub async fn handle_shutdown_remote_server( + async fn handle_shutdown_remote_server( _this: Entity, _envelope: TypedEnvelope, cx: AsyncApp, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 69fae7f399e506b26f005d30fdda6b7240df3e0b..e106a5ef18d59ebeb942564f24600635f78f89c7 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -22,7 +22,7 @@ use project::{ Project, ProjectPath, search::{SearchQuery, SearchResult}, }; -use remote::SshRemoteClient; +use remote::RemoteClient; use serde_json::json; use settings::{Settings, SettingsLocation, SettingsStore, initial_server_settings_content}; use smol::stream::StreamExt; @@ -1119,7 +1119,7 @@ async fn test_reconnect(cx: &mut TestAppContext, server_cx: &mut TestAppContext) buffer.edit([(ix..ix + 1, "100")], None, cx); }); - let client = cx.read(|cx| project.read(cx).ssh_client().unwrap()); + let client = cx.read(|cx| project.read(cx).remote_client().unwrap()); client .update(cx, |client, cx| client.simulate_disconnect(cx)) .detach(); @@ -1782,7 +1782,7 @@ pub async fn init_test( }); init_logger(); - let (opts, ssh_server_client) = SshRemoteClient::fake_server(cx, server_cx); + let (opts, ssh_server_client) = RemoteClient::fake_server(cx, server_cx); let http_client = Arc::new(BlockedHttpClient); let node_runtime = NodeRuntime::unavailable(); let languages = Arc::new(LanguageRegistry::new(cx.executor())); @@ -1804,7 +1804,7 @@ pub async fn init_test( ) }); - let ssh = SshRemoteClient::fake_client(opts, cx).await; + let ssh = RemoteClient::fake_client(opts, cx).await; let project = build_project(ssh, cx); project .update(cx, { @@ -1819,7 +1819,7 @@ fn init_logger() { zlog::init_test(); } -fn build_project(ssh: Entity, cx: &mut TestAppContext) -> Entity { +fn build_project(ssh: Entity, cx: &mut TestAppContext) -> Entity { cx.update(|cx| { if !cx.has_global::() { let settings_store = SettingsStore::test(cx); @@ -1845,5 +1845,5 @@ fn build_project(ssh: Entity, cx: &mut TestAppContext) -> Entit language::init(cx); }); - cx.update(|cx| Project::ssh(ssh, client, node, user_store, languages, fs, cx)) + cx.update(|cx| Project::remote(ssh, client, node, user_store, languages, fs, cx)) } diff --git a/crates/remote_server/src/unix.rs b/crates/remote_server/src/unix.rs index c6d1566d6038bb1b908c0b28eb8d24b85e5cc86d..cb671a72d9beab0983536571e81fcd78f3df21c8 100644 --- a/crates/remote_server/src/unix.rs +++ b/crates/remote_server/src/unix.rs @@ -19,14 +19,14 @@ use project::project_settings::ProjectSettings; use proto::CrashReport; use release_channel::{AppVersion, RELEASE_CHANNEL, ReleaseChannel}; -use remote::SshRemoteClient; +use remote::RemoteClient; use remote::{ json_log::LogRecord, protocol::{read_message, write_message}, proxy::ProxyLaunchError, }; use reqwest_client::ReqwestClient; -use rpc::proto::{self, Envelope, SSH_PROJECT_ID}; +use rpc::proto::{self, Envelope, REMOTE_SERVER_PROJECT_ID}; use rpc::{AnyProtoClient, TypedEnvelope}; use settings::{Settings, SettingsStore, watch_config_file}; use smol::channel::{Receiver, Sender}; @@ -396,7 +396,7 @@ fn start_server( }) .detach(); - SshRemoteClient::proto_client_from_channels(incoming_rx, outgoing_tx, cx, "server") + RemoteClient::proto_client_from_channels(incoming_rx, outgoing_tx, cx, "server") } fn init_paths() -> anyhow::Result<()> { @@ -867,34 +867,21 @@ where R: AsyncRead + Unpin, W: AsyncWrite + Unpin, { - use remote::protocol::read_message_raw; + use remote::protocol::{read_message_raw, write_size_prefixed_buffer}; let mut buffer = Vec::new(); loop { read_message_raw(&mut reader, &mut buffer) .await .with_context(|| format!("failed to read message from {}", socket_name))?; - write_size_prefixed_buffer(&mut writer, &mut buffer) .await .with_context(|| format!("failed to write message to {}", socket_name))?; - writer.flush().await?; - buffer.clear(); } } -async fn write_size_prefixed_buffer( - stream: &mut S, - buffer: &mut Vec, -) -> Result<()> { - let len = buffer.len() as u32; - stream.write_all(len.to_le_bytes().as_slice()).await?; - stream.write_all(buffer).await?; - Ok(()) -} - fn initialize_settings( session: AnyProtoClient, fs: Arc, @@ -910,7 +897,7 @@ fn initialize_settings( session .send(proto::Toast { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, notification_id: "server-settings-failed".to_string(), message: format!( "Error in settings on remote host {:?}: {}", @@ -922,7 +909,7 @@ fn initialize_settings( } else { session .send(proto::HideToast { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, notification_id: "server-settings-failed".to_string(), }) .log_err(); diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index 5ad3996e784a27f0552059bf5a2e55addb11f0fd..3d7962fa17d7fa4a4c3b12e88c90adcecd06667d 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -414,7 +414,7 @@ impl RulesLibrary { }); Self { title_bar: if !cfg!(target_os = "macos") { - Some(cx.new(|_| PlatformTitleBar::new("rules-library-title-bar"))) + Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx))) } else { None }, diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index a38dc8c35b3a0caef230247505a6131d40170bca..92992dced6066d7242ad15f666de5a3b98f76ed0 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -749,14 +749,16 @@ impl BufferSearchBar { return false; }; - self.configured_options = + let configured_options = SearchOptions::from_settings(&EditorSettings::get_global(cx).search); - if self.dismissed - && (self.configured_options != self.default_options - || self.configured_options != self.search_options) - { - self.search_options = self.configured_options; - self.default_options = self.configured_options; + let settings_changed = configured_options != self.configured_options; + + if self.dismissed && settings_changed { + // Only update configuration options when search bar is dismissed, + // so we don't miss updates even after calling show twice + self.configured_options = configured_options; + self.search_options = configured_options; + self.default_options = configured_options; } self.dismissed = false; @@ -1514,18 +1516,25 @@ mod tests { cx, ) }); - let cx = cx.add_empty_window(); - let editor = - cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx)); - - let search_bar = cx.new_window_entity(|window, cx| { + let mut editor = None; + let window = cx.add_window(|window, cx| { + let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure( + "keymaps/default-macos.json", + cx, + ) + .unwrap(); + cx.bind_keys(default_key_bindings); + editor = Some(cx.new(|cx| Editor::for_buffer(buffer.clone(), None, window, cx))); let mut search_bar = BufferSearchBar::new(None, window, cx); - search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.set_active_pane_item(Some(&editor.clone().unwrap()), window, cx); search_bar.show(window, cx); search_bar }); + let search_bar = window.root(cx).unwrap(); + + let cx = VisualTestContext::from_window(*window, cx).into_mut(); - (editor, search_bar, cx) + (editor.unwrap(), search_bar, cx) } #[gpui::test] @@ -2750,11 +2759,6 @@ mod tests { "Search bar should be present and visible" ); search_bar.deploy(&deploy, window, cx); - assert_eq!( - search_bar.configured_options, - SearchOptions::NONE, - "Should have configured search options matching the settings" - ); assert_eq!( search_bar.search_options, SearchOptions::WHOLE_WORD, @@ -2765,21 +2769,22 @@ mod tests { search_bar.deploy(&deploy, window, cx); assert_eq!( search_bar.search_options, - SearchOptions::NONE, - "After hiding and showing the search bar, default options should be used" + SearchOptions::WHOLE_WORD, + "After hiding and showing the search bar, search options should be preserved" ); search_bar.toggle_search_option(SearchOptions::REGEX, window, cx); search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx); assert_eq!( search_bar.search_options, - SearchOptions::REGEX | SearchOptions::WHOLE_WORD, + SearchOptions::REGEX, "Should enable the options toggled" ); assert!( !search_bar.dismissed, "Search bar should be present and visible" ); + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx); }); update_search_settings( @@ -2800,11 +2805,6 @@ mod tests { ); search_bar.deploy(&deploy, window, cx); - assert_eq!( - search_bar.configured_options, - SearchOptions::CASE_SENSITIVE, - "Should have configured search options matching the settings" - ); assert_eq!( search_bar.search_options, SearchOptions::REGEX | SearchOptions::WHOLE_WORD, @@ -2812,10 +2812,37 @@ mod tests { ); search_bar.dismiss(&Dismiss, window, cx); search_bar.deploy(&deploy, window, cx); + assert_eq!( + search_bar.configured_options, + SearchOptions::CASE_SENSITIVE, + "After a settings update and toggling the search bar, configured options should be updated" + ); assert_eq!( search_bar.search_options, SearchOptions::CASE_SENSITIVE, - "After hiding and showing the search bar, default options should be used" + "After a settings update and toggling the search bar, configured options should be used" + ); + }); + + update_search_settings( + SearchSettings { + button: true, + whole_word: true, + case_sensitive: true, + include_ignored: false, + regex: false, + }, + cx, + ); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.deploy(&deploy, window, cx); + search_bar.dismiss(&Dismiss, window, cx); + search_bar.show(window, cx); + assert_eq!( + search_bar.search_options, + SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD, + "Calling deploy on an already deployed search bar should not prevent settings updates from being detected" ); }); } diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 5d67e1047f39d90677274af42c9d66fa898b8679..a8ad5c90a2888784e6d56f86a5c122c89e5b77f9 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -1139,7 +1139,7 @@ impl ProjectSearchView { fn build_search_query(&mut self, cx: &mut Context) -> Option { // Do not bail early in this function, as we want to fill out `self.panels_with_errors`. - let text = self.query_editor.read(cx).text(cx); + let text = self.search_query_text(cx); let open_buffers = if self.included_opened_only { Some(self.open_buffers(cx)) } else { diff --git a/crates/settings/src/key_equivalents.rs b/crates/settings/src/key_equivalents.rs deleted file mode 100644 index 65801375356289f41fa1688cbb32dff4249333d9..0000000000000000000000000000000000000000 --- a/crates/settings/src/key_equivalents.rs +++ /dev/null @@ -1,1424 +0,0 @@ -use collections::HashMap; - -// On some keyboards (e.g. German QWERTZ) it is not possible to type the full ASCII range -// without using option. This means that some of our built in keyboard shortcuts do not work -// for those users. -// -// The way macOS solves this problem is to move shortcuts around so that they are all reachable, -// even if the mnemonic changes. https://developer.apple.com/documentation/swiftui/keyboardshortcut/localization-swift.struct -// -// For example, cmd-> is the "switch window" shortcut because the > key is right above tab. -// To ensure this doesn't cause problems for shortcuts defined for a QWERTY layout, apple moves -// any shortcuts defined as cmd-> to cmd-:. Coincidentally this s also the same keyboard position -// as cmd-> on a QWERTY layout. -// -// Another example is cmd-[ and cmd-], as they cannot be typed without option, those keys are remapped to cmd-ö -// and cmd-ä. These shortcuts are not in the same position as a QWERTY keyboard, because on a QWERTZ keyboard -// the + key is in the way; and shortcuts bound to cmd-+ are still typed as cmd-+ on either keyboard (though the -// specific key moves) -// -// As far as I can tell, there's no way to query the mappings Apple uses except by rendering a menu with every -// possible key combination, and inspecting the UI to see what it rendered. So that's what we did... -// -// These mappings were generated by running https://github.com/ConradIrwin/keyboard-inspector, tidying up the -// output to remove languages with no mappings and other oddities, and converting it to a less verbose representation with: -// jq -s 'map(to_entries | map({key: .key, value: [(.value | to_entries | map(.key) | join("")), (.value | to_entries | map(.value) | join(""))]}) | from_entries) | add' -// From there I used multi-cursor to produce this match statement. -#[cfg(target_os = "macos")] -pub fn get_key_equivalents(layout: &str) -> Option> { - let mappings: &[(char, char)] = match layout { - "com.apple.keylayout.ABC-AZERTY" => &[ - ('!', '1'), - ('"', '%'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('.', ';'), - ('/', ':'), - ('0', 'à'), - ('1', '&'), - ('2', 'é'), - ('3', '"'), - ('4', '\''), - ('5', '('), - ('6', '§'), - ('7', 'è'), - ('8', '!'), - ('9', 'ç'), - (':', '°'), - (';', ')'), - ('<', '.'), - ('>', '/'), - ('@', '2'), - ('[', '^'), - ('\'', 'ù'), - ('\\', '`'), - (']', '$'), - ('^', '6'), - ('`', '<'), - ('{', '¨'), - ('|', '£'), - ('}', '*'), - ('~', '>'), - ], - "com.apple.keylayout.ABC-QWERTZ" => &[ - ('"', '`'), - ('#', '§'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', 'ß'), - (':', 'Ü'), - (';', 'ü'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '´'), - ('\\', '#'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '\''), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.Albanian" => &[ - ('"', '\''), - (':', 'Ç'), - (';', 'ç'), - ('<', ';'), - ('>', ':'), - ('@', '"'), - ('\'', '@'), - ('\\', 'ë'), - ('`', '<'), - ('|', 'Ë'), - ('~', '>'), - ], - "com.apple.keylayout.Austrian" => &[ - ('"', '`'), - ('#', '§'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', 'ß'), - (':', 'Ü'), - (';', 'ü'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '´'), - ('\\', '#'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '\''), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.Azeri" => &[ - ('"', 'Ə'), - (',', 'ç'), - ('.', 'ş'), - ('/', '.'), - (':', 'I'), - (';', 'ı'), - ('<', 'Ç'), - ('>', 'Ş'), - ('?', ','), - ('W', 'Ü'), - ('[', 'ö'), - ('\'', 'ə'), - (']', 'ğ'), - ('w', 'ü'), - ('{', 'Ö'), - ('|', '/'), - ('}', 'Ğ'), - ], - "com.apple.keylayout.Belgian" => &[ - ('!', '1'), - ('"', '%'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('.', ';'), - ('/', ':'), - ('0', 'à'), - ('1', '&'), - ('2', 'é'), - ('3', '"'), - ('4', '\''), - ('5', '('), - ('6', '§'), - ('7', 'è'), - ('8', '!'), - ('9', 'ç'), - (':', '°'), - (';', ')'), - ('<', '.'), - ('>', '/'), - ('@', '2'), - ('[', '^'), - ('\'', 'ù'), - ('\\', '`'), - (']', '$'), - ('^', '6'), - ('`', '<'), - ('{', '¨'), - ('|', '£'), - ('}', '*'), - ('~', '>'), - ], - "com.apple.keylayout.Brazilian-ABNT2" => &[ - ('"', '`'), - ('/', 'ç'), - ('?', 'Ç'), - ('\'', '´'), - ('\\', '~'), - ('^', '¨'), - ('`', '\''), - ('|', '^'), - ('~', '"'), - ], - "com.apple.keylayout.Brazilian-Pro" => &[('^', 'ˆ'), ('~', '˜')], - "com.apple.keylayout.British" => &[('#', '£')], - "com.apple.keylayout.Canadian-CSA" => &[ - ('"', 'È'), - ('/', 'é'), - ('<', '\''), - ('>', '"'), - ('?', 'É'), - ('[', '^'), - ('\'', 'è'), - ('\\', 'à'), - (']', 'ç'), - ('`', 'ù'), - ('{', '¨'), - ('|', 'À'), - ('}', 'Ç'), - ('~', 'Ù'), - ], - "com.apple.keylayout.Croatian" => &[ - ('"', 'Ć'), - ('&', '\''), - ('(', ')'), - (')', '='), - ('*', '('), - (':', 'Č'), - (';', 'č'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'š'), - ('\'', 'ć'), - ('\\', 'ž'), - (']', 'đ'), - ('^', '&'), - ('`', '<'), - ('{', 'Š'), - ('|', 'Ž'), - ('}', 'Đ'), - ('~', '>'), - ], - "com.apple.keylayout.Croatian-PC" => &[ - ('"', 'Ć'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '\''), - (':', 'Č'), - (';', 'č'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'š'), - ('\'', 'ć'), - ('\\', 'ž'), - (']', 'đ'), - ('^', '&'), - ('`', '<'), - ('{', 'Š'), - ('|', 'Ž'), - ('}', 'Đ'), - ('~', '>'), - ], - "com.apple.keylayout.Czech" => &[ - ('!', '1'), - ('"', '!'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('+', '%'), - ('/', '\''), - ('0', 'é'), - ('1', '+'), - ('2', 'ě'), - ('3', 'š'), - ('4', 'č'), - ('5', 'ř'), - ('6', 'ž'), - ('7', 'ý'), - ('8', 'á'), - ('9', 'í'), - (':', '"'), - (';', 'ů'), - ('<', '?'), - ('>', ':'), - ('?', 'ˇ'), - ('@', '2'), - ('[', 'ú'), - ('\'', '§'), - (']', ')'), - ('^', '6'), - ('`', '¨'), - ('{', 'Ú'), - ('}', '('), - ('~', '`'), - ], - "com.apple.keylayout.Czech-QWERTY" => &[ - ('!', '1'), - ('"', '!'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('+', '%'), - ('/', '\''), - ('0', 'é'), - ('1', '+'), - ('2', 'ě'), - ('3', 'š'), - ('4', 'č'), - ('5', 'ř'), - ('6', 'ž'), - ('7', 'ý'), - ('8', 'á'), - ('9', 'í'), - (':', '"'), - (';', 'ů'), - ('<', '?'), - ('>', ':'), - ('?', 'ˇ'), - ('@', '2'), - ('[', 'ú'), - ('\'', '§'), - (']', ')'), - ('^', '6'), - ('`', '¨'), - ('{', 'Ú'), - ('}', '('), - ('~', '`'), - ], - "com.apple.keylayout.Danish" => &[ - ('"', '^'), - ('$', '€'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'æ'), - ('\'', '¨'), - ('\\', '\''), - (']', 'ø'), - ('^', '&'), - ('`', '<'), - ('{', 'Æ'), - ('|', '*'), - ('}', 'Ø'), - ('~', '>'), - ], - "com.apple.keylayout.Faroese" => &[ - ('"', 'Ø'), - ('$', '€'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Æ'), - (';', 'æ'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'å'), - ('\'', 'ø'), - ('\\', '\''), - (']', 'ð'), - ('^', '&'), - ('`', '<'), - ('{', 'Å'), - ('|', '*'), - ('}', 'Ð'), - ('~', '>'), - ], - "com.apple.keylayout.Finnish" => &[ - ('"', '^'), - ('$', '€'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '¨'), - ('\\', '\''), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '*'), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.FinnishExtended" => &[ - ('"', 'ˆ'), - ('$', '€'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '¨'), - ('\\', '\''), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '*'), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.FinnishSami-PC" => &[ - ('"', 'ˆ'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '¨'), - ('\\', '@'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '*'), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.French" => &[ - ('!', '1'), - ('"', '%'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('.', ';'), - ('/', ':'), - ('0', 'à'), - ('1', '&'), - ('2', 'é'), - ('3', '"'), - ('4', '\''), - ('5', '('), - ('6', '§'), - ('7', 'è'), - ('8', '!'), - ('9', 'ç'), - (':', '°'), - (';', ')'), - ('<', '.'), - ('>', '/'), - ('@', '2'), - ('[', '^'), - ('\'', 'ù'), - ('\\', '`'), - (']', '$'), - ('^', '6'), - ('`', '<'), - ('{', '¨'), - ('|', '£'), - ('}', '*'), - ('~', '>'), - ], - "com.apple.keylayout.French-PC" => &[ - ('!', '1'), - ('"', '%'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('-', ')'), - ('.', ';'), - ('/', ':'), - ('0', 'à'), - ('1', '&'), - ('2', 'é'), - ('3', '"'), - ('4', '\''), - ('5', '('), - ('6', '-'), - ('7', 'è'), - ('8', '_'), - ('9', 'ç'), - (':', '§'), - (';', '!'), - ('<', '.'), - ('>', '/'), - ('@', '2'), - ('[', '^'), - ('\'', 'ù'), - ('\\', '*'), - (']', '$'), - ('^', '6'), - ('_', '°'), - ('`', '<'), - ('{', '¨'), - ('|', 'μ'), - ('}', '£'), - ('~', '>'), - ], - "com.apple.keylayout.French-numerical" => &[ - ('!', '1'), - ('"', '%'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('.', ';'), - ('/', ':'), - ('0', 'à'), - ('1', '&'), - ('2', 'é'), - ('3', '"'), - ('4', '\''), - ('5', '('), - ('6', '§'), - ('7', 'è'), - ('8', '!'), - ('9', 'ç'), - (':', '°'), - (';', ')'), - ('<', '.'), - ('>', '/'), - ('@', '2'), - ('[', '^'), - ('\'', 'ù'), - ('\\', '`'), - (']', '$'), - ('^', '6'), - ('`', '<'), - ('{', '¨'), - ('|', '£'), - ('}', '*'), - ('~', '>'), - ], - "com.apple.keylayout.German" => &[ - ('"', '`'), - ('#', '§'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', 'ß'), - (':', 'Ü'), - (';', 'ü'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '´'), - ('\\', '#'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '\''), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.German-DIN-2137" => &[ - ('"', '`'), - ('#', '§'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', 'ß'), - (':', 'Ü'), - (';', 'ü'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '´'), - ('\\', '#'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '\''), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.Hawaiian" => &[('\'', 'ʻ')], - "com.apple.keylayout.Hungarian" => &[ - ('!', '\''), - ('"', 'Á'), - ('#', '+'), - ('$', '!'), - ('&', '='), - ('(', ')'), - (')', 'Ö'), - ('*', '('), - ('+', 'Ó'), - ('/', 'ü'), - ('0', 'ö'), - (':', 'É'), - (';', 'é'), - ('<', 'Ü'), - ('=', 'ó'), - ('>', ':'), - ('@', '"'), - ('[', 'ő'), - ('\'', 'á'), - ('\\', 'ű'), - (']', 'ú'), - ('^', '/'), - ('`', 'í'), - ('{', 'Ő'), - ('|', 'Ű'), - ('}', 'Ú'), - ('~', 'Í'), - ], - "com.apple.keylayout.Hungarian-QWERTY" => &[ - ('!', '\''), - ('"', 'Á'), - ('#', '+'), - ('$', '!'), - ('&', '='), - ('(', ')'), - (')', 'Ö'), - ('*', '('), - ('+', 'Ó'), - ('/', 'ü'), - ('0', 'ö'), - (':', 'É'), - (';', 'é'), - ('<', 'Ü'), - ('=', 'ó'), - ('>', ':'), - ('@', '"'), - ('[', 'ő'), - ('\'', 'á'), - ('\\', 'ű'), - (']', 'ú'), - ('^', '/'), - ('`', 'í'), - ('{', 'Ő'), - ('|', 'Ű'), - ('}', 'Ú'), - ('~', 'Í'), - ], - "com.apple.keylayout.Icelandic" => &[ - ('"', 'Ö'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '\''), - (':', 'Ð'), - (';', 'ð'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'æ'), - ('\'', 'ö'), - ('\\', 'þ'), - (']', '´'), - ('^', '&'), - ('`', '<'), - ('{', 'Æ'), - ('|', 'Þ'), - ('}', '´'), - ('~', '>'), - ], - "com.apple.keylayout.Irish" => &[('#', '£')], - "com.apple.keylayout.IrishExtended" => &[('#', '£')], - "com.apple.keylayout.Italian" => &[ - ('!', '1'), - ('"', '%'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - (',', ';'), - ('.', ':'), - ('/', ','), - ('0', 'é'), - ('1', '&'), - ('2', '"'), - ('3', '\''), - ('4', '('), - ('5', 'ç'), - ('6', 'è'), - ('7', ')'), - ('8', '£'), - ('9', 'à'), - (':', '!'), - (';', 'ò'), - ('<', '.'), - ('>', '/'), - ('@', '2'), - ('[', 'ì'), - ('\'', 'ù'), - ('\\', '§'), - (']', '$'), - ('^', '6'), - ('`', '<'), - ('{', '^'), - ('|', '°'), - ('}', '*'), - ('~', '>'), - ], - "com.apple.keylayout.Italian-Pro" => &[ - ('"', '^'), - ('#', '£'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '\''), - (':', 'é'), - (';', 'è'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'ò'), - ('\'', 'ì'), - ('\\', 'ù'), - (']', 'à'), - ('^', '&'), - ('`', '<'), - ('{', 'ç'), - ('|', '§'), - ('}', '°'), - ('~', '>'), - ], - "com.apple.keylayout.LatinAmerican" => &[ - ('"', '¨'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '\''), - (':', 'Ñ'), - (';', 'ñ'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', '{'), - ('\'', '´'), - ('\\', '¿'), - (']', '}'), - ('^', '&'), - ('`', '<'), - ('{', '['), - ('|', '¡'), - ('}', ']'), - ('~', '>'), - ], - "com.apple.keylayout.Lithuanian" => &[ - ('!', 'Ą'), - ('#', 'Ę'), - ('$', 'Ė'), - ('%', 'Į'), - ('&', 'Ų'), - ('*', 'Ū'), - ('+', 'Ž'), - ('1', 'ą'), - ('2', 'č'), - ('3', 'ę'), - ('4', 'ė'), - ('5', 'į'), - ('6', 'š'), - ('7', 'ų'), - ('8', 'ū'), - ('=', 'ž'), - ('@', 'Č'), - ('^', 'Š'), - ], - "com.apple.keylayout.Maltese" => &[ - ('#', '£'), - ('[', 'ġ'), - (']', 'ħ'), - ('`', 'ż'), - ('{', 'Ġ'), - ('}', 'Ħ'), - ('~', 'Ż'), - ], - "com.apple.keylayout.NorthernSami" => &[ - ('"', 'Ŋ'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('Q', 'Á'), - ('W', 'Š'), - ('X', 'Č'), - ('[', 'ø'), - ('\'', 'ŋ'), - ('\\', 'đ'), - (']', 'æ'), - ('^', '&'), - ('`', 'ž'), - ('q', 'á'), - ('w', 'š'), - ('x', 'č'), - ('{', 'Ø'), - ('|', 'Đ'), - ('}', 'Æ'), - ('~', 'Ž'), - ], - "com.apple.keylayout.Norwegian" => &[ - ('"', '^'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ø'), - ('\'', '¨'), - ('\\', '@'), - (']', 'æ'), - ('^', '&'), - ('`', '<'), - ('{', 'Ø'), - ('|', '*'), - ('}', 'Æ'), - ('~', '>'), - ], - "com.apple.keylayout.NorwegianExtended" => &[ - ('"', 'ˆ'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ø'), - ('\\', '@'), - (']', 'æ'), - ('`', '<'), - ('}', 'Æ'), - ('~', '>'), - ], - "com.apple.keylayout.NorwegianSami-PC" => &[ - ('"', 'ˆ'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ø'), - ('\'', '¨'), - ('\\', '@'), - (']', 'æ'), - ('^', '&'), - ('`', '<'), - ('{', 'Ø'), - ('|', '*'), - ('}', 'Æ'), - ('~', '>'), - ], - "com.apple.keylayout.Polish" => &[ - ('!', '§'), - ('"', 'ę'), - ('#', '!'), - ('$', '?'), - ('%', '+'), - ('&', ':'), - ('(', '/'), - (')', '"'), - ('*', '_'), - ('+', ']'), - (',', '.'), - ('.', ','), - ('/', 'ż'), - (':', 'Ł'), - (';', 'ł'), - ('<', 'ś'), - ('=', '['), - ('>', 'ń'), - ('?', 'Ż'), - ('@', '%'), - ('[', 'ó'), - ('\'', 'ą'), - ('\\', ';'), - (']', '('), - ('^', '='), - ('_', 'ć'), - ('`', '<'), - ('{', 'ź'), - ('|', '$'), - ('}', ')'), - ('~', '>'), - ], - "com.apple.keylayout.Portuguese" => &[ - ('"', '`'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '\''), - (':', 'ª'), - (';', 'º'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'ç'), - ('\'', '´'), - (']', '~'), - ('^', '&'), - ('`', '<'), - ('{', 'Ç'), - ('}', '^'), - ('~', '>'), - ], - "com.apple.keylayout.Sami-PC" => &[ - ('"', 'Ŋ'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('Q', 'Á'), - ('W', 'Š'), - ('X', 'Č'), - ('[', 'ø'), - ('\'', 'ŋ'), - ('\\', 'đ'), - (']', 'æ'), - ('^', '&'), - ('`', 'ž'), - ('q', 'á'), - ('w', 'š'), - ('x', 'č'), - ('{', 'Ø'), - ('|', 'Đ'), - ('}', 'Æ'), - ('~', 'Ž'), - ], - "com.apple.keylayout.Serbian-Latin" => &[ - ('"', 'Ć'), - ('&', '\''), - ('(', ')'), - (')', '='), - ('*', '('), - (':', 'Č'), - (';', 'č'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'š'), - ('\'', 'ć'), - ('\\', 'ž'), - (']', 'đ'), - ('^', '&'), - ('`', '<'), - ('{', 'Š'), - ('|', 'Ž'), - ('}', 'Đ'), - ('~', '>'), - ], - "com.apple.keylayout.Slovak" => &[ - ('!', '1'), - ('"', '!'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('+', '%'), - ('/', '\''), - ('0', 'é'), - ('1', '+'), - ('2', 'ľ'), - ('3', 'š'), - ('4', 'č'), - ('5', 'ť'), - ('6', 'ž'), - ('7', 'ý'), - ('8', 'á'), - ('9', 'í'), - (':', '"'), - (';', 'ô'), - ('<', '?'), - ('>', ':'), - ('?', 'ˇ'), - ('@', '2'), - ('[', 'ú'), - ('\'', '§'), - (']', 'ä'), - ('^', '6'), - ('`', 'ň'), - ('{', 'Ú'), - ('}', 'Ä'), - ('~', 'Ň'), - ], - "com.apple.keylayout.Slovak-QWERTY" => &[ - ('!', '1'), - ('"', '!'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('+', '%'), - ('/', '\''), - ('0', 'é'), - ('1', '+'), - ('2', 'ľ'), - ('3', 'š'), - ('4', 'č'), - ('5', 'ť'), - ('6', 'ž'), - ('7', 'ý'), - ('8', 'á'), - ('9', 'í'), - (':', '"'), - (';', 'ô'), - ('<', '?'), - ('>', ':'), - ('?', 'ˇ'), - ('@', '2'), - ('[', 'ú'), - ('\'', '§'), - (']', 'ä'), - ('^', '6'), - ('`', 'ň'), - ('{', 'Ú'), - ('}', 'Ä'), - ('~', 'Ň'), - ], - "com.apple.keylayout.Slovenian" => &[ - ('"', 'Ć'), - ('&', '\''), - ('(', ')'), - (')', '='), - ('*', '('), - (':', 'Č'), - (';', 'č'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'š'), - ('\'', 'ć'), - ('\\', 'ž'), - (']', 'đ'), - ('^', '&'), - ('`', '<'), - ('{', 'Š'), - ('|', 'Ž'), - ('}', 'Đ'), - ('~', '>'), - ], - "com.apple.keylayout.Spanish" => &[ - ('!', '¡'), - ('"', '¨'), - ('.', 'ç'), - ('/', '.'), - (':', 'º'), - (';', '´'), - ('<', '¿'), - ('>', 'Ç'), - ('@', '!'), - ('[', 'ñ'), - ('\'', '`'), - ('\\', '\''), - (']', ';'), - ('^', '/'), - ('`', '<'), - ('{', 'Ñ'), - ('|', '"'), - ('}', ':'), - ('~', '>'), - ], - "com.apple.keylayout.Spanish-ISO" => &[ - ('"', '¨'), - ('#', '·'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('.', 'ç'), - ('/', '.'), - (':', 'º'), - (';', '´'), - ('<', '¿'), - ('>', 'Ç'), - ('@', '"'), - ('[', 'ñ'), - ('\'', '`'), - ('\\', '\''), - (']', ';'), - ('^', '&'), - ('`', '<'), - ('{', 'Ñ'), - ('|', '"'), - ('}', '`'), - ('~', '>'), - ], - "com.apple.keylayout.Swedish" => &[ - ('"', '^'), - ('$', '€'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '¨'), - ('\\', '\''), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '*'), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.Swedish-Pro" => &[ - ('"', '^'), - ('$', '€'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '¨'), - ('\\', '\''), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '*'), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.SwedishSami-PC" => &[ - ('"', 'ˆ'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '¨'), - ('\\', '@'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '*'), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.SwissFrench" => &[ - ('!', '+'), - ('"', '`'), - ('#', '*'), - ('$', 'ç'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('+', '!'), - ('/', '\''), - (':', 'ü'), - (';', 'è'), - ('<', ';'), - ('=', '¨'), - ('>', ':'), - ('@', '"'), - ('[', 'é'), - ('\'', '^'), - ('\\', '$'), - (']', 'à'), - ('^', '&'), - ('`', '<'), - ('{', 'ö'), - ('|', '£'), - ('}', 'ä'), - ('~', '>'), - ], - "com.apple.keylayout.SwissGerman" => &[ - ('!', '+'), - ('"', '`'), - ('#', '*'), - ('$', 'ç'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('+', '!'), - ('/', '\''), - (':', 'è'), - (';', 'ü'), - ('<', ';'), - ('=', '¨'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '^'), - ('\\', '$'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'é'), - ('|', '£'), - ('}', 'à'), - ('~', '>'), - ], - "com.apple.keylayout.Turkish" => &[ - ('"', '-'), - ('#', '"'), - ('$', '\''), - ('%', '('), - ('&', ')'), - ('(', '%'), - (')', ':'), - ('*', '_'), - (',', 'ö'), - ('-', 'ş'), - ('.', 'ç'), - ('/', '.'), - (':', '$'), - ('<', 'Ö'), - ('>', 'Ç'), - ('@', '*'), - ('[', 'ğ'), - ('\'', ','), - ('\\', 'ü'), - (']', 'ı'), - ('^', '/'), - ('_', 'Ş'), - ('`', '<'), - ('{', 'Ğ'), - ('|', 'Ü'), - ('}', 'I'), - ('~', '>'), - ], - "com.apple.keylayout.Turkish-QWERTY-PC" => &[ - ('"', 'I'), - ('#', '^'), - ('$', '+'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('+', ':'), - (',', 'ö'), - ('.', 'ç'), - ('/', '*'), - (':', 'Ş'), - (';', 'ş'), - ('<', 'Ö'), - ('=', '.'), - ('>', 'Ç'), - ('@', '\''), - ('[', 'ğ'), - ('\'', 'ı'), - ('\\', ','), - (']', 'ü'), - ('^', '&'), - ('`', '<'), - ('{', 'Ğ'), - ('|', ';'), - ('}', 'Ü'), - ('~', '>'), - ], - "com.apple.keylayout.Turkish-Standard" => &[ - ('"', 'Ş'), - ('#', '^'), - ('&', '\''), - ('(', ')'), - (')', '='), - ('*', '('), - (',', '.'), - ('.', ','), - (':', 'Ç'), - (';', 'ç'), - ('<', ':'), - ('=', '*'), - ('>', ';'), - ('@', '"'), - ('[', 'ğ'), - ('\'', 'ş'), - ('\\', 'ü'), - (']', 'ı'), - ('^', '&'), - ('`', 'ö'), - ('{', 'Ğ'), - ('|', 'Ü'), - ('}', 'I'), - ('~', 'Ö'), - ], - "com.apple.keylayout.Turkmen" => &[ - ('C', 'Ç'), - ('Q', 'Ä'), - ('V', 'Ý'), - ('X', 'Ü'), - ('[', 'ň'), - ('\\', 'ş'), - (']', 'ö'), - ('^', '№'), - ('`', 'ž'), - ('c', 'ç'), - ('q', 'ä'), - ('v', 'ý'), - ('x', 'ü'), - ('{', 'Ň'), - ('|', 'Ş'), - ('}', 'Ö'), - ('~', 'Ž'), - ], - "com.apple.keylayout.USInternational-PC" => &[('^', 'ˆ'), ('~', '˜')], - "com.apple.keylayout.Welsh" => &[('#', '£')], - - _ => return None, - }; - - Some(HashMap::from_iter(mappings.iter().cloned())) -} - -#[cfg(not(target_os = "macos"))] -pub fn get_key_equivalents(_layout: &str) -> Option> { - None -} diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index ae3f42853ac11af0c1e4510c7ac51bd7379cb657..91fcca8d5cbddf9dd30b867b3b89848cbc86de1e 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -3,7 +3,8 @@ use collections::{BTreeMap, HashMap, IndexMap}; use fs::Fs; use gpui::{ Action, ActionBuildError, App, InvalidKeystrokeError, KEYSTROKE_PARSE_EXPECTED_MESSAGE, - KeyBinding, KeyBindingContextPredicate, KeyBindingMetaIndex, Keystroke, NoAction, SharedString, + KeyBinding, KeyBindingContextPredicate, KeyBindingMetaIndex, KeybindingKeystroke, Keystroke, + NoAction, SharedString, }; use schemars::{JsonSchema, json_schema}; use serde::Deserialize; @@ -211,9 +212,6 @@ impl KeymapFile { } pub fn load(content: &str, cx: &App) -> KeymapFileLoadResult { - let key_equivalents = - crate::key_equivalents::get_key_equivalents(cx.keyboard_layout().id()); - if content.is_empty() { return KeymapFileLoadResult::Success { key_bindings: Vec::new(), @@ -255,12 +253,6 @@ impl KeymapFile { } }; - let key_equivalents = if *use_key_equivalents { - key_equivalents.as_ref() - } else { - None - }; - let mut section_errors = String::new(); if !unrecognized_fields.is_empty() { @@ -278,7 +270,7 @@ impl KeymapFile { keystrokes, action, context_predicate.clone(), - key_equivalents, + *use_key_equivalents, cx, ); match result { @@ -336,7 +328,7 @@ impl KeymapFile { keystrokes: &str, action: &KeymapAction, context: Option>, - key_equivalents: Option<&HashMap>, + use_key_equivalents: bool, cx: &App, ) -> std::result::Result { let (build_result, action_input_string) = match &action.0 { @@ -404,8 +396,9 @@ impl KeymapFile { keystrokes, action, context, - key_equivalents, + use_key_equivalents, action_input_string.map(SharedString::from), + cx.keyboard_mapper().as_ref(), ) { Ok(key_binding) => key_binding, Err(InvalidKeystrokeError { keystroke }) => { @@ -607,6 +600,7 @@ impl KeymapFile { mut operation: KeybindUpdateOperation<'a>, mut keymap_contents: String, tab_size: usize, + keyboard_mapper: &dyn gpui::PlatformKeyboardMapper, ) -> Result { match operation { // if trying to replace a keybinding that is not user-defined, treat it as an add operation @@ -646,7 +640,7 @@ impl KeymapFile { .action_value() .context("Failed to generate target action JSON value")?; let Some((index, keystrokes_str)) = - find_binding(&keymap, &target, &target_action_value) + find_binding(&keymap, &target, &target_action_value, keyboard_mapper) else { anyhow::bail!("Failed to find keybinding to remove"); }; @@ -681,7 +675,7 @@ impl KeymapFile { .context("Failed to generate source action JSON value")?; if let Some((index, keystrokes_str)) = - find_binding(&keymap, &target, &target_action_value) + find_binding(&keymap, &target, &target_action_value, keyboard_mapper) { if target.context == source.context { // if we are only changing the keybinding (common case) @@ -781,7 +775,7 @@ impl KeymapFile { } let use_key_equivalents = from.and_then(|from| { let action_value = from.action_value().context("Failed to serialize action value. `use_key_equivalents` on new keybinding may be incorrect.").log_err()?; - let (index, _) = find_binding(&keymap, &from, &action_value)?; + let (index, _) = find_binding(&keymap, &from, &action_value, keyboard_mapper)?; Some(keymap.0[index].use_key_equivalents) }).unwrap_or(false); if use_key_equivalents { @@ -808,6 +802,7 @@ impl KeymapFile { keymap: &'b KeymapFile, target: &KeybindUpdateTarget<'a>, target_action_value: &Value, + keyboard_mapper: &dyn gpui::PlatformKeyboardMapper, ) -> Option<(usize, &'b str)> { let target_context_parsed = KeyBindingContextPredicate::parse(target.context.unwrap_or("")).ok(); @@ -823,8 +818,15 @@ impl KeymapFile { for (keystrokes_str, action) in bindings { let Ok(keystrokes) = keystrokes_str .split_whitespace() - .map(Keystroke::parse) - .collect::, _>>() + .map(|source| { + let keystroke = Keystroke::parse(source)?; + Ok(KeybindingKeystroke::new_with_mapper( + keystroke, + false, + keyboard_mapper, + )) + }) + .collect::, InvalidKeystrokeError>>() else { continue; }; @@ -832,7 +834,7 @@ impl KeymapFile { || !keystrokes .iter() .zip(target.keystrokes) - .all(|(a, b)| a.should_match(b)) + .all(|(a, b)| a.inner().should_match(b)) { continue; } @@ -847,7 +849,7 @@ impl KeymapFile { } } -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum KeybindUpdateOperation<'a> { Replace { /// Describes the keybind to create @@ -916,7 +918,7 @@ impl<'a> KeybindUpdateOperation<'a> { #[derive(Debug, Clone)] pub struct KeybindUpdateTarget<'a> { pub context: Option<&'a str>, - pub keystrokes: &'a [Keystroke], + pub keystrokes: &'a [KeybindingKeystroke], pub action_name: &'a str, pub action_arguments: Option<&'a str>, } @@ -941,6 +943,9 @@ impl<'a> KeybindUpdateTarget<'a> { fn keystrokes_unparsed(&self) -> String { let mut keystrokes = String::with_capacity(self.keystrokes.len() * 8); for keystroke in self.keystrokes { + // The reason use `keystroke.unparse()` instead of `keystroke.inner.unparse()` + // here is that, we want the user to use `ctrl-shift-4` instead of `ctrl-$` + // by default on Windows. keystrokes.push_str(&keystroke.unparse()); keystrokes.push(' '); } @@ -959,7 +964,7 @@ impl<'a> KeybindUpdateTarget<'a> { } } -#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Debug)] pub enum KeybindSource { User, Vim, @@ -1020,7 +1025,7 @@ impl From for KeyBindingMetaIndex { #[cfg(test)] mod tests { - use gpui::Keystroke; + use gpui::{DummyKeyboardMapper, KeybindingKeystroke, Keystroke}; use unindent::Unindent; use crate::{ @@ -1049,16 +1054,27 @@ mod tests { operation: KeybindUpdateOperation, expected: impl ToString, ) { - let result = KeymapFile::update_keybinding(operation, input.to_string(), 4) - .expect("Update succeeded"); + let result = KeymapFile::update_keybinding( + operation, + input.to_string(), + 4, + &gpui::DummyKeyboardMapper, + ) + .expect("Update succeeded"); pretty_assertions::assert_eq!(expected.to_string(), result); } #[track_caller] - fn parse_keystrokes(keystrokes: &str) -> Vec { + fn parse_keystrokes(keystrokes: &str) -> Vec { keystrokes .split(' ') - .map(|s| Keystroke::parse(s).expect("Keystrokes valid")) + .map(|s| { + KeybindingKeystroke::new_with_mapper( + Keystroke::parse(s).expect("Keystrokes valid"), + false, + &DummyKeyboardMapper, + ) + }) .collect() } diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index b73ab9ae95ae75b3ac9b7a58b663a79235261b5b..a0717333159e508ea42a1b95bd9f2226e6392871 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -1,6 +1,5 @@ mod base_keymap_setting; mod editable_setting_control; -mod key_equivalents; mod keymap_file; mod settings_file; mod settings_json; @@ -14,7 +13,6 @@ use util::asset_str; pub use base_keymap_setting::*; pub use editable_setting_control::*; -pub use key_equivalents::*; pub use keymap_file::{ KeyBindingValidator, KeyBindingValidatorRegistration, KeybindSource, KeybindUpdateOperation, KeybindUpdateTarget, KeymapFile, KeymapFileLoadResult, @@ -32,7 +30,7 @@ pub struct ActiveSettingsProfileName(pub String); impl Global for ActiveSettingsProfileName {} -#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] +#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord, serde::Serialize)] pub struct WorktreeId(usize); impl From for usize { @@ -89,7 +87,10 @@ pub fn default_settings() -> Cow<'static, str> { #[cfg(target_os = "macos")] pub const DEFAULT_KEYMAP_PATH: &str = "keymaps/default-macos.json"; -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] +pub const DEFAULT_KEYMAP_PATH: &str = "keymaps/default-windows.json"; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] pub const DEFAULT_KEYMAP_PATH: &str = "keymaps/default-linux.json"; pub fn default_keymap() -> Cow<'static, str> { diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 9c76725972cfeab2751cb05e968f9cf3e7211418..161e1e768ddd8a111e001198d8aad352169d1cef 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -14,9 +14,9 @@ use gpui::{ Action, AppContext as _, AsyncApp, Axis, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global, IsZero, KeyBindingContextPredicate::{And, Descendant, Equal, Identifier, Not, NotEqual, Or}, - KeyContext, Keystroke, MouseButton, Point, ScrollStrategy, ScrollWheelEvent, Stateful, - StyledText, Subscription, Task, TextStyleRefinement, WeakEntity, actions, anchored, deferred, - div, + KeyContext, KeybindingKeystroke, MouseButton, PlatformKeyboardMapper, Point, ScrollStrategy, + ScrollWheelEvent, Stateful, StyledText, Subscription, Task, TextStyleRefinement, WeakEntity, + actions, anchored, deferred, div, }; use language::{Language, LanguageConfig, ToOffset as _}; use notifications::status_toast::{StatusToast, ToastIcon}; @@ -174,7 +174,7 @@ impl FilterState { #[derive(Debug, Default, PartialEq, Eq, Clone, Hash)] struct ActionMapping { - keystrokes: Vec, + keystrokes: Vec, context: Option, } @@ -236,7 +236,7 @@ struct ConflictState { } type ConflictKeybindMapping = HashMap< - Vec, + Vec, Vec<( Option, Vec, @@ -414,12 +414,14 @@ impl Focusable for KeymapEditor { } } /// Helper function to check if two keystroke sequences match exactly -fn keystrokes_match_exactly(keystrokes1: &[Keystroke], keystrokes2: &[Keystroke]) -> bool { +fn keystrokes_match_exactly( + keystrokes1: &[KeybindingKeystroke], + keystrokes2: &[KeybindingKeystroke], +) -> bool { keystrokes1.len() == keystrokes2.len() - && keystrokes1 - .iter() - .zip(keystrokes2) - .all(|(k1, k2)| k1.key == k2.key && k1.modifiers == k2.modifiers) + && keystrokes1.iter().zip(keystrokes2).all(|(k1, k2)| { + k1.inner().key == k2.inner().key && k1.inner().modifiers == k2.inner().modifiers + }) } impl KeymapEditor { @@ -509,7 +511,7 @@ impl KeymapEditor { self.filter_editor.read(cx).text(cx) } - fn current_keystroke_query(&self, cx: &App) -> Vec { + fn current_keystroke_query(&self, cx: &App) -> Vec { match self.search_mode { SearchMode::KeyStroke { .. } => self.keystroke_editor.read(cx).keystrokes().to_vec(), SearchMode::Normal => Default::default(), @@ -530,7 +532,7 @@ impl KeymapEditor { let keystroke_query = keystroke_query .into_iter() - .map(|keystroke| keystroke.unparse()) + .map(|keystroke| keystroke.inner().unparse()) .collect::>() .join(" "); @@ -554,7 +556,7 @@ impl KeymapEditor { async fn update_matches( this: WeakEntity, action_query: String, - keystroke_query: Vec, + keystroke_query: Vec, cx: &mut AsyncApp, ) -> anyhow::Result<()> { let action_query = command_palette::normalize_action_query(&action_query); @@ -603,13 +605,15 @@ impl KeymapEditor { { let query = &keystroke_query[query_cursor]; let keystroke = &keystrokes[keystroke_cursor]; - let matches = - query.modifiers.is_subset_of(&keystroke.modifiers) - && ((query.key.is_empty() - || query.key == keystroke.key) - && query.key_char.as_ref().is_none_or( - |q_kc| q_kc == &keystroke.key, - )); + let matches = query + .inner() + .modifiers + .is_subset_of(&keystroke.inner().modifiers) + && ((query.inner().key.is_empty() + || query.inner().key == keystroke.inner().key) + && query.inner().key_char.as_ref().is_none_or( + |q_kc| q_kc == &keystroke.inner().key, + )); if matches { found_count += 1; query_cursor += 1; @@ -678,7 +682,7 @@ impl KeymapEditor { .map(KeybindSource::from_meta) .unwrap_or(KeybindSource::Unknown); - let keystroke_text = ui::text_for_keystrokes(key_binding.keystrokes(), cx); + let keystroke_text = ui::text_for_keybinding_keystrokes(key_binding.keystrokes(), cx); let ui_key_binding = ui::KeyBinding::new_from_gpui(key_binding.clone(), cx) .vim_mode(source == KeybindSource::Vim); @@ -1202,8 +1206,11 @@ impl KeymapEditor { .read(cx) .get_scrollbar_offset(Axis::Vertical), )); - cx.spawn(async move |_, _| remove_keybinding(to_remove, &fs, tab_size).await) - .detach_and_notify_err(window, cx); + let keyboard_mapper = cx.keyboard_mapper().clone(); + cx.spawn(async move |_, _| { + remove_keybinding(to_remove, &fs, tab_size, keyboard_mapper.as_ref()).await + }) + .detach_and_notify_err(window, cx); } fn copy_context_to_clipboard( @@ -1422,7 +1429,7 @@ impl ProcessedBinding { .map(|keybind| keybind.get_action_mapping()) } - fn keystrokes(&self) -> Option<&[Keystroke]> { + fn keystrokes(&self) -> Option<&[KeybindingKeystroke]> { self.ui_key_binding() .map(|binding| binding.keystrokes.as_slice()) } @@ -2220,7 +2227,7 @@ impl KeybindingEditorModal { Ok(action_arguments) } - fn validate_keystrokes(&self, cx: &App) -> anyhow::Result> { + fn validate_keystrokes(&self, cx: &App) -> anyhow::Result> { let new_keystrokes = self .keybind_editor .read_with(cx, |editor, _| editor.keystrokes().to_vec()); @@ -2249,12 +2256,10 @@ impl KeybindingEditorModal { let fs = self.fs.clone(); let tab_size = cx.global::().json_tab_size(); - let new_keystrokes = self - .validate_keystrokes(cx) - .map_err(InputError::error)? - .into_iter() - .map(remove_key_char) - .collect::>(); + let mut new_keystrokes = self.validate_keystrokes(cx).map_err(InputError::error)?; + new_keystrokes + .iter_mut() + .for_each(|ks| ks.remove_key_char()); let new_context = self.validate_context(cx).map_err(InputError::error)?; let new_action_args = self @@ -2316,6 +2321,7 @@ impl KeybindingEditorModal { }).unwrap_or(Ok(()))?; let create = self.creating; + let keyboard_mapper = cx.keyboard_mapper().clone(); cx.spawn(async move |this, cx| { let action_name = existing_keybind.action().name; @@ -2328,6 +2334,7 @@ impl KeybindingEditorModal { new_action_args.as_deref(), &fs, tab_size, + keyboard_mapper.as_ref(), ) .await { @@ -2445,14 +2452,6 @@ impl KeybindingEditorModal { } } -fn remove_key_char(Keystroke { modifiers, key, .. }: Keystroke) -> Keystroke { - Keystroke { - modifiers, - key, - ..Default::default() - } -} - impl Render for KeybindingEditorModal { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().colors(); @@ -2992,6 +2991,7 @@ async fn save_keybinding_update( new_args: Option<&str>, fs: &Arc, tab_size: usize, + keyboard_mapper: &dyn PlatformKeyboardMapper, ) -> anyhow::Result<()> { let keymap_contents = settings::KeymapFile::load_keymap_file(fs) .await @@ -3034,9 +3034,13 @@ async fn save_keybinding_update( let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry(); - let updated_keymap_contents = - settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size) - .map_err(|err| anyhow::anyhow!("Could not save updated keybinding: {}", err))?; + let updated_keymap_contents = settings::KeymapFile::update_keybinding( + operation, + keymap_contents, + tab_size, + keyboard_mapper, + ) + .map_err(|err| anyhow::anyhow!("Could not save updated keybinding: {}", err))?; fs.write( paths::keymap_file().as_path(), updated_keymap_contents.as_bytes(), @@ -3057,6 +3061,7 @@ async fn remove_keybinding( existing: ProcessedBinding, fs: &Arc, tab_size: usize, + keyboard_mapper: &dyn PlatformKeyboardMapper, ) -> anyhow::Result<()> { let Some(keystrokes) = existing.keystrokes() else { anyhow::bail!("Cannot remove a keybinding that does not exist"); @@ -3080,9 +3085,13 @@ async fn remove_keybinding( }; let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry(); - let updated_keymap_contents = - settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size) - .context("Failed to update keybinding")?; + let updated_keymap_contents = settings::KeymapFile::update_keybinding( + operation, + keymap_contents, + tab_size, + keyboard_mapper, + ) + .context("Failed to update keybinding")?; fs.write( paths::keymap_file().as_path(), updated_keymap_contents.as_bytes(), @@ -3348,12 +3357,15 @@ impl SerializableItem for KeymapEditor { } mod persistence { - use db::{define_connection, query, sqlez_macros::sql}; + use db::{query, sqlez::domain::Domain, sqlez_macros::sql}; use workspace::WorkspaceDb; - define_connection! { - pub static ref KEYBINDING_EDITORS: KeybindingEditorDb = - &[sql!( + pub struct KeybindingEditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection); + + impl Domain for KeybindingEditorDb { + const NAME: &str = stringify!(KeybindingEditorDb); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE keybinding_editors ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -3362,9 +3374,11 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - )]; + )]; } + db::static_connection!(KEYBINDING_EDITORS, KeybindingEditorDb, [WorkspaceDb]); + impl KeybindingEditorDb { query! { pub async fn save_keybinding_editor( diff --git a/crates/settings_ui/src/ui_components/keystroke_input.rs b/crates/settings_ui/src/ui_components/keystroke_input.rs index 1b8010853ecabc7f4198172a4364f7a1f88fbe67..e6b2ff710555403048c56bb1f249d71971d0e91b 100644 --- a/crates/settings_ui/src/ui_components/keystroke_input.rs +++ b/crates/settings_ui/src/ui_components/keystroke_input.rs @@ -1,6 +1,6 @@ use gpui::{ Animation, AnimationExt, Context, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext, - Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions, + KeybindingKeystroke, Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions, }; use ui::{ ActiveTheme as _, Color, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize, @@ -42,8 +42,8 @@ impl PartialEq for CloseKeystrokeResult { } pub struct KeystrokeInput { - keystrokes: Vec, - placeholder_keystrokes: Option>, + keystrokes: Vec, + placeholder_keystrokes: Option>, outer_focus_handle: FocusHandle, inner_focus_handle: FocusHandle, intercept_subscription: Option, @@ -70,7 +70,7 @@ impl KeystrokeInput { const KEYSTROKE_COUNT_MAX: usize = 3; pub fn new( - placeholder_keystrokes: Option>, + placeholder_keystrokes: Option>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -97,7 +97,7 @@ impl KeystrokeInput { } } - pub fn set_keystrokes(&mut self, keystrokes: Vec, cx: &mut Context) { + pub fn set_keystrokes(&mut self, keystrokes: Vec, cx: &mut Context) { self.keystrokes = keystrokes; self.keystrokes_changed(cx); } @@ -106,7 +106,7 @@ impl KeystrokeInput { self.search = search; } - pub fn keystrokes(&self) -> &[Keystroke] { + pub fn keystrokes(&self) -> &[KeybindingKeystroke] { if let Some(placeholders) = self.placeholder_keystrokes.as_ref() && self.keystrokes.is_empty() { @@ -116,19 +116,19 @@ impl KeystrokeInput { && self .keystrokes .last() - .is_some_and(|last| last.key.is_empty()) + .is_some_and(|last| last.key().is_empty()) { return &self.keystrokes[..self.keystrokes.len() - 1]; } &self.keystrokes } - fn dummy(modifiers: Modifiers) -> Keystroke { - Keystroke { + fn dummy(modifiers: Modifiers) -> KeybindingKeystroke { + KeybindingKeystroke::from_keystroke(Keystroke { modifiers, key: "".to_string(), key_char: None, - } + }) } fn keystrokes_changed(&self, cx: &mut Context) { @@ -254,7 +254,7 @@ impl KeystrokeInput { self.keystrokes_changed(cx); if let Some(last) = self.keystrokes.last_mut() - && last.key.is_empty() + && last.key().is_empty() && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX { if !self.search && !event.modifiers.modified() { @@ -263,13 +263,14 @@ impl KeystrokeInput { } if self.search { if self.previous_modifiers.modified() { - last.modifiers |= event.modifiers; + let modifiers = *last.modifiers() | event.modifiers; + last.set_modifiers(modifiers); } else { self.keystrokes.push(Self::dummy(event.modifiers)); } self.previous_modifiers |= event.modifiers; } else { - last.modifiers = event.modifiers; + last.set_modifiers(event.modifiers); return; } } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX { @@ -297,14 +298,15 @@ impl KeystrokeInput { return; } - let mut keystroke = keystroke.clone(); + let keystroke = KeybindingKeystroke::new_with_mapper( + keystroke.clone(), + false, + cx.keyboard_mapper().as_ref(), + ); if let Some(last) = self.keystrokes.last() - && last.key.is_empty() + && last.key().is_empty() && (!self.search || self.previous_modifiers.modified()) { - let key = keystroke.key.clone(); - keystroke = last.clone(); - keystroke.key = key; self.keystrokes.pop(); } @@ -320,15 +322,19 @@ impl KeystrokeInput { return; } - self.keystrokes.push(keystroke.clone()); + self.keystrokes.push(keystroke); self.keystrokes_changed(cx); + // The reason we use the real modifiers from the window instead of the keystroke's modifiers + // is that for keystrokes like `ctrl-$` the modifiers reported by keystroke is `ctrl` which + // is wrong, it should be `ctrl-shift`. The window's modifiers are always correct. + let real_modifiers = window.modifiers(); if self.search { - self.previous_modifiers = keystroke.modifiers; + self.previous_modifiers = real_modifiers; return; } - if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX && keystroke.modifiers.modified() { - self.keystrokes.push(Self::dummy(keystroke.modifiers)); + if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX && real_modifiers.modified() { + self.keystrokes.push(Self::dummy(real_modifiers)); } } @@ -364,7 +370,7 @@ impl KeystrokeInput { &self.keystrokes }; keystrokes.iter().map(move |keystroke| { - h_flex().children(ui::render_keystroke( + h_flex().children(ui::render_keybinding_keystroke( keystroke, Some(Color::Default), Some(rems(0.875).into()), @@ -706,8 +712,11 @@ mod tests { // Combine current modifiers with keystroke modifiers keystroke.modifiers |= self.current_modifiers; + let real_modifiers = keystroke.modifiers; + keystroke = to_gpui_keystroke(keystroke); self.update_input(|input, window, cx| { + window.set_modifiers(real_modifiers); input.handle_keystroke(&keystroke, window, cx); }); @@ -735,6 +744,7 @@ mod tests { }; self.update_input(|input, window, cx| { + window.set_modifiers(new_modifiers); input.on_modifiers_changed(&event, window, cx); }); @@ -809,9 +819,13 @@ mod tests { /// Verifies that the keystrokes match the expected strings #[track_caller] pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self { - let actual = self - .input - .read_with(&self.cx, |input, _| input.keystrokes.clone()); + let actual: Vec = self.input.read_with(&self.cx, |input, _| { + input + .keystrokes + .iter() + .map(|keystroke| keystroke.inner().clone()) + .collect() + }); Self::expect_keystrokes_equal(&actual, expected); self } @@ -938,8 +952,102 @@ mod tests { } } + /// For GPUI, when you press `ctrl-shift-2`, it produces `ctrl-@` without the shift modifier. + fn to_gpui_keystroke(mut keystroke: Keystroke) -> Keystroke { + if keystroke.modifiers.shift { + match keystroke.key.as_str() { + "`" => { + keystroke.key = "~".into(); + keystroke.modifiers.shift = false; + } + "1" => { + keystroke.key = "!".into(); + keystroke.modifiers.shift = false; + } + "2" => { + keystroke.key = "@".into(); + keystroke.modifiers.shift = false; + } + "3" => { + keystroke.key = "#".into(); + keystroke.modifiers.shift = false; + } + "4" => { + keystroke.key = "$".into(); + keystroke.modifiers.shift = false; + } + "5" => { + keystroke.key = "%".into(); + keystroke.modifiers.shift = false; + } + "6" => { + keystroke.key = "^".into(); + keystroke.modifiers.shift = false; + } + "7" => { + keystroke.key = "&".into(); + keystroke.modifiers.shift = false; + } + "8" => { + keystroke.key = "*".into(); + keystroke.modifiers.shift = false; + } + "9" => { + keystroke.key = "(".into(); + keystroke.modifiers.shift = false; + } + "0" => { + keystroke.key = ")".into(); + keystroke.modifiers.shift = false; + } + "-" => { + keystroke.key = "_".into(); + keystroke.modifiers.shift = false; + } + "=" => { + keystroke.key = "+".into(); + keystroke.modifiers.shift = false; + } + "[" => { + keystroke.key = "{".into(); + keystroke.modifiers.shift = false; + } + "]" => { + keystroke.key = "}".into(); + keystroke.modifiers.shift = false; + } + "\\" => { + keystroke.key = "|".into(); + keystroke.modifiers.shift = false; + } + ";" => { + keystroke.key = ":".into(); + keystroke.modifiers.shift = false; + } + "'" => { + keystroke.key = "\"".into(); + keystroke.modifiers.shift = false; + } + "," => { + keystroke.key = "<".into(); + keystroke.modifiers.shift = false; + } + "." => { + keystroke.key = ">".into(); + keystroke.modifiers.shift = false; + } + "/" => { + keystroke.key = "?".into(); + keystroke.modifiers.shift = false; + } + _ => {} + } + } + keystroke + } + struct KeystrokeUpdateTracker { - initial_keystrokes: Vec, + initial_keystrokes: Vec, _subscription: Subscription, input: Entity, received_keystrokes_updated: bool, @@ -983,8 +1091,8 @@ mod tests { ); } - fn keystrokes_str(ks: &[Keystroke]) -> String { - ks.iter().map(|ks| ks.unparse()).join(" ") + fn keystrokes_str(ks: &[KeybindingKeystroke]) -> String { + ks.iter().map(|ks| ks.inner().unparse()).join(" ") } } } @@ -1041,7 +1149,15 @@ mod tests { .send_events(&["+cmd", "shift-f", "-cmd"]) // In search mode, when completing a modifier-only keystroke with a key, // only the original modifiers are preserved, not the keystroke's modifiers - .expect_keystrokes(&["cmd-f"]); + // + // Update: + // This behavior was changed to preserve all modifiers in search mode, this is now reflected in the expected keystrokes. + // Specifically, considering the sequence: `+cmd +shift -shift 2`, we expect it to produce the same result as `+cmd +shift 2` + // which is `cmd-@`. But in the case of `+cmd +shift -shift 2`, the keystroke we receive is `cmd-2`, which means that + // we need to dynamically map the key from `2` to `@` when the shift modifier is not present, which is not possible. + // Therefore, we now preserve all modifiers in search mode to ensure consistent behavior. + // And also, VSCode seems to preserve all modifiers in search mode as well. + .expect_keystrokes(&["cmd-shift-f"]); } #[gpui::test] @@ -1218,7 +1334,7 @@ mod tests { .await .with_search_mode(true) .send_events(&["+ctrl", "+shift", "-shift", "a", "-ctrl"]) - .expect_keystrokes(&["ctrl-shift-a"]); + .expect_keystrokes(&["ctrl-a"]); } #[gpui::test] @@ -1326,7 +1442,7 @@ mod tests { .await .with_search_mode(true) .send_events(&["+ctrl+alt", "-ctrl", "j"]) - .expect_keystrokes(&["ctrl-alt-j"]); + .expect_keystrokes(&["alt-j"]); } #[gpui::test] @@ -1348,11 +1464,11 @@ mod tests { .send_events(&["+ctrl+alt", "-ctrl", "+shift"]) .expect_keystrokes(&["ctrl-shift-alt-"]) .send_keystroke("j") - .expect_keystrokes(&["ctrl-shift-alt-j"]) + .expect_keystrokes(&["shift-alt-j"]) .send_keystroke("i") - .expect_keystrokes(&["ctrl-shift-alt-j", "shift-alt-i"]) + .expect_keystrokes(&["shift-alt-j", "shift-alt-i"]) .send_events(&["-shift-alt", "+cmd"]) - .expect_keystrokes(&["ctrl-shift-alt-j", "shift-alt-i", "cmd-"]); + .expect_keystrokes(&["shift-alt-j", "shift-alt-i", "cmd-"]); } #[gpui::test] @@ -1385,4 +1501,13 @@ mod tests { .send_events(&["+ctrl", "-ctrl", "+alt", "-alt", "+shift", "-shift"]) .expect_empty(); } + + #[gpui::test] + async fn test_not_search_shifted_keys(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["+ctrl", "+shift", "4", "-all"]) + .expect_keystrokes(&["ctrl-$"]); + } } diff --git a/crates/sqlez/src/domain.rs b/crates/sqlez/src/domain.rs index a83f4e18d6600ce4ac1cc3373b4b235695785522..5744a67da20a8e5091a6071dcc2a0c63d5fd7448 100644 --- a/crates/sqlez/src/domain.rs +++ b/crates/sqlez/src/domain.rs @@ -1,8 +1,12 @@ use crate::connection::Connection; pub trait Domain: 'static { - fn name() -> &'static str; - fn migrations() -> &'static [&'static str]; + const NAME: &str; + const MIGRATIONS: &[&str]; + + fn should_allow_migration_change(_index: usize, _old: &str, _new: &str) -> bool { + false + } } pub trait Migrator: 'static { @@ -17,7 +21,11 @@ impl Migrator for () { impl Migrator for D { fn migrate(connection: &Connection) -> anyhow::Result<()> { - connection.migrate(Self::name(), Self::migrations()) + connection.migrate( + Self::NAME, + Self::MIGRATIONS, + Self::should_allow_migration_change, + ) } } diff --git a/crates/sqlez/src/migrations.rs b/crates/sqlez/src/migrations.rs index 7c59ffe65800128568b13d96cbdf457428d2b218..2429ddeb4127591b56fb74a9c84884d9dc5f378f 100644 --- a/crates/sqlez/src/migrations.rs +++ b/crates/sqlez/src/migrations.rs @@ -34,7 +34,12 @@ impl Connection { /// Note: Unlike everything else in SQLez, migrations are run eagerly, without first /// preparing the SQL statements. This makes it possible to do multi-statement schema /// updates in a single string without running into prepare errors. - pub fn migrate(&self, domain: &'static str, migrations: &[&'static str]) -> Result<()> { + pub fn migrate( + &self, + domain: &'static str, + migrations: &[&'static str], + mut should_allow_migration_change: impl FnMut(usize, &str, &str) -> bool, + ) -> Result<()> { self.with_savepoint("migrating", || { // Setup the migrations table unconditionally self.exec(indoc! {" @@ -65,9 +70,14 @@ impl Connection { &sqlformat::QueryParams::None, Default::default(), ); - if completed_migration == migration { + if completed_migration == migration + || migration.trim().starts_with("-- ALLOW_MIGRATION_CHANGE") + { // Migration already run. Continue continue; + } else if should_allow_migration_change(index, &completed_migration, &migration) + { + continue; } else { anyhow::bail!(formatdoc! {" Migration changed for {domain} at step {index} @@ -108,6 +118,7 @@ mod test { a TEXT, b TEXT )"}], + disallow_migration_change, ) .unwrap(); @@ -136,6 +147,7 @@ mod test { d TEXT )"}, ], + disallow_migration_change, ) .unwrap(); @@ -214,7 +226,11 @@ mod test { // Run the migration verifying that the row got dropped connection - .migrate("test", &["DELETE FROM test_table"]) + .migrate( + "test", + &["DELETE FROM test_table"], + disallow_migration_change, + ) .unwrap(); assert_eq!( connection @@ -232,7 +248,11 @@ mod test { // Run the same migration again and verify that the table was left unchanged connection - .migrate("test", &["DELETE FROM test_table"]) + .migrate( + "test", + &["DELETE FROM test_table"], + disallow_migration_change, + ) .unwrap(); assert_eq!( connection @@ -252,27 +272,28 @@ mod test { .migrate( "test migration", &[ - indoc! {" - CREATE TABLE test ( - col INTEGER - )"}, - indoc! {" - INSERT INTO test (col) VALUES (1)"}, + "CREATE TABLE test (col INTEGER)", + "INSERT INTO test (col) VALUES (1)", ], + disallow_migration_change, ) .unwrap(); + let mut migration_changed = false; + // Create another migration with the same domain but different steps let second_migration_result = connection.migrate( "test migration", &[ - indoc! {" - CREATE TABLE test ( - color INTEGER - )"}, - indoc! {" - INSERT INTO test (color) VALUES (1)"}, + "CREATE TABLE test (color INTEGER )", + "INSERT INTO test (color) VALUES (1)", ], + |_, old, new| { + assert_eq!(old, "CREATE TABLE test (col INTEGER)"); + assert_eq!(new, "CREATE TABLE test (color INTEGER)"); + migration_changed = true; + false + }, ); // Verify new migration returns error when run @@ -284,7 +305,11 @@ mod test { let connection = Connection::open_memory(Some("test_create_alter_drop")); connection - .migrate("first_migration", &["CREATE TABLE table1(a TEXT) STRICT;"]) + .migrate( + "first_migration", + &["CREATE TABLE table1(a TEXT) STRICT;"], + disallow_migration_change, + ) .unwrap(); connection @@ -305,6 +330,7 @@ mod test { ALTER TABLE table2 RENAME TO table1; "}], + disallow_migration_change, ) .unwrap(); @@ -312,4 +338,8 @@ mod test { assert_eq!(res, "test text"); } + + fn disallow_migration_change(_: usize, _: &str, _: &str) -> bool { + false + } } diff --git a/crates/sqlez/src/thread_safe_connection.rs b/crates/sqlez/src/thread_safe_connection.rs index afdc96586efdf4298cb2b1b814e77920af95d53d..58d3afe78fb4d8b211c48c0ae1f9f72af74ad5c1 100644 --- a/crates/sqlez/src/thread_safe_connection.rs +++ b/crates/sqlez/src/thread_safe_connection.rs @@ -278,12 +278,8 @@ mod test { enum TestDomain {} impl Domain for TestDomain { - fn name() -> &'static str { - "test" - } - fn migrations() -> &'static [&'static str] { - &["CREATE TABLE test(col1 TEXT, col2 TEXT) STRICT;"] - } + const NAME: &str = "test"; + const MIGRATIONS: &[&str] = &["CREATE TABLE test(col1 TEXT, col2 TEXT) STRICT;"]; } for _ in 0..100 { @@ -312,12 +308,9 @@ mod test { fn wild_zed_lost_failure() { enum TestWorkspace {} impl Domain for TestWorkspace { - fn name() -> &'static str { - "workspace" - } + const NAME: &str = "workspace"; - fn migrations() -> &'static [&'static str] { - &[" + const MIGRATIONS: &[&str] = &[" CREATE TABLE workspaces( workspace_id INTEGER PRIMARY KEY, dock_visible INTEGER, -- Boolean @@ -336,8 +329,7 @@ mod test { ON DELETE CASCADE ON UPDATE CASCADE ) STRICT; - "] - } + "]; } let builder = diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index bf3ce7b568f9388fee387caa654cbb9072df97b3..241642115a3025aba13fe1aa8788e2c382b24693 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -268,7 +268,7 @@ impl TabMatch { .flatten(); let colored_icon = icon.color(git_status_color.unwrap_or_default()); - let most_sever_diagostic_level = if show_diagnostics == ShowDiagnostics::Off { + let most_severe_diagnostic_level = if show_diagnostics == ShowDiagnostics::Off { None } else { let buffer_store = project.read(cx).buffer_store().read(cx); @@ -287,7 +287,7 @@ impl TabMatch { }; let decorations = - entry_diagnostic_aware_icon_decoration_and_color(most_sever_diagostic_level) + entry_diagnostic_aware_icon_decoration_and_color(most_severe_diagnostic_level) .filter(|(d, _)| { *d != IconDecorationKind::Triangle || show_diagnostics != ShowDiagnostics::Errors @@ -360,7 +360,12 @@ impl TabSwitcherDelegate { .detach(); } - fn update_all_pane_matches(&mut self, query: String, window: &mut Window, cx: &mut App) { + fn update_all_pane_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) { let Some(workspace) = self.workspace.upgrade() else { return; }; @@ -418,7 +423,7 @@ impl TabSwitcherDelegate { let selected_item_id = self.selected_item_id(); self.matches = matches; - self.selected_index = self.compute_selected_index(selected_item_id); + self.selected_index = self.compute_selected_index(selected_item_id, window, cx); } fn update_matches( @@ -477,7 +482,7 @@ impl TabSwitcherDelegate { a_score.cmp(&b_score) }); - self.selected_index = self.compute_selected_index(selected_item_id); + self.selected_index = self.compute_selected_index(selected_item_id, window, cx); } fn selected_item_id(&self) -> Option { @@ -486,7 +491,12 @@ impl TabSwitcherDelegate { .map(|tab_match| tab_match.item.item_id()) } - fn compute_selected_index(&mut self, prev_selected_item_id: Option) -> usize { + fn compute_selected_index( + &mut self, + prev_selected_item_id: Option, + window: &mut Window, + cx: &mut Context>, + ) -> usize { if self.matches.is_empty() { return 0; } @@ -508,8 +518,10 @@ impl TabSwitcherDelegate { return self.matches.len() - 1; } + // This only runs when initially opening the picker + // Index 0 is already active, so don't preselect it for switching. if self.matches.len() > 1 { - // Index 0 is active, so don't preselect it for switching. + self.set_selected_index(1, window, cx); return 1; } diff --git a/crates/task/src/shell_builder.rs b/crates/task/src/shell_builder.rs index de4ddc00f49eded4ba64faa6d94baa1cc6ecf3aa..38a5a970b73c334892bc494f94b9b759b015677e 100644 --- a/crates/task/src/shell_builder.rs +++ b/crates/task/src/shell_builder.rs @@ -1,3 +1,7 @@ +use std::fmt; + +use util::get_system_shell; + use crate::Shell; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] @@ -11,9 +15,22 @@ pub enum ShellKind { Cmd, } +impl fmt::Display for ShellKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ShellKind::Posix => write!(f, "sh"), + ShellKind::Csh => write!(f, "csh"), + ShellKind::Fish => write!(f, "fish"), + ShellKind::Powershell => write!(f, "powershell"), + ShellKind::Nushell => write!(f, "nu"), + ShellKind::Cmd => write!(f, "cmd"), + } + } +} + impl ShellKind { pub fn system() -> Self { - Self::new(&system_shell()) + Self::new(&get_system_shell()) } pub fn new(program: &str) -> Self { @@ -22,12 +39,12 @@ impl ShellKind { #[cfg(not(windows))] let (_, program) = program.rsplit_once('/').unwrap_or(("", program)); if program == "powershell" - || program == "powershell.exe" + || program.ends_with("powershell.exe") || program == "pwsh" - || program == "pwsh.exe" + || program.ends_with("pwsh.exe") { ShellKind::Powershell - } else if program == "cmd" || program == "cmd.exe" { + } else if program == "cmd" || program.ends_with("cmd.exe") { ShellKind::Cmd } else if program == "nu" { ShellKind::Nushell @@ -36,7 +53,7 @@ impl ShellKind { } else if program == "csh" { ShellKind::Csh } else { - // Someother shell detected, the user might install and use a + // Some other shell detected, the user might install and use a // unix-like shell. ShellKind::Posix } @@ -178,18 +195,6 @@ impl ShellKind { } } -fn system_shell() -> String { - if cfg!(target_os = "windows") { - // `alacritty_terminal` uses this as default on Windows. See: - // https://github.com/alacritty/alacritty/blob/0d4ab7bca43213d96ddfe40048fc0f922543c6f8/alacritty_terminal/src/tty/windows/mod.rs#L130 - // We could use `util::get_windows_system_shell()` here, but we are running tasks here, so leave it to `powershell.exe` - // should be okay. - "powershell.exe".to_string() - } else { - std::env::var("SHELL").unwrap_or("/bin/sh".to_string()) - } -} - /// ShellBuilder is used to turn a user-requested task into a /// program that can be executed by the shell. pub struct ShellBuilder { @@ -203,14 +208,15 @@ pub struct ShellBuilder { impl ShellBuilder { /// Create a new ShellBuilder as configured. pub fn new(remote_system_shell: Option<&str>, shell: &Shell) -> Self { - let (program, args) = match shell { - Shell::System => match remote_system_shell { - Some(remote_shell) => (remote_shell.to_string(), Vec::new()), - None => (system_shell(), Vec::new()), + let (program, args) = match remote_system_shell { + Some(program) => (program.to_string(), Vec::new()), + None => match shell { + Shell::System => (get_system_shell(), Vec::new()), + Shell::Program(shell) => (shell.clone(), Vec::new()), + Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()), }, - Shell::Program(shell) => (shell.clone(), Vec::new()), - Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()), }; + let kind = ShellKind::new(&program); Self { program, diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index b38a69f095049c80388d3c0ec2ab397fb4d2bec4..a5e0227533cf0e3ecbc9a8f2c6c55fa1254473e3 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -344,7 +344,6 @@ pub struct TerminalBuilder { impl TerminalBuilder { pub fn new( working_directory: Option, - python_venv_directory: Option, task: Option, shell: Shell, mut env: HashMap, @@ -353,8 +352,9 @@ impl TerminalBuilder { max_scroll_history_lines: Option, is_ssh_terminal: bool, window_id: u64, - completion_tx: Sender>, + completion_tx: Option>>, cx: &App, + activation_script: Option, ) -> Result { // If the parent environment doesn't have a locale set // (As is the case when launched from a .app on MacOS), @@ -428,13 +428,10 @@ impl TerminalBuilder { .clone() .or_else(|| Some(home_dir().to_path_buf())), drain_on_exit: true, - env: env.into_iter().collect(), + env: env.clone().into_iter().collect(), } }; - // Setup Alacritty's env, which modifies the current process's environment - alacritty_terminal::tty::setup_env(); - let default_cursor_style = AlacCursorStyle::from(cursor_shape); let scrolling_history = if task.is_some() { // Tasks like `cargo build --all` may produce a lot of output, ergo allow maximum scrolling. @@ -517,11 +514,19 @@ impl TerminalBuilder { hyperlink_regex_searches: RegexSearches::new(), vi_mode_enabled: false, is_ssh_terminal, - python_venv_directory, last_mouse_move_time: Instant::now(), last_hyperlink_search_position: None, #[cfg(windows)] shell_program, + activation_script, + template: CopyTemplate { + shell, + env, + cursor_shape, + alternate_scroll, + max_scroll_history_lines, + window_id, + }, }; Ok(TerminalBuilder { @@ -683,7 +688,7 @@ pub enum SelectionPhase { pub struct Terminal { pty_tx: Notifier, - completion_tx: Sender>, + completion_tx: Option>>, term: Arc>>, term_config: Config, events: VecDeque, @@ -695,7 +700,6 @@ pub struct Terminal { pub breadcrumb_text: String, pub pty_info: PtyProcessInfo, title_override: Option, - pub python_venv_directory: Option, scroll_px: Pixels, next_link_id: usize, selection_phase: SelectionPhase, @@ -707,6 +711,17 @@ pub struct Terminal { last_hyperlink_search_position: Option>, #[cfg(windows)] shell_program: Option, + template: CopyTemplate, + activation_script: Option, +} + +struct CopyTemplate { + shell: Shell, + env: HashMap, + cursor_shape: CursorShape, + alternate_scroll: AlternateScroll, + max_scroll_history_lines: Option, + window_id: u64, } pub struct TaskState { @@ -1895,7 +1910,9 @@ impl Terminal { } }); - self.completion_tx.try_send(e).ok(); + if let Some(tx) = &self.completion_tx { + tx.try_send(e).ok(); + } let task = match &mut self.task { Some(task) => task, None => { @@ -1950,6 +1967,28 @@ impl Terminal { pub fn vi_mode_enabled(&self) -> bool { self.vi_mode_enabled } + + pub fn clone_builder( + &self, + cx: &App, + cwd: impl FnOnce() -> Option, + ) -> Result { + let working_directory = self.working_directory().or_else(cwd); + TerminalBuilder::new( + working_directory, + None, + self.template.shell.clone(), + self.template.env.clone(), + self.template.cursor_shape, + self.template.alternate_scroll, + self.template.max_scroll_history_lines, + self.is_ssh_terminal, + self.template.window_id, + None, + cx, + self.activation_script.clone(), + ) + } } // Helper function to convert a grid row to a string @@ -2164,7 +2203,6 @@ mod tests { let (completion_tx, completion_rx) = smol::channel::unbounded(); let terminal = cx.new(|cx| { TerminalBuilder::new( - None, None, None, task::Shell::WithArguments { @@ -2178,8 +2216,9 @@ mod tests { None, false, 0, - completion_tx, + Some(completion_tx), cx, + None, ) .unwrap() .subscribe(cx) diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index b93b267f585814c2faafe75a9906021f3ad15932..9759fe8337bc4a870fb6fe0a903edf5c542f5d4f 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -3,13 +3,17 @@ use async_recursion::async_recursion; use collections::HashSet; use futures::{StreamExt as _, stream::FuturesUnordered}; use gpui::{AppContext as _, AsyncWindowContext, Axis, Entity, Task, WeakEntity}; -use project::{Project, terminals::TerminalKind}; +use project::Project; use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use ui::{App, Context, Pixels, Window}; use util::ResultExt as _; -use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; +use db::{ + query, + sqlez::{domain::Domain, statement::Statement, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, +}; use workspace::{ ItemHandle, ItemId, Member, Pane, PaneAxis, PaneGroup, SerializableItem as _, Workspace, WorkspaceDb, WorkspaceId, @@ -242,11 +246,9 @@ async fn deserialize_pane_group( .update(cx, |workspace, cx| default_working_directory(workspace, cx)) .ok() .flatten(); - let kind = TerminalKind::Shell( - working_directory.as_deref().map(Path::to_path_buf), - ); - let terminal = - project.update(cx, |project, cx| project.create_terminal(kind, cx)); + let terminal = project.update(cx, |project, cx| { + project.create_terminal_shell(working_directory, cx) + }); Some(Some(terminal)) } else { Some(None) @@ -375,9 +377,13 @@ impl<'de> Deserialize<'de> for SerializedAxis { } } -define_connection! { - pub static ref TERMINAL_DB: TerminalDb = - &[sql!( +pub struct TerminalDb(ThreadSafeConnection); + +impl Domain for TerminalDb { + const NAME: &str = stringify!(TerminalDb); + + const MIGRATIONS: &[&str] = &[ + sql!( CREATE TABLE terminals ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -414,6 +420,8 @@ define_connection! { ]; } +db::static_connection!(TERMINAL_DB, TerminalDb, [WorkspaceDb]); + impl TerminalDb { query! { pub async fn update_workspace_id( diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index fe3301fb89095a7cf9f1a841395b47e9caf1475b..56715b604eeffe0b42302adcdf0d6fdd93919879 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1403,7 +1403,7 @@ impl InputHandler for TerminalInputHandler { window.invalidate_character_coordinates(); let project = this.project().read(cx); let telemetry = project.client().telemetry().clone(); - telemetry.log_edit_event("terminal", project.is_via_ssh()); + telemetry.log_edit_event("terminal", project.is_via_remote_server()); }) .ok(); } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 6b17911487261a254cd5e7a5e9256358c0b2e696..848737aeb24ef52a6819e57882ab022edef94e25 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -16,7 +16,7 @@ use gpui::{ Task, WeakEntity, Window, actions, }; use itertools::Itertools; -use project::{Fs, Project, ProjectEntryId, terminals::TerminalKind}; +use project::{Fs, Project, ProjectEntryId}; use search::{BufferSearchBar, buffer_search::DivRegistrar}; use settings::Settings; use task::{RevealStrategy, RevealTarget, ShellBuilder, SpawnInTerminal, TaskId}; @@ -376,14 +376,19 @@ impl TerminalPanel { } self.serialize(cx); } - pane::Event::Split(direction) => { - let Some(new_pane) = self.new_pane_with_cloned_active_terminal(window, cx) else { - return; - }; + &pane::Event::Split(direction) => { + let fut = self.new_pane_with_cloned_active_terminal(window, cx); let pane = pane.clone(); - let direction = *direction; - self.center.split(&pane, &new_pane, direction).log_err(); - window.focus(&new_pane.focus_handle(cx)); + cx.spawn_in(window, async move |panel, cx| { + let Some(new_pane) = fut.await else { + return; + }; + _ = panel.update_in(cx, |panel, window, cx| { + panel.center.split(&pane, &new_pane, direction).log_err(); + window.focus(&new_pane.focus_handle(cx)); + }); + }) + .detach(); } pane::Event::Focus => { self.active_pane = pane.clone(); @@ -400,57 +405,62 @@ impl TerminalPanel { &mut self, window: &mut Window, cx: &mut Context, - ) -> Option> { - let workspace = self.workspace.upgrade()?; + ) -> Task>> { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; let workspace = workspace.read(cx); let database_id = workspace.database_id(); let weak_workspace = self.workspace.clone(); let project = workspace.project().clone(); - let (working_directory, python_venv_directory) = self - .active_pane + let active_pane = &self.active_pane; + let terminal_view = active_pane .read(cx) .active_item() - .and_then(|item| item.downcast::()) - .map(|terminal_view| { - let terminal = terminal_view.read(cx).terminal().read(cx); - ( - terminal - .working_directory() - .or_else(|| default_working_directory(workspace, cx)), - terminal.python_venv_directory.clone(), - ) - }) - .unwrap_or((None, None)); - let kind = TerminalKind::Shell(working_directory); - let terminal = project - .update(cx, |project, cx| { - project.create_terminal_with_venv(kind, python_venv_directory, cx) - }) - .ok()?; - - let terminal_view = Box::new(cx.new(|cx| { - TerminalView::new( - terminal.clone(), - weak_workspace.clone(), - database_id, - project.downgrade(), - window, - cx, - ) - })); - let pane = new_terminal_pane( - weak_workspace, - project, - self.active_pane.read(cx).is_zoomed(), - window, - cx, - ); - self.apply_tab_bar_buttons(&pane, cx); - pane.update(cx, |pane, cx| { - pane.add_item(terminal_view, true, true, None, window, cx); + .and_then(|item| item.downcast::()); + let working_directory = terminal_view.as_ref().and_then(|terminal_view| { + let terminal = terminal_view.read(cx).terminal().read(cx); + terminal + .working_directory() + .or_else(|| default_working_directory(workspace, cx)) }); + let is_zoomed = active_pane.read(cx).is_zoomed(); + cx.spawn_in(window, async move |panel, cx| { + let terminal = project + .update(cx, |project, cx| match terminal_view { + Some(view) => Task::ready(project.clone_terminal( + &view.read(cx).terminal.clone(), + cx, + || working_directory, + )), + None => project.create_terminal_shell(working_directory, cx), + }) + .ok()? + .await + .ok()?; - Some(pane) + panel + .update_in(cx, move |terminal_panel, window, cx| { + let terminal_view = Box::new(cx.new(|cx| { + TerminalView::new( + terminal.clone(), + weak_workspace.clone(), + database_id, + project.downgrade(), + window, + cx, + ) + })); + let pane = new_terminal_pane(weak_workspace, project, is_zoomed, window, cx); + terminal_panel.apply_tab_bar_buttons(&pane, cx); + pane.update(cx, |pane, cx| { + pane.add_item(terminal_view, true, true, None, window, cx); + }); + Some(pane) + }) + .ok() + .flatten() + }) } pub fn open_terminal( @@ -465,8 +475,8 @@ impl TerminalPanel { terminal_panel .update(cx, |panel, cx| { - panel.add_terminal( - TerminalKind::Shell(Some(action.working_directory.clone())), + panel.add_terminal_shell( + Some(action.working_directory.clone()), RevealStrategy::Always, window, cx, @@ -475,23 +485,34 @@ impl TerminalPanel { .detach_and_log_err(cx); } - fn spawn_task( + pub fn spawn_task( &mut self, task: &SpawnInTerminal, window: &mut Window, cx: &mut Context, ) -> Task>> { - let Ok((ssh_client, false)) = self.workspace.update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - ( - project.ssh_client().and_then(|it| it.read(cx).ssh_info()), - project.is_via_collab(), - ) - }) else { - return Task::ready(Err(anyhow!("Project is not local"))); + let remote_client = self + .workspace + .update(cx, |workspace, cx| { + let project = workspace.project().read(cx); + if project.is_via_collab() { + Err(anyhow!("cannot spawn tasks as a guest")) + } else { + Ok(project.remote_client()) + } + }) + .flatten(); + + let remote_client = match remote_client { + Ok(remote_client) => remote_client, + Err(e) => return Task::ready(Err(e)), }; - let builder = ShellBuilder::new(ssh_client.as_ref().map(|info| &*info.shell), &task.shell); + let remote_shell = remote_client + .as_ref() + .and_then(|remote_client| remote_client.read(cx).shell()); + + let builder = ShellBuilder::new(remote_shell.as_deref(), &task.shell); let command_label = builder.command_label(&task.command_label); let (command, args) = builder.build(task.command.clone(), &task.args); @@ -560,15 +581,16 @@ impl TerminalPanel { ) -> Task>> { let reveal = spawn_task.reveal; let reveal_target = spawn_task.reveal_target; - let kind = TerminalKind::Task(spawn_task); match reveal_target { RevealTarget::Center => self .workspace .update(cx, |workspace, cx| { - Self::add_center_terminal(workspace, kind, window, cx) + Self::add_center_terminal(workspace, window, cx, |project, cx| { + project.create_terminal_task(spawn_task, cx) + }) }) .unwrap_or_else(|e| Task::ready(Err(e))), - RevealTarget::Dock => self.add_terminal(kind, reveal, window, cx), + RevealTarget::Dock => self.add_terminal_task(spawn_task, reveal, window, cx), } } @@ -583,11 +605,14 @@ impl TerminalPanel { return; }; - let kind = TerminalKind::Shell(default_working_directory(workspace, cx)); - terminal_panel .update(cx, |this, cx| { - this.add_terminal(kind, RevealStrategy::Always, window, cx) + this.add_terminal_shell( + default_working_directory(workspace, cx), + RevealStrategy::Always, + window, + cx, + ) }) .detach_and_log_err(cx); } @@ -649,9 +674,13 @@ impl TerminalPanel { pub fn add_center_terminal( workspace: &mut Workspace, - kind: TerminalKind, window: &mut Window, cx: &mut Context, + create_terminal: impl FnOnce( + &mut Project, + &mut Context, + ) -> Task>> + + 'static, ) -> Task>> { if !is_enabled_in_workspace(workspace, cx) { return Task::ready(Err(anyhow!( @@ -660,9 +689,7 @@ impl TerminalPanel { } let project = workspace.project().downgrade(); cx.spawn_in(window, async move |workspace, cx| { - let terminal = project - .update(cx, |project, cx| project.create_terminal(kind, cx))? - .await?; + let terminal = project.update(cx, create_terminal)?.await?; workspace.update_in(cx, |workspace, window, cx| { let terminal_view = cx.new(|cx| { @@ -681,9 +708,9 @@ impl TerminalPanel { }) } - pub fn add_terminal( + pub fn add_terminal_task( &mut self, - kind: TerminalKind, + task: SpawnInTerminal, reveal_strategy: RevealStrategy, window: &mut Window, cx: &mut Context, @@ -699,7 +726,66 @@ impl TerminalPanel { })?; let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?; let terminal = project - .update(cx, |project, cx| project.create_terminal(kind, cx))? + .update(cx, |project, cx| project.create_terminal_task(task, cx))? + .await?; + let result = workspace.update_in(cx, |workspace, window, cx| { + let terminal_view = Box::new(cx.new(|cx| { + TerminalView::new( + terminal.clone(), + workspace.weak_handle(), + workspace.database_id(), + workspace.project().downgrade(), + window, + cx, + ) + })); + + match reveal_strategy { + RevealStrategy::Always => { + workspace.focus_panel::(window, cx); + } + RevealStrategy::NoFocus => { + workspace.open_panel::(window, cx); + } + RevealStrategy::Never => {} + } + + pane.update(cx, |pane, cx| { + let focus = pane.has_focus(window, cx) + || matches!(reveal_strategy, RevealStrategy::Always); + pane.add_item(terminal_view, true, focus, None, window, cx); + }); + + Ok(terminal.downgrade()) + })?; + terminal_panel.update(cx, |terminal_panel, cx| { + terminal_panel.pending_terminals_to_add = + terminal_panel.pending_terminals_to_add.saturating_sub(1); + terminal_panel.serialize(cx) + })?; + result + }) + } + + pub fn add_terminal_shell( + &mut self, + cwd: Option, + reveal_strategy: RevealStrategy, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let workspace = self.workspace.clone(); + cx.spawn_in(window, async move |terminal_panel, cx| { + if workspace.update(cx, |workspace, cx| !is_enabled_in_workspace(workspace, cx))? { + anyhow::bail!("terminal not yet supported for remote projects"); + } + let pane = terminal_panel.update(cx, |terminal_panel, _| { + terminal_panel.pending_terminals_to_add += 1; + terminal_panel.active_pane.clone() + })?; + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?; + let terminal = project + .update(cx, |project, cx| project.create_terminal_shell(cwd, cx))? .await?; let result = workspace.update_in(cx, |workspace, window, cx| { let terminal_view = Box::new(cx.new(|cx| { @@ -808,7 +894,7 @@ impl TerminalPanel { })??; let new_terminal = project .update(cx, |project, cx| { - project.create_terminal(TerminalKind::Task(spawn_task), cx) + project.create_terminal_task(spawn_task, cx) })? .await?; terminal_to_replace.update_in(cx, |terminal_to_replace, window, cx| { @@ -1237,18 +1323,29 @@ impl Render for TerminalPanel { let panes = terminal_panel.center.panes(); if let Some(&pane) = panes.get(action.0) { window.focus(&pane.read(cx).focus_handle(cx)); - } else if let Some(new_pane) = - terminal_panel.new_pane_with_cloned_active_terminal(window, cx) - { - terminal_panel - .center - .split( - &terminal_panel.active_pane, - &new_pane, - SplitDirection::Right, - ) - .log_err(); - window.focus(&new_pane.focus_handle(cx)); + } else { + let future = + terminal_panel.new_pane_with_cloned_active_terminal(window, cx); + cx.spawn_in(window, async move |terminal_panel, cx| { + if let Some(new_pane) = future.await { + _ = terminal_panel.update_in( + cx, + |terminal_panel, window, cx| { + terminal_panel + .center + .split( + &terminal_panel.active_pane, + &new_pane, + SplitDirection::Right, + ) + .log_err(); + let new_pane = new_pane.read(cx); + window.focus(&new_pane.focus_handle(cx)); + }, + ); + } + }) + .detach(); } }), ) @@ -1384,13 +1481,14 @@ impl Panel for TerminalPanel { return; } cx.defer_in(window, |this, window, cx| { - let Ok(kind) = this.workspace.update(cx, |workspace, cx| { - TerminalKind::Shell(default_working_directory(workspace, cx)) - }) else { + let Ok(kind) = this + .workspace + .update(cx, |workspace, cx| default_working_directory(workspace, cx)) + else { return; }; - this.add_terminal(kind, RevealStrategy::Always, window, cx) + this.add_terminal_shell(kind, RevealStrategy::Always, window, cx) .detach_and_log_err(cx) }) } diff --git a/crates/terminal_view/src/terminal_path_like_target.rs b/crates/terminal_view/src/terminal_path_like_target.rs index e20df7f0010480d782eb16375ca3480d4f390742..226a8f4c3d9bca398df778fa2043fb5872383b56 100644 --- a/crates/terminal_view/src/terminal_path_like_target.rs +++ b/crates/terminal_view/src/terminal_path_like_target.rs @@ -364,7 +364,7 @@ fn possibly_open_target( mod tests { use super::*; use gpui::TestAppContext; - use project::{Project, terminals::TerminalKind}; + use project::Project; use serde_json::json; use std::path::{Path, PathBuf}; use terminal::{HoveredWord, alacritty_terminal::index::Point as AlacPoint}; @@ -405,8 +405,8 @@ mod tests { app_cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let terminal = project - .update(cx, |project, cx| { - project.create_terminal(TerminalKind::Shell(None), cx) + .update(cx, |project: &mut Project, cx| { + project.create_terminal_shell(None, cx) }) .await .expect("Failed to create a terminal"); diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 9aa855acb7aa18a0431fcfc07e7a32932162e4f2..9e479464af224c4d85119d6ca2e0b25c360f9c3d 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -15,7 +15,7 @@ use gpui::{ deferred, div, }; use persistence::TERMINAL_DB; -use project::{Project, search::SearchQuery, terminals::TerminalKind}; +use project::{Project, search::SearchQuery}; use schemars::JsonSchema; use task::TaskId; use terminal::{ @@ -204,12 +204,9 @@ impl TerminalView { cx: &mut Context, ) { let working_directory = default_working_directory(workspace, cx); - TerminalPanel::add_center_terminal( - workspace, - TerminalKind::Shell(working_directory), - window, - cx, - ) + TerminalPanel::add_center_terminal(workspace, window, cx, |project, cx| { + project.create_terminal_shell(working_directory, cx) + }) .detach_and_log_err(cx); } @@ -1333,16 +1330,10 @@ impl Item for TerminalView { let terminal = self .project .update(cx, |project, cx| { - let terminal = self.terminal().read(cx); - let working_directory = terminal - .working_directory() - .or_else(|| Some(project.active_project_directory(cx)?.to_path_buf())); - let python_venv_directory = terminal.python_venv_directory.clone(); - project.create_terminal_with_venv( - TerminalKind::Shell(working_directory), - python_venv_directory, - cx, - ) + let cwd = project + .active_project_directory(cx) + .map(|it| it.to_path_buf()); + project.clone_terminal(self.terminal(), cx, || cwd) }) .ok()? .log_err()?; @@ -1498,9 +1489,7 @@ impl SerializableItem for TerminalView { .flatten(); let terminal = project - .update(cx, |project, cx| { - project.create_terminal(TerminalKind::Shell(cwd), cx) - })? + .update(cx, |project, cx| project.create_terminal_shell(cwd, cx))? .await?; cx.update(|window, cx| { cx.new(|cx| { diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index c667edb509b6e7c5f906f038f19a1e50b5c65032..78f22faa13900f05fbd0cea9877858857aac626e 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -337,7 +337,7 @@ impl TitleBar { let room = room.read(cx); let project = self.project.read(cx); - let is_local = project.is_local() || project.is_via_ssh(); + let is_local = project.is_local() || project.is_via_remote_server(); let is_shared = is_local && project.is_shared(); let is_muted = room.is_muted(); let muted_by_user = room.muted_by_user(); diff --git a/crates/title_bar/src/onboarding_banner.rs b/crates/title_bar/src/onboarding_banner.rs index ed43c5277a51d660738f2b0b3efee77ccbafd381..1c2894249000861f6de14f4960205e5deffab47b 100644 --- a/crates/title_bar/src/onboarding_banner.rs +++ b/crates/title_bar/src/onboarding_banner.rs @@ -119,7 +119,7 @@ impl Render for OnboardingBanner { h_flex() .h_full() .gap_1() - .child(Icon::new(self.details.icon_name).size(IconSize::Small)) + .child(Icon::new(self.details.icon_name).size(IconSize::XSmall)) .child( h_flex() .gap_0p5() diff --git a/crates/title_bar/src/platform_title_bar.rs b/crates/title_bar/src/platform_title_bar.rs index ef6ef93eed9ecd648bd5689eb14cb5cd5481463e..bc1057a4d4bd98a21031cb93d71d9f654d090b2c 100644 --- a/crates/title_bar/src/platform_title_bar.rs +++ b/crates/title_bar/src/platform_title_bar.rs @@ -1,28 +1,35 @@ use gpui::{ - AnyElement, Context, Decorations, Hsla, InteractiveElement, IntoElement, MouseButton, + AnyElement, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, px, }; use smallvec::SmallVec; use std::mem; use ui::prelude::*; -use crate::platforms::{platform_linux, platform_mac, platform_windows}; +use crate::{ + platforms::{platform_linux, platform_mac, platform_windows}, + system_window_tabs::SystemWindowTabs, +}; pub struct PlatformTitleBar { id: ElementId, platform_style: PlatformStyle, children: SmallVec<[AnyElement; 2]>, should_move: bool, + system_window_tabs: Entity, } impl PlatformTitleBar { - pub fn new(id: impl Into) -> Self { + pub fn new(id: impl Into, cx: &mut Context) -> Self { let platform_style = PlatformStyle::platform(); + let system_window_tabs = cx.new(|_cx| SystemWindowTabs::new()); + Self { id: id.into(), platform_style, children: SmallVec::new(), should_move: false, + system_window_tabs, } } @@ -66,7 +73,7 @@ impl Render for PlatformTitleBar { let close_action = Box::new(workspace::CloseWindow); let children = mem::take(&mut self.children); - h_flex() + let title_bar = h_flex() .window_control_area(WindowControlArea::Drag) .w_full() .h(height) @@ -162,7 +169,12 @@ impl Render for PlatformTitleBar { title_bar.child(platform_windows::WindowsWindowControls::new(height)) } } - }) + }); + + v_flex() + .w_full() + .child(title_bar) + .child(self.system_window_tabs.clone().into_any_element()) } } diff --git a/crates/title_bar/src/system_window_tabs.rs b/crates/title_bar/src/system_window_tabs.rs new file mode 100644 index 0000000000000000000000000000000000000000..cc50fbc2b99b56c2d8dab95e0c56deb33da2bb4b --- /dev/null +++ b/crates/title_bar/src/system_window_tabs.rs @@ -0,0 +1,477 @@ +use settings::Settings; + +use gpui::{ + AnyWindowHandle, Context, Hsla, InteractiveElement, MouseButton, ParentElement, ScrollHandle, + Styled, SystemWindowTab, SystemWindowTabController, Window, WindowId, actions, canvas, div, +}; + +use theme::ThemeSettings; +use ui::{ + Color, ContextMenu, DynamicSpacing, IconButton, IconButtonShape, IconName, IconSize, Label, + LabelSize, Tab, h_flex, prelude::*, right_click_menu, +}; +use workspace::{ + CloseWindow, ItemSettings, Workspace, + item::{ClosePosition, ShowCloseButton}, +}; + +actions!( + window, + [ + ShowNextWindowTab, + ShowPreviousWindowTab, + MergeAllWindows, + MoveTabToNewWindow + ] +); + +#[derive(Clone)] +pub struct DraggedWindowTab { + pub id: WindowId, + pub ix: usize, + pub handle: AnyWindowHandle, + pub title: String, + pub width: Pixels, + pub is_active: bool, + pub active_background_color: Hsla, + pub inactive_background_color: Hsla, +} + +pub struct SystemWindowTabs { + tab_bar_scroll_handle: ScrollHandle, + measured_tab_width: Pixels, + last_dragged_tab: Option, +} + +impl SystemWindowTabs { + pub fn new() -> Self { + Self { + tab_bar_scroll_handle: ScrollHandle::new(), + measured_tab_width: px(0.), + last_dragged_tab: None, + } + } + + pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _, _| { + workspace.register_action_renderer(|div, _, window, cx| { + let window_id = window.window_handle().window_id(); + let controller = cx.global::(); + + let tab_groups = controller.tab_groups(); + let tabs = controller.tabs(window_id); + let Some(tabs) = tabs else { + return div; + }; + + div.when(tabs.len() > 1, |div| { + div.on_action(move |_: &ShowNextWindowTab, window, cx| { + SystemWindowTabController::select_next_tab( + cx, + window.window_handle().window_id(), + ); + }) + .on_action(move |_: &ShowPreviousWindowTab, window, cx| { + SystemWindowTabController::select_previous_tab( + cx, + window.window_handle().window_id(), + ); + }) + .on_action(move |_: &MoveTabToNewWindow, window, cx| { + SystemWindowTabController::move_tab_to_new_window( + cx, + window.window_handle().window_id(), + ); + window.move_tab_to_new_window(); + }) + }) + .when(tab_groups.len() > 1, |div| { + div.on_action(move |_: &MergeAllWindows, window, cx| { + SystemWindowTabController::merge_all_windows( + cx, + window.window_handle().window_id(), + ); + window.merge_all_windows(); + }) + }) + }); + }) + .detach(); + } + + fn render_tab( + &self, + ix: usize, + item: SystemWindowTab, + tabs: Vec, + active_background_color: Hsla, + inactive_background_color: Hsla, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let entity = cx.entity(); + let settings = ItemSettings::get_global(cx); + let close_side = &settings.close_position; + let show_close_button = &settings.show_close_button; + + let rem_size = window.rem_size(); + let width = self.measured_tab_width.max(rem_size * 10); + let is_active = window.window_handle().window_id() == item.id; + let title = item.title.to_string(); + + let label = Label::new(&title) + .size(LabelSize::Small) + .truncate() + .color(if is_active { + Color::Default + } else { + Color::Muted + }); + + let tab = h_flex() + .id(ix) + .group("tab") + .w_full() + .overflow_hidden() + .h(Tab::content_height(cx)) + .relative() + .px(DynamicSpacing::Base16.px(cx)) + .justify_center() + .border_l_1() + .border_color(cx.theme().colors().border) + .cursor_pointer() + .on_drag( + DraggedWindowTab { + id: item.id, + ix, + handle: item.handle, + title: item.title.to_string(), + width, + is_active, + active_background_color, + inactive_background_color, + }, + move |tab, _, _, cx| { + entity.update(cx, |this, _cx| { + this.last_dragged_tab = Some(tab.clone()); + }); + cx.new(|_| tab.clone()) + }, + ) + .drag_over::({ + let tab_ix = ix; + move |element, dragged_tab: &DraggedWindowTab, _, cx| { + let mut styled_tab = element + .bg(cx.theme().colors().drop_target_background) + .border_color(cx.theme().colors().drop_target_border) + .border_0(); + + if tab_ix < dragged_tab.ix { + styled_tab = styled_tab.border_l_2(); + } else if tab_ix > dragged_tab.ix { + styled_tab = styled_tab.border_r_2(); + } + + styled_tab + } + }) + .on_drop({ + let tab_ix = ix; + cx.listener(move |this, dragged_tab: &DraggedWindowTab, _window, cx| { + this.last_dragged_tab = None; + Self::handle_tab_drop(dragged_tab, tab_ix, cx); + }) + }) + .on_click(move |_, _, cx| { + let _ = item.handle.update(cx, |_, window, _| { + window.activate_window(); + }); + }) + .child(label) + .map(|this| match show_close_button { + ShowCloseButton::Hidden => this, + _ => this.child( + div() + .absolute() + .top_2() + .w_4() + .h_4() + .map(|this| match close_side { + ClosePosition::Left => this.left_1(), + ClosePosition::Right => this.right_1(), + }) + .child( + IconButton::new("close", IconName::Close) + .shape(IconButtonShape::Square) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .on_click({ + move |_, window, cx| { + if item.handle.window_id() + == window.window_handle().window_id() + { + window.dispatch_action(Box::new(CloseWindow), cx); + } else { + let _ = item.handle.update(cx, |_, window, cx| { + window.dispatch_action(Box::new(CloseWindow), cx); + }); + } + } + }) + .map(|this| match show_close_button { + ShowCloseButton::Hover => this.visible_on_hover("tab"), + _ => this, + }), + ), + ), + }) + .into_any(); + + let menu = right_click_menu(ix) + .trigger(|_, _, _| tab) + .menu(move |window, cx| { + let focus_handle = cx.focus_handle(); + let tabs = tabs.clone(); + let other_tabs = tabs.clone(); + let move_tabs = tabs.clone(); + let merge_tabs = tabs.clone(); + + ContextMenu::build(window, cx, move |mut menu, _window_, _cx| { + menu = menu.entry("Close Tab", None, move |window, cx| { + Self::handle_right_click_action( + cx, + window, + &tabs, + |tab| tab.id == item.id, + |window, cx| { + window.dispatch_action(Box::new(CloseWindow), cx); + }, + ); + }); + + menu = menu.entry("Close Other Tabs", None, move |window, cx| { + Self::handle_right_click_action( + cx, + window, + &other_tabs, + |tab| tab.id != item.id, + |window, cx| { + window.dispatch_action(Box::new(CloseWindow), cx); + }, + ); + }); + + menu = menu.entry("Move Tab to New Window", None, move |window, cx| { + Self::handle_right_click_action( + cx, + window, + &move_tabs, + |tab| tab.id == item.id, + |window, cx| { + SystemWindowTabController::move_tab_to_new_window( + cx, + window.window_handle().window_id(), + ); + window.move_tab_to_new_window(); + }, + ); + }); + + menu = menu.entry("Show All Tabs", None, move |window, cx| { + Self::handle_right_click_action( + cx, + window, + &merge_tabs, + |tab| tab.id == item.id, + |window, _cx| { + window.toggle_window_tab_overview(); + }, + ); + }); + + menu.context(focus_handle) + }) + }); + + div() + .flex_1() + .min_w(rem_size * 10) + .when(is_active, |this| this.bg(active_background_color)) + .border_t_1() + .border_color(if is_active { + active_background_color + } else { + cx.theme().colors().border + }) + .child(menu) + } + + fn handle_tab_drop(dragged_tab: &DraggedWindowTab, ix: usize, cx: &mut Context) { + SystemWindowTabController::update_tab_position(cx, dragged_tab.id, ix); + } + + fn handle_right_click_action( + cx: &mut App, + window: &mut Window, + tabs: &Vec, + predicate: P, + mut action: F, + ) where + P: Fn(&SystemWindowTab) -> bool, + F: FnMut(&mut Window, &mut App), + { + for tab in tabs { + if predicate(tab) { + if tab.id == window.window_handle().window_id() { + action(window, cx); + } else { + let _ = tab.handle.update(cx, |_view, window, cx| { + action(window, cx); + }); + } + } + } + } +} + +impl Render for SystemWindowTabs { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let active_background_color = cx.theme().colors().title_bar_background; + let inactive_background_color = cx.theme().colors().tab_bar_background; + let entity = cx.entity(); + + let controller = cx.global::(); + let visible = controller.is_visible(); + let current_window_tab = vec![SystemWindowTab::new( + SharedString::from(window.window_title()), + window.window_handle(), + )]; + let tabs = controller + .tabs(window.window_handle().window_id()) + .unwrap_or(¤t_window_tab) + .clone(); + + let tab_items = tabs + .iter() + .enumerate() + .map(|(ix, item)| { + self.render_tab( + ix, + item.clone(), + tabs.clone(), + active_background_color, + inactive_background_color, + window, + cx, + ) + }) + .collect::>(); + + let number_of_tabs = tab_items.len().max(1); + if !window.tab_bar_visible() && !visible { + return h_flex().into_any_element(); + } + + h_flex() + .w_full() + .h(Tab::container_height(cx)) + .bg(inactive_background_color) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _event, window, cx| { + if let Some(tab) = this.last_dragged_tab.take() { + SystemWindowTabController::move_tab_to_new_window(cx, tab.id); + if tab.id == window.window_handle().window_id() { + window.move_tab_to_new_window(); + } else { + let _ = tab.handle.update(cx, |_, window, _cx| { + window.move_tab_to_new_window(); + }); + } + } + }), + ) + .child( + h_flex() + .id("window tabs") + .w_full() + .h(Tab::container_height(cx)) + .bg(inactive_background_color) + .overflow_x_scroll() + .track_scroll(&self.tab_bar_scroll_handle) + .children(tab_items) + .child( + canvas( + |_, _, _| (), + move |bounds, _, _, cx| { + let entity = entity.clone(); + entity.update(cx, |this, cx| { + let width = bounds.size.width / number_of_tabs as f32; + if width != this.measured_tab_width { + this.measured_tab_width = width; + cx.notify(); + } + }); + }, + ) + .absolute() + .size_full(), + ), + ) + .child( + h_flex() + .h_full() + .px(DynamicSpacing::Base06.rems(cx)) + .border_t_1() + .border_l_1() + .border_color(cx.theme().colors().border) + .child( + IconButton::new("plus", IconName::Plus) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click(|_event, window, cx| { + window.dispatch_action( + Box::new(zed_actions::OpenRecent { + create_new_window: true, + }), + cx, + ); + }), + ), + ) + .into_any_element() + } +} + +impl Render for DraggedWindowTab { + fn render( + &mut self, + _window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + let ui_font = ThemeSettings::get_global(cx).ui_font.clone(); + let label = Label::new(self.title.clone()) + .size(LabelSize::Small) + .truncate() + .color(if self.is_active { + Color::Default + } else { + Color::Muted + }); + + h_flex() + .h(Tab::container_height(cx)) + .w(self.width) + .px(DynamicSpacing::Base16.px(cx)) + .justify_center() + .bg(if self.is_active { + self.active_background_color + } else { + self.inactive_background_color + }) + .border_1() + .border_color(cx.theme().colors().border) + .font(ui_font) + .child(label) + } +} diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index b84a2800b65f5a2c280256a4765101ae125f7ec4..ac5e9201b3be083fef43e58c2e717cb59a0ba185 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -3,6 +3,7 @@ mod collab; mod onboarding_banner; pub mod platform_title_bar; mod platforms; +mod system_window_tabs; mod title_bar_settings; #[cfg(feature = "stories")] @@ -11,6 +12,7 @@ mod stories; use crate::{ application_menu::{ApplicationMenu, show_menus}, platform_title_bar::PlatformTitleBar, + system_window_tabs::SystemWindowTabs, }; #[cfg(not(target_os = "macos"))] @@ -65,6 +67,7 @@ actions!( pub fn init(cx: &mut App) { TitleBarSettings::register(cx); + SystemWindowTabs::init(cx); cx.observe_new(|workspace: &mut Workspace, window, cx| { let Some(window) = window else { @@ -275,16 +278,16 @@ impl TitleBar { let banner = cx.new(|cx| { OnboardingBanner::new( - "Debugger Onboarding", - IconName::Debug, - "The Debugger", - None, - zed_actions::debugger::OpenOnboardingModal.boxed_clone(), + "ACP Onboarding", + IconName::Sparkle, + "Bring Your Own Agent", + Some("Introducing:".into()), + zed_actions::agent::OpenAcpOnboardingModal.boxed_clone(), cx, ) }); - let platform_titlebar = cx.new(|_| PlatformTitleBar::new(id)); + let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx)); Self { platform_titlebar, @@ -299,8 +302,8 @@ impl TitleBar { } } - fn render_ssh_project_host(&self, cx: &mut Context) -> Option { - let options = self.project.read(cx).ssh_connection_options(cx)?; + fn render_remote_project_connection(&self, cx: &mut Context) -> Option { + let options = self.project.read(cx).remote_connection_options(cx)?; let host: SharedString = options.connection_string().into(); let nickname = options @@ -308,7 +311,7 @@ impl TitleBar { .map(|nick| nick.into()) .unwrap_or_else(|| host.clone()); - let (indicator_color, meta) = match self.project.read(cx).ssh_connection_state(cx)? { + let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? { remote::ConnectionState::Connecting => (Color::Info, format!("Connecting to: {host}")), remote::ConnectionState::Connected => (Color::Success, format!("Connected to: {host}")), remote::ConnectionState::HeartbeatMissed => ( @@ -324,7 +327,7 @@ impl TitleBar { } }; - let icon_color = match self.project.read(cx).ssh_connection_state(cx)? { + let icon_color = match self.project.read(cx).remote_connection_state(cx)? { remote::ConnectionState::Connecting => Color::Info, remote::ConnectionState::Connected => Color::Default, remote::ConnectionState::HeartbeatMissed => Color::Warning, @@ -379,8 +382,8 @@ impl TitleBar { } pub fn render_project_host(&self, cx: &mut Context) -> Option { - if self.project.read(cx).is_via_ssh() { - return self.render_ssh_project_host(cx); + if self.project.read(cx).is_via_remote_server() { + return self.render_remote_project_connection(cx); } if self.project.read(cx).is_disconnected(cx) { diff --git a/crates/ui/src/components/image.rs b/crates/ui/src/components/image.rs index 09c3bbeb943ca11a00d42621f0bdd73613efaee3..6e552ddcee83e20d3812f78c67270c0291c2c0e7 100644 --- a/crates/ui/src/components/image.rs +++ b/crates/ui/src/components/image.rs @@ -13,6 +13,9 @@ use crate::prelude::*; )] #[strum(serialize_all = "snake_case")] pub enum VectorName { + AcpGrid, + AcpLogo, + AcpLogoSerif, AiGrid, DebuggerGrid, Grid, diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 1e7bb40c400e053788862544287474dfe075758f..98703f65f4cf3ccec9e483c70f5a43ac8d53a280 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -1,8 +1,8 @@ use crate::PlatformStyle; use crate::{Icon, IconName, IconSize, h_flex, prelude::*}; use gpui::{ - Action, AnyElement, App, FocusHandle, Global, IntoElement, Keystroke, Modifiers, Window, - relative, + Action, AnyElement, App, FocusHandle, Global, IntoElement, KeybindingKeystroke, Keystroke, + Modifiers, Window, relative, }; use itertools::Itertools; @@ -13,7 +13,7 @@ pub struct KeyBinding { /// More than one keystroke produces a chord. /// /// This should always contain at least one keystroke. - pub keystrokes: Vec, + pub keystrokes: Vec, /// The [`PlatformStyle`] to use when displaying this keybinding. platform_style: PlatformStyle, @@ -59,7 +59,7 @@ impl KeyBinding { cx.try_global::().is_some_and(|g| g.0) } - pub fn new(keystrokes: Vec, cx: &App) -> Self { + pub fn new(keystrokes: Vec, cx: &App) -> Self { Self { keystrokes, platform_style: PlatformStyle::platform(), @@ -99,16 +99,16 @@ impl KeyBinding { } fn render_key( - keystroke: &Keystroke, + key: &str, color: Option, platform_style: PlatformStyle, size: impl Into>, ) -> AnyElement { - let key_icon = icon_for_key(keystroke, platform_style); + let key_icon = icon_for_key(key, platform_style); match key_icon { Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), None => { - let key = util::capitalize(&keystroke.key); + let key = util::capitalize(key); Key::new(&key, color).size(size).into_any_element() } } @@ -124,7 +124,7 @@ impl RenderOnce for KeyBinding { "KEY_BINDING-{}", self.keystrokes .iter() - .map(|k| k.key.to_string()) + .map(|k| k.key().to_string()) .collect::>() .join(" ") ) @@ -137,7 +137,7 @@ impl RenderOnce for KeyBinding { .py_0p5() .rounded_xs() .text_color(cx.theme().colors().text_muted) - .children(render_keystroke( + .children(render_keybinding_keystroke( keystroke, color, self.size, @@ -148,8 +148,8 @@ impl RenderOnce for KeyBinding { } } -pub fn render_keystroke( - keystroke: &Keystroke, +pub fn render_keybinding_keystroke( + keystroke: &KeybindingKeystroke, color: Option, size: impl Into>, platform_style: PlatformStyle, @@ -163,26 +163,34 @@ pub fn render_keystroke( let size = size.into(); if use_text { - let element = Key::new(keystroke_text(keystroke, platform_style, vim_mode), color) - .size(size) - .into_any_element(); + let element = Key::new( + keystroke_text( + keystroke.modifiers(), + keystroke.key(), + platform_style, + vim_mode, + ), + color, + ) + .size(size) + .into_any_element(); vec![element] } else { let mut elements = Vec::new(); elements.extend(render_modifiers( - &keystroke.modifiers, + keystroke.modifiers(), platform_style, color, size, true, )); - elements.push(render_key(keystroke, color, platform_style, size)); + elements.push(render_key(keystroke.key(), color, platform_style, size)); elements } } -fn icon_for_key(keystroke: &Keystroke, platform_style: PlatformStyle) -> Option { - match keystroke.key.as_str() { +fn icon_for_key(key: &str, platform_style: PlatformStyle) -> Option { + match key { "left" => Some(IconName::ArrowLeft), "right" => Some(IconName::ArrowRight), "up" => Some(IconName::ArrowUp), @@ -379,7 +387,7 @@ impl KeyIcon { /// Returns a textual representation of the key binding for the given [`Action`]. pub fn text_for_action(action: &dyn Action, window: &Window, cx: &App) -> Option { let key_binding = window.highest_precedence_binding_for_action(action)?; - Some(text_for_keystrokes(key_binding.keystrokes(), cx)) + Some(text_for_keybinding_keystrokes(key_binding.keystrokes(), cx)) } pub fn text_for_keystrokes(keystrokes: &[Keystroke], cx: &App) -> String { @@ -387,22 +395,50 @@ pub fn text_for_keystrokes(keystrokes: &[Keystroke], cx: &App) -> String { let vim_enabled = cx.try_global::().is_some(); keystrokes .iter() - .map(|keystroke| keystroke_text(keystroke, platform_style, vim_enabled)) + .map(|keystroke| { + keystroke_text( + &keystroke.modifiers, + &keystroke.key, + platform_style, + vim_enabled, + ) + }) + .join(" ") +} + +pub fn text_for_keybinding_keystrokes(keystrokes: &[KeybindingKeystroke], cx: &App) -> String { + let platform_style = PlatformStyle::platform(); + let vim_enabled = cx.try_global::().is_some(); + keystrokes + .iter() + .map(|keystroke| { + keystroke_text( + keystroke.modifiers(), + keystroke.key(), + platform_style, + vim_enabled, + ) + }) .join(" ") } -pub fn text_for_keystroke(keystroke: &Keystroke, cx: &App) -> String { +pub fn text_for_keystroke(modifiers: &Modifiers, key: &str, cx: &App) -> String { let platform_style = PlatformStyle::platform(); let vim_enabled = cx.try_global::().is_some(); - keystroke_text(keystroke, platform_style, vim_enabled) + keystroke_text(modifiers, key, platform_style, vim_enabled) } /// Returns a textual representation of the given [`Keystroke`]. -fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode: bool) -> String { +fn keystroke_text( + modifiers: &Modifiers, + key: &str, + platform_style: PlatformStyle, + vim_mode: bool, +) -> String { let mut text = String::new(); let delimiter = '-'; - if keystroke.modifiers.function { + if modifiers.function { match vim_mode { false => text.push_str("Fn"), true => text.push_str("fn"), @@ -411,7 +447,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode text.push(delimiter); } - if keystroke.modifiers.control { + if modifiers.control { match (platform_style, vim_mode) { (PlatformStyle::Mac, false) => text.push_str("Control"), (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Ctrl"), @@ -421,7 +457,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode text.push(delimiter); } - if keystroke.modifiers.platform { + if modifiers.platform { match (platform_style, vim_mode) { (PlatformStyle::Mac, false) => text.push_str("Command"), (PlatformStyle::Mac, true) => text.push_str("cmd"), @@ -434,7 +470,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode text.push(delimiter); } - if keystroke.modifiers.alt { + if modifiers.alt { match (platform_style, vim_mode) { (PlatformStyle::Mac, false) => text.push_str("Option"), (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Alt"), @@ -444,7 +480,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode text.push(delimiter); } - if keystroke.modifiers.shift { + if modifiers.shift { match (platform_style, vim_mode) { (_, false) => text.push_str("Shift"), (_, true) => text.push_str("shift"), @@ -453,9 +489,9 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode } if vim_mode { - text.push_str(&keystroke.key) + text.push_str(key) } else { - let key = match keystroke.key.as_str() { + let key = match key { "pageup" => "PageUp", "pagedown" => "PageDown", key => &util::capitalize(key), @@ -562,9 +598,11 @@ mod tests { #[test] fn test_text_for_keystroke() { + let keystroke = Keystroke::parse("cmd-c").unwrap(); assert_eq!( keystroke_text( - &Keystroke::parse("cmd-c").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Mac, false ), @@ -572,7 +610,8 @@ mod tests { ); assert_eq!( keystroke_text( - &Keystroke::parse("cmd-c").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Linux, false ), @@ -580,16 +619,19 @@ mod tests { ); assert_eq!( keystroke_text( - &Keystroke::parse("cmd-c").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Windows, false ), "Win-C".to_string() ); + let keystroke = Keystroke::parse("ctrl-alt-delete").unwrap(); assert_eq!( keystroke_text( - &Keystroke::parse("ctrl-alt-delete").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Mac, false ), @@ -597,7 +639,8 @@ mod tests { ); assert_eq!( keystroke_text( - &Keystroke::parse("ctrl-alt-delete").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Linux, false ), @@ -605,16 +648,19 @@ mod tests { ); assert_eq!( keystroke_text( - &Keystroke::parse("ctrl-alt-delete").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Windows, false ), "Ctrl-Alt-Delete".to_string() ); + let keystroke = Keystroke::parse("shift-pageup").unwrap(); assert_eq!( keystroke_text( - &Keystroke::parse("shift-pageup").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Mac, false ), @@ -622,7 +668,8 @@ mod tests { ); assert_eq!( keystroke_text( - &Keystroke::parse("shift-pageup").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Linux, false, ), @@ -630,7 +677,8 @@ mod tests { ); assert_eq!( keystroke_text( - &Keystroke::parse("shift-pageup").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Windows, false ), diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 1192b14812580bf21e262620a3ccefc90c5acd54..318900d540172035b29ae25ad5f42dbbac87bf60 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -3,6 +3,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; use std::fmt::{Display, Formatter}; +use std::mem; use std::path::StripPrefixError; use std::sync::{Arc, OnceLock}; use std::{ @@ -99,21 +100,86 @@ impl> PathExt for T { } } -/// Due to the issue of UNC paths on Windows, which can cause bugs in various parts of Zed, introducing this `SanitizedPath` -/// leverages Rust's type system to ensure that all paths entering Zed are always "sanitized" by removing the `\\\\?\\` prefix. -/// On non-Windows operating systems, this struct is effectively a no-op. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct SanitizedPath(pub Arc); +/// In memory, this is identical to `Path`. On non-Windows conversions to this type are no-ops. On +/// windows, these conversions sanitize UNC paths by removing the `\\\\?\\` prefix. +#[derive(Eq, PartialEq, Hash, Ord, PartialOrd)] +#[repr(transparent)] +pub struct SanitizedPath(Path); impl SanitizedPath { - pub fn starts_with(&self, prefix: &SanitizedPath) -> bool { + pub fn new + ?Sized>(path: &T) -> &Self { + #[cfg(not(target_os = "windows"))] + return Self::unchecked_new(path.as_ref()); + + #[cfg(target_os = "windows")] + return Self::unchecked_new(dunce::simplified(path.as_ref())); + } + + pub fn unchecked_new + ?Sized>(path: &T) -> &Self { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::<&Path, &Self>(path.as_ref()) } + } + + pub fn from_arc(path: Arc) -> Arc { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + #[cfg(not(target_os = "windows"))] + return unsafe { mem::transmute::, Arc>(path) }; + + // TODO: could avoid allocating here if dunce::simplified results in the same path + #[cfg(target_os = "windows")] + return Self::new(&path).into(); + } + + pub fn new_arc + ?Sized>(path: &T) -> Arc { + Self::new(path).into() + } + + pub fn cast_arc(path: Arc) -> Arc { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::, Arc>(path) } + } + + pub fn cast_arc_ref(path: &Arc) -> &Arc { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::<&Arc, &Arc>(path) } + } + + pub fn starts_with(&self, prefix: &Self) -> bool { self.0.starts_with(&prefix.0) } - pub fn as_path(&self) -> &Arc { + pub fn as_path(&self) -> &Path { &self.0 } + pub fn file_name(&self) -> Option<&std::ffi::OsStr> { + self.0.file_name() + } + + pub fn extension(&self) -> Option<&std::ffi::OsStr> { + self.0.extension() + } + + pub fn join>(&self, path: P) -> PathBuf { + self.0.join(path) + } + + pub fn parent(&self) -> Option<&Self> { + self.0.parent().map(Self::unchecked_new) + } + + pub fn strip_prefix(&self, base: &Self) -> Result<&Path, StripPrefixError> { + self.0.strip_prefix(base.as_path()) + } + + pub fn to_str(&self) -> Option<&str> { + self.0.to_str() + } + + pub fn to_path_buf(&self) -> PathBuf { + self.0.to_path_buf() + } + pub fn to_glob_string(&self) -> String { #[cfg(target_os = "windows")] { @@ -124,13 +190,11 @@ impl SanitizedPath { self.0.to_string_lossy().to_string() } } +} - pub fn join(&self, path: &Self) -> Self { - self.0.join(&path.0).into() - } - - pub fn strip_prefix(&self, base: &Self) -> Result<&Path, StripPrefixError> { - self.0.strip_prefix(base.as_path()) +impl std::fmt::Debug for SanitizedPath { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(&self.0, formatter) } } @@ -140,29 +204,23 @@ impl Display for SanitizedPath { } } -impl From for Arc { - fn from(sanitized_path: SanitizedPath) -> Self { - sanitized_path.0 +impl From<&SanitizedPath> for Arc { + fn from(sanitized_path: &SanitizedPath) -> Self { + let path: Arc = sanitized_path.0.into(); + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute(path) } } } -impl From for PathBuf { - fn from(sanitized_path: SanitizedPath) -> Self { - sanitized_path.0.as_ref().into() +impl From<&SanitizedPath> for PathBuf { + fn from(sanitized_path: &SanitizedPath) -> Self { + sanitized_path.as_path().into() } } -impl> From for SanitizedPath { - #[cfg(not(target_os = "windows"))] - fn from(path: T) -> Self { - let path = path.as_ref(); - SanitizedPath(path.into()) - } - - #[cfg(target_os = "windows")] - fn from(path: T) -> Self { - let path = path.as_ref(); - SanitizedPath(dunce::simplified(path).into()) +impl AsRef for SanitizedPath { + fn as_ref(&self) -> &Path { + &self.0 } } @@ -1195,14 +1253,14 @@ mod tests { #[cfg(target_os = "windows")] fn test_sanitized_path() { let path = Path::new("C:\\Users\\someone\\test_file.rs"); - let sanitized_path = SanitizedPath::from(path); + let sanitized_path = SanitizedPath::new(path); assert_eq!( sanitized_path.to_string(), "C:\\Users\\someone\\test_file.rs" ); let path = Path::new("\\\\?\\C:\\Users\\someone\\test_file.rs"); - let sanitized_path = SanitizedPath::from(path); + let sanitized_path = SanitizedPath::new(path); assert_eq!( sanitized_path.to_string(), "C:\\Users\\someone\\test_file.rs" diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 69a2c88706bff8b459ec7b678976a84ae4f943bf..0aceec5d7ae4b672afc6111bd4f2389d7b1b6af7 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -1057,6 +1057,18 @@ pub fn get_system_shell() -> String { } } +pub fn get_default_system_shell() -> String { + #[cfg(target_os = "windows")] + { + get_windows_system_shell() + } + + #[cfg(not(target_os = "windows"))] + { + "/bin/sh".to_string() + } +} + #[derive(Debug)] pub enum ConnectionResult { Timeout, diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index b57c916db988f683cef6bae15ec562392a488be6..29fe6aae0252bcc1ca5767f71b7c668ecae1b9a8 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1924,7 +1924,9 @@ impl ShellExec { let Some(range) = input_range else { return }; - let mut process = project.read(cx).exec_in_shell(command, cx); + let Some(mut process) = project.read(cx).exec_in_shell(command, cx).log_err() else { + return; + }; process.stdout(Stdio::piped()); process.stderr(Stdio::piped()); diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 2bc531268d4f909c11c29d6b001d8a34e887c927..abde3a8ce6e8755bb49826fb408a6af36661f00c 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -1,8 +1,10 @@ use editor::display_map::DisplaySnapshot; -use editor::{DisplayPoint, Editor, SelectionEffects, ToOffset, ToPoint, movement}; +use editor::{ + DisplayPoint, Editor, HideMouseCursorOrigin, SelectionEffects, ToOffset, ToPoint, movement, +}; use gpui::{Action, actions}; use gpui::{Context, Window}; -use language::{CharClassifier, CharKind}; +use language::{CharClassifier, CharKind, Point}; use text::{Bias, SelectionGoal}; use crate::motion; @@ -23,14 +25,20 @@ actions!( HelixInsert, /// Appends at the end of the selection. HelixAppend, + /// Goes to the location of the last modification. + HelixGotoLastModification, + /// Select entire line or multiple lines, extending downwards. + HelixSelectLine, ] ); pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, Vim::helix_normal_after); + Vim::action(editor, cx, Vim::helix_select_lines); Vim::action(editor, cx, Vim::helix_insert); Vim::action(editor, cx, Vim::helix_append); Vim::action(editor, cx, Vim::helix_yank); + Vim::action(editor, cx, Vim::helix_goto_last_modification); } impl Vim { @@ -430,6 +438,56 @@ impl Vim { }); self.switch_mode(Mode::HelixNormal, true, window, cx); } + + pub fn helix_goto_last_modification( + &mut self, + _: &HelixGotoLastModification, + window: &mut Window, + cx: &mut Context, + ) { + self.jump(".".into(), false, false, window, cx); + } + + pub fn helix_select_lines( + &mut self, + _: &HelixSelectLine, + window: &mut Window, + cx: &mut Context, + ) { + let count = Vim::take_count(cx).unwrap_or(1); + self.update_editor(cx, |_, editor, cx| { + editor.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); + let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx)); + let mut selections = editor.selections.all::(cx); + let max_point = display_map.buffer_snapshot.max_point(); + let buffer_snapshot = &display_map.buffer_snapshot; + + for selection in &mut selections { + // Start always goes to column 0 of the first selected line + let start_row = selection.start.row; + let current_end_row = selection.end.row; + + // Check if cursor is on empty line by checking first character + let line_start_offset = buffer_snapshot.point_to_offset(Point::new(start_row, 0)); + let first_char = buffer_snapshot.chars_at(line_start_offset).next(); + let extra_line = if first_char == Some('\n') { 1 } else { 0 }; + + let end_row = current_end_row + count as u32 + extra_line; + + selection.start = Point::new(start_row, 0); + selection.end = if end_row > max_point.row { + max_point + } else { + Point::new(end_row, 0) + }; + selection.reversed = false; + } + + editor.change_selections(Default::default(), window, cx, |s| { + s.select(selections); + }); + }); + } } #[cfg(test)] @@ -441,6 +499,7 @@ mod test { #[gpui::test] async fn test_word_motions(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); // « // ˇ // » @@ -502,6 +561,7 @@ mod test { #[gpui::test] async fn test_delete(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); // test delete a selection cx.set_state( @@ -582,6 +642,7 @@ mod test { #[gpui::test] async fn test_f_and_t(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); cx.set_state( indoc! {" @@ -635,6 +696,7 @@ mod test { #[gpui::test] async fn test_newline_char(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal); @@ -652,6 +714,7 @@ mod test { #[gpui::test] async fn test_insert_selected(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); cx.set_state( indoc! {" «The ˇ»quick brown @@ -674,6 +737,7 @@ mod test { #[gpui::test] async fn test_append(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); // test from the end of the selection cx.set_state( indoc! {" @@ -716,6 +780,7 @@ mod test { #[gpui::test] async fn test_replace(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); // No selection (single character) cx.set_state("ˇaa", Mode::HelixNormal); @@ -763,4 +828,210 @@ mod test { cx.shared_clipboard().assert_eq("worl"); cx.assert_state("hello «worlˇ»d", Mode::HelixNormal); } + #[gpui::test] + async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // First copy some text to clipboard + cx.set_state("«hello worldˇ»", Mode::HelixNormal); + cx.simulate_keystrokes("y"); + + // Test paste with shift-r on single cursor + cx.set_state("foo ˇbar", Mode::HelixNormal); + cx.simulate_keystrokes("shift-r"); + + cx.assert_state("foo hello worldˇbar", Mode::HelixNormal); + + // Test paste with shift-r on selection + cx.set_state("foo «barˇ» baz", Mode::HelixNormal); + cx.simulate_keystrokes("shift-r"); + + cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal); + } + + #[gpui::test] + async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Make a modification at a specific location + cx.set_state("ˇhello", Mode::HelixNormal); + assert_eq!(cx.mode(), Mode::HelixNormal); + cx.simulate_keystrokes("i"); + assert_eq!(cx.mode(), Mode::Insert); + cx.simulate_keystrokes("escape"); + assert_eq!(cx.mode(), Mode::HelixNormal); + } + + #[gpui::test] + async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Make a modification at a specific location + cx.set_state("line one\nline ˇtwo\nline three", Mode::HelixNormal); + cx.assert_state("line one\nline ˇtwo\nline three", Mode::HelixNormal); + cx.simulate_keystrokes("i"); + cx.simulate_keystrokes("escape"); + cx.simulate_keystrokes("i"); + cx.simulate_keystrokes("m o d i f i e d space"); + cx.simulate_keystrokes("escape"); + + // TODO: this fails, because state is no longer helix + cx.assert_state( + "line one\nline modified ˇtwo\nline three", + Mode::HelixNormal, + ); + + // Move cursor away from the modification + cx.simulate_keystrokes("up"); + + // Use "g ." to go back to last modification + cx.simulate_keystrokes("g ."); + + // Verify we're back at the modification location and still in HelixNormal mode + cx.assert_state( + "line one\nline modifiedˇ two\nline three", + Mode::HelixNormal, + ); + } + + #[gpui::test] + async fn test_helix_select_lines(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.set_state( + "line one\nline ˇtwo\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("2 x"); + cx.assert_state( + "line one\n«line two\nline three\nˇ»line four", + Mode::HelixNormal, + ); + + // Test extending existing line selection + cx.set_state( + indoc! {" + li«ˇne one + li»ne two + line three + line four"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + «line one + line two + ˇ»line three + line four"}, + Mode::HelixNormal, + ); + + // Pressing x in empty line, select next line (because helix considers cursor a selection) + cx.set_state( + indoc! {" + line one + ˇ + line three + line four"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + line one + « + line three + ˇ»line four"}, + Mode::HelixNormal, + ); + + // Empty line with count selects extra + count lines + cx.set_state( + indoc! {" + line one + ˇ + line three + line four + line five"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("2 x"); + cx.assert_state( + indoc! {" + line one + « + line three + line four + ˇ»line five"}, + Mode::HelixNormal, + ); + + // Compare empty vs non-empty line behavior + cx.set_state( + indoc! {" + ˇnon-empty line + line two + line three"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + «non-empty line + ˇ»line two + line three"}, + Mode::HelixNormal, + ); + + // Same test but with empty line - should select one extra + cx.set_state( + indoc! {" + ˇ + line two + line three"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + « + line two + ˇ»line three"}, + Mode::HelixNormal, + ); + + // Test selecting multiple lines with count + cx.set_state( + indoc! {" + ˇline one + line two + line threeˇ + line four + line five"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + «line one + ˇ»line two + «line three + ˇ»line four + line five"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + «line one + line two + line three + line four + ˇ»line five"}, + Mode::HelixNormal, + ); + } } diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 4fbeec72365c1613acd4d1d740c518a4676a48a5..dba003ec5fce9b74375b1791dba690a2267ff0ba 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -203,7 +203,10 @@ impl Vim { // hook into the existing to clear out any vim search state on cmd+f or edit -> find. fn search_deploy(&mut self, _: &buffer_search::Deploy, _: &mut Window, cx: &mut Context) { + // Preserve the current mode when resetting search state + let current_mode = self.mode; self.search = Default::default(); + self.search.prior_mode = current_mode; cx.propagate(); } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index c0176cb12c34ac0d58504edde1508bbfd04c6be8..fe4bc7433d57f882b9935cfd547fab6e2eb736c1 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -7,8 +7,10 @@ use crate::{motion::Motion, object::Object}; use anyhow::Result; use collections::HashMap; use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor}; -use db::define_connection; -use db::sqlez_macros::sql; +use db::{ + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, +}; use editor::display_map::{is_invisible, replacement}; use editor::{Anchor, ClipboardSelection, Editor, MultiBuffer, ToPoint as EditorToPoint}; use gpui::{ @@ -1668,8 +1670,12 @@ impl MarksView { } } -define_connection! ( - pub static ref DB: VimDb = &[ +pub struct VimDb(ThreadSafeConnection); + +impl Domain for VimDb { + const NAME: &str = stringify!(VimDb); + + const MIGRATIONS: &[&str] = &[ sql! ( CREATE TABLE vim_marks ( workspace_id INTEGER, @@ -1689,7 +1695,9 @@ define_connection! ( ON vim_global_marks_paths(workspace_id, mark_name); ), ]; -); +} + +db::static_connection!(DB, VimDb, [WorkspaceDb]); struct SerializedMark { path: Arc, diff --git a/crates/workspace/src/path_list.rs b/crates/workspace/src/path_list.rs index 4f9ed4231289516a5434cc429adc7242731d9a27..01e2ffda949faf502de087fb0077cdbc758001ab 100644 --- a/crates/workspace/src/path_list.rs +++ b/crates/workspace/src/path_list.rs @@ -26,7 +26,7 @@ impl PathList { let mut indexed_paths: Vec<(usize, PathBuf)> = paths .iter() .enumerate() - .map(|(ix, path)| (ix, SanitizedPath::from(path).into())) + .map(|(ix, path)| (ix, SanitizedPath::new(path).into())) .collect(); indexed_paths.sort_by(|(_, a), (_, b)| a.cmp(b)); let order = indexed_paths.iter().map(|e| e.0).collect::>().into(); @@ -58,11 +58,7 @@ impl PathList { let mut paths: Vec = if serialized.paths.is_empty() { Vec::new() } else { - serde_json::from_str::>(&serialized.paths) - .unwrap_or(Vec::new()) - .into_iter() - .map(|s| SanitizedPath::from(s).into()) - .collect() + serialized.paths.split('\n').map(PathBuf::from).collect() }; let mut order: Vec = serialized @@ -85,7 +81,13 @@ impl PathList { pub fn serialize(&self) -> SerializedPathList { use std::fmt::Write as _; - let paths = serde_json::to_string(&self.paths).unwrap_or_default(); + let mut paths = String::new(); + for path in self.paths.iter() { + if !paths.is_empty() { + paths.push('\n'); + } + paths.push_str(&path.to_string_lossy()); + } let mut order = String::new(); for ix in self.order.iter() { diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 39a1e08c9315e08c2f243ac5b97c1db32bcd639f..3ef9ff65eb0fe5aedfd5e72aa18f1481a011fce7 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -10,7 +10,11 @@ use std::{ use anyhow::{Context as _, Result, bail}; use collections::HashMap; -use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql}; +use db::{ + query, + sqlez::{connection::Connection, domain::Domain}, + sqlez_macros::sql, +}; use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size}; use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}; @@ -275,186 +279,189 @@ impl sqlez::bindable::Bind for SerializedPixels { } } -define_connection! { - pub static ref DB: WorkspaceDb<()> = - &[ - sql!( - CREATE TABLE workspaces( - workspace_id INTEGER PRIMARY KEY, - workspace_location BLOB UNIQUE, - dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed. - dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed. - dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed. - left_sidebar_open INTEGER, // Boolean - timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, - FOREIGN KEY(dock_pane) REFERENCES panes(pane_id) - ) STRICT; - - CREATE TABLE pane_groups( - group_id INTEGER PRIMARY KEY, - workspace_id INTEGER NOT NULL, - parent_group_id INTEGER, // NULL indicates that this is a root node - position INTEGER, // NULL indicates that this is a root node - axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal' - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) - ON DELETE CASCADE - ON UPDATE CASCADE, - FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE - ) STRICT; - - CREATE TABLE panes( - pane_id INTEGER PRIMARY KEY, - workspace_id INTEGER NOT NULL, - active INTEGER NOT NULL, // Boolean - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) - ON DELETE CASCADE - ON UPDATE CASCADE - ) STRICT; - - CREATE TABLE center_panes( - pane_id INTEGER PRIMARY KEY, - parent_group_id INTEGER, // NULL means that this is a root pane - position INTEGER, // NULL means that this is a root pane - FOREIGN KEY(pane_id) REFERENCES panes(pane_id) - ON DELETE CASCADE, - FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE - ) STRICT; - - CREATE TABLE items( - item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique - workspace_id INTEGER NOT NULL, - pane_id INTEGER NOT NULL, - kind TEXT NOT NULL, - position INTEGER NOT NULL, - active INTEGER NOT NULL, - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) - ON DELETE CASCADE - ON UPDATE CASCADE, - FOREIGN KEY(pane_id) REFERENCES panes(pane_id) - ON DELETE CASCADE, - PRIMARY KEY(item_id, workspace_id) - ) STRICT; - ), - sql!( - ALTER TABLE workspaces ADD COLUMN window_state TEXT; - ALTER TABLE workspaces ADD COLUMN window_x REAL; - ALTER TABLE workspaces ADD COLUMN window_y REAL; - ALTER TABLE workspaces ADD COLUMN window_width REAL; - ALTER TABLE workspaces ADD COLUMN window_height REAL; - ALTER TABLE workspaces ADD COLUMN display BLOB; - ), - // Drop foreign key constraint from workspaces.dock_pane to panes table. - sql!( - CREATE TABLE workspaces_2( - workspace_id INTEGER PRIMARY KEY, - workspace_location BLOB UNIQUE, - dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed. - dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed. - dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed. - left_sidebar_open INTEGER, // Boolean - timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, - window_state TEXT, - window_x REAL, - window_y REAL, - window_width REAL, - window_height REAL, - display BLOB - ) STRICT; - INSERT INTO workspaces_2 SELECT * FROM workspaces; - DROP TABLE workspaces; - ALTER TABLE workspaces_2 RENAME TO workspaces; - ), - // Add panels related information - sql!( - ALTER TABLE workspaces ADD COLUMN left_dock_visible INTEGER; //bool - ALTER TABLE workspaces ADD COLUMN left_dock_active_panel TEXT; - ALTER TABLE workspaces ADD COLUMN right_dock_visible INTEGER; //bool - ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT; - ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool - ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT; - ), - // Add panel zoom persistence - sql!( - ALTER TABLE workspaces ADD COLUMN left_dock_zoom INTEGER; //bool - ALTER TABLE workspaces ADD COLUMN right_dock_zoom INTEGER; //bool - ALTER TABLE workspaces ADD COLUMN bottom_dock_zoom INTEGER; //bool - ), - // Add pane group flex data - sql!( - ALTER TABLE pane_groups ADD COLUMN flexes TEXT; - ), - // Add fullscreen field to workspace - // Deprecated, `WindowBounds` holds the fullscreen state now. - // Preserving so users can downgrade Zed. - sql!( - ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool - ), - // Add preview field to items - sql!( - ALTER TABLE items ADD COLUMN preview INTEGER; //bool - ), - // Add centered_layout field to workspace - sql!( - ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool - ), - sql!( - CREATE TABLE remote_projects ( - remote_project_id INTEGER NOT NULL UNIQUE, - path TEXT, - dev_server_name TEXT - ); - ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER; - ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths; - ), - sql!( - DROP TABLE remote_projects; - CREATE TABLE dev_server_projects ( - id INTEGER NOT NULL UNIQUE, - path TEXT, - dev_server_name TEXT - ); - ALTER TABLE workspaces DROP COLUMN remote_project_id; - ALTER TABLE workspaces ADD COLUMN dev_server_project_id INTEGER; - ), - sql!( - ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB; - ), - sql!( - ALTER TABLE workspaces ADD COLUMN session_id TEXT DEFAULT NULL; - ), - sql!( - ALTER TABLE workspaces ADD COLUMN window_id INTEGER DEFAULT NULL; - ), - sql!( - ALTER TABLE panes ADD COLUMN pinned_count INTEGER DEFAULT 0; - ), - sql!( - CREATE TABLE ssh_projects ( - id INTEGER PRIMARY KEY, - host TEXT NOT NULL, - port INTEGER, - path TEXT NOT NULL, - user TEXT - ); - ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE; - ), - sql!( - ALTER TABLE ssh_projects RENAME COLUMN path TO paths; - ), - sql!( - CREATE TABLE toolchains ( - workspace_id INTEGER, - worktree_id INTEGER, - language_name TEXT NOT NULL, - name TEXT NOT NULL, - path TEXT NOT NULL, - PRIMARY KEY (workspace_id, worktree_id, language_name) - ); - ), - sql!( - ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}"; - ), - sql!( +pub struct WorkspaceDb(ThreadSafeConnection); + +impl Domain for WorkspaceDb { + const NAME: &str = stringify!(WorkspaceDb); + + const MIGRATIONS: &[&str] = &[ + sql!( + CREATE TABLE workspaces( + workspace_id INTEGER PRIMARY KEY, + workspace_location BLOB UNIQUE, + dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed. + dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed. + dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed. + left_sidebar_open INTEGER, // Boolean + timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY(dock_pane) REFERENCES panes(pane_id) + ) STRICT; + + CREATE TABLE pane_groups( + group_id INTEGER PRIMARY KEY, + workspace_id INTEGER NOT NULL, + parent_group_id INTEGER, // NULL indicates that this is a root node + position INTEGER, // NULL indicates that this is a root node + axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal' + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ON UPDATE CASCADE, + FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE + ) STRICT; + + CREATE TABLE panes( + pane_id INTEGER PRIMARY KEY, + workspace_id INTEGER NOT NULL, + active INTEGER NOT NULL, // Boolean + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ON UPDATE CASCADE + ) STRICT; + + CREATE TABLE center_panes( + pane_id INTEGER PRIMARY KEY, + parent_group_id INTEGER, // NULL means that this is a root pane + position INTEGER, // NULL means that this is a root pane + FOREIGN KEY(pane_id) REFERENCES panes(pane_id) + ON DELETE CASCADE, + FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE + ) STRICT; + + CREATE TABLE items( + item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique + workspace_id INTEGER NOT NULL, + pane_id INTEGER NOT NULL, + kind TEXT NOT NULL, + position INTEGER NOT NULL, + active INTEGER NOT NULL, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ON UPDATE CASCADE, + FOREIGN KEY(pane_id) REFERENCES panes(pane_id) + ON DELETE CASCADE, + PRIMARY KEY(item_id, workspace_id) + ) STRICT; + ), + sql!( + ALTER TABLE workspaces ADD COLUMN window_state TEXT; + ALTER TABLE workspaces ADD COLUMN window_x REAL; + ALTER TABLE workspaces ADD COLUMN window_y REAL; + ALTER TABLE workspaces ADD COLUMN window_width REAL; + ALTER TABLE workspaces ADD COLUMN window_height REAL; + ALTER TABLE workspaces ADD COLUMN display BLOB; + ), + // Drop foreign key constraint from workspaces.dock_pane to panes table. + sql!( + CREATE TABLE workspaces_2( + workspace_id INTEGER PRIMARY KEY, + workspace_location BLOB UNIQUE, + dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed. + dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed. + dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed. + left_sidebar_open INTEGER, // Boolean + timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + window_state TEXT, + window_x REAL, + window_y REAL, + window_width REAL, + window_height REAL, + display BLOB + ) STRICT; + INSERT INTO workspaces_2 SELECT * FROM workspaces; + DROP TABLE workspaces; + ALTER TABLE workspaces_2 RENAME TO workspaces; + ), + // Add panels related information + sql!( + ALTER TABLE workspaces ADD COLUMN left_dock_visible INTEGER; //bool + ALTER TABLE workspaces ADD COLUMN left_dock_active_panel TEXT; + ALTER TABLE workspaces ADD COLUMN right_dock_visible INTEGER; //bool + ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT; + ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool + ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT; + ), + // Add panel zoom persistence + sql!( + ALTER TABLE workspaces ADD COLUMN left_dock_zoom INTEGER; //bool + ALTER TABLE workspaces ADD COLUMN right_dock_zoom INTEGER; //bool + ALTER TABLE workspaces ADD COLUMN bottom_dock_zoom INTEGER; //bool + ), + // Add pane group flex data + sql!( + ALTER TABLE pane_groups ADD COLUMN flexes TEXT; + ), + // Add fullscreen field to workspace + // Deprecated, `WindowBounds` holds the fullscreen state now. + // Preserving so users can downgrade Zed. + sql!( + ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool + ), + // Add preview field to items + sql!( + ALTER TABLE items ADD COLUMN preview INTEGER; //bool + ), + // Add centered_layout field to workspace + sql!( + ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool + ), + sql!( + CREATE TABLE remote_projects ( + remote_project_id INTEGER NOT NULL UNIQUE, + path TEXT, + dev_server_name TEXT + ); + ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER; + ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths; + ), + sql!( + DROP TABLE remote_projects; + CREATE TABLE dev_server_projects ( + id INTEGER NOT NULL UNIQUE, + path TEXT, + dev_server_name TEXT + ); + ALTER TABLE workspaces DROP COLUMN remote_project_id; + ALTER TABLE workspaces ADD COLUMN dev_server_project_id INTEGER; + ), + sql!( + ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB; + ), + sql!( + ALTER TABLE workspaces ADD COLUMN session_id TEXT DEFAULT NULL; + ), + sql!( + ALTER TABLE workspaces ADD COLUMN window_id INTEGER DEFAULT NULL; + ), + sql!( + ALTER TABLE panes ADD COLUMN pinned_count INTEGER DEFAULT 0; + ), + sql!( + CREATE TABLE ssh_projects ( + id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port INTEGER, + path TEXT NOT NULL, + user TEXT + ); + ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE; + ), + sql!( + ALTER TABLE ssh_projects RENAME COLUMN path TO paths; + ), + sql!( + CREATE TABLE toolchains ( + workspace_id INTEGER, + worktree_id INTEGER, + language_name TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + PRIMARY KEY (workspace_id, worktree_id, language_name) + ); + ), + sql!( + ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}"; + ), + sql!( CREATE TABLE breakpoints ( workspace_id INTEGER NOT NULL, path TEXT NOT NULL, @@ -466,141 +473,172 @@ define_connection! { ON UPDATE CASCADE ); ), - sql!( - ALTER TABLE workspaces ADD COLUMN local_paths_array TEXT; - CREATE UNIQUE INDEX local_paths_array_uq ON workspaces(local_paths_array); - ALTER TABLE workspaces ADD COLUMN local_paths_order_array TEXT; - ), - sql!( - ALTER TABLE breakpoints ADD COLUMN state INTEGER DEFAULT(0) NOT NULL - ), - sql!( - ALTER TABLE breakpoints DROP COLUMN kind - ), - sql!(ALTER TABLE toolchains ADD COLUMN relative_worktree_path TEXT DEFAULT "" NOT NULL), - sql!( - ALTER TABLE breakpoints ADD COLUMN condition TEXT; - ALTER TABLE breakpoints ADD COLUMN hit_condition TEXT; - ), - sql!(CREATE TABLE toolchains2 ( - workspace_id INTEGER, - worktree_id INTEGER, - language_name TEXT NOT NULL, - name TEXT NOT NULL, - path TEXT NOT NULL, - raw_json TEXT NOT NULL, - relative_worktree_path TEXT NOT NULL, - PRIMARY KEY (workspace_id, worktree_id, language_name, relative_worktree_path)) STRICT; - INSERT INTO toolchains2 - SELECT * FROM toolchains; - DROP TABLE toolchains; - ALTER TABLE toolchains2 RENAME TO toolchains; - ), - sql!( - CREATE TABLE ssh_connections ( - id INTEGER PRIMARY KEY, - host TEXT NOT NULL, - port INTEGER, - user TEXT - ); + sql!( + ALTER TABLE workspaces ADD COLUMN local_paths_array TEXT; + CREATE UNIQUE INDEX local_paths_array_uq ON workspaces(local_paths_array); + ALTER TABLE workspaces ADD COLUMN local_paths_order_array TEXT; + ), + sql!( + ALTER TABLE breakpoints ADD COLUMN state INTEGER DEFAULT(0) NOT NULL + ), + sql!( + ALTER TABLE breakpoints DROP COLUMN kind + ), + sql!(ALTER TABLE toolchains ADD COLUMN relative_worktree_path TEXT DEFAULT "" NOT NULL), + sql!( + ALTER TABLE breakpoints ADD COLUMN condition TEXT; + ALTER TABLE breakpoints ADD COLUMN hit_condition TEXT; + ), + sql!(CREATE TABLE toolchains2 ( + workspace_id INTEGER, + worktree_id INTEGER, + language_name TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + raw_json TEXT NOT NULL, + relative_worktree_path TEXT NOT NULL, + PRIMARY KEY (workspace_id, worktree_id, language_name, relative_worktree_path)) STRICT; + INSERT INTO toolchains2 + SELECT * FROM toolchains; + DROP TABLE toolchains; + ALTER TABLE toolchains2 RENAME TO toolchains; + ), + sql!( + CREATE TABLE ssh_connections ( + id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port INTEGER, + user TEXT + ); + + INSERT INTO ssh_connections (host, port, user) + SELECT DISTINCT host, port, user + FROM ssh_projects; + + CREATE TABLE workspaces_2( + workspace_id INTEGER PRIMARY KEY, + paths TEXT, + paths_order TEXT, + ssh_connection_id INTEGER REFERENCES ssh_connections(id), + timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + window_state TEXT, + window_x REAL, + window_y REAL, + window_width REAL, + window_height REAL, + display BLOB, + left_dock_visible INTEGER, + left_dock_active_panel TEXT, + right_dock_visible INTEGER, + right_dock_active_panel TEXT, + bottom_dock_visible INTEGER, + bottom_dock_active_panel TEXT, + left_dock_zoom INTEGER, + right_dock_zoom INTEGER, + bottom_dock_zoom INTEGER, + fullscreen INTEGER, + centered_layout INTEGER, + session_id TEXT, + window_id INTEGER + ) STRICT; + + INSERT + INTO workspaces_2 + SELECT + workspaces.workspace_id, + CASE + WHEN ssh_projects.id IS NOT NULL THEN ssh_projects.paths + ELSE + CASE + WHEN workspaces.local_paths_array IS NULL OR workspaces.local_paths_array = "" THEN + NULL + ELSE + replace(workspaces.local_paths_array, ',', CHAR(10)) + END + END as paths, + + CASE + WHEN ssh_projects.id IS NOT NULL THEN "" + ELSE workspaces.local_paths_order_array + END as paths_order, + + CASE + WHEN ssh_projects.id IS NOT NULL THEN ( + SELECT ssh_connections.id + FROM ssh_connections + WHERE + ssh_connections.host IS ssh_projects.host AND + ssh_connections.port IS ssh_projects.port AND + ssh_connections.user IS ssh_projects.user + ) + ELSE NULL + END as ssh_connection_id, + + workspaces.timestamp, + workspaces.window_state, + workspaces.window_x, + workspaces.window_y, + workspaces.window_width, + workspaces.window_height, + workspaces.display, + workspaces.left_dock_visible, + workspaces.left_dock_active_panel, + workspaces.right_dock_visible, + workspaces.right_dock_active_panel, + workspaces.bottom_dock_visible, + workspaces.bottom_dock_active_panel, + workspaces.left_dock_zoom, + workspaces.right_dock_zoom, + workspaces.bottom_dock_zoom, + workspaces.fullscreen, + workspaces.centered_layout, + workspaces.session_id, + workspaces.window_id + FROM + workspaces LEFT JOIN + ssh_projects ON + workspaces.ssh_project_id = ssh_projects.id; + + DELETE FROM workspaces_2 + WHERE workspace_id NOT IN ( + SELECT MAX(workspace_id) + FROM workspaces_2 + GROUP BY ssh_connection_id, paths + ); - INSERT INTO ssh_connections (host, port, user) - SELECT DISTINCT host, port, user - FROM ssh_projects; - - CREATE TABLE workspaces_2( - workspace_id INTEGER PRIMARY KEY, - paths TEXT, - paths_order TEXT, - ssh_connection_id INTEGER REFERENCES ssh_connections(id), - timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, - window_state TEXT, - window_x REAL, - window_y REAL, - window_width REAL, - window_height REAL, - display BLOB, - left_dock_visible INTEGER, - left_dock_active_panel TEXT, - right_dock_visible INTEGER, - right_dock_active_panel TEXT, - bottom_dock_visible INTEGER, - bottom_dock_active_panel TEXT, - left_dock_zoom INTEGER, - right_dock_zoom INTEGER, - bottom_dock_zoom INTEGER, - fullscreen INTEGER, - centered_layout INTEGER, - session_id TEXT, - window_id INTEGER - ) STRICT; - - INSERT - INTO workspaces_2 - SELECT - workspaces.workspace_id, - CASE - WHEN ssh_projects.id IS NOT NULL THEN ssh_projects.paths + DROP TABLE ssh_projects; + DROP TABLE workspaces; + ALTER TABLE workspaces_2 RENAME TO workspaces; + + CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(ssh_connection_id, paths); + ), + // Fix any data from when workspaces.paths were briefly encoded as JSON arrays + sql!( + UPDATE workspaces + SET paths = CASE + WHEN substr(paths, 1, 2) = '[' || '"' AND substr(paths, -2, 2) = '"' || ']' THEN + replace( + substr(paths, 3, length(paths) - 4), + '"' || ',' || '"', + CHAR(10) + ) ELSE - CASE - WHEN workspaces.local_paths_array IS NULL OR workspaces.local_paths_array = "" THEN - NULL - ELSE - json('[' || '"' || replace(workspaces.local_paths_array, ',', '"' || "," || '"') || '"' || ']') - END - END as paths, - - CASE - WHEN ssh_projects.id IS NOT NULL THEN "" - ELSE workspaces.local_paths_order_array - END as paths_order, - - CASE - WHEN ssh_projects.id IS NOT NULL THEN ( - SELECT ssh_connections.id - FROM ssh_connections - WHERE - ssh_connections.host IS ssh_projects.host AND - ssh_connections.port IS ssh_projects.port AND - ssh_connections.user IS ssh_projects.user - ) - ELSE NULL - END as ssh_connection_id, - - workspaces.timestamp, - workspaces.window_state, - workspaces.window_x, - workspaces.window_y, - workspaces.window_width, - workspaces.window_height, - workspaces.display, - workspaces.left_dock_visible, - workspaces.left_dock_active_panel, - workspaces.right_dock_visible, - workspaces.right_dock_active_panel, - workspaces.bottom_dock_visible, - workspaces.bottom_dock_active_panel, - workspaces.left_dock_zoom, - workspaces.right_dock_zoom, - workspaces.bottom_dock_zoom, - workspaces.fullscreen, - workspaces.centered_layout, - workspaces.session_id, - workspaces.window_id - FROM - workspaces LEFT JOIN - ssh_projects ON - workspaces.ssh_project_id = ssh_projects.id; - - DROP TABLE ssh_projects; - DROP TABLE workspaces; - ALTER TABLE workspaces_2 RENAME TO workspaces; - - CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(ssh_connection_id, paths); - ), + replace(paths, ',', CHAR(10)) + END + WHERE paths IS NOT NULL + ), ]; + + // Allow recovering from bad migration that was initially shipped to nightly + // when introducing the ssh_connections table. + fn should_allow_migration_change(_index: usize, old: &str, new: &str) -> bool { + old.starts_with("CREATE TABLE ssh_connections") + && new.starts_with("CREATE TABLE ssh_connections") + } } +db::static_connection!(DB, WorkspaceDb, []); + impl WorkspaceDb { /// Returns a serialized workspace for the given worktree_roots. If the passed array /// is empty, the most recent workspace is returned instead. If no workspace for the @@ -786,7 +824,6 @@ impl WorkspaceDb { conn.exec_bound( sql!( DELETE FROM breakpoints WHERE workspace_id = ?1; - DELETE FROM toolchains WHERE workspace_id = ?1; ) )?(workspace.id).context("Clearing old breakpoints")?; @@ -1059,7 +1096,6 @@ impl WorkspaceDb { query! { pub async fn delete_workspace_by_id(id: WorkspaceId) -> Result<()> { - DELETE FROM toolchains WHERE workspace_id = ?1; DELETE FROM workspaces WHERE workspace_id IS ? } @@ -1386,24 +1422,24 @@ impl WorkspaceDb { &self, workspace_id: WorkspaceId, worktree_id: WorktreeId, - relative_path: String, + relative_worktree_path: String, language_name: LanguageName, ) -> Result> { self.write(move |this| { let mut select = this .select_bound(sql!( - SELECT name, path, raw_json FROM toolchains WHERE workspace_id = ? AND language_name = ? AND worktree_id = ? AND relative_path = ? + SELECT name, path, raw_json FROM toolchains WHERE workspace_id = ? AND language_name = ? AND worktree_id = ? AND relative_worktree_path = ? )) - .context("Preparing insertion")?; + .context("select toolchain")?; let toolchain: Vec<(String, String, String)> = - select((workspace_id, language_name.as_ref().to_string(), worktree_id.to_usize(), relative_path))?; + select((workspace_id, language_name.as_ref().to_string(), worktree_id.to_usize(), relative_worktree_path))?; Ok(toolchain.into_iter().next().and_then(|(name, path, raw_json)| Some(Toolchain { name: name.into(), path: path.into(), language_name, - as_json: serde_json::Value::from_str(&raw_json).ok()? + as_json: serde_json::Value::from_str(&raw_json).ok()?, }))) }) .await @@ -1418,7 +1454,7 @@ impl WorkspaceDb { .select_bound(sql!( SELECT name, path, worktree_id, relative_worktree_path, language_name, raw_json FROM toolchains WHERE workspace_id = ? )) - .context("Preparing insertion")?; + .context("select toolchains")?; let toolchain: Vec<(String, String, u64, String, String, String)> = select(workspace_id)?; @@ -1427,7 +1463,7 @@ impl WorkspaceDb { name: name.into(), path: path.into(), language_name: LanguageName::new(&language_name), - as_json: serde_json::Value::from_str(&raw_json).ok()? + as_json: serde_json::Value::from_str(&raw_json).ok()?, }, WorktreeId::from_proto(worktree_id), Arc::from(relative_worktree_path.as_ref())))).collect()) }) .await @@ -1803,6 +1839,7 @@ mod tests { ON DELETE CASCADE ) STRICT; )], + |_, _, _| false, ) .unwrap(); }) @@ -1851,6 +1888,7 @@ mod tests { REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT;)], + |_, _, _| false, ) }) .await diff --git a/crates/workspace/src/tasks.rs b/crates/workspace/src/tasks.rs index 32d066c7eb74f9019348d3bcac9402ebb7216a4e..71394c874ae988d7b8fef3e3a224d25e1c290640 100644 --- a/crates/workspace/src/tasks.rs +++ b/crates/workspace/src/tasks.rs @@ -20,7 +20,7 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - match self.project.read(cx).ssh_connection_state(cx) { + match self.project.read(cx).remote_connection_state(cx) { None | Some(ConnectionState::Connected) => {} Some( ConnectionState::Connecting diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 044601df97840bc95a821cd4f2ccfc2f8b0abbbf..0f119c14003d0f54f2f3a5323cb5e9106716a24d 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -42,9 +42,9 @@ use gpui::{ Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context, CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke, ManagedView, MouseButton, - PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription, Task, - Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId, WindowOptions, actions, canvas, - point, relative, size, transparent_black, + PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription, + SystemWindowTabController, Task, Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId, + WindowOptions, actions, canvas, point, relative, size, transparent_black, }; pub use history_manager::*; pub use item::{ @@ -74,7 +74,7 @@ use project::{ DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId, debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus}, }; -use remote::{SshClientDelegate, SshConnectionOptions, ssh_session::ConnectionIdentifier}; +use remote::{RemoteClientDelegate, SshConnectionOptions, remote_client::ConnectionIdentifier}; use schemars::JsonSchema; use serde::Deserialize; use session::AppSession; @@ -2084,7 +2084,7 @@ impl Workspace { cx: &mut Context, ) -> oneshot::Receiver>> { if self.project.read(cx).is_via_collab() - || self.project.read(cx).is_via_ssh() + || self.project.read(cx).is_via_remote_server() || !WorkspaceSettings::get_global(cx).use_system_path_prompts { let prompt = self.on_prompt_for_new_path.take().unwrap(); @@ -2576,7 +2576,7 @@ impl Workspace { }; let this = this.clone(); - let abs_path: Arc = SanitizedPath::from(abs_path.clone()).into(); + let abs_path: Arc = SanitizedPath::new(&abs_path).as_path().into(); let fs = fs.clone(); let pane = pane.clone(); let task = cx.spawn(async move |cx| { @@ -4375,6 +4375,11 @@ impl Workspace { return; } window.set_window_title(&title); + SystemWindowTabController::update_tab_title( + cx, + window.window_handle().window_id(), + SharedString::from(&title), + ); self.last_window_title = Some(title); } @@ -5249,7 +5254,7 @@ impl Workspace { fn serialize_workspace_location(&self, cx: &App) -> WorkspaceLocation { let paths = PathList::new(&self.root_paths(cx)); - if let Some(connection) = self.project.read(cx).ssh_connection_options(cx) { + if let Some(connection) = self.project.read(cx).remote_connection_options(cx) { WorkspaceLocation::Location( SerializedWorkspaceLocation::Ssh(SerializedSshConnection { host: connection.host, @@ -5797,17 +5802,22 @@ impl Workspace { return; }; let windows = cx.windows(); - let Some(next_window) = windows - .iter() - .cycle() - .skip_while(|window| window.window_id() != current_window_id) - .nth(1) - else { - return; - }; - next_window - .update(cx, |_, window, _| window.activate_window()) - .ok(); + let next_window = + SystemWindowTabController::get_next_tab_group_window(cx, current_window_id).or_else( + || { + windows + .iter() + .cycle() + .skip_while(|window| window.window_id() != current_window_id) + .nth(1) + }, + ); + + if let Some(window) = next_window { + window + .update(cx, |_, window, _| window.activate_window()) + .ok(); + } } pub fn activate_previous_window(&mut self, cx: &mut Context) { @@ -5815,18 +5825,23 @@ impl Workspace { return; }; let windows = cx.windows(); - let Some(prev_window) = windows - .iter() - .rev() - .cycle() - .skip_while(|window| window.window_id() != current_window_id) - .nth(1) - else { - return; - }; - prev_window - .update(cx, |_, window, _| window.activate_window()) - .ok(); + let prev_window = + SystemWindowTabController::get_prev_tab_group_window(cx, current_window_id).or_else( + || { + windows + .iter() + .rev() + .cycle() + .skip_while(|window| window.window_id() != current_window_id) + .nth(1) + }, + ); + + if let Some(window) = prev_window { + window + .update(cx, |_, window, _| window.activate_window()) + .ok(); + } } pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { @@ -6875,7 +6890,8 @@ async fn join_channel_internal( | Status::Authenticating | Status::Authenticated | Status::Reconnecting - | Status::Reauthenticating => continue, + | Status::Reauthenticating + | Status::Reauthenticated => continue, Status::Connected { .. } => break 'outer, Status::SignedOut | Status::AuthenticationError => { return Err(ErrorCode::SignedOut.into()); @@ -6917,7 +6933,7 @@ async fn join_channel_internal( return None; } - if (project.is_local() || project.is_via_ssh()) + if (project.is_local() || project.is_via_remote_server()) && project.visible_worktrees(cx).any(|tree| { tree.read(cx) .root_entry() @@ -7263,7 +7279,7 @@ pub fn open_ssh_project_with_new_connection( window: WindowHandle, connection_options: SshConnectionOptions, cancel_rx: oneshot::Receiver<()>, - delegate: Arc, + delegate: Arc, app_state: Arc, paths: Vec, cx: &mut App, @@ -7274,7 +7290,7 @@ pub fn open_ssh_project_with_new_connection( let session = match cx .update(|cx| { - remote::SshRemoteClient::new( + remote::RemoteClient::ssh( ConnectionIdentifier::Workspace(workspace_id.0), connection_options, cancel_rx, @@ -7289,7 +7305,7 @@ pub fn open_ssh_project_with_new_connection( }; let project = cx.update(|cx| { - project::Project::ssh( + project::Project::remote( session, app_state.client.clone(), app_state.node_runtime.clone(), diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 3b6bc1ea970d0e7502e36f75630c9e8dd05906b5..0d7fb9bb9c1ae6f8ff4a6644132c4a347da4117d 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -29,6 +29,7 @@ pub struct WorkspaceSettings { pub on_last_window_closed: OnLastWindowClosed, pub resize_all_panels_in_dock: Vec, pub close_on_file_delete: bool, + pub use_system_window_tabs: bool, pub zoomed_padding: bool, } @@ -203,6 +204,10 @@ pub struct WorkspaceSettingsContent { /// /// Default: false pub close_on_file_delete: Option, + /// Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). + /// + /// Default: false + pub use_system_window_tabs: Option, /// Whether to show padding for zoomed panels. /// When enabled, zoomed bottom panels will have some top padding, /// while zoomed left/right panels will have padding to the right/left (respectively). @@ -357,6 +362,8 @@ impl Settings for WorkspaceSettings { current.max_tabs = Some(n) } + vscode.bool_setting("window.nativeTabs", &mut current.use_system_window_tabs); + // some combination of "window.restoreWindows" and "workbench.startupEditor" might // map to our "restore_on_startup" diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index cf61ee2669663f172fa1238db4d359970e23e4bc..711c99ce28bbbc557a293d8b644ac6594f31ad7f 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -158,7 +158,7 @@ pub struct RemoteWorktree { #[derive(Clone)] pub struct Snapshot { id: WorktreeId, - abs_path: SanitizedPath, + abs_path: Arc, root_name: String, root_char_bag: CharBag, entries_by_path: SumTree, @@ -457,7 +457,7 @@ enum ScanState { scanning: bool, }, RootUpdated { - new_path: Option, + new_path: Option>, }, } @@ -763,8 +763,8 @@ impl Worktree { pub fn abs_path(&self) -> Arc { match self { - Worktree::Local(worktree) => worktree.abs_path.clone().into(), - Worktree::Remote(worktree) => worktree.abs_path.clone().into(), + Worktree::Local(worktree) => SanitizedPath::cast_arc(worktree.abs_path.clone()), + Worktree::Remote(worktree) => SanitizedPath::cast_arc(worktree.abs_path.clone()), } } @@ -1813,7 +1813,7 @@ impl LocalWorktree { // Otherwise, the FS watcher would do it on the `RootUpdated` event, // but with a noticeable delay, so we handle it proactively. local.update_abs_path_and_refresh( - Some(SanitizedPath::from(abs_path.clone())), + Some(SanitizedPath::new_arc(&abs_path)), cx, ); Task::ready(Ok(this.root_entry().cloned())) @@ -2090,7 +2090,7 @@ impl LocalWorktree { fn update_abs_path_and_refresh( &mut self, - new_path: Option, + new_path: Option>, cx: &Context, ) { if let Some(new_path) = new_path { @@ -2340,7 +2340,7 @@ impl Snapshot { pub fn new(id: u64, root_name: String, abs_path: Arc) -> Self { Snapshot { id: WorktreeId::from_usize(id as usize), - abs_path: abs_path.into(), + abs_path: SanitizedPath::from_arc(abs_path), root_char_bag: root_name.chars().map(|c| c.to_ascii_lowercase()).collect(), root_name, always_included_entries: Default::default(), @@ -2368,7 +2368,7 @@ impl Snapshot { // // This is definitely a bug, but it's not clear if we should handle it here or not. pub fn abs_path(&self) -> &Arc { - self.abs_path.as_path() + SanitizedPath::cast_arc_ref(&self.abs_path) } fn build_initial_update(&self, project_id: u64, worktree_id: u64) -> proto::UpdateWorktree { @@ -2464,7 +2464,7 @@ impl Snapshot { Some(removed_entry.path) } - fn update_abs_path(&mut self, abs_path: SanitizedPath, root_name: String) { + fn update_abs_path(&mut self, abs_path: Arc, root_name: String) { self.abs_path = abs_path; if root_name != self.root_name { self.root_char_bag = root_name.chars().map(|c| c.to_ascii_lowercase()).collect(); @@ -2483,7 +2483,7 @@ impl Snapshot { update.removed_entries.len() ); self.update_abs_path( - SanitizedPath::from(PathBuf::from_proto(update.abs_path)), + SanitizedPath::new_arc(&PathBuf::from_proto(update.abs_path)), update.root_name, ); @@ -3151,16 +3151,6 @@ impl BackgroundScannerState { .work_directory_abs_path(&work_directory) .log_err()?; - if self - .snapshot - .git_repositories - .get(&work_dir_entry.id) - .is_some() - { - log::trace!("existing git repository for {work_directory:?}"); - return None; - } - let dot_git_abs_path: Arc = self .snapshot .abs_path @@ -3859,7 +3849,11 @@ impl BackgroundScanner { root_entry.is_ignored = true; state.insert_entry(root_entry.clone(), self.fs.as_ref(), self.watcher.as_ref()); } - state.enqueue_scan_dir(root_abs_path.into(), &root_entry, &scan_job_tx); + state.enqueue_scan_dir( + SanitizedPath::cast_arc(root_abs_path), + &root_entry, + &scan_job_tx, + ); } }; @@ -3940,8 +3934,9 @@ impl BackgroundScanner { self.forcibly_load_paths(&request.relative_paths).await; let root_path = self.state.lock().snapshot.abs_path.clone(); - let root_canonical_path = match self.fs.canonicalize(root_path.as_path()).await { - Ok(path) => SanitizedPath::from(path), + let root_canonical_path = self.fs.canonicalize(root_path.as_path()).await; + let root_canonical_path = match &root_canonical_path { + Ok(path) => SanitizedPath::new(path), Err(err) => { log::error!("failed to canonicalize root path {root_path:?}: {err}"); return true; @@ -3969,8 +3964,8 @@ impl BackgroundScanner { } self.reload_entries_for_paths( - root_path, - root_canonical_path, + &root_path, + &root_canonical_path, &request.relative_paths, abs_paths, None, @@ -3982,8 +3977,9 @@ impl BackgroundScanner { async fn process_events(&self, mut abs_paths: Vec) { let root_path = self.state.lock().snapshot.abs_path.clone(); - let root_canonical_path = match self.fs.canonicalize(root_path.as_path()).await { - Ok(path) => SanitizedPath::from(path), + let root_canonical_path = self.fs.canonicalize(root_path.as_path()).await; + let root_canonical_path = match &root_canonical_path { + Ok(path) => SanitizedPath::new(path), Err(err) => { let new_path = self .state @@ -3992,7 +3988,7 @@ impl BackgroundScanner { .root_file_handle .clone() .and_then(|handle| handle.current_path(&self.fs).log_err()) - .map(SanitizedPath::from) + .map(|path| SanitizedPath::new_arc(&path)) .filter(|new_path| *new_path != root_path); if let Some(new_path) = new_path.as_ref() { @@ -4021,7 +4017,7 @@ impl BackgroundScanner { abs_paths.sort_unstable(); abs_paths.dedup_by(|a, b| a.starts_with(b)); abs_paths.retain(|abs_path| { - let abs_path = SanitizedPath::from(abs_path); + let abs_path = &SanitizedPath::new(abs_path); let snapshot = &self.state.lock().snapshot; { @@ -4064,7 +4060,7 @@ impl BackgroundScanner { return false; }; - if abs_path.0.file_name() == Some(*GITIGNORE) { + if abs_path.file_name() == Some(*GITIGNORE) { for (_, repo) in snapshot.git_repositories.iter().filter(|(_, repo)| repo.directory_contains(&relative_path)) { if !dot_git_abs_paths.iter().any(|dot_git_abs_path| dot_git_abs_path == repo.common_dir_abs_path.as_ref()) { dot_git_abs_paths.push(repo.common_dir_abs_path.to_path_buf()); @@ -4103,8 +4099,8 @@ impl BackgroundScanner { let (scan_job_tx, scan_job_rx) = channel::unbounded(); log::debug!("received fs events {:?}", relative_paths); self.reload_entries_for_paths( - root_path, - root_canonical_path, + &root_path, + &root_canonical_path, &relative_paths, abs_paths, Some(scan_job_tx.clone()), @@ -4451,8 +4447,8 @@ impl BackgroundScanner { /// All list arguments should be sorted before calling this function async fn reload_entries_for_paths( &self, - root_abs_path: SanitizedPath, - root_canonical_path: SanitizedPath, + root_abs_path: &SanitizedPath, + root_canonical_path: &SanitizedPath, relative_paths: &[Arc], abs_paths: Vec, scan_queue_tx: Option>, @@ -4480,7 +4476,7 @@ impl BackgroundScanner { } } - anyhow::Ok(Some((metadata, SanitizedPath::from(canonical_path)))) + anyhow::Ok(Some((metadata, SanitizedPath::new_arc(&canonical_path)))) } else { Ok(None) } diff --git a/crates/x_ai/src/x_ai.rs b/crates/x_ai/src/x_ai.rs index 569503784c68d2676de24d369cb36774ee48f054..50f8681c31b5c95d2fc74351416512cbb539252f 100644 --- a/crates/x_ai/src/x_ai.rs +++ b/crates/x_ai/src/x_ai.rs @@ -20,6 +20,8 @@ pub enum Model { Grok3MiniFast, #[serde(rename = "grok-4-latest")] Grok4, + #[serde(rename = "grok-code-fast-1")] + GrokCodeFast1, #[serde(rename = "custom")] Custom { name: String, @@ -43,6 +45,7 @@ impl Model { "grok-3-mini" => Ok(Self::Grok3Mini), "grok-3-fast" => Ok(Self::Grok3Fast), "grok-3-mini-fast" => Ok(Self::Grok3MiniFast), + "grok-code-fast-1" => Ok(Self::GrokCodeFast1), _ => anyhow::bail!("invalid model id '{id}'"), } } @@ -55,6 +58,7 @@ impl Model { Self::Grok3Fast => "grok-3-fast", Self::Grok3MiniFast => "grok-3-mini-fast", Self::Grok4 => "grok-4", + Self::GrokCodeFast1 => "grok-code-fast-1", Self::Custom { name, .. } => name, } } @@ -67,6 +71,7 @@ impl Model { Self::Grok3Fast => "Grok 3 Fast", Self::Grok3MiniFast => "Grok 3 Mini Fast", Self::Grok4 => "Grok 4", + Self::GrokCodeFast1 => "Grok Code Fast 1", Self::Custom { name, display_name, .. } => display_name.as_ref().unwrap_or(name), @@ -76,7 +81,7 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { Self::Grok3 | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast => 131_072, - Self::Grok4 => 256_000, + Self::Grok4 | Self::GrokCodeFast1 => 256_000, Self::Grok2Vision => 8_192, Self::Custom { max_tokens, .. } => *max_tokens, } @@ -85,7 +90,7 @@ impl Model { pub fn max_output_tokens(&self) -> Option { match self { Self::Grok3 | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast => Some(8_192), - Self::Grok4 => Some(64_000), + Self::Grok4 | Self::GrokCodeFast1 => Some(64_000), Self::Grok2Vision => Some(4_096), Self::Custom { max_output_tokens, .. @@ -101,7 +106,7 @@ impl Model { | Self::Grok3Fast | Self::Grok3MiniFast | Self::Grok4 => true, - Model::Custom { .. } => false, + Self::GrokCodeFast1 | Model::Custom { .. } => false, } } @@ -116,7 +121,8 @@ impl Model { | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast - | Self::Grok4 => true, + | Self::Grok4 + | Self::GrokCodeFast1 => true, Model::Custom { .. } => false, } } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 6f4ead9ebb6d39d0cd14a5c6ad1fca07b9c1e83a..0ddfe3dde1b57de8f6fb5ae83d1bb3ccef8b12ff 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.202.0" +version = "0.203.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e99c8b564b2cd7915fc352f9caee97b77eccaaf5..5e7934c3094755b39535ef054f077dbc9fb180af 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -2,7 +2,7 @@ mod reliability; mod zed; use agent_ui::AgentPanel; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Error, Result}; use clap::{Parser, command}; use cli::FORCE_CLI_MODE_ENV_VAR_NAME; use client::{Client, ProxySettings, UserStore, parse_zed_link}; @@ -947,9 +947,13 @@ async fn installation_id() -> Result { async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp) -> Result<()> { if let Some(locations) = restorable_workspace_locations(cx, &app_state).await { + let use_system_window_tabs = cx + .update(|cx| WorkspaceSettings::get(None, cx).use_system_window_tabs) + .unwrap_or(false); + let mut results: Vec> = Vec::new(); let mut tasks = Vec::new(); - for (location, paths) in locations { + for (index, (location, paths)) in locations.into_iter().enumerate() { match location { SerializedWorkspaceLocation::Local => { let app_state = app_state.clone(); @@ -964,7 +968,14 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp })?; open_task.await.map(|_| ()) }); - tasks.push(task); + + // If we're using system window tabs and this is the first workspace, + // wait for it to finish so that the other windows can be added as tabs. + if use_system_window_tabs && index == 0 { + results.push(task.await); + } else { + tasks.push(task); + } } SerializedWorkspaceLocation::Ssh(ssh) => { let app_state = app_state.clone(); @@ -998,7 +1009,7 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp } // Wait for all workspaces to open concurrently - let results = future::join_all(tasks).await; + results.extend(future::join_all(tasks).await); // Show notifications for any errors that occurred let mut error_count = 0; diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index ac06f1fd9f4d9842eb34a67abeef2432074ecc91..9c12a5f1466323cf22233156d1e9bde741f10fa6 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -220,10 +220,10 @@ pub fn init( let installation_id = installation_id.clone(); let system_id = system_id.clone(); - let Some(ssh_client) = project.ssh_client() else { + let Some(remote_client) = project.remote_client() else { return; }; - ssh_client.update(cx, |client, cx| { + remote_client.update(cx, |client, cx| { if !TelemetrySettings::get_global(cx).diagnostics { return; } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 638e1dca0e261dcb7d66c7ac2b8df9ed9ac78ff9..5a180e4b42705332bd51dffe43943d131a42907f 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -32,7 +32,8 @@ use gpui::{ }; use image_viewer::ImageInfo; use language::Capability; -use language_tools::lsp_tool::{self, LspTool}; +use language_tools::lsp_button::{self, LspButton}; +use language_tools::lsp_log_view::LspLogToolbarItemView; use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationType}; use migrator::{migrate_keymap, migrate_settings}; use onboarding::DOCS_URL; @@ -282,6 +283,8 @@ pub fn build_window_options(display_uuid: Option, cx: &mut App) -> WindowO _ => gpui::WindowDecorations::Client, }; + let use_system_window_tabs = WorkspaceSettings::get_global(cx).use_system_window_tabs; + WindowOptions { titlebar: Some(TitlebarOptions { title: None, @@ -301,6 +304,12 @@ pub fn build_window_options(display_uuid: Option, cx: &mut App) -> WindowO width: px(360.0), height: px(240.0), }), + tabbing_identifier: if use_system_window_tabs { + Some(String::from("zed")) + } else { + None + }, + ..Default::default() } } @@ -388,12 +397,12 @@ pub fn initialize_workspace( let vim_mode_indicator = cx.new(|cx| vim::ModeIndicator::new(window, cx)); let image_info = cx.new(|_cx| ImageInfo::new(workspace)); - let lsp_tool_menu_handle = PopoverMenuHandle::default(); - let lsp_tool = - cx.new(|cx| LspTool::new(workspace, lsp_tool_menu_handle.clone(), window, cx)); + let lsp_button_menu_handle = PopoverMenuHandle::default(); + let lsp_button = + cx.new(|cx| LspButton::new(workspace, lsp_button_menu_handle.clone(), window, cx)); workspace.register_action({ - move |_, _: &lsp_tool::ToggleMenu, window, cx| { - lsp_tool_menu_handle.toggle(window, cx); + move |_, _: &lsp_button::ToggleMenu, window, cx| { + lsp_button_menu_handle.toggle(window, cx); } }); @@ -401,7 +410,7 @@ pub fn initialize_workspace( cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace)); workspace.status_bar().update(cx, |status_bar, cx| { status_bar.add_left_item(search_button, window, cx); - status_bar.add_left_item(lsp_tool, window, cx); + status_bar.add_left_item(lsp_button, window, cx); status_bar.add_left_item(diagnostic_summary, window, cx); status_bar.add_left_item(activity_indicator, window, cx); status_bar.add_right_item(edit_prediction_button, window, cx); @@ -918,7 +927,7 @@ fn register_actions( capture_audio(workspace, window, cx); }); - if workspace.project().read(cx).is_via_ssh() { + if workspace.project().read(cx).is_via_remote_server() { workspace.register_action({ move |workspace, _: &OpenServerSettings, window, cx| { let open_server_settings = workspace @@ -980,7 +989,7 @@ fn initialize_pane( toolbar.add_item(diagnostic_editor_controls, window, cx); let project_search_bar = cx.new(|_| ProjectSearchBar::new()); toolbar.add_item(project_search_bar, window, cx); - let lsp_log_item = cx.new(|_| language_tools::LspLogToolbarItemView::new()); + let lsp_log_item = cx.new(|_| LspLogToolbarItemView::new()); toolbar.add_item(lsp_log_item, window, cx); let dap_log_item = cx.new(|_| debugger_tools::DapLogToolbarItemView::new()); toolbar.add_item(dap_log_item, window, cx); @@ -1308,11 +1317,11 @@ pub fn handle_keymap_file_changes( }) .detach(); - let mut current_mapping = settings::get_key_equivalents(cx.keyboard_layout().id()); + let mut current_layout_id = cx.keyboard_layout().id().to_string(); cx.on_keyboard_layout_change(move |cx| { - let next_mapping = settings::get_key_equivalents(cx.keyboard_layout().id()); - if next_mapping != current_mapping { - current_mapping = next_mapping; + let next_layout_id = cx.keyboard_layout().id(); + if next_layout_id != current_layout_id { + current_layout_id = next_layout_id.to_string(); keyboard_layout_tx.unbounded_send(()).ok(); } }) @@ -1543,7 +1552,7 @@ pub fn open_new_ssh_project_from_project( cx: &mut Context, ) -> Task> { let app_state = workspace.app_state().clone(); - let Some(ssh_client) = workspace.project().read(cx).ssh_client() else { + let Some(ssh_client) = workspace.project().read(cx).remote_client() else { return Task::ready(Err(anyhow::anyhow!("Not an ssh project"))); }; let connection_options = ssh_client.read(cx).connection_options(); @@ -4506,6 +4515,7 @@ mod tests { "zed", "zed_predict_onboarding", "zeta", + "window", ]; assert_eq!( all_namespaces, @@ -4729,7 +4739,7 @@ mod tests { // and key strokes contain the given key bindings .into_iter() - .any(|binding| binding.keystrokes().iter().any(|k| k.key == key)), + .any(|binding| binding.keystrokes().iter().any(|k| k.key() == key)), "On {} Failed to find {} with key binding {}", line, action.name(), diff --git a/crates/zed/src/zed/component_preview/persistence.rs b/crates/zed/src/zed/component_preview/persistence.rs index 780f7f76264e9e132d0a932e213bb1743156106d..c37a4cc3899fd2da4834070f0a987650079ad515 100644 --- a/crates/zed/src/zed/component_preview/persistence.rs +++ b/crates/zed/src/zed/component_preview/persistence.rs @@ -1,10 +1,17 @@ use anyhow::Result; -use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; +use db::{ + query, + sqlez::{domain::Domain, statement::Statement, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, +}; use workspace::{ItemId, WorkspaceDb, WorkspaceId}; -define_connection! { - pub static ref COMPONENT_PREVIEW_DB: ComponentPreviewDb = - &[sql!( +pub struct ComponentPreviewDb(ThreadSafeConnection); + +impl Domain for ComponentPreviewDb { + const NAME: &str = stringify!(ComponentPreviewDb); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE component_previews ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -13,9 +20,11 @@ define_connection! { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - )]; + )]; } +db::static_connection!(COMPONENT_PREVIEW_DB, ComponentPreviewDb, [WorkspaceDb]); + impl ComponentPreviewDb { pub async fn save_active_page( &self, diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index 06ed524e6505e6b71d19efd88dd848582fa502fd..e21720c1a1ef6900f3fb74a6b068ed2c0c0303f8 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -8,7 +8,6 @@ use settings::SettingsStore; use std::{cell::RefCell, rc::Rc, sync::Arc}; use supermaven::{Supermaven, SupermavenCompletionProvider}; use ui::Window; -use workspace::Workspace; use zeta::{ProviderDataCollection, ZetaEditPredictionProvider}; pub fn init(client: Arc, user_store: Entity, cx: &mut App) { @@ -204,10 +203,14 @@ fn assign_edit_prediction_provider( } } +<<<<<<< HEAD let workspace = window.root::().flatten(); let zeta = zeta::Zeta::register(workspace, worktree, client.clone(), user_store, cx); +======= + let zeta = zeta::Zeta::register(worktree, client.clone(), user_store, cx); +>>>>>>> main if let Some(buffer) = &singleton_buffer && buffer.read(cx).file().is_some() diff --git a/crates/zed/src/zed/quick_action_bar/preview.rs b/crates/zed/src/zed/quick_action_bar/preview.rs index 3772104f39050c53ced37031e2c2f3e052dcb12d..fb5a75f78d834ab3943e9dfd87cc7744fc453fcd 100644 --- a/crates/zed/src/zed/quick_action_bar/preview.rs +++ b/crates/zed/src/zed/quick_action_bar/preview.rs @@ -72,7 +72,10 @@ impl QuickActionBar { Tooltip::with_meta( tooltip_text, Some(open_action_for_tooltip), - format!("{} to open in a split", text_for_keystroke(&alt_click, cx)), + format!( + "{} to open in a split", + text_for_keystroke(&alt_click.modifiers, &alt_click.key, cx) + ), window, cx, ) diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index a5223a2cdf6d7a7799050be9e8b5e77df003cd9c..8f4c42ca496e26d23765eb006d7eb0fe9db197ee 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -284,6 +284,8 @@ pub mod agent { OpenSettings, /// Opens the agent onboarding modal. OpenOnboardingModal, + /// Opens the ACP onboarding modal. + OpenAcpOnboardingModal, /// Resets the agent onboarding state. ResetOnboarding, /// Starts a chat conversation with the agent. diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index b26ac42b3b25c8af7e2fe36b896eb8628958b756..ecdba5139f5444ad6426283a7b9e30d072c473e1 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -27,7 +27,7 @@ use collections::{HashMap, HashSet, VecDeque}; use futures::AsyncReadExt; use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EntityId, Global, SemanticVersion, - Subscription, Task, WeakEntity, actions, + SharedString, Subscription, Task, actions, }; use http_client::{AsyncBody, HttpClient, Method, Request, Response}; use input_excerpt::excerpt_for_cursor_position; @@ -55,8 +55,7 @@ use telemetry_events::EditPredictionRating; use thiserror::Error; use util::{ResultExt, maybe}; use uuid::Uuid; -use workspace::Workspace; -use workspace::notifications::{ErrorMessagePrompt, NotificationId}; +use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; use worktree::Worktree; const CURSOR_MARKER: &str = "<|user_cursor_is_here|>"; @@ -235,7 +234,10 @@ impl std::fmt::Debug for EditPrediction { } pub struct Zeta { +<<<<<<< HEAD workspace: WeakEntity, +======= +>>>>>>> main client: Arc, events: VecDeque, registered_buffers: HashMap, @@ -274,14 +276,17 @@ impl Zeta { } pub fn register( +<<<<<<< HEAD workspace: Option>, +======= +>>>>>>> main worktree: Option>, client: Arc, user_store: Entity, cx: &mut App, ) -> Entity { let this = Self::global(cx).unwrap_or_else(|| { - let entity = cx.new(|cx| Self::new(workspace, client, user_store, cx)); + let entity = cx.new(|cx| Self::new(client, user_store, cx)); cx.set_global(ZetaGlobal(entity.clone())); entity }); @@ -306,12 +311,16 @@ impl Zeta { self.user_store.read(cx).edit_prediction_usage() } +<<<<<<< HEAD fn new( workspace: Option>, client: Arc, user_store: Entity, cx: &mut Context, ) -> Self { +======= + fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { +>>>>>>> main let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); let data_collection_choice = Self::load_data_collection_choices(); @@ -354,10 +363,13 @@ impl Zeta { } Self { +<<<<<<< HEAD workspace: workspace.map_or_else( || WeakEntity::new_invalid(), |workspace| workspace.downgrade(), ), +======= +>>>>>>> main client, events: VecDeque::with_capacity(MAX_EVENT_COUNT), shown_completions: VecDeque::with_capacity(MAX_SHOWN_COMPLETION_COUNT), @@ -454,8 +466,12 @@ impl Zeta { fn request_completion_impl( &mut self, +<<<<<<< HEAD workspace: Option>, project: Option>, +======= + project: Option<&Entity>, +>>>>>>> main buffer: &Entity, cursor: language::Anchor, can_collect_data: CanCollectData, @@ -554,23 +570,20 @@ impl Zeta { zeta.update_required = true; }); - if let Some(workspace) = workspace { - workspace.update(cx, |workspace, cx| { - workspace.show_notification( - NotificationId::unique::(), - cx, - |cx| { - cx.new(|cx| { - ErrorMessagePrompt::new(err.to_string(), cx) - .with_link_button( - "Update Zed", - "https://zed.dev/releases", - ) - }) - }, - ); - }); - } + let error_message: SharedString = err.to_string().into(); + show_app_notification( + NotificationId::unique::(), + cx, + move |cx| { + cx.new(|cx| { + ErrorMessagePrompt::new(error_message.clone(), cx) + .with_link_button( + "Update Zed", + "https://zed.dev/releases", + ) + }) + }, + ); }) .ok(); } @@ -802,6 +815,7 @@ and then another ) -> Task>> { use std::future::ready; +<<<<<<< HEAD self.request_completion_impl( None, project, @@ -811,6 +825,11 @@ and then another cx, |_params| ready(Ok((response, None))), ) +======= + self.request_completion_impl(project, buffer, position, false, cx, |_params| { + ready(Ok((response, None))) + }) +>>>>>>> main } pub fn request_completion( @@ -822,7 +841,10 @@ and then another cx: &mut Context, ) -> Task>> { self.request_completion_impl( +<<<<<<< HEAD self.workspace.upgrade(), +======= +>>>>>>> main project, buffer, position, @@ -2408,7 +2430,7 @@ mod tests { // Construct the fake server to authenticate. let _server = FakeServer::for_client(42, &client, cx).await; let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let zeta = cx.new(|cx| Zeta::new(None, client, user_store.clone(), cx)); + let zeta = cx.new(|cx| Zeta::new(client, user_store.clone(), cx)); let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0))); @@ -2472,7 +2494,7 @@ mod tests { // Construct the fake server to authenticate. let _server = FakeServer::for_client(42, &client, cx).await; let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let zeta = cx.new(|cx| Zeta::new(None, client, user_store.clone(), cx)); + let zeta = cx.new(|cx| Zeta::new(client, user_store.clone(), cx)); let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 251cad6234f10d73f423680bcd600500daae65b2..9d07881914d1f73a7333d3dc67ad1d3ca6731bc5 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -47,6 +47,7 @@ - [Overview](./ai/overview.md) - [Agent Panel](./ai/agent-panel.md) - [Tools](./ai/tools.md) + - [External Agents](./ai/external-agents.md) - [Inline Assistant](./ai/inline-assistant.md) - [Edit Prediction](./ai/edit-prediction.md) - [Text Threads](./ai/text-threads.md) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index f944eb88b06c8be21002ff319a972ff1843de39d..002c7d64150d53f734b9f1bbce87567b7c05036a 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -1,14 +1,15 @@ # Agent Panel -The Agent Panel provides you with a surface to interact with LLMs, enabling various types of tasks, such as generating code, asking questions about your codebase, and general inquiries like emails, documentation, and more. +The Agent Panel allows you to interact with many LLMs and coding agents that can support you in various types of tasks, such as generating code, codebase understanding, and other general inquiries like writing emails, documentation, and more. To open it, use the `agent: new thread` action in [the Command Palette](../getting-started.md#command-palette) or click the ✨ (sparkles) icon in the status bar. -If you're using the Agent Panel for the first time, you need to have at least one LLM provider configured. +If you're using the Agent Panel for the first time, you need to have at least one LLM or agent provider configured. You can do that by: 1. [subscribing to our Pro plan](https://zed.dev/pricing), so you have access to our hosted models -2. or by [bringing your own API keys](./llm-providers.md#use-your-own-keys) for your desired provider +2. [bringing your own API keys](./llm-providers.md#use-your-own-keys) for your desired provider +3. using an external agent like [Gemini CLI](./external-agents.md#gemini-cli) ## Overview {#overview} @@ -17,6 +18,15 @@ If you need extra room to type, you can expand the message editor with {#kb agen You should start to see the responses stream in with indications of [which tools](./tools.md) the model is using to fulfill your prompt. +> Note that, at the moment, not all features outlined below work for external agents, like [Gemini CLI](./external-agents.md#gemini-cli)—features like _restoring threads from history_, _checkpoints_, _token usage display_, _model selection_, and others may be supported in the future. + +### Creating New Threads + +By default, the Agent Panel uses Zed's first-party agent. + +To change that, go to the plus button in the top-right of the Agent Panel and choose another option. +You choose to create a new [Text Thread](./text-threads.md) or, if you have [external agents](./external-agents.md) connected, you can create new threads with them. + ### Editing Messages {#editing-messages} Any message that you send to the AI is editable. @@ -30,7 +40,7 @@ The checkpoint button appears even if you interrupt the thread midway through an ### Navigating History {#navigating-history} -To quickly navigate through recently opened threads, use the {#kb agent::ToggleNavigationMenu} binding, when focused on the panel's editor, or click the menu icon button at the top left of the panel to open the dropdown that shows you the six most recent threads. +To quickly navigate through recently opened threads, use the {#kb agent::ToggleNavigationMenu} binding, when focused on the panel's editor, or click the menu icon button at the top right of the panel to open the dropdown that shows you the six most recent threads. The items in this menu function similarly to tabs, and closing them doesn’t delete the thread; instead, it simply removes them from the recent list. @@ -70,16 +80,13 @@ So, if your active tab had edits made by the AI, you'll see diffs with the same Although Zed's agent is very efficient at reading through your code base to autonomously pick up relevant files, directories, and other context, manually adding context is still encouraged as a way to speed up and improve the AI's response quality. -If you have a tab open while using the Agent Panel, that tab appears as a suggested context in form of a dashed button. -You can also add other forms of context by either mentioning them with `@` or hitting the `+` icon button. - -You can even add previous threads as context by mentioning them with `@thread`, or by selecting the "New From Summary" option from the `+` menu to continue a longer conversation, keeping it within the context window. +To add any file, directory, symbol, previous threads, rules files, or even web pages as context, type `@` to mention them in the editor. Pasting images as context is also supported by the Agent Panel. ### Token Usage {#token-usage} -Zed surfaces how many tokens you are consuming for your currently active thread in the panel's toolbar. +Zed surfaces how many tokens you are consuming for your currently active thread nearby the profile selector in the panel's message editor. Depending on how many pieces of context you add, your token consumption can grow rapidly. With that in mind, once you get close to the model's context window, a banner appears below the message editor suggesting to start a new thread with the current one summarized and added as context. @@ -145,7 +152,7 @@ Zed's UI will inform about this via a warning icon that appears close to the mod ## Text Threads {#text-threads} -["Text threads"](./text-threads.md) present your conversation with the LLM in a different format—as raw text. +["Text Threads"](./text-threads.md) present your conversation with the LLM in a different format—as raw text. With text threads, you have full control over the conversation data. You can remove and edit responses from the LLM, swap roles, and include more context earlier in the conversation. diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md index ff97bcb8eeb941d2c072b95dbcc8089da927df42..d78f812e4704123be34144e84709df71474c82c0 100644 --- a/docs/src/ai/agent-settings.md +++ b/docs/src/ai/agent-settings.md @@ -131,7 +131,7 @@ The default value is `false`. ```json { "agent": { - "always_allow_tool_actions": "true" + "always_allow_tool_actions": true } } ``` @@ -146,7 +146,7 @@ The default value is `false`. ```json { "agent": { - "single_file_review": "true" + "single_file_review": true } } ``` @@ -163,7 +163,7 @@ The default value is `false`. ```json { "agent": { - "play_sound_when_agent_done": "true" + "play_sound_when_agent_done": true } } ``` @@ -179,7 +179,7 @@ The default value is `false`. ```json { "agent": { - "use_modifier_to_send": "true" + "use_modifier_to_send": true } } ``` @@ -194,7 +194,7 @@ It is set to `true` by default, but if set to false, the card's height is capped ```json { "agent": { - "expand_edit_card": "false" + "expand_edit_card": false } } ``` @@ -207,7 +207,7 @@ It is set to `true` by default, but if set to false, the card will be fully coll ```json { "agent": { - "expand_terminal_card": "false" + "expand_terminal_card": false } } ``` @@ -220,7 +220,7 @@ The default value is `true`. ```json { "agent": { - "enable_feedback": "false" + "enable_feedback": false } } ``` diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md new file mode 100644 index 0000000000000000000000000000000000000000..3d263afdb060a5acc99e4315ac32c7063386317f --- /dev/null +++ b/docs/src/ai/external-agents.md @@ -0,0 +1,82 @@ +# External Agents + +Zed supports terminal-based agentic coding tools through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com). + +Currently, [Gemini CLI](https://github.com/google-gemini/gemini-cli) serves as the reference implementation, and you can [add custom ACP-compatible agents](#add-custom-agents) as well. + +## Gemini CLI {#gemini-cli} + +Zed provides the ability to run [Gemini CLI](https://github.com/google-gemini/gemini-cli) directly in the [agent panel](./agent-panel.md). + +Under the hood we run Gemini CLI in the background, and talk to it over ACP. +This means that you're running the real Gemini CLI, with all of the advantages of that, but you can see and interact with files in your editor. + +### Getting Started + +As of Zed Stable v0.201.5 you should be able to use Gemini CLI directly from Zed. First open the agent panel with {#kb agent::ToggleFocus}, and then use the `+` button in the top right to start a New Gemini CLI thread. + +If you'd like to bind this to a keyboard shortcut, you can do so by editing your keybindings file to include: + +```json +[ + { + "bindings": { + "cmd-alt-g": ["agent::NewExternalAgentThread", { "agent": "gemini" }] + } + } +] +``` + +#### Installation + +If you don't yet have Gemini CLI installed, then Zed will install a version for you. If you do, then we will use the version of Gemini CLI on your path. + +You need to be running at least Gemini version `0.2.0`, and if your version of Gemini is too old you will see an +error message. + +The instructions to upgrade Gemini depend on how you originally installed it, but typically, running `npm install -g @google/gemini-cli@latest` should work. + +#### Authentication + +After you have Gemini CLI running, you'll be prompted to choose your authentication method. + +Most users should click the "Log in with Google". This will cause a browser window to pop-up and auth directly with Gemini CLI. Zed does not see your oauth or access tokens in this case. + +You can also use the "Gemini API Key". If you select this, and have the `GEMINI_API_KEY` set, then we will use that. Otherwise Zed will prompt you for an API key which will be stored securely in your keychain, and used to start Gemini CLI from within Zed. + +The "Vertex AI" option is for those who are using Vertex AI, and have already configured their environment correctly. + +For more information, see the [Gemini CLI docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/index.md). + +### Usage + +Similar to Zed's first-party agent, you can use Gemini CLI to do anything that you need. + +You can @-mention files, recent threads, symbols, or fetch the web. + +> Note that some first-party agent features don't yet work with Gemini CLI: editing past messages, resuming threads from history, checkpointing, and using the agent in SSH projects. +> We hope to add these features in the near future. + +## Add Custom Agents {#add-custom-agents} + +You can run any agent speaking ACP in Zed by changing your settings as follows: + +```json +{ + "agent_servers": { + "Custom Agent": { + "command": "node", + "args": ["~/projects/agent/index.js", "--acp"], + "env": {} + } + } +} +``` + +This can also be useful if you're in the middle of developing a new agent that speaks the protocol and you want to debug it. + +## Debugging Agents + +When using external agents in Zed, you can access the debug view via with `dev: open acp logs` from the Command Palette. This lets you see the messages being sent and received between Zed and the agent. + +![The debug view for ACP logs.](https://zed.dev/img/acp/acp-logs.webp) diff --git a/docs/src/ai/overview.md b/docs/src/ai/overview.md index 6f081cb243ffcfb77b4304373df67865cc71ee10..8bd45240fdad156e11f28e5ba92289c97de92218 100644 --- a/docs/src/ai/overview.md +++ b/docs/src/ai/overview.md @@ -6,6 +6,8 @@ Learn how to get started using AI with Zed and all its capabilities. - [Configuration](./configuration.md): Learn how to set up different language model providers like Anthropic, OpenAI, Ollama, Google AI, and more. +- [External Agents](./external-agents.md): Learn how to plug in your favorite agent into Zed. + - [Subscription](./subscription.md): Learn about Zed's hosted model service and other billing-related information. - [Privacy and Security](./privacy-and-security.md): Understand how Zed handles privacy and security with AI features. diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index fb139db6e404a22a25c63967ea2c46f94a9ca648..fb9306acc5a4b21b709904618a6438e58c30039f 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -104,6 +104,70 @@ Non-negative `float` values } ``` +## Agent Font Size + +- Description: The font size for text in the agent panel. Inherits the UI font size if unset. +- Setting: `agent_font_size` +- Default: `null` + +**Options** + +`integer` values from `6` to `100` pixels (inclusive) + +## Allow Rewrap + +- Description: Controls where the `editor::Rewrap` action is allowed in the current language scope +- Setting: `allow_rewrap` +- Default: `"in_comments"` + +**Options** + +1. Allow rewrap in comments only: + +```json +{ + "allow_rewrap": "in_comments" +} +``` + +2. Allow rewrap everywhere: + +```json +{ + "allow_rewrap": "everywhere" +} +``` + +3. Never allow rewrap: + +```json +{ + "allow_rewrap": "never" +} +``` + +Note: This setting has no effect in Vim mode, as rewrap is already allowed everywhere. + +## Auto Indent + +- Description: Whether indentation should be adjusted based on the context whilst typing. This can be specified on a per-language basis. +- Setting: `auto_indent` +- Default: `true` + +**Options** + +`boolean` values + +## Auto Indent On Paste + +- Description: Whether indentation of pasted content should be adjusted based on the context +- Setting: `auto_indent_on_paste` +- Default: `true` + +**Options** + +`boolean` values + ## Auto Install extensions - Description: Define extensions to be autoinstalled or never be installed. @@ -182,42 +246,30 @@ Define extensions which should be installed (`true`) or never installed (`false` } ``` -## Restore on Startup +## Autoscroll on Clicks -- Description: Controls session restoration on startup. -- Setting: `restore_on_startup` -- Default: `last_session` +- Description: Whether to scroll when clicking near the edge of the visible text area. +- Setting: `autoscroll_on_clicks` +- Default: `false` **Options** -1. Restore all workspaces that were open when quitting Zed: - -```json -{ - "restore_on_startup": "last_session" -} -``` +`boolean` values -2. Restore the workspace that was closed last: +## Auto Signature Help -```json -{ - "restore_on_startup": "last_workspace" -} -``` +- Description: Show method signatures in the editor, when inside parentheses +- Setting: `auto_signature_help` +- Default: `false` -3. Always start with an empty editor: +**Options** -```json -{ - "restore_on_startup": "none" -} -``` +`boolean` values -## Autoscroll on Clicks +### Show Signature Help After Edits -- Description: Whether to scroll when clicking near the edge of the visible text area. -- Setting: `autoscroll_on_clicks` +- Description: Whether to show the signature help after completion or a bracket pair inserted. If `auto_signature_help` is enabled, this setting will be treated as enabled also. +- Setting: `show_signature_help_after_edits` - Default: `false` **Options** @@ -378,6 +430,24 @@ For example, to use `Nerd Font` as a fallback, add the following to your setting `"standard"`, `"comfortable"` or `{ "custom": float }` (`1` is compact, `2` is loose) +## Centered Layout + +- Description: Configuration for the centered layout mode. +- Setting: `centered_layout` +- Default: + +```json +"centered_layout": { + "left_padding": 0.2, + "right_padding": 0.2, +} +``` + +**Options** + +The `left_padding` and `right_padding` options define the relative width of the +left and right padding of the central pane from the workspace when the centered layout mode is activated. Valid values range is from `0` to `0.4`. + ## Close on File Delete - Description: Whether to automatically close editor tabs when their corresponding files are deleted from disk. @@ -402,23 +472,63 @@ Note: Dirty files (files with unsaved changes) will not be automatically closed `boolean` values -## Centered Layout +## Diagnostics Max Severity -- Description: Configuration for the centered layout mode. -- Setting: `centered_layout` -- Default: +- Description: Which level to use to filter out diagnostics displayed in the editor +- Setting: `diagnostics_max_severity` +- Default: `null` + +**Options** + +1. Allow all diagnostics (default): ```json -"centered_layout": { - "left_padding": 0.2, - "right_padding": 0.2, +{ + "diagnostics_max_severity": null +} +``` + +2. Show only errors: + +```json +{ + "diagnostics_max_severity": "error" +} +``` + +3. Show errors and warnings: + +```json +{ + "diagnostics_max_severity": "warning" +} +``` + +4. Show errors, warnings, and information: + +```json +{ + "diagnostics_max_severity": "information" +} +``` + +5. Show all including hints: + +```json +{ + "diagnostics_max_severity": "hint" } ``` +## Disable AI + +- Description: Whether to disable all AI features in Zed +- Setting: `disable_ai` +- Default: `false` + **Options** -The `left_padding` and `right_padding` options define the relative width of the -left and right padding of the central pane from the workspace when the centered layout mode is activated. Valid values range is from `0` to `0.4`. +`boolean` values ## Direnv Integration @@ -435,6 +545,42 @@ There are two options to choose from: 1. `shell_hook`: Use the shell hook to load direnv. This relies on direnv to activate upon entering the directory. Supports POSIX shells and fish. 2. `direct`: Use `direnv export json` to load direnv. This will load direnv directly without relying on the shell hook and might cause some inconsistencies. This allows direnv to work with any shell. +## Double Click In Multibuffer + +- Description: What to do when multibuffer is double clicked in some of its excerpts (parts of singleton buffers) +- Setting: `double_click_in_multibuffer` +- Default: `"select"` + +**Options** + +1. Behave as a regular buffer and select the whole word (default): + +```json +{ + "double_click_in_multibuffer": "select" +} +``` + +2. Open the excerpt clicked as a new buffer in the new tab: + +```json +{ + "double_click_in_multibuffer": "open" +} +``` + +For the case of "open", regular selection behavior can be achieved by holding `alt` when double clicking. + +## Drop Target Size + +- Description: Relative size of the drop target in the editor that will open dropped file as a split pane (0-0.5). For example, 0.25 means if you drop onto the top/bottom quarter of the pane a new vertical split will be used, if you drop onto the left/right quarter of the pane a new horizontal split will be used. +- Setting: `drop_target_size` +- Default: `0.2` + +**Options** + +`float` values between `0` and `0.5` + ## Edit Predictions - Description: Settings for edit predictions. @@ -581,6 +727,32 @@ List of `string` values "cursor_shape": "hollow" ``` +## Gutter + +- Description: Settings for the editor gutter +- Setting: `gutter` +- Default: + +```json +{ + "gutter": { + "line_numbers": true, + "runnables": true, + "breakpoints": true, + "folds": true, + "min_line_number_digits": 4 + } +} +``` + +**Options** + +- `line_numbers`: Whether to show line numbers in the gutter +- `runnables`: Whether to show runnable buttons in the gutter +- `breakpoints`: Whether to show breakpoints in the gutter +- `folds`: Whether to show fold buttons in the gutter +- `min_line_number_digits`: Minimum number of characters to reserve space for in the gutter + ## Hide Mouse - Description: Determines when the mouse cursor should be hidden in an editor or input box. @@ -1249,6 +1421,16 @@ or Each option controls displaying of a particular toolbar element. If all elements are hidden, the editor toolbar is not displayed. +## Use System Tabs + +- Description: Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). +- Setting: `use_system_window_tabs` +- Default: `false` + +**Options** + +This setting enables integration with macOS’s native window tabbing feature. When set to `true`, Zed windows can be grouped together as tabs in a single macOS window, following the system-wide tabbing preferences set by the user (such as "Always", "In Full Screen", or "Never"). This setting is only available on macOS. + ## Enable Language Server - Description: Whether or not to use language servers to provide code intelligence. @@ -1269,6 +1451,26 @@ Each option controls displaying of a particular toolbar element. If all elements `boolean` values +## Expand Excerpt Lines + +- Description: The default number of lines to expand excerpts in the multibuffer by +- Setting: `expand_excerpt_lines` +- Default: `5` + +**Options** + +Positive `integer` values + +## Extend Comment On Newline + +- Description: Whether to start a new line with a comment when a previous line is a comment as well. +- Setting: `extend_comment_on_newline` +- Default: `true` + +**Options** + +`boolean` values + ## Status Bar - Description: Control various elements in the status bar. Note that some items in the status bar have their own settings set elsewhere. @@ -1327,6 +1529,24 @@ While other options may be changed at a runtime and should be placed under `sett } ``` +## Global LSP Settings + +- Description: Configuration for global LSP settings that apply to all language servers +- Setting: `global_lsp_settings` +- Default: + +```json +{ + "global_lsp_settings": { + "button": true + } +} +``` + +**Options** + +- `button`: Whether to show the LSP status button in the status bar + ## LSP Highlight Debounce - Description: The debounce delay in milliseconds before querying highlights from the language server based on the current cursor location. @@ -1349,6 +1569,68 @@ While other options may be changed at a runtime and should be placed under `sett `integer` values representing milliseconds +## Features + +- Description: Features that can be globally enabled or disabled +- Setting: `features` +- Default: + +```json +{ + "features": { + "edit_prediction_provider": "zed" + } +} +``` + +### Edit Prediction Provider + +- Description: Which edit prediction provider to use +- Setting: `edit_prediction_provider` +- Default: `"zed"` + +**Options** + +1. Use Zeta as the edit prediction provider: + +```json +{ + "features": { + "edit_prediction_provider": "zed" + } +} +``` + +2. Use Copilot as the edit prediction provider: + +```json +{ + "features": { + "edit_prediction_provider": "copilot" + } +} +``` + +3. Use Supermaven as the edit prediction provider: + +```json +{ + "features": { + "edit_prediction_provider": "supermaven" + } +} +``` + +4. Turn off edit predictions across all providers + +```json +{ + "features": { + "edit_prediction_provider": "none" + } +} +``` + ## Format On Save - Description: Whether or not to perform a buffer format before saving. @@ -1892,18 +2174,62 @@ Example: } ``` -## Indent Guides +## Go to Definition Fallback -- Description: Configuration related to indent guides. Indent guides can be configured separately for each language. -- Setting: `indent_guides` -- Default: +- Description: What to do when the "go to definition" action fails to find a definition +- Setting: `go_to_definition_fallback` +- Default: `"find_all_references"` + +**Options** + +1. Do nothing: ```json { - "indent_guides": { - "enabled": true, - "line_width": 1, - "active_line_width": 1, + "go_to_definition_fallback": "none" +} +``` + +2. Find references for the same symbol (default): + +```json +{ + "go_to_definition_fallback": "find_all_references" +} +``` + +## Hard Tabs + +- Description: Whether to indent lines using tab characters or multiple spaces. +- Setting: `hard_tabs` +- Default: `false` + +**Options** + +`boolean` values + +## Helix Mode + +- Description: Whether or not to enable Helix mode. Enabling `helix_mode` also enables `vim_mode`. See the [Helix documentation](./helix.md) for more details. +- Setting: `helix_mode` +- Default: `false` + +**Options** + +`boolean` values + +## Indent Guides + +- Description: Configuration related to indent guides. Indent guides can be configured separately for each language. +- Setting: `indent_guides` +- Default: + +```json +{ + "indent_guides": { + "enabled": true, + "line_width": 1, + "active_line_width": 1, "coloring": "fixed", "background_coloring": "disabled" } @@ -1961,40 +2287,6 @@ Example: } ``` -## Hard Tabs - -- Description: Whether to indent lines using tab characters or multiple spaces. -- Setting: `hard_tabs` -- Default: `false` - -**Options** - -`boolean` values - -## Multi Cursor Modifier - -- Description: Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier. -- Setting: `multi_cursor_modifier` -- Default: `alt` - -**Options** - -1. Maps to `Alt` on Linux and Windows and to `Option` on MacOS: - -```json -{ - "multi_cursor_modifier": "alt" -} -``` - -2. Maps `Control` on Linux and Windows and to `Command` on MacOS: - -```json -{ - "multi_cursor_modifier": "cmd_or_ctrl" // alias: "cmd", "ctrl" -} -``` - ## Hover Popover Enabled - Description: Whether or not to show the informational hover box when moving the mouse over symbols in the editor. @@ -2087,6 +2379,50 @@ Run the `icon theme selector: toggle` action in the command palette to see a cur Run the `icon theme selector: toggle` action in the command palette to see a current list of valid icon themes names. +## Image Viewer + +- Description: Settings for image viewer functionality +- Setting: `image_viewer` +- Default: + +```json +{ + "image_viewer": { + "unit": "binary" + } +} +``` + +**Options** + +### Unit + +- Description: The unit for image file sizes +- Setting: `unit` +- Default: `"binary"` + +**Options** + +1. Use binary units (KiB, MiB): + +```json +{ + "image_viewer": { + "unit": "binary" + } +} +``` + +2. Use decimal units (KB, MB): + +```json +{ + "image_viewer": { + "unit": "decimal" + } +} +``` + ## Inlay hints - Description: Configuration for displaying extra text with hints in the editor. @@ -2187,6 +2523,24 @@ Unspecified values have a `false` value, hints won't be toggled if all the modif } ``` +## JSX Tag Auto Close + +- Description: Whether to automatically close JSX tags +- Setting: `jsx_tag_auto_close` +- Default: + +```json +{ + "jsx_tag_auto_close": { + "enabled": true + } +} +``` + +**Options** + +- `enabled`: Whether to enable automatic JSX tag closing + ## Languages - Description: Configuration for specific languages. @@ -2226,141 +2580,546 @@ The following settings can be overridden for each specific language: - [`use_autoclose`](#use-autoclose) - [`always_treat_brackets_as_autoclosed`](#always-treat-brackets-as-autoclosed) -These values take in the same options as the root-level settings with the same name. +These values take in the same options as the root-level settings with the same name. + +## Language Models + +- Description: Configuration for language model providers +- Setting: `language_models` +- Default: + +```json +{ + "language_models": { + "anthropic": { + "api_url": "https://api.anthropic.com" + }, + "google": { + "api_url": "https://generativelanguage.googleapis.com" + }, + "ollama": { + "api_url": "http://localhost:11434" + }, + "openai": { + "api_url": "https://api.openai.com/v1" + } + } +} +``` + +**Options** + +Configuration for various AI model providers including API URLs and authentication settings. + +## Line Indicator Format + +- Description: Format for line indicator in the status bar +- Setting: `line_indicator_format` +- Default: `"short"` + +**Options** + +1. Short format: + +```json +{ + "line_indicator_format": "short" +} +``` + +2. Long format: + +```json +{ + "line_indicator_format": "long" +} +``` + +## Linked Edits + +- Description: Whether to perform linked edits of associated ranges, if the language server supports it. For example, when editing opening `` tag, the contents of the closing `` tag will be edited as well. +- Setting: `linked_edits` +- Default: `true` + +**Options** + +`boolean` values + +## LSP Document Colors + +- Description: Whether to show document color information from the language server +- Setting: `lsp_document_colors` +- Default: `true` + +**Options** + +`boolean` values + +## Max Tabs + +- Description: Maximum number of tabs to show in the tab bar +- Setting: `max_tabs` +- Default: `null` + +**Options** + +Positive `integer` values or `null` for unlimited tabs + +## Middle Click Paste (Linux only) + +- Description: Enable middle-click paste on Linux +- Setting: `middle_click_paste` +- Default: `true` + +**Options** + +`boolean` values + +## Multi Cursor Modifier + +- Description: Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier. +- Setting: `multi_cursor_modifier` +- Default: `alt` + +**Options** + +1. Maps to `Alt` on Linux and Windows and to `Option` on MacOS: + +```json +{ + "multi_cursor_modifier": "alt" +} +``` + +2. Maps `Control` on Linux and Windows and to `Command` on MacOS: + +```json +{ + "multi_cursor_modifier": "cmd_or_ctrl" // alias: "cmd", "ctrl" +} +``` + +## Node + +- Description: Configuration for Node.js integration +- Setting: `node` +- Default: + +```json +{ + "node": { + "ignore_system_version": false, + "path": null, + "npm_path": null + } +} +``` + +**Options** + +- `ignore_system_version`: Whether to ignore the system Node.js version +- `path`: Custom path to Node.js binary +- `npm_path`: Custom path to npm binary + +## Network Proxy + +- Description: Configure a network proxy for Zed. +- Setting: `proxy` +- Default: `null` + +**Options** + +The proxy setting must contain a URL to the proxy. + +The following URI schemes are supported: + +- `http` +- `https` +- `socks4` - SOCKS4 proxy with local DNS +- `socks4a` - SOCKS4 proxy with remote DNS +- `socks5` - SOCKS5 proxy with local DNS +- `socks5h` - SOCKS5 proxy with remote DNS + +`http` will be used when no scheme is specified. + +By default no proxy will be used, or Zed will attempt to retrieve proxy settings from environment variables, such as `http_proxy`, `HTTP_PROXY`, `https_proxy`, `HTTPS_PROXY`, `all_proxy`, `ALL_PROXY`, `no_proxy` and `NO_PROXY`. + +For example, to set an `http` proxy, add the following to your settings: + +```json +{ + "proxy": "http://127.0.0.1:10809" +} +``` + +Or to set a `socks5` proxy: + +```json +{ + "proxy": "socks5h://localhost:10808" +} +``` + +If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` environment variable. This accepts a comma-separated list of hostnames, host suffixes, IPv4/IPv6 addresses or blocks that should not use the proxy. For example if your environment included `NO_PROXY="google.com, 192.168.1.0/24"` all hosts in `192.168.1.*`, `google.com` and `*.google.com` would bypass the proxy. See [reqwest NoProxy docs](https://docs.rs/reqwest/latest/reqwest/struct.NoProxy.html#method.from_string) for more. + +## On Last Window Closed + +- Description: What to do when the last window is closed +- Setting: `on_last_window_closed` +- Default: `"platform_default"` + +**Options** + +1. Use platform default behavior: + +```json +{ + "on_last_window_closed": "platform_default" +} +``` + +2. Always quit the application: + +```json +{ + "on_last_window_closed": "quit_app" +} +``` + +## Profiles + +- Description: Configuration profiles that can be applied on top of existing settings +- Setting: `profiles` +- Default: `{}` + +**Options** + +Configuration object for defining settings profiles. Example: + +```json +{ + "profiles": { + "presentation": { + "buffer_font_size": 20, + "ui_font_size": 18, + "theme": "One Light" + } + } +} +``` + +## Preview tabs + +- Description: + Preview tabs allow you to open files in preview mode, where they close automatically when you switch to another file unless you explicitly pin them. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \ + There are several ways to convert a preview tab into a regular tab: + + - Double-clicking on the file + - Double-clicking on the tab header + - Using the `project_panel::OpenPermanent` action + - Editing the file + - Dragging the file to a different pane + +- Setting: `preview_tabs` +- Default: + +```json +"preview_tabs": { + "enabled": true, + "enable_preview_from_file_finder": false, + "enable_preview_from_code_navigation": false, +} +``` + +### Enable preview from file finder + +- Description: Determines whether to open files in preview mode when selected from the file finder. +- Setting: `enable_preview_from_file_finder` +- Default: `false` + +**Options** + +`boolean` values + +### Enable preview from code navigation + +- Description: Determines whether a preview tab gets replaced when code navigation is used to navigate away from the tab. +- Setting: `enable_preview_from_code_navigation` +- Default: `false` + +**Options** + +`boolean` values + +## File Finder + +### File Icons + +- Description: Whether to show file icons in the file finder. +- Setting: `file_icons` +- Default: `true` + +### Modal Max Width + +- Description: Max-width of the file finder modal. It can take one of these values: `small`, `medium`, `large`, `xlarge`, and `full`. +- Setting: `modal_max_width` +- Default: `small` + +### Skip Focus For Active In Search + +- Description: Determines whether the file finder should skip focus for the active file in search results. +- Setting: `skip_focus_for_active_in_search` +- Default: `true` + +## Pane Split Direction Horizontal + +- Description: The direction that you want to split panes horizontally +- Setting: `pane_split_direction_horizontal` +- Default: `"up"` + +**Options** + +1. Split upward: + +```json +{ + "pane_split_direction_horizontal": "up" +} +``` + +2. Split downward: + +```json +{ + "pane_split_direction_horizontal": "down" +} +``` + +## Pane Split Direction Vertical + +- Description: The direction that you want to split panes vertically +- Setting: `pane_split_direction_vertical` +- Default: `"left"` + +**Options** + +1. Split to the left: + +```json +{ + "pane_split_direction_vertical": "left" +} +``` + +2. Split to the right: + +```json +{ + "pane_split_direction_vertical": "right" +} +``` + +## Preferred Line Length + +- Description: The column at which to soft-wrap lines, for buffers where soft-wrap is enabled. +- Setting: `preferred_line_length` +- Default: `80` + +**Options** + +`integer` values + +## Private Files + +- Description: Globs to match against file paths to determine if a file is private +- Setting: `private_files` +- Default: `["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"]` + +**Options** + +List of `string` glob patterns + +## Projects Online By Default + +- Description: Whether or not to show the online projects view by default. +- Setting: `projects_online_by_default` +- Default: `true` + +**Options** + +`boolean` values + +## Read SSH Config + +- Description: Whether to read SSH configuration files +- Setting: `read_ssh_config` +- Default: `true` + +**Options** + +`boolean` values + +## Redact Private Values + +- Description: Hide the values of variables from visual display in private files +- Setting: `redact_private_values` +- Default: `false` + +**Options** + +`boolean` values + +## Relative Line Numbers + +- Description: Whether to show relative line numbers in the gutter +- Setting: `relative_line_numbers` +- Default: `false` + +**Options** + +`boolean` values + +## Remove Trailing Whitespace On Save + +- Description: Whether or not to remove any trailing whitespace from lines of a buffer before saving it. +- Setting: `remove_trailing_whitespace_on_save` +- Default: `true` + +**Options** -## Network Proxy +`boolean` values -- Description: Configure a network proxy for Zed. -- Setting: `proxy` -- Default: `null` +## Resize All Panels In Dock + +- Description: Whether to resize all the panels in a dock when resizing the dock. Can be a combination of "left", "right" and "bottom". +- Setting: `resize_all_panels_in_dock` +- Default: `["left"]` **Options** -The proxy setting must contain a URL to the proxy. +List of strings containing any combination of: -The following URI schemes are supported: +- `"left"`: Resize left dock panels together +- `"right"`: Resize right dock panels together +- `"bottom"`: Resize bottom dock panels together -- `http` -- `https` -- `socks4` - SOCKS4 proxy with local DNS -- `socks4a` - SOCKS4 proxy with remote DNS -- `socks5` - SOCKS5 proxy with local DNS -- `socks5h` - SOCKS5 proxy with remote DNS +## Restore on File Reopen -`http` will be used when no scheme is specified. +- Description: Whether to attempt to restore previous file's state when opening it again. The state is stored per pane. +- Setting: `restore_on_file_reopen` +- Default: `true` -By default no proxy will be used, or Zed will attempt to retrieve proxy settings from environment variables, such as `http_proxy`, `HTTP_PROXY`, `https_proxy`, `HTTPS_PROXY`, `all_proxy`, `ALL_PROXY`, `no_proxy` and `NO_PROXY`. +**Options** -For example, to set an `http` proxy, add the following to your settings: +`boolean` values + +## Restore on Startup + +- Description: Controls session restoration on startup. +- Setting: `restore_on_startup` +- Default: `last_session` + +**Options** + +1. Restore all workspaces that were open when quitting Zed: ```json { - "proxy": "http://127.0.0.1:10809" + "restore_on_startup": "last_session" } ``` -Or to set a `socks5` proxy: +2. Restore the workspace that was closed last: ```json { - "proxy": "socks5h://localhost:10808" + "restore_on_startup": "last_workspace" } ``` -If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` environment variable. This accepts a comma-separated list of hostnames, host suffixes, IPv4/IPv6 addresses or blocks that should not use the proxy. For example if your environment included `NO_PROXY="google.com, 192.168.1.0/24"` all hosts in `192.168.1.*`, `google.com` and `*.google.com` would bypass the proxy. See [reqwest NoProxy docs](https://docs.rs/reqwest/latest/reqwest/struct.NoProxy.html#method.from_string) for more. - -## Preview tabs - -- Description: - Preview tabs allow you to open files in preview mode, where they close automatically when you switch to another file unless you explicitly pin them. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \ - There are several ways to convert a preview tab into a regular tab: - - - Double-clicking on the file - - Double-clicking on the tab header - - Using the `project_panel::OpenPermanent` action - - Editing the file - - Dragging the file to a different pane - -- Setting: `preview_tabs` -- Default: +3. Always start with an empty editor: ```json -"preview_tabs": { - "enabled": true, - "enable_preview_from_file_finder": false, - "enable_preview_from_code_navigation": false, +{ + "restore_on_startup": "none" } ``` -### Enable preview from file finder +## Scroll Beyond Last Line -- Description: Determines whether to open files in preview mode when selected from the file finder. -- Setting: `enable_preview_from_file_finder` -- Default: `false` +- Description: Whether the editor will scroll beyond the last line +- Setting: `scroll_beyond_last_line` +- Default: `"one_page"` **Options** -`boolean` values +1. Scroll one page beyond the last line by one page: -### Enable preview from code navigation +```json +{ + "scroll_beyond_last_line": "one_page" +} +``` -- Description: Determines whether a preview tab gets replaced when code navigation is used to navigate away from the tab. -- Setting: `enable_preview_from_code_navigation` -- Default: `false` +2. The editor will scroll beyond the last line by the same amount of lines as `vertical_scroll_margin`: -**Options** +```json +{ + "scroll_beyond_last_line": "vertical_scroll_margin" +} +``` -`boolean` values +3. The editor will not scroll beyond the last line: -## File Finder +```json +{ + "scroll_beyond_last_line": "off" +} +``` -### File Icons +**Options** -- Description: Whether to show file icons in the file finder. -- Setting: `file_icons` -- Default: `true` +`boolean` values -### Modal Max Width +## Scroll Sensitivity -- Description: Max-width of the file finder modal. It can take one of these values: `small`, `medium`, `large`, `xlarge`, and `full`. -- Setting: `modal_max_width` -- Default: `small` +- Description: Scroll sensitivity multiplier. This multiplier is applied to both the horizontal and vertical delta values while scrolling. +- Setting: `scroll_sensitivity` +- Default: `1.0` -### Skip Focus For Active In Search +**Options** -- Description: Determines whether the file finder should skip focus for the active file in search results. -- Setting: `skip_focus_for_active_in_search` -- Default: `true` +Positive `float` values -## Preferred Line Length +### Fast Scroll Sensitivity -- Description: The column at which to soft-wrap lines, for buffers where soft-wrap is enabled. -- Setting: `preferred_line_length` -- Default: `80` +- Description: Scroll sensitivity multiplier for fast scrolling. This multiplier is applied to both the horizontal and vertical delta values while scrolling. Fast scrolling happens when a user holds the alt or option key while scrolling. +- Setting: `fast_scroll_sensitivity` +- Default: `4.0` **Options** -`integer` values +Positive `float` values -## Projects Online By Default +### Horizontal Scroll Margin -- Description: Whether or not to show the online projects view by default. -- Setting: `projects_online_by_default` -- Default: `true` +- Description: The number of characters to keep on either side when scrolling with the mouse +- Setting: `horizontal_scroll_margin` +- Default: `5` **Options** -`boolean` values +Non-negative `integer` values -## Remove Trailing Whitespace On Save +### Vertical Scroll Margin -- Description: Whether or not to remove any trailing whitespace from lines of a buffer before saving it. -- Setting: `remove_trailing_whitespace_on_save` -- Default: `true` +- Description: The number of lines to keep above/below the cursor when scrolling with the keyboard +- Setting: `vertical_scroll_margin` +- Default: `3` **Options** -`boolean` values +Non-negative `integer` values ## Search @@ -2377,6 +3136,12 @@ If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` en }, ``` +## Search Wrap + +- Description: If `search_wrap` is disabled, search result do not wrap around the end of the file +- Setting: `search_wrap` +- Default: `true` + ## Seed Search Query From Cursor - Description: When to populate a new search's query based on the text under the cursor. @@ -2546,6 +3311,56 @@ Positive integer values 4. `preferred_line_length` to wrap lines that overflow `preferred_line_length` config value 5. `bounded` to wrap lines at the minimum of `editor_width` and `preferred_line_length` +## Show Wrap Guides + +- Description: Whether to show wrap guides (vertical rulers) in the editor. Setting this to true will show a guide at the 'preferred_line_length' value if 'soft_wrap' is set to 'preferred_line_length', and will show any additional guides as specified by the 'wrap_guides' setting. +- Setting: `show_wrap_guides` +- Default: `true` + +**Options** + +`boolean` values + +## Use On Type Format + +- Description: Whether to use additional LSP queries to format (and amend) the code after every "trigger" symbol input, defined by LSP server capabilities +- Setting: `use_on_type_format` +- Default: `true` + +**Options** + +`boolean` values + +## Use Auto Surround + +- Description: Whether to automatically surround selected text when typing opening parenthesis, bracket, brace, single or double quote characters. For example, when you select text and type (, Zed will surround the text with (). +- Setting: `use_auto_surround` +- Default: `true` + +**Options** + +`boolean` values + +## Use System Path Prompts + +- Description: Whether to use the system provided dialogs for Open and Save As. When set to false, Zed will use the built-in keyboard-first pickers. +- Setting: `use_system_path_prompts` +- Default: `true` + +**Options** + +`boolean` values + +## Use System Prompts + +- Description: Whether to use the system provided dialogs for prompts, such as confirmation prompts. When set to false, Zed will use its built-in prompts. Note that on Linux, this option is ignored and Zed will always use the built-in prompts. +- Setting: `use_system_prompts` +- Default: `true` + +**Options** + +`boolean` values + ## Wrap Guides (Vertical Rulers) - Description: Where to display vertical rulers as wrap-guides. Disable by setting `show_wrap_guides` to `false`. @@ -2566,6 +3381,28 @@ List of `integer` column numbers `integer` values +## Tasks + +- Description: Configuration for tasks that can be run within Zed +- Setting: `tasks` +- Default: + +```json +{ + "tasks": { + "variables": {}, + "enabled": true, + "prefer_lsp": false + } +} +``` + +**Options** + +- `variables`: Custom variables for task configuration +- `enabled`: Whether tasks are enabled +- `prefer_lsp`: Whether to prefer LSP-provided tasks over Zed language extension ones + ## Telemetry - Description: Control what info is collected by Zed. @@ -3212,17 +4049,71 @@ Run the `theme selector: toggle` action in the command palette to see a current Run the `theme selector: toggle` action in the command palette to see a current list of valid themes names. +## Title Bar + +- Description: Whether or not to show various elements in the title bar +- Setting: `title_bar` +- Default: + +```json +"title_bar": { + "show_branch_icon": false, + "show_branch_name": true, + "show_project_items": true, + "show_onboarding_banner": true, + "show_user_picture": true, + "show_sign_in": true, + "show_menus": false +} +``` + +**Options** + +- `show_branch_icon`: Whether to show the branch icon beside branch switcher in the titlebar +- `show_branch_name`: Whether to show the branch name button in the titlebar +- `show_project_items`: Whether to show the project host and name in the titlebar +- `show_onboarding_banner`: Whether to show onboarding banners in the titlebar +- `show_user_picture`: Whether to show user picture in the titlebar +- `show_sign_in`: Whether to show the sign in button in the titlebar +- `show_menus`: Whether to show the menus in the titlebar + ## Vim -- Description: Whether or not to enable vim mode. See the [Vim documentation](./vim.md) for more details on configuration. +- Description: Whether or not to enable vim mode. - Setting: `vim_mode` - Default: `false` -## Helix Mode +## When Closing With No Tabs -- Description: Whether or not to enable Helix mode. Enabling `helix_mode` also enables `vim_mode`. See the [Helix documentation](./helix.md) for more details. -- Setting: `helix_mode` -- Default: `false` +- Description: Whether the window should be closed when using 'close active item' on a window with no tabs +- Setting: `when_closing_with_no_tabs` +- Default: `"platform_default"` + +**Options** + +1. Use platform default behavior: + +```json +{ + "when_closing_with_no_tabs": "platform_default" +} +``` + +2. Always close the window: + +```json +{ + "when_closing_with_no_tabs": "close_window" +} +``` + +3. Never close the window: + +```json +{ + "when_closing_with_no_tabs": "keep_window_open" +} +``` ## Project Panel @@ -3243,6 +4134,7 @@ Run the `theme selector: toggle` action in the command palette to see a current "indent_size": 20, "auto_reveal_entries": true, "auto_fold_dirs": true, + "drag_and_drop": true, "scrollbar": { "show": null }, @@ -3465,6 +4357,103 @@ Run the `theme selector: toggle` action in the command palette to see a current Visit [the Configuration page](./ai/configuration.md) under the AI section to learn more about all the agent-related settings. +## Collaboration Panel + +- Description: Customizations for the collaboration panel. +- Setting: `collaboration_panel` +- Default: + +```json +{ + "collaboration_panel": { + "button": true, + "dock": "left", + "default_width": 240 + } +} +``` + +**Options** + +- `button`: Whether to show the collaboration panel button in the status bar +- `dock`: Where to dock the collaboration panel. Can be `left` or `right` +- `default_width`: Default width of the collaboration panel + +## Chat Panel + +- Description: Customizations for the chat panel. +- Setting: `chat_panel` +- Default: + +```json +{ + "chat_panel": { + "button": "when_in_call", + "dock": "right", + "default_width": 240 + } +} +``` + +**Options** + +- `button`: When to show the chat panel button in the status bar. Can be `never`, `always`, or `when_in_call`. +- `dock`: Where to dock the chat panel. Can be 'left' or 'right' +- `default_width`: Default width of the chat panel + +## Debugger + +- Description: Configuration for debugger panel and settings +- Setting: `debugger` +- Default: + +```json +{ + "debugger": { + "stepping_granularity": "line", + "save_breakpoints": true, + "dock": "bottom", + "button": true + } +} +``` + +See the [debugger page](./debugger.md) for more information about debugging support within Zed. + +## Git Panel + +- Description: Setting to customize the behavior of the git panel. +- Setting: `git_panel` +- Default: + +```json +{ + "git_panel": { + "button": true, + "dock": "left", + "default_width": 360, + "status_style": "icon", + "fallback_branch_name": "main", + "sort_by_path": false, + "collapse_untracked_diff": false, + "scrollbar": { + "show": null + } + } +} +``` + +**Options** + +- `button`: Whether to show the git panel button in the status bar +- `dock`: Where to dock the git panel. Can be `left` or `right` +- `default_width`: Default width of the git panel +- `status_style`: How to display git status. Can be `label_color` or `icon` +- `fallback_branch_name`: What branch name to use if `init.defaultBranch` is not set +- `sort_by_path`: Whether to sort entries in the panel by path or by status (the default) +- `collapse_untracked_diff`: Whether to collapse untracked files in the diff panel +- `scrollbar`: When to show the scrollbar in the git panel + ## Outline Panel - Description: Customize outline Panel diff --git a/docs/src/development/releases.md b/docs/src/development/releases.md index 5b821f3cf2046b982ed4ca99fe729075174cf532..d1f99401d6b78545c34a64b47a146cecacc7eec1 100644 --- a/docs/src/development/releases.md +++ b/docs/src/development/releases.md @@ -1,49 +1,100 @@ # Zed Releases -Zed currently maintains two public releases for macOS: +Read about Zed's release channels [here](https://zed.dev/faq#what-are-the-release-channels). -- [Stable](https://zed.dev/download): This is the primary version that people download and use. -- [Preview](https://zed.dev/releases/preview): which receives updates a week ahead of Stable for early adopters. +## Wednesday release process -Typically we cut a new minor release every Wednesday. The current Preview becomes Stable, and the new Preview contains everything on main up until that point. +You will need write access to the Zed repository to do this. -If bugs are found and fixed during the week, they may be cherry-picked into the release branches and so new patch versions for preview and stable can become available throughout the week. +Credentials for various services used in this process can be found in 1Password. -## Wednesday release process +--- -You will need write access to the Zed repository to do this: +1. Checkout `main` and ensure your working copy is clean. + +1. Run `git fetch && git pull` to ensure you have the latest commits locally. + +1. Run `git fetch --tags --force` to forcibly ensure your local tags are in sync with the remote. + +1. Run `./script/get-stable-channel-release-notes`. + + - Follow the instructions at the end of the script and aggregate the release notes into one structure. + +1. Run `./script/bump-zed-minor-versions`. + + - Push the tags and branches as instructed. + +1. Run `./script/get-preview-channel-changes`. + + - Take the script's output and build release notes by organizing each release note line into a category. + - Use a prior release for the initial outline. + - Make sure to append the `Credit` line, if present, to the end of the release note line. + +1. Once release drafts are up on [GitHub Releases](https://github.com/zed-industries/zed/releases), paste both preview and stable release notes into each and **save**. + + - **Do not publish the drafts, yet.** + +1. Check the release assets. + + - Ensure the stable and preview release jobs have finished without error. + - Ensure each draft has the proper number of assets—releases currently have 10 assets each. + - Download the artifacts for each release draft and test that you can run them locally. + +1. Publish stable / preview drafts, one at a time. -- Checkout `main` and ensure your working copy is clean. -- Run `./script/bump-zed-minor-versions` and push the tags - and branches as instructed. -- Wait for the builds to appear on [the Releases tab on GitHub](https://github.com/zed-industries/zed/releases) (typically takes around 30 minutes) -- While you're waiting: - - Start creating the new release notes for preview. You can start with the output of `./script/get-preview-channel-changes`. - - Start drafting the release tweets. -- Once the builds are ready: - - Copy the release notes from the previous Preview release(s) to the current Stable release. - - Download the artifacts for each release and test that you can run them locally. - - Publish the releases on GitHub. - - Tweet the tweets (Credentials are in 1Password). + - Use [Vercel](https://vercel.com/zed-industries/zed-dev) to check the progress of the website rebuild. + The release will be public once the rebuild has completed. + +1. Publish the release email that has been sent to [Kit](https://kit.com). + + - Make sure to double-check that the email is correct before publishing. + - We sometimes correct things here and there that didn't translate from GitHub's renderer to Kit's. + +1. Build social media posts based on the popular items in stable. + + - You can use the [prior week's post chain](https://zed.dev/channel/tweets-23331) as your outline. + - Stage the copy and assets using [Buffer](https://buffer.com), for both X and BlueSky. + - Publish both, one at a time, ensuring both are posted to each respective platform. ## Patch release process -If your PR fixes a panic or a crash, you should cherry-pick it to the current stable and preview branches. If your PR fixes a regression in recently released code, you should cherry-pick it to preview. +If your PR fixes a panic or a crash, you should cherry-pick it to the current stable and preview branches. +If your PR fixes a regression in recently released code, you should cherry-pick it to preview. You will need write access to the Zed repository to do this: -- Send a PR containing your change to `main` as normal. -- Leave a comment on the PR `/cherry-pick v0.XXX.x`. Once your PR is merged, the GitHub bot will send a PR to the branch. - - In case of a merge conflict, you will have to cherry-pick manually and push the change to the `v0.XXX.x` branch. -- After the commits are cherry-picked onto the branch, run `./script/trigger-release {preview|stable}`. This will bump the version numbers, create a new release tag, and kick off a release build. - - This can also be run from the [GitHub Actions UI](https://github.com/zed-industries/zed/actions/workflows/bump_patch_version.yml): - ![](https://github.com/zed-industries/zed/assets/1486634/9e31ae95-09e1-4c7f-9591-944f4f5b63ea) -- Wait for the builds to appear on [the Releases tab on GitHub](https://github.com/zed-industries/zed/releases) (typically takes around 30 minutes) -- Proof-read and edit the release notes as needed. -- Download the artifacts for each release and test that you can run them locally. -- Publish the release. +--- + +1. Send a PR containing your change to `main` as normal. + +1. Once it is merged, cherry-pick the commit locally to either of the release branches (`v0.XXX.x`). + + - In some cases, you may have to handle a merge conflict. + More often than not, this will happen when cherry-picking to stable, as the stable branch is more "stale" than the preview branch. + +1. After the commit is cherry-picked, run `./script/trigger-release {preview|stable}`. + This will bump the version numbers, create a new release tag, and kick off a release build. + + - This can also be run from the [GitHub Actions UI](https://github.com/zed-industries/zed/actions/workflows/bump_patch_version.yml): + ![](https://github.com/zed-industries/zed/assets/1486634/9e31ae95-09e1-4c7f-9591-944f4f5b63ea) + +1. Once release drafts are up on [GitHub Releases](https://github.com/zed-industries/zed/releases), proofread and edit the release notes as needed and **save**. + + - **Do not publish the drafts, yet.** + +1. Check the release assets. + + - Ensure the stable / preview release jobs have finished without error. + - Ensure each draft has the proper number of assets—releases currently have 10 assets each. + - Download the artifacts for each release draft and test that you can run them locally. + +1. Publish stable / preview drafts, one at a time. + + - Use [Vercel](https://vercel.com/zed-industries/zed-dev) to check the progress of the website rebuild. + The release will be public once the rebuild has completed. ## Nightly release process In addition to the public releases, we also have a nightly build that we encourage employees to use. -Nightly is released by cron once a day, and can be shipped as often as you'd like. There are no release notes or announcements, so you can just merge your changes to main and run `./script/trigger-release nightly`. +Nightly is released by cron once a day, and can be shipped as often as you'd like. +There are no release notes or announcements, so you can just merge your changes to main and run `./script/trigger-release nightly`. diff --git a/docs/src/languages/elixir.md b/docs/src/languages/elixir.md index 175d0d2e8c48631fbb8862558f24c4e555eb8b01..c7b7e2287a0d772871bee331035944a5e7bab8a1 100644 --- a/docs/src/languages/elixir.md +++ b/docs/src/languages/elixir.md @@ -6,35 +6,72 @@ Elixir support is available through the [Elixir extension](https://github.com/ze - [elixir-lang/tree-sitter-elixir](https://github.com/elixir-lang/tree-sitter-elixir) - [phoenixframework/tree-sitter-heex](https://github.com/phoenixframework/tree-sitter-heex) - Language servers: + - [elixir-lang/expert](https://github.com/elixir-lang/expert) - [elixir-lsp/elixir-ls](https://github.com/elixir-lsp/elixir-ls) - [elixir-tools/next-ls](https://github.com/elixir-tools/next-ls) - [lexical-lsp/lexical](https://github.com/lexical-lsp/lexical) ## Choosing a language server -The Elixir extension offers language server support for `elixir-ls`, `next-ls`, and `lexical`. +The Elixir extension offers language server support for `expert`, `elixir-ls`, `next-ls`, and `lexical`. `elixir-ls` is enabled by default. +### Expert + +To switch to `expert`, add the following to your `settings.json`: + +```json +{ + "languages": { + "Elixir": { + "language_servers": [ + "expert", + "!elixir-ls", + "!next-ls", + "!lexical", + "..." + ] + } + } +} +``` + +### Next LS + To switch to `next-ls`, add the following to your `settings.json`: ```json { "languages": { "Elixir": { - "language_servers": ["next-ls", "!elixir-ls", "..."] + "language_servers": [ + "next-ls", + "!expert", + "!elixir-ls", + "!lexical", + "..." + ] } } } ``` +### Lexical + To switch to `lexical`, add the following to your `settings.json`: ```json { "languages": { "Elixir": { - "language_servers": ["lexical", "!elixir-ls", "..."] + "language_servers": [ + "lexical", + "!expert", + "!elixir-ls", + "!next-ls", + "..." + ] } } } diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 95505634327c297b795846cceea63d70aa068008..bff3eac86048752be50f8fd605bc5b76677ca0c0 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -45,9 +45,9 @@ Zed supports ways to spawn (and rerun) commands using its integrated terminal to // Whether to show the task line in the output of the spawned task, defaults to `true`. "show_summary": true, // Whether to show the command line in the output of the spawned task, defaults to `true`. - "show_output": true, + "show_output": true // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. - "tags": [] + // "tags": [] } ] ``` diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index 24b2a9d769764215c0868d455ffe6bfd615be158..4fc5a9ba8864bc3a721d4d7d101977d729082e59 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -431,6 +431,7 @@ Project panel can be shown/hidden with {#action project_panel::ToggleFocus} ({#k "auto_reveal_entries": true, // Show file in panel when activating its buffer "auto_fold_dirs": true, // Fold dirs with single subdir "sticky_scroll": true, // Stick parent directories at top of the project panel. + "drag_and_drop": true, // Whether drag and drop is enabled "scrollbar": { // Project panel scrollbar settings "show": null // Show/hide: (auto, system, always, never) }, diff --git a/extensions/html/Cargo.toml b/extensions/html/Cargo.toml index eacafeb2e4c7e2b663cb0353ae8fa5ad4640bc86..27425da67185b80935c55b8bdc9e02088650a06b 100644 --- a/extensions/html/Cargo.toml +++ b/extensions/html/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_html" -version = "0.2.1" +version = "0.2.2" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/html/extension.toml b/extensions/html/extension.toml index 0f6ea79f56d7f3b3d94aa0e987053f344860967a..c1d45d40602c6a2a87864cca3b534f465266b0a4 100644 --- a/extensions/html/extension.toml +++ b/extensions/html/extension.toml @@ -1,7 +1,7 @@ id = "html" name = "HTML" description = "HTML support." -version = "0.2.1" +version = "0.2.2" schema_version = 1 authors = ["Isaac Clayton "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/html/languages/html/config.toml b/extensions/html/languages/html/config.toml index f74db2888eb71e6e9f9afcbb1b41ab98e232a7a7..388949d95caf56803690b5533c871978a3f0d100 100644 --- a/extensions/html/languages/html/config.toml +++ b/extensions/html/languages/html/config.toml @@ -3,6 +3,7 @@ grammar = "html" path_suffixes = ["html", "htm", "shtml"] autoclose_before = ">})" block_comment = { start = "", tab_size = 0 } +wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "" } brackets = [ { start = "{", end = "}", close = true, newline = true }, { start = "[", end = "]", close = true, newline = true }, diff --git a/extensions/html/languages/html/outline.scm b/extensions/html/languages/html/outline.scm index a9b95da55651bde5a28691ca1ca92d636a444e8c..e7f9dc4fab01b89e68a2b668425fc7655b7d275e 100644 --- a/extensions/html/languages/html/outline.scm +++ b/extensions/html/languages/html/outline.scm @@ -1 +1,5 @@ (comment) @annotation + +(element + (start_tag + (tag_name) @name)) @item diff --git a/script/get-preview-channel-changes b/script/get-preview-channel-changes index 8e7dcf9b1e1172d846b1204a1f8ff9e0a34b5c8a..14cd80f1550aec9784d3a24f89f577adc275632f 100755 --- a/script/get-preview-channel-changes +++ b/script/get-preview-channel-changes @@ -1,12 +1,11 @@ #!/usr/bin/env node --redirect-warnings=/dev/null const { execFileSync } = require("child_process"); -let { GITHUB_ACCESS_TOKEN } = process.env; +const { GITHUB_ACCESS_TOKEN } = process.env; const GITHUB_URL = "https://github.com"; const SKIPPABLE_NOTE_REGEX = /^\s*-?\s*n\/?a\s*/ims; const PULL_REQUEST_WEB_URL = "https://github.com/zed-industries/zed/pull"; -const PULL_REQUEST_API_URL = - "https://api.github.com/repos/zed-industries/zed/pulls"; +const PULL_REQUEST_API_URL = "https://api.github.com/repos/zed-industries/zed/pulls"; const DIVIDER = "-".repeat(80); main(); @@ -25,15 +24,12 @@ async function main() { const STAFF_MEMBERS = new Set( ( await ( - await fetch( - "https://api.github.com/orgs/zed-industries/teams/staff/members", - { - headers: { - Authorization: `token ${GITHUB_ACCESS_TOKEN}`, - Accept: "application/vnd.github+json", - }, + await fetch("https://api.github.com/orgs/zed-industries/teams/staff/members", { + headers: { + Authorization: `token ${GITHUB_ACCESS_TOKEN}`, + Accept: "application/vnd.github+json", }, - ) + }) ).json() ).map(({ login }) => login.toLowerCase()), ); @@ -44,11 +40,7 @@ async function main() { }; // Get the last two preview tags - const [newTag, oldTag] = execFileSync( - "git", - ["tag", "--sort", "-committerdate"], - { encoding: "utf8" }, - ) + const [newTag, oldTag] = execFileSync("git", ["tag", "--sort", "-committerdate"], { encoding: "utf8" }) .split("\n") .filter((t) => t.startsWith("v") && t.endsWith("-pre")); @@ -59,14 +51,10 @@ async function main() { const pullRequestNumbers = getPullRequestNumbers(oldTag, newTag); // Get the PRs that were cherry-picked between main and the old tag. - const existingPullRequestNumbers = new Set( - getPullRequestNumbers("main", oldTag), - ); + const existingPullRequestNumbers = new Set(getPullRequestNumbers("main", oldTag)); // Filter out those existing PRs from the set of new PRs. - const newPullRequestNumbers = pullRequestNumbers.filter( - (number) => !existingPullRequestNumbers.has(number), - ); + const newPullRequestNumbers = pullRequestNumbers.filter((number) => !existingPullRequestNumbers.has(number)); // Fetch the pull requests from the GitHub API. console.log("Merged Pull requests:"); @@ -84,8 +72,7 @@ async function main() { const releaseNotesHeader = /^\s*Release Notes:(.+)/ims; const releaseNotes = pullRequest.body || ""; - let contributor = - pullRequest.user?.login ?? "Unable to identify contributor"; + let contributor = pullRequest.user?.login ?? "Unable to identify contributor"; const captures = releaseNotesHeader.exec(releaseNotes); let notes = captures ? captures[1] : "MISSING"; notes = notes.trim(); @@ -127,11 +114,7 @@ function getCreditString(pullRequestNumber, contributor, isStaff) { } function getPullRequestNumbers(oldTag, newTag) { - const pullRequestNumbers = execFileSync( - "git", - ["log", `${oldTag}..${newTag}`, "--oneline"], - { encoding: "utf8" }, - ) + const pullRequestNumbers = execFileSync("git", ["log", `${oldTag}..${newTag}`, "--oneline"], { encoding: "utf8" }) .split("\n") .filter((line) => line.length > 0) .map((line) => { diff --git a/script/get-stable-channel-release-notes b/script/get-stable-channel-release-notes new file mode 100755 index 0000000000000000000000000000000000000000..b16bc9e41f3111821180ce7844e3a804e5d0a9d7 --- /dev/null +++ b/script/get-stable-channel-release-notes @@ -0,0 +1,101 @@ +#!/usr/bin/env node --redirect-warnings=/dev/null + +// This script should be ran before `bump-zed-minor-versions` + +// Prints the changelogs for all preview releases associated with the most +// recent preview minor version. + +// Future TODO: Have the script perform deduplication of lines that were +// included in both past stable and preview patches that shouldn't be mentioned +// again in this week's stable minor release. + +// Future TODO: Get changelogs for latest cherry-picked commits on preview and +// stable that didn't make it into a release, as they were cherry picked + +const { execFileSync } = require("child_process"); +const { GITHUB_ACCESS_TOKEN } = process.env; +const GITHUB_TAGS_API_URL = "https://api.github.com/repos/zed-industries/zed/releases/tags"; +const DIVIDER = "-".repeat(80); + +main(); + +async function main() { + if (!GITHUB_ACCESS_TOKEN) { + try { + GITHUB_ACCESS_TOKEN = execFileSync("gh", ["auth", "token"]).toString(); + } catch (error) { + console.log(error); + console.log("No GITHUB_ACCESS_TOKEN and no `gh auth token`"); + process.exit(1); + } + } + + const allTags = execFileSync("git", ["tag", "--sort", "-committerdate"], { encoding: "utf8" }) + .split("\n") + .filter((t) => t.length > 0); + const latestPreviewTag = allTags.filter((t) => t.startsWith("v") && t.endsWith("-pre"))[0]; + const latestPreviewMinorVersion = latestPreviewTag.split(".")[1]; + const latestPreviewTagRegex = new RegExp(`^v(\\d+)\\.(${latestPreviewMinorVersion})\\.(\\d+)-pre$`); + + const parsedPreviewTags = allTags + .map((tag) => { + const match = tag.match(latestPreviewTagRegex); + if (match) { + return { + tag, + version: { + major: parseInt(match[1]), + minor: parseInt(match[2]), + patch: parseInt(match[3]), + }, + }; + } + return null; + }) + .filter((item) => item !== null) + .sort((a, b) => a.version.patch - b.version.patch); + + const matchingPreviewTags = parsedPreviewTags.map((item) => item.tag); + + console.log("Fetching release information for preview tags:"); + console.log(DIVIDER); + + for (const tag of matchingPreviewTags) { + const releaseApiUrl = `${GITHUB_TAGS_API_URL}/${tag}`; + + try { + const response = await fetch(releaseApiUrl, { + headers: { + Authorization: `token ${GITHUB_ACCESS_TOKEN}`, + }, + }); + + if (!response.ok) { + console.log(`Failed to fetch release for ${tag}: ${response.status}`); + continue; + } + + const release = await response.json(); + + console.log(`\nRelease: ${release.name || tag}`); + console.log(`Tag: ${tag}`); + console.log(`Published: ${release.published_at}`); + console.log(`URL: ${release.html_url}`); + console.log("\nRelease Notes:"); + console.log(release.body || "No release notes"); + console.log(DIVIDER); + } catch (error) { + console.log(`Error fetching release for ${tag}:`, error.message); + } + } + + const patchUpdateTags = parsedPreviewTags.filter((tag) => tag.version.patch != 0).map((tag) => tag.tag); + + console.log(); + console.log("Please review the release notes associated with the following patch versions:"); + for (const tag of patchUpdateTags) { + console.log(`- ${tag}`); + } + console.log("Remove items that have already been mentioned in the current published stable versions."); + console.log("https://github.com/zed-industries/zed/releases?q=prerelease%3Afalse&expanded=true"); +}