diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bd43b2e74cbfc45f6cef76a2553c14d03cbf658..5a4d49553e96da40dae1b011f0dfd762deb90a2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -271,7 +271,7 @@ jobs: - name: Check that Cargo.lock is up to date run: | - cargo update --frozen --workspace + cargo update --locked --workspace - name: cargo clippy run: ./script/clippy diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index f799133ea700af96f61a8301a30ab2ac4b77fe8b..4f7506967bdbf934cc41a4fdc83af1d5b4068ac3 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -111,6 +111,11 @@ jobs: echo "Publishing version: ${version} on release channel nightly" echo "nightly" > crates/zed/RELEASE_CHANNEL + - name: Setup Sentry CLI + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 + with: + token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} + - name: Create macOS app bundle run: script/bundle-mac @@ -136,6 +141,11 @@ jobs: - name: Install Linux dependencies run: ./script/linux && ./script/install-mold 2.34.0 + - name: Setup Sentry CLI + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 + with: + token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} + - name: Limit target directory size run: script/clear-target-dir-if-larger-than 100 @@ -168,6 +178,11 @@ jobs: - name: Install Linux dependencies run: ./script/linux + - name: Setup Sentry CLI + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 + with: + token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} + - name: Limit target directory size run: script/clear-target-dir-if-larger-than 100 @@ -262,6 +277,11 @@ jobs: Write-Host "Publishing version: $version on release channel nightly" "nightly" | Set-Content -Path "crates/zed/RELEASE_CHANNEL" + - name: Setup Sentry CLI + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 + with: + token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} + - name: Build Zed installer working-directory: ${{ env.ZED_WORKSPACE }} run: script/bundle-windows.ps1 diff --git a/Cargo.lock b/Cargo.lock index 006163b79f784c92d7dc57be2a2fd9581d646fd2..e0ca736fb6b15fac2da7123d0919928e9f6a3226 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,9 +138,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.0.10" +version = "0.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fb7f39671e02f8a1aeb625652feae40b6fc2597baaa97e028a98863477aecbd" +checksum = "72ec54650c1fc2d63498bab47eeeaa9eddc7d239d53f615b797a0e84f7ccc87b" dependencies = [ "schemars", "serde", @@ -168,6 +168,7 @@ dependencies = [ "nix 0.29.0", "paths", "project", + "rand 0.8.5", "schemars", "serde", "serde_json", @@ -4257,7 +4258,7 @@ dependencies = [ [[package]] name = "dap-types" version = "0.0.1" -source = "git+https://github.com/zed-industries/dap-types?rev=7f39295b441614ca9dbf44293e53c32f666897f9#7f39295b441614ca9dbf44293e53c32f666897f9" +source = "git+https://github.com/zed-industries/dap-types?rev=1b461b310481d01e02b2603c16d7144b926339f8#1b461b310481d01e02b2603c16d7144b926339f8" dependencies = [ "schemars", "serde", @@ -11031,6 +11032,7 @@ dependencies = [ "ui", "workspace", "workspace-hack", + "zed_actions", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1957f8b4cf9039f2d3c4f91d9985ef0acf02403f..e45af3ca34b3ae05b14e30d4bd16296b452f4215 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -413,7 +413,7 @@ zlog_settings = { path = "crates/zlog_settings" } # agentic-coding-protocol = "0.0.10" -agent-client-protocol = "0.0.10" +agent-client-protocol = "0.0.11" aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } any_vec = "0.14" @@ -460,7 +460,7 @@ core-video = { version = "0.4.3", features = ["metal"] } cpal = "0.16" criterion = { version = "0.5", features = ["html_reports"] } ctor = "0.4.0" -dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "7f39295b441614ca9dbf44293e53c32f666897f9" } +dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "1b461b310481d01e02b2603c16d7144b926339f8" } dashmap = "6.0" derive_more = "0.99.17" dirs = "4.0" diff --git a/assets/icons/audio_off.svg b/assets/icons/audio_off.svg index 93b98471ca1a15e4ef92860e953dde8beb559c37..dfb5a1c45829119ea0dc89bbca3a3f33228ee88f 100644 --- a/assets/icons/audio_off.svg +++ b/assets/icons/audio_off.svg @@ -1 +1,7 @@ - + + + + + + + diff --git a/assets/icons/audio_on.svg b/assets/icons/audio_on.svg index 42310ea32c289e2ecf24a6fa231ae55fce3cb05e..d1bef0d337d6c8a0e79cb0dab8b7d63d5cb2a4d1 100644 --- a/assets/icons/audio_on.svg +++ b/assets/icons/audio_on.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/cloud_download.svg b/assets/icons/cloud_download.svg new file mode 100644 index 0000000000000000000000000000000000000000..bc7a8376d123088119643fc20346506690559bdc --- /dev/null +++ b/assets/icons/cloud_download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/exit.svg b/assets/icons/exit.svg index 2cc6ce120dc9af17a642ac3bf2f2451209cb5e5e..1ff9d7882441548e9c3534ae5ffe6b6331391b45 100644 --- a/assets/icons/exit.svg +++ b/assets/icons/exit.svg @@ -1,8 +1,5 @@ - - + + + + diff --git a/assets/icons/mic.svg b/assets/icons/mic.svg index 01f4c9bf669ba253edaa43dc641fdb9a1b7c51d1..1d9c5bc9edf2a48b3311965fb57758b3ee2e015e 100644 --- a/assets/icons/mic.svg +++ b/assets/icons/mic.svg @@ -1,3 +1,5 @@ - - + + + + diff --git a/assets/icons/mic_mute.svg b/assets/icons/mic_mute.svg index fe5f8201cc4da5e2cf6a1b770c538d421994e1c4..8c61ae2f1ccedc1b27244ed80e1a3fdd75cd4120 100644 --- a/assets/icons/mic_mute.svg +++ b/assets/icons/mic_mute.svg @@ -1,3 +1,8 @@ - - + + + + + + + diff --git a/assets/icons/screen.svg b/assets/icons/screen.svg index ad252e64cf5c73d4cd6ae48dd0abede47d3323e6..4b686b58f9de2e4993546ddad1a20af395d50330 100644 --- a/assets/icons/screen.svg +++ b/assets/icons/screen.svg @@ -1,8 +1,5 @@ - - + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 31adef8cd595bfa4010919dbd29a0ed6c470a1f0..a4f812b2fcf70d44b4ae3dd371d9745755ab1f5e 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -872,8 +872,6 @@ "tab": "git_panel::FocusEditor", "shift-tab": "git_panel::FocusEditor", "escape": "git_panel::ToggleFocus", - "ctrl-enter": "git::Commit", - "ctrl-shift-enter": "git::Amend", "alt-enter": "menu::SecondaryConfirm", "delete": ["git::RestoreFile", { "skip_prompt": false }], "backspace": ["git::RestoreFile", { "skip_prompt": false }], @@ -910,7 +908,9 @@ "ctrl-g backspace": "git::RestoreTrackedFiles", "ctrl-g shift-backspace": "git::TrashUntrackedFiles", "ctrl-space": "git::StageAll", - "ctrl-shift-space": "git::UnstageAll" + "ctrl-shift-space": "git::UnstageAll", + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f942c6f8ae1daa830aa10c473b76a4a99dd8320f..eded8c73e64ae12da04466a654eb2f52fd175bdd 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -950,8 +950,6 @@ "tab": "git_panel::FocusEditor", "shift-tab": "git_panel::FocusEditor", "escape": "git_panel::ToggleFocus", - "cmd-enter": "git::Commit", - "cmd-shift-enter": "git::Amend", "backspace": ["git::RestoreFile", { "skip_prompt": false }], "delete": ["git::RestoreFile", { "skip_prompt": false }], "cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }], @@ -1001,7 +999,9 @@ "ctrl-g backspace": "git::RestoreTrackedFiles", "ctrl-g shift-backspace": "git::TrashUntrackedFiles", "cmd-ctrl-y": "git::StageAll", - "cmd-ctrl-shift-y": "git::UnstageAll" + "cmd-ctrl-shift-y": "git::UnstageAll", + "cmd-enter": "git::Commit", + "cmd-shift-enter": "git::Amend" } }, { diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index 629333663d25a3c8215a3b792d72427aa78b3fde..f81f363ae00efcff7a1af6a90c0729c9771e587d 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -4,6 +4,7 @@ "ctrl-alt-s": "zed::OpenSettings", "ctrl-{": "pane::ActivatePreviousItem", "ctrl-}": "pane::ActivateNextItem", + "shift-escape": null, // Unmap workspace::zoom "ctrl-f2": "debugger::Stop", "f6": "debugger::Pause", "f7": "debugger::StepInto", @@ -44,8 +45,8 @@ "ctrl-alt-right": "pane::GoForward", "alt-f7": "editor::FindAllReferences", "ctrl-alt-f7": "editor::FindAllReferences", - // "ctrl-b": "editor::GoToDefinition", // Conflicts with workspace::ToggleLeftDock - // "ctrl-alt-b": "editor::GoToDefinitionSplit", // Conflicts with workspace::ToggleLeftDock + "ctrl-b": "editor::GoToDefinition", // Conflicts with workspace::ToggleLeftDock + "ctrl-alt-b": "editor::GoToDefinitionSplit", // Conflicts with workspace::ToggleRightDock "ctrl-shift-b": "editor::GoToTypeDefinition", "ctrl-alt-shift-b": "editor::GoToTypeDefinitionSplit", "f2": "editor::GoToDiagnostic", @@ -100,12 +101,27 @@ "shift shift": "command_palette::Toggle", "ctrl-alt-shift-n": "project_symbols::Toggle", "alt-0": "git_panel::ToggleFocus", - "alt-1": "workspace::ToggleLeftDock", + "alt-1": "project_panel::ToggleFocus", "alt-5": "debug_panel::ToggleFocus", "alt-6": "diagnostics::Deploy", "alt-7": "outline_panel::ToggleFocus" } }, + { + "context": "Pane", // this is to override the default Pane mappings to switch tabs + "bindings": { + "alt-1": "project_panel::ToggleFocus", + "alt-2": null, // Bookmarks (left dock) + "alt-3": null, // Find Panel (bottom dock) + "alt-4": null, // Run Panel (bottom dock) + "alt-5": "debug_panel::ToggleFocus", + "alt-6": "diagnostics::Deploy", + "alt-7": "outline_panel::ToggleFocus", + "alt-8": null, // Services (bottom dock) + "alt-9": null, // Git History (bottom dock) + "alt-0": "git_panel::ToggleFocus" + } + }, { "context": "Workspace || Editor", "bindings": { @@ -151,6 +167,9 @@ { "context": "OutlinePanel", "bindings": { "alt-7": "workspace::CloseActiveDock" } }, { "context": "Dock || Workspace || Terminal || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", - "bindings": { "escape": "editor::ToggleFocus" } + "bindings": { + "escape": "editor::ToggleFocus", + "shift-escape": "workspace::CloseActiveDock" + } } ] diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index e8b796f534aa133b517f163779d012dddfb8161f..5795d2ac7e6b5e8ae946a3bafce640946847e78b 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -4,6 +4,7 @@ "cmd-{": "pane::ActivatePreviousItem", "cmd-}": "pane::ActivateNextItem", "cmd-0": "git_panel::ToggleFocus", // overrides `cmd-0` zoom reset + "shift-escape": null, // Unmap workspace::zoom "ctrl-f2": "debugger::Stop", "f6": "debugger::Pause", "f7": "debugger::StepInto", @@ -108,6 +109,21 @@ "cmd-7": "outline_panel::ToggleFocus" } }, + { + "context": "Pane", // this is to override the default Pane mappings to switch tabs + "bindings": { + "cmd-1": "project_panel::ToggleFocus", + "cmd-2": null, // Bookmarks (left dock) + "cmd-3": null, // Find Panel (bottom dock) + "cmd-4": null, // Run Panel (bottom dock) + "cmd-5": "debug_panel::ToggleFocus", + "cmd-6": "diagnostics::Deploy", + "cmd-7": "outline_panel::ToggleFocus", + "cmd-8": null, // Services (bottom dock) + "cmd-9": null, // Git History (bottom dock) + "cmd-0": "git_panel::ToggleFocus" + } + }, { "context": "Workspace || Editor", "bindings": { @@ -146,11 +162,15 @@ } }, { "context": "GitPanel", "bindings": { "cmd-0": "workspace::CloseActiveDock" } }, + { "context": "ProjectPanel", "bindings": { "cmd-1": "workspace::CloseActiveDock" } }, { "context": "DebugPanel", "bindings": { "cmd-5": "workspace::CloseActiveDock" } }, { "context": "Diagnostics > Editor", "bindings": { "cmd-6": "pane::CloseActiveItem" } }, { "context": "OutlinePanel", "bindings": { "cmd-7": "workspace::CloseActiveDock" } }, { "context": "Dock || Workspace || Terminal || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", - "bindings": { "escape": "editor::ToggleFocus" } + "bindings": { + "escape": "editor::ToggleFocus", + "shift-escape": "workspace::CloseActiveDock" + } } ] diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 3c6c21205f825bac9dcdc6539a207d1c25caa646..d572992c548d24f18be4c8bf82dcf86673c7cac4 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -166,6 +166,7 @@ pub struct ToolCall { pub content: Vec, pub status: ToolCallStatus, pub locations: Vec, + pub raw_input: Option, } impl ToolCall { @@ -193,6 +194,50 @@ impl ToolCall { .collect(), locations: tool_call.locations, status, + raw_input: tool_call.raw_input, + } + } + + fn update( + &mut self, + fields: acp::ToolCallUpdateFields, + language_registry: Arc, + cx: &mut App, + ) { + let acp::ToolCallUpdateFields { + kind, + status, + label, + content, + locations, + raw_input, + } = fields; + + if let Some(kind) = kind { + self.kind = kind; + } + + if let Some(status) = status { + self.status = ToolCallStatus::Allowed { status }; + } + + if let Some(label) = label { + self.label = cx.new(|cx| Markdown::new_text(label.into(), cx)); + } + + if let Some(content) = content { + self.content = content + .into_iter() + .map(|chunk| ToolCallContent::from_acp(chunk, language_registry.clone(), cx)) + .collect(); + } + + if let Some(locations) = locations { + self.locations = locations; + } + + if let Some(raw_input) = raw_input { + self.raw_input = Some(raw_input); } } @@ -238,6 +283,7 @@ impl Display for ToolCallStatus { match self { ToolCallStatus::WaitingForConfirmation { .. } => "Waiting for confirmation", ToolCallStatus::Allowed { status } => match status { + acp::ToolCallStatus::Pending => "Pending", acp::ToolCallStatus::InProgress => "In Progress", acp::ToolCallStatus::Completed => "Completed", acp::ToolCallStatus::Failed => "Failed", @@ -345,7 +391,7 @@ impl ToolCallContent { cx: &mut App, ) -> Self { match content { - acp::ToolCallContent::ContentBlock { content } => Self::ContentBlock { + acp::ToolCallContent::ContentBlock(content) => Self::ContentBlock { content: ContentBlock::new(content, &language_registry, cx), }, acp::ToolCallContent::Diff { diff } => Self::Diff { @@ -630,12 +676,50 @@ impl AcpThread { false } - pub fn push_entry(&mut self, entry: AgentThreadEntry, cx: &mut Context) { - self.entries.push(entry); - cx.emit(AcpThreadEvent::NewEntry); + pub fn handle_session_update( + &mut self, + update: acp::SessionUpdate, + cx: &mut Context, + ) -> Result<()> { + match update { + acp::SessionUpdate::UserMessage(content_block) => { + self.push_user_content_block(content_block, cx); + } + acp::SessionUpdate::AgentMessageChunk(content_block) => { + self.push_assistant_content_block(content_block, false, cx); + } + acp::SessionUpdate::AgentThoughtChunk(content_block) => { + self.push_assistant_content_block(content_block, true, cx); + } + acp::SessionUpdate::ToolCall(tool_call) => { + self.upsert_tool_call(tool_call, cx); + } + acp::SessionUpdate::ToolCallUpdate(tool_call_update) => { + self.update_tool_call(tool_call_update, cx)?; + } + acp::SessionUpdate::Plan(plan) => { + self.update_plan(plan, cx); + } + } + Ok(()) + } + + pub fn push_user_content_block(&mut self, chunk: acp::ContentBlock, cx: &mut Context) { + let language_registry = self.project.read(cx).languages().clone(); + let entries_len = self.entries.len(); + + if let Some(last_entry) = self.entries.last_mut() + && let AgentThreadEntry::UserMessage(UserMessage { content }) = last_entry + { + content.append(chunk, &language_registry, cx); + cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1)); + } else { + let content = ContentBlock::new(chunk, &language_registry, cx); + self.push_entry(AgentThreadEntry::UserMessage(UserMessage { content }), cx); + } } - pub fn push_assistant_chunk( + pub fn push_assistant_content_block( &mut self, chunk: acp::ContentBlock, is_thought: bool, @@ -678,23 +762,22 @@ impl AcpThread { } } + fn push_entry(&mut self, entry: AgentThreadEntry, cx: &mut Context) { + self.entries.push(entry); + cx.emit(AcpThreadEvent::NewEntry); + } + pub fn update_tool_call( &mut self, - id: acp::ToolCallId, - status: acp::ToolCallStatus, - content: Option>, + update: acp::ToolCallUpdate, cx: &mut Context, ) -> Result<()> { let languages = self.project.read(cx).languages().clone(); - let (ix, current_call) = self.tool_call_mut(&id).context("Tool call not found")?; - if let Some(content) = content { - current_call.content = content - .into_iter() - .map(|chunk| ToolCallContent::from_acp(chunk, languages.clone(), cx)) - .collect(); - } - current_call.status = ToolCallStatus::Allowed { status }; + let (ix, current_call) = self + .tool_call_mut(&update.id) + .context("Tool call not found")?; + current_call.update(update.fields, languages, cx); cx.emit(AcpThreadEvent::EntryUpdated(ix)); @@ -751,6 +834,37 @@ impl AcpThread { }) } + pub fn set_project_location(&self, location: acp::ToolCallLocation, cx: &mut Context) { + self.project.update(cx, |project, cx| { + let Some(path) = project.project_path_for_absolute_path(&location.path, cx) else { + return; + }; + let buffer = project.open_buffer(path, cx); + cx.spawn(async move |project, cx| { + let buffer = buffer.await?; + + project.update(cx, |project, cx| { + let position = if let Some(line) = location.line { + let snapshot = buffer.read(cx).snapshot(); + let point = snapshot.clip_point(Point::new(line, 0), Bias::Left); + snapshot.anchor_before(point) + } else { + Anchor::MIN + }; + + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position, + }), + cx, + ); + }) + }) + .detach_and_log_err(cx); + }); + } + pub fn request_tool_call_permission( &mut self, tool_call: acp::ToolCall, @@ -801,6 +915,25 @@ impl AcpThread { cx.emit(AcpThreadEvent::EntryUpdated(ix)); } + /// Returns true if the last turn is awaiting tool authorization + pub fn waiting_for_tool_confirmation(&self) -> bool { + for entry in self.entries.iter().rev() { + match &entry { + AgentThreadEntry::ToolCall(call) => match call.status { + ToolCallStatus::WaitingForConfirmation { .. } => return true, + ToolCallStatus::Allowed { .. } + | ToolCallStatus::Rejected + | ToolCallStatus::Canceled => continue, + }, + AgentThreadEntry::UserMessage(_) | AgentThreadEntry::AssistantMessage(_) => { + // Reached the beginning of the turn + return false; + } + } + } + false + } + pub fn plan(&self) -> &Plan { &self.plan } @@ -824,56 +957,6 @@ impl AcpThread { cx.notify(); } - pub fn set_project_location(&self, location: acp::ToolCallLocation, cx: &mut Context) { - self.project.update(cx, |project, cx| { - let Some(path) = project.project_path_for_absolute_path(&location.path, cx) else { - return; - }; - let buffer = project.open_buffer(path, cx); - cx.spawn(async move |project, cx| { - let buffer = buffer.await?; - - project.update(cx, |project, cx| { - let position = if let Some(line) = location.line { - let snapshot = buffer.read(cx).snapshot(); - let point = snapshot.clip_point(Point::new(line, 0), Bias::Left); - snapshot.anchor_before(point) - } else { - Anchor::MIN - }; - - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position, - }), - cx, - ); - }) - }) - .detach_and_log_err(cx); - }); - } - - /// Returns true if the last turn is awaiting tool authorization - pub fn waiting_for_tool_confirmation(&self) -> bool { - for entry in self.entries.iter().rev() { - match &entry { - AgentThreadEntry::ToolCall(call) => match call.status { - ToolCallStatus::WaitingForConfirmation { .. } => return true, - ToolCallStatus::Allowed { .. } - | ToolCallStatus::Rejected - | ToolCallStatus::Canceled => continue, - }, - AgentThreadEntry::UserMessage(_) | AgentThreadEntry::AssistantMessage(_) => { - // Reached the beginning of the turn - return false; - } - } - } - false - } - pub fn authenticate(&self, cx: &mut App) -> impl use<> + Future> { self.connection.authenticate(cx) } @@ -919,7 +1002,7 @@ impl AcpThread { let result = this .update(cx, |this, cx| { this.connection.prompt( - acp::PromptToolArguments { + acp::PromptArguments { prompt: message, session_id: this.session_id.clone(), }, @@ -1148,7 +1231,87 @@ mod tests { } #[gpui::test] - async fn test_thinking_concatenation(cx: &mut TestAppContext) { + async fn test_push_user_content_block(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (thread, _fake_server) = fake_acp_thread(project, cx); + + // Test creating a new user message + thread.update(cx, |thread, cx| { + thread.push_user_content_block( + acp::ContentBlock::Text(acp::TextContent { + annotations: None, + text: "Hello, ".to_string(), + }), + cx, + ); + }); + + thread.update(cx, |thread, cx| { + assert_eq!(thread.entries.len(), 1); + if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[0] { + assert_eq!(user_msg.content.to_markdown(cx), "Hello, "); + } else { + panic!("Expected UserMessage"); + } + }); + + // Test appending to existing user message + thread.update(cx, |thread, cx| { + thread.push_user_content_block( + acp::ContentBlock::Text(acp::TextContent { + annotations: None, + text: "world!".to_string(), + }), + cx, + ); + }); + + thread.update(cx, |thread, cx| { + assert_eq!(thread.entries.len(), 1); + if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[0] { + assert_eq!(user_msg.content.to_markdown(cx), "Hello, world!"); + } else { + panic!("Expected UserMessage"); + } + }); + + // Test creating new user message after assistant message + thread.update(cx, |thread, cx| { + thread.push_assistant_content_block( + acp::ContentBlock::Text(acp::TextContent { + annotations: None, + text: "Assistant response".to_string(), + }), + false, + cx, + ); + }); + + thread.update(cx, |thread, cx| { + thread.push_user_content_block( + acp::ContentBlock::Text(acp::TextContent { + annotations: None, + text: "New user message".to_string(), + }), + cx, + ); + }); + + thread.update(cx, |thread, cx| { + assert_eq!(thread.entries.len(), 3); + if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[2] { + assert_eq!(user_msg.content.to_markdown(cx), "New user message"); + } else { + panic!("Expected UserMessage at index 2"); + } + }); + } + + #[gpui::test] + async fn test_thinking_concatenation(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index fde167da5f6001c7b91c78988b66b0bfd04a8b10..5b25b71863568f61aa7813004857d14d4052988b 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -20,7 +20,7 @@ pub trait AgentConnection { fn authenticate(&self, cx: &mut App) -> Task>; - fn prompt(&self, params: acp::PromptToolArguments, cx: &mut App) -> Task>; + fn prompt(&self, params: acp::PromptArguments, cx: &mut App) -> Task>; fn cancel(&self, session_id: &acp::SessionId, cx: &mut App); } diff --git a/crates/acp_thread/src/old_acp_support.rs b/crates/acp_thread/src/old_acp_support.rs index 316a5bcf25183cb186210e1cecdeffdf399aafdc..44cd00348fa4dc5de282378f64fed042a7b35439 100644 --- a/crates/acp_thread/src/old_acp_support.rs +++ b/crates/acp_thread/src/old_acp_support.rs @@ -8,7 +8,7 @@ use project::Project; use std::{cell::RefCell, error::Error, fmt, path::Path, rc::Rc}; use ui::App; -use crate::{AcpThread, AcpThreadEvent, AgentConnection, ToolCallContent, ToolCallStatus}; +use crate::{AcpThread, AgentConnection}; #[derive(Clone)] pub struct OldAcpClientDelegate { @@ -40,10 +40,10 @@ impl acp_old::Client for OldAcpClientDelegate { .borrow() .update(cx, |thread, cx| match params.chunk { acp_old::AssistantMessageChunk::Text { text } => { - thread.push_assistant_chunk(text.into(), false, cx) + thread.push_assistant_content_block(text.into(), false, cx) } acp_old::AssistantMessageChunk::Thought { thought } => { - thread.push_assistant_chunk(thought.into(), true, cx) + thread.push_assistant_content_block(thought.into(), true, cx) } }) .ok(); @@ -182,31 +182,23 @@ impl acp_old::Client for OldAcpClientDelegate { cx.update(|cx| { self.thread.borrow().update(cx, |thread, cx| { - let languages = thread.project.read(cx).languages().clone(); - - if let Some((ix, tool_call)) = thread - .tool_call_mut(&acp::ToolCallId(request.tool_call_id.0.to_string().into())) - { - tool_call.status = ToolCallStatus::Allowed { - status: into_new_tool_call_status(request.status), - }; - tool_call.content = request - .content - .into_iter() - .map(|content| { - ToolCallContent::from_acp( - into_new_tool_call_content(content), - languages.clone(), - cx, - ) - }) - .collect(); - - cx.emit(AcpThreadEvent::EntryUpdated(ix)); - anyhow::Ok(()) - } else { - anyhow::bail!("Tool call not found") - } + thread.update_tool_call( + acp::ToolCallUpdate { + id: acp::ToolCallId(request.tool_call_id.0.to_string().into()), + fields: acp::ToolCallUpdateFields { + status: Some(into_new_tool_call_status(request.status)), + content: Some( + request + .content + .into_iter() + .map(into_new_tool_call_content) + .collect::>(), + ), + ..Default::default() + }, + }, + cx, + ) }) })? .context("Failed to update thread")??; @@ -285,6 +277,7 @@ fn into_new_tool_call(id: acp::ToolCallId, request: acp_old::PushToolCallParams) .into_iter() .map(into_new_tool_call_location) .collect(), + raw_input: None, } } @@ -311,12 +304,7 @@ fn into_new_tool_call_status(status: acp_old::ToolCallStatus) -> acp::ToolCallSt fn into_new_tool_call_content(content: acp_old::ToolCallContent) -> acp::ToolCallContent { match content { - acp_old::ToolCallContent::Markdown { markdown } => acp::ToolCallContent::ContentBlock { - content: acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: markdown, - }), - }, + acp_old::ToolCallContent::Markdown { markdown } => markdown.into(), acp_old::ToolCallContent::Diff { diff } => acp::ToolCallContent::Diff { diff: into_new_diff(diff), }, @@ -423,7 +411,7 @@ impl AgentConnection for OldAcpAgentConnection { }) } - fn prompt(&self, params: acp::PromptToolArguments, cx: &mut App) -> Task> { + fn prompt(&self, params: acp::PromptArguments, cx: &mut App) -> Task> { let chunks = params .prompt .into_iter() diff --git a/crates/agent/src/agent_profile.rs b/crates/agent/src/agent_profile.rs index a89857e71a6b8ed0f4e7a397be2bcd1bce4b1d7a..34ea1c8df7c4e2bbc58ac8a57b11655917c7aac2 100644 --- a/crates/agent/src/agent_profile.rs +++ b/crates/agent/src/agent_profile.rs @@ -308,7 +308,12 @@ mod tests { unimplemented!() } - fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool { + fn needs_confirmation( + &self, + _input: &serde_json::Value, + _project: &Entity, + _cx: &App, + ) -> bool { unimplemented!() } diff --git a/crates/agent/src/context_server_tool.rs b/crates/agent/src/context_server_tool.rs index 4c6d2b2b0bcc528df364b3122eacc8350d6a99be..85e8ac7451405a292af1679406f4d184fe709eb9 100644 --- a/crates/agent/src/context_server_tool.rs +++ b/crates/agent/src/context_server_tool.rs @@ -47,7 +47,7 @@ impl Tool for ContextServerTool { } } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { true } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 1af27ca8a7a7e98f9ba06c7ed5d5549a66e071a4..1b8aa012a18feda39282fc96987d574e3df38f65 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -942,7 +942,7 @@ impl Thread { } pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec { - self.tool_use.tool_uses_for_message(id, cx) + self.tool_use.tool_uses_for_message(id, &self.project, cx) } pub fn tool_results_for_message( @@ -2557,7 +2557,7 @@ impl Thread { return self.handle_hallucinated_tool_use(tool_use.id, tool_use.name, window, cx); } - if tool.needs_confirmation(&tool_use.input, cx) + if tool.needs_confirmation(&tool_use.input, &self.project, cx) && !AgentSettings::get_global(cx).always_allow_tool_actions { self.tool_use.confirm_tool_use( diff --git a/crates/agent/src/tool_use.rs b/crates/agent/src/tool_use.rs index 74c719b4e6cf4ad0743a833f8b1c9fcc9da8b929..7392c0878d17adf8038292b10a7a8c349d3ec4e8 100644 --- a/crates/agent/src/tool_use.rs +++ b/crates/agent/src/tool_use.rs @@ -165,7 +165,12 @@ impl ToolUseState { self.pending_tool_uses_by_id.values().collect() } - pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec { + pub fn tool_uses_for_message( + &self, + id: MessageId, + project: &Entity, + cx: &App, + ) -> Vec { let Some(tool_uses_for_message) = &self.tool_uses_by_assistant_message.get(&id) else { return Vec::new(); }; @@ -211,7 +216,10 @@ impl ToolUseState { let (icon, needs_confirmation) = if let Some(tool) = self.tools.read(cx).tool(&tool_use.name, cx) { - (tool.icon(), tool.needs_confirmation(&tool_use.input, cx)) + ( + tool.icon(), + tool.needs_confirmation(&tool_use.input, project, cx), + ) } else { (IconName::Cog, false) }; diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 4371f7684dd4d618d755eb5468c8b8f62d4a8432..dcffb05bc07188ed73c602782ce6075a97a3ef70 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -29,6 +29,7 @@ itertools.workspace = true log.workspace = true paths.workspace = true project.workspace = true +rand.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true @@ -40,6 +41,7 @@ ui.workspace = true util.workspace = true uuid.workspace = true watch.workspace = true +indoc.workspace = true which.workspace = true workspace-hack.workspace = true diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index 660f61f9071132c5cc0f01eeda168bb829dcaab7..212bb74d8aab6cab2639ac1699481163eb0082e7 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -1,11 +1,14 @@ mod claude; +mod codex; mod gemini; +mod mcp_server; mod settings; #[cfg(test)] mod e2e_tests; pub use claude::*; +pub use codex::*; pub use gemini::*; pub use settings::*; diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index d63d8c43cfbf0951cf469a3eae5e982d8d136cf3..6565786204ac01978b632c56a0ff78bae0086f53 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -9,7 +9,6 @@ use smol::process::Child; use std::cell::RefCell; use std::fmt::Display; use std::path::Path; -use std::pin::pin; use std::rc::Rc; use uuid::Uuid; @@ -45,7 +44,7 @@ impl AgentServer for ClaudeCode { } fn empty_state_message(&self) -> &'static str { - "" + "How can I help you today?" } fn logo(&self) -> ui::IconName { @@ -66,19 +65,6 @@ impl AgentServer for ClaudeCode { } } -#[cfg(unix)] -fn send_interrupt(pid: libc::pid_t) -> anyhow::Result<()> { - let pid = nix::unistd::Pid::from_raw(pid); - - nix::sys::signal::kill(pid, nix::sys::signal::SIGINT) - .map_err(|e| anyhow!("Failed to interrupt process: {}", e)) -} - -#[cfg(windows)] -fn send_interrupt(_pid: i32) -> anyhow::Result<()> { - panic!("Cancel not implemented on Windows") -} - struct ClaudeAgentConnection { sessions: Rc>>, } @@ -127,7 +113,6 @@ impl AgentConnection for ClaudeAgentConnection { let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded(); let (outgoing_tx, outgoing_rx) = mpsc::unbounded(); - let (cancel_tx, mut cancel_rx) = mpsc::unbounded::>>(); let session_id = acp::SessionId(Uuid::new_v4().to_string().into()); @@ -137,50 +122,28 @@ impl AgentConnection for ClaudeAgentConnection { let session_id = session_id.clone(); async move { let mut outgoing_rx = Some(outgoing_rx); - let mut mode = ClaudeSessionMode::Start; - - loop { - let mut child = spawn_claude( - &command, - mode, - session_id.clone(), - &mcp_config_path, - &cwd, - ) - .await?; - mode = ClaudeSessionMode::Resume; - - let pid = child.id(); - log::trace!("Spawned (pid: {})", pid); - - let mut io_fut = pin!( - ClaudeAgentSession::handle_io( - outgoing_rx.take().unwrap(), - incoming_message_tx.clone(), - child.stdin.take().unwrap(), - child.stdout.take().unwrap(), - ) - .fuse() - ); - - select_biased! { - done_tx = cancel_rx.next() => { - if let Some(done_tx) = done_tx { - log::trace!("Interrupted (pid: {})", pid); - let result = send_interrupt(pid as i32); - outgoing_rx.replace(io_fut.await?); - done_tx.send(result).log_err(); - continue; - } - } - result = io_fut => { - result?; - } - } - log::trace!("Stopped (pid: {})", pid); - break; - } + let mut child = spawn_claude( + &command, + ClaudeSessionMode::Start, + session_id.clone(), + &mcp_config_path, + &cwd, + ) + .await?; + + let pid = child.id(); + log::trace!("Spawned (pid: {})", pid); + + ClaudeAgentSession::handle_io( + outgoing_rx.take().unwrap(), + incoming_message_tx.clone(), + child.stdin.take().unwrap(), + child.stdout.take().unwrap(), + ) + .await?; + + log::trace!("Stopped (pid: {})", pid); drop(mcp_config_path); anyhow::Ok(()) @@ -213,7 +176,6 @@ impl AgentConnection for ClaudeAgentConnection { let session = ClaudeAgentSession { outgoing_tx, end_turn_tx, - cancel_tx, _handler_task: handler_task, _mcp_server: Some(permission_mcp_server), }; @@ -228,7 +190,7 @@ impl AgentConnection for ClaudeAgentConnection { Task::ready(Err(anyhow!("Authentication not supported"))) } - fn prompt(&self, params: acp::PromptToolArguments, cx: &mut App) -> Task> { + fn prompt(&self, params: acp::PromptArguments, cx: &mut App) -> Task> { let sessions = self.sessions.borrow(); let Some(session) = sessions.get(¶ms.session_id) else { return Task::ready(Err(anyhow!( @@ -278,37 +240,24 @@ impl AgentConnection for ClaudeAgentConnection { }) } - fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { + 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 (done_tx, done_rx) = oneshot::channel(); - if session - .cancel_tx - .unbounded_send(done_tx) - .log_err() - .is_some() - { - let end_turn_tx = session.end_turn_tx.clone(); - cx.foreground_executor() - .spawn(async move { - done_rx.await??; - if let Some(end_turn_tx) = end_turn_tx.take() { - end_turn_tx.send(Ok(())).ok(); - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } + session + .outgoing_tx + .unbounded_send(SdkMessage::new_interrupt_message()) + .log_err(); } } #[derive(Clone, Copy)] enum ClaudeSessionMode { Start, + #[expect(dead_code)] Resume, } @@ -364,7 +313,6 @@ async fn spawn_claude( struct ClaudeAgentSession { outgoing_tx: UnboundedSender, end_turn_tx: Rc>>>>, - cancel_tx: UnboundedSender>>, _mcp_server: Option, _handler_task: Task<()>, } @@ -377,6 +325,8 @@ impl ClaudeAgentSession { cx: &mut AsyncApp, ) { match message { + // we should only be sending these out, they don't need to be in the thread + SdkMessage::ControlRequest { .. } => {} SdkMessage::Assistant { message, session_id: _, @@ -400,7 +350,7 @@ impl ClaudeAgentSession { ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => { thread .update(cx, |thread, cx| { - thread.push_assistant_chunk(text.into(), false, cx) + thread.push_assistant_content_block(text.into(), false, cx) }) .log_err(); } @@ -437,9 +387,15 @@ impl ClaudeAgentSession { thread .update(cx, |thread, cx| { thread.update_tool_call( - acp::ToolCallId(tool_use_id.into()), - acp::ToolCallStatus::Completed, - (!content.is_empty()).then(|| vec![content.into()]), + acp::ToolCallUpdate { + id: acp::ToolCallId(tool_use_id.into()), + fields: acp::ToolCallUpdateFields { + status: Some(acp::ToolCallStatus::Completed), + content: (!content.is_empty()) + .then(|| vec![content.into()]), + ..Default::default() + }, + }, cx, ) }) @@ -452,7 +408,7 @@ impl ClaudeAgentSession { | ContentChunk::WebSearchToolResult => { thread .update(cx, |thread, cx| { - thread.push_assistant_chunk( + thread.push_assistant_content_block( format!("Unsupported content: {:?}", chunk).into(), false, cx, @@ -464,17 +420,25 @@ impl ClaudeAgentSession { } } SdkMessage::Result { - is_error, subtype, .. + is_error, + subtype, + result, + .. } => { if let Some(end_turn_tx) = end_turn_tx.borrow_mut().take() { if is_error { - end_turn_tx.send(Err(anyhow!("Error: {subtype}"))).ok(); + end_turn_tx + .send(Err(anyhow!( + "Error: {}", + result.unwrap_or_else(|| subtype.to_string()) + ))) + .ok(); } else { end_turn_tx.send(Ok(())).ok(); } } } - SdkMessage::System { .. } => {} + SdkMessage::System { .. } | SdkMessage::ControlResponse { .. } => {} } } @@ -643,14 +607,12 @@ enum SdkMessage { #[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, @@ -675,6 +637,26 @@ enum SdkMessage { #[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)] @@ -695,6 +677,24 @@ impl Display for ResultErrorType { } } +impl SdkMessage { + fn new_interrupt_message() -> Self { + use rand::Rng; + // In the Claude Code TS SDK they just generate a random 12 character string, + // `Math.random().toString(36).substring(2, 15)` + let request_id = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(12) + .map(char::from) + .collect(); + + Self::ControlRequest { + request_id, + request: ControlRequest::Interrupt, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct McpServer { name: String, @@ -715,7 +715,7 @@ pub(crate) mod tests { use super::*; use serde_json::json; - crate::common_e2e_tests!(ClaudeCode); + crate::common_e2e_tests!(ClaudeCode, allow_option_id = "allow"); pub fn local_command() -> AgentServerCommand { AgentServerCommand { diff --git a/crates/agent_servers/src/claude/mcp_server.rs b/crates/agent_servers/src/claude/mcp_server.rs index a320a6d37fb5e8714790fe4cf35ef4011e8774e1..cc303016f11295c0a75924e0ca12a10dcd2cf379 100644 --- a/crates/agent_servers/src/claude/mcp_server.rs +++ b/crates/agent_servers/src/claude/mcp_server.rs @@ -42,9 +42,13 @@ impl ClaudeZedMcpServer { } 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![ @@ -174,6 +178,7 @@ impl McpServerTool for PermissionTool { updated_input: input.input, } } else { + debug_assert_eq!(chosen_option, reject_option_id); PermissionToolResponse { behavior: PermissionToolBehavior::Deny, updated_input: input.input, diff --git a/crates/agent_servers/src/claude/tools.rs b/crates/agent_servers/src/claude/tools.rs index ed25f9af7f1c57375470575910aba4235ad4121d..6acb6355aacfbfa471f9a5844b5db0c373e5b1ed 100644 --- a/crates/agent_servers/src/claude/tools.rs +++ b/crates/agent_servers/src/claude/tools.rs @@ -311,6 +311,7 @@ impl ClaudeTool { label: self.label(), content: self.content(), locations: self.locations(), + raw_input: None, } } } diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs new file mode 100644 index 0000000000000000000000000000000000000000..b10ce9cf54b75039e768e70ad65d2f0eb318aaf8 --- /dev/null +++ b/crates/agent_servers/src/codex.rs @@ -0,0 +1,317 @@ +use agent_client_protocol as acp; +use anyhow::anyhow; +use collections::HashMap; +use context_server::listener::McpServerTool; +use context_server::types::requests; +use context_server::{ContextServer, ContextServerCommand, ContextServerId}; +use futures::channel::{mpsc, oneshot}; +use project::Project; +use settings::SettingsStore; +use smol::stream::StreamExt as _; +use std::cell::RefCell; +use std::rc::Rc; +use std::{path::Path, sync::Arc}; +use util::ResultExt; + +use anyhow::{Context, Result}; +use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity}; + +use crate::mcp_server::ZedMcpServer; +use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings, mcp_server}; +use acp_thread::{AcpThread, AgentConnection}; + +#[derive(Clone)] +pub struct Codex; + +impl AgentServer for Codex { + fn name(&self) -> &'static str { + "Codex" + } + + fn empty_state_headline(&self) -> &'static str { + "Welcome to Codex" + } + + fn empty_state_message(&self) -> &'static str { + "What can I help with?" + } + + fn logo(&self) -> ui::IconName { + ui::IconName::AiOpenAi + } + + fn connect( + &self, + _root_dir: &Path, + project: &Entity, + cx: &mut App, + ) -> Task>> { + let project = project.clone(); + cx.spawn(async move |cx| { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).codex.clone() + })?; + + let Some(command) = + AgentServerCommand::resolve("codex", &["mcp"], settings, &project, cx).await + else { + anyhow::bail!("Failed to find codex binary"); + }; + + let client: Arc = ContextServer::stdio( + ContextServerId("codex-mcp-server".into()), + ContextServerCommand { + path: command.path, + args: command.args, + env: command.env, + }, + ) + .into(); + ContextServer::start(client.clone(), cx).await?; + + let (notification_tx, mut notification_rx) = mpsc::unbounded(); + client + .client() + .context("Failed to subscribe")? + .on_notification(acp::SESSION_UPDATE_METHOD_NAME, { + move |notification, _cx| { + let notification_tx = notification_tx.clone(); + log::trace!( + "ACP Notification: {}", + serde_json::to_string_pretty(¬ification).unwrap() + ); + + if let Some(notification) = + serde_json::from_value::(notification) + .log_err() + { + notification_tx.unbounded_send(notification).ok(); + } + } + }); + + let sessions = Rc::new(RefCell::new(HashMap::default())); + + let notification_handler_task = cx.spawn({ + let sessions = sessions.clone(); + async move |cx| { + while let Some(notification) = notification_rx.next().await { + CodexConnection::handle_session_notification( + notification, + sessions.clone(), + cx, + ) + } + } + }); + + let connection = CodexConnection { + client, + sessions, + _notification_handler_task: notification_handler_task, + }; + Ok(Rc::new(connection) as _) + }) + } +} + +struct CodexConnection { + client: Arc, + sessions: Rc>>, + _notification_handler_task: Task<()>, +} + +struct CodexSession { + thread: WeakEntity, + cancel_tx: Option>, + _mcp_server: ZedMcpServer, +} + +impl AgentConnection for CodexConnection { + fn name(&self) -> &'static str { + "Codex" + } + + fn new_thread( + self: Rc, + project: Entity, + cwd: &Path, + cx: &mut AsyncApp, + ) -> Task>> { + let client = self.client.client(); + let sessions = self.sessions.clone(); + let cwd = cwd.to_path_buf(); + cx.spawn(async move |cx| { + let client = client.context("MCP server is not initialized yet")?; + let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid()); + + let mcp_server = ZedMcpServer::new(thread_rx, cx).await?; + + let response = client + .request::(context_server::types::CallToolParams { + name: acp::NEW_SESSION_TOOL_NAME.into(), + arguments: Some(serde_json::to_value(acp::NewSessionArguments { + mcp_servers: [( + mcp_server::SERVER_NAME.to_string(), + mcp_server.server_config()?, + )] + .into(), + client_tools: acp::ClientTools { + request_permission: Some(acp::McpToolId { + mcp_server: mcp_server::SERVER_NAME.into(), + tool_name: mcp_server::RequestPermissionTool::NAME.into(), + }), + read_text_file: Some(acp::McpToolId { + mcp_server: mcp_server::SERVER_NAME.into(), + tool_name: mcp_server::ReadTextFileTool::NAME.into(), + }), + write_text_file: Some(acp::McpToolId { + mcp_server: mcp_server::SERVER_NAME.into(), + tool_name: mcp_server::WriteTextFileTool::NAME.into(), + }), + }, + cwd, + })?), + meta: None, + }) + .await?; + + if response.is_error.unwrap_or_default() { + return Err(anyhow!(response.text_contents())); + } + + let result = serde_json::from_value::( + response.structured_content.context("Empty response")?, + )?; + + let thread = + cx.new(|cx| AcpThread::new(self.clone(), project, result.session_id.clone(), cx))?; + + thread_tx.send(thread.downgrade())?; + + let session = CodexSession { + thread: thread.downgrade(), + cancel_tx: None, + _mcp_server: mcp_server, + }; + sessions.borrow_mut().insert(result.session_id, session); + + Ok(thread) + }) + } + + fn authenticate(&self, _cx: &mut App) -> Task> { + Task::ready(Err(anyhow!("Authentication not supported"))) + } + + fn prompt( + &self, + params: agent_client_protocol::PromptArguments, + cx: &mut App, + ) -> Task> { + let client = self.client.client(); + let sessions = self.sessions.clone(); + + cx.foreground_executor().spawn(async move { + let client = client.context("MCP server is not initialized yet")?; + + let (new_cancel_tx, cancel_rx) = oneshot::channel(); + { + let mut sessions = sessions.borrow_mut(); + let session = sessions + .get_mut(¶ms.session_id) + .context("Session not found")?; + session.cancel_tx.replace(new_cancel_tx); + } + + let result = client + .request_with::( + context_server::types::CallToolParams { + name: acp::PROMPT_TOOL_NAME.into(), + arguments: Some(serde_json::to_value(params)?), + meta: None, + }, + Some(cancel_rx), + None, + ) + .await; + + if let Err(err) = &result + && err.is::() + { + return Ok(()); + } + + let response = result?; + + if response.is_error.unwrap_or_default() { + return Err(anyhow!(response.text_contents())); + } + + Ok(()) + }) + } + + fn cancel(&self, session_id: &agent_client_protocol::SessionId, _cx: &mut App) { + let mut sessions = self.sessions.borrow_mut(); + + if let Some(cancel_tx) = sessions + .get_mut(session_id) + .and_then(|session| session.cancel_tx.take()) + { + cancel_tx.send(()).ok(); + } + } +} + +impl CodexConnection { + pub fn handle_session_notification( + notification: acp::SessionNotification, + threads: Rc>>, + cx: &mut AsyncApp, + ) { + let threads = threads.borrow(); + let Some(thread) = threads + .get(¬ification.session_id) + .and_then(|session| session.thread.upgrade()) + else { + log::error!( + "Thread not found for session ID: {}", + notification.session_id + ); + return; + }; + + thread + .update(cx, |thread, cx| { + thread.handle_session_update(notification.update, cx) + }) + .log_err(); + } +} + +impl Drop for CodexConnection { + fn drop(&mut self) { + self.client.stop().log_err(); + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::AgentServerCommand; + use std::path::Path; + + crate::common_e2e_tests!(Codex, allow_option_id = "approve"); + + pub fn local_command() -> AgentServerCommand { + let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../codex/codex-rs/target/debug/codex"); + + AgentServerCommand { + path: cli_path, + args: vec!["mcp".into()], + env: None, + } + } +} diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 9bc6fd60fe5b99dc41120131d8e5415008beae51..aca9001c79de2cf60947ec4d67b10ae52e936107 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -1,4 +1,8 @@ -use std::{path::Path, sync::Arc, time::Duration}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; use crate::{AgentServer, AgentServerSettings, AllAgentServersSettings}; use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; @@ -79,21 +83,28 @@ pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut Tes .unwrap(); thread.read_with(cx, |thread, cx| { - assert_eq!(thread.entries().len(), 3); assert!(matches!( thread.entries()[0], AgentThreadEntry::UserMessage(_) )); - assert!(matches!(thread.entries()[1], AgentThreadEntry::ToolCall(_))); - let AgentThreadEntry::AssistantMessage(assistant_message) = &thread.entries()[2] else { - panic!("Expected AssistantMessage") - }; + let assistant_message = &thread + .entries() + .iter() + .rev() + .find_map(|entry| match entry { + AgentThreadEntry::AssistantMessage(msg) => Some(msg), + _ => None, + }) + .unwrap(); + assert!( assistant_message.to_markdown(cx).contains("Hello, world!"), "unexpected assistant message: {:?}", assistant_message.to_markdown(cx) ); }); + + drop(tempdir); } pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestAppContext) { @@ -136,6 +147,7 @@ pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestApp pub async fn test_tool_call_with_confirmation( server: impl AgentServer + 'static, + allow_option_id: acp::PermissionOptionId, cx: &mut TestAppContext, ) { let fs = init_test(cx).await; @@ -186,7 +198,7 @@ pub async fn test_tool_call_with_confirmation( thread.update(cx, |thread, cx| { thread.authorize_tool_call( tool_call_id, - acp::PermissionOptionId("0".into()), + allow_option_id, acp::PermissionOptionKind::AllowOnce, cx, ); @@ -294,7 +306,7 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon #[macro_export] macro_rules! common_e2e_tests { - ($server:expr) => { + ($server:expr, allow_option_id = $allow_option_id:expr) => { mod common_e2e { use super::*; @@ -319,7 +331,12 @@ macro_rules! common_e2e_tests { #[::gpui::test] #[cfg_attr(not(feature = "e2e"), ignore)] async fn tool_call_with_confirmation(cx: &mut ::gpui::TestAppContext) { - $crate::e2e_tests::test_tool_call_with_confirmation($server, cx).await; + $crate::e2e_tests::test_tool_call_with_confirmation( + $server, + ::agent_client_protocol::PermissionOptionId($allow_option_id.into()), + cx, + ) + .await; } #[::gpui::test] @@ -351,6 +368,9 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { gemini: Some(AgentServerSettings { command: crate::gemini::tests::local_command(), }), + codex: Some(AgentServerSettings { + command: crate::codex::tests::local_command(), + }), }, cx, ); @@ -409,3 +429,24 @@ pub async fn run_until_first_tool_call( } } } + +pub fn get_zed_path() -> PathBuf { + let mut zed_path = std::env::current_exe().unwrap(); + + while zed_path + .file_name() + .map_or(true, |name| name.to_string_lossy() != "debug") + { + if !zed_path.pop() { + panic!("Could not find target directory"); + } + } + + zed_path.push("zed"); + + if !zed_path.exists() { + panic!("\n🚨 Run `cargo build` at least once before running e2e tests\n\n"); + } + + zed_path +} diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 47b965cdada579019364f8798abb3c0baef691ff..8b9fed5777f2ff409170db998cb114e6e7a380e6 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -188,7 +188,7 @@ pub(crate) mod tests { use crate::AgentServerCommand; use std::path::Path; - crate::common_e2e_tests!(Gemini); + crate::common_e2e_tests!(Gemini, allow_option_id = "0"); pub fn local_command() -> AgentServerCommand { let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/agent_servers/src/mcp_server.rs b/crates/agent_servers/src/mcp_server.rs new file mode 100644 index 0000000000000000000000000000000000000000..055b89dfe2de2649fd0aedab66e5aef8aa362100 --- /dev/null +++ b/crates/agent_servers/src/mcp_server.rs @@ -0,0 +1,207 @@ +use acp_thread::AcpThread; +use agent_client_protocol as acp; +use anyhow::Result; +use context_server::listener::{McpServerTool, ToolResponse}; +use context_server::types::{ + Implementation, InitializeParams, InitializeResponse, ProtocolVersion, ServerCapabilities, + ToolsCapabilities, requests, +}; +use futures::channel::oneshot; +use gpui::{App, AsyncApp, Task, WeakEntity}; +use indoc::indoc; + +pub struct ZedMcpServer { + server: context_server::listener::McpServer, +} + +pub const SERVER_NAME: &str = "zed"; + +impl ZedMcpServer { + pub async fn new( + thread_rx: watch::Receiver>, + 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(RequestPermissionTool { + thread_rx: thread_rx.clone(), + }); + mcp_server.add_tool(ReadTextFileTool { + thread_rx: thread_rx.clone(), + }); + mcp_server.add_tool(WriteTextFileTool { + thread_rx: thread_rx.clone(), + }); + + Ok(Self { server: mcp_server }) + } + + pub fn server_config(&self) -> Result { + #[cfg(not(test))] + let zed_path = anyhow::Context::context( + std::env::current_exe(), + "finding current executable path for use in mcp_server", + )?; + + #[cfg(test)] + let zed_path = crate::e2e_tests::get_zed_path(); + + Ok(acp::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, + }) + }) + } +} + +// Tools + +#[derive(Clone)] +pub struct RequestPermissionTool { + thread_rx: watch::Receiver>, +} + +impl McpServerTool for RequestPermissionTool { + type Input = acp::RequestPermissionArguments; + type Output = acp::RequestPermissionOutput; + + const NAME: &'static str = "Confirmation"; + + fn description(&self) -> &'static str { + indoc! {" + Request permission for tool calls. + + This tool is meant to be called programmatically by the agent loop, not the LLM. + "} + } + + 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 result = thread + .update(cx, |thread, cx| { + thread.request_tool_call_permission(input.tool_call, input.options, cx) + })? + .await; + + let outcome = match result { + Ok(option_id) => acp::RequestPermissionOutcome::Selected { option_id }, + Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Canceled, + }; + + Ok(ToolResponse { + content: vec![], + structured_content: acp::RequestPermissionOutput { outcome }, + }) + } +} + +#[derive(Clone)] +pub struct ReadTextFileTool { + thread_rx: watch::Receiver>, +} + +impl McpServerTool for ReadTextFileTool { + type Input = acp::ReadTextFileArguments; + type Output = acp::ReadTextFileOutput; + + const NAME: &'static str = "Read"; + + fn description(&self) -> &'static str { + "Reads the content of the given file in the project including unsaved changes." + } + + 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.path, input.line, input.limit, false, cx) + })? + .await?; + + Ok(ToolResponse { + content: vec![], + structured_content: acp::ReadTextFileOutput { content }, + }) + } +} + +#[derive(Clone)] +pub struct WriteTextFileTool { + thread_rx: watch::Receiver>, +} + +impl McpServerTool for WriteTextFileTool { + type Input = acp::WriteTextFileArguments; + type Output = (); + + const NAME: &'static str = "Write"; + + fn description(&self) -> &'static str { + "Write to a file replacing its contents" + } + + 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.path, input.content, cx) + })? + .await?; + + Ok(ToolResponse { + content: vec![], + structured_content: (), + }) + } +} diff --git a/crates/agent_servers/src/settings.rs b/crates/agent_servers/src/settings.rs index 645674b5f15087250c2364fb9a8a846e163ad54c..aeb34a5e61df382e99e8cb5f8b613993d6bd82b0 100644 --- a/crates/agent_servers/src/settings.rs +++ b/crates/agent_servers/src/settings.rs @@ -13,6 +13,7 @@ pub fn init(cx: &mut App) { pub struct AllAgentServersSettings { pub gemini: Option, pub claude: Option, + pub codex: Option, } #[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)] @@ -29,13 +30,21 @@ impl settings::Settings for AllAgentServersSettings { fn load(sources: SettingsSources, _: &mut App) -> Result { let mut settings = AllAgentServersSettings::default(); - for AllAgentServersSettings { gemini, claude } in sources.defaults_and_customizations() { + for AllAgentServersSettings { + gemini, + claude, + codex, + } in sources.defaults_and_customizations() + { if gemini.is_some() { settings.gemini = gemini.clone(); } if claude.is_some() { settings.claude = claude.clone(); } + if codex.is_some() { + settings.codex = codex.clone(); + } } Ok(settings) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 7f5de9db5f2c8f7b900a698193f0599e6c7270a2..e46e1ae3ab0a4ec356e1926059b485923e2f901b 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -872,7 +872,10 @@ impl AcpThreadView { let header_id = SharedString::from(format!("tool-call-header-{}", entry_ix)); let status_icon = match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { .. } => None, + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Pending, + } + | ToolCallStatus::WaitingForConfirmation { .. } => None, ToolCallStatus::Allowed { status: acp::ToolCallStatus::InProgress, .. @@ -957,6 +960,8 @@ impl AcpThreadView { Icon::new(match tool_call.kind { acp::ToolKind::Read => IconName::ToolRead, acp::ToolKind::Edit => IconName::ToolPencil, + acp::ToolKind::Delete => IconName::ToolDeleteFile, + acp::ToolKind::Move => IconName::ArrowRightLeft, acp::ToolKind::Search => IconName::ToolSearch, acp::ToolKind::Execute => IconName::ToolTerminal, acp::ToolKind::Think => IconName::ToolBulb, @@ -1068,6 +1073,7 @@ impl AcpThreadView { options, entry_ix, tool_call.id.clone(), + tool_call.content.is_empty(), cx, )), ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => { @@ -1126,6 +1132,7 @@ impl AcpThreadView { options: &[acp::PermissionOption], entry_ix: usize, tool_call_id: acp::ToolCallId, + empty_content: bool, cx: &Context, ) -> Div { h_flex() @@ -1133,8 +1140,10 @@ impl AcpThreadView { .px_1p5() .gap_1() .justify_end() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) + .when(!empty_content, |this| { + this.border_t_1() + .border_color(self.tool_card_border_color(cx)) + }) .children(options.iter().map(|option| { let option_id = SharedString::from(option.id.0.clone()); Button::new((option_id, entry_ix), option.label.clone()) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 43c1167af80cd35a48f340ded8933fda3f4e6175..61a65de50b537a8982b080ae2054ffd0eeaaa706 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1991,6 +1991,20 @@ impl AgentPanel { ); }), ) + .item( + ContextMenuEntry::new("New Codex Thread") + .icon(IconName::AiOpenAi) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some(crate::ExternalAgent::Codex), + } + .boxed_clone(), + cx, + ); + }), + ) }); menu })) @@ -2652,6 +2666,25 @@ impl AgentPanel { ) }, ), + ) + .child( + NewThreadButton::new( + "new-codex-thread-btn", + "New Codex Thread", + IconName::AiOpenAi, + ) + .on_click( + |window, cx| { + window.dispatch_action( + Box::new(NewExternalAgentThread { + agent: Some( + crate::ExternalAgent::Codex, + ), + }), + cx, + ) + }, + ), ), ) }), diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 6ae78585decb4797496d60afbfff2ea643030da0..4b75cc9e77916b6ac3a281aca52cc6a75e81267f 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -150,6 +150,7 @@ enum ExternalAgent { #[default] Gemini, ClaudeCode, + Codex, } impl ExternalAgent { @@ -157,6 +158,7 @@ impl ExternalAgent { match self { ExternalAgent::Gemini => Rc::new(agent_servers::Gemini), ExternalAgent::ClaudeCode => Rc::new(agent_servers::ClaudeCode), + ExternalAgent::Codex => Rc::new(agent_servers::Codex), } } } diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 7fffb60ecc32f65ddd97264f511ec4f5d735cfbe..3aec9c62cd101e3332ba08745f4a82d0e92a75d0 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -1,12 +1,14 @@ mod agent_api_keys_onboarding; mod agent_panel_onboarding_card; mod agent_panel_onboarding_content; +mod ai_upsell_card; mod edit_prediction_onboarding_content; mod young_account_banner; pub use agent_api_keys_onboarding::{ApiKeysWithProviders, ApiKeysWithoutProviders}; pub use agent_panel_onboarding_card::AgentPanelOnboardingCard; pub use agent_panel_onboarding_content::AgentPanelOnboarding; +pub use ai_upsell_card::AiUpsellCard; pub use edit_prediction_onboarding_content::EditPredictionOnboarding; pub use young_account_banner::YoungAccountBanner; @@ -54,6 +56,7 @@ impl RenderOnce for BulletItem { } } +#[derive(PartialEq)] pub enum SignInStatus { SignedIn, SigningIn, diff --git a/crates/ai_onboarding/src/ai_upsell_card.rs b/crates/ai_onboarding/src/ai_upsell_card.rs new file mode 100644 index 0000000000000000000000000000000000000000..041e0d87ecb78485600956ddfc422acf16a4a64d --- /dev/null +++ b/crates/ai_onboarding/src/ai_upsell_card.rs @@ -0,0 +1,201 @@ +use std::sync::Arc; + +use client::{Client, zed_urls}; +use gpui::{AnyElement, App, IntoElement, RenderOnce, Window}; +use ui::{Divider, List, Vector, VectorName, prelude::*}; + +use crate::{BulletItem, SignInStatus}; + +#[derive(IntoElement, RegisterComponent)] +pub struct AiUpsellCard { + pub sign_in_status: SignInStatus, + pub sign_in: Arc, +} + +impl AiUpsellCard { + pub fn new(client: Arc) -> Self { + let status = *client.status().borrow(); + + Self { + sign_in_status: status.into(), + sign_in: Arc::new(move |_window, cx| { + cx.spawn({ + let client = client.clone(); + async move |cx| { + client.authenticate_and_connect(true, cx).await; + } + }) + .detach(); + }), + } + } +} + +impl RenderOnce for AiUpsellCard { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let pro_section = v_flex() + .w_full() + .gap_1() + .child( + h_flex() + .gap_2() + .child( + Label::new("Pro") + .size(LabelSize::Small) + .color(Color::Accent) + .buffer_font(cx), + ) + .child(Divider::horizontal()), + ) + .child( + List::new() + .child(BulletItem::new("500 prompts with Claude models")) + .child(BulletItem::new( + "Unlimited edit predictions with Zeta, our open-source model", + )), + ); + + let free_section = v_flex() + .w_full() + .gap_1() + .child( + h_flex() + .gap_2() + .child( + Label::new("Free") + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx), + ) + .child(Divider::horizontal()), + ) + .child( + List::new() + .child(BulletItem::new("50 prompts with the Claude models")) + .child(BulletItem::new("2,000 accepted edit predictions")), + ); + + let grid_bg = h_flex().absolute().inset_0().w_full().h(px(240.)).child( + Vector::new(VectorName::Grid, rems_from_px(500.), rems_from_px(240.)) + .color(Color::Custom(cx.theme().colors().border.opacity(0.05))), + ); + + let gradient_bg = div() + .absolute() + .inset_0() + .size_full() + .bg(gpui::linear_gradient( + 180., + gpui::linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.8), + 0., + ), + gpui::linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.), + 0.8, + ), + )); + + const DESCRIPTION: &str = "Zed offers a complete agentic experience, with robust editing and reviewing features to collaborate with AI."; + + let footer_buttons = match self.sign_in_status { + SignInStatus::SignedIn => v_flex() + .items_center() + .gap_1() + .child( + Button::new("sign_in", "Start 14-day Free Pro Trial") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .on_click(move |_, _window, cx| { + telemetry::event!("Start Trial Clicked", state = "post-sign-in"); + cx.open_url(&zed_urls::start_trial_url(cx)) + }), + ) + .child( + Label::new("No credit card required") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element(), + _ => Button::new("sign_in", "Sign In") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .on_click({ + let callback = self.sign_in.clone(); + move |_, window, cx| { + telemetry::event!("Start Trial Clicked", state = "pre-sign-in"); + callback(window, cx) + } + }) + .into_any_element(), + }; + + v_flex() + .relative() + .p_6() + .pt_4() + .border_1() + .border_color(cx.theme().colors().border) + .rounded_lg() + .overflow_hidden() + .child(grid_bg) + .child(gradient_bg) + .child(Headline::new("Try Zed AI")) + .child(Label::new(DESCRIPTION).color(Color::Muted).mb_2()) + .child( + h_flex() + .mt_1p5() + .mb_2p5() + .items_start() + .gap_12() + .child(free_section) + .child(pro_section), + ) + .child(footer_buttons) + } +} + +impl Component for AiUpsellCard { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn name() -> &'static str { + "AI Upsell Card" + } + + fn sort_name() -> &'static str { + "AI Upsell Card" + } + + fn description() -> Option<&'static str> { + Some("A card presenting the Zed AI product during user's first-open onboarding flow.") + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .p_4() + .gap_4() + .children(vec![example_group(vec![ + single_example( + "Signed Out State", + AiUpsellCard { + sign_in_status: SignInStatus::SignedOut, + sign_in: Arc::new(|_, _| {}), + } + .into_any_element(), + ), + single_example( + "Signed In State", + AiUpsellCard { + sign_in_status: SignInStatus::SignedIn, + sign_in: Arc::new(|_, _| {}), + } + .into_any_element(), + ), + ])]) + .into_any_element(), + ) + } +} diff --git a/crates/assistant_tool/src/assistant_tool.rs b/crates/assistant_tool/src/assistant_tool.rs index 554b3f3f3cf7eb0bc369ee6fed67722755704443..22cbaac3f8b0df95df3c14a6237092cf83ae35ac 100644 --- a/crates/assistant_tool/src/assistant_tool.rs +++ b/crates/assistant_tool/src/assistant_tool.rs @@ -216,7 +216,12 @@ pub trait Tool: 'static + Send + Sync { /// Returns true if the tool needs the users's confirmation /// before having permission to run. - fn needs_confirmation(&self, input: &serde_json::Value, cx: &App) -> bool; + fn needs_confirmation( + &self, + input: &serde_json::Value, + project: &Entity, + cx: &App, + ) -> bool; /// Returns true if the tool may perform edits. fn may_perform_edits(&self) -> bool; diff --git a/crates/assistant_tool/src/tool_working_set.rs b/crates/assistant_tool/src/tool_working_set.rs index 9a6ec49914eea3cd22f014ce2a5c014d1dca1220..c0a358917b499908d85fbc157212cf6db5b5e0eb 100644 --- a/crates/assistant_tool/src/tool_working_set.rs +++ b/crates/assistant_tool/src/tool_working_set.rs @@ -375,7 +375,12 @@ mod tests { false } - fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool { + fn needs_confirmation( + &self, + _input: &serde_json::Value, + _project: &Entity, + _cx: &App, + ) -> bool { true } diff --git a/crates/assistant_tools/src/copy_path_tool.rs b/crates/assistant_tools/src/copy_path_tool.rs index 1922b5677a94e0eff8fef2bc12bdab8a0971f395..e34ae9ff9305689593241b45fe986414a211ec3b 100644 --- a/crates/assistant_tools/src/copy_path_tool.rs +++ b/crates/assistant_tools/src/copy_path_tool.rs @@ -44,7 +44,7 @@ impl Tool for CopyPathTool { "copy_path".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/create_directory_tool.rs b/crates/assistant_tools/src/create_directory_tool.rs index 224e8357e5a6de98b088aede62daaa8524f2b6c2..11d969d234228e32a0f4baff2c9cad055a488993 100644 --- a/crates/assistant_tools/src/create_directory_tool.rs +++ b/crates/assistant_tools/src/create_directory_tool.rs @@ -37,7 +37,7 @@ impl Tool for CreateDirectoryTool { include_str!("./create_directory_tool/description.md").into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/delete_path_tool.rs b/crates/assistant_tools/src/delete_path_tool.rs index b13f9863c9f7203ceb5e236c8a06903be4b93b68..9e69c18b65d2f78618ac54b28c4808401c08bd72 100644 --- a/crates/assistant_tools/src/delete_path_tool.rs +++ b/crates/assistant_tools/src/delete_path_tool.rs @@ -33,7 +33,7 @@ impl Tool for DeletePathTool { "delete_path".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/diagnostics_tool.rs b/crates/assistant_tools/src/diagnostics_tool.rs index 84595a37b7069a194694cb70482928148116d465..12ab97f820d89e2d66deba3b58ab388b7f1c886e 100644 --- a/crates/assistant_tools/src/diagnostics_tool.rs +++ b/crates/assistant_tools/src/diagnostics_tool.rs @@ -46,7 +46,7 @@ impl Tool for DiagnosticsTool { "diagnostics".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index 6413677bd9178c40259ca56fa00491478efda30c..1c41b2609270471cd3050417c35ceb22fa22bd47 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -25,6 +25,7 @@ use language::{ }; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; +use paths; use project::{ Project, ProjectPath, lsp_store::{FormatTrigger, LspFormatTarget}, @@ -126,8 +127,47 @@ impl Tool for EditFileTool { "edit_file".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { - false + fn needs_confirmation( + &self, + input: &serde_json::Value, + project: &Entity, + cx: &App, + ) -> bool { + if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { + return false; + } + + let Ok(input) = serde_json::from_value::(input.clone()) else { + // If it's not valid JSON, it's going to error and confirming won't do anything. + return false; + }; + + // If any path component matches the local settings folder, then this could affect + // the editor in ways beyond the project source, so prompt. + let local_settings_folder = paths::local_settings_folder_relative_path(); + let path = Path::new(&input.path); + if path + .components() + .any(|component| component.as_os_str() == local_settings_folder.as_os_str()) + { + return true; + } + + // It's also possible that the global config dir is configured to be inside the project, + // so check for that edge case too. + if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { + if canonical_path.starts_with(paths::config_dir()) { + return true; + } + } + + // Check if path is inside the global config directory + // First check if it's already inside project - if not, try to canonicalize + let project_path = project.read(cx).find_project_path(&input.path, cx); + + // If the path is inside the project, and it's not one of the above edge cases, + // then no confirmation is necessary. Otherwise, confirmation is necessary. + project_path.is_none() } fn may_perform_edits(&self) -> bool { @@ -148,7 +188,25 @@ impl Tool for EditFileTool { fn ui_text(&self, input: &serde_json::Value) -> String { match serde_json::from_value::(input.clone()) { - Ok(input) => input.display_description, + Ok(input) => { + let path = Path::new(&input.path); + let mut description = input.display_description.clone(); + + // Add context about why confirmation may be needed + let local_settings_folder = paths::local_settings_folder_relative_path(); + if path + .components() + .any(|c| c.as_os_str() == local_settings_folder.as_os_str()) + { + description.push_str(" (local settings)"); + } else if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { + if canonical_path.starts_with(paths::config_dir()) { + description.push_str(" (global settings)"); + } + } + + description + } Err(_) => "Editing file".to_string(), } } @@ -1175,19 +1233,20 @@ async fn build_buffer_diff( #[cfg(test)] mod tests { use super::*; + use ::fs::Fs; use client::TelemetrySettings; - use fs::{FakeFs, Fs}; use gpui::{TestAppContext, UpdateGlobal}; use language_model::fake_provider::FakeLanguageModel; use serde_json::json; use settings::SettingsStore; + use std::fs; use util::path; #[gpui::test] async fn test_edit_nonexistent_file(cx: &mut TestAppContext) { init_test(cx); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/root", json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); @@ -1277,7 +1336,7 @@ mod tests { ) -> anyhow::Result { init_test(cx); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree( "/root", json!({ @@ -1384,6 +1443,21 @@ mod tests { cx.set_global(settings_store); language::init(cx); TelemetrySettings::register(cx); + agent_settings::AgentSettings::register(cx); + Project::init_settings(cx); + }); + } + + fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) { + cx.update(|cx| { + // Set custom data directory (config will be under data_dir/config) + paths::set_custom_data_dir(data_dir.to_str().unwrap()); + + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + TelemetrySettings::register(cx); + agent_settings::AgentSettings::register(cx); Project::init_settings(cx); }); } @@ -1392,7 +1466,7 @@ mod tests { async fn test_format_on_save(cx: &mut TestAppContext) { init_test(cx); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/root", json!({"src": {}})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; @@ -1591,7 +1665,7 @@ mod tests { async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) { init_test(cx); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/root", json!({"src": {}})).await; // Create a simple file with trailing whitespace @@ -1723,4 +1797,641 @@ mod tests { "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" ); } + + #[gpui::test] + async fn test_needs_confirmation(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({})).await; + + // Test 1: Path with .zed component should require confirmation + let input_with_zed = json!({ + "display_description": "Edit settings", + "path": ".zed/settings.json", + "mode": "edit" + }); + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input_with_zed, &project, cx), + "Path with .zed component should require confirmation" + ); + }); + + // Test 2: Absolute path should require confirmation + let input_absolute = json!({ + "display_description": "Edit file", + "path": "/etc/hosts", + "mode": "edit" + }); + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input_absolute, &project, cx), + "Absolute path should require confirmation" + ); + }); + + // Test 3: Relative path without .zed should not require confirmation + let input_relative = json!({ + "display_description": "Edit file", + "path": "root/src/main.rs", + "mode": "edit" + }); + cx.update(|cx| { + assert!( + !tool.needs_confirmation(&input_relative, &project, cx), + "Relative path without .zed should not require confirmation" + ); + }); + + // Test 4: Path with .zed in the middle should require confirmation + let input_zed_middle = json!({ + "display_description": "Edit settings", + "path": "root/.zed/tasks.json", + "mode": "edit" + }); + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input_zed_middle, &project, cx), + "Path with .zed in any component should require confirmation" + ); + }); + + // Test 5: When always_allow_tool_actions is enabled, no confirmation needed + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = true; + agent_settings::AgentSettings::override_global(settings, cx); + + assert!( + !tool.needs_confirmation(&input_with_zed, &project, cx), + "When always_allow_tool_actions is true, no confirmation should be needed" + ); + assert!( + !tool.needs_confirmation(&input_absolute, &project, cx), + "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths" + ); + }); + } + + #[gpui::test] + async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) { + // Set up a custom config directory for testing + let temp_dir = tempfile::tempdir().unwrap(); + init_test_with_config(cx, temp_dir.path()); + + let tool = Arc::new(EditFileTool); + + // Test ui_text shows context for various paths + let test_cases = vec![ + ( + json!({ + "display_description": "Update config", + "path": ".zed/settings.json", + "mode": "edit" + }), + "Update config (local settings)", + ".zed path should show local settings context", + ), + ( + json!({ + "display_description": "Fix bug", + "path": "src/.zed/local.json", + "mode": "edit" + }), + "Fix bug (local settings)", + "Nested .zed path should show local settings context", + ), + ( + json!({ + "display_description": "Update readme", + "path": "README.md", + "mode": "edit" + }), + "Update readme", + "Normal path should not show additional context", + ), + ( + json!({ + "display_description": "Edit config", + "path": "config.zed", + "mode": "edit" + }), + "Edit config", + ".zed as extension should not show context", + ), + ]; + + for (input, expected_text, description) in test_cases { + cx.update(|_cx| { + let ui_text = tool.ui_text(&input); + assert_eq!(ui_text, expected_text, "Failed for case: {}", description); + }); + } + } + + #[gpui::test] + async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + let fs = project::FakeFs::new(cx.executor()); + + // Create a project in /project directory + fs.insert_tree("/project", json!({})).await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // Test file outside project requires confirmation + let input_outside = json!({ + "display_description": "Edit file", + "path": "/outside/file.txt", + "mode": "edit" + }); + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input_outside, &project, cx), + "File outside project should require confirmation" + ); + }); + + // Test file inside project doesn't require confirmation + let input_inside = json!({ + "display_description": "Edit file", + "path": "project/file.txt", + "mode": "edit" + }); + cx.update(|cx| { + assert!( + !tool.needs_confirmation(&input_inside, &project, cx), + "File inside project should not require confirmation" + ); + }); + } + + #[gpui::test] + async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) { + // Set up a custom data directory for testing + let temp_dir = tempfile::tempdir().unwrap(); + init_test_with_config(cx, temp_dir.path()); + + let tool = Arc::new(EditFileTool); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/home/user/myproject", json!({})).await; + let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await; + + // Get the actual local settings folder name + let local_settings_folder = paths::local_settings_folder_relative_path(); + + // Test various config path patterns + let test_cases = vec![ + ( + format!("{}/settings.json", local_settings_folder.display()), + true, + "Top-level local settings file".to_string(), + ), + ( + format!( + "myproject/{}/settings.json", + local_settings_folder.display() + ), + true, + "Local settings in project path".to_string(), + ), + ( + format!("src/{}/config.toml", local_settings_folder.display()), + true, + "Local settings in subdirectory".to_string(), + ), + ( + ".zed.backup/file.txt".to_string(), + true, + ".zed.backup is outside project".to_string(), + ), + ( + "my.zed/file.txt".to_string(), + true, + "my.zed is outside project".to_string(), + ), + ( + "myproject/src/file.zed".to_string(), + false, + ".zed as file extension".to_string(), + ), + ( + "myproject/normal/path/file.rs".to_string(), + false, + "Normal file without config paths".to_string(), + ), + ]; + + for (path, should_confirm, description) in test_cases { + let input = json!({ + "display_description": "Edit file", + "path": path, + "mode": "edit" + }); + cx.update(|cx| { + assert_eq!( + tool.needs_confirmation(&input, &project, cx), + should_confirm, + "Failed for case: {} - path: {}", + description, + path + ); + }); + } + } + + #[gpui::test] + async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) { + // Set up a custom data directory for testing + let temp_dir = tempfile::tempdir().unwrap(); + init_test_with_config(cx, temp_dir.path()); + + let tool = Arc::new(EditFileTool); + let fs = project::FakeFs::new(cx.executor()); + + // Create test files in the global config directory + let global_config_dir = paths::config_dir(); + fs::create_dir_all(&global_config_dir).unwrap(); + let global_settings_path = global_config_dir.join("settings.json"); + fs::write(&global_settings_path, "{}").unwrap(); + + fs.insert_tree("/project", json!({})).await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // Test global config paths + let test_cases = vec![ + ( + global_settings_path.to_str().unwrap().to_string(), + true, + "Global settings file should require confirmation", + ), + ( + global_config_dir + .join("keymap.json") + .to_str() + .unwrap() + .to_string(), + true, + "Global keymap file should require confirmation", + ), + ( + "project/normal_file.rs".to_string(), + false, + "Normal project file should not require confirmation", + ), + ]; + + for (path, should_confirm, description) in test_cases { + let input = json!({ + "display_description": "Edit file", + "path": path, + "mode": "edit" + }); + cx.update(|cx| { + assert_eq!( + tool.needs_confirmation(&input, &project, cx), + should_confirm, + "Failed for case: {}", + description + ); + }); + } + } + + #[gpui::test] + async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + let fs = project::FakeFs::new(cx.executor()); + + // Create multiple worktree directories + fs.insert_tree( + "/workspace/frontend", + json!({ + "src": { + "main.js": "console.log('frontend');" + } + }), + ) + .await; + fs.insert_tree( + "/workspace/backend", + json!({ + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.insert_tree( + "/workspace/shared", + json!({ + ".zed": { + "settings.json": "{}" + } + }), + ) + .await; + + // Create project with multiple worktrees + let project = Project::test( + fs.clone(), + [ + path!("/workspace/frontend").as_ref(), + path!("/workspace/backend").as_ref(), + path!("/workspace/shared").as_ref(), + ], + cx, + ) + .await; + + // Test files in different worktrees + let test_cases = vec![ + ("frontend/src/main.js", false, "File in first worktree"), + ("backend/src/main.rs", false, "File in second worktree"), + ( + "shared/.zed/settings.json", + true, + ".zed file in third worktree", + ), + ("/etc/hosts", true, "Absolute path outside all worktrees"), + ( + "../outside/file.txt", + true, + "Relative path outside worktrees", + ), + ]; + + for (path, should_confirm, description) in test_cases { + let input = json!({ + "display_description": "Edit file", + "path": path, + "mode": "edit" + }); + cx.update(|cx| { + assert_eq!( + tool.needs_confirmation(&input, &project, cx), + should_confirm, + "Failed for case: {} - path: {}", + description, + path + ); + }); + } + } + + #[gpui::test] + async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".zed": { + "settings.json": "{}" + }, + "src": { + ".zed": { + "local.json": "{}" + } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // Test edge cases + let test_cases = vec![ + // Empty path - find_project_path returns Some for empty paths + ("", false, "Empty path is treated as project root"), + // Root directory + ("/", true, "Root directory should be outside project"), + // Parent directory references - find_project_path resolves these + ( + "project/../other", + false, + "Path with .. is resolved by find_project_path", + ), + ( + "project/./src/file.rs", + false, + "Path with . should work normally", + ), + // Windows-style paths (if on Windows) + #[cfg(target_os = "windows")] + ("C:\\Windows\\System32\\hosts", true, "Windows system path"), + #[cfg(target_os = "windows")] + ("project\\src\\main.rs", false, "Windows-style project path"), + ]; + + for (path, should_confirm, description) in test_cases { + let input = json!({ + "display_description": "Edit file", + "path": path, + "mode": "edit" + }); + cx.update(|cx| { + assert_eq!( + tool.needs_confirmation(&input, &project, cx), + should_confirm, + "Failed for case: {} - path: {}", + description, + path + ); + }); + } + } + + #[gpui::test] + async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + + // Test UI text for various scenarios + let test_cases = vec![ + ( + json!({ + "display_description": "Update config", + "path": ".zed/settings.json", + "mode": "edit" + }), + "Update config (local settings)", + ".zed path should show local settings context", + ), + ( + json!({ + "display_description": "Fix bug", + "path": "src/.zed/local.json", + "mode": "edit" + }), + "Fix bug (local settings)", + "Nested .zed path should show local settings context", + ), + ( + json!({ + "display_description": "Update readme", + "path": "README.md", + "mode": "edit" + }), + "Update readme", + "Normal path should not show additional context", + ), + ( + json!({ + "display_description": "Edit config", + "path": "config.zed", + "mode": "edit" + }), + "Edit config", + ".zed as extension should not show context", + ), + ]; + + for (input, expected_text, description) in test_cases { + cx.update(|_cx| { + let ui_text = tool.ui_text(&input); + assert_eq!(ui_text, expected_text, "Failed for case: {}", description); + }); + } + } + + #[gpui::test] + async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + "existing.txt": "content", + ".zed": { + "settings.json": "{}" + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // Test different EditFileMode values + let modes = vec![ + EditFileMode::Edit, + EditFileMode::Create, + EditFileMode::Overwrite, + ]; + + for mode in modes { + // Test .zed path with different modes + let input_zed = json!({ + "display_description": "Edit settings", + "path": "project/.zed/settings.json", + "mode": mode + }); + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input_zed, &project, cx), + ".zed path should require confirmation regardless of mode: {:?}", + mode + ); + }); + + // Test outside path with different modes + let input_outside = json!({ + "display_description": "Edit file", + "path": "/outside/file.txt", + "mode": mode + }); + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input_outside, &project, cx), + "Outside path should require confirmation regardless of mode: {:?}", + mode + ); + }); + + // Test normal path with different modes + let input_normal = json!({ + "display_description": "Edit file", + "path": "project/normal.txt", + "mode": mode + }); + cx.update(|cx| { + assert!( + !tool.needs_confirmation(&input_normal, &project, cx), + "Normal path should not require confirmation regardless of mode: {:?}", + mode + ); + }); + } + } + + #[gpui::test] + async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) { + // Set up with custom directories for deterministic testing + let temp_dir = tempfile::tempdir().unwrap(); + init_test_with_config(cx, temp_dir.path()); + + let tool = Arc::new(EditFileTool); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({})).await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // Enable always_allow_tool_actions + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = true; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + // Test that all paths that normally require confirmation are bypassed + let global_settings_path = paths::config_dir().join("settings.json"); + fs::create_dir_all(paths::config_dir()).unwrap(); + fs::write(&global_settings_path, "{}").unwrap(); + + let test_cases = vec![ + ".zed/settings.json", + "project/.zed/config.toml", + global_settings_path.to_str().unwrap(), + "/etc/hosts", + "/absolute/path/file.txt", + "../outside/project.txt", + ]; + + for path in test_cases { + let input = json!({ + "display_description": "Edit file", + "path": path, + "mode": "edit" + }); + cx.update(|cx| { + assert!( + !tool.needs_confirmation(&input, &project, cx), + "Path {} should not require confirmation when always_allow_tool_actions is true", + path + ); + }); + } + + // Disable always_allow_tool_actions and verify confirmation is required again + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = false; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + // Verify .zed path requires confirmation again + let input = json!({ + "display_description": "Edit file", + "path": ".zed/settings.json", + "mode": "edit" + }); + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input, &project, cx), + ".zed path should require confirmation when always_allow_tool_actions is false" + ); + }); + } } diff --git a/crates/assistant_tools/src/fetch_tool.rs b/crates/assistant_tools/src/fetch_tool.rs index 54d49359baeae51ab4c575824bd5b42728925a66..a31ec39268d7afcf6072a783c5faa40f2a2e0f78 100644 --- a/crates/assistant_tools/src/fetch_tool.rs +++ b/crates/assistant_tools/src/fetch_tool.rs @@ -116,7 +116,7 @@ impl Tool for FetchTool { "fetch".to_string() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/find_path_tool.rs b/crates/assistant_tools/src/find_path_tool.rs index fd0e44e42cbe6fad373de21be6e263620c07d3d6..affc01941735e5e97b3c3a509cacbce5fd3c7264 100644 --- a/crates/assistant_tools/src/find_path_tool.rs +++ b/crates/assistant_tools/src/find_path_tool.rs @@ -55,7 +55,7 @@ impl Tool for FindPathTool { "find_path".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/grep_tool.rs b/crates/assistant_tools/src/grep_tool.rs index 053273d71bc01191c19fa1e498290d77e8caac7c..43c3d1d9904e486a9a4309ff24a9e6d4be0dfdca 100644 --- a/crates/assistant_tools/src/grep_tool.rs +++ b/crates/assistant_tools/src/grep_tool.rs @@ -57,7 +57,7 @@ impl Tool for GrepTool { "grep".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/list_directory_tool.rs b/crates/assistant_tools/src/list_directory_tool.rs index 723416e2ce1048d42ceca2af18667817a467d1f2..b1980615d677894264ce2f785068b9e99cb55a61 100644 --- a/crates/assistant_tools/src/list_directory_tool.rs +++ b/crates/assistant_tools/src/list_directory_tool.rs @@ -45,7 +45,7 @@ impl Tool for ListDirectoryTool { "list_directory".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/move_path_tool.rs b/crates/assistant_tools/src/move_path_tool.rs index 27ae10151d4e91f951e198e850e5ff6fc2fb331b..c1cbbf848d53d4e0341bad84fbc7d8cf90f142ac 100644 --- a/crates/assistant_tools/src/move_path_tool.rs +++ b/crates/assistant_tools/src/move_path_tool.rs @@ -42,7 +42,7 @@ impl Tool for MovePathTool { "move_path".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/now_tool.rs b/crates/assistant_tools/src/now_tool.rs index b6b1cf90a43b487684b9c8f0d4f6a69a14af6455..b51b91d3d51b6cc15e54faab55be50287815d96c 100644 --- a/crates/assistant_tools/src/now_tool.rs +++ b/crates/assistant_tools/src/now_tool.rs @@ -33,7 +33,7 @@ impl Tool for NowTool { "now".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/open_tool.rs b/crates/assistant_tools/src/open_tool.rs index 97a4769e19e60758fe509fab56bf7329ac7f30b6..8fddbb0431aee7c8d9d7508c535b598887998225 100644 --- a/crates/assistant_tools/src/open_tool.rs +++ b/crates/assistant_tools/src/open_tool.rs @@ -23,7 +23,7 @@ impl Tool for OpenTool { "open".to_string() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { true } fn may_perform_edits(&self) -> bool { diff --git a/crates/assistant_tools/src/project_notifications_tool.rs b/crates/assistant_tools/src/project_notifications_tool.rs index 7567926dcaa38aca7740818a82dcac93b8410c1e..03487e5419002f0fe08458c49e325f7202612d29 100644 --- a/crates/assistant_tools/src/project_notifications_tool.rs +++ b/crates/assistant_tools/src/project_notifications_tool.rs @@ -19,7 +19,7 @@ impl Tool for ProjectNotificationsTool { "project_notifications".to_string() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } fn may_perform_edits(&self) -> bool { diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index dc504e2dc4adf5dbb155f03f9c92320fdedc15ae..ee38273cc04338180d36eb1b64e78dd0235ccfa0 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -54,7 +54,7 @@ impl Tool for ReadFileTool { "read_file".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index 03e76f6a5b657a706c2337087984757b62d0ab84..58833c520848aa3bb345db0af7cccf70429784ea 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -77,7 +77,7 @@ impl Tool for TerminalTool { Self::NAME.to_string() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { true } diff --git a/crates/assistant_tools/src/thinking_tool.rs b/crates/assistant_tools/src/thinking_tool.rs index 422204f97d46a487032534a846fce455c5bdc0b3..443c2930bef7d696635294f5acd6070831d3c4c5 100644 --- a/crates/assistant_tools/src/thinking_tool.rs +++ b/crates/assistant_tools/src/thinking_tool.rs @@ -24,7 +24,7 @@ impl Tool for ThinkingTool { "thinking".to_string() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/web_search_tool.rs b/crates/assistant_tools/src/web_search_tool.rs index 24bc8e9cba36d09a301a5a398e268ff530bdd072..5eeca9c2c44fa6d2d27bc2e95f3e03faca387cc8 100644 --- a/crates/assistant_tools/src/web_search_tool.rs +++ b/crates/assistant_tools/src/web_search_tool.rs @@ -32,7 +32,7 @@ impl Tool for WebSearchTool { "web_search".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/collab/src/api.rs b/crates/collab/src/api.rs index 3b0f5396a77b924ab3452a971d3e7af0878110a6..5cb26eb50703c8ca7bcf7b493709ac4f80a3a6df 100644 --- a/crates/collab/src/api.rs +++ b/crates/collab/src/api.rs @@ -106,7 +106,6 @@ pub fn routes(rpc_server: Arc) -> Router<(), Body> { .route("/users/:id/refresh_llm_tokens", post(refresh_llm_tokens)) .route("/users/:id/update_plan", post(update_plan)) .route("/rpc_server_snapshot", get(get_rpc_server_snapshot)) - .merge(billing::router()) .merge(contributors::router()) .layer( ServiceBuilder::new() diff --git a/crates/collab/src/api/billing.rs b/crates/collab/src/api/billing.rs index 9a27e22f87f5fd954f545c78f7c105aad6f61bf2..1cb20173c12759f4c07d976c16f108a81fd3b3e5 100644 --- a/crates/collab/src/api/billing.rs +++ b/crates/collab/src/api/billing.rs @@ -1,15 +1,13 @@ use anyhow::{Context as _, bail}; -use axum::{Extension, Json, Router, extract, routing::post}; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet}; -use reqwest::StatusCode; use sea_orm::ActiveValue; -use serde::{Deserialize, Serialize}; use std::{sync::Arc, time::Duration}; use stripe::{CancellationDetailsReason, EventObject, EventType, ListEvents, SubscriptionStatus}; use util::{ResultExt, maybe}; use zed_llm_client::LanguageModelProvider; +use crate::AppState; use crate::db::billing_subscription::{ StripeCancellationReason, StripeSubscriptionStatus, SubscriptionKind, }; @@ -19,7 +17,6 @@ use crate::stripe_client::{ StripeCancellationDetailsReason, StripeClient, StripeCustomerId, StripeSubscription, StripeSubscriptionId, }; -use crate::{AppState, Error, Result}; use crate::{db::UserId, llm::db::LlmDatabase}; use crate::{ db::{ @@ -30,70 +27,6 @@ use crate::{ stripe_billing::StripeBilling, }; -pub fn router() -> Router { - Router::new().route( - "/billing/subscriptions/sync", - post(sync_billing_subscription), - ) -} - -#[derive(Debug, Deserialize)] -struct SyncBillingSubscriptionBody { - github_user_id: i32, -} - -#[derive(Debug, Serialize)] -struct SyncBillingSubscriptionResponse { - stripe_customer_id: String, -} - -async fn sync_billing_subscription( - Extension(app): Extension>, - extract::Json(body): extract::Json, -) -> Result> { - let Some(stripe_client) = app.stripe_client.clone() else { - log::error!("failed to retrieve Stripe client"); - Err(Error::http( - StatusCode::NOT_IMPLEMENTED, - "not supported".into(), - ))? - }; - - let user = app - .db - .get_user_by_github_user_id(body.github_user_id) - .await? - .context("user not found")?; - - let billing_customer = app - .db - .get_billing_customer_by_user_id(user.id) - .await? - .context("billing customer not found")?; - let stripe_customer_id = StripeCustomerId(billing_customer.stripe_customer_id.clone().into()); - - let subscriptions = stripe_client - .list_subscriptions_for_customer(&stripe_customer_id) - .await?; - - for subscription in subscriptions { - let subscription_id = subscription.id.clone(); - - sync_subscription(&app, &stripe_client, subscription) - .await - .with_context(|| { - format!( - "failed to sync subscription {subscription_id} for user {}", - user.id, - ) - })?; - } - - Ok(Json(SyncBillingSubscriptionResponse { - stripe_customer_id: billing_customer.stripe_customer_id.clone(), - })) -} - /// The amount of time we wait in between each poll of Stripe events. /// /// This value should strike a balance between: diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs index 8c5e7da0f12773b8f1551266c3cf69abe57c58e8..ff4d79c07d0eccba0e64a2aadec3e3035c9c169f 100644 --- a/crates/context_server/src/client.rs +++ b/crates/context_server/src/client.rs @@ -330,23 +330,16 @@ impl Client { method: &str, params: impl Serialize, ) -> Result { - self.request_impl(method, params, None).await + self.request_with(method, params, None, Some(REQUEST_TIMEOUT)) + .await } - pub async fn cancellable_request( - &self, - method: &str, - params: impl Serialize, - cancel_rx: oneshot::Receiver<()>, - ) -> Result { - self.request_impl(method, params, Some(cancel_rx)).await - } - - pub async fn request_impl( + pub async fn request_with( &self, method: &str, params: impl Serialize, cancel_rx: Option>, + timeout: Option, ) -> Result { let id = self.next_id.fetch_add(1, SeqCst); let request = serde_json::to_string(&Request { @@ -382,7 +375,13 @@ impl Client { handle_response?; send?; - let mut timeout = executor.timer(REQUEST_TIMEOUT).fuse(); + let mut timeout_fut = pin!( + match timeout { + Some(timeout) => future::Either::Left(executor.timer(timeout)), + None => future::Either::Right(future::pending()), + } + .fuse() + ); let mut cancel_fut = pin!( match cancel_rx { Some(rx) => future::Either::Left(async { @@ -419,10 +418,10 @@ impl Client { reason: None }) ).log_err(); - anyhow::bail!("Request cancelled") + anyhow::bail!(RequestCanceled) } - _ = timeout => { - log::error!("cancelled csp request task for {method:?} id {id} which took over {:?}", REQUEST_TIMEOUT); + _ = timeout_fut => { + log::error!("cancelled csp request task for {method:?} id {id} which took over {:?}", timeout.unwrap()); anyhow::bail!("Context server request timeout"); } } @@ -452,6 +451,17 @@ impl Client { } } +#[derive(Debug)] +pub struct RequestCanceled; + +impl std::error::Error for RequestCanceled {} + +impl std::fmt::Display for RequestCanceled { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Context server request was canceled") + } +} + impl fmt::Display for ContextServerId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) diff --git a/crates/context_server/src/listener.rs b/crates/context_server/src/listener.rs index 192f53081604bbc534475889547831df906badbd..34e3a9a78cb53cef9f80da89d65dfc5889120f24 100644 --- a/crates/context_server/src/listener.rs +++ b/crates/context_server/src/listener.rs @@ -419,7 +419,7 @@ pub struct ToolResponse { pub structured_content: T, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] struct RawRequest { #[serde(skip_serializing_if = "Option::is_none")] id: Option, diff --git a/crates/context_server/src/protocol.rs b/crates/context_server/src/protocol.rs index 7263f502fa44b05b6bba72eda58c7ad84b52ebf7..9ccbc8a55380c1c4c222894579f3b4f56d57468a 100644 --- a/crates/context_server/src/protocol.rs +++ b/crates/context_server/src/protocol.rs @@ -5,6 +5,8 @@ //! read/write messages and the types from types.rs for serialization/deserialization //! of messages. +use std::time::Duration; + use anyhow::Result; use futures::channel::oneshot; use gpui::AsyncApp; @@ -98,13 +100,14 @@ impl InitializedContextServerProtocol { self.inner.request(T::METHOD, params).await } - pub async fn cancellable_request( + pub async fn request_with( &self, params: T::Params, - cancel_rx: oneshot::Receiver<()>, + cancel_rx: Option>, + timeout: Option, ) -> Result { self.inner - .cancellable_request(T::METHOD, params, cancel_rx) + .request_with(T::METHOD, params, cancel_rx, timeout) .await } diff --git a/crates/context_server/src/types.rs b/crates/context_server/src/types.rs index cd97ff95bc733c1e437c68ac366cca66b54ff80f..5fa2420a3d40ce04ee97b4f88c1105711dea8793 100644 --- a/crates/context_server/src/types.rs +++ b/crates/context_server/src/types.rs @@ -626,6 +626,7 @@ pub enum ClientNotification { } #[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct CancelledParams { pub request_id: RequestId, #[serde(skip_serializing_if = "Option::is_none")] @@ -685,6 +686,18 @@ pub struct CallToolResponse { pub structured_content: Option, } +impl CallToolResponse { + pub fn text_contents(&self) -> String { + let mut text = String::new(); + for chunk in &self.content { + if let ToolResponseContent::Text { text: chunk } = chunk { + text.push_str(&chunk) + }; + } + text + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type")] pub enum ToolResponseContent { diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs index 505df09cfb2b47821cb59448801f014f923be8f1..6180831ea9dccfb3c1ee861daac099e54b2242c3 100644 --- a/crates/debugger_ui/src/tests/debugger_panel.rs +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -918,7 +918,7 @@ async fn test_debug_panel_item_thread_status_reset_on_failure( .unwrap(); let client = session.update(cx, |session, _| session.adapter_client().unwrap()); - const THREAD_ID_NUM: u64 = 1; + const THREAD_ID_NUM: i64 = 1; client.on_request::(move |_, _| { Ok(dap::ThreadsResponse { diff --git a/crates/editor/src/code_completion_tests.rs b/crates/editor/src/code_completion_tests.rs index 4f9822b597eee13a8e3bad540b833e3158e2123e..fd8db29584d8eb6944ff674dd8bf5d860ce32428 100644 --- a/crates/editor/src/code_completion_tests.rs +++ b/crates/editor/src/code_completion_tests.rs @@ -94,7 +94,7 @@ async fn test_fuzzy_score(cx: &mut TestAppContext) { filter_and_sort_matches("set_text", &completions, SnippetSortOrder::Top, cx).await; assert_eq!(matches[0].string, "set_text"); assert_eq!(matches[1].string, "set_text_style_refinement"); - assert_eq!(matches[2].string, "set_context_menu_options"); + assert_eq!(matches[2].string, "set_placeholder_text"); } // fuzzy filter text over label, sort_text and sort_kind @@ -216,6 +216,28 @@ async fn test_sort_positions(cx: &mut TestAppContext) { assert_eq!(matches[0].string, "rounded-full"); } +#[gpui::test] +async fn test_fuzzy_over_sort_positions(cx: &mut TestAppContext) { + let completions = vec![ + CompletionBuilder::variable("lsp_document_colors", None, "7fffffff"), // 0.29 fuzzy score + CompletionBuilder::function( + "language_servers_running_disk_based_diagnostics", + None, + "7fffffff", + ), // 0.168 fuzzy score + CompletionBuilder::function("code_lens", None, "7fffffff"), // 3.2 fuzzy score + CompletionBuilder::variable("lsp_code_lens", None, "7fffffff"), // 3.2 fuzzy score + CompletionBuilder::function("fetch_code_lens", None, "7fffffff"), // 3.2 fuzzy score + ]; + + let matches = + filter_and_sort_matches("lens", &completions, SnippetSortOrder::default(), cx).await; + + assert_eq!(matches[0].string, "code_lens"); + assert_eq!(matches[1].string, "lsp_code_lens"); + assert_eq!(matches[2].string, "fetch_code_lens"); +} + async fn test_for_each_prefix( target: &str, completions: &Vec, diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 52446ceafcaa47dc3e26ac4ee0684645df4bd99a..4ae2a14ca730dafa7cfecd9e9b3bacbe3f7bc47b 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -844,7 +844,7 @@ impl CompletionsMenu { .with_sizing_behavior(ListSizingBehavior::Infer) .w(rems(34.)); - Popover::new().child(div().child(list)).into_any_element() + Popover::new().child(list).into_any_element() } fn render_aside( @@ -1057,9 +1057,9 @@ impl CompletionsMenu { enum MatchTier<'a> { WordStartMatch { sort_exact: Reverse, - sort_positions: Vec, sort_snippet: Reverse, sort_score: Reverse>, + sort_positions: Vec, sort_text: Option<&'a str>, sort_kind: usize, sort_label: &'a str, @@ -1137,9 +1137,9 @@ impl CompletionsMenu { MatchTier::WordStartMatch { sort_exact, - sort_positions, sort_snippet, sort_score, + sort_positions, sort_text, sort_kind, sort_label, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8f57fb1a2063f51caf415d9d3d1e6c0b7f80ae1d..6bbd1a409d2da162348c964def3d6abc39283dfa 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1774,7 +1774,7 @@ impl Editor { ) -> Self { debug_assert!( display_map.is_none() || mode.is_minimap(), - "Providing a display map for a new editor is only intended for the minimap and might have unindended side effects otherwise!" + "Providing a display map for a new editor is only intended for the minimap and might have unintended side effects otherwise!" ); let full_mode = mode.is_full(); @@ -8235,8 +8235,7 @@ impl Editor { return; }; - // Try to find a closest, enclosing node using tree-sitter that has a - // task + // Try to find a closest, enclosing node using tree-sitter that has a task let Some((buffer, buffer_row, tasks)) = self .find_enclosing_node_task(cx) // Or find the task that's closest in row-distance. diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index b99f628806f51d424304a746571731ad36a7765f..88ec2dc84e3778e91102cc22ddc58d17e9ba5f85 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -295,11 +295,13 @@ impl CommitModal { IconPosition::Start, Some(Box::new(Amend)), { - let git_panel = git_panel_entity.clone(); - move |window, cx| { - git_panel.update(cx, |git_panel, cx| { - git_panel.toggle_amend_pending(&Amend, window, cx); - }) + let git_panel = git_panel_entity.downgrade(); + move |_, cx| { + git_panel + .update(cx, |git_panel, cx| { + git_panel.toggle_amend_pending(cx); + }) + .ok(); } }, ) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 725a1b6db596feae0b328f7445b392e928a6e8db..f7efada469c525a6de17568d9acebbf6332345ee 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3113,6 +3113,7 @@ impl GitPanel { ), ) .menu({ + let git_panel = cx.entity(); let has_previous_commit = self.head_commit(cx).is_some(); let amend = self.amend_pending(); let signoff = self.signoff_enabled; @@ -3129,7 +3130,16 @@ impl GitPanel { amend, IconPosition::Start, Some(Box::new(Amend)), - move |window, cx| window.dispatch_action(Box::new(Amend), cx), + { + let git_panel = git_panel.downgrade(); + move |_, cx| { + git_panel + .update(cx, |git_panel, cx| { + git_panel.toggle_amend_pending(cx); + }) + .ok(); + } + }, ) }) .toggleable_entry( @@ -3500,9 +3510,11 @@ impl GitPanel { .truncate(), ), ) - .child(panel_button("Cancel").size(ButtonSize::Default).on_click( - cx.listener(|this, _, window, cx| this.toggle_amend_pending(&Amend, window, cx)), - )) + .child( + panel_button("Cancel") + .size(ButtonSize::Default) + .on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))), + ) } fn render_previous_commit(&self, cx: &mut Context) -> Option { @@ -4263,17 +4275,8 @@ impl GitPanel { pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context) { self.amend_pending = value; - cx.notify(); - } - - pub fn toggle_amend_pending( - &mut self, - _: &Amend, - _window: &mut Window, - cx: &mut Context, - ) { - self.set_amend_pending(!self.amend_pending, cx); self.serialize(cx); + cx.notify(); } pub fn signoff_enabled(&self) -> bool { @@ -4367,6 +4370,13 @@ impl GitPanel { anchor: path, }); } + + pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context) { + self.set_amend_pending(!self.amend_pending, cx); + if self.amend_pending { + self.load_last_commit_message_if_empty(cx); + } + } } fn current_language_model(cx: &Context<'_, GitPanel>) -> Option> { @@ -4411,7 +4421,6 @@ impl Render for GitPanel { .on_action(cx.listener(Self::stage_range)) .on_action(cx.listener(GitPanel::commit)) .on_action(cx.listener(GitPanel::amend)) - .on_action(cx.listener(GitPanel::toggle_amend_pending)) .on_action(cx.listener(GitPanel::toggle_signoff_enabled)) .on_action(cx.listener(Self::stage_all)) .on_action(cx.listener(Self::unstage_all)) diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 68903fba03756ad70969aa1f92047bac1f75a3bb..cc7b1d7fb826d8d069613023b02bb5ab5a6025d7 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -72,7 +72,6 @@ screen-capture = [ "scap", ] windows-manifest = [] -enable-renderdoc = [] [lib] path = "src/gpui.rs" @@ -219,10 +218,6 @@ xim = { git = "https://github.com/XDeme1/xim-rs", rev = "d50d461764c2213655cd9cf x11-clipboard = { version = "0.9.3", optional = true } [target.'cfg(target_os = "windows")'.dependencies] -blade-util.workspace = true -bytemuck = "1" -blade-graphics.workspace = true -blade-macros.workspace = true flume = "0.11" rand.workspace = true windows.workspace = true @@ -243,7 +238,6 @@ util = { workspace = true, features = ["test-support"] } [target.'cfg(target_os = "windows")'.build-dependencies] embed-resource = "3.0" -naga.workspace = true [target.'cfg(target_os = "macos")'.build-dependencies] bindgen = "0.71" @@ -290,6 +284,10 @@ path = "examples/shadow.rs" name = "svg" path = "examples/svg/svg.rs" +[[example]] +name = "tab_stop" +path = "examples/tab_stop.rs" + [[example]] name = "text" path = "examples/text.rs" diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index 73ce73babd3668878387d0f21a36238dce7cab1f..93a1c15c41dd173a35ffc0adf06af6c449809890 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -11,7 +11,7 @@ fn main() { #[cfg(any( not(any(target_os = "macos", target_os = "windows")), - feature = "macos-blade" + all(target_os = "macos", feature = "macos-blade") ))] check_wgsl_shaders(); @@ -28,7 +28,10 @@ fn main() { }; } -#[allow(dead_code)] +#[cfg(any( + not(any(target_os = "macos", target_os = "windows")), + all(target_os = "macos", feature = "macos-blade") +))] fn check_wgsl_shaders() { use std::path::PathBuf; use std::process; @@ -286,7 +289,8 @@ mod windows { let modules = [ "quad", "shadow", - "paths", + "path_rasterization", + "path_sprite", "underline", "monochrome_sprite", "polychrome_sprite", @@ -330,7 +334,11 @@ mod windows { } // Try to find in PATH - if let Ok(output) = std::process::Command::new("where").arg("fxc.exe").output() { + // NOTE: This has to be `where.exe` on Windows, not `where`, it must be ended with `.exe` + if let Ok(output) = std::process::Command::new("where.exe") + .arg("fxc.exe") + .output() + { if output.status.success() { let path = String::from_utf8_lossy(&output.stdout); return path.trim().to_string(); @@ -364,7 +372,7 @@ mod windows { &output_file, &const_name, shader_path, - "vs_5_0", + "vs_4_1", ); generate_rust_binding(&const_name, &output_file, &rust_binding_path); @@ -377,7 +385,7 @@ mod windows { &output_file, &const_name, shader_path, - "ps_5_0", + "ps_4_1", ); generate_rust_binding(&const_name, &output_file, &rust_binding_path); } @@ -411,7 +419,7 @@ mod windows { return; } eprintln!( - "Pixel shader compilation failed for {}:\n{}", + "Shader compilation failed for {}:\n{}", entry_point, String::from_utf8_lossy(&result.stderr) ); diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 76a5eb4c021e8dbc1cb6dfc1b6d875ab806568fd..b495d70dfdd3594a27ed3c1793e7e0ac4e7e0b4a 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -447,6 +447,8 @@ impl Tiling { #[derive(Debug, Copy, Clone, Eq, PartialEq, Default)] pub(crate) struct RequestFrameOptions { pub(crate) require_presentation: bool, + /// Force refresh of all rendering states when true + pub(crate) force_render: bool, } pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 8b6e72d1508c829d15b9786dea69bef41cf4d7ef..24601eefd6de450622247caaca5ff680c60a3257 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/crates/gpui/src/platform/keystroke.rs @@ -417,17 +417,6 @@ impl Modifiers { self.control || self.alt || self.shift || self.platform || self.function } - /// Returns the XOR of two modifier sets - pub fn xor(&self, other: &Modifiers) -> Modifiers { - Modifiers { - control: self.control ^ other.control, - alt: self.alt ^ other.alt, - shift: self.shift ^ other.shift, - platform: self.platform ^ other.platform, - function: self.function ^ other.function, - } - } - /// Whether the semantically 'secondary' modifier key is pressed. /// /// On macOS, this is the command key. @@ -545,11 +534,62 @@ impl Modifiers { /// Checks if this [`Modifiers`] is a subset of another [`Modifiers`]. pub fn is_subset_of(&self, other: &Modifiers) -> bool { - (other.control || !self.control) - && (other.alt || !self.alt) - && (other.shift || !self.shift) - && (other.platform || !self.platform) - && (other.function || !self.function) + (*other & *self) == *self + } +} + +impl std::ops::BitOr for Modifiers { + type Output = Self; + + fn bitor(mut self, other: Self) -> Self::Output { + self |= other; + self + } +} + +impl std::ops::BitOrAssign for Modifiers { + fn bitor_assign(&mut self, other: Self) { + self.control |= other.control; + self.alt |= other.alt; + self.shift |= other.shift; + self.platform |= other.platform; + self.function |= other.function; + } +} + +impl std::ops::BitXor for Modifiers { + type Output = Self; + fn bitxor(mut self, rhs: Self) -> Self::Output { + self ^= rhs; + self + } +} + +impl std::ops::BitXorAssign for Modifiers { + fn bitxor_assign(&mut self, other: Self) { + self.control ^= other.control; + self.alt ^= other.alt; + self.shift ^= other.shift; + self.platform ^= other.platform; + self.function ^= other.function; + } +} + +impl std::ops::BitAnd for Modifiers { + type Output = Self; + fn bitand(mut self, rhs: Self) -> Self::Output { + self &= rhs; + self + } +} + +impl std::ops::BitAndAssign for Modifiers { + fn bitand_assign(&mut self, other: Self) { + self.control &= other.control; + self.alt &= other.alt; + self.shift &= other.shift; + self.platform &= other.platform; + self.function &= other.function; } } diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs index 255ae9c3721ec43d47f6a603a7de75a6d13ef5d4..2b2207e22c86fc25e6387581bb92b9c304f4bc9d 100644 --- a/crates/gpui/src/platform/linux/wayland/window.rs +++ b/crates/gpui/src/platform/linux/wayland/window.rs @@ -111,7 +111,7 @@ pub struct WaylandWindowState { resize_throttle: bool, in_progress_window_controls: Option, window_controls: WindowControls, - inset: Option, + client_inset: Option, } #[derive(Clone)] @@ -186,7 +186,7 @@ impl WaylandWindowState { hovered: false, in_progress_window_controls: None, window_controls: WindowControls::default(), - inset: None, + client_inset: None, }) } @@ -211,6 +211,13 @@ impl WaylandWindowState { self.display = current_output; scale } + + pub fn inset(&self) -> Pixels { + match self.decorations { + WindowDecorations::Server => px(0.0), + WindowDecorations::Client => self.client_inset.unwrap_or(px(0.0)), + } + } } pub(crate) struct WaylandWindow(pub WaylandWindowStatePtr); @@ -380,7 +387,7 @@ impl WaylandWindowStatePtr { configure.size = if got_unmaximized { Some(state.window_bounds.size) } else { - compute_outer_size(state.inset, configure.size, state.tiling) + compute_outer_size(state.inset(), configure.size, state.tiling) }; if let Some(size) = configure.size { state.window_bounds = Bounds { @@ -400,7 +407,7 @@ impl WaylandWindowStatePtr { let window_geometry = inset_by_tiling( state.bounds.map_origin(|_| px(0.0)), - state.inset.unwrap_or(px(0.0)), + state.inset(), state.tiling, ) .map(|v| v.0 as i32) @@ -818,7 +825,7 @@ impl PlatformWindow for WaylandWindow { } else if state.maximized { WindowBounds::Maximized(state.window_bounds) } else { - let inset = state.inset.unwrap_or(px(0.)); + let inset = state.inset(); drop(state); WindowBounds::Windowed(self.bounds().inset(inset)) } @@ -1073,8 +1080,8 @@ impl PlatformWindow for WaylandWindow { fn set_client_inset(&self, inset: Pixels) { let mut state = self.borrow_mut(); - if Some(inset) != state.inset { - state.inset = Some(inset); + if Some(inset) != state.client_inset { + state.client_inset = Some(inset); update_window(state); } } @@ -1094,9 +1101,7 @@ fn update_window(mut state: RefMut) { state.renderer.update_transparency(!opaque); let mut opaque_area = state.window_bounds.map(|v| v.0 as i32); - if let Some(inset) = state.inset { - opaque_area.inset(inset.0 as i32); - } + opaque_area.inset(state.inset().0 as i32); let region = state .globals @@ -1169,12 +1174,10 @@ impl ResizeEdge { /// updating to account for the client decorations. But that's not the area we want to render /// to, due to our intrusize CSD. So, here we calculate the 'actual' size, by adding back in the insets fn compute_outer_size( - inset: Option, + inset: Pixels, new_size: Option>, tiling: Tiling, ) -> Option> { - let Some(inset) = inset else { return new_size }; - new_size.map(|mut new_size| { if !tiling.top { new_size.height += inset; diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index d1cb7d00cc7468f7b9bc02b10dfde04e195b8950..0d98c1db196be66a32a248ddc0588ec3f3faa9e7 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -1793,6 +1793,7 @@ impl X11ClientState { drop(state); window.refresh(RequestFrameOptions { require_presentation: expose_event_received, + force_render: false, }); } xcb_connection diff --git a/crates/gpui/src/platform/windows/directx_atlas.rs b/crates/gpui/src/platform/windows/directx_atlas.rs index 7ad293cd3970c6776807dc740283f8479c216e6d..c70a1d88b274a4653fa8d316d23435bd14910a4f 100644 --- a/crates/gpui/src/platform/windows/directx_atlas.rs +++ b/crates/gpui/src/platform/windows/directx_atlas.rs @@ -142,7 +142,7 @@ impl DirectXAtlasState { } } - let texture = self.push_texture(size, texture_kind); + let texture = self.push_texture(size, texture_kind)?; texture.allocate(size) } @@ -150,7 +150,7 @@ impl DirectXAtlasState { &mut self, min_size: Size, kind: AtlasTextureKind, - ) -> &mut DirectXAtlasTexture { + ) -> Option<&mut DirectXAtlasTexture> { const DEFAULT_ATLAS_SIZE: Size = Size { width: DevicePixels(1024), height: DevicePixels(1024), @@ -194,9 +194,11 @@ impl DirectXAtlasState { }; let mut texture: Option = None; unsafe { + // This only returns None if the device is lost, which we will recreate later. + // So it's ok to return None here. self.device .CreateTexture2D(&texture_desc, None, Some(&mut texture)) - .unwrap(); + .ok()?; } let texture = texture.unwrap(); @@ -209,7 +211,7 @@ impl DirectXAtlasState { let mut view = None; self.device .CreateShaderResourceView(&texture, None, Some(&mut view)) - .unwrap(); + .ok()?; [view] }; let atlas_texture = DirectXAtlasTexture { @@ -225,10 +227,10 @@ impl DirectXAtlasState { }; if let Some(ix) = index { texture_list.textures[ix] = Some(atlas_texture); - texture_list.textures.get_mut(ix).unwrap().as_mut().unwrap() + texture_list.textures.get_mut(ix).unwrap().as_mut() } else { texture_list.textures.push(Some(atlas_texture)); - texture_list.textures.last_mut().unwrap().as_mut().unwrap() + texture_list.textures.last_mut().unwrap().as_mut() } } @@ -236,7 +238,6 @@ impl DirectXAtlasState { let textures = match id.kind { crate::AtlasTextureKind::Monochrome => &self.monochrome_textures, crate::AtlasTextureKind::Polychrome => &self.polychrome_textures, - // crate::AtlasTextureKind::Path => &self.path_textures, }; textures[id.index as usize].as_ref().unwrap() } diff --git a/crates/gpui/src/platform/windows/directx_renderer.rs b/crates/gpui/src/platform/windows/directx_renderer.rs index a429d2049b343450ea0cab9473aca4fb3da1fa68..cf5b538ceabe890017018eb96c6c519261c2a19c 100644 --- a/crates/gpui/src/platform/windows/directx_renderer.rs +++ b/crates/gpui/src/platform/windows/directx_renderer.rs @@ -2,16 +2,18 @@ use std::{mem::ManuallyDrop, sync::Arc}; use ::util::ResultExt; use anyhow::{Context, Result}; -use windows::Win32::{ - Foundation::{HMODULE, HWND}, - Graphics::{ - Direct3D::*, - Direct3D11::*, - Dxgi::{Common::*, *}, +use windows::{ + Win32::{ + Foundation::{HMODULE, HWND}, + Graphics::{ + Direct3D::*, + Direct3D11::*, + DirectComposition::*, + Dxgi::{Common::*, *}, + }, }, + core::Interface, }; -#[cfg(not(feature = "enable-renderdoc"))] -use windows::{Win32::Graphics::DirectComposition::*, core::Interface}; use crate::{ platform::windows::directx_renderer::shader_resources::{ @@ -20,9 +22,10 @@ use crate::{ *, }; +pub(crate) const DISABLE_DIRECT_COMPOSITION: &str = "GPUI_DISABLE_DIRECT_COMPOSITION"; const RENDER_TARGET_FORMAT: DXGI_FORMAT = DXGI_FORMAT_B8G8R8A8_UNORM; -// This configuration is used for MSAA rendering, and it's guaranteed to be supported by DirectX 11. -const MULTISAMPLE_COUNT: u32 = 4; +// This configuration is used for MSAA rendering on paths only, and it's guaranteed to be supported by DirectX 11. +const PATH_MULTISAMPLE_COUNT: u32 = 4; pub(crate) struct DirectXRenderer { hwnd: HWND, @@ -31,8 +34,7 @@ pub(crate) struct DirectXRenderer { resources: ManuallyDrop, globals: DirectXGlobalElements, pipelines: DirectXRenderPipelines, - #[cfg(not(feature = "enable-renderdoc"))] - _direct_composition: ManuallyDrop, + direct_composition: Option, } /// Direct3D objects @@ -40,10 +42,9 @@ pub(crate) struct DirectXRenderer { pub(crate) struct DirectXDevices { adapter: IDXGIAdapter1, dxgi_factory: IDXGIFactory6, - #[cfg(not(feature = "enable-renderdoc"))] - dxgi_device: IDXGIDevice, pub(crate) device: ID3D11Device, pub(crate) device_context: ID3D11DeviceContext, + dxgi_device: Option, } struct DirectXResources { @@ -51,8 +52,12 @@ struct DirectXResources { swap_chain: IDXGISwapChain1, render_target: ManuallyDrop, render_target_view: [Option; 1], - msaa_target: ID3D11Texture2D, - msaa_view: [Option; 1], + + // Path intermediate textures (with MSAA) + path_intermediate_texture: ID3D11Texture2D, + path_intermediate_srv: [Option; 1], + path_intermediate_msaa_texture: ID3D11Texture2D, + path_intermediate_msaa_view: [Option; 1], // Cached window size and viewport width: u32, @@ -63,7 +68,8 @@ struct DirectXResources { struct DirectXRenderPipelines { shadow_pipeline: PipelineState, quad_pipeline: PipelineState, - paths_pipeline: PathsPipelineState, + path_rasterization_pipeline: PipelineState, + path_sprite_pipeline: PipelineState, underline_pipeline: PipelineState, mono_sprites: PipelineState, poly_sprites: PipelineState, @@ -72,18 +78,8 @@ struct DirectXRenderPipelines { struct DirectXGlobalElements { global_params_buffer: [Option; 1], sampler: [Option; 1], - blend_state: ID3D11BlendState, -} - -#[repr(C)] -struct DrawInstancedIndirectArgs { - vertex_count_per_instance: u32, - instance_count: u32, - start_vertex_location: u32, - start_instance_location: u32, } -#[cfg(not(feature = "enable-renderdoc"))] struct DirectComposition { comp_device: IDCompositionDevice, comp_target: IDCompositionTarget, @@ -91,46 +87,77 @@ struct DirectComposition { } impl DirectXDevices { - pub(crate) fn new() -> Result { - let dxgi_factory = get_dxgi_factory()?; - let adapter = get_adapter(&dxgi_factory)?; + pub(crate) fn new(disable_direct_composition: bool) -> Result> { + let dxgi_factory = get_dxgi_factory().context("Creating DXGI factory")?; + let adapter = get_adapter(&dxgi_factory).context("Getting DXGI adapter")?; let (device, device_context) = { let mut device: Option = None; let mut context: Option = None; - get_device(&adapter, Some(&mut device), Some(&mut context))?; + let mut feature_level = D3D_FEATURE_LEVEL::default(); + get_device( + &adapter, + Some(&mut device), + Some(&mut context), + Some(&mut feature_level), + ) + .context("Creating Direct3D device")?; + match feature_level { + D3D_FEATURE_LEVEL_11_1 => { + log::info!("Created device with Direct3D 11.1 feature level.") + } + D3D_FEATURE_LEVEL_11_0 => { + log::info!("Created device with Direct3D 11.0 feature level.") + } + D3D_FEATURE_LEVEL_10_1 => { + log::info!("Created device with Direct3D 10.1 feature level.") + } + _ => unreachable!(), + } (device.unwrap(), context.unwrap()) }; - #[cfg(not(feature = "enable-renderdoc"))] - let dxgi_device: IDXGIDevice = device.cast()?; + let dxgi_device = if disable_direct_composition { + None + } else { + Some(device.cast().context("Creating DXGI device")?) + }; - Ok(Self { + Ok(ManuallyDrop::new(Self { adapter, dxgi_factory, - #[cfg(not(feature = "enable-renderdoc"))] dxgi_device, device, device_context, - }) + })) } } impl DirectXRenderer { - pub(crate) fn new(hwnd: HWND) -> Result { - let devices = ManuallyDrop::new(DirectXDevices::new().context("Creating DirectX devices")?); - let atlas = Arc::new(DirectXAtlas::new(&devices.device, &devices.device_context)); - - #[cfg(not(feature = "enable-renderdoc"))] - let resources = DirectXResources::new(&devices, 1, 1)?; - #[cfg(feature = "enable-renderdoc")] - let resources = DirectXResources::new(&devices, 1, 1, hwnd)?; + pub(crate) fn new(hwnd: HWND, disable_direct_composition: bool) -> Result { + if disable_direct_composition { + log::info!("Direct Composition is disabled."); + } - let globals = DirectXGlobalElements::new(&devices.device)?; - let pipelines = DirectXRenderPipelines::new(&devices.device)?; + let devices = + DirectXDevices::new(disable_direct_composition).context("Creating DirectX devices")?; + let atlas = Arc::new(DirectXAtlas::new(&devices.device, &devices.device_context)); - #[cfg(not(feature = "enable-renderdoc"))] - let direct_composition = DirectComposition::new(&devices.dxgi_device, hwnd)?; - #[cfg(not(feature = "enable-renderdoc"))] - direct_composition.set_swap_chain(&resources.swap_chain)?; + let resources = DirectXResources::new(&devices, 1, 1, hwnd, disable_direct_composition) + .context("Creating DirectX resources")?; + let globals = DirectXGlobalElements::new(&devices.device) + .context("Creating DirectX global elements")?; + let pipelines = DirectXRenderPipelines::new(&devices.device) + .context("Creating DirectX render pipelines")?; + + let direct_composition = if disable_direct_composition { + None + } else { + let composition = DirectComposition::new(devices.dxgi_device.as_ref().unwrap(), hwnd) + .context("Creating DirectComposition")?; + composition + .set_swap_chain(&resources.swap_chain) + .context("Setting swap chain for DirectComposition")?; + Some(composition) + }; Ok(DirectXRenderer { hwnd, @@ -139,8 +166,7 @@ impl DirectXRenderer { resources, globals, pipelines, - #[cfg(not(feature = "enable-renderdoc"))] - _direct_composition: direct_composition, + direct_composition, }) } @@ -167,36 +193,22 @@ impl DirectXRenderer { }], )?; unsafe { + self.devices.device_context.ClearRenderTargetView( + self.resources.render_target_view[0].as_ref().unwrap(), + &[0.0; 4], + ); self.devices .device_context - .ClearRenderTargetView(self.resources.msaa_view[0].as_ref().unwrap(), &[0.0; 4]); - self.devices - .device_context - .OMSetRenderTargets(Some(&self.resources.msaa_view), None); + .OMSetRenderTargets(Some(&self.resources.render_target_view), None); self.devices .device_context .RSSetViewports(Some(&self.resources.viewport)); - self.devices.device_context.OMSetBlendState( - &self.globals.blend_state, - None, - 0xFFFFFFFF, - ); } Ok(()) } fn present(&mut self) -> Result<()> { unsafe { - self.devices.device_context.ResolveSubresource( - &*self.resources.render_target, - 0, - &self.resources.msaa_target, - 0, - RENDER_TARGET_FORMAT, - ); - self.devices - .device_context - .OMSetRenderTargets(Some(&self.resources.render_target_view), None); let result = self.resources.swap_chain.Present(1, DXGI_PRESENT(0)); // Presenting the swap chain can fail if the DirectX device was removed or reset. if result == DXGI_ERROR_DEVICE_REMOVED || result == DXGI_ERROR_DEVICE_RESET { @@ -214,36 +226,51 @@ impl DirectXRenderer { } fn handle_device_lost(&mut self) -> Result<()> { + // Here we wait a bit to ensure the the system has time to recover from the device lost state. + // If we don't wait, the final drawing result will be blank. + std::thread::sleep(std::time::Duration::from_millis(300)); + let disable_direct_composition = self.direct_composition.is_none(); + unsafe { - ManuallyDrop::drop(&mut self.devices); + #[cfg(debug_assertions)] + report_live_objects(&self.devices.device) + .context("Failed to report live objects after device lost") + .log_err(); + ManuallyDrop::drop(&mut self.resources); - #[cfg(not(feature = "enable-renderdoc"))] - ManuallyDrop::drop(&mut self._direct_composition); - } - let devices = - ManuallyDrop::new(DirectXDevices::new().context("Recreating DirectX devices")?); - unsafe { - devices.device_context.OMSetRenderTargets(None, None); - devices.device_context.ClearState(); - devices.device_context.Flush(); + self.devices.device_context.OMSetRenderTargets(None, None); + self.devices.device_context.ClearState(); + self.devices.device_context.Flush(); + + #[cfg(debug_assertions)] + report_live_objects(&self.devices.device) + .context("Failed to report live objects after device lost") + .log_err(); + + drop(self.direct_composition.take()); + ManuallyDrop::drop(&mut self.devices); } - #[cfg(not(feature = "enable-renderdoc"))] - let resources = - DirectXResources::new(&devices, self.resources.width, self.resources.height)?; - #[cfg(feature = "enable-renderdoc")] + + let devices = DirectXDevices::new(disable_direct_composition) + .context("Recreating DirectX devices")?; let resources = DirectXResources::new( &devices, self.resources.width, self.resources.height, self.hwnd, + disable_direct_composition, )?; let globals = DirectXGlobalElements::new(&devices.device)?; let pipelines = DirectXRenderPipelines::new(&devices.device)?; - #[cfg(not(feature = "enable-renderdoc"))] - let direct_composition = DirectComposition::new(&devices.dxgi_device, self.hwnd)?; - #[cfg(not(feature = "enable-renderdoc"))] - direct_composition.set_swap_chain(&resources.swap_chain)?; + let direct_composition = if disable_direct_composition { + None + } else { + let composition = + DirectComposition::new(devices.dxgi_device.as_ref().unwrap(), self.hwnd)?; + composition.set_swap_chain(&resources.swap_chain)?; + Some(composition) + }; self.atlas .handle_device_lost(&devices.device, &devices.device_context); @@ -251,10 +278,8 @@ impl DirectXRenderer { self.resources = resources; self.globals = globals; self.pipelines = pipelines; - #[cfg(not(feature = "enable-renderdoc"))] - { - self._direct_composition = direct_composition; - } + self.direct_composition = direct_composition; + unsafe { self.devices .device_context @@ -269,7 +294,10 @@ impl DirectXRenderer { match batch { PrimitiveBatch::Shadows(shadows) => self.draw_shadows(shadows), PrimitiveBatch::Quads(quads) => self.draw_quads(quads), - PrimitiveBatch::Paths(paths) => self.draw_paths(paths), + PrimitiveBatch::Paths(paths) => { + self.draw_paths_to_intermediate(paths)?; + self.draw_paths_from_intermediate(paths) + } PrimitiveBatch::Underlines(underlines) => self.draw_underlines(underlines), PrimitiveBatch::MonochromeSprites { texture_id, @@ -324,11 +352,14 @@ impl DirectXRenderer { "DirectX device removed or reset when resizing. Reason: {:?}", reason ); + self.resources.width = width; + self.resources.height = height; self.handle_device_lost()?; return Ok(()); + } else { + log::error!("Failed to resize swap chain: {:?}", e); + return Err(e.into()); } - log::error!("Failed to resize swap chain: {:?}", e); - return Err(e.into()); } } @@ -354,6 +385,8 @@ impl DirectXRenderer { &self.devices.device_context, &self.resources.viewport, &self.globals.global_params_buffer, + D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, + 4, shadows.len() as u32, ) } @@ -371,51 +404,116 @@ impl DirectXRenderer { &self.devices.device_context, &self.resources.viewport, &self.globals.global_params_buffer, + D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, + 4, quads.len() as u32, ) } - fn draw_paths(&mut self, paths: &[Path]) -> Result<()> { + fn draw_paths_to_intermediate(&mut self, paths: &[Path]) -> Result<()> { if paths.is_empty() { return Ok(()); } + + // Clear intermediate MSAA texture + unsafe { + self.devices.device_context.ClearRenderTargetView( + self.resources.path_intermediate_msaa_view[0] + .as_ref() + .unwrap(), + &[0.0; 4], + ); + // Set intermediate MSAA texture as render target + self.devices + .device_context + .OMSetRenderTargets(Some(&self.resources.path_intermediate_msaa_view), None); + } + + // Collect all vertices and sprites for a single draw call let mut vertices = Vec::new(); - let mut sprites = Vec::with_capacity(paths.len()); - let mut draw_indirect_commands = Vec::with_capacity(paths.len()); - let mut start_vertex_location = 0; - for (i, path) in paths.iter().enumerate() { - draw_indirect_commands.push(DrawInstancedIndirectArgs { - vertex_count_per_instance: path.vertices.len() as u32, - instance_count: 1, - start_vertex_location, - start_instance_location: i as u32, - }); - start_vertex_location += path.vertices.len() as u32; - - vertices.extend(path.vertices.iter().map(|v| DirectXPathVertex { + + for path in paths { + vertices.extend(path.vertices.iter().map(|v| PathRasterizationSprite { xy_position: v.xy_position, - content_mask: path.content_mask.bounds, - sprite_index: i as u32, + st_position: v.st_position, + color: path.color, + bounds: path.bounds.intersect(&path.content_mask.bounds), })); + } - sprites.push(PathSprite { - bounds: path.bounds, - color: path.color, - }); + self.pipelines.path_rasterization_pipeline.update_buffer( + &self.devices.device, + &self.devices.device_context, + &vertices, + )?; + self.pipelines.path_rasterization_pipeline.draw( + &self.devices.device_context, + &self.resources.viewport, + &self.globals.global_params_buffer, + D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, + vertices.len() as u32, + 1, + )?; + + // Resolve MSAA to non-MSAA intermediate texture + unsafe { + self.devices.device_context.ResolveSubresource( + &self.resources.path_intermediate_texture, + 0, + &self.resources.path_intermediate_msaa_texture, + 0, + RENDER_TARGET_FORMAT, + ); + // Restore main render target + self.devices + .device_context + .OMSetRenderTargets(Some(&self.resources.render_target_view), None); } - self.pipelines.paths_pipeline.update_buffer( + Ok(()) + } + + fn draw_paths_from_intermediate(&mut self, paths: &[Path]) -> Result<()> { + let Some(first_path) = paths.first() else { + return Ok(()); + }; + + // When copying paths from the intermediate texture to the drawable, + // each pixel must only be copied once, in case of transparent paths. + // + // If all paths have the same draw order, then their bounds are all + // disjoint, so we can copy each path's bounds individually. If this + // batch combines different draw orders, we perform a single copy + // for a minimal spanning rect. + let sprites = if paths.last().unwrap().order == first_path.order { + paths + .iter() + .map(|path| PathSprite { + bounds: path.bounds, + }) + .collect::>() + } else { + let mut bounds = first_path.bounds; + for path in paths.iter().skip(1) { + bounds = bounds.union(&path.bounds); + } + vec![PathSprite { bounds }] + }; + + self.pipelines.path_sprite_pipeline.update_buffer( &self.devices.device, &self.devices.device_context, &sprites, - &vertices, - &draw_indirect_commands, )?; - self.pipelines.paths_pipeline.draw( + + // Draw the sprites with the path texture + self.pipelines.path_sprite_pipeline.draw_with_texture( &self.devices.device_context, - paths.len(), + &self.resources.path_intermediate_srv, &self.resources.viewport, &self.globals.global_params_buffer, + &self.globals.sampler, + sprites.len() as u32, ) } @@ -432,6 +530,8 @@ impl DirectXRenderer { &self.devices.device_context, &self.resources.viewport, &self.globals.global_params_buffer, + D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, + 4, underlines.len() as u32, ) } @@ -501,13 +601,13 @@ impl DirectXRenderer { 0x10DE => "NVIDIA Corporation".to_string(), 0x1002 => "AMD Corporation".to_string(), 0x8086 => "Intel Corporation".to_string(), - _ => "Unknown Vendor".to_string(), + id => format!("Unknown Vendor (ID: {:#X})", id), }; let driver_version = match desc.VendorId { 0x10DE => nvidia::get_driver_version(), 0x1002 => amd::get_driver_version(), - 0x8086 => intel::get_driver_version(&self.devices.adapter), - _ => Err(anyhow::anyhow!("Unknown vendor detected.")), + // For Intel and other vendors, we use the DXGI API to get the driver version. + _ => dxgi::get_driver_version(&self.devices.adapter), } .context("Failed to get gpu driver info") .log_err() @@ -526,27 +626,42 @@ impl DirectXResources { devices: &DirectXDevices, width: u32, height: u32, - #[cfg(feature = "enable-renderdoc")] hwnd: HWND, + hwnd: HWND, + disable_direct_composition: bool, ) -> Result> { - #[cfg(not(feature = "enable-renderdoc"))] - let swap_chain = create_swap_chain(&devices.dxgi_factory, &devices.device, width, height)?; - #[cfg(feature = "enable-renderdoc")] - let swap_chain = - create_swap_chain(&devices.dxgi_factory, &devices.device, hwnd, width, height)?; + let swap_chain = if disable_direct_composition { + create_swap_chain(&devices.dxgi_factory, &devices.device, hwnd, width, height)? + } else { + create_swap_chain_for_composition( + &devices.dxgi_factory, + &devices.device, + width, + height, + )? + }; - let (render_target, render_target_view, msaa_target, msaa_view, viewport) = - create_resources(devices, &swap_chain, width, height)?; + let ( + render_target, + render_target_view, + path_intermediate_texture, + path_intermediate_srv, + path_intermediate_msaa_texture, + path_intermediate_msaa_view, + viewport, + ) = create_resources(devices, &swap_chain, width, height)?; set_rasterizer_state(&devices.device, &devices.device_context)?; Ok(ManuallyDrop::new(Self { swap_chain, render_target, render_target_view, - msaa_target, - msaa_view, + path_intermediate_texture, + path_intermediate_msaa_texture, + path_intermediate_msaa_view, + path_intermediate_srv, + viewport, width, height, - viewport, })) } @@ -557,12 +672,21 @@ impl DirectXResources { width: u32, height: u32, ) -> Result<()> { - let (render_target, render_target_view, msaa_target, msaa_view, viewport) = - create_resources(devices, &self.swap_chain, width, height)?; + let ( + render_target, + render_target_view, + path_intermediate_texture, + path_intermediate_srv, + path_intermediate_msaa_texture, + path_intermediate_msaa_view, + viewport, + ) = create_resources(devices, &self.swap_chain, width, height)?; self.render_target = render_target; self.render_target_view = render_target_view; - self.msaa_target = msaa_target; - self.msaa_view = msaa_view; + self.path_intermediate_texture = path_intermediate_texture; + self.path_intermediate_msaa_texture = path_intermediate_msaa_texture; + self.path_intermediate_msaa_view = path_intermediate_msaa_view; + self.path_intermediate_srv = path_intermediate_srv; self.viewport = viewport; self.width = width; self.height = height; @@ -572,29 +696,61 @@ impl DirectXResources { impl DirectXRenderPipelines { pub fn new(device: &ID3D11Device) -> Result { - let shadow_pipeline = - PipelineState::new(device, "shadow_pipeline", ShaderModule::Shadow, 4)?; - let quad_pipeline = PipelineState::new(device, "quad_pipeline", ShaderModule::Quad, 64)?; - let paths_pipeline = PathsPipelineState::new(device)?; - let underline_pipeline = - PipelineState::new(device, "underline_pipeline", ShaderModule::Underline, 4)?; + let shadow_pipeline = PipelineState::new( + device, + "shadow_pipeline", + ShaderModule::Shadow, + 4, + create_blend_state(device)?, + )?; + let quad_pipeline = PipelineState::new( + device, + "quad_pipeline", + ShaderModule::Quad, + 64, + create_blend_state(device)?, + )?; + let path_rasterization_pipeline = PipelineState::new( + device, + "path_rasterization_pipeline", + ShaderModule::PathRasterization, + 32, + create_blend_state_for_path_rasterization(device)?, + )?; + let path_sprite_pipeline = PipelineState::new( + device, + "path_sprite_pipeline", + ShaderModule::PathSprite, + 4, + create_blend_state_for_path_sprite(device)?, + )?; + let underline_pipeline = PipelineState::new( + device, + "underline_pipeline", + ShaderModule::Underline, + 4, + create_blend_state(device)?, + )?; let mono_sprites = PipelineState::new( device, "monochrome_sprite_pipeline", ShaderModule::MonochromeSprite, 512, + create_blend_state(device)?, )?; let poly_sprites = PipelineState::new( device, "polychrome_sprite_pipeline", ShaderModule::PolychromeSprite, 16, + create_blend_state(device)?, )?; Ok(Self { shadow_pipeline, quad_pipeline, - paths_pipeline, + path_rasterization_pipeline, + path_sprite_pipeline, underline_pipeline, mono_sprites, poly_sprites, @@ -602,18 +758,17 @@ impl DirectXRenderPipelines { } } -#[cfg(not(feature = "enable-renderdoc"))] impl DirectComposition { - pub fn new(dxgi_device: &IDXGIDevice, hwnd: HWND) -> Result> { + pub fn new(dxgi_device: &IDXGIDevice, hwnd: HWND) -> Result { let comp_device = get_comp_device(&dxgi_device)?; let comp_target = unsafe { comp_device.CreateTargetForHwnd(hwnd, true) }?; let comp_visual = unsafe { comp_device.CreateVisual() }?; - Ok(ManuallyDrop::new(Self { + Ok(Self { comp_device, comp_target, comp_visual, - })) + }) } pub fn set_swap_chain(&self, swap_chain: &IDXGISwapChain1) -> Result<()> { @@ -659,12 +814,9 @@ impl DirectXGlobalElements { [output] }; - let blend_state = create_blend_state(device)?; - Ok(Self { global_params_buffer, sampler, - blend_state, }) } } @@ -684,28 +836,17 @@ struct PipelineState { buffer: ID3D11Buffer, buffer_size: usize, view: [Option; 1], + blend_state: ID3D11BlendState, _marker: std::marker::PhantomData, } -struct PathsPipelineState { - vertex: ID3D11VertexShader, - fragment: ID3D11PixelShader, - buffer: ID3D11Buffer, - buffer_size: usize, - vertex_buffer: Option, - vertex_buffer_size: usize, - indirect_draw_buffer: ID3D11Buffer, - indirect_buffer_size: usize, - input_layout: ID3D11InputLayout, - view: [Option; 1], -} - impl PipelineState { fn new( device: &ID3D11Device, label: &'static str, shader_module: ShaderModule, buffer_size: usize, + blend_state: ID3D11BlendState, ) -> Result { let vertex = { let raw_shader = RawShaderBytes::new(shader_module, ShaderTarget::Vertex)?; @@ -725,6 +866,7 @@ impl PipelineState { buffer, buffer_size, view, + blend_state, _marker: std::marker::PhantomData, }) } @@ -757,19 +899,22 @@ impl PipelineState { device_context: &ID3D11DeviceContext, viewport: &[D3D11_VIEWPORT], global_params: &[Option], + topology: D3D_PRIMITIVE_TOPOLOGY, + vertex_count: u32, instance_count: u32, ) -> Result<()> { set_pipeline_state( device_context, &self.view, - D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, + topology, viewport, &self.vertex, &self.fragment, global_params, + &self.blend_state, ); unsafe { - device_context.DrawInstanced(4, instance_count, 0, 0); + device_context.DrawInstanced(vertex_count, instance_count, 0, 0); } Ok(()) } @@ -791,6 +936,7 @@ impl PipelineState { &self.vertex, &self.fragment, global_params, + &self.blend_state, ); unsafe { device_context.PSSetSamplers(0, Some(sampler)); @@ -803,207 +949,28 @@ impl PipelineState { } } -impl PathsPipelineState { - fn new(device: &ID3D11Device) -> Result { - let (vertex, vertex_shader) = { - let raw_vertex_shader = RawShaderBytes::new(ShaderModule::Paths, ShaderTarget::Vertex)?; - ( - create_vertex_shader(device, raw_vertex_shader.as_bytes())?, - raw_vertex_shader, - ) - }; - let fragment = { - let raw_shader = RawShaderBytes::new(ShaderModule::Paths, ShaderTarget::Fragment)?; - create_fragment_shader(device, raw_shader.as_bytes())? - }; - let buffer = create_buffer(device, std::mem::size_of::(), 32)?; - let view = create_buffer_view(device, &buffer)?; - let vertex_buffer = Some(create_buffer( - device, - std::mem::size_of::(), - 32, - )?); - let indirect_draw_buffer = create_indirect_draw_buffer(device, 32)?; - // Create input layout - let input_layout = unsafe { - let mut layout = None; - device.CreateInputLayout( - &[ - D3D11_INPUT_ELEMENT_DESC { - SemanticName: windows::core::s!("POSITION"), - SemanticIndex: 0, - Format: DXGI_FORMAT_R32G32_FLOAT, - InputSlot: 0, - AlignedByteOffset: 0, - InputSlotClass: D3D11_INPUT_PER_VERTEX_DATA, - InstanceDataStepRate: 0, - }, - D3D11_INPUT_ELEMENT_DESC { - SemanticName: windows::core::s!("TEXCOORD"), - SemanticIndex: 0, - Format: DXGI_FORMAT_R32G32_FLOAT, - InputSlot: 0, - AlignedByteOffset: 8, - InputSlotClass: D3D11_INPUT_PER_VERTEX_DATA, - InstanceDataStepRate: 0, - }, - D3D11_INPUT_ELEMENT_DESC { - SemanticName: windows::core::s!("TEXCOORD"), - SemanticIndex: 1, - Format: DXGI_FORMAT_R32G32_FLOAT, - InputSlot: 0, - AlignedByteOffset: 16, - InputSlotClass: D3D11_INPUT_PER_VERTEX_DATA, - InstanceDataStepRate: 0, - }, - D3D11_INPUT_ELEMENT_DESC { - SemanticName: windows::core::s!("GLOBALIDX"), - SemanticIndex: 0, - Format: DXGI_FORMAT_R32_UINT, - InputSlot: 0, - AlignedByteOffset: 24, - InputSlotClass: D3D11_INPUT_PER_VERTEX_DATA, - InstanceDataStepRate: 0, - }, - ], - vertex_shader.as_bytes(), - Some(&mut layout), - )?; - layout.unwrap() - }; - - Ok(Self { - vertex, - fragment, - buffer, - buffer_size: 32, - vertex_buffer, - vertex_buffer_size: 32, - indirect_draw_buffer, - indirect_buffer_size: 32, - input_layout, - view, - }) - } - - fn update_buffer( - &mut self, - device: &ID3D11Device, - device_context: &ID3D11DeviceContext, - buffer_data: &[PathSprite], - vertices_data: &[DirectXPathVertex], - draw_commands: &[DrawInstancedIndirectArgs], - ) -> Result<()> { - if self.buffer_size < buffer_data.len() { - let new_buffer_size = buffer_data.len().next_power_of_two(); - log::info!( - "Updating Paths Pipeline buffer size from {} to {}", - self.buffer_size, - new_buffer_size - ); - let buffer = create_buffer(device, std::mem::size_of::(), new_buffer_size)?; - let view = create_buffer_view(device, &buffer)?; - self.buffer = buffer; - self.view = view; - self.buffer_size = new_buffer_size; - } - update_buffer(device_context, &self.buffer, buffer_data)?; - if self.vertex_buffer_size < vertices_data.len() { - let new_vertex_buffer_size = vertices_data.len().next_power_of_two(); - log::info!( - "Updating Paths Pipeline vertex buffer size from {} to {}", - self.vertex_buffer_size, - new_vertex_buffer_size - ); - let vertex_buffer = create_buffer( - device, - std::mem::size_of::(), - new_vertex_buffer_size, - )?; - self.vertex_buffer = Some(vertex_buffer); - self.vertex_buffer_size = new_vertex_buffer_size; - } - update_buffer( - device_context, - self.vertex_buffer.as_ref().unwrap(), - vertices_data, - )?; - if self.indirect_buffer_size < draw_commands.len() { - let new_indirect_buffer_size = draw_commands.len().next_power_of_two(); - log::info!( - "Updating Paths Pipeline indirect buffer size from {} to {}", - self.indirect_buffer_size, - new_indirect_buffer_size - ); - let indirect_draw_buffer = - create_indirect_draw_buffer(device, new_indirect_buffer_size)?; - self.indirect_draw_buffer = indirect_draw_buffer; - self.indirect_buffer_size = new_indirect_buffer_size; - } - update_buffer(device_context, &self.indirect_draw_buffer, draw_commands)?; - Ok(()) - } - - fn draw( - &self, - device_context: &ID3D11DeviceContext, - count: usize, - viewport: &[D3D11_VIEWPORT], - global_params: &[Option], - ) -> Result<()> { - set_pipeline_state( - device_context, - &self.view, - D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, - viewport, - &self.vertex, - &self.fragment, - global_params, - ); - unsafe { - const STRIDE: u32 = std::mem::size_of::() as u32; - device_context.IASetVertexBuffers( - 0, - 1, - Some(&self.vertex_buffer), - Some(&STRIDE), - Some(&0), - ); - device_context.IASetInputLayout(&self.input_layout); - } - for i in 0..count { - unsafe { - device_context.DrawInstancedIndirect( - &self.indirect_draw_buffer, - (i * std::mem::size_of::()) as u32, - ); - } - } - Ok(()) - } -} - +#[derive(Clone, Copy)] #[repr(C)] -struct DirectXPathVertex { +struct PathRasterizationSprite { xy_position: Point, - content_mask: Bounds, - sprite_index: u32, + st_position: Point, + color: Background, + bounds: Bounds, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Copy)] #[repr(C)] struct PathSprite { bounds: Bounds, - color: Background, } impl Drop for DirectXRenderer { fn drop(&mut self) { + #[cfg(debug_assertions)] + report_live_objects(&self.devices.device).ok(); unsafe { ManuallyDrop::drop(&mut self.devices); ManuallyDrop::drop(&mut self.resources); - #[cfg(not(feature = "enable-renderdoc"))] - ManuallyDrop::drop(&mut self._direct_composition); } } } @@ -1019,7 +986,17 @@ impl Drop for DirectXResources { #[inline] fn get_dxgi_factory() -> Result { #[cfg(debug_assertions)] - let factory_flag = DXGI_CREATE_FACTORY_DEBUG; + let factory_flag = if unsafe { DXGIGetDebugInterface1::(0) } + .log_err() + .is_some() + { + DXGI_CREATE_FACTORY_DEBUG + } else { + log::warn!( + "Failed to get DXGI debug interface. DirectX debugging features will be disabled." + ); + DXGI_CREATE_FACTORY_FLAGS::default() + }; #[cfg(not(debug_assertions))] let factory_flag = DXGI_CREATE_FACTORY_FLAGS::default(); unsafe { Ok(CreateDXGIFactory2(factory_flag)?) } @@ -1039,7 +1016,7 @@ fn get_adapter(dxgi_factory: &IDXGIFactory6) -> Result { } // Check to see whether the adapter supports Direct3D 11, but don't // create the actual device yet. - if get_device(&adapter, None, None).log_err().is_some() { + if get_device(&adapter, None, None, None).log_err().is_some() { return Ok(adapter); } } @@ -1051,6 +1028,7 @@ fn get_device( adapter: &IDXGIAdapter1, device: Option<*mut Option>, context: Option<*mut Option>, + feature_level: Option<*mut D3D_FEATURE_LEVEL>, ) -> Result<()> { #[cfg(debug_assertions)] let device_flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_DEBUG; @@ -1063,24 +1041,26 @@ fn get_device( HMODULE::default(), device_flags, // 4x MSAA is required for Direct3D Feature Level 10.1 or better - // 8x MSAA is required for Direct3D Feature Level 11.0 or better - Some(&[D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1]), + Some(&[ + D3D_FEATURE_LEVEL_11_1, + D3D_FEATURE_LEVEL_11_0, + D3D_FEATURE_LEVEL_10_1, + ]), D3D11_SDK_VERSION, device, - None, + feature_level, context, )?; } Ok(()) } -#[cfg(not(feature = "enable-renderdoc"))] +#[inline] fn get_comp_device(dxgi_device: &IDXGIDevice) -> Result { Ok(unsafe { DCompositionCreateDevice(dxgi_device)? }) } -#[cfg(not(feature = "enable-renderdoc"))] -fn create_swap_chain( +fn create_swap_chain_for_composition( dxgi_factory: &IDXGIFactory6, device: &ID3D11Device, width: u32, @@ -1106,7 +1086,6 @@ fn create_swap_chain( Ok(unsafe { dxgi_factory.CreateSwapChainForComposition(device, &desc, None)? }) } -#[cfg(feature = "enable-renderdoc")] fn create_swap_chain( dxgi_factory: &IDXGIFactory6, device: &ID3D11Device, @@ -1127,7 +1106,7 @@ fn create_swap_chain( }, BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, BufferCount: BUFFER_COUNT as u32, - Scaling: DXGI_SCALING_STRETCH, + Scaling: DXGI_SCALING_NONE, SwapEffect: DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL, AlphaMode: DXGI_ALPHA_MODE_IGNORE, Flags: 0, @@ -1148,18 +1127,25 @@ fn create_resources( ManuallyDrop, [Option; 1], ID3D11Texture2D, + [Option; 1], + ID3D11Texture2D, [Option; 1], [D3D11_VIEWPORT; 1], )> { let (render_target, render_target_view) = create_render_target_and_its_view(&swap_chain, &devices.device)?; - let (msaa_target, msaa_view) = create_msaa_target_and_its_view(&devices.device, width, height)?; + let (path_intermediate_texture, path_intermediate_srv) = + create_path_intermediate_texture(&devices.device, width, height)?; + let (path_intermediate_msaa_texture, path_intermediate_msaa_view) = + create_path_intermediate_msaa_texture_and_view(&devices.device, width, height)?; let viewport = set_viewport(&devices.device_context, width as f32, height as f32); Ok(( render_target, render_target_view, - msaa_target, - msaa_view, + path_intermediate_texture, + path_intermediate_srv, + path_intermediate_msaa_texture, + path_intermediate_msaa_view, viewport, )) } @@ -1182,12 +1168,12 @@ fn create_render_target_and_its_view( } #[inline] -fn create_msaa_target_and_its_view( +fn create_path_intermediate_texture( device: &ID3D11Device, width: u32, height: u32, -) -> Result<(ID3D11Texture2D, [Option; 1])> { - let msaa_target = unsafe { +) -> Result<(ID3D11Texture2D, [Option; 1])> { + let texture = unsafe { let mut output = None; let desc = D3D11_TEXTURE2D_DESC { Width: width, @@ -1196,23 +1182,53 @@ fn create_msaa_target_and_its_view( ArraySize: 1, Format: RENDER_TARGET_FORMAT, SampleDesc: DXGI_SAMPLE_DESC { - Count: MULTISAMPLE_COUNT, - Quality: D3D11_STANDARD_MULTISAMPLE_PATTERN.0 as u32, + Count: 1, + Quality: 0, }, Usage: D3D11_USAGE_DEFAULT, - BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, CPUAccessFlags: 0, MiscFlags: 0, }; device.CreateTexture2D(&desc, None, Some(&mut output))?; output.unwrap() }; - let msaa_view = unsafe { + + let mut shader_resource_view = None; + unsafe { device.CreateShaderResourceView(&texture, None, Some(&mut shader_resource_view))? }; + + Ok((texture, [Some(shader_resource_view.unwrap())])) +} + +#[inline] +fn create_path_intermediate_msaa_texture_and_view( + device: &ID3D11Device, + width: u32, + height: u32, +) -> Result<(ID3D11Texture2D, [Option; 1])> { + let msaa_texture = unsafe { let mut output = None; - device.CreateRenderTargetView(&msaa_target, None, Some(&mut output))?; + let desc = D3D11_TEXTURE2D_DESC { + Width: width, + Height: height, + MipLevels: 1, + ArraySize: 1, + Format: RENDER_TARGET_FORMAT, + SampleDesc: DXGI_SAMPLE_DESC { + Count: PATH_MULTISAMPLE_COUNT, + Quality: D3D11_STANDARD_MULTISAMPLE_PATTERN.0 as u32, + }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + device.CreateTexture2D(&desc, None, Some(&mut output))?; output.unwrap() }; - Ok((msaa_target, [Some(msaa_view)])) + let mut msaa_view = None; + unsafe { device.CreateRenderTargetView(&msaa_texture, None, Some(&mut msaa_view))? }; + Ok((msaa_texture, [Some(msaa_view.unwrap())])) } #[inline] @@ -1244,7 +1260,6 @@ fn set_rasterizer_state(device: &ID3D11Device, device_context: &ID3D11DeviceCont SlopeScaledDepthBias: 0.0, DepthClipEnable: true.into(), ScissorEnable: false.into(), - // MultisampleEnable: false.into(), MultisampleEnable: true.into(), AntialiasedLineEnable: false.into(), }; @@ -1278,6 +1293,46 @@ fn create_blend_state(device: &ID3D11Device) -> Result { } } +#[inline] +fn create_blend_state_for_path_rasterization(device: &ID3D11Device) -> Result { + // If the feature level is set to greater than D3D_FEATURE_LEVEL_9_3, the display + // device performs the blend in linear space, which is ideal. + let mut desc = D3D11_BLEND_DESC::default(); + desc.RenderTarget[0].BlendEnable = true.into(); + desc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD; + desc.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD; + desc.RenderTarget[0].SrcBlend = D3D11_BLEND_ONE; + desc.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE; + desc.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA; + desc.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_INV_SRC_ALPHA; + desc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8; + unsafe { + let mut state = None; + device.CreateBlendState(&desc, Some(&mut state))?; + Ok(state.unwrap()) + } +} + +#[inline] +fn create_blend_state_for_path_sprite(device: &ID3D11Device) -> Result { + // If the feature level is set to greater than D3D_FEATURE_LEVEL_9_3, the display + // device performs the blend in linear space, which is ideal. + let mut desc = D3D11_BLEND_DESC::default(); + desc.RenderTarget[0].BlendEnable = true.into(); + desc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD; + desc.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD; + desc.RenderTarget[0].SrcBlend = D3D11_BLEND_ONE; + desc.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE; + desc.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA; + desc.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_ONE; + desc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8; + unsafe { + let mut state = None; + device.CreateBlendState(&desc, Some(&mut state))?; + Ok(state.unwrap()) + } +} + #[inline] fn create_vertex_shader(device: &ID3D11Device, bytes: &[u8]) -> Result { unsafe { @@ -1325,21 +1380,6 @@ fn create_buffer_view( Ok([view]) } -#[inline] -fn create_indirect_draw_buffer(device: &ID3D11Device, buffer_size: usize) -> Result { - let desc = D3D11_BUFFER_DESC { - ByteWidth: (std::mem::size_of::() * buffer_size) as u32, - Usage: D3D11_USAGE_DYNAMIC, - BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, - CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, - MiscFlags: D3D11_RESOURCE_MISC_DRAWINDIRECT_ARGS.0 as u32, - StructureByteStride: std::mem::size_of::() as u32, - }; - let mut buffer = None; - unsafe { device.CreateBuffer(&desc, None, Some(&mut buffer)) }?; - Ok(buffer.unwrap()) -} - #[inline] fn update_buffer( device_context: &ID3D11DeviceContext, @@ -1364,6 +1404,7 @@ fn set_pipeline_state( vertex_shader: &ID3D11VertexShader, fragment_shader: &ID3D11PixelShader, global_params: &[Option], + blend_state: &ID3D11BlendState, ) { unsafe { device_context.VSSetShaderResources(1, Some(buffer_view)); @@ -1374,9 +1415,19 @@ fn set_pipeline_state( device_context.PSSetShader(fragment_shader, None); device_context.VSSetConstantBuffers(0, Some(global_params)); device_context.PSSetConstantBuffers(0, Some(global_params)); + device_context.OMSetBlendState(blend_state, None, 0xFFFFFFFF); } } +#[cfg(debug_assertions)] +fn report_live_objects(device: &ID3D11Device) -> Result<()> { + let debug_device: ID3D11Debug = device.cast()?; + unsafe { + debug_device.ReportLiveDeviceObjects(D3D11_RLDO_DETAIL)?; + } + Ok(()) +} + const BUFFER_COUNT: usize = 3; pub(crate) mod shader_resources { @@ -1396,7 +1447,8 @@ pub(crate) mod shader_resources { Quad, Shadow, Underline, - Paths, + PathRasterization, + PathSprite, MonochromeSprite, PolychromeSprite, EmojiRasterization, @@ -1453,9 +1505,13 @@ pub(crate) mod shader_resources { ShaderTarget::Vertex => UNDERLINE_VERTEX_BYTES, ShaderTarget::Fragment => UNDERLINE_FRAGMENT_BYTES, }, - ShaderModule::Paths => match target { - ShaderTarget::Vertex => PATHS_VERTEX_BYTES, - ShaderTarget::Fragment => PATHS_FRAGMENT_BYTES, + ShaderModule::PathRasterization => match target { + ShaderTarget::Vertex => PATH_RASTERIZATION_VERTEX_BYTES, + ShaderTarget::Fragment => PATH_RASTERIZATION_FRAGMENT_BYTES, + }, + ShaderModule::PathSprite => match target { + ShaderTarget::Vertex => PATH_SPRITE_VERTEX_BYTES, + ShaderTarget::Fragment => PATH_SPRITE_FRAGMENT_BYTES, }, ShaderModule::MonochromeSprite => match target { ShaderTarget::Vertex => MONOCHROME_SPRITE_VERTEX_BYTES, @@ -1492,8 +1548,8 @@ pub(crate) mod shader_resources { } ); let target = match target { - ShaderTarget::Vertex => "vs_5_0\0", - ShaderTarget::Fragment => "ps_5_0\0", + ShaderTarget::Vertex => "vs_4_1\0", + ShaderTarget::Fragment => "ps_4_1\0", }; let mut compile_blob = None; @@ -1542,7 +1598,8 @@ pub(crate) mod shader_resources { ShaderModule::Quad => "quad", ShaderModule::Shadow => "shadow", ShaderModule::Underline => "underline", - ShaderModule::Paths => "paths", + ShaderModule::PathRasterization => "path_rasterization", + ShaderModule::PathSprite => "path_sprite", ShaderModule::MonochromeSprite => "monochrome_sprite", ShaderModule::PolychromeSprite => "polychrome_sprite", ShaderModule::EmojiRasterization => "emoji_rasterization", @@ -1721,7 +1778,7 @@ mod amd { } } -mod intel { +mod dxgi { use windows::{ Win32::Graphics::Dxgi::{IDXGIAdapter1, IDXGIDevice}, core::Interface, diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index 4b905302af4714bcccbe732ef4f847af4b3805ee..61f410a8c6f9ebbdf70ce2ba29c18b83229f552f 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -23,6 +23,7 @@ pub(crate) const WM_GPUI_CURSOR_STYLE_CHANGED: u32 = WM_USER + 1; pub(crate) const WM_GPUI_CLOSE_ONE_WINDOW: u32 = WM_USER + 2; pub(crate) const WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD: u32 = WM_USER + 3; pub(crate) const WM_GPUI_DOCK_MENU_ACTION: u32 = WM_USER + 4; +pub(crate) const WM_GPUI_FORCE_UPDATE_WINDOW: u32 = WM_USER + 5; const SIZE_MOVE_LOOP_TIMER_ID: usize = 1; const AUTO_HIDE_TASKBAR_THICKNESS_PX: i32 = 1; @@ -97,6 +98,7 @@ pub(crate) fn handle_msg( WM_SETTINGCHANGE => handle_system_settings_changed(handle, wparam, lparam, state_ptr), WM_INPUTLANGCHANGE => handle_input_language_changed(lparam, state_ptr), WM_GPUI_CURSOR_STYLE_CHANGED => handle_cursor_changed(lparam, state_ptr), + WM_GPUI_FORCE_UPDATE_WINDOW => draw_window(handle, true, state_ptr), _ => None, }; if let Some(n) = handled { @@ -1202,6 +1204,19 @@ fn handle_device_change_msg( state_ptr: Rc, ) -> Option { if wparam.0 == DBT_DEVNODES_CHANGED as usize { + // The reason for sending this message is to actually trigger a redraw of the window. + unsafe { + PostMessageW( + Some(handle), + WM_GPUI_FORCE_UPDATE_WINDOW, + WPARAM(0), + LPARAM(0), + ) + .log_err(); + } + // If the GPU device is lost, this redraw will take care of recreating the device context. + // The WM_GPUI_FORCE_UPDATE_WINDOW message will take care of redrawing the window, after + // the device context has been recreated. draw_window(handle, true, state_ptr) } else { // Other device change messages are not handled. @@ -1212,7 +1227,7 @@ fn handle_device_change_msg( #[inline] fn draw_window( handle: HWND, - force_draw: bool, + force_render: bool, state_ptr: Rc, ) -> Option { let mut request_frame = state_ptr @@ -1222,7 +1237,8 @@ fn draw_window( .request_frame .take()?; request_frame(RequestFrameOptions { - require_presentation: force_draw, + require_presentation: false, + force_render, }); state_ptr.state.borrow_mut().callbacks.request_frame = Some(request_frame); unsafe { ValidateRect(Some(handle), None).ok().log_err() }; diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index 835df48c295b3a9ea6b1980e583d68704a43adf5..27acb91f684619a77e9c3c9c4b997c35db655bc5 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -437,8 +437,7 @@ impl Platform for WindowsPlatform { handle: AnyWindowHandle, options: WindowParams, ) -> Result> { - let window = WindowsWindow::new(handle, options, self.generate_creation_info()) - .inspect_err(|err| show_error("Failed to open new window", err.to_string()))?; + let window = WindowsWindow::new(handle, options, self.generate_creation_info())?; let handle = window.get_raw_handle(); self.raw_window_handles.write().push(handle); diff --git a/crates/gpui/src/platform/windows/shaders.hlsl b/crates/gpui/src/platform/windows/shaders.hlsl index 33f2e0392502b12f5a66336bf8648a1c742511ff..daef05059b5703c62e9dcbb2c067eb4a7b0f755c 100644 --- a/crates/gpui/src/platform/windows/shaders.hlsl +++ b/crates/gpui/src/platform/windows/shaders.hlsl @@ -873,67 +873,105 @@ float4 shadow_fragment(ShadowFragmentInput input): SV_TARGET { /* ** -** Paths +** Path Rasterization ** */ -struct PathVertex { - float2 xy_position: POSITION; - Bounds content_mask: TEXCOORD; - uint idx: GLOBALIDX; -}; - -struct PathSprite { - Bounds bounds; +struct PathRasterizationSprite { + float2 xy_position; + float2 st_position; Background color; + Bounds bounds; }; +StructuredBuffer path_rasterization_sprites: register(t1); + struct PathVertexOutput { float4 position: SV_Position; - nointerpolation uint sprite_id: TEXCOORD0; - nointerpolation float4 solid_color: COLOR0; - nointerpolation float4 color0: COLOR1; - nointerpolation float4 color1: COLOR2; + float2 st_position: TEXCOORD0; + nointerpolation uint vertex_id: TEXCOORD1; float4 clip_distance: SV_ClipDistance; }; struct PathFragmentInput { float4 position: SV_Position; - nointerpolation uint sprite_id: TEXCOORD0; - nointerpolation float4 solid_color: COLOR0; - nointerpolation float4 color0: COLOR1; - nointerpolation float4 color1: COLOR2; + float2 st_position: TEXCOORD0; + nointerpolation uint vertex_id: TEXCOORD1; }; -StructuredBuffer path_sprites: register(t1); - -PathVertexOutput paths_vertex(PathVertex input) { - PathSprite sprite = path_sprites[input.idx]; +PathVertexOutput path_rasterization_vertex(uint vertex_id: SV_VertexID) { + PathRasterizationSprite sprite = path_rasterization_sprites[vertex_id]; PathVertexOutput output; - output.position = to_device_position_impl(input.xy_position); - output.clip_distance = distance_from_clip_rect_impl(input.xy_position, input.content_mask); - output.sprite_id = input.idx; + output.position = to_device_position_impl(sprite.xy_position); + output.st_position = sprite.st_position; + output.vertex_id = vertex_id; + output.clip_distance = distance_from_clip_rect_impl(sprite.xy_position, sprite.bounds); + + return output; +} + +float4 path_rasterization_fragment(PathFragmentInput input): SV_Target { + float2 dx = ddx(input.st_position); + float2 dy = ddy(input.st_position); + PathRasterizationSprite sprite = path_rasterization_sprites[input.vertex_id]; + + Background background = sprite.color; + Bounds bounds = sprite.bounds; + + float alpha; + if (length(float2(dx.x, dy.x))) { + alpha = 1.0; + } else { + float2 gradient = 2.0 * input.st_position.xx * float2(dx.x, dy.x) - float2(dx.y, dy.y); + float f = input.st_position.x * input.st_position.x - input.st_position.y; + float distance = f / length(gradient); + alpha = saturate(0.5 - distance); + } GradientColor gradient = prepare_gradient_color( - sprite.color.tag, - sprite.color.color_space, - sprite.color.solid, - sprite.color.colors - ); + background.tag, background.color_space, background.solid, background.colors); - output.solid_color = gradient.solid; - output.color0 = gradient.color0; - output.color1 = gradient.color1; + float4 color = gradient_color(background, input.position.xy, bounds, + gradient.solid, gradient.color0, gradient.color1); + return float4(color.rgb * color.a * alpha, alpha * color.a); +} + +/* +** +** Path Sprites +** +*/ + +struct PathSprite { + Bounds bounds; +}; + +struct PathSpriteVertexOutput { + float4 position: SV_Position; + float2 texture_coords: TEXCOORD0; +}; + +StructuredBuffer path_sprites: register(t1); + +PathSpriteVertexOutput path_sprite_vertex(uint vertex_id: SV_VertexID, uint sprite_id: SV_InstanceID) { + float2 unit_vertex = float2(float(vertex_id & 1u), 0.5 * float(vertex_id & 2u)); + PathSprite sprite = path_sprites[sprite_id]; + + // Don't apply content mask because it was already accounted for when rasterizing the path + float4 device_position = to_device_position(unit_vertex, sprite.bounds); + + float2 screen_position = sprite.bounds.origin + unit_vertex * sprite.bounds.size; + float2 texture_coords = screen_position / global_viewport_size; + + PathSpriteVertexOutput output; + output.position = device_position; + output.texture_coords = texture_coords; return output; } -float4 paths_fragment(PathFragmentInput input): SV_Target { - PathSprite sprite = path_sprites[input.sprite_id]; - Background background = sprite.color; - float4 color = gradient_color(background, input.position.xy, sprite.bounds, - input.solid_color, input.color0, input.color1); - return color; +float4 path_sprite_fragment(PathSpriteVertexOutput input): SV_Target { + return t_sprite.Sample(s_sprite, input.texture_coords); } /* diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index 159cb6d95f876da130fec03e6f69e34437c6bab9..1141e93565b5afb5602a4cc921970f8f9e21d52d 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -84,6 +84,7 @@ impl WindowsWindowState { display: WindowsDisplay, min_size: Option>, appearance: WindowAppearance, + disable_direct_composition: bool, ) -> Result { let scale_factor = { let monitor_dpi = unsafe { GetDpiForWindow(hwnd) } as f32; @@ -100,7 +101,8 @@ impl WindowsWindowState { }; let border_offset = WindowBorderOffset::default(); let restore_from_minimized = None; - let renderer = DirectXRenderer::new(hwnd)?; + let renderer = DirectXRenderer::new(hwnd, disable_direct_composition) + .context("Creating DirectX renderer")?; let callbacks = Callbacks::default(); let input_handler = None; let pending_surrogate = None; @@ -208,6 +210,7 @@ impl WindowsWindowStatePtr { context.display, context.min_size, context.appearance, + context.disable_direct_composition, )?); Ok(Rc::new_cyclic(|this| Self { @@ -339,6 +342,7 @@ struct WindowCreateContext { main_receiver: flume::Receiver, main_thread_id_win32: u32, appearance: WindowAppearance, + disable_direct_composition: bool, } impl WindowsWindow { @@ -371,17 +375,20 @@ impl WindowsWindow { .map(|title| title.as_ref()) .unwrap_or(""), ); - let (dwexstyle, mut dwstyle) = if params.kind == WindowKind::PopUp { - ( - WS_EX_TOOLWINDOW | WS_EX_NOREDIRECTIONBITMAP, - WINDOW_STYLE(0x0), - ) + let disable_direct_composition = std::env::var(DISABLE_DIRECT_COMPOSITION) + .is_ok_and(|value| value == "true" || value == "1"); + + let (mut dwexstyle, dwstyle) = if params.kind == WindowKind::PopUp { + (WS_EX_TOOLWINDOW, WINDOW_STYLE(0x0)) } else { ( - WS_EX_APPWINDOW | WS_EX_NOREDIRECTIONBITMAP, + WS_EX_APPWINDOW, WS_THICKFRAME | WS_SYSMENU | WS_MAXIMIZEBOX | WS_MINIMIZEBOX, ) }; + if !disable_direct_composition { + dwexstyle |= WS_EX_NOREDIRECTIONBITMAP; + } let hinstance = get_module_handle(); let display = if let Some(display_id) = params.display_id { @@ -406,6 +413,7 @@ impl WindowsWindow { main_receiver, main_thread_id_win32, appearance, + disable_direct_composition, }; let lpparam = Some(&context as *const _ as *const _); let creation_result = unsafe { diff --git a/crates/gpui/src/tab_stop.rs b/crates/gpui/src/tab_stop.rs index 2ec3f560e8be80d486f0920b4d41d1964c7645da..1aa4cd6d9fbb1815fb8e566a223fc8d85cf304b1 100644 --- a/crates/gpui/src/tab_stop.rs +++ b/crates/gpui/src/tab_stop.rs @@ -5,7 +5,7 @@ use crate::{FocusHandle, FocusId}; /// Used to manage the `Tab` event to switch between focus handles. #[derive(Default)] pub(crate) struct TabHandles { - handles: Vec, + pub(crate) handles: Vec, } impl TabHandles { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 963d2bb45c437e98d7a56587dedd7ff56827a56f..6ebb1cac40c550680b81cbe5b05180b3009b8475 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -702,6 +702,7 @@ pub(crate) struct PaintIndex { input_handlers_index: usize, cursor_styles_index: usize, accessed_element_states_index: usize, + tab_handle_index: usize, line_layout_index: LineLayoutIndex, } @@ -1019,7 +1020,7 @@ impl Window { || (active.get() && last_input_timestamp.get().elapsed() < Duration::from_secs(1)); - if invalidator.is_dirty() { + if invalidator.is_dirty() || request_frame_options.force_render { measure("frame duration", || { handle .update(&mut cx, |_, window, cx| { @@ -2208,6 +2209,7 @@ impl Window { input_handlers_index: self.next_frame.input_handlers.len(), cursor_styles_index: self.next_frame.cursor_styles.len(), accessed_element_states_index: self.next_frame.accessed_element_states.len(), + tab_handle_index: self.next_frame.tab_handles.handles.len(), line_layout_index: self.text_system.layout_index(), } } @@ -2237,6 +2239,12 @@ impl Window { .iter() .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)), ); + self.next_frame.tab_handles.handles.extend( + self.rendered_frame.tab_handles.handles + [range.start.tab_handle_index..range.end.tab_handle_index] + .iter() + .cloned(), + ); self.text_system .reuse_layouts(range.start.line_layout_index..range.end.line_layout_index); diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index e7066ae151664a196d78547ae62dcd39d1ba0653..7552060be415666fb67039609b629a1662c316cc 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -71,6 +71,7 @@ pub enum IconName { CircleHelp, Close, Cloud, + CloudDownload, Code, Cog, Command, diff --git a/crates/languages/src/go/runnables.scm b/crates/languages/src/go/runnables.scm index 49e112b860d331f4399d53cff8bd849bdc559f42..6418cd04d8d69c2bb97434053c93f591aed55c68 100644 --- a/crates/languages/src/go/runnables.scm +++ b/crates/languages/src/go/runnables.scm @@ -69,7 +69,7 @@ ( ( (function_declaration name: (_) @run @_name - (#match? @_name "^Benchmark.+")) + (#match? @_name "^Benchmark.*")) ) @_ (#set! tag go-benchmark) ) diff --git a/crates/livekit_client/src/mock_client/participant.rs b/crates/livekit_client/src/mock_client/participant.rs index 991d10bd5057014de3726ae4d1d0bc2c5b1b4661..033808cbb54189fa2a7841264097751da4deb027 100644 --- a/crates/livekit_client/src/mock_client/participant.rs +++ b/crates/livekit_client/src/mock_client/participant.rs @@ -5,7 +5,9 @@ use crate::{ }; use anyhow::Result; use collections::HashMap; -use gpui::{AsyncApp, ScreenCaptureSource, ScreenCaptureStream, TestScreenCaptureStream}; +use gpui::{ + AsyncApp, DevicePixels, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, size, +}; #[derive(Clone, Debug)] pub struct LocalParticipant { @@ -119,3 +121,16 @@ impl RemoteParticipant { self.identity.clone() } } + +struct TestScreenCaptureStream; + +impl ScreenCaptureStream for TestScreenCaptureStream { + fn metadata(&self) -> Result { + Ok(SourceMetadata { + id: 0, + is_main: None, + label: None, + resolution: size(DevicePixels(1), DevicePixels(1)), + }) + } +} diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml index 693e39d4ca052dc3ed91376622235db2bd92f6ca..6ec8f8b162c5b8d62817e81893217e6d22a5d7f2 100644 --- a/crates/onboarding/Cargo.toml +++ b/crates/onboarding/Cargo.toml @@ -26,3 +26,4 @@ theme.workspace = true ui.workspace = true workspace.workspace = true workspace-hack.workspace = true +zed_actions.workspace = true diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index dfdea1ca5b21b5256a9bd420630d2a6f4677fc25..b675ed2dd77d803d587f9365f8e0f283b7b9fd97 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -1,3 +1,4 @@ +use crate::welcome::{ShowWelcome, WelcomePage}; use command_palette_hooks::CommandPaletteFilter; use db::kvp::KEY_VALUE_STORE; use feature_flags::{FeatureFlag, FeatureFlagViewExt as _}; @@ -20,6 +21,8 @@ use workspace::{ open_new, with_active_or_new_workspace, }; +mod welcome; + pub struct OnBoardingFeatureFlag {} impl FeatureFlag for OnBoardingFeatureFlag { @@ -63,12 +66,43 @@ pub fn init(cx: &mut App) { .detach(); }); }); + + cx.on_action(|_: &ShowWelcome, cx| { + with_active_or_new_workspace(cx, |workspace, window, cx| { + workspace + .with_local_workspace(window, cx, |workspace, window, cx| { + let existing = workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()); + + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + } else { + let settings_page = WelcomePage::new(cx); + workspace.add_item_to_active_pane( + Box::new(settings_page), + None, + true, + window, + cx, + ) + } + }) + .detach(); + }); + }); + cx.observe_new::(|_, window, cx| { let Some(window) = window else { return; }; - let onboarding_actions = [std::any::TypeId::of::()]; + let onboarding_actions = [ + std::any::TypeId::of::(), + std::any::TypeId::of::(), + ]; CommandPaletteFilter::update_global(cx, |filter, _cx| { filter.hide_action_types(&onboarding_actions); diff --git a/crates/onboarding/src/welcome.rs b/crates/onboarding/src/welcome.rs new file mode 100644 index 0000000000000000000000000000000000000000..2ea120e02187d3a6b556a32312512ae0f25d316f --- /dev/null +++ b/crates/onboarding/src/welcome.rs @@ -0,0 +1,275 @@ +use gpui::{ + Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, + NoAction, ParentElement, Render, Styled, Window, actions, +}; +use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*}; +use workspace::{ + NewFile, Open, Workspace, WorkspaceId, + item::{Item, ItemEvent}, +}; +use zed_actions::{Extensions, OpenSettings, command_palette}; + +actions!( + zed, + [ + /// Show the Zed welcome screen + ShowWelcome + ] +); + +const CONTENT: (Section<4>, Section<3>) = ( + Section { + title: "Get Started", + entries: [ + SectionEntry { + icon: IconName::Plus, + title: "New File", + action: &NewFile, + }, + SectionEntry { + icon: IconName::FolderOpen, + title: "Open Project", + action: &Open, + }, + SectionEntry { + icon: IconName::CloudDownload, + title: "Clone a Repo", + // TODO: use proper action + action: &NoAction, + }, + SectionEntry { + icon: IconName::ListCollapse, + title: "Open Command Palette", + action: &command_palette::Toggle, + }, + ], + }, + Section { + title: "Configure", + entries: [ + SectionEntry { + icon: IconName::Settings, + title: "Open Settings", + action: &OpenSettings, + }, + SectionEntry { + icon: IconName::ZedAssistant, + title: "View AI Settings", + // TODO: use proper action + action: &NoAction, + }, + SectionEntry { + icon: IconName::Blocks, + title: "Explore Extensions", + action: &Extensions { + category_filter: None, + id: None, + }, + }, + ], + }, +); + +struct Section { + title: &'static str, + entries: [SectionEntry; COLS], +} + +impl Section { + fn render( + self, + index_offset: usize, + focus: &FocusHandle, + window: &mut Window, + cx: &mut App, + ) -> impl IntoElement { + v_flex() + .min_w_full() + .gap_2() + .child( + h_flex() + .px_1() + .gap_4() + .child( + Label::new(self.title.to_ascii_uppercase()) + .buffer_font(cx) + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + .child(Divider::horizontal().color(DividerColor::Border)), + ) + .children( + self.entries + .iter() + .enumerate() + .map(|(index, entry)| entry.render(index_offset + index, &focus, window, cx)), + ) + } +} + +struct SectionEntry { + icon: IconName, + title: &'static str, + action: &'static dyn Action, +} + +impl SectionEntry { + fn render( + &self, + button_index: usize, + focus: &FocusHandle, + window: &Window, + cx: &App, + ) -> impl IntoElement { + ButtonLike::new(("onboarding-button-id", button_index)) + .full_width() + .child( + h_flex() + .w_full() + .gap_1() + .justify_between() + .child( + h_flex() + .gap_2() + .child( + Icon::new(self.icon) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .child(Label::new(self.title)), + ) + .children(KeyBinding::for_action_in(self.action, focus, window, cx)), + ) + .on_click(|_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx)) + } +} + +pub struct WelcomePage { + focus_handle: FocusHandle, +} + +impl Render for WelcomePage { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let (first_section, second_entries) = CONTENT; + let first_section_entries = first_section.entries.len(); + + h_flex() + .size_full() + .justify_center() + .overflow_hidden() + .bg(cx.theme().colors().editor_background) + .key_context("Welcome") + .track_focus(&self.focus_handle(cx)) + .child( + h_flex() + .px_12() + .py_40() + .size_full() + .relative() + .max_w(px(1100.)) + .child( + div() + .size_full() + .max_w_128() + .mx_auto() + .child( + h_flex() + .w_full() + .justify_center() + .gap_4() + .child(Vector::square(VectorName::ZedLogo, rems(2.))) + .child( + div().child(Headline::new("Welcome to Zed")).child( + Label::new("The editor for what's next") + .size(LabelSize::Small) + .color(Color::Muted) + .italic(), + ), + ), + ) + .child( + v_flex() + .mt_12() + .gap_8() + .child(first_section.render( + Default::default(), + &self.focus_handle, + window, + cx, + )) + .child(second_entries.render( + first_section_entries, + &self.focus_handle, + window, + cx, + )) + .child( + h_flex() + .w_full() + .pt_4() + .justify_center() + // We call this a hack + .rounded_b_xs() + .border_t_1() + .border_color(DividerColor::Border.hsla(cx)) + .border_dashed() + .child( + div().child( + Button::new("welcome-exit", "Return to Setup") + .full_width() + .label_size(LabelSize::XSmall), + ), + ), + ), + ), + ), + ) + } +} + +impl WelcomePage { + pub fn new(cx: &mut Context) -> Entity { + let this = cx.new(|cx| WelcomePage { + focus_handle: cx.focus_handle(), + }); + + this + } +} + +impl EventEmitter for WelcomePage {} + +impl Focusable for WelcomePage { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for WelcomePage { + type Event = ItemEvent; + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Welcome".into() + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("New Welcome Page Opened") + } + + fn show_toolbar(&self) -> bool { + false + } + + fn clone_on_split( + &self, + _workspace_id: Option, + _: &mut Window, + _: &mut Context, + ) -> Option> { + None + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { + f(*event) + } +} diff --git a/crates/project/src/debugger/dap_command.rs b/crates/project/src/debugger/dap_command.rs index 1cb611680c34342db9645dab44240b73df13cc56..3be3192369452b58fd2382471ca2f41f4aeac75f 100644 --- a/crates/project/src/debugger/dap_command.rs +++ b/crates/project/src/debugger/dap_command.rs @@ -107,7 +107,7 @@ impl DapCommand for Arc { #[derive(Debug, Hash, PartialEq, Eq)] pub struct StepCommand { - pub thread_id: u64, + pub thread_id: i64, pub granularity: Option, pub single_thread: Option, } @@ -483,7 +483,7 @@ impl DapCommand for ContinueCommand { #[derive(Debug, Hash, PartialEq, Eq)] pub(crate) struct PauseCommand { - pub thread_id: u64, + pub thread_id: i64, } impl LocalDapCommand for PauseCommand { @@ -612,7 +612,7 @@ impl DapCommand for DisconnectCommand { #[derive(Debug, Hash, PartialEq, Eq)] pub(crate) struct TerminateThreadsCommand { - pub thread_ids: Option>, + pub thread_ids: Option>, } impl LocalDapCommand for TerminateThreadsCommand { @@ -1182,7 +1182,7 @@ impl DapCommand for LoadedSourcesCommand { #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub(crate) struct StackTraceCommand { - pub thread_id: u64, + pub thread_id: i64, pub start_frame: Option, pub levels: Option, } diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs index d494088b1379dc3820260ae99a98582cc24db319..6f834b5dc0cfd3fc6357d92403bdb7cbfefdd4b0 100644 --- a/crates/project/src/debugger/dap_store.rs +++ b/crates/project/src/debugger/dap_store.rs @@ -920,12 +920,22 @@ impl dap::adapters::DapDelegate for DapAdapterDelegate { self.console.unbounded_send(msg).ok(); } + #[cfg(not(target_os = "windows"))] async fn which(&self, command: &OsStr) -> Option { let worktree_abs_path = self.worktree.abs_path(); let shell_path = self.shell_env().await.get("PATH").cloned(); which::which_in(command, shell_path.as_ref(), worktree_abs_path).ok() } + #[cfg(target_os = "windows")] + async fn which(&self, command: &OsStr) -> Option { + // On Windows, `PATH` is handled differently from Unix. Windows generally expects users to modify the `PATH` themselves, + // and every program loads it directly from the system at startup. + // There's also no concept of a default shell on Windows, and you can't really retrieve one, so trying to get shell environment variables + // from a specific directory doesn’t make sense on Windows. + which::which(command).ok() + } + async fn shell_env(&self) -> HashMap { let task = self.load_shell_env_task.clone(); task.await.unwrap_or_default() diff --git a/crates/project/src/debugger/locators/cargo.rs b/crates/project/src/debugger/locators/cargo.rs index 7d70371380192c99e1ace9676b02088f86ed9e5f..fa265dae586148f9c8efe14187ee26c805c65e42 100644 --- a/crates/project/src/debugger/locators/cargo.rs +++ b/crates/project/src/debugger/locators/cargo.rs @@ -128,7 +128,7 @@ impl DapLocator for CargoLocator { .chain(Some("--message-format=json".to_owned())) .collect(), ); - let mut child = Command::new(program) + let mut child = util::command::new_smol_command(program) .args(args) .envs(build_config.env.iter().map(|(k, v)| (k.clone(), v.clone()))) .current_dir(cwd) diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index 1e296ac2ac9b87a9fae4c0aaa8ae9fb474f64eb2..f60a7becf78f51d335b5cb60a624e898a5cdb09b 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -61,15 +61,10 @@ use worktree::Worktree; #[derive(Debug, Copy, Clone, Hash, PartialEq, PartialOrd, Ord, Eq)] #[repr(transparent)] -pub struct ThreadId(pub u64); +pub struct ThreadId(pub i64); -impl ThreadId { - pub const MIN: ThreadId = ThreadId(u64::MIN); - pub const MAX: ThreadId = ThreadId(u64::MAX); -} - -impl From for ThreadId { - fn from(id: u64) -> Self { +impl From for ThreadId { + fn from(id: i64) -> Self { Self(id) } } diff --git a/crates/proto/proto/debugger.proto b/crates/proto/proto/debugger.proto index 09abd4bf1c1aa73e89d77c55ade1bce21f0027d4..c6f9c9f1342336c36ab8dfd0ec70a24ff6564476 100644 --- a/crates/proto/proto/debugger.proto +++ b/crates/proto/proto/debugger.proto @@ -188,7 +188,7 @@ message DapSetVariableValueResponse { message DapPauseRequest { uint64 project_id = 1; uint64 client_id = 2; - uint64 thread_id = 3; + int64 thread_id = 3; } message DapDisconnectRequest { @@ -202,7 +202,7 @@ message DapDisconnectRequest { message DapTerminateThreadsRequest { uint64 project_id = 1; uint64 client_id = 2; - repeated uint64 thread_ids = 3; + repeated int64 thread_ids = 3; } message DapThreadsRequest { @@ -246,7 +246,7 @@ message IgnoreBreakpointState { message DapNextRequest { uint64 project_id = 1; uint64 client_id = 2; - uint64 thread_id = 3; + int64 thread_id = 3; optional bool single_thread = 4; optional SteppingGranularity granularity = 5; } @@ -254,7 +254,7 @@ message DapNextRequest { message DapStepInRequest { uint64 project_id = 1; uint64 client_id = 2; - uint64 thread_id = 3; + int64 thread_id = 3; optional uint64 target_id = 4; optional bool single_thread = 5; optional SteppingGranularity granularity = 6; @@ -263,7 +263,7 @@ message DapStepInRequest { message DapStepOutRequest { uint64 project_id = 1; uint64 client_id = 2; - uint64 thread_id = 3; + int64 thread_id = 3; optional bool single_thread = 4; optional SteppingGranularity granularity = 5; } @@ -271,7 +271,7 @@ message DapStepOutRequest { message DapStepBackRequest { uint64 project_id = 1; uint64 client_id = 2; - uint64 thread_id = 3; + int64 thread_id = 3; optional bool single_thread = 4; optional SteppingGranularity granularity = 5; } @@ -279,7 +279,7 @@ message DapStepBackRequest { message DapContinueRequest { uint64 project_id = 1; uint64 client_id = 2; - uint64 thread_id = 3; + int64 thread_id = 3; optional bool single_thread = 4; } @@ -311,7 +311,7 @@ message DapLoadedSourcesResponse { message DapStackTraceRequest { uint64 project_id = 1; uint64 client_id = 2; - uint64 thread_id = 3; + int64 thread_id = 3; optional uint64 start_frame = 4; optional uint64 stack_trace_levels = 5; } @@ -358,7 +358,7 @@ message DapVariable { } message DapThread { - uint64 id = 1; + int64 id = 1; string name = 2; } diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index a0cbdb9680b59f5faa8c8e5c33399762b9f286b4..5ff91246f4de52fa1057c7aef598d88b6b1ed306 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -566,24 +566,40 @@ impl KeymapEditor { && query.modifiers == keystroke.modifiers }, ) + } else if keystroke_query.len() > keystrokes.len() { + return false; } else { - let key_press_query = - KeyPressIterator::new(keystroke_query.as_slice()); - let mut last_match_idx = 0; - - key_press_query.into_iter().all(|key| { - let key_presses = KeyPressIterator::new(keystrokes); - key_presses.into_iter().enumerate().any( - |(index, keystroke)| { - if last_match_idx > index || keystroke != key { - return false; - } + for keystroke_offset in 0..keystrokes.len() { + let mut found_count = 0; + let mut query_cursor = 0; + let mut keystroke_cursor = keystroke_offset; + while query_cursor < keystroke_query.len() + && keystroke_cursor < keystrokes.len() + { + 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() + .map_or(true, |q_kc| { + q_kc == &keystroke.key + })); + if matches { + found_count += 1; + query_cursor += 1; + } + keystroke_cursor += 1; + } - last_match_idx = index; - true - }, - ) - }) + if found_count == keystroke_query.len() { + return true; + } + } + return false; } }) }); @@ -1232,11 +1248,14 @@ impl KeymapEditor { match self.search_mode { SearchMode::KeyStroke { .. } => { - window.focus(&self.keystroke_editor.read(cx).recording_focus_handle(cx)); + self.keystroke_editor.update(cx, |editor, cx| { + editor.start_recording(&StartRecording, window, cx); + }); } SearchMode::Normal => { self.keystroke_editor.update(cx, |editor, cx| { - editor.clear_keystrokes(&ClearKeystrokes, window, cx) + editor.stop_recording(&StopRecording, window, cx); + editor.clear_keystrokes(&ClearKeystrokes, window, cx); }); window.focus(&self.filter_editor.focus_handle(cx)); } @@ -1671,7 +1690,7 @@ impl Render for KeymapEditor { move |window, cx| this.read(cx).render_no_matches_hint(window, cx) }) .column_widths([ - DefiniteLength::Absolute(AbsoluteLength::Pixels(px(40.))), + DefiniteLength::Absolute(AbsoluteLength::Pixels(px(36.))), DefiniteLength::Fraction(0.25), DefiniteLength::Fraction(0.20), DefiniteLength::Fraction(0.14), @@ -1746,6 +1765,7 @@ impl Render for KeymapEditor { }, ) .into_any_element(); + let keystrokes = binding.ui_key_binding().cloned().map_or( binding .keystroke_text() @@ -1754,6 +1774,7 @@ impl Render for KeymapEditor { .into_any_element(), IntoElement::into_any_element, ); + let action_arguments = match binding.action().arguments.clone() { Some(arguments) => arguments.into_any_element(), @@ -1766,6 +1787,7 @@ impl Render for KeymapEditor { } } }; + let context = binding.context().cloned().map_or( gpui::Empty.into_any_element(), |context| { @@ -1790,11 +1812,13 @@ impl Render for KeymapEditor { .into_any_element() }, ); + let source = binding .keybind_source() .map(|source| source.name()) .unwrap_or_default() .into_any_element(); + Some([ icon.into_any_element(), action, @@ -2962,16 +2986,6 @@ enum CloseKeystrokeResult { None, } -#[derive(PartialEq, Eq, Debug, Clone)] -enum KeyPress<'a> { - Alt, - Control, - Function, - Shift, - Platform, - Key(&'a String), -} - struct KeystrokeInput { keystrokes: Vec, placeholder_keystrokes: Option>, @@ -2983,6 +2997,7 @@ struct KeystrokeInput { /// Handles tripe escape to stop recording close_keystrokes: Option>, close_keystrokes_start: Option, + previous_modifiers: Modifiers, } impl KeystrokeInput { @@ -3009,6 +3024,7 @@ impl KeystrokeInput { search: false, close_keystrokes: None, close_keystrokes_start: None, + previous_modifiers: Modifiers::default(), } } @@ -3031,7 +3047,7 @@ impl KeystrokeInput { } fn key_context() -> KeyContext { - let mut key_context = KeyContext::new_with_defaults(); + let mut key_context = KeyContext::default(); key_context.add("KeystrokeInput"); key_context } @@ -3098,12 +3114,26 @@ impl KeystrokeInput { ) { let keystrokes_len = self.keystrokes.len(); + if self.previous_modifiers.modified() + && event.modifiers.is_subset_of(&self.previous_modifiers) + { + self.previous_modifiers &= event.modifiers; + cx.stop_propagation(); + return; + } + if let Some(last) = self.keystrokes.last_mut() && last.key.is_empty() && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX { if self.search { - last.modifiers = last.modifiers.xor(&event.modifiers); + if self.previous_modifiers.modified() { + last.modifiers |= event.modifiers; + self.previous_modifiers |= event.modifiers; + } else { + self.keystrokes.push(Self::dummy(event.modifiers)); + self.previous_modifiers |= event.modifiers; + } } else if !event.modifiers.modified() { self.keystrokes.pop(); } else { @@ -3113,6 +3143,9 @@ impl KeystrokeInput { self.keystrokes_changed(cx); } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX { self.keystrokes.push(Self::dummy(event.modifiers)); + if self.search { + self.previous_modifiers |= event.modifiers; + } self.keystrokes_changed(cx); } cx.stop_propagation(); @@ -3138,6 +3171,9 @@ impl KeystrokeInput { { self.close_keystrokes_start = Some(self.keystrokes.len() - 1); } + if self.search { + self.previous_modifiers = keystroke.modifiers; + } self.keystrokes_changed(cx); cx.stop_propagation(); return; @@ -3152,7 +3188,9 @@ impl KeystrokeInput { self.close_keystrokes_start = Some(self.keystrokes.len()); } self.keystrokes.push(keystroke.clone()); - if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { + if self.search { + self.previous_modifiers = keystroke.modifiers; + } else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { self.keystrokes.push(Self::dummy(keystroke.modifiers)); } } else if close_keystroke_result != CloseKeystrokeResult::Partial { @@ -3222,17 +3260,11 @@ impl KeystrokeInput { }) } - fn recording_focus_handle(&self, _cx: &App) -> FocusHandle { - self.inner_focus_handle.clone() - } - fn start_recording(&mut self, _: &StartRecording, window: &mut Window, cx: &mut Context) { - if !self.outer_focus_handle.is_focused(window) { - return; - } - self.clear_keystrokes(&ClearKeystrokes, window, cx); window.focus(&self.inner_focus_handle); - cx.notify(); + self.clear_keystrokes(&ClearKeystrokes, window, cx); + self.previous_modifiers = window.modifiers(); + cx.stop_propagation(); } fn stop_recording(&mut self, _: &StopRecording, window: &mut Window, cx: &mut Context) { @@ -3364,7 +3396,7 @@ impl Render for KeystrokeInput { }) .key_context(Self::key_context()) .on_action(cx.listener(Self::start_recording)) - .on_action(cx.listener(Self::stop_recording)) + .on_action(cx.listener(Self::clear_keystrokes)) .child( h_flex() .w(horizontal_padding) @@ -3633,72 +3665,3 @@ mod persistence { } } } - -/// Iterator that yields KeyPress values from a slice of Keystrokes -struct KeyPressIterator<'a> { - keystrokes: &'a [Keystroke], - current_keystroke_index: usize, - current_key_press_index: usize, -} - -impl<'a> KeyPressIterator<'a> { - fn new(keystrokes: &'a [Keystroke]) -> Self { - Self { - keystrokes, - current_keystroke_index: 0, - current_key_press_index: 0, - } - } -} - -impl<'a> Iterator for KeyPressIterator<'a> { - type Item = KeyPress<'a>; - - fn next(&mut self) -> Option { - loop { - let keystroke = self.keystrokes.get(self.current_keystroke_index)?; - - match self.current_key_press_index { - 0 => { - self.current_key_press_index = 1; - if keystroke.modifiers.platform { - return Some(KeyPress::Platform); - } - } - 1 => { - self.current_key_press_index = 2; - if keystroke.modifiers.alt { - return Some(KeyPress::Alt); - } - } - 2 => { - self.current_key_press_index = 3; - if keystroke.modifiers.control { - return Some(KeyPress::Control); - } - } - 3 => { - self.current_key_press_index = 4; - if keystroke.modifiers.shift { - return Some(KeyPress::Shift); - } - } - 4 => { - self.current_key_press_index = 5; - if keystroke.modifiers.function { - return Some(KeyPress::Function); - } - } - _ => { - self.current_keystroke_index += 1; - self.current_key_press_index = 0; - - if keystroke.key.is_empty() { - continue; - } - return Some(KeyPress::Key(&keystroke.key)); - } - } - } - } -} diff --git a/crates/settings_ui/src/ui_components/table.rs b/crates/settings_ui/src/ui_components/table.rs index 65778c20eb4bc60f22dd15b634de433dc781cd97..3c9992bd68edcd463d329caa9132391b0056b0d6 100644 --- a/crates/settings_ui/src/ui_components/table.rs +++ b/crates/settings_ui/src/ui_components/table.rs @@ -17,7 +17,7 @@ use ui::{ StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex, }; -const RESIZE_COLUMN_WIDTH: f32 = 5.0; +const RESIZE_COLUMN_WIDTH: f32 = 8.0; #[derive(Debug)] struct DraggedColumn(usize); @@ -214,6 +214,7 @@ impl TableInteractionState { let mut column_ix = 0; let resizable_columns_slice = *resizable_columns; let mut resizable_columns = resizable_columns.into_iter(); + let dividers = intersperse_with(spacers, || { window.with_id(column_ix, |window| { let mut resize_divider = div() @@ -221,9 +222,9 @@ impl TableInteractionState { .id(column_ix) .relative() .top_0() - .w_0p5() + .w_px() .h_full() - .bg(cx.theme().colors().border.opacity(0.5)); + .bg(cx.theme().colors().border.opacity(0.8)); let mut resize_handle = div() .id("column-resize-handle") @@ -237,9 +238,11 @@ impl TableInteractionState { .is_some_and(ResizeBehavior::is_resizable) { let hovered = window.use_state(cx, |_window, _cx| false); + resize_divider = resize_divider.when(*hovered.read(cx), |div| { div.bg(cx.theme().colors().border_focused) }); + resize_handle = resize_handle .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered)) .cursor_col_resize() @@ -269,12 +272,11 @@ impl TableInteractionState { }) }); - div() + h_flex() .id("resize-handles") - .h_flex() .absolute() - .w_full() .inset_0() + .w_full() .children(dividers) .into_any_element() } @@ -896,7 +898,6 @@ fn base_cell_style(width: Option) -> Div { .px_1p5() .when_some(width, |this, width| this.w(width)) .when(width.is_none(), |this| this.flex_1()) - .justify_start() .whitespace_nowrap() .text_ellipsis() .overflow_hidden() @@ -941,7 +942,7 @@ pub fn render_row( .map(IntoElement::into_any_element) .into_iter() .zip(column_widths) - .map(|(cell, width)| base_cell_style_text(width, cx).px_1p5().py_1().child(cell)), + .map(|(cell, width)| base_cell_style_text(width, cx).px_1().py_0p5().child(cell)), ); let row = if let Some(map_row) = table_context.map_row { @@ -950,7 +951,7 @@ pub fn render_row( row.into_any_element() }; - div().h_full().w_full().child(row).into_any_element() + div().size_full().child(row).into_any_element() } pub fn render_header( diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index 056c981ccf16a1e56fcaff99c32b4a284553a706..d026b4de14263f442c1ede308da0fe467fe69bba 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -11,8 +11,8 @@ use gpui::{App, Task, Window, actions}; use rpc::proto::{self}; use theme::ActiveTheme; use ui::{ - Avatar, AvatarAudioStatusIndicator, ContextMenu, ContextMenuItem, Divider, Facepile, - PopoverMenu, SplitButton, SplitButtonStyle, TintColor, Tooltip, prelude::*, + Avatar, AvatarAudioStatusIndicator, ContextMenu, ContextMenuItem, Divider, DividerColor, + Facepile, PopoverMenu, SplitButton, SplitButtonStyle, TintColor, Tooltip, prelude::*, }; use util::maybe; use workspace::notifications::DetachAndPromptErr; @@ -343,6 +343,24 @@ impl TitleBar { let mut children = Vec::new(); + children.push( + h_flex() + .gap_1() + .child( + IconButton::new("leave-call", IconName::Exit) + .style(ButtonStyle::Subtle) + .tooltip(Tooltip::text("Leave Call")) + .icon_size(IconSize::Small) + .on_click(move |_, _window, cx| { + ActiveCall::global(cx) + .update(cx, |call, cx| call.hang_up(cx)) + .detach_and_log_err(cx); + }), + ) + .child(Divider::vertical().color(DividerColor::Border)) + .into_any_element(), + ); + if is_local && can_share_projects && !is_connecting_to_project { children.push( Button::new( @@ -369,32 +387,14 @@ impl TitleBar { ); } - children.push( - div() - .pr_2() - .child( - IconButton::new("leave-call", ui::IconName::Exit) - .style(ButtonStyle::Subtle) - .tooltip(Tooltip::text("Leave call")) - .icon_size(IconSize::Small) - .on_click(move |_, _window, cx| { - ActiveCall::global(cx) - .update(cx, |call, cx| call.hang_up(cx)) - .detach_and_log_err(cx); - }), - ) - .child(Divider::vertical()) - .into_any_element(), - ); - if can_use_microphone { children.push( IconButton::new( "mute-microphone", if is_muted { - ui::IconName::MicMute + IconName::MicMute } else { - ui::IconName::Mic + IconName::Mic }, ) .tooltip(move |window, cx| { @@ -429,9 +429,9 @@ impl TitleBar { IconButton::new( "mute-sound", if is_deafened { - ui::IconName::AudioOff + IconName::AudioOff } else { - ui::IconName::AudioOn + IconName::AudioOn }, ) .style(ButtonStyle::Subtle) @@ -462,7 +462,7 @@ impl TitleBar { ); if can_use_microphone && screen_sharing_supported { - let trigger = IconButton::new("screen-share", ui::IconName::Screen) + let trigger = IconButton::new("screen-share", IconName::Screen) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) .toggle_state(is_screen_sharing) @@ -498,7 +498,7 @@ impl TitleBar { trigger.render(window, cx), self.render_screen_list().into_any_element(), ) - .style(SplitButtonStyle::Outlined) + .style(SplitButtonStyle::Transparent) .into_any_element(), ); } @@ -513,11 +513,11 @@ impl TitleBar { .with_handle(self.screen_share_popover_handle.clone()) .trigger( ui::ButtonLike::new_rounded_right("screen-share-screen-list-trigger") - .layer(ui::ElevationIndex::ModalSurface) - .size(ui::ButtonSize::None) .child( - div() - .px_1() + h_flex() + .mx_neg_0p5() + .h_full() + .justify_center() .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)), ) .toggle_state(self.screen_share_popover_handle.is_deployed()), diff --git a/crates/ui/src/components/button/split_button.rs b/crates/ui/src/components/button/split_button.rs index a7fa2106d127662c4f1498e41372780065649e56..14b9fd153cd5ad662467c75ff81700587667cee3 100644 --- a/crates/ui/src/components/button/split_button.rs +++ b/crates/ui/src/components/button/split_button.rs @@ -12,6 +12,7 @@ use super::ButtonLike; pub enum SplitButtonStyle { Filled, Outlined, + Transparent, } /// /// A button with two parts: a primary action on the left and a secondary action on the right. @@ -44,10 +45,17 @@ impl SplitButton { impl RenderOnce for SplitButton { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let is_filled_or_outlined = matches!( + self.style, + SplitButtonStyle::Filled | SplitButtonStyle::Outlined + ); + h_flex() .rounded_sm() - .border_1() - .border_color(cx.theme().colors().border.opacity(0.5)) + .when(is_filled_or_outlined, |this| { + this.border_1() + .border_color(cx.theme().colors().border.opacity(0.8)) + }) .child(div().flex_grow().child(self.left)) .child( div() diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 1d91492f26c7e9e93a761a1d9d46b06300ba3614..5779093ccc63845a8f8b5c151680b584f2b201bd 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -44,7 +44,7 @@ impl KeyBinding { pub fn for_action_in( action: &dyn Action, focus: &FocusHandle, - window: &mut Window, + window: &Window, cx: &App, ) -> Option { let key_binding = window.highest_precedence_binding_for_action_in(action, focus)?; diff --git a/crates/ui/src/components/popover.rs b/crates/ui/src/components/popover.rs index 24460f6d9ce8e28b80c1e345007a88e7ee21a7a9..7143514c5269baf6dba2802f96e59ec0f8634317 100644 --- a/crates/ui/src/components/popover.rs +++ b/crates/ui/src/components/popover.rs @@ -50,7 +50,7 @@ impl RenderOnce for Popover { v_flex() .elevation_2(cx) .py(POPOVER_Y_PADDING / 2.) - .children(self.children), + .child(div().children(self.children)), ) .when_some(self.aside, |this, aside| { this.child( diff --git a/crates/util/src/shell_env.rs b/crates/util/src/shell_env.rs index d737999e4569cad51466f77d234b48def81c95ef..2b1063316fa1d08ba3fa6e4c945b30175ff2cfdc 100644 --- a/crates/util/src/shell_env.rs +++ b/crates/util/src/shell_env.rs @@ -30,6 +30,7 @@ pub fn capture(directory: &std::path::Path) -> Result { // For csh/tcsh, login shell requires passing `-` as 0th argument (instead of `-l`) @@ -40,13 +41,20 @@ pub fn capture(directory: &std::path::Path) -> Result { + // nu needs special handling for -- options. + command_prefix = String::from("^"); + } _ => { command.arg("-l"); } } // cd into the directory, triggering directory specific side-effects (asdf, direnv, etc) command_string.push_str(&format!("cd '{}';", directory.display())); - command_string.push_str(&format!("{} --printenv {}", zed_path, redir)); + command_string.push_str(&format!( + "{}{} --printenv {}", + command_prefix, zed_path, redir + )); command.args(["-i", "-c", &command_string]); super::set_pre_exec_to_start_new_session(&mut command); diff --git a/crates/workspace/src/tasks.rs b/crates/workspace/src/tasks.rs index 26edbd8d03ed37d4bddca65f0a94cc9413760dd9..32d066c7eb74f9019348d3bcac9402ebb7216a4e 100644 --- a/crates/workspace/src/tasks.rs +++ b/crates/workspace/src/tasks.rs @@ -73,7 +73,7 @@ impl Workspace { if let Some(terminal_provider) = self.terminal_provider.as_ref() { let task_status = terminal_provider.spawn(spawn_in_terminal, window, cx); - cx.background_spawn(async move { + let task = cx.background_spawn(async move { match task_status.await { Some(Ok(status)) => { if status.success() { @@ -82,11 +82,11 @@ impl Workspace { log::debug!("Task spawn failed, code: {:?}", status.code()); } } - Some(Err(e)) => log::error!("Task spawn failed: {e}"), + Some(Err(e)) => log::error!("Task spawn failed: {e:#}"), None => log::debug!("Task spawn got cancelled"), } - }) - .detach(); + }); + self.scheduled_tasks.push(task); } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0ee8177dd87c396e4b09a1b11d119034a6ef548d..77d76b44f566832fed2e15c83413c63cad29e058 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1104,6 +1104,7 @@ pub struct Workspace { serialized_ssh_project: Option, _items_serializer: Task>, session_id: Option, + scheduled_tasks: Vec>, } impl EventEmitter for Workspace {} @@ -1435,6 +1436,7 @@ impl Workspace { _items_serializer, session_id: Some(session_id), serialized_ssh_project: None, + scheduled_tasks: Vec::new(), } } diff --git a/crates/zed/resources/app-icon-nightly.png b/crates/zed/resources/app-icon-nightly.png index 6c5241f207252641c6e11dd62170156b4888cdfd..776cd06b1bca36c74257dafbc4bffebbbc8f55ad 100644 Binary files a/crates/zed/resources/app-icon-nightly.png and b/crates/zed/resources/app-icon-nightly.png differ diff --git a/crates/zed/resources/app-icon-nightly@2x.png b/crates/zed/resources/app-icon-nightly@2x.png index e31eeb74f213e9aae7f349f9fea92f44ee0fb08b..6d781594ac658d32e5fcff01f66543f7f4f70d93 100644 Binary files a/crates/zed/resources/app-icon-nightly@2x.png and b/crates/zed/resources/app-icon-nightly@2x.png differ diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 0a90f89fa41d1ea087bfb6e24e010dbe81e90705..c72fe39d2d0eb65c7332dd52ce1db8ab34b00efe 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -126,17 +126,28 @@ pub fn init(cx: &mut App) { cx.on_action(quit); cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx)); - if ReleaseChannel::global(cx) == ReleaseChannel::Dev || cx.has_flag::() { - cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")); - cx.on_action(|_: &TestCrash, _| { - unsafe extern "C" { - fn puts(s: *const i8); - } - unsafe { - puts(0xabad1d3a as *const i8); - } - }); - } + let flag = cx.wait_for_flag::(); + cx.spawn(async |cx| { + if cx + .update(|cx| ReleaseChannel::global(cx) == ReleaseChannel::Dev) + .unwrap_or_default() + || flag.await + { + cx.update(|cx| { + cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")); + cx.on_action(|_: &TestCrash, _| { + unsafe extern "C" { + fn puts(s: *const i8); + } + unsafe { + puts(0xabad1d3a as *const i8); + } + }); + }) + .ok(); + }; + }) + .detach(); cx.on_action(|_: &OpenLog, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_log_file(workspace, window, cx); diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md index 60702e42fc6d8cbae749acecf66497293b315b00..315ae21929b26b9b2f3f24a4b46072b691fce2a5 100644 --- a/docs/src/ai/agent-settings.md +++ b/docs/src/ai/agent-settings.md @@ -6,13 +6,12 @@ Learn about all the settings you can customize in Zed's Agent Panel. ### Default Model {#default-model} -If you're using Zed's hosted LLM service, it sets `claude-sonnet-4` as the default model. -But if you're not subscribed to the hosted service or simply just want to change it, you can do it so either via the model dropdown in the Agent Panel's bottom-right corner or by manually editing the `default_model` object in your settings: +If you're using [Zed's hosted LLM service](./plans-and-usage.md), it sets `claude-sonnet-4` as the default model. +But if you're not subscribed to it or simply just want to change it, you can do it so either via the model dropdown in the Agent Panel's bottom-right corner or by manually editing the `default_model` object in your settings: ```json { "agent": { - "version": "2", "default_model": { "provider": "zed.dev", "model": "gpt-4o" @@ -32,7 +31,6 @@ Assign distinct and specific models for the following AI-powered features in Zed ```json { "agent": { - "version": "2", "default_model": { "provider": "zed.dev", "model": "claude-sonnet-4" @@ -53,7 +51,7 @@ Assign distinct and specific models for the following AI-powered features in Zed } ``` -> If a model isn't set for one of these features, they automatically fall back to using the default model. +> If a custom model isn't set for one of these features, they automatically fall back to using the default model. ### Alternative Models for Inline Assists {#alternative-assists} @@ -128,6 +126,7 @@ You can choose between `thread` (the default) and `text_thread`: ### Auto-run Commands Control whether you want to allow the agent to run commands without asking you for permission. +The default value is `false`. ```json { @@ -142,6 +141,7 @@ Control whether you want to allow the agent to run commands without asking you f ### Single-file Review Control whether you want to see review actions (accept & reject) in single buffers after the agent is done performing edits. +The default value is `false`. ```json { @@ -158,6 +158,7 @@ When set to false, these controls are only available in the multibuffer review t ### Sound Notification Control whether you want to hear a notification sound when the agent is done generating changes or needs your input. +The default value is `false`. ```json { @@ -173,6 +174,7 @@ Control whether you want to hear a notification sound when the agent is done gen Make a modifier (`cmd` on macOS, `ctrl` on Linux) required to send messages. This is encouraged for more thoughtful prompt crafting. +The default value is `false`. ```json { @@ -213,6 +215,7 @@ It is set to `true` by default, but if set to false, the card will be fully coll ### Feedback Controls Control whether you want to see the thumbs up/down buttons to give Zed feedback about the agent's performance. +The default value is `true`. ```json { diff --git a/docs/src/ai/billing.md b/docs/src/ai/billing.md index e8587e1fefa1268f16731cbc2e9b416980553b4a..d519b136aeea8c505979cde224d406ac995b65f0 100644 --- a/docs/src/ai/billing.md +++ b/docs/src/ai/billing.md @@ -1,7 +1,7 @@ # Billing We use Stripe as our billing and payments provider. All Pro plans require payment via credit card. -For invoice-based billing, a Business plan is required. Contact sales@zed.dev for more information. +For invoice-based billing, a Business plan is required. Contact [sales@zed.dev](mailto:sales@zed.dev) for more information. ## Settings {#settings} @@ -12,7 +12,8 @@ Clicking the button under Account Settings will navigate you to Stripe’s secur Zed is billed on a monthly basis based on the date you initially subscribe. -We’ll also bill in-month for additional prompts used beyond your plan’s prompt limit, if usage exceeds $20 before month end. See [usage-based pricing](./plans-and-usage.md#ubp) for more. +We’ll also bill in-month for additional prompts used beyond your plan’s prompt limit, if usage exceeds $20 before month end. +See [usage-based pricing](./plans-and-usage.md#ubp) for more. ## Invoice History {#invoice-history} @@ -33,4 +34,4 @@ Zed partners with [Sphere](https://www.getsphere.com/) to calculate indirect tax If you have a VAT/GST ID, you can add it at [zed.dev/account](https://zed.dev/account) by clicking "Manage" on your subscription. Check the box that denotes you as a business. Please note that changes to VAT/GST IDs and address will **only** affect future invoices — **we cannot modify historical invoices**. -Questions or issues can be directed to billing-support@zed.dev. +Questions or issues can be directed to [billing-support@zed.dev](mailto:billing-support@zed.dev). diff --git a/docs/src/ai/configuration.md b/docs/src/ai/configuration.md index 13a01217121638e4819edd6a5f31cd97e341b738..d28a7e8ed006b1c788cc0f649362bae41879a99b 100644 --- a/docs/src/ai/configuration.md +++ b/docs/src/ai/configuration.md @@ -6,7 +6,7 @@ When using AI in Zed, you can customize several aspects: 2. [Model parameters and usage](./agent-settings.md#model-settings) 3. [Interactions with the Agent Panel](./agent-settings.md#agent-panel-settings) -## Turning AI off entirely +## Turning AI Off Entirely We want to respect users who want to use Zed without interacting with AI whatsoever. To do that, add the following key to your `settings.json`: diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 227bb239839217ae7769a2521840da65691d497b..cb55c1c94e9495db6590f7cbb36fc822629497a0 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -2,13 +2,13 @@ To use AI in Zed, you need to have at least one large language model provider set up. -You can do that by either subscribing to [one of Zed's plans](./subscription.md), or by using API keys you already have for the supported providers. +You can do that by either subscribing to [one of Zed's plans](./plans-and-usage.md), or by using API keys you already have for the supported providers. ## Use Your Own Keys {#use-your-own-keys} If you already have an API key for an existing LLM provider—say Anthropic or OpenAI, for example—you can insert them in Zed and use the Agent Panel **_for free_**. -You can add your API key to a given provider either via the Agent Panel's settings UI or the `settings.json` directly, through the `language_models` key. +You can add your API key to a given provider either via the Agent Panel's settings UI or directly via the `settings.json` through the `language_models` key. ## Supported Providers @@ -25,7 +25,7 @@ Here's all the supported LLM providers for which you can use your own API keys: | [Mistral](#mistral) | ✅ | | [Ollama](#ollama) | ✅ | | [OpenAI](#openai) | ✅ | -| [OpenAI API Compatible](#openai-api-compatible) | 🚫 | +| [OpenAI API Compatible](#openai-api-compatible) | ✅ | | [OpenRouter](#openrouter) | ✅ | | [Vercel](#vercel-v0) | ✅ | | [xAI](#xai) | ✅ | diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md index 95929b2d7e259ad2f6192c260f2e717ddf8b51ba..5aef3d3d72d79c9a1be2dd8152991e41f38d11ed 100644 --- a/docs/src/ai/mcp.md +++ b/docs/src/ai/mcp.md @@ -75,7 +75,7 @@ Mentioning your MCP server by name helps the agent pick it up. If you want to ensure a given server will be used, you can create [a custom profile](./agent-panel.md#custom-profiles) by turning off the built-in tools (either all of them or the ones that would cause conflicts) and turning on only the tools coming from the MCP server. -As an example, [the Dagger team suggests](https://container-use.com/agent-integrations#add-container-use-agent-profile-optional) doing that with their [Container Use MCP server](https://zed.dev/extensions/container-use-mcp-server): +As an example, [the Dagger team suggests](https://container-use.com/agent-integrations#add-container-use-agent-profile-optional) doing that with their [Container Use MCP server](https://zed.dev/extensions/mcp-server-container-use): ```json "agent": { diff --git a/docs/src/ai/overview.md b/docs/src/ai/overview.md index ff3dd84fce0c1412ae33d00f2f821d2a9bcfd867..6f081cb243ffcfb77b4304373df67865cc71ee10 100644 --- a/docs/src/ai/overview.md +++ b/docs/src/ai/overview.md @@ -6,9 +6,7 @@ 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. -- [Models](./models.md): Learn about the various language models available in Zed. - -- [Subscription](./subscription.md): Learn about Zed's subscriptions and other billing-related information. +- [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/extensions/installing-extensions.md b/docs/src/extensions/installing-extensions.md index aed8bef4288d58fa9892235704f1eb160320ddeb..801fe5c55c0f47530e2656cd831619d1457ba13e 100644 --- a/docs/src/extensions/installing-extensions.md +++ b/docs/src/extensions/installing-extensions.md @@ -1,6 +1,6 @@ # Installing Extensions -You can search for extensions by launching the Zed Extension Gallery by pressing `cmd-shift-x` (macOS) or `ctrl-shift-x` (Linux), opening the command palette and selecting `zed: extensions` or by selecting "Zed > Extensions" from the menu bar. +You can search for extensions by launching the Zed Extension Gallery by pressing {#kb zed::Extensions} , opening the command palette and selecting {#action zed::Extensions} or by selecting "Zed > Extensions" from the menu bar. Here you can view the extensions that you currently have installed or search and install new ones. diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 5940c74b219da806aa4d6ceea5d855ea86f463b7..22af3b36d733f9d7eccb72cc622d6d07c942ca20 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -83,6 +83,6 @@ Visit [the AI overview page](./ai/overview.md) to learn how to quickly get start ## Set up your key bindings -To open your custom keymap to add your key bindings, use the {#kb zed::OpenKeymap} keybinding. +To edit your custom keymap and add or remap bindings, you can either use {#kb zed::OpenKeymapEditor} to spawn the Zed Keymap Editor ({#action zed::OpenKeymapEditor}) or you can directly open your Zed Keymap json (`~/.config/zed/keymap.json`) with {#action zed::OpenKeymap}. To access the default key binding set, open the Command Palette with {#kb command_palette::Toggle} and search for "zed: open default keymap". See [Key Bindings](./key-bindings.md) for more info. diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index 90aa400bb443b710fd4ef0bf01543f5b01bc8174..9984f234add620f6a0facb3e5eaf40b8c2100377 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -18,7 +18,7 @@ You can also enable `vim_mode`, which adds vim bindings too. ## User keymaps -Zed reads your keymap from `~/.config/zed/keymap.json`. You can open the file within Zed with {#kb zed::OpenKeymap}, or via `zed: Open Keymap` in the command palette. +Zed reads your keymap from `~/.config/zed/keymap.json`. You can open the file within Zed with {#action zed::OpenKeymap} from the command palette or to spawn the Zed Keymap Editor ({#action zed::OpenKeymapEditor}) use {#kb zed::OpenKeymapEditor}. The file contains a JSON array of objects with `"bindings"`. If no `"context"` is set the bindings are always active. If it is set the binding is only active when the [context matches](#contexts). diff --git a/docs/src/linux.md b/docs/src/linux.md index ca65da29695c71659650edad8fde60523b4fd029..309354de6d1b6e3c8f0936350708c161132fd803 100644 --- a/docs/src/linux.md +++ b/docs/src/linux.md @@ -294,3 +294,78 @@ If your system uses PipeWire: ``` 3. **Restart your system** + +### Forcing X11 scale factor + +On X11 systems, Zed automatically detects the appropriate scale factor for high-DPI displays. The scale factor is determined using the following priority order: + +1. `GPUI_X11_SCALE_FACTOR` environment variable (if set) +2. `Xft.dpi` from X resources database (xrdb) +3. Automatic detection via RandR based on monitor resolution and physical size + +If you want to customize the scale factor beyond what Zed detects automatically, you have several options: + +#### Check your current scale factor + +You can verify if you have `Xft.dpi` set: + +```sh +xrdb -query | grep Xft.dpi +``` + +If this command returns no output, Zed is using RandR (X11's monitor management extension) to automatically calculate the scale factor based on your monitor's reported resolution and physical dimensions. + +#### Option 1: Set Xft.dpi (X Resources Database) + +`Xft.dpi` is a standard X11 setting that many applications use for consistent font and UI scaling. Setting this ensures Zed scales the same way as other X11 applications that respect this setting. + +Edit or create the `~/.Xresources` file: + +```sh +vim ~/.Xresources +``` + +Add this line with your desired DPI: + +```sh +Xft.dpi: 96 +``` + +Common DPI values: + +- `96` for standard 1x scaling +- `144` for 1.5x scaling +- `192` for 2x scaling +- `288` for 3x scaling + +Load the configuration: + +```sh +xrdb -merge ~/.Xresources +``` + +Restart Zed for the changes to take effect. + +#### Option 2: Use the GPUI_X11_SCALE_FACTOR environment variable + +This Zed-specific environment variable directly sets the scale factor, bypassing all automatic detection. + +```sh +GPUI_X11_SCALE_FACTOR=1.5 zed +``` + +You can use decimal values (e.g., `1.25`, `1.5`, `2.0`) or set `GPUI_X11_SCALE_FACTOR=randr` to force RandR-based detection even when `Xft.dpi` is set. + +To make this permanent, add it to your shell profile or desktop entry. + +#### Option 3: Adjust system-wide RandR DPI + +This changes the reported DPI for your entire X11 session, affecting how RandR calculates scaling for all applications that use it. + +Add this to your `.xprofile` or `.xinitrc`: + +```sh +xrandr --dpi 192 +``` + +Replace `192` with your desired DPI value. This affects the system globally and will be used by Zed's automatic RandR detection when `Xft.dpi` is not set. diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index 197c9b80f858fc8d0dcb4adfc1aa8787754072fa..8b307d97d5851861b0a94e1834c67d1f85166afe 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -267,7 +267,7 @@ TBD: Centered layout related settings "display_in": "active_editor", // Where to show (active_editor, all_editor) "thumb": "always", // When to show thumb (always, hover) "thumb_border": "left_open", // Thumb border (left_open, right_open, full, none) - "max_width_columns": 80 // Maximum width of minimap + "max_width_columns": 80, // Maximum width of minimap "current_line_highlight": null // Highlight current line (null, line, gutter) }, diff --git a/script/bundle-linux b/script/bundle-linux index c52312015bed92cad021a187c59146ae8aeb9800..64de62ce9b63655b521f2edb3b662246c539a759 100755 --- a/script/bundle-linux +++ b/script/bundle-linux @@ -83,6 +83,23 @@ if [[ "$remote_server_triple" == "$musl_triple" ]]; then fi cargo build --release --target "${remote_server_triple}" --package remote_server +# Upload debug info to sentry.io +if ! command -v sentry-cli >/dev/null 2>&1; then + echo "sentry-cli not found. skipping sentry upload." + echo "install with: 'curl -sL https://sentry.io/get-cli | bash'" +else + if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then + echo "Uploading zed debug symbols to sentry..." + # note: this uploads the unstripped binary which is needed because it contains + # .eh_frame data for stack unwinindg. see https://github.com/getsentry/symbolic/issues/783 + sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev \ + "${target_dir}/${target_triple}"/release/zed \ + "${target_dir}/${remote_server_triple}"/release/remote_server + else + echo "missing SENTRY_AUTH_TOKEN. skipping sentry upload." + fi +fi + # Strip debug symbols and save them for upload to DigitalOcean objcopy --only-keep-debug "${target_dir}/${target_triple}/release/zed" "${target_dir}/${target_triple}/release/zed.dbg" objcopy --only-keep-debug "${target_dir}/${remote_server_triple}/release/remote_server" "${target_dir}/${remote_server_triple}/release/remote_server.dbg" diff --git a/script/bundle-mac b/script/bundle-mac index 18dfe90815243c0c948e66fab0ad6d1b5d78d44c..b2be5732355c352bbbfa2ef248acdaf63c74193d 100755 --- a/script/bundle-mac +++ b/script/bundle-mac @@ -366,3 +366,20 @@ else gzip -f --stdout --best target/x86_64-apple-darwin/release/remote_server > target/zed-remote-server-macos-x86_64.gz gzip -f --stdout --best target/aarch64-apple-darwin/release/remote_server > target/zed-remote-server-macos-aarch64.gz fi + +# Upload debug info to sentry.io +if ! command -v sentry-cli >/dev/null 2>&1; then + echo "sentry-cli not found. skipping sentry upload." + echo "install with: 'curl -sL https://sentry.io/get-cli | bash'" +else + if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then + echo "Uploading zed debug symbols to sentry..." + # note: this uploads the unstripped binary which is needed because it contains + # .eh_frame data for stack unwinindg. see https://github.com/getsentry/symbolic/issues/783 + sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev \ + "target/x86_64-apple-darwin/${target_dir}/" \ + "target/aarch64-apple-darwin/${target_dir}/" + else + echo "missing SENTRY_AUTH_TOKEN. skipping sentry upload." + fi +fi diff --git a/script/bundle-windows.ps1 b/script/bundle-windows.ps1 index 0018d7c9cb091d35151d553f5de31b03affa5aa5..c22db5bd360df58f49a0d2c06224e759b5ab2e49 100644 --- a/script/bundle-windows.ps1 +++ b/script/bundle-windows.ps1 @@ -96,6 +96,21 @@ function ZipZedAndItsFriendsDebug { Compress-Archive -Path $items -DestinationPath ".\target\release\zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip" -Force } + +function UploadToSentry { + if (-not (Get-Command "sentry-cli" -ErrorAction SilentlyContinue)) { + Write-Output "sentry-cli not found. skipping sentry upload." + Write-Output "install with: 'winget install -e --id=Sentry.sentry-cli'" + return + } + if (-not (Test-Path "env:SENTRY_AUTH_TOKEN")) { + Write-Output "missing SENTRY_AUTH_TOKEN. skipping sentry upload." + return + } + Write-Output "Uploading zed debug symbols to sentry..." + sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev .\target\release\ +} + function MakeAppx { switch ($channel) { "stable" { @@ -252,6 +267,8 @@ function BuildInstaller { ParseZedWorkspace $innoDir = "$env:ZED_WORKSPACE\inno" +$debugArchive = ".\target\release\zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip" +$debugStoreKey = "$env:ZED_RELEASE_CHANNEL/zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip" CheckEnvironmentVariables PrepareForBundle @@ -264,9 +281,8 @@ DownloadAMDGpuServices CollectFiles BuildInstaller -$debugArchive = ".\target\release\zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip" -$debugStoreKey = "$env:ZED_RELEASE_CHANNEL/zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip" UploadToBlobStorePublic -BucketName "zed-debug-symbols" -FileToUpload $debugArchive -BlobStoreKey $debugStoreKey +UploadToSentry if ($buildSuccess) { Write-Output "Build successful" diff --git a/tooling/workspace-hack/Cargo.toml b/tooling/workspace-hack/Cargo.toml index 10264540262bfd021577a954bf8933a2554ca222..80b200d2e54a64d60071e31bd427d8c9b06b3842 100644 --- a/tooling/workspace-hack/Cargo.toml +++ b/tooling/workspace-hack/Cargo.toml @@ -564,7 +564,6 @@ getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-f getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } -naga = { version = "25", features = ["spv-out", "wgsl-in"] } ring = { version = "0.17", features = ["std"] } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event"] } scopeguard = { version = "1" } @@ -588,7 +587,6 @@ getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-f getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } -naga = { version = "25", features = ["spv-out", "wgsl-in"] } proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] } ring = { version = "0.17", features = ["std"] } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event"] }