diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bd43b2e74..5a4d49553e 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 f799133ea7..4f7506967b 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 006163b79f..e0ca736fb6 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 1957f8b4cf..e45af3ca34 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 93b98471ca..dfb5a1c458 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 42310ea32c..d1bef0d337 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 0000000000..bc7a8376d1 --- /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 2cc6ce120d..1ff9d78824 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 01f4c9bf66..1d9c5bc9ed 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 fe5f8201cc..8c61ae2f1c 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 ad252e64cf..4b686b58f9 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 31adef8cd5..a4f812b2fc 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 f942c6f8ae..eded8c73e6 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 629333663d..f81f363ae0 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 e8b796f534..5795d2ac7e 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 3c6c21205f..d572992c54 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_assistant_chunk( + 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_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 fde167da5f..5b25b71863 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 316a5bcf25..44cd00348f 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 a89857e71a..34ea1c8df7 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 4c6d2b2b0b..85e8ac7451 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 1af27ca8a7..1b8aa012a1 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 74c719b4e6..7392c0878d 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 4371f7684d..dcffb05bc0 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 660f61f907..212bb74d8a 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 d63d8c43cf..6565786204 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 mut child = spawn_claude( + &command, + ClaudeSessionMode::Start, + session_id.clone(), + &mcp_config_path, + &cwd, + ) + .await?; - let pid = child.id(); - log::trace!("Spawned (pid: {})", pid); + 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() - ); + ClaudeAgentSession::handle_io( + outgoing_rx.take().unwrap(), + incoming_message_tx.clone(), + child.stdin.take().unwrap(), + child.stdout.take().unwrap(), + ) + .await?; - 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; - } + 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 a320a6d37f..cc303016f1 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 ed25f9af7f..6acb6355aa 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 0000000000..b10ce9cf54 --- /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 9bc6fd60fe..aca9001c79 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 47b965cdad..8b9fed5777 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 0000000000..055b89dfe2 --- /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 645674b5f1..aeb34a5e61 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 7f5de9db5f..e46e1ae3ab 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 43c1167af8..61a65de50b 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 6ae78585de..4b75cc9e77 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 7fffb60ecc..3aec9c62cd 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 0000000000..041e0d87ec --- /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 554b3f3f3c..22cbaac3f8 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 9a6ec49914..c0a358917b 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 1922b5677a..e34ae9ff93 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 224e8357e5..11d969d234 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 b13f9863c9..9e69c18b65 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 84595a37b7..12ab97f820 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 6413677bd9..1c41b26092 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 54d49359ba..a31ec39268 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 fd0e44e42c..affc019417 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 053273d71b..43c3d1d990 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 723416e2ce..b1980615d6 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 27ae10151d..c1cbbf848d 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 b6b1cf90a4..b51b91d3d5 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 97a4769e19..8fddbb0431 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 7567926dca..03487e5419 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 dc504e2dc4..ee38273cc0 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 03e76f6a5b..58833c5208 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 422204f97d..443c2930be 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 24bc8e9cba..5eeca9c2c4 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 3b0f5396a7..5cb26eb507 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 9a27e22f87..1cb20173c1 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 8c5e7da0f1..ff4d79c07d 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 192f530816..34e3a9a78c 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 7263f502fa..9ccbc8a553 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 cd97ff95bc..5fa2420a3d 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 505df09cfb..6180831ea9 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 4f9822b597..fd8db29584 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 52446ceafc..4ae2a14ca7 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 8f57fb1a20..6bbd1a409d 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 b99f628806..88ec2dc84e 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 725a1b6db5..f7efada469 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 68903fba03..cc7b1d7fb8 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 73ce73babd..93a1c15c41 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 76a5eb4c02..b495d70dfd 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 8b6e72d150..24601eefd6 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 255ae9c372..2b2207e22c 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 d1cb7d00cc..0d98c1db19 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 7ad293cd39..c70a1d88b2 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 a429d2049b..cf5b538cea 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")?); + pub(crate) fn new(hwnd: HWND, disable_direct_composition: bool) -> Result { + if disable_direct_composition { + log::info!("Direct Composition is disabled."); + } + + 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 resources = DirectXResources::new(&devices, 1, 1)?; - #[cfg(feature = "enable-renderdoc")] - let resources = DirectXResources::new(&devices, 1, 1, hwnd)?; + 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 globals = DirectXGlobalElements::new(&devices.device)?; - let pipelines = DirectXRenderPipelines::new(&devices.device)?; - - #[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 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); + 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); } - 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(); - } - #[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(()); } - 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 { - xy_position: v.xy_position, - content_mask: path.content_mask.bounds, - sprite_index: i as u32, - })); - - sprites.push(PathSprite { - bounds: path.bounds, - color: path.color, - }); + // 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); } - self.pipelines.paths_pipeline.update_buffer( + // Collect all vertices and sprites for a single draw call + let mut vertices = Vec::new(); + + for path in paths { + vertices.extend(path.vertices.iter().map(|v| PathRasterizationSprite { + xy_position: v.xy_position, + st_position: v.st_position, + color: path.color, + bounds: path.bounds.intersect(&path.content_mask.bounds), + })); + } + + 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); + } + + 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,7 +1182,40 @@ fn create_msaa_target_and_its_view( ArraySize: 1, Format: RENDER_TARGET_FORMAT, SampleDesc: DXGI_SAMPLE_DESC { - Count: MULTISAMPLE_COUNT, + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + 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 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; + 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, @@ -1207,12 +1226,9 @@ fn create_msaa_target_and_its_view( device.CreateTexture2D(&desc, None, Some(&mut output))?; output.unwrap() }; - let msaa_view = unsafe { - let mut output = None; - device.CreateRenderTargetView(&msaa_target, 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 4b905302af..61f410a8c6 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 835df48c29..27acb91f68 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 33f2e03925..daef05059b 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 PathRasterizationSprite { + float2 xy_position; + float2 st_position; + Background color; + Bounds bounds; }; -struct PathSprite { - Bounds bounds; - Background color; -}; +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; +}; + +PathVertexOutput path_rasterization_vertex(uint vertex_id: SV_VertexID) { + PathRasterizationSprite sprite = path_rasterization_sprites[vertex_id]; + + PathVertexOutput output; + 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( + background.tag, background.color_space, background.solid, background.colors); + + 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); -PathVertexOutput paths_vertex(PathVertex input) { - PathSprite sprite = path_sprites[input.idx]; +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]; - 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; + // 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); - GradientColor gradient = prepare_gradient_color( - sprite.color.tag, - sprite.color.color_space, - sprite.color.solid, - sprite.color.colors - ); + float2 screen_position = sprite.bounds.origin + unit_vertex * sprite.bounds.size; + float2 texture_coords = screen_position / global_viewport_size; - output.solid_color = gradient.solid; - output.color0 = gradient.color0; - output.color1 = gradient.color1; + 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 159cb6d95f..1141e93565 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 2ec3f560e8..1aa4cd6d9f 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 963d2bb45c..6ebb1cac40 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 e7066ae151..7552060be4 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 49e112b860..6418cd04d8 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 991d10bd50..033808cbb5 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 693e39d4ca..6ec8f8b162 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 dfdea1ca5b..b675ed2dd7 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 0000000000..2ea120e021 --- /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 1cb611680c..3be3192369 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 d494088b13..6f834b5dc0 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 7d70371380..fa265dae58 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 1e296ac2ac..f60a7becf7 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 09abd4bf1c..c6f9c9f134 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 a0cbdb9680..5ff91246f4 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; + 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; + } - 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; - } - - 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 65778c20eb..3c9992bd68 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 056c981ccf..d026b4de14 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 a7fa2106d1..14b9fd153c 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 1d91492f26..5779093ccc 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 24460f6d9c..7143514c52 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 d737999e45..2b1063316f 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 26edbd8d03..32d066c7eb 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 0ee8177dd8..77d76b44f5 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 6c5241f207..776cd06b1b 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 e31eeb74f2..6d781594ac 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 0a90f89fa4..c72fe39d2d 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 60702e42fc..315ae21929 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 e8587e1fef..d519b136ae 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 13a0121712..d28a7e8ed0 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 227bb23983..cb55c1c94e 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 95929b2d7e..5aef3d3d72 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 ff3dd84fce..6f081cb243 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 aed8bef428..801fe5c55c 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 5940c74b21..22af3b36d7 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 90aa400bb4..9984f234ad 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 ca65da2969..309354de6d 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 197c9b80f8..8b307d97d5 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 c52312015b..64de62ce9b 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 18dfe90815..b2be573235 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 0018d7c9cb..c22db5bd36 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 1026454026..80b200d2e5 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"] }