Compare commits
12 Commits
register-l
...
screenshot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de1cc44dd7 | ||
|
|
ba90b55b13 | ||
|
|
1dcf1cf8dc | ||
|
|
60261963a8 | ||
|
|
c705931001 | ||
|
|
038be5b46c | ||
|
|
02eda685b0 | ||
|
|
bdcc69dc1e | ||
|
|
9de9b0bde0 | ||
|
|
0ce65331f8 | ||
|
|
b32f6daab6 | ||
|
|
b5d0f5d4f8 |
3
.github/workflows/extension_tests.yml
vendored
3
.github/workflows/extension_tests.yml
vendored
@@ -61,7 +61,8 @@ jobs:
|
||||
uses: namespacelabs/nscloud-cache-action@v1
|
||||
with:
|
||||
cache: rust
|
||||
- name: steps::cargo_fmt
|
||||
- id: cargo_fmt
|
||||
name: steps::cargo_fmt
|
||||
run: cargo fmt --all -- --check
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: extension_tests::run_clippy
|
||||
|
||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -26,7 +26,8 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::clear_target_dir_if_large
|
||||
@@ -71,9 +72,15 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
- id: record_clippy_failure
|
||||
name: steps::record_clippy_failure
|
||||
if: always()
|
||||
run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::cargo_install_nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
- name: steps::clear_target_dir_if_large
|
||||
@@ -87,6 +94,8 @@ jobs:
|
||||
run: |
|
||||
rm -rf ./../.cargo
|
||||
shell: bash -euxo pipefail {0}
|
||||
outputs:
|
||||
clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }}
|
||||
timeout-minutes: 60
|
||||
run_tests_windows:
|
||||
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
|
||||
@@ -105,7 +114,8 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy.ps1
|
||||
shell: pwsh
|
||||
- name: steps::clear_target_dir_if_large
|
||||
|
||||
6
.github/workflows/release_nightly.yml
vendored
6
.github/workflows/release_nightly.yml
vendored
@@ -20,7 +20,8 @@ jobs:
|
||||
with:
|
||||
clean: false
|
||||
fetch-depth: 0
|
||||
- name: steps::cargo_fmt
|
||||
- id: cargo_fmt
|
||||
name: steps::cargo_fmt
|
||||
run: cargo fmt --all -- --check
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: ./script/clippy
|
||||
@@ -44,7 +45,8 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy.ps1
|
||||
shell: pwsh
|
||||
- name: steps::clear_target_dir_if_large
|
||||
|
||||
47
.github/workflows/run_tests.yml
vendored
47
.github/workflows/run_tests.yml
vendored
@@ -74,12 +74,19 @@ jobs:
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||
with:
|
||||
version: '9'
|
||||
- name: steps::prettier
|
||||
- id: prettier
|
||||
name: steps::prettier
|
||||
run: ./script/prettier
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::cargo_fmt
|
||||
- id: cargo_fmt
|
||||
name: steps::cargo_fmt
|
||||
run: cargo fmt --all -- --check
|
||||
shell: bash -euxo pipefail {0}
|
||||
- id: record_style_failure
|
||||
name: steps::record_style_failure
|
||||
if: always()
|
||||
run: echo "failed=${{ steps.prettier.outcome == 'failure' || steps.cargo_fmt.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: ./script/check-todos
|
||||
run: ./script/check-todos
|
||||
shell: bash -euxo pipefail {0}
|
||||
@@ -90,6 +97,8 @@ jobs:
|
||||
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06
|
||||
with:
|
||||
config: ./typos.toml
|
||||
outputs:
|
||||
style_failed: ${{ steps.record_style_failure.outputs.failed == 'true' }}
|
||||
timeout-minutes: 60
|
||||
run_tests_windows:
|
||||
needs:
|
||||
@@ -110,7 +119,8 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy.ps1
|
||||
shell: pwsh
|
||||
- name: steps::clear_target_dir_if_large
|
||||
@@ -157,9 +167,15 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
- id: record_clippy_failure
|
||||
name: steps::record_clippy_failure
|
||||
if: always()
|
||||
run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::cargo_install_nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
- name: steps::clear_target_dir_if_large
|
||||
@@ -173,6 +189,8 @@ jobs:
|
||||
run: |
|
||||
rm -rf ./../.cargo
|
||||
shell: bash -euxo pipefail {0}
|
||||
outputs:
|
||||
clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }}
|
||||
timeout-minutes: 60
|
||||
run_tests_mac:
|
||||
needs:
|
||||
@@ -193,7 +211,8 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::clear_target_dir_if_large
|
||||
@@ -573,6 +592,24 @@ jobs:
|
||||
|
||||
exit $EXIT_CODE
|
||||
shell: bash -euxo pipefail {0}
|
||||
call_autofix:
|
||||
needs:
|
||||
- check_style
|
||||
- run_tests_linux
|
||||
if: always() && (needs.check_style.outputs.style_failed == 'true' || needs.run_tests_linux.outputs.clippy_failed == 'true') && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
|
||||
runs-on: namespace-profile-2x4-ubuntu-2404
|
||||
steps:
|
||||
- id: get-app-token
|
||||
name: steps::authenticate_as_zippy
|
||||
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
|
||||
with:
|
||||
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
|
||||
private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
|
||||
- name: run_tests::call_autofix::dispatch_autofix
|
||||
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=${{ needs.run_tests_linux.outputs.clippy_failed == 'true' }}
|
||||
shell: bash -euxo pipefail {0}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
@@ -44,7 +44,7 @@ submitted. If you'd like your PR to have the best chance of being merged:
|
||||
effort. If there isn't already a GitHub issue for your feature with staff
|
||||
confirmation that we want it, start with a GitHub discussion rather than a PR.
|
||||
- Include a clear description of **what you're solving**, and why it's important.
|
||||
- Include **tests**.
|
||||
- Include **tests**. For UI changes, consider updating visual regression tests (see [Building Zed for macOS](./docs/src/development/macos.md#visual-regression-tests)).
|
||||
- If it changes the UI, attach **screenshots** or screen recordings.
|
||||
- Make the PR about **one thing only**, e.g. if it's a bugfix, don't add two
|
||||
features and a refactoring on top of that.
|
||||
|
||||
39
Cargo.lock
generated
39
Cargo.lock
generated
@@ -226,9 +226,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
version = "0.9.2"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e527d7dfe0f334313d42d1d9318f0a79665f6f21c440d0798f230a77a7ed6c"
|
||||
checksum = "c2ffe7d502c1e451aafc5aff655000f84d09c9af681354ac0012527009b1af13"
|
||||
dependencies = [
|
||||
"agent-client-protocol-schema",
|
||||
"anyhow",
|
||||
@@ -243,9 +243,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol-schema"
|
||||
version = "0.10.5"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6903a00e8ac822f9bacac59a1932754d7387c72ebb7c9c7439ad021505591da4"
|
||||
checksum = "8af81cc2d5c3f9c04f73db452efd058333735ba9d51c2cf7ef33c9fee038e7e6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"derive_more 2.0.1",
|
||||
@@ -793,7 +793,7 @@ dependencies = [
|
||||
"url",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols 0.32.9",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
@@ -7370,7 +7370,7 @@ dependencies = [
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-cursor",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols 0.31.2",
|
||||
"wayland-protocols-plasma",
|
||||
"wayland-protocols-wlr",
|
||||
"windows 0.61.3",
|
||||
@@ -8932,8 +8932,6 @@ dependencies = [
|
||||
"credentials_provider",
|
||||
"deepseek",
|
||||
"editor",
|
||||
"extension",
|
||||
"extension_host",
|
||||
"fs",
|
||||
"futures 0.3.31",
|
||||
"google_ai",
|
||||
@@ -12650,8 +12648,6 @@ dependencies = [
|
||||
"paths",
|
||||
"rope",
|
||||
"serde",
|
||||
"strum 0.27.2",
|
||||
"tempfile",
|
||||
"text",
|
||||
"util",
|
||||
"uuid",
|
||||
@@ -18931,6 +18927,18 @@ dependencies = [
|
||||
"xcursor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.31.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.9"
|
||||
@@ -18945,14 +18953,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-plasma"
|
||||
version = "0.3.9"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032"
|
||||
checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols 0.31.2",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
@@ -18965,7 +18973,7 @@ dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols 0.32.9",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
@@ -20630,6 +20638,7 @@ dependencies = [
|
||||
"clap",
|
||||
"cli",
|
||||
"client",
|
||||
"clock",
|
||||
"codestral",
|
||||
"collab_ui",
|
||||
"collections",
|
||||
@@ -20663,6 +20672,7 @@ dependencies = [
|
||||
"gpui",
|
||||
"gpui_tokio",
|
||||
"http_client",
|
||||
"image",
|
||||
"image_viewer",
|
||||
"inspector_ui",
|
||||
"install_cli",
|
||||
@@ -20729,6 +20739,7 @@ dependencies = [
|
||||
"task",
|
||||
"tasks_ui",
|
||||
"telemetry",
|
||||
"tempfile",
|
||||
"terminal_view",
|
||||
"theme",
|
||||
"theme_extension",
|
||||
|
||||
@@ -438,7 +438,7 @@ ztracing_macro = { path = "crates/ztracing_macro" }
|
||||
# External crates
|
||||
#
|
||||
|
||||
agent-client-protocol = { version = "=0.9.2", features = ["unstable"] }
|
||||
agent-client-protocol = { version = "=0.9.0", features = ["unstable"] }
|
||||
aho-corasick = "1.1"
|
||||
alacritty_terminal = "0.25.1-rc1"
|
||||
any_vec = "0.14"
|
||||
|
||||
@@ -20,6 +20,7 @@ Other platforms are not yet available:
|
||||
- [Building Zed for macOS](./docs/src/development/macos.md)
|
||||
- [Building Zed for Linux](./docs/src/development/linux.md)
|
||||
- [Building Zed for Windows](./docs/src/development/windows.md)
|
||||
- [Running Collaboration Locally](./docs/src/development/local-collaboration.md)
|
||||
|
||||
### Contributing
|
||||
|
||||
|
||||
@@ -227,7 +227,6 @@
|
||||
"ctrl-g": "search::SelectNextMatch",
|
||||
"ctrl-shift-g": "search::SelectPreviousMatch",
|
||||
"ctrl-k l": "agent::OpenRulesLibrary",
|
||||
"ctrl-shift-v": "agent::PasteRaw",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -294,7 +293,6 @@
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll",
|
||||
"ctrl-shift-v": "agent::PasteRaw",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -306,7 +304,6 @@
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll",
|
||||
"ctrl-shift-v": "agent::PasteRaw",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -267,7 +267,6 @@
|
||||
"cmd-shift-g": "search::SelectPreviousMatch",
|
||||
"cmd-k l": "agent::OpenRulesLibrary",
|
||||
"alt-tab": "agent::CycleFavoriteModels",
|
||||
"cmd-shift-v": "agent::PasteRaw",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -336,7 +335,6 @@
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"cmd-shift-y": "agent::KeepAll",
|
||||
"cmd-shift-n": "agent::RejectAll",
|
||||
"cmd-shift-v": "agent::PasteRaw",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -349,7 +347,6 @@
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"cmd-shift-y": "agent::KeepAll",
|
||||
"cmd-shift-n": "agent::RejectAll",
|
||||
"cmd-shift-v": "agent::PasteRaw",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -227,7 +227,6 @@
|
||||
"ctrl-g": "search::SelectNextMatch",
|
||||
"ctrl-shift-g": "search::SelectPreviousMatch",
|
||||
"ctrl-k l": "agent::OpenRulesLibrary",
|
||||
"ctrl-shift-v": "agent::PasteRaw",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -297,7 +296,6 @@
|
||||
"ctrl-shift-r": "agent::OpenAgentDiff",
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll",
|
||||
"ctrl-shift-v": "agent::PasteRaw",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -310,7 +308,6 @@
|
||||
"ctrl-shift-r": "agent::OpenAgentDiff",
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll",
|
||||
"ctrl-shift-v": "agent::PasteRaw",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1321,14 +1321,6 @@
|
||||
"hidden_files": ["**/.*"],
|
||||
// Git gutter behavior configuration.
|
||||
"git": {
|
||||
// Global switch to enable or disable all git integration features.
|
||||
// If set to true, disables all git integration features.
|
||||
// If set to false, individual git integration features below will be independently enabled or disabled.
|
||||
"disable_git": false,
|
||||
// Whether to enable git status tracking.
|
||||
"enable_status": true,
|
||||
// Whether to enable git diff display.
|
||||
"enable_diff": true,
|
||||
// Control whether the git gutter is shown. May take 2 values:
|
||||
// 1. Show the gutter
|
||||
// "git_gutter": "tracked_files"
|
||||
|
||||
@@ -3,11 +3,11 @@ use agent_client_protocol::{self as acp};
|
||||
use anyhow::Result;
|
||||
use collections::IndexMap;
|
||||
use gpui::{Entity, SharedString, Task};
|
||||
use language_model::{IconOrSvg, LanguageModelProviderId};
|
||||
use language_model::LanguageModelProviderId;
|
||||
use project::Project;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{any::Any, error::Error, fmt, path::Path, rc::Rc, sync::Arc};
|
||||
use ui::App;
|
||||
use ui::{App, IconName};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
@@ -215,7 +215,7 @@ pub struct AgentModelInfo {
|
||||
pub id: acp::ModelId,
|
||||
pub name: SharedString,
|
||||
pub description: Option<SharedString>,
|
||||
pub icon: Option<IconOrSvg>,
|
||||
pub icon: Option<IconName>,
|
||||
}
|
||||
|
||||
impl From<acp::ModelInfo> for AgentModelInfo {
|
||||
|
||||
@@ -93,7 +93,7 @@ impl LanguageModels {
|
||||
fn refresh_list(&mut self, cx: &App) {
|
||||
let providers = LanguageModelRegistry::global(cx)
|
||||
.read(cx)
|
||||
.visible_providers()
|
||||
.providers()
|
||||
.into_iter()
|
||||
.filter(|provider| provider.is_authenticated(cx))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -164,7 +164,7 @@ impl LanguageModels {
|
||||
fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> {
|
||||
let authenticate_all_providers = LanguageModelRegistry::global(cx)
|
||||
.read(cx)
|
||||
.visible_providers()
|
||||
.providers()
|
||||
.iter()
|
||||
.map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -426,7 +426,7 @@ impl NativeAgent {
|
||||
.into_iter()
|
||||
.flat_map(|(contents, prompt_metadata)| match contents {
|
||||
Ok(contents) => Some(UserRulesContext {
|
||||
uuid: prompt_metadata.id.as_user()?,
|
||||
uuid: prompt_metadata.id.user_id()?,
|
||||
title: prompt_metadata.title.map(|title| title.to_string()),
|
||||
contents,
|
||||
}),
|
||||
@@ -1630,7 +1630,7 @@ mod internal_tests {
|
||||
id: acp::ModelId::new("fake/fake"),
|
||||
name: "Fake".into(),
|
||||
description: None,
|
||||
icon: Some(language_model::IconOrSvg::Icon(ui::IconName::ZedAssistant)),
|
||||
icon: Some(ui::IconName::ZedAssistant),
|
||||
}]
|
||||
)])
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ use theme::ThemeSettings;
|
||||
use ui::prelude::*;
|
||||
use util::{ResultExt, debug_panic};
|
||||
use workspace::{CollaboratorId, Workspace};
|
||||
use zed_actions::agent::{Chat, PasteRaw};
|
||||
use zed_actions::agent::Chat;
|
||||
|
||||
pub struct MessageEditor {
|
||||
mention_set: Entity<MentionSet>,
|
||||
@@ -543,9 +543,6 @@ impl MessageEditor {
|
||||
}
|
||||
|
||||
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let editor_clipboard_selections = cx
|
||||
.read_from_clipboard()
|
||||
.and_then(|item| item.entries().first().cloned())
|
||||
@@ -556,127 +553,133 @@ impl MessageEditor {
|
||||
_ => None,
|
||||
});
|
||||
|
||||
// Insert creases for pasted clipboard selections that:
|
||||
// 1. Contain exactly one selection
|
||||
// 2. Have an associated file path
|
||||
// 3. Span multiple lines (not single-line selections)
|
||||
// 4. Belong to a file that exists in the current project
|
||||
let should_insert_creases = util::maybe!({
|
||||
let selections = editor_clipboard_selections.as_ref()?;
|
||||
if selections.len() > 1 {
|
||||
return Some(false);
|
||||
}
|
||||
let selection = selections.first()?;
|
||||
let file_path = selection.file_path.as_ref()?;
|
||||
let line_range = selection.line_range.as_ref()?;
|
||||
let has_file_context = editor_clipboard_selections
|
||||
.as_ref()
|
||||
.is_some_and(|selections| {
|
||||
selections
|
||||
.iter()
|
||||
.any(|sel| sel.file_path.is_some() && sel.line_range.is_some())
|
||||
});
|
||||
|
||||
if line_range.start() == line_range.end() {
|
||||
return Some(false);
|
||||
}
|
||||
|
||||
Some(
|
||||
workspace
|
||||
.read(cx)
|
||||
.project()
|
||||
.read(cx)
|
||||
.project_path_for_absolute_path(file_path, cx)
|
||||
.is_some(),
|
||||
)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if should_insert_creases && let Some(selections) = editor_clipboard_selections {
|
||||
cx.stop_propagation();
|
||||
let insertion_target = self
|
||||
.editor
|
||||
.read(cx)
|
||||
.selections
|
||||
.newest_anchor()
|
||||
.start
|
||||
.text_anchor;
|
||||
|
||||
let project = workspace.read(cx).project().clone();
|
||||
for selection in selections {
|
||||
if let (Some(file_path), Some(line_range)) =
|
||||
(selection.file_path, selection.line_range)
|
||||
{
|
||||
let crease_text =
|
||||
acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
|
||||
|
||||
let mention_uri = MentionUri::Selection {
|
||||
abs_path: Some(file_path.clone()),
|
||||
line_range: line_range.clone(),
|
||||
};
|
||||
|
||||
let mention_text = mention_uri.as_link().to_string();
|
||||
let (excerpt_id, text_anchor, content_len) =
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
let buffer = editor.buffer().read(cx);
|
||||
let snapshot = buffer.snapshot(cx);
|
||||
let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
|
||||
let text_anchor = insertion_target.bias_left(&buffer_snapshot);
|
||||
|
||||
editor.insert(&mention_text, window, cx);
|
||||
editor.insert(" ", window, cx);
|
||||
|
||||
(*excerpt_id, text_anchor, mention_text.len())
|
||||
});
|
||||
|
||||
let Some((crease_id, tx)) = insert_crease_for_mention(
|
||||
excerpt_id,
|
||||
text_anchor,
|
||||
content_len,
|
||||
crease_text.into(),
|
||||
mention_uri.icon_path(cx),
|
||||
None,
|
||||
self.editor.clone(),
|
||||
window,
|
||||
cx,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
drop(tx);
|
||||
|
||||
let mention_task = cx
|
||||
.spawn({
|
||||
let project = project.clone();
|
||||
async move |_, cx| {
|
||||
let project_path = project
|
||||
.update(cx, |project, cx| {
|
||||
project.project_path_for_absolute_path(&file_path, cx)
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "project path not found".to_string())?;
|
||||
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| project.open_buffer(project_path, cx))
|
||||
.map_err(|e| e.to_string())?
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
buffer
|
||||
.update(cx, |buffer, cx| {
|
||||
let start = Point::new(*line_range.start(), 0)
|
||||
.min(buffer.max_point());
|
||||
let end = Point::new(*line_range.end() + 1, 0)
|
||||
.min(buffer.max_point());
|
||||
let content = buffer.text_for_range(start..end).collect();
|
||||
Mention::Text {
|
||||
content,
|
||||
tracked_buffers: vec![cx.entity()],
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
})
|
||||
.shared();
|
||||
|
||||
self.mention_set.update(cx, |mention_set, _cx| {
|
||||
mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
|
||||
});
|
||||
if has_file_context {
|
||||
if let Some((workspace, selections)) =
|
||||
self.workspace.upgrade().zip(editor_clipboard_selections)
|
||||
{
|
||||
let Some(first_selection) = selections.first() else {
|
||||
return;
|
||||
};
|
||||
if let Some(file_path) = &first_selection.file_path {
|
||||
// In case someone pastes selections from another window
|
||||
// with a different project, we don't want to insert the
|
||||
// crease (containing the absolute path) since the agent
|
||||
// cannot access files outside the project.
|
||||
let is_in_project = workspace
|
||||
.read(cx)
|
||||
.project()
|
||||
.read(cx)
|
||||
.project_path_for_absolute_path(file_path, cx)
|
||||
.is_some();
|
||||
if !is_in_project {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cx.stop_propagation();
|
||||
let insertion_target = self
|
||||
.editor
|
||||
.read(cx)
|
||||
.selections
|
||||
.newest_anchor()
|
||||
.start
|
||||
.text_anchor;
|
||||
|
||||
let project = workspace.read(cx).project().clone();
|
||||
for selection in selections {
|
||||
if let (Some(file_path), Some(line_range)) =
|
||||
(selection.file_path, selection.line_range)
|
||||
{
|
||||
let crease_text =
|
||||
acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
|
||||
|
||||
let mention_uri = MentionUri::Selection {
|
||||
abs_path: Some(file_path.clone()),
|
||||
line_range: line_range.clone(),
|
||||
};
|
||||
|
||||
let mention_text = mention_uri.as_link().to_string();
|
||||
let (excerpt_id, text_anchor, content_len) =
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
let buffer = editor.buffer().read(cx);
|
||||
let snapshot = buffer.snapshot(cx);
|
||||
let (excerpt_id, _, buffer_snapshot) =
|
||||
snapshot.as_singleton().unwrap();
|
||||
let text_anchor = insertion_target.bias_left(&buffer_snapshot);
|
||||
|
||||
editor.insert(&mention_text, window, cx);
|
||||
editor.insert(" ", window, cx);
|
||||
|
||||
(*excerpt_id, text_anchor, mention_text.len())
|
||||
});
|
||||
|
||||
let Some((crease_id, tx)) = insert_crease_for_mention(
|
||||
excerpt_id,
|
||||
text_anchor,
|
||||
content_len,
|
||||
crease_text.into(),
|
||||
mention_uri.icon_path(cx),
|
||||
None,
|
||||
self.editor.clone(),
|
||||
window,
|
||||
cx,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
drop(tx);
|
||||
|
||||
let mention_task = cx
|
||||
.spawn({
|
||||
let project = project.clone();
|
||||
async move |_, cx| {
|
||||
let project_path = project
|
||||
.update(cx, |project, cx| {
|
||||
project.project_path_for_absolute_path(&file_path, cx)
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "project path not found".to_string())?;
|
||||
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_buffer(project_path, cx)
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
buffer
|
||||
.update(cx, |buffer, cx| {
|
||||
let start = Point::new(*line_range.start(), 0)
|
||||
.min(buffer.max_point());
|
||||
let end = Point::new(*line_range.end() + 1, 0)
|
||||
.min(buffer.max_point());
|
||||
let content =
|
||||
buffer.text_for_range(start..end).collect();
|
||||
Mention::Text {
|
||||
content,
|
||||
tracked_buffers: vec![cx.entity()],
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
})
|
||||
.shared();
|
||||
|
||||
self.mention_set.update(cx, |mention_set, _cx| {
|
||||
mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.prompt_capabilities.borrow().image
|
||||
@@ -687,13 +690,6 @@ impl MessageEditor {
|
||||
}
|
||||
}
|
||||
|
||||
fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let editor = self.editor.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn insert_dragged_files(
|
||||
&mut self,
|
||||
paths: Vec<project::ProjectPath>,
|
||||
@@ -971,7 +967,6 @@ impl Render for MessageEditor {
|
||||
.on_action(cx.listener(Self::chat))
|
||||
.on_action(cx.listener(Self::chat_with_follow))
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.on_action(cx.listener(Self::paste_raw))
|
||||
.capture_action(cx.listener(Self::paste))
|
||||
.flex_1()
|
||||
.child({
|
||||
|
||||
@@ -13,7 +13,6 @@ use gpui::{
|
||||
Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Task, WeakEntity,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use language_model::IconOrSvg;
|
||||
use ordered_float::OrderedFloat;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use settings::Settings;
|
||||
@@ -351,11 +350,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
})
|
||||
.child(
|
||||
ModelSelectorListItem::new(ix, model_info.name.clone())
|
||||
.map(|this| match &model_info.icon {
|
||||
Some(IconOrSvg::Svg(path)) => this.icon_path(path.clone()),
|
||||
Some(IconOrSvg::Icon(icon)) => this.icon(*icon),
|
||||
None => this,
|
||||
})
|
||||
.when_some(model_info.icon, |this, icon| this.icon(icon))
|
||||
.is_selected(is_selected)
|
||||
.is_focused(selected)
|
||||
.when(supports_favorites, |this| {
|
||||
|
||||
@@ -6,7 +6,6 @@ use agent_servers::AgentServer;
|
||||
use agent_settings::AgentSettings;
|
||||
use fs::Fs;
|
||||
use gpui::{Entity, FocusHandle};
|
||||
use language_model::IconOrSvg;
|
||||
use picker::popover_menu::PickerPopoverMenu;
|
||||
use settings::Settings as _;
|
||||
use ui::{ButtonLike, KeyBinding, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
|
||||
@@ -71,7 +70,7 @@ impl Render for AcpModelSelectorPopover {
|
||||
.map(|model| model.name.clone())
|
||||
.unwrap_or_else(|| SharedString::from("Select a Model"));
|
||||
|
||||
let model_icon = model.as_ref().and_then(|model| model.icon.clone());
|
||||
let model_icon = model.as_ref().and_then(|model| model.icon);
|
||||
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
@@ -126,14 +125,7 @@ impl Render for AcpModelSelectorPopover {
|
||||
ButtonLike::new("active-model")
|
||||
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.when_some(model_icon, |this, icon| {
|
||||
this.child(
|
||||
match icon {
|
||||
IconOrSvg::Svg(path) => Icon::from_external_svg(path),
|
||||
IconOrSvg::Icon(icon_name) => Icon::new(icon_name),
|
||||
}
|
||||
.color(color)
|
||||
.size(IconSize::XSmall),
|
||||
)
|
||||
this.child(Icon::new(icon).color(color).size(IconSize::XSmall))
|
||||
})
|
||||
.child(
|
||||
Label::new(model_name)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::acp::AcpThreadView;
|
||||
use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
|
||||
use agent::{HistoryEntry, HistoryStore};
|
||||
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
|
||||
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
|
||||
use editor::{Editor, EditorEvent};
|
||||
use fuzzy::StringMatchCandidate;
|
||||
use gpui::{
|
||||
@@ -402,22 +402,7 @@ impl AcpThreadHistory {
|
||||
let selected = ix == self.selected_index;
|
||||
let hovered = Some(ix) == self.hovered_index;
|
||||
let timestamp = entry.updated_at().timestamp();
|
||||
|
||||
let display_text = match format {
|
||||
EntryTimeFormat::DateAndTime => {
|
||||
let entry_time = entry.updated_at();
|
||||
let now = Utc::now();
|
||||
let duration = now.signed_duration_since(entry_time);
|
||||
let days = duration.num_days();
|
||||
|
||||
format!("{}d", days)
|
||||
}
|
||||
EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone),
|
||||
};
|
||||
|
||||
let title = entry.title().clone();
|
||||
let full_date =
|
||||
EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone);
|
||||
let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
@@ -438,14 +423,11 @@ impl AcpThreadHistory {
|
||||
.truncate(),
|
||||
)
|
||||
.child(
|
||||
Label::new(display_text)
|
||||
Label::new(thread_timestamp)
|
||||
.color(Color::Muted)
|
||||
.size(LabelSize::XSmall),
|
||||
),
|
||||
)
|
||||
.tooltip(move |_, cx| {
|
||||
Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
|
||||
})
|
||||
.on_hover(cx.listener(move |this, is_hovered, _window, cx| {
|
||||
if *is_hovered {
|
||||
this.hovered_index = Some(ix);
|
||||
|
||||
@@ -338,13 +338,7 @@ impl AcpThreadView {
|
||||
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
|
||||
let available_commands = Rc::new(RefCell::new(vec![]));
|
||||
|
||||
let agent_server_store = project.read(cx).agent_server_store().clone();
|
||||
let agent_display_name = agent_server_store
|
||||
.read(cx)
|
||||
.agent_display_name(&ExternalAgentServerName(agent.name()))
|
||||
.unwrap_or_else(|| agent.name());
|
||||
|
||||
let placeholder = placeholder_text(agent_display_name.as_ref(), false);
|
||||
let placeholder = placeholder_text(agent.name().as_ref(), false);
|
||||
|
||||
let message_editor = cx.new(|cx| {
|
||||
let mut editor = MessageEditor::new(
|
||||
@@ -383,6 +377,7 @@ impl AcpThreadView {
|
||||
)
|
||||
});
|
||||
|
||||
let agent_server_store = project.read(cx).agent_server_store().clone();
|
||||
let subscriptions = [
|
||||
cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
|
||||
cx.observe_global_in::<AgentFontSize>(window, Self::agent_ui_font_size_changed),
|
||||
@@ -1503,13 +1498,7 @@ impl AcpThreadView {
|
||||
let has_commands = !available_commands.is_empty();
|
||||
self.available_commands.replace(available_commands);
|
||||
|
||||
let agent_display_name = self
|
||||
.agent_server_store
|
||||
.read(cx)
|
||||
.agent_display_name(&ExternalAgentServerName(self.agent.name()))
|
||||
.unwrap_or_else(|| self.agent.name());
|
||||
|
||||
let new_placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
|
||||
let new_placeholder = placeholder_text(self.agent.name().as_ref(), has_commands);
|
||||
|
||||
self.message_editor.update(cx, |editor, cx| {
|
||||
editor.set_placeholder_text(&new_placeholder, window, cx);
|
||||
|
||||
@@ -22,8 +22,7 @@ use gpui::{
|
||||
};
|
||||
use language::LanguageRegistry;
|
||||
use language_model::{
|
||||
IconOrSvg, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
|
||||
ZED_CLOUD_PROVIDER_ID,
|
||||
LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID,
|
||||
};
|
||||
use language_models::AllLanguageModelSettings;
|
||||
use notifications::status_toast::{StatusToast, ToastIcon};
|
||||
@@ -118,7 +117,7 @@ impl AgentConfiguration {
|
||||
}
|
||||
|
||||
fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let providers = LanguageModelRegistry::read_global(cx).visible_providers();
|
||||
let providers = LanguageModelRegistry::read_global(cx).providers();
|
||||
for provider in providers {
|
||||
self.add_provider_configuration_view(&provider, window, cx);
|
||||
}
|
||||
@@ -262,12 +261,9 @@ impl AgentConfiguration {
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
match provider.icon() {
|
||||
IconOrSvg::Svg(path) => Icon::from_external_svg(path),
|
||||
IconOrSvg::Icon(name) => Icon::new(name),
|
||||
}
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
Icon::new(provider.icon())
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -420,7 +416,7 @@ impl AgentConfiguration {
|
||||
&mut self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let providers = LanguageModelRegistry::read_global(cx).visible_providers();
|
||||
let providers = LanguageModelRegistry::read_global(cx).providers();
|
||||
|
||||
let popover_menu = PopoverMenu::new("add-provider-popover")
|
||||
.trigger(
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::{
|
||||
};
|
||||
use fs::Fs;
|
||||
use gpui::{Entity, FocusHandle, SharedString};
|
||||
use language_model::IconOrSvg;
|
||||
use picker::popover_menu::PickerPopoverMenu;
|
||||
use settings::update_settings_file;
|
||||
use std::sync::Arc;
|
||||
@@ -104,14 +103,7 @@ impl Render for AgentModelSelector {
|
||||
self.selector.clone(),
|
||||
ButtonLike::new("active-model")
|
||||
.when_some(provider_icon, |this, icon| {
|
||||
this.child(
|
||||
match icon {
|
||||
IconOrSvg::Svg(path) => Icon::from_external_svg(path),
|
||||
IconOrSvg::Icon(name) => Icon::new(name),
|
||||
}
|
||||
.color(color)
|
||||
.size(IconSize::XSmall),
|
||||
)
|
||||
this.child(Icon::new(icon).color(color).size(IconSize::XSmall))
|
||||
})
|
||||
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.child(
|
||||
@@ -123,7 +115,7 @@ impl Render for AgentModelSelector {
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.color(color)
|
||||
.size(IconSize::XSmall),
|
||||
.size(IconSize::Small),
|
||||
),
|
||||
move |_window, cx| {
|
||||
Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
|
||||
|
||||
@@ -2428,7 +2428,7 @@ impl AgentPanel {
|
||||
let history_is_empty = self.history_store.read(cx).is_empty(cx);
|
||||
|
||||
let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
|
||||
.visible_providers()
|
||||
.providers()
|
||||
.iter()
|
||||
.any(|provider| {
|
||||
provider.is_authenticated(cx)
|
||||
|
||||
@@ -348,8 +348,7 @@ fn init_language_model_settings(cx: &mut App) {
|
||||
|_, event: &language_model::Event, cx| match event {
|
||||
language_model::Event::ProviderStateChanged(_)
|
||||
| language_model::Event::AddedProvider(_)
|
||||
| language_model::Event::RemovedProvider(_)
|
||||
| language_model::Event::ProvidersChanged => {
|
||||
| language_model::Event::RemovedProvider(_) => {
|
||||
update_active_language_model_from_settings(cx);
|
||||
}
|
||||
_ => {}
|
||||
@@ -361,52 +360,33 @@ fn init_language_model_settings(cx: &mut App) {
|
||||
fn update_active_language_model_from_settings(cx: &mut App) {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
|
||||
fn to_selected_model(
|
||||
selection: &LanguageModelSelection,
|
||||
registry: &LanguageModelRegistry,
|
||||
cx: &App,
|
||||
) -> Option<language_model::SelectedModel> {
|
||||
let provider_id = LanguageModelProviderId::from(selection.provider.0.clone());
|
||||
|
||||
if registry
|
||||
.provider(&provider_id)
|
||||
.map_or(false, |provider| provider.is_authenticated(cx))
|
||||
{
|
||||
Some(language_model::SelectedModel {
|
||||
provider: LanguageModelProviderId::from(selection.provider.0.clone()),
|
||||
model: LanguageModelId::from(selection.model.clone()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
fn to_selected_model(selection: &LanguageModelSelection) -> language_model::SelectedModel {
|
||||
language_model::SelectedModel {
|
||||
provider: LanguageModelProviderId::from(selection.provider.0.clone()),
|
||||
model: LanguageModelId::from(selection.model.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
let registry = LanguageModelRegistry::global(cx);
|
||||
let registry_ref = registry.read(cx);
|
||||
|
||||
let default = settings
|
||||
.default_model
|
||||
.as_ref()
|
||||
.and_then(|s| to_selected_model(s, registry_ref, cx));
|
||||
let default = settings.default_model.as_ref().map(to_selected_model);
|
||||
let inline_assistant = settings
|
||||
.inline_assistant_model
|
||||
.as_ref()
|
||||
.and_then(|s| to_selected_model(s, registry_ref, cx));
|
||||
.map(to_selected_model);
|
||||
let commit_message = settings
|
||||
.commit_message_model
|
||||
.as_ref()
|
||||
.and_then(|s| to_selected_model(s, registry_ref, cx));
|
||||
.map(to_selected_model);
|
||||
let thread_summary = settings
|
||||
.thread_summary_model
|
||||
.as_ref()
|
||||
.and_then(|s| to_selected_model(s, registry_ref, cx));
|
||||
.map(to_selected_model);
|
||||
let inline_alternatives = settings
|
||||
.inline_alternatives
|
||||
.iter()
|
||||
.filter_map(|s| to_selected_model(s, registry_ref, cx))
|
||||
.map(to_selected_model)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
||||
registry.select_default_model(default.as_ref(), cx);
|
||||
registry.select_inline_assistant_model(inline_assistant.as_ref(), cx);
|
||||
registry.select_commit_message_model(commit_message.as_ref(), cx);
|
||||
|
||||
@@ -1586,7 +1586,7 @@ pub(crate) fn search_rules(
|
||||
None
|
||||
} else {
|
||||
Some(RulesContextEntry {
|
||||
prompt_id: metadata.id.as_user()?,
|
||||
prompt_id: metadata.id.user_id()?,
|
||||
title: metadata.title?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ use std::{cmp::Reverse, sync::Arc};
|
||||
|
||||
use agent_settings::AgentSettings;
|
||||
use collections::{HashMap, HashSet, IndexMap};
|
||||
use futures::{StreamExt, channel::mpsc};
|
||||
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
|
||||
use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Task};
|
||||
use gpui::{
|
||||
Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
|
||||
};
|
||||
use language_model::{
|
||||
AuthenticateError, ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId,
|
||||
LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
|
||||
AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelRegistry,
|
||||
};
|
||||
use ordered_float::OrderedFloat;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
@@ -54,7 +55,7 @@ pub fn language_model_selector(
|
||||
|
||||
fn all_models(cx: &App) -> GroupedModels {
|
||||
let lm_registry = LanguageModelRegistry::global(cx).read(cx);
|
||||
let providers = lm_registry.visible_providers();
|
||||
let providers = lm_registry.providers();
|
||||
|
||||
let mut favorites_index = FavoritesIndex::default();
|
||||
|
||||
@@ -75,7 +76,7 @@ fn all_models(cx: &App) -> GroupedModels {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let all: Vec<ModelInfo> = providers
|
||||
let all = providers
|
||||
.iter()
|
||||
.flat_map(|provider| {
|
||||
provider
|
||||
@@ -93,7 +94,7 @@ type FavoritesIndex = HashMap<LanguageModelProviderId, HashSet<LanguageModelId>>
|
||||
#[derive(Clone)]
|
||||
struct ModelInfo {
|
||||
model: Arc<dyn LanguageModel>,
|
||||
icon: IconOrSvg,
|
||||
icon: IconName,
|
||||
is_favorite: bool,
|
||||
}
|
||||
|
||||
@@ -123,7 +124,7 @@ pub struct LanguageModelPickerDelegate {
|
||||
filtered_entries: Vec<LanguageModelPickerEntry>,
|
||||
selected_index: usize,
|
||||
_authenticate_all_providers_task: Task<()>,
|
||||
_refresh_models_task: Task<()>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
popover_styles: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
@@ -150,42 +151,24 @@ impl LanguageModelPickerDelegate {
|
||||
get_active_model: Arc::new(get_active_model),
|
||||
on_toggle_favorite: Arc::new(on_toggle_favorite),
|
||||
_authenticate_all_providers_task: Self::authenticate_all_providers(cx),
|
||||
_refresh_models_task: {
|
||||
// Create a channel to signal when models need refreshing
|
||||
let (refresh_tx, mut refresh_rx) = mpsc::unbounded::<()>();
|
||||
|
||||
// Subscribe to registry events and send refresh signals through the channel
|
||||
let registry = LanguageModelRegistry::global(cx);
|
||||
cx.subscribe(®istry, move |_picker, _, event, _cx| match event {
|
||||
language_model::Event::ProviderStateChanged(_)
|
||||
| language_model::Event::AddedProvider(_)
|
||||
| language_model::Event::RemovedProvider(_)
|
||||
| language_model::Event::ProvidersChanged => {
|
||||
refresh_tx.unbounded_send(()).ok();
|
||||
}
|
||||
language_model::Event::DefaultModelChanged
|
||||
| language_model::Event::InlineAssistantModelChanged
|
||||
| language_model::Event::CommitMessageModelChanged
|
||||
| language_model::Event::ThreadSummaryModelChanged => {}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Spawn a task that listens for refresh signals and updates the picker
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
while let Some(()) = refresh_rx.next().await {
|
||||
if this
|
||||
.update_in(cx, |picker, window, cx| {
|
||||
picker.delegate.all_models = Arc::new(all_models(cx));
|
||||
picker.refresh(window, cx);
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
// Picker was dropped, exit the loop
|
||||
break;
|
||||
_subscriptions: vec![cx.subscribe_in(
|
||||
&LanguageModelRegistry::global(cx),
|
||||
window,
|
||||
|picker, _, event, window, cx| {
|
||||
match event {
|
||||
language_model::Event::ProviderStateChanged(_)
|
||||
| language_model::Event::AddedProvider(_)
|
||||
| language_model::Event::RemovedProvider(_) => {
|
||||
let query = picker.query(cx);
|
||||
picker.delegate.all_models = Arc::new(all_models(cx));
|
||||
// Update matches will automatically drop the previous task
|
||||
// if we get a provider event again
|
||||
picker.update_matches(query, window, cx)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
)],
|
||||
popover_styles,
|
||||
focus_handle,
|
||||
}
|
||||
@@ -220,7 +203,7 @@ impl LanguageModelPickerDelegate {
|
||||
fn authenticate_all_providers(cx: &mut App) -> Task<()> {
|
||||
let authenticate_all_providers = LanguageModelRegistry::global(cx)
|
||||
.read(cx)
|
||||
.visible_providers()
|
||||
.providers()
|
||||
.iter()
|
||||
.map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -491,7 +474,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
|
||||
let configured_providers = language_model_registry
|
||||
.read(cx)
|
||||
.visible_providers()
|
||||
.providers()
|
||||
.into_iter()
|
||||
.filter(|provider| provider.is_authenticated(cx))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -583,10 +566,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
|
||||
Some(
|
||||
ModelSelectorListItem::new(ix, model_info.model.name().0)
|
||||
.map(|this| match &model_info.icon {
|
||||
IconOrSvg::Icon(icon_name) => this.icon(*icon_name),
|
||||
IconOrSvg::Svg(icon_path) => this.icon_path(icon_path.clone()),
|
||||
})
|
||||
.icon(model_info.icon)
|
||||
.is_selected(is_selected)
|
||||
.is_focused(selected)
|
||||
.is_favorite(is_favorite)
|
||||
@@ -722,7 +702,7 @@ mod tests {
|
||||
.any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name);
|
||||
ModelInfo {
|
||||
model: Arc::new(TestLanguageModel::new(name, provider)),
|
||||
icon: IconOrSvg::Icon(IconName::Ai),
|
||||
icon: IconName::Ai,
|
||||
is_favorite,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -33,8 +33,7 @@ use language::{
|
||||
language_settings::{SoftWrap, all_language_settings},
|
||||
};
|
||||
use language_model::{
|
||||
ConfigurationError, IconOrSvg, LanguageModelExt, LanguageModelImage, LanguageModelRegistry,
|
||||
Role,
|
||||
ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, Role,
|
||||
};
|
||||
use multi_buffer::MultiBufferRow;
|
||||
use picker::{Picker, popover_menu::PickerPopoverMenu};
|
||||
@@ -72,7 +71,7 @@ use workspace::{
|
||||
pane,
|
||||
searchable::{SearchEvent, SearchableItem},
|
||||
};
|
||||
use zed_actions::agent::{AddSelectionToThread, PasteRaw, ToggleModelSelector};
|
||||
use zed_actions::agent::{AddSelectionToThread, ToggleModelSelector};
|
||||
|
||||
use crate::CycleFavoriteModels;
|
||||
|
||||
@@ -1699,9 +1698,6 @@ impl TextThreadEditor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let editor_clipboard_selections = cx
|
||||
.read_from_clipboard()
|
||||
.and_then(|item| item.entries().first().cloned())
|
||||
@@ -1712,101 +1708,84 @@ impl TextThreadEditor {
|
||||
_ => None,
|
||||
});
|
||||
|
||||
// Insert creases for pasted clipboard selections that:
|
||||
// 1. Contain exactly one selection
|
||||
// 2. Have an associated file path
|
||||
// 3. Span multiple lines (not single-line selections)
|
||||
// 4. Belong to a file that exists in the current project
|
||||
let should_insert_creases = util::maybe!({
|
||||
let selections = editor_clipboard_selections.as_ref()?;
|
||||
if selections.len() > 1 {
|
||||
return Some(false);
|
||||
}
|
||||
let selection = selections.first()?;
|
||||
let file_path = selection.file_path.as_ref()?;
|
||||
let line_range = selection.line_range.as_ref()?;
|
||||
let has_file_context = editor_clipboard_selections
|
||||
.as_ref()
|
||||
.is_some_and(|selections| {
|
||||
selections
|
||||
.iter()
|
||||
.any(|sel| sel.file_path.is_some() && sel.line_range.is_some())
|
||||
});
|
||||
|
||||
if line_range.start() == line_range.end() {
|
||||
return Some(false);
|
||||
}
|
||||
if has_file_context {
|
||||
if let Some(clipboard_item) = cx.read_from_clipboard() {
|
||||
if let Some(ClipboardEntry::String(clipboard_text)) =
|
||||
clipboard_item.entries().first()
|
||||
{
|
||||
if let Some(selections) = editor_clipboard_selections {
|
||||
cx.stop_propagation();
|
||||
|
||||
Some(
|
||||
workspace
|
||||
.read(cx)
|
||||
.project()
|
||||
.read(cx)
|
||||
.project_path_for_absolute_path(file_path, cx)
|
||||
.is_some(),
|
||||
)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let text = clipboard_text.text();
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
let mut current_offset = 0;
|
||||
let weak_editor = cx.entity().downgrade();
|
||||
|
||||
if should_insert_creases && let Some(clipboard_item) = cx.read_from_clipboard() {
|
||||
if let Some(ClipboardEntry::String(clipboard_text)) = clipboard_item.entries().first() {
|
||||
if let Some(selections) = editor_clipboard_selections {
|
||||
cx.stop_propagation();
|
||||
for selection in selections {
|
||||
if let (Some(file_path), Some(line_range)) =
|
||||
(selection.file_path, selection.line_range)
|
||||
{
|
||||
let selected_text =
|
||||
&text[current_offset..current_offset + selection.len];
|
||||
let fence = assistant_slash_commands::codeblock_fence_for_path(
|
||||
file_path.to_str(),
|
||||
Some(line_range.clone()),
|
||||
);
|
||||
let formatted_text = format!("{fence}{selected_text}\n```");
|
||||
|
||||
let text = clipboard_text.text();
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
let mut current_offset = 0;
|
||||
let weak_editor = cx.entity().downgrade();
|
||||
let insert_point = editor
|
||||
.selections
|
||||
.newest::<Point>(&editor.display_snapshot(cx))
|
||||
.head();
|
||||
let start_row = MultiBufferRow(insert_point.row);
|
||||
|
||||
for selection in selections {
|
||||
if let (Some(file_path), Some(line_range)) =
|
||||
(selection.file_path, selection.line_range)
|
||||
{
|
||||
let selected_text =
|
||||
&text[current_offset..current_offset + selection.len];
|
||||
let fence = assistant_slash_commands::codeblock_fence_for_path(
|
||||
file_path.to_str(),
|
||||
Some(line_range.clone()),
|
||||
);
|
||||
let formatted_text = format!("{fence}{selected_text}\n```");
|
||||
editor.insert(&formatted_text, window, cx);
|
||||
|
||||
let insert_point = editor
|
||||
.selections
|
||||
.newest::<Point>(&editor.display_snapshot(cx))
|
||||
.head();
|
||||
let start_row = MultiBufferRow(insert_point.row);
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
let anchor_before = snapshot.anchor_after(insert_point);
|
||||
let anchor_after = editor
|
||||
.selections
|
||||
.newest_anchor()
|
||||
.head()
|
||||
.bias_left(&snapshot);
|
||||
|
||||
editor.insert(&formatted_text, window, cx);
|
||||
editor.insert("\n", window, cx);
|
||||
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
let anchor_before = snapshot.anchor_after(insert_point);
|
||||
let anchor_after = editor
|
||||
.selections
|
||||
.newest_anchor()
|
||||
.head()
|
||||
.bias_left(&snapshot);
|
||||
let crease_text = acp_thread::selection_name(
|
||||
Some(file_path.as_ref()),
|
||||
&line_range,
|
||||
);
|
||||
|
||||
editor.insert("\n", window, cx);
|
||||
let fold_placeholder = quote_selection_fold_placeholder(
|
||||
crease_text,
|
||||
weak_editor.clone(),
|
||||
);
|
||||
let crease = Crease::inline(
|
||||
anchor_before..anchor_after,
|
||||
fold_placeholder,
|
||||
render_quote_selection_output_toggle,
|
||||
|_, _, _, _| Empty.into_any(),
|
||||
);
|
||||
editor.insert_creases(vec![crease], cx);
|
||||
editor.fold_at(start_row, window, cx);
|
||||
|
||||
let crease_text = acp_thread::selection_name(
|
||||
Some(file_path.as_ref()),
|
||||
&line_range,
|
||||
);
|
||||
|
||||
let fold_placeholder = quote_selection_fold_placeholder(
|
||||
crease_text,
|
||||
weak_editor.clone(),
|
||||
);
|
||||
let crease = Crease::inline(
|
||||
anchor_before..anchor_after,
|
||||
fold_placeholder,
|
||||
render_quote_selection_output_toggle,
|
||||
|_, _, _, _| Empty.into_any(),
|
||||
);
|
||||
editor.insert_creases(vec![crease], cx);
|
||||
editor.fold_at(start_row, window, cx);
|
||||
|
||||
current_offset += selection.len;
|
||||
if !selection.is_entire_line && current_offset < text.len() {
|
||||
current_offset += 1;
|
||||
current_offset += selection.len;
|
||||
if !selection.is_entire_line && current_offset < text.len() {
|
||||
current_offset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1965,12 +1944,6 @@ impl TextThreadEditor {
|
||||
}
|
||||
}
|
||||
|
||||
fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.paste(&editor::actions::Paste, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn update_image_blocks(&mut self, cx: &mut Context<Self>) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
let buffer = editor.buffer().read(cx).snapshot(cx);
|
||||
@@ -2232,10 +2205,10 @@ impl TextThreadEditor {
|
||||
.default_model()
|
||||
.map(|default| default.provider);
|
||||
|
||||
let provider_icon = active_provider
|
||||
.as_ref()
|
||||
.map(|p| p.icon())
|
||||
.unwrap_or(IconOrSvg::Icon(IconName::Ai));
|
||||
let provider_icon = match active_provider {
|
||||
Some(provider) => provider.icon(),
|
||||
None => IconName::Ai,
|
||||
};
|
||||
|
||||
let focus_handle = self.editor().focus_handle(cx);
|
||||
|
||||
@@ -2245,13 +2218,6 @@ impl TextThreadEditor {
|
||||
(Color::Muted, IconName::ChevronDown)
|
||||
};
|
||||
|
||||
let provider_icon_element = match provider_icon {
|
||||
IconOrSvg::Svg(path) => Icon::from_external_svg(path),
|
||||
IconOrSvg::Icon(name) => Icon::new(name),
|
||||
}
|
||||
.color(color)
|
||||
.size(IconSize::XSmall);
|
||||
|
||||
let tooltip = Tooltip::element({
|
||||
move |_, cx| {
|
||||
let focus_handle = focus_handle.clone();
|
||||
@@ -2299,7 +2265,7 @@ impl TextThreadEditor {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(provider_icon_element)
|
||||
.child(Icon::new(provider_icon).color(color).size(IconSize::XSmall))
|
||||
.child(
|
||||
Label::new(model_name)
|
||||
.color(color)
|
||||
@@ -2661,7 +2627,6 @@ impl Render for TextThreadEditor {
|
||||
.capture_action(cx.listener(TextThreadEditor::copy))
|
||||
.capture_action(cx.listener(TextThreadEditor::cut))
|
||||
.capture_action(cx.listener(TextThreadEditor::paste))
|
||||
.on_action(cx.listener(TextThreadEditor::paste_raw))
|
||||
.capture_action(cx.listener(TextThreadEditor::cycle_message_role))
|
||||
.capture_action(cx.listener(TextThreadEditor::confirm_command))
|
||||
.on_action(cx.listener(TextThreadEditor::assist))
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
use gpui::{Action, FocusHandle, prelude::*};
|
||||
use ui::{ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
|
||||
|
||||
enum ModelIcon {
|
||||
Name(IconName),
|
||||
Path(SharedString),
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ModelSelectorHeader {
|
||||
title: SharedString,
|
||||
@@ -44,7 +39,7 @@ impl RenderOnce for ModelSelectorHeader {
|
||||
pub struct ModelSelectorListItem {
|
||||
index: usize,
|
||||
title: SharedString,
|
||||
icon: Option<ModelIcon>,
|
||||
icon: Option<IconName>,
|
||||
is_selected: bool,
|
||||
is_focused: bool,
|
||||
is_favorite: bool,
|
||||
@@ -65,12 +60,7 @@ impl ModelSelectorListItem {
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = Some(ModelIcon::Name(icon));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon_path(mut self, path: SharedString) -> Self {
|
||||
self.icon = Some(ModelIcon::Path(path));
|
||||
self.icon = Some(icon);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -115,12 +105,9 @@ impl RenderOnce for ModelSelectorListItem {
|
||||
.gap_1p5()
|
||||
.when_some(self.icon, |this, icon| {
|
||||
this.child(
|
||||
match icon {
|
||||
ModelIcon::Name(icon_name) => Icon::new(icon_name),
|
||||
ModelIcon::Path(icon_path) => Icon::from_external_svg(icon_path),
|
||||
}
|
||||
.color(model_icon_color)
|
||||
.size(IconSize::Small),
|
||||
Icon::new(icon)
|
||||
.color(model_icon_color)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
})
|
||||
.child(Label::new(self.title).truncate()),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use agent::{HistoryEntry, HistoryStore};
|
||||
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
|
||||
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
|
||||
use editor::{Editor, EditorEvent};
|
||||
use fuzzy::StringMatchCandidate;
|
||||
use gpui::{
|
||||
@@ -411,22 +411,7 @@ impl AcpThreadHistory {
|
||||
let selected = ix == self.selected_index;
|
||||
let hovered = Some(ix) == self.hovered_index;
|
||||
let timestamp = entry.updated_at().timestamp();
|
||||
|
||||
let display_text = match format {
|
||||
EntryTimeFormat::DateAndTime => {
|
||||
let entry_time = entry.updated_at();
|
||||
let now = Utc::now();
|
||||
let duration = now.signed_duration_since(entry_time);
|
||||
let days = duration.num_days();
|
||||
|
||||
format!("{}d", days)
|
||||
}
|
||||
EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone),
|
||||
};
|
||||
|
||||
let title = entry.title().clone();
|
||||
let full_date =
|
||||
EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone);
|
||||
let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
@@ -447,14 +432,11 @@ impl AcpThreadHistory {
|
||||
.truncate(),
|
||||
)
|
||||
.child(
|
||||
Label::new(display_text)
|
||||
Label::new(thread_timestamp)
|
||||
.color(Color::Muted)
|
||||
.size(LabelSize::XSmall),
|
||||
),
|
||||
)
|
||||
.tooltip(move |_, cx| {
|
||||
Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
|
||||
})
|
||||
.on_hover(cx.listener(move |this, is_hovered, _window, cx| {
|
||||
if *is_hovered {
|
||||
this.hovered_index = Some(ix);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use gpui::{Action, IntoElement, ParentElement, RenderOnce, point};
|
||||
use language_model::{IconOrSvg, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
|
||||
use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
|
||||
use ui::{Divider, List, ListBulletItem, prelude::*};
|
||||
|
||||
pub struct ApiKeysWithProviders {
|
||||
configured_providers: Vec<(IconOrSvg, SharedString)>,
|
||||
configured_providers: Vec<(IconName, SharedString)>,
|
||||
}
|
||||
|
||||
impl ApiKeysWithProviders {
|
||||
@@ -13,8 +13,7 @@ impl ApiKeysWithProviders {
|
||||
|this: &mut Self, _registry, event: &language_model::Event, cx| match event {
|
||||
language_model::Event::ProviderStateChanged(_)
|
||||
| language_model::Event::AddedProvider(_)
|
||||
| language_model::Event::RemovedProvider(_)
|
||||
| language_model::Event::ProvidersChanged => {
|
||||
| language_model::Event::RemovedProvider(_) => {
|
||||
this.configured_providers = Self::compute_configured_providers(cx)
|
||||
}
|
||||
_ => {}
|
||||
@@ -27,9 +26,9 @@ impl ApiKeysWithProviders {
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_configured_providers(cx: &App) -> Vec<(IconOrSvg, SharedString)> {
|
||||
fn compute_configured_providers(cx: &App) -> Vec<(IconName, SharedString)> {
|
||||
LanguageModelRegistry::read_global(cx)
|
||||
.visible_providers()
|
||||
.providers()
|
||||
.iter()
|
||||
.filter(|provider| {
|
||||
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
|
||||
@@ -48,14 +47,7 @@ impl Render for ApiKeysWithProviders {
|
||||
.map(|(icon, name)| {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
match icon {
|
||||
IconOrSvg::Icon(icon_name) => Icon::new(icon_name),
|
||||
IconOrSvg::Svg(icon_path) => Icon::from_external_svg(icon_path),
|
||||
}
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
|
||||
.child(Label::new(name))
|
||||
});
|
||||
div()
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding};
|
||||
pub struct AgentPanelOnboarding {
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
has_configured_providers: bool,
|
||||
configured_providers: Vec<(IconName, SharedString)>,
|
||||
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
}
|
||||
|
||||
@@ -27,9 +27,8 @@ impl AgentPanelOnboarding {
|
||||
|this: &mut Self, _registry, event: &language_model::Event, cx| match event {
|
||||
language_model::Event::ProviderStateChanged(_)
|
||||
| language_model::Event::AddedProvider(_)
|
||||
| language_model::Event::RemovedProvider(_)
|
||||
| language_model::Event::ProvidersChanged => {
|
||||
this.has_configured_providers = Self::has_configured_providers(cx)
|
||||
| language_model::Event::RemovedProvider(_) => {
|
||||
this.configured_providers = Self::compute_available_providers(cx)
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
@@ -39,16 +38,20 @@ impl AgentPanelOnboarding {
|
||||
Self {
|
||||
user_store,
|
||||
client,
|
||||
has_configured_providers: Self::has_configured_providers(cx),
|
||||
configured_providers: Self::compute_available_providers(cx),
|
||||
continue_with_zed_ai: Arc::new(continue_with_zed_ai),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_configured_providers(cx: &App) -> bool {
|
||||
fn compute_available_providers(cx: &App) -> Vec<(IconName, SharedString)> {
|
||||
LanguageModelRegistry::read_global(cx)
|
||||
.visible_providers()
|
||||
.providers()
|
||||
.iter()
|
||||
.any(|provider| provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID)
|
||||
.filter(|provider| {
|
||||
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
|
||||
})
|
||||
.map(|provider| (provider.icon(), provider.name().0))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +81,7 @@ impl Render for AgentPanelOnboarding {
|
||||
}),
|
||||
)
|
||||
.map(|this| {
|
||||
if enrolled_in_trial || is_pro_user || self.has_configured_providers {
|
||||
if enrolled_in_trial || is_pro_user || !self.configured_providers.is_empty() {
|
||||
this
|
||||
} else {
|
||||
this.child(ApiKeysWithoutProviders::new())
|
||||
|
||||
@@ -51,8 +51,6 @@ pub const MENU_ASIDE_MIN_WIDTH: Pixels = px(260.);
|
||||
pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.);
|
||||
pub const COMPLETION_MENU_MIN_WIDTH: Pixels = px(280.);
|
||||
pub const COMPLETION_MENU_MAX_WIDTH: Pixels = px(540.);
|
||||
pub const CODE_ACTION_MENU_MIN_WIDTH: Pixels = px(220.);
|
||||
pub const CODE_ACTION_MENU_MAX_WIDTH: Pixels = px(540.);
|
||||
|
||||
// Constants for the markdown cache. The purpose of this cache is to reduce flickering due to
|
||||
// documentation not yet being parsed.
|
||||
@@ -181,7 +179,7 @@ impl CodeContextMenu {
|
||||
) -> Option<AnyElement> {
|
||||
match self {
|
||||
CodeContextMenu::Completions(menu) => menu.render_aside(max_size, window, cx),
|
||||
CodeContextMenu::CodeActions(menu) => menu.render_aside(max_size, window, cx),
|
||||
CodeContextMenu::CodeActions(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1421,6 +1419,26 @@ pub enum CodeActionsItem {
|
||||
}
|
||||
|
||||
impl CodeActionsItem {
|
||||
fn as_task(&self) -> Option<&ResolvedTask> {
|
||||
let Self::Task(_, task) = self else {
|
||||
return None;
|
||||
};
|
||||
Some(task)
|
||||
}
|
||||
|
||||
fn as_code_action(&self) -> Option<&CodeAction> {
|
||||
let Self::CodeAction { action, .. } = self else {
|
||||
return None;
|
||||
};
|
||||
Some(action)
|
||||
}
|
||||
fn as_debug_scenario(&self) -> Option<&DebugScenario> {
|
||||
let Self::DebugScenario(scenario) = self else {
|
||||
return None;
|
||||
};
|
||||
Some(scenario)
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::CodeAction { action, .. } => action.lsp_action.title().to_owned(),
|
||||
@@ -1428,14 +1446,6 @@ impl CodeActionsItem {
|
||||
Self::DebugScenario(scenario) => scenario.label.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu_label(&self) -> String {
|
||||
match self {
|
||||
Self::CodeAction { action, .. } => action.lsp_action.title().replace("\n", ""),
|
||||
Self::Task(_, task) => task.resolved_label.replace("\n", ""),
|
||||
Self::DebugScenario(scenario) => format!("debug: {}", scenario.label),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CodeActionsMenu {
|
||||
@@ -1545,33 +1555,60 @@ impl CodeActionsMenu {
|
||||
let item_ix = range.start + ix;
|
||||
let selected = item_ix == selected_item;
|
||||
let colors = cx.theme().colors();
|
||||
|
||||
ListItem::new(item_ix)
|
||||
.inset(true)
|
||||
.toggle_state(selected)
|
||||
.overflow_x()
|
||||
.child(
|
||||
div()
|
||||
.min_w(CODE_ACTION_MENU_MIN_WIDTH)
|
||||
.max_w(CODE_ACTION_MENU_MAX_WIDTH)
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.when(is_quick_action_bar, |this| this.text_ui(cx))
|
||||
.when(selected, |this| this.text_color(colors.text_accent))
|
||||
.child(action.menu_label()),
|
||||
)
|
||||
.on_click(cx.listener(move |editor, _, window, cx| {
|
||||
cx.stop_propagation();
|
||||
if let Some(task) = editor.confirm_code_action(
|
||||
&ConfirmCodeAction {
|
||||
item_ix: Some(item_ix),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
) {
|
||||
task.detach_and_log_err(cx)
|
||||
}
|
||||
}))
|
||||
div().min_w(px(220.)).max_w(px(540.)).child(
|
||||
ListItem::new(item_ix)
|
||||
.inset(true)
|
||||
.toggle_state(selected)
|
||||
.when_some(action.as_code_action(), |this, action| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.overflow_hidden()
|
||||
.when(is_quick_action_bar, |this| this.text_ui(cx))
|
||||
.child(
|
||||
// TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
|
||||
action.lsp_action.title().replace("\n", ""),
|
||||
)
|
||||
.when(selected, |this| {
|
||||
this.text_color(colors.text_accent)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when_some(action.as_task(), |this, task| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.overflow_hidden()
|
||||
.when(is_quick_action_bar, |this| this.text_ui(cx))
|
||||
.child(task.resolved_label.replace("\n", ""))
|
||||
.when(selected, |this| {
|
||||
this.text_color(colors.text_accent)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when_some(action.as_debug_scenario(), |this, scenario| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.overflow_hidden()
|
||||
.when(is_quick_action_bar, |this| this.text_ui(cx))
|
||||
.child("debug: ")
|
||||
.child(scenario.label.clone())
|
||||
.when(selected, |this| {
|
||||
this.text_color(colors.text_accent)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.on_click(cx.listener(move |editor, _, window, cx| {
|
||||
cx.stop_propagation();
|
||||
if let Some(task) = editor.confirm_code_action(
|
||||
&ConfirmCodeAction {
|
||||
item_ix: Some(item_ix),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
) {
|
||||
task.detach_and_log_err(cx)
|
||||
}
|
||||
})),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
@@ -1598,42 +1635,4 @@ impl CodeActionsMenu {
|
||||
|
||||
Popover::new().child(list).into_any_element()
|
||||
}
|
||||
|
||||
fn render_aside(
|
||||
&mut self,
|
||||
max_size: Size<Pixels>,
|
||||
window: &mut Window,
|
||||
_cx: &mut Context<Editor>,
|
||||
) -> Option<AnyElement> {
|
||||
let Some(action) = self.actions.get(self.selected_item) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let label = action.menu_label();
|
||||
let text_system = window.text_system();
|
||||
let mut line_wrapper = text_system.line_wrapper(
|
||||
window.text_style().font(),
|
||||
window.text_style().font_size.to_pixels(window.rem_size()),
|
||||
);
|
||||
let is_truncated =
|
||||
line_wrapper.should_truncate_line(&label, CODE_ACTION_MENU_MAX_WIDTH, "…");
|
||||
|
||||
if is_truncated.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
Popover::new()
|
||||
.child(
|
||||
div()
|
||||
.child(label)
|
||||
.id("code_actions_menu_extended")
|
||||
.px(MENU_ASIDE_X_PADDING / 2.)
|
||||
.max_w(max_size.width)
|
||||
.max_h(max_size.height)
|
||||
.occlude(),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,8 +215,7 @@ impl Settings for EditorSettings {
|
||||
},
|
||||
scrollbar: Scrollbar {
|
||||
show: scrollbar.show.map(Into::into).unwrap(),
|
||||
git_diff: scrollbar.git_diff.unwrap()
|
||||
&& content.git.unwrap().enabled.unwrap().is_git_diff_enabled(),
|
||||
git_diff: scrollbar.git_diff.unwrap(),
|
||||
selected_text: scrollbar.selected_text.unwrap(),
|
||||
selected_symbol: scrollbar.selected_symbol.unwrap(),
|
||||
search_results: scrollbar.search_results.unwrap(),
|
||||
|
||||
@@ -20880,36 +20880,6 @@ async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) {
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.move_up(&MoveUp, window, cx);
|
||||
editor.toggle_selected_diff_hunks(&Default::default(), window, cx);
|
||||
});
|
||||
cx.assert_state_with_diff(
|
||||
indoc! { "
|
||||
ˇone
|
||||
- two
|
||||
three
|
||||
five
|
||||
"}
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.move_down(&MoveDown, window, cx);
|
||||
editor.move_down(&MoveDown, window, cx);
|
||||
editor.toggle_selected_diff_hunks(&Default::default(), window, cx);
|
||||
});
|
||||
cx.assert_state_with_diff(
|
||||
indoc! { "
|
||||
one
|
||||
- two
|
||||
ˇthree
|
||||
- four
|
||||
five
|
||||
"}
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
cx.set_state(indoc! { "
|
||||
one
|
||||
ˇTWO
|
||||
@@ -20949,66 +20919,6 @@ async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_toggling_adjacent_diff_hunks_2(
|
||||
executor: BackgroundExecutor,
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
|
||||
let diff_base = r#"
|
||||
lineA
|
||||
lineB
|
||||
lineC
|
||||
lineD
|
||||
"#
|
||||
.unindent();
|
||||
|
||||
cx.set_state(
|
||||
&r#"
|
||||
ˇlineA1
|
||||
lineB
|
||||
lineD
|
||||
"#
|
||||
.unindent(),
|
||||
);
|
||||
cx.set_head_text(&diff_base);
|
||||
executor.run_until_parked();
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx);
|
||||
});
|
||||
executor.run_until_parked();
|
||||
cx.assert_state_with_diff(
|
||||
r#"
|
||||
- lineA
|
||||
+ ˇlineA1
|
||||
lineB
|
||||
lineD
|
||||
"#
|
||||
.unindent(),
|
||||
);
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.move_down(&MoveDown, window, cx);
|
||||
editor.move_right(&MoveRight, window, cx);
|
||||
editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx);
|
||||
});
|
||||
executor.run_until_parked();
|
||||
cx.assert_state_with_diff(
|
||||
r#"
|
||||
- lineA
|
||||
+ lineA1
|
||||
lˇineB
|
||||
- lineC
|
||||
lineD
|
||||
"#
|
||||
.unindent(),
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_edits_around_expanded_deletion_hunks(
|
||||
executor: BackgroundExecutor,
|
||||
|
||||
@@ -1 +1 @@
|
||||
../../LICENSE-GPL
|
||||
LICENSE-GPL
|
||||
@@ -19,9 +19,6 @@ impl Global for GlobalExtensionHostProxy {}
|
||||
///
|
||||
/// This object implements each of the individual proxy types so that their
|
||||
/// methods can be called directly on it.
|
||||
/// Registration function for language model providers.
|
||||
pub type LanguageModelProviderRegistration = Box<dyn FnOnce(&mut App) + Send>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ExtensionHostProxy {
|
||||
theme_proxy: RwLock<Option<Arc<dyn ExtensionThemeProxy>>>,
|
||||
@@ -32,7 +29,6 @@ pub struct ExtensionHostProxy {
|
||||
slash_command_proxy: RwLock<Option<Arc<dyn ExtensionSlashCommandProxy>>>,
|
||||
context_server_proxy: RwLock<Option<Arc<dyn ExtensionContextServerProxy>>>,
|
||||
debug_adapter_provider_proxy: RwLock<Option<Arc<dyn ExtensionDebugAdapterProviderProxy>>>,
|
||||
language_model_provider_proxy: RwLock<Option<Arc<dyn ExtensionLanguageModelProviderProxy>>>,
|
||||
}
|
||||
|
||||
impl ExtensionHostProxy {
|
||||
@@ -58,7 +54,6 @@ impl ExtensionHostProxy {
|
||||
slash_command_proxy: RwLock::default(),
|
||||
context_server_proxy: RwLock::default(),
|
||||
debug_adapter_provider_proxy: RwLock::default(),
|
||||
language_model_provider_proxy: RwLock::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,15 +90,6 @@ impl ExtensionHostProxy {
|
||||
.write()
|
||||
.replace(Arc::new(proxy));
|
||||
}
|
||||
|
||||
pub fn register_language_model_provider_proxy(
|
||||
&self,
|
||||
proxy: impl ExtensionLanguageModelProviderProxy,
|
||||
) {
|
||||
self.language_model_provider_proxy
|
||||
.write()
|
||||
.replace(Arc::new(proxy));
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ExtensionThemeProxy: Send + Sync + 'static {
|
||||
@@ -460,37 +446,3 @@ impl ExtensionDebugAdapterProviderProxy for ExtensionHostProxy {
|
||||
proxy.unregister_debug_locator(locator_name)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ExtensionLanguageModelProviderProxy: Send + Sync + 'static {
|
||||
fn register_language_model_provider(
|
||||
&self,
|
||||
provider_id: Arc<str>,
|
||||
register_fn: LanguageModelProviderRegistration,
|
||||
cx: &mut App,
|
||||
);
|
||||
|
||||
fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App);
|
||||
}
|
||||
|
||||
impl ExtensionLanguageModelProviderProxy for ExtensionHostProxy {
|
||||
fn register_language_model_provider(
|
||||
&self,
|
||||
provider_id: Arc<str>,
|
||||
register_fn: LanguageModelProviderRegistration,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(proxy) = self.language_model_provider_proxy.read().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
proxy.register_language_model_provider(provider_id, register_fn, cx)
|
||||
}
|
||||
|
||||
fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App) {
|
||||
let Some(proxy) = self.language_model_provider_proxy.read().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
proxy.unregister_language_model_provider(provider_id, cx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,8 +93,6 @@ pub struct ExtensionManifest {
|
||||
pub debug_adapters: BTreeMap<Arc<str>, DebugAdapterManifestEntry>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub debug_locators: BTreeMap<Arc<str>, DebugLocatorManifestEntry>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub language_model_providers: BTreeMap<Arc<str>, LanguageModelProviderManifestEntry>,
|
||||
}
|
||||
|
||||
impl ExtensionManifest {
|
||||
@@ -290,16 +288,6 @@ pub struct DebugAdapterManifestEntry {
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
pub struct DebugLocatorManifestEntry {}
|
||||
|
||||
/// Manifest entry for a language model provider.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
pub struct LanguageModelProviderManifestEntry {
|
||||
/// Display name for the provider.
|
||||
pub name: String,
|
||||
/// Path to an SVG icon file relative to the extension root (e.g., "icons/provider.svg").
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl ExtensionManifest {
|
||||
pub async fn load(fs: Arc<dyn Fs>, extension_dir: &Path) -> Result<Self> {
|
||||
let extension_name = extension_dir
|
||||
@@ -370,7 +358,6 @@ fn manifest_from_old_manifest(
|
||||
capabilities: Vec::new(),
|
||||
debug_adapters: Default::default(),
|
||||
debug_locators: Default::default(),
|
||||
language_model_providers: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +391,6 @@ mod tests {
|
||||
capabilities: vec![],
|
||||
debug_adapters: Default::default(),
|
||||
debug_locators: Default::default(),
|
||||
language_model_providers: BTreeMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -255,21 +255,6 @@ async fn copy_extension_resources(
|
||||
}
|
||||
}
|
||||
|
||||
for (_, provider_entry) in &manifest.language_model_providers {
|
||||
if let Some(icon_path) = &provider_entry.icon {
|
||||
let source_icon = extension_path.join(icon_path);
|
||||
let dest_icon = output_dir.join(icon_path);
|
||||
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = dest_icon.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
fs::copy(&source_icon, &dest_icon)
|
||||
.with_context(|| format!("failed to copy LLM provider icon '{}'", icon_path))?;
|
||||
}
|
||||
}
|
||||
|
||||
if !manifest.languages.is_empty() {
|
||||
let output_languages_dir = output_dir.join("languages");
|
||||
fs::create_dir_all(&output_languages_dir)?;
|
||||
|
||||
@@ -148,7 +148,6 @@ fn manifest() -> ExtensionManifest {
|
||||
)],
|
||||
debug_adapters: Default::default(),
|
||||
debug_locators: Default::default(),
|
||||
language_model_providers: BTreeMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,6 @@ mod tests {
|
||||
capabilities: vec![],
|
||||
debug_adapters: Default::default(),
|
||||
debug_locators: Default::default(),
|
||||
language_model_providers: BTreeMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,6 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
capabilities: Vec::new(),
|
||||
debug_adapters: Default::default(),
|
||||
debug_locators: Default::default(),
|
||||
language_model_providers: BTreeMap::default(),
|
||||
}),
|
||||
dev: false,
|
||||
},
|
||||
@@ -197,7 +196,6 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
capabilities: Vec::new(),
|
||||
debug_adapters: Default::default(),
|
||||
debug_locators: Default::default(),
|
||||
language_model_providers: BTreeMap::default(),
|
||||
}),
|
||||
dev: false,
|
||||
},
|
||||
@@ -378,7 +376,6 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
capabilities: Vec::new(),
|
||||
debug_adapters: Default::default(),
|
||||
debug_locators: Default::default(),
|
||||
language_model_providers: BTreeMap::default(),
|
||||
}),
|
||||
dev: false,
|
||||
},
|
||||
|
||||
@@ -58,7 +58,7 @@ use project::{
|
||||
git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
|
||||
project_settings::{GitPathStyle, ProjectSettings},
|
||||
};
|
||||
use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES};
|
||||
use prompt_store::{PromptId, PromptStore, RULES_FILE_NAMES};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsStore, StatusStyle};
|
||||
use std::future::Future;
|
||||
@@ -2579,26 +2579,25 @@ impl GitPanel {
|
||||
is_using_legacy_zed_pro: bool,
|
||||
cx: &mut AsyncApp,
|
||||
) -> String {
|
||||
const DEFAULT_PROMPT: &str = include_str!("commit_message_prompt.txt");
|
||||
|
||||
// Remove this once we stop supporting legacy Zed Pro
|
||||
// In legacy Zed Pro, Git commit summary generation did not count as a
|
||||
// prompt. If the user changes the prompt, our classification will fail,
|
||||
// meaning that users will be charged for generating commit messages.
|
||||
if is_using_legacy_zed_pro {
|
||||
return BuiltInPrompt::CommitMessage.default_content().to_string();
|
||||
return DEFAULT_PROMPT.to_string();
|
||||
}
|
||||
|
||||
let load = async {
|
||||
let store = cx.update(|cx| PromptStore::global(cx)).ok()?.await.ok()?;
|
||||
store
|
||||
.update(cx, |s, cx| {
|
||||
s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx)
|
||||
})
|
||||
.update(cx, |s, cx| s.load(PromptId::CommitMessage, cx))
|
||||
.ok()?
|
||||
.await
|
||||
.ok()
|
||||
};
|
||||
load.await
|
||||
.unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string())
|
||||
load.await.unwrap_or_else(|| DEFAULT_PROMPT.to_string())
|
||||
}
|
||||
|
||||
/// Generates a commit message using an LLM.
|
||||
|
||||
@@ -198,14 +198,14 @@ wayland-backend = { version = "0.3.3", features = [
|
||||
"client_system",
|
||||
"dlopen",
|
||||
], optional = true }
|
||||
wayland-client = { version = "0.31.11", optional = true }
|
||||
wayland-cursor = { version = "0.31.11", optional = true }
|
||||
wayland-protocols = { version = "0.32.9", features = [
|
||||
wayland-client = { version = "0.31.2", optional = true }
|
||||
wayland-cursor = { version = "0.31.1", optional = true }
|
||||
wayland-protocols = { version = "0.31.2", features = [
|
||||
"client",
|
||||
"staging",
|
||||
"unstable",
|
||||
], optional = true }
|
||||
wayland-protocols-plasma = { version = "0.3.9", features = [
|
||||
wayland-protocols-plasma = { version = "0.2.0", features = [
|
||||
"client",
|
||||
], optional = true }
|
||||
wayland-protocols-wlr = { version = "0.3.9", features = [
|
||||
|
||||
@@ -5,7 +5,6 @@ use gpui::{
|
||||
|
||||
struct SubWindow {
|
||||
custom_titlebar: bool,
|
||||
is_dialog: bool,
|
||||
}
|
||||
|
||||
fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> impl IntoElement {
|
||||
@@ -24,10 +23,7 @@ fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> imp
|
||||
}
|
||||
|
||||
impl Render for SubWindow {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let window_bounds =
|
||||
WindowBounds::Windowed(Bounds::centered(None, size(px(250.0), px(200.0)), cx));
|
||||
|
||||
fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
@@ -56,28 +52,8 @@ impl Render for SubWindow {
|
||||
.child(
|
||||
div()
|
||||
.p_8()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child("SubWindow")
|
||||
.when(self.is_dialog, |div| {
|
||||
div.child(button("Open Nested Dialog", move |_, cx| {
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(window_bounds),
|
||||
kind: WindowKind::Dialog,
|
||||
..Default::default()
|
||||
},
|
||||
|_, cx| {
|
||||
cx.new(|_| SubWindow {
|
||||
custom_titlebar: false,
|
||||
is_dialog: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}))
|
||||
})
|
||||
.child(button("Close", |window, _| {
|
||||
window.remove_window();
|
||||
})),
|
||||
@@ -110,7 +86,6 @@ impl Render for WindowDemo {
|
||||
|_, cx| {
|
||||
cx.new(|_| SubWindow {
|
||||
custom_titlebar: false,
|
||||
is_dialog: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -126,39 +101,6 @@ impl Render for WindowDemo {
|
||||
|_, cx| {
|
||||
cx.new(|_| SubWindow {
|
||||
custom_titlebar: false,
|
||||
is_dialog: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}))
|
||||
.child(button("Floating", move |_, cx| {
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(window_bounds),
|
||||
kind: WindowKind::Floating,
|
||||
..Default::default()
|
||||
},
|
||||
|_, cx| {
|
||||
cx.new(|_| SubWindow {
|
||||
custom_titlebar: false,
|
||||
is_dialog: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}))
|
||||
.child(button("Dialog", move |_, cx| {
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(window_bounds),
|
||||
kind: WindowKind::Dialog,
|
||||
..Default::default()
|
||||
},
|
||||
|_, cx| {
|
||||
cx.new(|_| SubWindow {
|
||||
custom_titlebar: false,
|
||||
is_dialog: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -174,7 +116,6 @@ impl Render for WindowDemo {
|
||||
|_, cx| {
|
||||
cx.new(|_| SubWindow {
|
||||
custom_titlebar: true,
|
||||
is_dialog: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -190,7 +131,6 @@ impl Render for WindowDemo {
|
||||
|_, cx| {
|
||||
cx.new(|_| SubWindow {
|
||||
custom_titlebar: false,
|
||||
is_dialog: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -207,7 +147,6 @@ impl Render for WindowDemo {
|
||||
|_, cx| {
|
||||
cx.new(|_| SubWindow {
|
||||
custom_titlebar: false,
|
||||
is_dialog: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -223,7 +162,6 @@ impl Render for WindowDemo {
|
||||
|_, cx| {
|
||||
cx.new(|_| SubWindow {
|
||||
custom_titlebar: false,
|
||||
is_dialog: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -239,7 +177,6 @@ impl Render for WindowDemo {
|
||||
|_, cx| {
|
||||
cx.new(|_| SubWindow {
|
||||
custom_titlebar: false,
|
||||
is_dialog: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -30,6 +30,8 @@ use smallvec::SmallVec;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub use test_context::*;
|
||||
use util::{ResultExt, debug_panic};
|
||||
#[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
|
||||
pub use visual_test_context::*;
|
||||
|
||||
#[cfg(any(feature = "inspector", debug_assertions))]
|
||||
use crate::InspectorElementRegistry;
|
||||
@@ -52,6 +54,8 @@ mod context;
|
||||
mod entity_map;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
mod test_context;
|
||||
#[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
|
||||
mod visual_test_context;
|
||||
|
||||
/// The duration for which futures returned from [Context::on_app_quit] can run before the application fully quits.
|
||||
pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
@@ -316,7 +320,6 @@ impl SystemWindowTabController {
|
||||
.find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group));
|
||||
|
||||
let current_group = current_group?;
|
||||
// TODO: `.keys()` returns arbitrary order, what does "next" mean?
|
||||
let mut group_ids: Vec<_> = controller.tab_groups.keys().collect();
|
||||
let idx = group_ids.iter().position(|g| *g == current_group)?;
|
||||
let next_idx = (idx + 1) % group_ids.len();
|
||||
@@ -341,7 +344,6 @@ impl SystemWindowTabController {
|
||||
.find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group));
|
||||
|
||||
let current_group = current_group?;
|
||||
// TODO: `.keys()` returns arbitrary order, what does "previous" mean?
|
||||
let mut group_ids: Vec<_> = controller.tab_groups.keys().collect();
|
||||
let idx = group_ids.iter().position(|g| *g == current_group)?;
|
||||
let prev_idx = if idx == 0 {
|
||||
@@ -363,9 +365,12 @@ impl SystemWindowTabController {
|
||||
|
||||
/// Get all tabs in the same window.
|
||||
pub fn tabs(&self, id: WindowId) -> Option<&Vec<SystemWindowTab>> {
|
||||
self.tab_groups
|
||||
.values()
|
||||
.find(|tabs| tabs.iter().any(|tab| tab.id == id))
|
||||
let tab_group = self
|
||||
.tab_groups
|
||||
.iter()
|
||||
.find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group))?;
|
||||
|
||||
self.tab_groups.get(&tab_group)
|
||||
}
|
||||
|
||||
/// Initialize the visibility of the system window tab controller.
|
||||
@@ -440,7 +445,7 @@ impl SystemWindowTabController {
|
||||
/// Insert a tab into a tab group.
|
||||
pub fn add_tab(cx: &mut App, id: WindowId, tabs: Vec<SystemWindowTab>) {
|
||||
let mut controller = cx.global_mut::<SystemWindowTabController>();
|
||||
let Some(tab) = tabs.iter().find(|tab| tab.id == id).cloned() else {
|
||||
let Some(tab) = tabs.clone().into_iter().find(|tab| tab.id == id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -503,14 +508,16 @@ impl SystemWindowTabController {
|
||||
return;
|
||||
};
|
||||
|
||||
let initial_tabs_len = initial_tabs.len();
|
||||
let mut all_tabs = initial_tabs.clone();
|
||||
|
||||
for (_, mut tabs) in controller.tab_groups.drain() {
|
||||
tabs.retain(|tab| !all_tabs[..initial_tabs_len].contains(tab));
|
||||
all_tabs.extend(tabs);
|
||||
for tabs in controller.tab_groups.values() {
|
||||
all_tabs.extend(
|
||||
tabs.iter()
|
||||
.filter(|tab| !initial_tabs.contains(tab))
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
|
||||
controller.tab_groups.clear();
|
||||
controller.tab_groups.insert(0, all_tabs);
|
||||
}
|
||||
|
||||
|
||||
478
crates/gpui/src/app/visual_test_context.rs
Normal file
478
crates/gpui/src/app/visual_test_context.rs
Normal file
@@ -0,0 +1,478 @@
|
||||
#[cfg(feature = "screen-capture")]
|
||||
use crate::capture_window_screenshot;
|
||||
use crate::{
|
||||
Action, AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, Bounds,
|
||||
ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers,
|
||||
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, Render,
|
||||
Result, Size, Task, TextSystem, Window, WindowBounds, WindowHandle, WindowOptions,
|
||||
app::GpuiMode, current_platform,
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
#[cfg(feature = "screen-capture")]
|
||||
use image::RgbaImage;
|
||||
use std::{future::Future, rc::Rc, sync::Arc, time::Duration};
|
||||
|
||||
/// A test context that uses real macOS rendering instead of mocked rendering.
|
||||
/// This is used for visual tests that need to capture actual screenshots.
|
||||
///
|
||||
/// Unlike `TestAppContext` which uses `TestPlatform` with mocked rendering,
|
||||
/// `VisualTestAppContext` uses the real `MacPlatform` to produce actual rendered output.
|
||||
///
|
||||
/// Windows created through this context are positioned off-screen (at coordinates like -10000, -10000)
|
||||
/// so they are invisible to the user but still fully rendered by the compositor.
|
||||
#[derive(Clone)]
|
||||
pub struct VisualTestAppContext {
|
||||
/// The underlying app cell
|
||||
pub app: Rc<AppCell>,
|
||||
/// The background executor for running async tasks
|
||||
pub background_executor: BackgroundExecutor,
|
||||
/// The foreground executor for running tasks on the main thread
|
||||
pub foreground_executor: ForegroundExecutor,
|
||||
platform: Rc<dyn Platform>,
|
||||
text_system: Arc<TextSystem>,
|
||||
}
|
||||
|
||||
impl VisualTestAppContext {
|
||||
/// Creates a new `VisualTestAppContext` with real macOS platform rendering.
|
||||
///
|
||||
/// This initializes the real macOS platform (not the test platform), which means:
|
||||
/// - Windows are actually rendered by Metal/the compositor
|
||||
/// - Screenshots can be captured via ScreenCaptureKit
|
||||
/// - All platform APIs work as they do in production
|
||||
pub fn new() -> Self {
|
||||
let platform = current_platform(false);
|
||||
let background_executor = platform.background_executor();
|
||||
let foreground_executor = platform.foreground_executor();
|
||||
let text_system = Arc::new(TextSystem::new(platform.text_system()));
|
||||
|
||||
let asset_source = Arc::new(());
|
||||
let http_client = http_client::FakeHttpClient::with_404_response();
|
||||
|
||||
let mut app = App::new_app(platform.clone(), asset_source, http_client);
|
||||
app.borrow_mut().mode = GpuiMode::test();
|
||||
|
||||
Self {
|
||||
app,
|
||||
background_executor,
|
||||
foreground_executor,
|
||||
platform,
|
||||
text_system,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a window positioned off-screen for invisible rendering.
|
||||
///
|
||||
/// The window is positioned at (-10000, -10000) so it's not visible on any display,
|
||||
/// but it's still fully rendered by the compositor and can be captured via ScreenCaptureKit.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `size` - The size of the window to create
|
||||
/// * `build_root` - A closure that builds the root view for the window
|
||||
pub fn open_offscreen_window<V: Render + 'static>(
|
||||
&mut self,
|
||||
size: Size<Pixels>,
|
||||
build_root: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
|
||||
) -> Result<WindowHandle<V>> {
|
||||
use crate::{point, px};
|
||||
|
||||
let bounds = Bounds {
|
||||
origin: point(px(-10000.0), px(-10000.0)),
|
||||
size,
|
||||
};
|
||||
|
||||
let mut cx = self.app.borrow_mut();
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
focus: false,
|
||||
show: true,
|
||||
..Default::default()
|
||||
},
|
||||
build_root,
|
||||
)
|
||||
}
|
||||
|
||||
/// Opens an off-screen window with default size (1280x800).
|
||||
pub fn open_offscreen_window_default<V: Render + 'static>(
|
||||
&mut self,
|
||||
build_root: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
|
||||
) -> Result<WindowHandle<V>> {
|
||||
use crate::{px, size};
|
||||
self.open_offscreen_window(size(px(1280.0), px(800.0)), build_root)
|
||||
}
|
||||
|
||||
/// Returns whether screen capture is supported on this platform.
|
||||
pub fn is_screen_capture_supported(&self) -> bool {
|
||||
self.platform.is_screen_capture_supported()
|
||||
}
|
||||
|
||||
/// Returns the text system used by this context.
|
||||
pub fn text_system(&self) -> &Arc<TextSystem> {
|
||||
&self.text_system
|
||||
}
|
||||
|
||||
/// Returns the background executor.
|
||||
pub fn executor(&self) -> BackgroundExecutor {
|
||||
self.background_executor.clone()
|
||||
}
|
||||
|
||||
/// Returns the foreground executor.
|
||||
pub fn foreground_executor(&self) -> ForegroundExecutor {
|
||||
self.foreground_executor.clone()
|
||||
}
|
||||
|
||||
/// Runs pending background tasks until there's nothing left to do.
|
||||
pub fn run_until_parked(&self) {
|
||||
self.background_executor.run_until_parked();
|
||||
}
|
||||
|
||||
/// Updates the app state.
|
||||
pub fn update<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
|
||||
let mut app = self.app.borrow_mut();
|
||||
f(&mut app)
|
||||
}
|
||||
|
||||
/// Reads from the app state.
|
||||
pub fn read<R>(&self, f: impl FnOnce(&App) -> R) -> R {
|
||||
let app = self.app.borrow();
|
||||
f(&app)
|
||||
}
|
||||
|
||||
/// Updates a window.
|
||||
pub fn update_window<T, F>(&mut self, window: AnyWindowHandle, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(AnyView, &mut Window, &mut App) -> T,
|
||||
{
|
||||
let mut lock = self.app.borrow_mut();
|
||||
lock.update_window(window, f)
|
||||
}
|
||||
|
||||
/// Spawns a task on the foreground executor.
|
||||
pub fn spawn<F, R>(&self, f: F) -> Task<R>
|
||||
where
|
||||
F: Future<Output = R> + 'static,
|
||||
R: 'static,
|
||||
{
|
||||
self.foreground_executor.spawn(f)
|
||||
}
|
||||
|
||||
/// Checks if a global of type G exists.
|
||||
pub fn has_global<G: Global>(&self) -> bool {
|
||||
let app = self.app.borrow();
|
||||
app.has_global::<G>()
|
||||
}
|
||||
|
||||
/// Reads a global value.
|
||||
pub fn read_global<G: Global, R>(&self, f: impl FnOnce(&G, &App) -> R) -> R {
|
||||
let app = self.app.borrow();
|
||||
f(app.global::<G>(), &app)
|
||||
}
|
||||
|
||||
/// Sets a global value.
|
||||
pub fn set_global<G: Global>(&mut self, global: G) {
|
||||
let mut app = self.app.borrow_mut();
|
||||
app.set_global(global);
|
||||
}
|
||||
|
||||
/// Updates a global value.
|
||||
pub fn update_global<G: Global, R>(&mut self, f: impl FnOnce(&mut G, &mut App) -> R) -> R {
|
||||
let mut lock = self.app.borrow_mut();
|
||||
lock.update(|cx| {
|
||||
let mut global = cx.lease_global::<G>();
|
||||
let result = f(&mut global, cx);
|
||||
cx.end_global_lease(global);
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
/// Simulates a sequence of keystrokes on the given window.
|
||||
///
|
||||
/// Keystrokes are specified as a space-separated string, e.g., "cmd-p escape".
|
||||
pub fn simulate_keystrokes(&mut self, window: AnyWindowHandle, keystrokes: &str) {
|
||||
for keystroke_text in keystrokes.split_whitespace() {
|
||||
let keystroke = Keystroke::parse(keystroke_text)
|
||||
.unwrap_or_else(|_| panic!("Invalid keystroke: {}", keystroke_text));
|
||||
self.dispatch_keystroke(window, keystroke);
|
||||
}
|
||||
self.run_until_parked();
|
||||
}
|
||||
|
||||
/// Dispatches a single keystroke to a window.
|
||||
pub fn dispatch_keystroke(&mut self, window: AnyWindowHandle, keystroke: Keystroke) {
|
||||
self.update_window(window, |_, window, cx| {
|
||||
window.dispatch_keystroke(keystroke, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Simulates typing text input on the given window.
|
||||
pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) {
|
||||
for char in input.chars() {
|
||||
let key = char.to_string();
|
||||
let keystroke = Keystroke {
|
||||
modifiers: Modifiers::default(),
|
||||
key: key.clone(),
|
||||
key_char: Some(key),
|
||||
};
|
||||
self.dispatch_keystroke(window, keystroke);
|
||||
}
|
||||
self.run_until_parked();
|
||||
}
|
||||
|
||||
/// Simulates a mouse move event.
|
||||
pub fn simulate_mouse_move(
|
||||
&mut self,
|
||||
window: AnyWindowHandle,
|
||||
position: Point<Pixels>,
|
||||
button: impl Into<Option<MouseButton>>,
|
||||
modifiers: Modifiers,
|
||||
) {
|
||||
self.simulate_event(
|
||||
window,
|
||||
MouseMoveEvent {
|
||||
position,
|
||||
modifiers,
|
||||
pressed_button: button.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Simulates a mouse down event.
|
||||
pub fn simulate_mouse_down(
|
||||
&mut self,
|
||||
window: AnyWindowHandle,
|
||||
position: Point<Pixels>,
|
||||
button: MouseButton,
|
||||
modifiers: Modifiers,
|
||||
) {
|
||||
self.simulate_event(
|
||||
window,
|
||||
MouseDownEvent {
|
||||
position,
|
||||
modifiers,
|
||||
button,
|
||||
click_count: 1,
|
||||
first_mouse: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Simulates a mouse up event.
|
||||
pub fn simulate_mouse_up(
|
||||
&mut self,
|
||||
window: AnyWindowHandle,
|
||||
position: Point<Pixels>,
|
||||
button: MouseButton,
|
||||
modifiers: Modifiers,
|
||||
) {
|
||||
self.simulate_event(
|
||||
window,
|
||||
MouseUpEvent {
|
||||
position,
|
||||
modifiers,
|
||||
button,
|
||||
click_count: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Simulates a click (mouse down followed by mouse up).
|
||||
pub fn simulate_click(
|
||||
&mut self,
|
||||
window: AnyWindowHandle,
|
||||
position: Point<Pixels>,
|
||||
modifiers: Modifiers,
|
||||
) {
|
||||
self.simulate_mouse_down(window, position, MouseButton::Left, modifiers);
|
||||
self.simulate_mouse_up(window, position, MouseButton::Left, modifiers);
|
||||
}
|
||||
|
||||
/// Simulates an input event on the given window.
|
||||
pub fn simulate_event<E: InputEvent>(&mut self, window: AnyWindowHandle, event: E) {
|
||||
self.update_window(window, |_, window, cx| {
|
||||
window.dispatch_event(event.to_platform_input(), cx);
|
||||
})
|
||||
.ok();
|
||||
self.run_until_parked();
|
||||
}
|
||||
|
||||
/// Dispatches an action to the given window.
|
||||
pub fn dispatch_action(&mut self, window: AnyWindowHandle, action: impl Action) {
|
||||
self.update_window(window, |_, window, cx| {
|
||||
window.dispatch_action(action.boxed_clone(), cx);
|
||||
})
|
||||
.ok();
|
||||
self.run_until_parked();
|
||||
}
|
||||
|
||||
/// Writes to the clipboard.
|
||||
pub fn write_to_clipboard(&self, item: ClipboardItem) {
|
||||
self.platform.write_to_clipboard(item);
|
||||
}
|
||||
|
||||
/// Reads from the clipboard.
|
||||
pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
|
||||
self.platform.read_from_clipboard()
|
||||
}
|
||||
|
||||
/// Waits for a condition to become true, with a timeout.
|
||||
pub async fn wait_for<T: 'static>(
|
||||
&mut self,
|
||||
entity: &Entity<T>,
|
||||
predicate: impl Fn(&T) -> bool,
|
||||
timeout: Duration,
|
||||
) -> Result<()> {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
{
|
||||
let app = self.app.borrow();
|
||||
if predicate(entity.read(&app)) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if start.elapsed() > timeout {
|
||||
return Err(anyhow!("Timed out waiting for condition"));
|
||||
}
|
||||
|
||||
self.run_until_parked();
|
||||
self.background_executor
|
||||
.timer(Duration::from_millis(10))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the native window ID (CGWindowID on macOS) for a window.
|
||||
/// This can be used to capture screenshots of specific windows.
|
||||
#[cfg(feature = "screen-capture")]
|
||||
pub fn native_window_id(&mut self, window: AnyWindowHandle) -> Result<u32> {
|
||||
self.update_window(window, |_, window, _| {
|
||||
window
|
||||
.native_window_id()
|
||||
.ok_or_else(|| anyhow!("Window does not have a native window ID"))
|
||||
})?
|
||||
}
|
||||
|
||||
/// Captures a screenshot of the specified window.
|
||||
///
|
||||
/// This uses ScreenCaptureKit to capture the window contents, even if the window
|
||||
/// is positioned off-screen (e.g., at -10000, -10000 for invisible rendering).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `window` - The window handle to capture
|
||||
///
|
||||
/// # Returns
|
||||
/// An `RgbaImage` containing the captured window contents, or an error if capture failed.
|
||||
#[cfg(feature = "screen-capture")]
|
||||
pub async fn capture_screenshot(&mut self, window: AnyWindowHandle) -> Result<RgbaImage> {
|
||||
let window_id = self.native_window_id(window)?;
|
||||
|
||||
let rx = capture_window_screenshot(window_id);
|
||||
|
||||
rx.await
|
||||
.map_err(|_| anyhow!("Screenshot capture was cancelled"))?
|
||||
}
|
||||
|
||||
/// Waits for animations to complete by waiting a couple of frames.
|
||||
pub async fn wait_for_animations(&self) {
|
||||
self.background_executor
|
||||
.timer(Duration::from_millis(32))
|
||||
.await;
|
||||
self.run_until_parked();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VisualTestAppContext {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppContext for VisualTestAppContext {
|
||||
type Result<T> = T;
|
||||
|
||||
fn new<T: 'static>(
|
||||
&mut self,
|
||||
build_entity: impl FnOnce(&mut Context<T>) -> T,
|
||||
) -> Self::Result<Entity<T>> {
|
||||
let mut app = self.app.borrow_mut();
|
||||
app.new(build_entity)
|
||||
}
|
||||
|
||||
fn reserve_entity<T: 'static>(&mut self) -> Self::Result<crate::Reservation<T>> {
|
||||
let mut app = self.app.borrow_mut();
|
||||
app.reserve_entity()
|
||||
}
|
||||
|
||||
fn insert_entity<T: 'static>(
|
||||
&mut self,
|
||||
reservation: crate::Reservation<T>,
|
||||
build_entity: impl FnOnce(&mut Context<T>) -> T,
|
||||
) -> Self::Result<Entity<T>> {
|
||||
let mut app = self.app.borrow_mut();
|
||||
app.insert_entity(reservation, build_entity)
|
||||
}
|
||||
|
||||
fn update_entity<T: 'static, R>(
|
||||
&mut self,
|
||||
handle: &Entity<T>,
|
||||
update: impl FnOnce(&mut T, &mut Context<T>) -> R,
|
||||
) -> Self::Result<R> {
|
||||
let mut app = self.app.borrow_mut();
|
||||
app.update_entity(handle, update)
|
||||
}
|
||||
|
||||
fn as_mut<'a, T>(&'a mut self, _: &Entity<T>) -> Self::Result<crate::GpuiBorrow<'a, T>>
|
||||
where
|
||||
T: 'static,
|
||||
{
|
||||
panic!("Cannot use as_mut with a visual test app context. Try calling update() first")
|
||||
}
|
||||
|
||||
fn read_entity<T, R>(
|
||||
&self,
|
||||
handle: &Entity<T>,
|
||||
read: impl FnOnce(&T, &App) -> R,
|
||||
) -> Self::Result<R>
|
||||
where
|
||||
T: 'static,
|
||||
{
|
||||
let app = self.app.borrow();
|
||||
app.read_entity(handle, read)
|
||||
}
|
||||
|
||||
fn update_window<T, F>(&mut self, window: AnyWindowHandle, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(AnyView, &mut Window, &mut App) -> T,
|
||||
{
|
||||
let mut lock = self.app.borrow_mut();
|
||||
lock.update_window(window, f)
|
||||
}
|
||||
|
||||
fn read_window<T, R>(
|
||||
&self,
|
||||
window: &WindowHandle<T>,
|
||||
read: impl FnOnce(Entity<T>, &App) -> R,
|
||||
) -> Result<R>
|
||||
where
|
||||
T: 'static,
|
||||
{
|
||||
let app = self.app.borrow();
|
||||
app.read_window(window, read)
|
||||
}
|
||||
|
||||
fn background_spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>
|
||||
where
|
||||
R: Send + 'static,
|
||||
{
|
||||
self.background_executor.spawn(future)
|
||||
}
|
||||
|
||||
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result<R>
|
||||
where
|
||||
G: Global,
|
||||
{
|
||||
let app = self.app.borrow();
|
||||
callback(app.global::<G>(), &app)
|
||||
}
|
||||
}
|
||||
@@ -425,6 +425,7 @@ impl BackgroundExecutor {
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Fut::Output, impl Future<Output = Fut::Output> + use<Fut>> {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::time::Instant;
|
||||
|
||||
use parking::Parker;
|
||||
|
||||
@@ -432,8 +433,36 @@ impl BackgroundExecutor {
|
||||
if timeout == Some(Duration::ZERO) {
|
||||
return Err(future);
|
||||
}
|
||||
|
||||
// If there's no test dispatcher, fall back to production blocking behavior
|
||||
let Some(dispatcher) = self.dispatcher.as_test() else {
|
||||
return Err(future);
|
||||
let deadline = timeout.map(|timeout| Instant::now() + timeout);
|
||||
|
||||
let parker = Parker::new();
|
||||
let unparker = parker.unparker();
|
||||
let waker = waker_fn(move || {
|
||||
unparker.unpark();
|
||||
});
|
||||
let mut cx = std::task::Context::from_waker(&waker);
|
||||
|
||||
loop {
|
||||
match future.as_mut().poll(&mut cx) {
|
||||
Poll::Ready(result) => return Ok(result),
|
||||
Poll::Pending => {
|
||||
let timeout = deadline
|
||||
.map(|deadline| deadline.saturating_duration_since(Instant::now()));
|
||||
if let Some(timeout) = timeout {
|
||||
if !parker.park_timeout(timeout)
|
||||
&& deadline.is_some_and(|deadline| deadline < Instant::now())
|
||||
{
|
||||
return Err(future);
|
||||
}
|
||||
} else {
|
||||
parker.park();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut max_ticks = if timeout.is_some() {
|
||||
|
||||
@@ -47,6 +47,8 @@ use crate::{
|
||||
use anyhow::Result;
|
||||
use async_task::Runnable;
|
||||
use futures::channel::oneshot;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use image::RgbaImage;
|
||||
use image::codecs::gif::GifDecoder;
|
||||
use image::{AnimationDecoder as _, Frame};
|
||||
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||
@@ -88,6 +90,15 @@ pub use linux::layer_shell;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream};
|
||||
|
||||
#[cfg(all(
|
||||
target_os = "macos",
|
||||
feature = "screen-capture",
|
||||
any(test, feature = "test-support")
|
||||
))]
|
||||
pub use mac::{
|
||||
capture_window_screenshot, cv_pixel_buffer_to_rgba_image, screen_capture_frame_to_rgba_image,
|
||||
};
|
||||
|
||||
/// Returns a background executor for the current platform.
|
||||
pub fn background_executor() -> BackgroundExecutor {
|
||||
current_platform(true).background_executor()
|
||||
@@ -564,6 +575,21 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
|
||||
fn as_test(&mut self) -> Option<&mut TestWindow> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the native window ID (CGWindowID on macOS) for window capture.
|
||||
/// This is used by visual testing infrastructure to capture window screenshots.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn native_window_id(&self) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Renders the given scene to a texture and returns the pixel data as an RGBA image.
|
||||
/// This does not present the frame to screen - useful for visual testing where we want
|
||||
/// to capture what would be rendered without displaying it or requiring the window to be visible.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn render_to_image(&self, _scene: &Scene) -> Result<RgbaImage> {
|
||||
anyhow::bail!("render_to_image not implemented for this platform")
|
||||
}
|
||||
}
|
||||
|
||||
/// This type is public so that our test macro can generate and use it, but it should not
|
||||
@@ -1348,10 +1374,6 @@ pub enum WindowKind {
|
||||
/// docks, notifications or wallpapers.
|
||||
#[cfg(all(target_os = "linux", feature = "wayland"))]
|
||||
LayerShell(layer_shell::LayerShellOptions),
|
||||
|
||||
/// A window that appears on top of its parent window and blocks interaction with it
|
||||
/// until the modal window is closed
|
||||
Dialog,
|
||||
}
|
||||
|
||||
/// The appearance of the window, as defined by the operating system.
|
||||
|
||||
@@ -7,9 +7,13 @@ use crate::{
|
||||
PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, Underline,
|
||||
get_gamma_correction_ratios,
|
||||
};
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use anyhow::Result;
|
||||
use blade_graphics as gpu;
|
||||
use blade_util::{BufferBelt, BufferBeltDescriptor};
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use image::RgbaImage;
|
||||
#[cfg(target_os = "macos")]
|
||||
use media::core_video::CVMetalTextureCache;
|
||||
use std::sync::Arc;
|
||||
@@ -917,6 +921,13 @@ impl BladeRenderer {
|
||||
self.wait_for_gpu();
|
||||
self.last_sync_point = Some(sync_point);
|
||||
}
|
||||
|
||||
/// Renders the scene to a texture and returns the pixel data as an RGBA image.
|
||||
/// This is not yet implemented for BladeRenderer.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn render_to_image(&mut self, _scene: &Scene) -> Result<RgbaImage> {
|
||||
anyhow::bail!("render_to_image is not yet implemented for BladeRenderer")
|
||||
}
|
||||
}
|
||||
|
||||
fn create_path_intermediate_texture(
|
||||
|
||||
@@ -36,6 +36,12 @@ use wayland_client::{
|
||||
wl_shm_pool, wl_surface,
|
||||
},
|
||||
};
|
||||
use wayland_protocols::wp::cursor_shape::v1::client::{
|
||||
wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1,
|
||||
};
|
||||
use wayland_protocols::wp::fractional_scale::v1::client::{
|
||||
wp_fractional_scale_manager_v1, wp_fractional_scale_v1,
|
||||
};
|
||||
use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{
|
||||
self, ZwpPrimarySelectionOfferV1,
|
||||
};
|
||||
@@ -55,14 +61,6 @@ use wayland_protocols::xdg::decoration::zv1::client::{
|
||||
zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
|
||||
};
|
||||
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
|
||||
use wayland_protocols::{
|
||||
wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1},
|
||||
xdg::dialog::v1::client::xdg_wm_dialog_v1::{self, XdgWmDialogV1},
|
||||
};
|
||||
use wayland_protocols::{
|
||||
wp::fractional_scale::v1::client::{wp_fractional_scale_manager_v1, wp_fractional_scale_v1},
|
||||
xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1,
|
||||
};
|
||||
use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blur_manager};
|
||||
use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1};
|
||||
use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1;
|
||||
@@ -124,7 +122,6 @@ pub struct Globals {
|
||||
pub layer_shell: Option<zwlr_layer_shell_v1::ZwlrLayerShellV1>,
|
||||
pub blur_manager: Option<org_kde_kwin_blur_manager::OrgKdeKwinBlurManager>,
|
||||
pub text_input_manager: Option<zwp_text_input_manager_v3::ZwpTextInputManagerV3>,
|
||||
pub dialog: Option<xdg_wm_dialog_v1::XdgWmDialogV1>,
|
||||
pub executor: ForegroundExecutor,
|
||||
}
|
||||
|
||||
@@ -135,7 +132,6 @@ impl Globals {
|
||||
qh: QueueHandle<WaylandClientStatePtr>,
|
||||
seat: wl_seat::WlSeat,
|
||||
) -> Self {
|
||||
let dialog_v = XdgWmDialogV1::interface().version;
|
||||
Globals {
|
||||
activation: globals.bind(&qh, 1..=1, ()).ok(),
|
||||
compositor: globals
|
||||
@@ -164,7 +160,6 @@ impl Globals {
|
||||
layer_shell: globals.bind(&qh, 1..=5, ()).ok(),
|
||||
blur_manager: globals.bind(&qh, 1..=1, ()).ok(),
|
||||
text_input_manager: globals.bind(&qh, 1..=1, ()).ok(),
|
||||
dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(),
|
||||
executor,
|
||||
qh,
|
||||
}
|
||||
@@ -734,7 +729,10 @@ impl LinuxClient for WaylandClient {
|
||||
) -> anyhow::Result<Box<dyn PlatformWindow>> {
|
||||
let mut state = self.0.borrow_mut();
|
||||
|
||||
let parent = state.keyboard_focused_window.clone();
|
||||
let parent = state
|
||||
.keyboard_focused_window
|
||||
.as_ref()
|
||||
.and_then(|w| w.toplevel());
|
||||
|
||||
let (window, surface_id) = WaylandWindow::new(
|
||||
handle,
|
||||
@@ -753,12 +751,7 @@ impl LinuxClient for WaylandClient {
|
||||
fn set_cursor_style(&self, style: CursorStyle) {
|
||||
let mut state = self.0.borrow_mut();
|
||||
|
||||
let need_update = state.cursor_style != Some(style)
|
||||
&& (state.mouse_focused_window.is_none()
|
||||
|| state
|
||||
.mouse_focused_window
|
||||
.as_ref()
|
||||
.is_some_and(|w| !w.is_blocked()));
|
||||
let need_update = state.cursor_style != Some(style);
|
||||
|
||||
if need_update {
|
||||
let serial = state.serial_tracker.get(SerialKind::MouseEnter);
|
||||
@@ -1018,7 +1011,7 @@ impl Dispatch<WlCallback, ObjectId> for WaylandClientStatePtr {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_window(
|
||||
fn get_window(
|
||||
mut state: &mut RefMut<WaylandClientState>,
|
||||
surface_id: &ObjectId,
|
||||
) -> Option<WaylandWindowStatePtr> {
|
||||
@@ -1661,30 +1654,6 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
|
||||
state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32)));
|
||||
|
||||
if let Some(window) = state.mouse_focused_window.clone() {
|
||||
if window.is_blocked() {
|
||||
let default_style = CursorStyle::Arrow;
|
||||
if state.cursor_style != Some(default_style) {
|
||||
let serial = state.serial_tracker.get(SerialKind::MouseEnter);
|
||||
state.cursor_style = Some(default_style);
|
||||
|
||||
if let Some(cursor_shape_device) = &state.cursor_shape_device {
|
||||
cursor_shape_device.set_shape(serial, default_style.to_shape());
|
||||
} else {
|
||||
// cursor-shape-v1 isn't supported, set the cursor using a surface.
|
||||
let wl_pointer = state
|
||||
.wl_pointer
|
||||
.clone()
|
||||
.expect("window is focused by pointer");
|
||||
let scale = window.primary_output_scale();
|
||||
state.cursor.set_icon(
|
||||
&wl_pointer,
|
||||
serial,
|
||||
default_style.to_icon_names(),
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if state
|
||||
.keyboard_focused_window
|
||||
.as_ref()
|
||||
@@ -2256,27 +2225,3 @@ impl Dispatch<zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1, ()>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<XdgWmDialogV1, ()> for WaylandClientStatePtr {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &XdgWmDialogV1,
|
||||
_: <XdgWmDialogV1 as Proxy>::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<XdgDialogV1, ()> for WaylandClientStatePtr {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &XdgDialogV1,
|
||||
_event: <XdgDialogV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qhandle: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::{
|
||||
};
|
||||
|
||||
use blade_graphics as gpu;
|
||||
use collections::{FxHashSet, HashMap};
|
||||
use collections::HashMap;
|
||||
use futures::channel::oneshot::Receiver;
|
||||
|
||||
use raw_window_handle as rwh;
|
||||
@@ -20,7 +20,7 @@ use wayland_protocols::xdg::shell::client::xdg_surface;
|
||||
use wayland_protocols::xdg::shell::client::xdg_toplevel::{self};
|
||||
use wayland_protocols::{
|
||||
wp::fractional_scale::v1::client::wp_fractional_scale_v1,
|
||||
xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1,
|
||||
xdg::shell::client::xdg_toplevel::XdgToplevel,
|
||||
};
|
||||
use wayland_protocols_plasma::blur::client::org_kde_kwin_blur;
|
||||
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1;
|
||||
@@ -29,7 +29,7 @@ use crate::{
|
||||
AnyWindowHandle, Bounds, Decorations, Globals, GpuSpecs, Modifiers, Output, Pixels,
|
||||
PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions,
|
||||
ResizeEdge, Size, Tiling, WaylandClientStatePtr, WindowAppearance, WindowBackgroundAppearance,
|
||||
WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, get_window,
|
||||
WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams,
|
||||
layer_shell::LayerShellNotSupportedError, px, size,
|
||||
};
|
||||
use crate::{
|
||||
@@ -87,8 +87,6 @@ struct InProgressConfigure {
|
||||
pub struct WaylandWindowState {
|
||||
surface_state: WaylandSurfaceState,
|
||||
acknowledged_first_configure: bool,
|
||||
parent: Option<WaylandWindowStatePtr>,
|
||||
children: FxHashSet<ObjectId>,
|
||||
pub surface: wl_surface::WlSurface,
|
||||
app_id: Option<String>,
|
||||
appearance: WindowAppearance,
|
||||
@@ -128,7 +126,7 @@ impl WaylandSurfaceState {
|
||||
surface: &wl_surface::WlSurface,
|
||||
globals: &Globals,
|
||||
params: &WindowParams,
|
||||
parent: Option<WaylandWindowStatePtr>,
|
||||
parent: Option<XdgToplevel>,
|
||||
) -> anyhow::Result<Self> {
|
||||
// For layer_shell windows, create a layer surface instead of an xdg surface
|
||||
if let WindowKind::LayerShell(options) = ¶ms.kind {
|
||||
@@ -180,28 +178,10 @@ impl WaylandSurfaceState {
|
||||
.get_xdg_surface(&surface, &globals.qh, surface.id());
|
||||
|
||||
let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id());
|
||||
let xdg_parent = parent.as_ref().and_then(|w| w.toplevel());
|
||||
|
||||
if params.kind == WindowKind::Floating || params.kind == WindowKind::Dialog {
|
||||
toplevel.set_parent(xdg_parent.as_ref());
|
||||
if params.kind == WindowKind::Floating {
|
||||
toplevel.set_parent(parent.as_ref());
|
||||
}
|
||||
|
||||
let dialog = if params.kind == WindowKind::Dialog {
|
||||
let dialog = globals.dialog.as_ref().map(|dialog| {
|
||||
let xdg_dialog = dialog.get_xdg_dialog(&toplevel, &globals.qh, ());
|
||||
xdg_dialog.set_modal();
|
||||
xdg_dialog
|
||||
});
|
||||
|
||||
if let Some(parent) = parent.as_ref() {
|
||||
parent.add_child(surface.id());
|
||||
}
|
||||
|
||||
dialog
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(size) = params.window_min_size {
|
||||
toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32);
|
||||
}
|
||||
@@ -218,7 +198,6 @@ impl WaylandSurfaceState {
|
||||
xdg_surface,
|
||||
toplevel,
|
||||
decoration,
|
||||
dialog,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -227,7 +206,6 @@ pub struct WaylandXdgSurfaceState {
|
||||
xdg_surface: xdg_surface::XdgSurface,
|
||||
toplevel: xdg_toplevel::XdgToplevel,
|
||||
decoration: Option<zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1>,
|
||||
dialog: Option<XdgDialogV1>,
|
||||
}
|
||||
|
||||
pub struct WaylandLayerSurfaceState {
|
||||
@@ -280,13 +258,7 @@ impl WaylandSurfaceState {
|
||||
xdg_surface,
|
||||
toplevel,
|
||||
decoration: _decoration,
|
||||
dialog,
|
||||
}) => {
|
||||
// drop the dialog before toplevel so compositor can explicitly unapply it's effects
|
||||
if let Some(dialog) = dialog {
|
||||
dialog.destroy();
|
||||
}
|
||||
|
||||
// The role object (toplevel) must always be destroyed before the xdg_surface.
|
||||
// See https://wayland.app/protocols/xdg-shell#xdg_surface:request:destroy
|
||||
toplevel.destroy();
|
||||
@@ -316,7 +288,6 @@ impl WaylandWindowState {
|
||||
globals: Globals,
|
||||
gpu_context: &BladeContext,
|
||||
options: WindowParams,
|
||||
parent: Option<WaylandWindowStatePtr>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let renderer = {
|
||||
let raw_window = RawWindow {
|
||||
@@ -348,8 +319,6 @@ impl WaylandWindowState {
|
||||
Ok(Self {
|
||||
surface_state,
|
||||
acknowledged_first_configure: false,
|
||||
parent,
|
||||
children: FxHashSet::default(),
|
||||
surface,
|
||||
app_id: None,
|
||||
blur: None,
|
||||
@@ -422,10 +391,6 @@ impl Drop for WaylandWindow {
|
||||
fn drop(&mut self) {
|
||||
let mut state = self.0.state.borrow_mut();
|
||||
let surface_id = state.surface.id();
|
||||
if let Some(parent) = state.parent.as_ref() {
|
||||
parent.state.borrow_mut().children.remove(&surface_id);
|
||||
}
|
||||
|
||||
let client = state.client.clone();
|
||||
|
||||
state.renderer.destroy();
|
||||
@@ -483,10 +448,10 @@ impl WaylandWindow {
|
||||
client: WaylandClientStatePtr,
|
||||
params: WindowParams,
|
||||
appearance: WindowAppearance,
|
||||
parent: Option<WaylandWindowStatePtr>,
|
||||
parent: Option<XdgToplevel>,
|
||||
) -> anyhow::Result<(Self, ObjectId)> {
|
||||
let surface = globals.compositor.create_surface(&globals.qh, ());
|
||||
let surface_state = WaylandSurfaceState::new(&surface, &globals, ¶ms, parent.clone())?;
|
||||
let surface_state = WaylandSurfaceState::new(&surface, &globals, ¶ms, parent)?;
|
||||
|
||||
if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() {
|
||||
fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id());
|
||||
@@ -508,7 +473,6 @@ impl WaylandWindow {
|
||||
globals,
|
||||
gpu_context,
|
||||
params,
|
||||
parent,
|
||||
)?)),
|
||||
callbacks: Rc::new(RefCell::new(Callbacks::default())),
|
||||
});
|
||||
@@ -537,16 +501,6 @@ impl WaylandWindowStatePtr {
|
||||
Rc::ptr_eq(&self.state, &other.state)
|
||||
}
|
||||
|
||||
pub fn add_child(&self, child: ObjectId) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.children.insert(child);
|
||||
}
|
||||
|
||||
pub fn is_blocked(&self) -> bool {
|
||||
let state = self.state.borrow();
|
||||
!state.children.is_empty()
|
||||
}
|
||||
|
||||
pub fn frame(&self) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.surface.frame(&state.globals.qh, state.surface.id());
|
||||
@@ -864,9 +818,6 @@ impl WaylandWindowStatePtr {
|
||||
}
|
||||
|
||||
pub fn handle_ime(&self, ime: ImeInput) {
|
||||
if self.is_blocked() {
|
||||
return;
|
||||
}
|
||||
let mut state = self.state.borrow_mut();
|
||||
if let Some(mut input_handler) = state.input_handler.take() {
|
||||
drop(state);
|
||||
@@ -943,21 +894,6 @@ impl WaylandWindowStatePtr {
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let state = self.state.borrow();
|
||||
let client = state.client.get_client();
|
||||
#[allow(clippy::mutable_key_type)]
|
||||
let children = state.children.clone();
|
||||
drop(state);
|
||||
|
||||
for child in children {
|
||||
let mut client_state = client.borrow_mut();
|
||||
let window = get_window(&mut client_state, &child);
|
||||
drop(client_state);
|
||||
|
||||
if let Some(child) = window {
|
||||
child.close();
|
||||
}
|
||||
}
|
||||
let mut callbacks = self.callbacks.borrow_mut();
|
||||
if let Some(fun) = callbacks.close.take() {
|
||||
fun()
|
||||
@@ -965,9 +901,6 @@ impl WaylandWindowStatePtr {
|
||||
}
|
||||
|
||||
pub fn handle_input(&self, input: PlatformInput) {
|
||||
if self.is_blocked() {
|
||||
return;
|
||||
}
|
||||
if let Some(ref mut fun) = self.callbacks.borrow_mut().input
|
||||
&& !fun(input.clone()).propagate
|
||||
{
|
||||
|
||||
@@ -222,7 +222,7 @@ pub struct X11ClientState {
|
||||
pub struct X11ClientStatePtr(pub Weak<RefCell<X11ClientState>>);
|
||||
|
||||
impl X11ClientStatePtr {
|
||||
pub fn get_client(&self) -> Option<X11Client> {
|
||||
fn get_client(&self) -> Option<X11Client> {
|
||||
self.0.upgrade().map(X11Client)
|
||||
}
|
||||
|
||||
@@ -752,7 +752,7 @@ impl X11Client {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_window(&self, win: xproto::Window) -> Option<X11WindowStatePtr> {
|
||||
fn get_window(&self, win: xproto::Window) -> Option<X11WindowStatePtr> {
|
||||
let state = self.0.borrow();
|
||||
state
|
||||
.windows
|
||||
@@ -789,12 +789,12 @@ impl X11Client {
|
||||
let [atom, arg1, arg2, arg3, arg4] = event.data.as_data32();
|
||||
let mut state = self.0.borrow_mut();
|
||||
|
||||
if atom == state.atoms.WM_DELETE_WINDOW && window.should_close() {
|
||||
if atom == state.atoms.WM_DELETE_WINDOW {
|
||||
// window "x" button clicked by user
|
||||
// Rest of the close logic is handled in drop_window()
|
||||
drop(state);
|
||||
window.close();
|
||||
state = self.0.borrow_mut();
|
||||
if window.should_close() {
|
||||
// Rest of the close logic is handled in drop_window()
|
||||
window.close();
|
||||
}
|
||||
} else if atom == state.atoms._NET_WM_SYNC_REQUEST {
|
||||
window.state.borrow_mut().last_sync_counter =
|
||||
Some(x11rb::protocol::sync::Int64 {
|
||||
@@ -1216,33 +1216,6 @@ impl X11Client {
|
||||
Event::XinputMotion(event) => {
|
||||
let window = self.get_window(event.event)?;
|
||||
let mut state = self.0.borrow_mut();
|
||||
if window.is_blocked() {
|
||||
// We want to set the cursor to the default arrow
|
||||
// when the window is blocked
|
||||
let style = CursorStyle::Arrow;
|
||||
|
||||
let current_style = state
|
||||
.cursor_styles
|
||||
.get(&window.x_window)
|
||||
.unwrap_or(&CursorStyle::Arrow);
|
||||
if *current_style != style
|
||||
&& let Some(cursor) = state.get_cursor_icon(style)
|
||||
{
|
||||
state.cursor_styles.insert(window.x_window, style);
|
||||
check_reply(
|
||||
|| "Failed to set cursor style",
|
||||
state.xcb_connection.change_window_attributes(
|
||||
window.x_window,
|
||||
&ChangeWindowAttributesAux {
|
||||
cursor: Some(cursor),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
)
|
||||
.log_err();
|
||||
state.xcb_connection.flush().log_err();
|
||||
};
|
||||
}
|
||||
let pressed_button = pressed_button_from_mask(event.button_mask[0]);
|
||||
let position = point(
|
||||
px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor),
|
||||
@@ -1516,7 +1489,7 @@ impl LinuxClient for X11Client {
|
||||
let parent_window = state
|
||||
.keyboard_focused_window
|
||||
.and_then(|focused_window| state.windows.get(&focused_window))
|
||||
.map(|w| w.window.clone());
|
||||
.map(|window| window.window.x_window);
|
||||
let x_window = state
|
||||
.xcb_connection
|
||||
.generate_id()
|
||||
@@ -1571,15 +1544,7 @@ impl LinuxClient for X11Client {
|
||||
.cursor_styles
|
||||
.get(&focused_window)
|
||||
.unwrap_or(&CursorStyle::Arrow);
|
||||
|
||||
let window = state
|
||||
.mouse_focused_window
|
||||
.and_then(|w| state.windows.get(&w));
|
||||
|
||||
let should_change = *current_style != style
|
||||
&& (window.is_none() || window.is_some_and(|w| !w.is_blocked()));
|
||||
|
||||
if !should_change {
|
||||
if *current_style == style {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ use crate::{
|
||||
};
|
||||
|
||||
use blade_graphics as gpu;
|
||||
use collections::FxHashSet;
|
||||
use raw_window_handle as rwh;
|
||||
use util::{ResultExt, maybe};
|
||||
use x11rb::{
|
||||
@@ -75,7 +74,6 @@ x11rb::atom_manager! {
|
||||
_NET_WM_WINDOW_TYPE,
|
||||
_NET_WM_WINDOW_TYPE_NOTIFICATION,
|
||||
_NET_WM_WINDOW_TYPE_DIALOG,
|
||||
_NET_WM_STATE_MODAL,
|
||||
_NET_WM_SYNC,
|
||||
_NET_SUPPORTED,
|
||||
_MOTIF_WM_HINTS,
|
||||
@@ -251,8 +249,6 @@ pub struct Callbacks {
|
||||
|
||||
pub struct X11WindowState {
|
||||
pub destroyed: bool,
|
||||
parent: Option<X11WindowStatePtr>,
|
||||
children: FxHashSet<xproto::Window>,
|
||||
client: X11ClientStatePtr,
|
||||
executor: ForegroundExecutor,
|
||||
atoms: XcbAtoms,
|
||||
@@ -398,7 +394,7 @@ impl X11WindowState {
|
||||
atoms: &XcbAtoms,
|
||||
scale_factor: f32,
|
||||
appearance: WindowAppearance,
|
||||
parent_window: Option<X11WindowStatePtr>,
|
||||
parent_window: Option<xproto::Window>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let x_screen_index = params
|
||||
.display_id
|
||||
@@ -550,8 +546,8 @@ impl X11WindowState {
|
||||
)?;
|
||||
}
|
||||
|
||||
if params.kind == WindowKind::Floating || params.kind == WindowKind::Dialog {
|
||||
if let Some(parent_window) = parent_window.as_ref().map(|w| w.x_window) {
|
||||
if params.kind == WindowKind::Floating {
|
||||
if let Some(parent_window) = parent_window {
|
||||
// WM_TRANSIENT_FOR hint indicating the main application window. For floating windows, we set
|
||||
// a parent window (WM_TRANSIENT_FOR) such that the window manager knows where to
|
||||
// place the floating window in relation to the main window.
|
||||
@@ -567,23 +563,11 @@ impl X11WindowState {
|
||||
),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let parent = if params.kind == WindowKind::Dialog
|
||||
&& let Some(parent) = parent_window
|
||||
{
|
||||
parent.add_child(x_window);
|
||||
|
||||
Some(parent)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if params.kind == WindowKind::Dialog {
|
||||
// _NET_WM_WINDOW_TYPE_DIALOG indicates that this is a dialog (floating) window
|
||||
// https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html
|
||||
check_reply(
|
||||
|| "X11 ChangeProperty32 setting window type for dialog window failed.",
|
||||
|| "X11 ChangeProperty32 setting window type for floating window failed.",
|
||||
xcb.change_property32(
|
||||
xproto::PropMode::REPLACE,
|
||||
x_window,
|
||||
@@ -592,20 +576,6 @@ impl X11WindowState {
|
||||
&[atoms._NET_WM_WINDOW_TYPE_DIALOG],
|
||||
),
|
||||
)?;
|
||||
|
||||
// We set the modal state for dialog windows, so that the window manager
|
||||
// can handle it appropriately (e.g., prevent interaction with the parent window
|
||||
// while the dialog is open).
|
||||
check_reply(
|
||||
|| "X11 ChangeProperty32 setting modal state for dialog window failed.",
|
||||
xcb.change_property32(
|
||||
xproto::PropMode::REPLACE,
|
||||
x_window,
|
||||
atoms._NET_WM_STATE,
|
||||
xproto::AtomEnum::ATOM,
|
||||
&[atoms._NET_WM_STATE_MODAL],
|
||||
),
|
||||
)?;
|
||||
}
|
||||
|
||||
check_reply(
|
||||
@@ -697,8 +667,6 @@ impl X11WindowState {
|
||||
let display = Rc::new(X11Display::new(xcb, scale_factor, x_screen_index)?);
|
||||
|
||||
Ok(Self {
|
||||
parent,
|
||||
children: FxHashSet::default(),
|
||||
client,
|
||||
executor,
|
||||
display,
|
||||
@@ -752,11 +720,6 @@ pub(crate) struct X11Window(pub X11WindowStatePtr);
|
||||
impl Drop for X11Window {
|
||||
fn drop(&mut self) {
|
||||
let mut state = self.0.state.borrow_mut();
|
||||
|
||||
if let Some(parent) = state.parent.as_ref() {
|
||||
parent.state.borrow_mut().children.remove(&self.0.x_window);
|
||||
}
|
||||
|
||||
state.renderer.destroy();
|
||||
|
||||
let destroy_x_window = maybe!({
|
||||
@@ -771,6 +734,8 @@ impl Drop for X11Window {
|
||||
.log_err();
|
||||
|
||||
if destroy_x_window.is_some() {
|
||||
// Mark window as destroyed so that we can filter out when X11 events
|
||||
// for it still come in.
|
||||
state.destroyed = true;
|
||||
|
||||
let this_ptr = self.0.clone();
|
||||
@@ -808,7 +773,7 @@ impl X11Window {
|
||||
atoms: &XcbAtoms,
|
||||
scale_factor: f32,
|
||||
appearance: WindowAppearance,
|
||||
parent_window: Option<X11WindowStatePtr>,
|
||||
parent_window: Option<xproto::Window>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let ptr = X11WindowStatePtr {
|
||||
state: Rc::new(RefCell::new(X11WindowState::new(
|
||||
@@ -1014,31 +979,7 @@ impl X11WindowStatePtr {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_child(&self, child: xproto::Window) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.children.insert(child);
|
||||
}
|
||||
|
||||
pub fn is_blocked(&self) -> bool {
|
||||
let state = self.state.borrow();
|
||||
!state.children.is_empty()
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let state = self.state.borrow();
|
||||
let client = state.client.clone();
|
||||
#[allow(clippy::mutable_key_type)]
|
||||
let children = state.children.clone();
|
||||
drop(state);
|
||||
|
||||
if let Some(client) = client.get_client() {
|
||||
for child in children {
|
||||
if let Some(child_window) = client.get_window(child) {
|
||||
child_window.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut callbacks = self.callbacks.borrow_mut();
|
||||
if let Some(fun) = callbacks.close.take() {
|
||||
fun()
|
||||
@@ -1053,9 +994,6 @@ impl X11WindowStatePtr {
|
||||
}
|
||||
|
||||
pub fn handle_input(&self, input: PlatformInput) {
|
||||
if self.is_blocked() {
|
||||
return;
|
||||
}
|
||||
if let Some(ref mut fun) = self.callbacks.borrow_mut().input
|
||||
&& !fun(input.clone()).propagate
|
||||
{
|
||||
@@ -1078,9 +1016,6 @@ impl X11WindowStatePtr {
|
||||
}
|
||||
|
||||
pub fn handle_ime_commit(&self, text: String) {
|
||||
if self.is_blocked() {
|
||||
return;
|
||||
}
|
||||
let mut state = self.state.borrow_mut();
|
||||
if let Some(mut input_handler) = state.input_handler.take() {
|
||||
drop(state);
|
||||
@@ -1091,9 +1026,6 @@ impl X11WindowStatePtr {
|
||||
}
|
||||
|
||||
pub fn handle_ime_preedit(&self, text: String) {
|
||||
if self.is_blocked() {
|
||||
return;
|
||||
}
|
||||
let mut state = self.state.borrow_mut();
|
||||
if let Some(mut input_handler) = state.input_handler.take() {
|
||||
drop(state);
|
||||
@@ -1104,9 +1036,6 @@ impl X11WindowStatePtr {
|
||||
}
|
||||
|
||||
pub fn handle_ime_unmark(&self) {
|
||||
if self.is_blocked() {
|
||||
return;
|
||||
}
|
||||
let mut state = self.state.borrow_mut();
|
||||
if let Some(mut input_handler) = state.input_handler.take() {
|
||||
drop(state);
|
||||
@@ -1117,9 +1046,6 @@ impl X11WindowStatePtr {
|
||||
}
|
||||
|
||||
pub fn handle_ime_delete(&self) {
|
||||
if self.is_blocked() {
|
||||
return;
|
||||
}
|
||||
let mut state = self.state.borrow_mut();
|
||||
if let Some(mut input_handler) = state.input_handler.take() {
|
||||
drop(state);
|
||||
|
||||
@@ -8,6 +8,10 @@ mod keyboard;
|
||||
|
||||
#[cfg(feature = "screen-capture")]
|
||||
mod screen_capture;
|
||||
#[cfg(all(feature = "screen-capture", any(test, feature = "test-support")))]
|
||||
pub use screen_capture::{
|
||||
capture_window_screenshot, cv_pixel_buffer_to_rgba_image, screen_capture_frame_to_rgba_image,
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "macos-blade"))]
|
||||
mod metal_atlas;
|
||||
|
||||
@@ -11,6 +11,8 @@ use cocoa::{
|
||||
foundation::{NSSize, NSUInteger},
|
||||
quartzcore::AutoresizingMask,
|
||||
};
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use image::RgbaImage;
|
||||
|
||||
use core_foundation::base::TCFType;
|
||||
use core_video::{
|
||||
@@ -46,9 +48,9 @@ pub unsafe fn new_renderer(
|
||||
_native_window: *mut c_void,
|
||||
_native_view: *mut c_void,
|
||||
_bounds: crate::Size<f32>,
|
||||
transparent: bool,
|
||||
_transparent: bool,
|
||||
) -> Renderer {
|
||||
MetalRenderer::new(context, transparent)
|
||||
MetalRenderer::new(context)
|
||||
}
|
||||
|
||||
pub(crate) struct InstanceBufferPool {
|
||||
@@ -128,7 +130,7 @@ pub struct PathRasterizationVertex {
|
||||
}
|
||||
|
||||
impl MetalRenderer {
|
||||
pub fn new(instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>, transparent: bool) -> Self {
|
||||
pub fn new(instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>) -> Self {
|
||||
// Prefer low‐power integrated GPUs on Intel Mac. On Apple
|
||||
// Silicon, there is only ever one GPU, so this is equivalent to
|
||||
// `metal::Device::system_default()`.
|
||||
@@ -152,13 +154,11 @@ impl MetalRenderer {
|
||||
let layer = metal::MetalLayer::new();
|
||||
layer.set_device(&device);
|
||||
layer.set_pixel_format(MTLPixelFormat::BGRA8Unorm);
|
||||
// Support direct-to-display rendering if the window is not transparent
|
||||
// https://developer.apple.com/documentation/metal/managing-your-game-window-for-metal-in-macos
|
||||
layer.set_opaque(!transparent);
|
||||
layer.set_opaque(false);
|
||||
layer.set_maximum_drawable_count(3);
|
||||
// We already present at display sync with the display link
|
||||
// This allows to use direct-to-display even in window mode
|
||||
layer.set_display_sync_enabled(false);
|
||||
// Allow texture reading for visual tests (captures screenshots without ScreenCaptureKit)
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
layer.set_framebuffer_only(false);
|
||||
unsafe {
|
||||
let _: () = msg_send![&*layer, setAllowsNextDrawableTimeout: NO];
|
||||
let _: () = msg_send![&*layer, setNeedsDisplayOnBoundsChange: YES];
|
||||
@@ -357,8 +357,8 @@ impl MetalRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_transparency(&self, transparent: bool) {
|
||||
self.layer.set_opaque(!transparent);
|
||||
pub fn update_transparency(&self, _transparent: bool) {
|
||||
// todo(mac)?
|
||||
}
|
||||
|
||||
pub fn destroy(&self) {
|
||||
@@ -431,6 +431,97 @@ impl MetalRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the scene to a texture and returns the pixel data as an RGBA image.
|
||||
/// This does not present the frame to screen - useful for visual testing
|
||||
/// where we want to capture what would be rendered without displaying it.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn render_to_image(&mut self, scene: &Scene) -> Result<RgbaImage> {
|
||||
let layer = self.layer.clone();
|
||||
let viewport_size = layer.drawable_size();
|
||||
let viewport_size: Size<DevicePixels> = size(
|
||||
(viewport_size.width.ceil() as i32).into(),
|
||||
(viewport_size.height.ceil() as i32).into(),
|
||||
);
|
||||
let drawable = layer
|
||||
.next_drawable()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get drawable for render_to_image"))?;
|
||||
|
||||
loop {
|
||||
let mut instance_buffer = self.instance_buffer_pool.lock().acquire(&self.device);
|
||||
|
||||
let command_buffer =
|
||||
self.draw_primitives(scene, &mut instance_buffer, drawable, viewport_size);
|
||||
|
||||
match command_buffer {
|
||||
Ok(command_buffer) => {
|
||||
let instance_buffer_pool = self.instance_buffer_pool.clone();
|
||||
let instance_buffer = Cell::new(Some(instance_buffer));
|
||||
let block = ConcreteBlock::new(move |_| {
|
||||
if let Some(instance_buffer) = instance_buffer.take() {
|
||||
instance_buffer_pool.lock().release(instance_buffer);
|
||||
}
|
||||
});
|
||||
let block = block.copy();
|
||||
command_buffer.add_completed_handler(&block);
|
||||
|
||||
// Commit and wait for completion without presenting
|
||||
command_buffer.commit();
|
||||
command_buffer.wait_until_completed();
|
||||
|
||||
// Read pixels from the texture
|
||||
let texture = drawable.texture();
|
||||
let width = texture.width() as u32;
|
||||
let height = texture.height() as u32;
|
||||
let bytes_per_row = width as usize * 4;
|
||||
let buffer_size = height as usize * bytes_per_row;
|
||||
|
||||
let mut pixels = vec![0u8; buffer_size];
|
||||
|
||||
let region = metal::MTLRegion {
|
||||
origin: metal::MTLOrigin { x: 0, y: 0, z: 0 },
|
||||
size: metal::MTLSize {
|
||||
width: width as u64,
|
||||
height: height as u64,
|
||||
depth: 1,
|
||||
},
|
||||
};
|
||||
|
||||
texture.get_bytes(
|
||||
pixels.as_mut_ptr() as *mut std::ffi::c_void,
|
||||
bytes_per_row as u64,
|
||||
region,
|
||||
0,
|
||||
);
|
||||
|
||||
// Convert BGRA to RGBA (swap B and R channels)
|
||||
for chunk in pixels.chunks_exact_mut(4) {
|
||||
chunk.swap(0, 2);
|
||||
}
|
||||
|
||||
return RgbaImage::from_raw(width, height, pixels).ok_or_else(|| {
|
||||
anyhow::anyhow!("Failed to create RgbaImage from pixel data")
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"failed to render: {}. retrying with larger instance buffer size",
|
||||
err
|
||||
);
|
||||
let mut instance_buffer_pool = self.instance_buffer_pool.lock();
|
||||
let buffer_size = instance_buffer_pool.buffer_size;
|
||||
if buffer_size >= 256 * 1024 * 1024 {
|
||||
anyhow::bail!("instance buffer size grew too large: {}", buffer_size);
|
||||
}
|
||||
instance_buffer_pool.reset(buffer_size * 2);
|
||||
log::info!(
|
||||
"increased instance buffer size to {}",
|
||||
instance_buffer_pool.buffer_size
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_primitives(
|
||||
&mut self,
|
||||
scene: &Scene,
|
||||
|
||||
@@ -7,17 +7,25 @@ use crate::{
|
||||
use anyhow::{Result, anyhow};
|
||||
use block::ConcreteBlock;
|
||||
use cocoa::{
|
||||
base::{YES, id, nil},
|
||||
base::{NO, YES, id, nil},
|
||||
foundation::NSArray,
|
||||
};
|
||||
use collections::HashMap;
|
||||
use core_foundation::base::TCFType;
|
||||
use core_graphics::display::{
|
||||
CGDirectDisplayID, CGDisplayCopyDisplayMode, CGDisplayModeGetPixelHeight,
|
||||
CGDisplayModeGetPixelWidth, CGDisplayModeRelease,
|
||||
use core_graphics::{
|
||||
base::CGFloat,
|
||||
color_space::CGColorSpace,
|
||||
display::{
|
||||
CGDirectDisplayID, CGDisplayCopyDisplayMode, CGDisplayModeGetPixelHeight,
|
||||
CGDisplayModeGetPixelWidth, CGDisplayModeRelease,
|
||||
},
|
||||
image::CGImage,
|
||||
};
|
||||
use core_video::pixel_buffer::CVPixelBuffer;
|
||||
use ctor::ctor;
|
||||
use foreign_types::ForeignType;
|
||||
use futures::channel::oneshot;
|
||||
use image::{ImageBuffer, Rgba, RgbaImage};
|
||||
use media::core_media::{CMSampleBuffer, CMSampleBufferRef};
|
||||
use metal::NSInteger;
|
||||
use objc::{
|
||||
@@ -285,6 +293,281 @@ pub(crate) fn get_sources() -> oneshot::Receiver<Result<Vec<Rc<dyn ScreenCapture
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures a single screenshot of a specific window by its CGWindowID.
|
||||
///
|
||||
/// This uses ScreenCaptureKit's `initWithDesktopIndependentWindow:` API which can
|
||||
/// capture windows even when they are positioned off-screen (e.g., at -10000, -10000).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `window_id` - The CGWindowID (NSWindow's windowNumber) of the window to capture
|
||||
///
|
||||
/// # Returns
|
||||
/// An `RgbaImage` containing the captured window contents, or an error if capture failed.
|
||||
pub fn capture_window_screenshot(window_id: u32) -> oneshot::Receiver<Result<RgbaImage>> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let tx = Rc::new(RefCell::new(Some(tx)));
|
||||
|
||||
unsafe {
|
||||
log::info!(
|
||||
"capture_window_screenshot: looking for window_id={}",
|
||||
window_id
|
||||
);
|
||||
let content_handler = ConcreteBlock::new(move |shareable_content: id, error: id| {
|
||||
log::info!("capture_window_screenshot: content handler called");
|
||||
if error != nil {
|
||||
if let Some(sender) = tx.borrow_mut().take() {
|
||||
let msg: id = msg_send![error, localizedDescription];
|
||||
sender
|
||||
.send(Err(anyhow!(
|
||||
"Failed to get shareable content: {:?}",
|
||||
NSStringExt::to_str(&msg)
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let windows: id = msg_send![shareable_content, windows];
|
||||
let count: usize = msg_send![windows, count];
|
||||
|
||||
let mut target_window: id = nil;
|
||||
log::info!(
|
||||
"capture_window_screenshot: searching {} windows for window_id={}",
|
||||
count,
|
||||
window_id
|
||||
);
|
||||
for i in 0..count {
|
||||
let window: id = msg_send![windows, objectAtIndex: i];
|
||||
let wid: u32 = msg_send![window, windowID];
|
||||
if wid == window_id {
|
||||
log::info!(
|
||||
"capture_window_screenshot: found matching window at index {}",
|
||||
i
|
||||
);
|
||||
target_window = window;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if target_window == nil {
|
||||
if let Some(sender) = tx.borrow_mut().take() {
|
||||
sender
|
||||
.send(Err(anyhow!(
|
||||
"Window with ID {} not found in shareable content",
|
||||
window_id
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("capture_window_screenshot: calling capture_window_frame");
|
||||
capture_window_frame(target_window, &tx);
|
||||
});
|
||||
let content_handler = content_handler.copy();
|
||||
|
||||
let _: () = msg_send![
|
||||
class!(SCShareableContent),
|
||||
getShareableContentExcludingDesktopWindows:NO
|
||||
onScreenWindowsOnly:NO
|
||||
completionHandler:content_handler
|
||||
];
|
||||
}
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
unsafe fn capture_window_frame(
|
||||
sc_window: id,
|
||||
tx: &Rc<RefCell<Option<oneshot::Sender<Result<RgbaImage>>>>>,
|
||||
) {
|
||||
log::info!("capture_window_frame: creating filter for window");
|
||||
let filter: id = msg_send![class!(SCContentFilter), alloc];
|
||||
let filter: id = msg_send![filter, initWithDesktopIndependentWindow: sc_window];
|
||||
log::info!("capture_window_frame: filter created: {:?}", filter);
|
||||
|
||||
let configuration: id = msg_send![class!(SCStreamConfiguration), alloc];
|
||||
let configuration: id = msg_send![configuration, init];
|
||||
|
||||
let frame: cocoa::foundation::NSRect = msg_send![sc_window, frame];
|
||||
let width = frame.size.width as i64;
|
||||
let height = frame.size.height as i64;
|
||||
log::info!("capture_window_frame: window frame {}x{}", width, height);
|
||||
|
||||
if width <= 0 || height <= 0 {
|
||||
if let Some(tx) = tx.borrow_mut().take() {
|
||||
tx.send(Err(anyhow!(
|
||||
"Window has invalid dimensions: {}x{}",
|
||||
width,
|
||||
height
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let _: () = msg_send![configuration, setWidth: width];
|
||||
let _: () = msg_send![configuration, setHeight: height];
|
||||
let _: () = msg_send![configuration, setScalesToFit: true];
|
||||
let _: () = msg_send![configuration, setPixelFormat: 0x42475241u32]; // 'BGRA'
|
||||
let _: () = msg_send![configuration, setShowsCursor: false];
|
||||
let _: () = msg_send![configuration, setCapturesAudio: false];
|
||||
|
||||
let tx_for_capture = tx.clone();
|
||||
// The completion handler receives (CGImageRef, NSError*), not CMSampleBuffer
|
||||
let capture_handler =
|
||||
ConcreteBlock::new(move |cg_image: core_graphics::sys::CGImageRef, error: id| {
|
||||
log::info!("Screenshot capture handler called");
|
||||
|
||||
let Some(tx) = tx_for_capture.borrow_mut().take() else {
|
||||
log::warn!("Screenshot capture: tx already taken");
|
||||
return;
|
||||
};
|
||||
|
||||
unsafe {
|
||||
if error != nil {
|
||||
let msg: id = msg_send![error, localizedDescription];
|
||||
let error_str = NSStringExt::to_str(&msg);
|
||||
log::error!("Screenshot capture error from API: {:?}", error_str);
|
||||
tx.send(Err(anyhow!("Screenshot capture failed: {:?}", error_str)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
|
||||
if cg_image.is_null() {
|
||||
log::error!("Screenshot capture: cg_image is null");
|
||||
tx.send(Err(anyhow!(
|
||||
"Screenshot capture returned null CGImage. \
|
||||
This may mean Screen Recording permission is not granted."
|
||||
)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("Screenshot capture: got CGImage, converting...");
|
||||
let cg_image = CGImage::from_ptr(cg_image);
|
||||
match cg_image_to_rgba_image(&cg_image) {
|
||||
Ok(image) => {
|
||||
log::info!(
|
||||
"Screenshot capture: success! {}x{}",
|
||||
image.width(),
|
||||
image.height()
|
||||
);
|
||||
tx.send(Ok(image)).ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Screenshot capture: CGImage conversion failed: {}", e);
|
||||
tx.send(Err(e)).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let capture_handler = capture_handler.copy();
|
||||
|
||||
log::info!("Calling SCScreenshotManager captureImageWithFilter...");
|
||||
let _: () = msg_send![
|
||||
class!(SCScreenshotManager),
|
||||
captureImageWithFilter: filter
|
||||
configuration: configuration
|
||||
completionHandler: capture_handler
|
||||
];
|
||||
log::info!("SCScreenshotManager captureImageWithFilter called");
|
||||
}
|
||||
|
||||
/// Converts a CGImage to an RgbaImage.
|
||||
fn cg_image_to_rgba_image(cg_image: &CGImage) -> Result<RgbaImage> {
|
||||
let width = cg_image.width();
|
||||
let height = cg_image.height();
|
||||
|
||||
if width == 0 || height == 0 {
|
||||
return Err(anyhow!("CGImage has zero dimensions: {}x{}", width, height));
|
||||
}
|
||||
|
||||
// Create a bitmap context to draw the CGImage into
|
||||
let color_space = CGColorSpace::create_device_rgb();
|
||||
let bytes_per_row = width * 4;
|
||||
let mut pixel_data: Vec<u8> = vec![0; height * bytes_per_row];
|
||||
|
||||
let context = core_graphics::context::CGContext::create_bitmap_context(
|
||||
Some(pixel_data.as_mut_ptr() as *mut c_void),
|
||||
width,
|
||||
height,
|
||||
8, // bits per component
|
||||
bytes_per_row, // bytes per row
|
||||
&color_space,
|
||||
core_graphics::base::kCGImageAlphaPremultipliedLast // RGBA
|
||||
| core_graphics::base::kCGBitmapByteOrder32Big,
|
||||
);
|
||||
|
||||
// Draw the image into the context
|
||||
let rect = core_graphics::geometry::CGRect::new(
|
||||
&core_graphics::geometry::CGPoint::new(0.0, 0.0),
|
||||
&core_graphics::geometry::CGSize::new(width as CGFloat, height as CGFloat),
|
||||
);
|
||||
context.draw_image(rect, cg_image);
|
||||
|
||||
// The pixel data is now in RGBA format
|
||||
ImageBuffer::<Rgba<u8>, Vec<u8>>::from_raw(width as u32, height as u32, pixel_data)
|
||||
.ok_or_else(|| anyhow!("Failed to create RgbaImage from CGImage pixel data"))
|
||||
}
|
||||
|
||||
/// Converts a CVPixelBuffer (in BGRA format) to an RgbaImage.
|
||||
///
|
||||
/// This function locks the pixel buffer, reads the raw pixel data,
|
||||
/// converts from BGRA to RGBA format, and returns an image::RgbaImage.
|
||||
pub fn cv_pixel_buffer_to_rgba_image(pixel_buffer: &CVPixelBuffer) -> Result<RgbaImage> {
|
||||
use core_video::r#return::kCVReturnSuccess;
|
||||
|
||||
unsafe {
|
||||
if pixel_buffer.lock_base_address(0) != kCVReturnSuccess {
|
||||
return Err(anyhow!("Failed to lock pixel buffer base address"));
|
||||
}
|
||||
|
||||
let width = pixel_buffer.get_width();
|
||||
let height = pixel_buffer.get_height();
|
||||
let bytes_per_row = pixel_buffer.get_bytes_per_row();
|
||||
let base_address = pixel_buffer.get_base_address();
|
||||
|
||||
if base_address.is_null() {
|
||||
pixel_buffer.unlock_base_address(0);
|
||||
return Err(anyhow!("Pixel buffer base address is null"));
|
||||
}
|
||||
|
||||
let mut rgba_data = Vec::with_capacity(width * height * 4);
|
||||
|
||||
for y in 0..height {
|
||||
let row_start = base_address.add(y * bytes_per_row) as *const u8;
|
||||
for x in 0..width {
|
||||
let pixel = row_start.add(x * 4);
|
||||
let b = *pixel;
|
||||
let g = *pixel.add(1);
|
||||
let r = *pixel.add(2);
|
||||
let a = *pixel.add(3);
|
||||
rgba_data.push(r);
|
||||
rgba_data.push(g);
|
||||
rgba_data.push(b);
|
||||
rgba_data.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
pixel_buffer.unlock_base_address(0);
|
||||
|
||||
ImageBuffer::<Rgba<u8>, Vec<u8>>::from_raw(width as u32, height as u32, rgba_data)
|
||||
.ok_or_else(|| anyhow!("Failed to create RgbaImage from pixel data"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a ScreenCaptureFrame to an RgbaImage.
|
||||
///
|
||||
/// This is useful for converting frames received from continuous screen capture streams.
|
||||
pub fn screen_capture_frame_to_rgba_image(frame: &ScreenCaptureFrame) -> Result<RgbaImage> {
|
||||
unsafe {
|
||||
let pixel_buffer =
|
||||
CVPixelBuffer::wrap_under_get_rule(frame.0.as_concrete_TypeRef() as *mut _);
|
||||
cv_pixel_buffer_to_rgba_image(&pixel_buffer)
|
||||
}
|
||||
}
|
||||
|
||||
#[ctor]
|
||||
unsafe fn build_classes() {
|
||||
let mut decl = ClassDecl::new("GPUIStreamDelegate", class!(NSObject)).unwrap();
|
||||
|
||||
@@ -8,6 +8,8 @@ use crate::{
|
||||
WindowBounds, WindowControlArea, WindowKind, WindowParams, dispatch_get_main_queue,
|
||||
dispatch_sys::dispatch_async_f, platform::PlatformInputHandler, point, px, size,
|
||||
};
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use anyhow::Result;
|
||||
use block::ConcreteBlock;
|
||||
use cocoa::{
|
||||
appkit::{
|
||||
@@ -25,6 +27,8 @@ use cocoa::{
|
||||
NSUserDefaults,
|
||||
},
|
||||
};
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use image::RgbaImage;
|
||||
|
||||
use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect};
|
||||
use ctor::ctor;
|
||||
@@ -62,12 +66,9 @@ static mut BLURRED_VIEW_CLASS: *const Class = ptr::null();
|
||||
#[allow(non_upper_case_globals)]
|
||||
const NSWindowStyleMaskNonactivatingPanel: NSWindowStyleMask =
|
||||
NSWindowStyleMask::from_bits_retain(1 << 7);
|
||||
// WindowLevel const value ref: https://docs.rs/core-graphics2/0.4.1/src/core_graphics2/window_level.rs.html
|
||||
#[allow(non_upper_case_globals)]
|
||||
const NSNormalWindowLevel: NSInteger = 0;
|
||||
#[allow(non_upper_case_globals)]
|
||||
const NSFloatingWindowLevel: NSInteger = 3;
|
||||
#[allow(non_upper_case_globals)]
|
||||
const NSPopUpWindowLevel: NSInteger = 101;
|
||||
#[allow(non_upper_case_globals)]
|
||||
const NSTrackingMouseEnteredAndExited: NSUInteger = 0x01;
|
||||
@@ -426,8 +427,6 @@ struct MacWindowState {
|
||||
select_previous_tab_callback: Option<Box<dyn FnMut()>>,
|
||||
toggle_tab_bar_callback: Option<Box<dyn FnMut()>>,
|
||||
activated_least_once: bool,
|
||||
// The parent window if this window is a sheet (Dialog kind)
|
||||
sheet_parent: Option<id>,
|
||||
}
|
||||
|
||||
impl MacWindowState {
|
||||
@@ -627,16 +626,11 @@ impl MacWindow {
|
||||
}
|
||||
|
||||
let native_window: id = match kind {
|
||||
WindowKind::Normal => {
|
||||
msg_send![WINDOW_CLASS, alloc]
|
||||
}
|
||||
WindowKind::Normal | WindowKind::Floating => msg_send![WINDOW_CLASS, alloc],
|
||||
WindowKind::PopUp => {
|
||||
style_mask |= NSWindowStyleMaskNonactivatingPanel;
|
||||
msg_send![PANEL_CLASS, alloc]
|
||||
}
|
||||
WindowKind::Floating | WindowKind::Dialog => {
|
||||
msg_send![PANEL_CLASS, alloc]
|
||||
}
|
||||
};
|
||||
|
||||
let display = display_id
|
||||
@@ -739,7 +733,6 @@ impl MacWindow {
|
||||
select_previous_tab_callback: None,
|
||||
toggle_tab_bar_callback: None,
|
||||
activated_least_once: false,
|
||||
sheet_parent: None,
|
||||
})));
|
||||
|
||||
(*native_window).set_ivar(
|
||||
@@ -790,18 +783,9 @@ impl MacWindow {
|
||||
content_view.addSubview_(native_view.autorelease());
|
||||
native_window.makeFirstResponder_(native_view);
|
||||
|
||||
let app: id = NSApplication::sharedApplication(nil);
|
||||
let main_window: id = msg_send![app, mainWindow];
|
||||
let mut sheet_parent = None;
|
||||
|
||||
match kind {
|
||||
WindowKind::Normal | WindowKind::Floating => {
|
||||
if kind == WindowKind::Floating {
|
||||
// Let the window float keep above normal windows.
|
||||
native_window.setLevel_(NSFloatingWindowLevel);
|
||||
} else {
|
||||
native_window.setLevel_(NSNormalWindowLevel);
|
||||
}
|
||||
native_window.setLevel_(NSNormalWindowLevel);
|
||||
native_window.setAcceptsMouseMovedEvents_(YES);
|
||||
|
||||
if let Some(tabbing_identifier) = tabbing_identifier {
|
||||
@@ -836,23 +820,10 @@ impl MacWindow {
|
||||
NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary
|
||||
);
|
||||
}
|
||||
WindowKind::Dialog => {
|
||||
if !main_window.is_null() {
|
||||
let parent = {
|
||||
let active_sheet: id = msg_send![main_window, attachedSheet];
|
||||
if active_sheet.is_null() {
|
||||
main_window
|
||||
} else {
|
||||
active_sheet
|
||||
}
|
||||
};
|
||||
let _: () =
|
||||
msg_send![parent, beginSheet: native_window completionHandler: nil];
|
||||
sheet_parent = Some(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let app = NSApplication::sharedApplication(nil);
|
||||
let main_window: id = msg_send![app, mainWindow];
|
||||
if allows_automatic_window_tabbing
|
||||
&& !main_window.is_null()
|
||||
&& main_window != native_window
|
||||
@@ -894,11 +865,7 @@ impl MacWindow {
|
||||
// the window position might be incorrect if the main screen (the screen that contains the window that has focus)
|
||||
// is different from the primary screen.
|
||||
NSWindow::setFrameTopLeftPoint_(native_window, window_rect.origin);
|
||||
{
|
||||
let mut window_state = window.0.lock();
|
||||
window_state.move_traffic_light();
|
||||
window_state.sheet_parent = sheet_parent;
|
||||
}
|
||||
window.0.lock().move_traffic_light();
|
||||
|
||||
pool.drain();
|
||||
|
||||
@@ -968,6 +935,14 @@ impl MacWindow {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the CGWindowID (NSWindow's windowNumber) for this window.
|
||||
/// This can be used for ScreenCaptureKit window capture.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn window_number(&self) -> u32 {
|
||||
let this = self.0.lock();
|
||||
unsafe { this.native_window.windowNumber() as u32 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MacWindow {
|
||||
@@ -975,7 +950,6 @@ impl Drop for MacWindow {
|
||||
let mut this = self.0.lock();
|
||||
this.renderer.destroy();
|
||||
let window = this.native_window;
|
||||
let sheet_parent = this.sheet_parent.take();
|
||||
this.display_link.take();
|
||||
unsafe {
|
||||
this.native_window.setDelegate_(nil);
|
||||
@@ -984,9 +958,6 @@ impl Drop for MacWindow {
|
||||
this.executor
|
||||
.spawn(async move {
|
||||
unsafe {
|
||||
if let Some(parent) = sheet_parent {
|
||||
let _: () = msg_send![parent, endSheet: window];
|
||||
}
|
||||
window.close();
|
||||
window.autorelease();
|
||||
}
|
||||
@@ -1598,6 +1569,17 @@ impl PlatformWindow for MacWindow {
|
||||
let _: () = msg_send![window, performWindowDragWithEvent: event];
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn native_window_id(&self) -> Option<u32> {
|
||||
Some(self.window_number())
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn render_to_image(&self, scene: &crate::Scene) -> Result<RgbaImage> {
|
||||
let mut this = self.0.lock();
|
||||
this.renderer.render_to_image(scene)
|
||||
}
|
||||
}
|
||||
|
||||
impl rwh::HasWindowHandle for MacWindow {
|
||||
|
||||
@@ -270,14 +270,6 @@ impl WindowsWindowInner {
|
||||
|
||||
fn handle_destroy_msg(&self, handle: HWND) -> Option<isize> {
|
||||
let callback = { self.state.callbacks.close.take() };
|
||||
// Re-enable parent window if this was a modal dialog
|
||||
if let Some(parent_hwnd) = self.parent_hwnd {
|
||||
unsafe {
|
||||
let _ = EnableWindow(parent_hwnd, true);
|
||||
let _ = SetForegroundWindow(parent_hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(callback) = callback {
|
||||
callback();
|
||||
}
|
||||
|
||||
@@ -83,7 +83,6 @@ pub(crate) struct WindowsWindowInner {
|
||||
pub(crate) validation_number: usize,
|
||||
pub(crate) main_receiver: flume::Receiver<RunnableVariant>,
|
||||
pub(crate) platform_window_handle: HWND,
|
||||
pub(crate) parent_hwnd: Option<HWND>,
|
||||
}
|
||||
|
||||
impl WindowsWindowState {
|
||||
@@ -242,7 +241,6 @@ impl WindowsWindowInner {
|
||||
main_receiver: context.main_receiver.clone(),
|
||||
platform_window_handle: context.platform_window_handle,
|
||||
system_settings: WindowsSystemSettings::new(context.display),
|
||||
parent_hwnd: context.parent_hwnd,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -370,7 +368,6 @@ struct WindowCreateContext {
|
||||
disable_direct_composition: bool,
|
||||
directx_devices: DirectXDevices,
|
||||
invalidate_devices: Arc<AtomicBool>,
|
||||
parent_hwnd: Option<HWND>,
|
||||
}
|
||||
|
||||
impl WindowsWindow {
|
||||
@@ -393,20 +390,6 @@ impl WindowsWindow {
|
||||
invalidate_devices,
|
||||
} = creation_info;
|
||||
register_window_class(icon);
|
||||
let parent_hwnd = if params.kind == WindowKind::Dialog {
|
||||
let parent_window = unsafe { GetActiveWindow() };
|
||||
if parent_window.is_invalid() {
|
||||
None
|
||||
} else {
|
||||
// Disable the parent window to make this dialog modal
|
||||
unsafe {
|
||||
EnableWindow(parent_window, false).as_bool();
|
||||
};
|
||||
Some(parent_window)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let hide_title_bar = params
|
||||
.titlebar
|
||||
.as_ref()
|
||||
@@ -433,14 +416,8 @@ impl WindowsWindow {
|
||||
if params.is_minimizable {
|
||||
dwstyle |= WS_MINIMIZEBOX;
|
||||
}
|
||||
let dwexstyle = if params.kind == WindowKind::Dialog {
|
||||
dwstyle |= WS_POPUP | WS_CAPTION;
|
||||
WS_EX_DLGMODALFRAME
|
||||
} else {
|
||||
WS_EX_APPWINDOW
|
||||
};
|
||||
|
||||
(dwexstyle, dwstyle)
|
||||
(WS_EX_APPWINDOW, dwstyle)
|
||||
};
|
||||
if !disable_direct_composition {
|
||||
dwexstyle |= WS_EX_NOREDIRECTIONBITMAP;
|
||||
@@ -472,7 +449,6 @@ impl WindowsWindow {
|
||||
disable_direct_composition,
|
||||
directx_devices,
|
||||
invalidate_devices,
|
||||
parent_hwnd,
|
||||
};
|
||||
let creation_result = unsafe {
|
||||
CreateWindowExW(
|
||||
@@ -484,7 +460,7 @@ impl WindowsWindow {
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
parent_hwnd,
|
||||
None,
|
||||
None,
|
||||
Some(hinstance.into()),
|
||||
Some(&context as *const _ as *const _),
|
||||
|
||||
@@ -128,36 +128,6 @@ impl LineWrapper {
|
||||
})
|
||||
}
|
||||
|
||||
/// Determines if a line should be truncated based on its width.
|
||||
pub fn should_truncate_line(
|
||||
&mut self,
|
||||
line: &str,
|
||||
truncate_width: Pixels,
|
||||
truncation_suffix: &str,
|
||||
) -> Option<usize> {
|
||||
let mut width = px(0.);
|
||||
let suffix_width = truncation_suffix
|
||||
.chars()
|
||||
.map(|c| self.width_for_char(c))
|
||||
.fold(px(0.0), |a, x| a + x);
|
||||
let mut truncate_ix = 0;
|
||||
|
||||
for (ix, c) in line.char_indices() {
|
||||
if width + suffix_width < truncate_width {
|
||||
truncate_ix = ix;
|
||||
}
|
||||
|
||||
let char_width = self.width_for_char(c);
|
||||
width += char_width;
|
||||
|
||||
if width.floor() > truncate_width {
|
||||
return Some(truncate_ix);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Truncate a line of text to the given width with this wrapper's font and font size.
|
||||
pub fn truncate_line<'a>(
|
||||
&mut self,
|
||||
@@ -166,17 +136,32 @@ impl LineWrapper {
|
||||
truncation_suffix: &str,
|
||||
runs: &'a [TextRun],
|
||||
) -> (SharedString, Cow<'a, [TextRun]>) {
|
||||
if let Some(truncate_ix) =
|
||||
self.should_truncate_line(&line, truncate_width, truncation_suffix)
|
||||
{
|
||||
let result =
|
||||
SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix));
|
||||
let mut runs = runs.to_vec();
|
||||
update_runs_after_truncation(&result, truncation_suffix, &mut runs);
|
||||
(result, Cow::Owned(runs))
|
||||
} else {
|
||||
(line, Cow::Borrowed(runs))
|
||||
let mut width = px(0.);
|
||||
let mut suffix_width = truncation_suffix
|
||||
.chars()
|
||||
.map(|c| self.width_for_char(c))
|
||||
.fold(px(0.0), |a, x| a + x);
|
||||
let mut char_indices = line.char_indices();
|
||||
let mut truncate_ix = 0;
|
||||
for (ix, c) in char_indices {
|
||||
if width + suffix_width < truncate_width {
|
||||
truncate_ix = ix;
|
||||
}
|
||||
|
||||
let char_width = self.width_for_char(c);
|
||||
width += char_width;
|
||||
|
||||
if width.floor() > truncate_width {
|
||||
let result =
|
||||
SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix));
|
||||
let mut runs = runs.to_vec();
|
||||
update_runs_after_truncation(&result, truncation_suffix, &mut runs);
|
||||
|
||||
return (result, Cow::Owned(runs));
|
||||
}
|
||||
}
|
||||
|
||||
(line, Cow::Borrowed(runs))
|
||||
}
|
||||
|
||||
/// Any character in this list should be treated as a word character,
|
||||
|
||||
@@ -1776,6 +1776,23 @@ impl Window {
|
||||
self.platform_window.bounds()
|
||||
}
|
||||
|
||||
/// Returns the native window ID (CGWindowID on macOS) for window capture.
|
||||
/// This is used by visual testing infrastructure to capture window screenshots.
|
||||
/// Returns None on platforms that don't support this or in non-test builds.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn native_window_id(&self) -> Option<u32> {
|
||||
self.platform_window.native_window_id()
|
||||
}
|
||||
|
||||
/// Renders the current frame's scene to a texture and returns the pixel data as an RGBA image.
|
||||
/// This does not present the frame to screen - useful for visual testing where we want
|
||||
/// to capture what would be rendered without displaying it or requiring the window to be visible.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn render_to_image(&self) -> anyhow::Result<image::RgbaImage> {
|
||||
self.platform_window
|
||||
.render_to_image(&self.rendered_frame.scene)
|
||||
}
|
||||
|
||||
/// Set the content size of the window.
|
||||
pub fn resize(&mut self, size: Size<Pixels>) {
|
||||
self.platform_window.resize(size);
|
||||
|
||||
@@ -797,26 +797,11 @@ pub enum AuthenticateError {
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
/// Either a built-in icon name or a path to an external SVG.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum IconOrSvg {
|
||||
/// A built-in icon from Zed's icon set.
|
||||
Icon(IconName),
|
||||
/// Path to a custom SVG icon file.
|
||||
Svg(SharedString),
|
||||
}
|
||||
|
||||
impl Default for IconOrSvg {
|
||||
fn default() -> Self {
|
||||
Self::Icon(IconName::ZedAssistant)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait LanguageModelProvider: 'static {
|
||||
fn id(&self) -> LanguageModelProviderId;
|
||||
fn name(&self) -> LanguageModelProviderName;
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::default()
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::ZedAssistant
|
||||
}
|
||||
fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>>;
|
||||
fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>>;
|
||||
@@ -835,7 +820,7 @@ pub trait LanguageModelProvider: 'static {
|
||||
fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>>;
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, PartialEq, Eq)]
|
||||
#[derive(Default, Clone)]
|
||||
pub enum ConfigurationViewTargetAgent {
|
||||
#[default]
|
||||
ZedAgent,
|
||||
|
||||
@@ -2,16 +2,12 @@ use crate::{
|
||||
LanguageModel, LanguageModelId, LanguageModelProvider, LanguageModelProviderId,
|
||||
LanguageModelProviderState,
|
||||
};
|
||||
use collections::{BTreeMap, HashSet};
|
||||
use collections::BTreeMap;
|
||||
use gpui::{App, Context, Entity, EventEmitter, Global, prelude::*};
|
||||
use std::{str::FromStr, sync::Arc};
|
||||
use thiserror::Error;
|
||||
use util::maybe;
|
||||
|
||||
/// Function type for checking if a built-in provider should be hidden.
|
||||
/// Returns Some(extension_id) if the provider should be hidden when that extension is installed.
|
||||
pub type BuiltinProviderHidingFn = Box<dyn Fn(&str) -> Option<&'static str> + Send + Sync>;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
let registry = cx.new(|_cx| LanguageModelRegistry::default());
|
||||
cx.set_global(GlobalLanguageModelRegistry(registry));
|
||||
@@ -52,11 +48,6 @@ pub struct LanguageModelRegistry {
|
||||
thread_summary_model: Option<ConfiguredModel>,
|
||||
providers: BTreeMap<LanguageModelProviderId, Arc<dyn LanguageModelProvider>>,
|
||||
inline_alternatives: Vec<Arc<dyn LanguageModel>>,
|
||||
/// Set of installed extension IDs that provide language models.
|
||||
/// Used to determine which built-in providers should be hidden.
|
||||
installed_llm_extension_ids: HashSet<Arc<str>>,
|
||||
/// Function to check if a built-in provider should be hidden by an extension.
|
||||
builtin_provider_hiding_fn: Option<BuiltinProviderHidingFn>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -113,8 +104,6 @@ pub enum Event {
|
||||
ProviderStateChanged(LanguageModelProviderId),
|
||||
AddedProvider(LanguageModelProviderId),
|
||||
RemovedProvider(LanguageModelProviderId),
|
||||
/// Emitted when provider visibility changes due to extension install/uninstall.
|
||||
ProvidersChanged,
|
||||
}
|
||||
|
||||
impl EventEmitter<Event> for LanguageModelRegistry {}
|
||||
@@ -194,60 +183,6 @@ impl LanguageModelRegistry {
|
||||
providers
|
||||
}
|
||||
|
||||
/// Returns providers, filtering out hidden built-in providers.
|
||||
pub fn visible_providers(&self) -> Vec<Arc<dyn LanguageModelProvider>> {
|
||||
self.providers()
|
||||
.into_iter()
|
||||
.filter(|p| !self.should_hide_provider(&p.id()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sets the function used to check if a built-in provider should be hidden.
|
||||
pub fn set_builtin_provider_hiding_fn(&mut self, hiding_fn: BuiltinProviderHidingFn) {
|
||||
self.builtin_provider_hiding_fn = Some(hiding_fn);
|
||||
}
|
||||
|
||||
/// Called when an extension is installed/loaded.
|
||||
/// If the extension provides language models, track it so we can hide the corresponding built-in.
|
||||
pub fn extension_installed(&mut self, extension_id: Arc<str>, cx: &mut Context<Self>) {
|
||||
if self.installed_llm_extension_ids.insert(extension_id) {
|
||||
cx.emit(Event::ProvidersChanged);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when an extension is uninstalled/unloaded.
|
||||
pub fn extension_uninstalled(&mut self, extension_id: &str, cx: &mut Context<Self>) {
|
||||
if self.installed_llm_extension_ids.remove(extension_id) {
|
||||
cx.emit(Event::ProvidersChanged);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync the set of installed LLM extension IDs.
|
||||
pub fn sync_installed_llm_extensions(
|
||||
&mut self,
|
||||
extension_ids: HashSet<Arc<str>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if extension_ids != self.installed_llm_extension_ids {
|
||||
self.installed_llm_extension_ids = extension_ids;
|
||||
cx.emit(Event::ProvidersChanged);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if a provider should be hidden from the UI.
|
||||
/// Built-in providers are hidden when their corresponding extension is installed.
|
||||
pub fn should_hide_provider(&self, provider_id: &LanguageModelProviderId) -> bool {
|
||||
if let Some(ref hiding_fn) = self.builtin_provider_hiding_fn {
|
||||
if let Some(extension_id) = hiding_fn(&provider_id.0) {
|
||||
return self.installed_llm_extension_ids.contains(extension_id);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn configuration_error(
|
||||
&self,
|
||||
model: Option<ConfiguredModel>,
|
||||
@@ -481,132 +416,4 @@ mod tests {
|
||||
let providers = registry.read(cx).providers();
|
||||
assert!(providers.is_empty());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_provider_hiding_on_extension_install(cx: &mut App) {
|
||||
let registry = cx.new(|_| LanguageModelRegistry::default());
|
||||
|
||||
let provider = Arc::new(FakeLanguageModelProvider::default());
|
||||
let provider_id = provider.id();
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.register_provider(provider.clone(), cx);
|
||||
|
||||
registry.set_builtin_provider_hiding_fn(Box::new(|id| {
|
||||
if id == "fake" {
|
||||
Some("fake-extension")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
let visible = registry.read(cx).visible_providers();
|
||||
assert_eq!(visible.len(), 1);
|
||||
assert_eq!(visible[0].id(), provider_id);
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.extension_installed("fake-extension".into(), cx);
|
||||
});
|
||||
|
||||
let visible = registry.read(cx).visible_providers();
|
||||
assert!(visible.is_empty());
|
||||
|
||||
let all = registry.read(cx).providers();
|
||||
assert_eq!(all.len(), 1);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_provider_unhiding_on_extension_uninstall(cx: &mut App) {
|
||||
let registry = cx.new(|_| LanguageModelRegistry::default());
|
||||
|
||||
let provider = Arc::new(FakeLanguageModelProvider::default());
|
||||
let provider_id = provider.id();
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.register_provider(provider.clone(), cx);
|
||||
|
||||
registry.set_builtin_provider_hiding_fn(Box::new(|id| {
|
||||
if id == "fake" {
|
||||
Some("fake-extension")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}));
|
||||
|
||||
registry.extension_installed("fake-extension".into(), cx);
|
||||
});
|
||||
|
||||
let visible = registry.read(cx).visible_providers();
|
||||
assert!(visible.is_empty());
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.extension_uninstalled("fake-extension", cx);
|
||||
});
|
||||
|
||||
let visible = registry.read(cx).visible_providers();
|
||||
assert_eq!(visible.len(), 1);
|
||||
assert_eq!(visible[0].id(), provider_id);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_should_hide_provider(cx: &mut App) {
|
||||
let registry = cx.new(|_| LanguageModelRegistry::default());
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.set_builtin_provider_hiding_fn(Box::new(|id| {
|
||||
if id == "anthropic" {
|
||||
Some("anthropic")
|
||||
} else if id == "openai" {
|
||||
Some("openai")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}));
|
||||
|
||||
registry.extension_installed("anthropic".into(), cx);
|
||||
});
|
||||
|
||||
let registry_read = registry.read(cx);
|
||||
|
||||
assert!(registry_read.should_hide_provider(&LanguageModelProviderId("anthropic".into())));
|
||||
|
||||
assert!(!registry_read.should_hide_provider(&LanguageModelProviderId("openai".into())));
|
||||
|
||||
assert!(!registry_read.should_hide_provider(&LanguageModelProviderId("unknown".into())));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_sync_installed_llm_extensions(cx: &mut App) {
|
||||
let registry = cx.new(|_| LanguageModelRegistry::default());
|
||||
|
||||
let provider = Arc::new(FakeLanguageModelProvider::default());
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.register_provider(provider.clone(), cx);
|
||||
|
||||
registry.set_builtin_provider_hiding_fn(Box::new(|id| {
|
||||
if id == "fake" {
|
||||
Some("fake-extension")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
let mut extension_ids = HashSet::default();
|
||||
extension_ids.insert(Arc::from("fake-extension"));
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.sync_installed_llm_extensions(extension_ids, cx);
|
||||
});
|
||||
|
||||
assert!(registry.read(cx).visible_providers().is_empty());
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.sync_installed_llm_extensions(HashSet::default(), cx);
|
||||
});
|
||||
|
||||
assert_eq!(registry.read(cx).visible_providers().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ convert_case.workspace = true
|
||||
copilot.workspace = true
|
||||
credentials_provider.workspace = true
|
||||
deepseek = { workspace = true, features = ["schemars"] }
|
||||
extension.workspace = true
|
||||
extension_host.workspace = true
|
||||
fs.workspace = true
|
||||
futures.workspace = true
|
||||
google_ai = { workspace = true, features = ["schemars"] }
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
use collections::HashMap;
|
||||
use extension::{
|
||||
ExtensionHostProxy, ExtensionLanguageModelProviderProxy, LanguageModelProviderRegistration,
|
||||
};
|
||||
use gpui::{App, Entity};
|
||||
use language_model::{LanguageModelProviderId, LanguageModelRegistry};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
/// Maps built-in provider IDs to their corresponding extension IDs.
|
||||
/// When an extension with this ID is installed, the built-in provider should be hidden.
|
||||
static BUILTIN_TO_EXTENSION_MAP: LazyLock<HashMap<&'static str, &'static str>> =
|
||||
LazyLock::new(|| {
|
||||
let mut map = HashMap::default();
|
||||
map.insert("anthropic", "anthropic");
|
||||
map.insert("openai", "openai");
|
||||
map.insert("google", "google-ai");
|
||||
map.insert("openrouter", "openrouter");
|
||||
map.insert("copilot_chat", "copilot-chat");
|
||||
map
|
||||
});
|
||||
|
||||
/// Returns the extension ID that should hide the given built-in provider.
|
||||
pub fn extension_for_builtin_provider(provider_id: &str) -> Option<&'static str> {
|
||||
BUILTIN_TO_EXTENSION_MAP.get(provider_id).copied()
|
||||
}
|
||||
|
||||
/// Proxy that registers extension language model providers with the LanguageModelRegistry.
|
||||
pub struct LanguageModelProviderRegistryProxy {
|
||||
registry: Entity<LanguageModelRegistry>,
|
||||
}
|
||||
|
||||
impl LanguageModelProviderRegistryProxy {
|
||||
pub fn new(registry: Entity<LanguageModelRegistry>) -> Self {
|
||||
Self { registry }
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtensionLanguageModelProviderProxy for LanguageModelProviderRegistryProxy {
|
||||
fn register_language_model_provider(
|
||||
&self,
|
||||
_provider_id: Arc<str>,
|
||||
register_fn: LanguageModelProviderRegistration,
|
||||
cx: &mut App,
|
||||
) {
|
||||
register_fn(cx);
|
||||
}
|
||||
|
||||
fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App) {
|
||||
self.registry.update(cx, |registry, cx| {
|
||||
registry.unregister_provider(LanguageModelProviderId::from(provider_id), cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the extension language model provider proxy.
|
||||
/// This must be called BEFORE extension_host::init to ensure the proxy is available
|
||||
/// when extensions try to register their language model providers.
|
||||
pub fn init_proxy(cx: &mut App) {
|
||||
let proxy = ExtensionHostProxy::default_global(cx);
|
||||
let registry = LanguageModelRegistry::global(cx);
|
||||
|
||||
registry.update(cx, |registry, _cx| {
|
||||
registry.set_builtin_provider_hiding_fn(Box::new(extension_for_builtin_provider));
|
||||
});
|
||||
|
||||
proxy.register_language_model_provider_proxy(LanguageModelProviderRegistryProxy::new(registry));
|
||||
}
|
||||
@@ -7,16 +7,14 @@ use gpui::{App, Context, Entity};
|
||||
use language_model::{LanguageModelProviderId, LanguageModelRegistry};
|
||||
use provider::deepseek::DeepSeekLanguageModelProvider;
|
||||
|
||||
pub mod extension;
|
||||
pub mod provider;
|
||||
mod settings;
|
||||
|
||||
pub use crate::extension::init_proxy as init_extension_proxy;
|
||||
use crate::provider::anthropic::AnthropicLanguageModelProvider;
|
||||
use crate::provider::bedrock::BedrockLanguageModelProvider;
|
||||
use crate::provider::cloud::CloudLanguageModelProvider;
|
||||
use crate::provider::copilot_chat::CopilotChatLanguageModelProvider;
|
||||
pub use crate::provider::google::GoogleLanguageModelProvider;
|
||||
use crate::provider::google::GoogleLanguageModelProvider;
|
||||
use crate::provider::lmstudio::LmStudioLanguageModelProvider;
|
||||
pub use crate::provider::mistral::MistralLanguageModelProvider;
|
||||
use crate::provider::ollama::OllamaLanguageModelProvider;
|
||||
@@ -33,61 +31,6 @@ pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) {
|
||||
register_language_model_providers(registry, user_store, client.clone(), cx);
|
||||
});
|
||||
|
||||
// Subscribe to extension store events to track LLM extension installations
|
||||
if let Some(extension_store) = extension_host::ExtensionStore::try_global(cx) {
|
||||
cx.subscribe(&extension_store, {
|
||||
let registry = registry.clone();
|
||||
move |extension_store, event, cx| {
|
||||
match event {
|
||||
extension_host::Event::ExtensionInstalled(extension_id) => {
|
||||
// Check if this extension has language_model_providers
|
||||
if let Some(manifest) = extension_store
|
||||
.read(cx)
|
||||
.extension_manifest_for_id(extension_id)
|
||||
{
|
||||
if !manifest.language_model_providers.is_empty() {
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.extension_installed(extension_id.clone(), cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
extension_host::Event::ExtensionUninstalled(extension_id) => {
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.extension_uninstalled(extension_id, cx);
|
||||
});
|
||||
}
|
||||
extension_host::Event::ExtensionsUpdated => {
|
||||
// Re-sync installed extensions on bulk updates
|
||||
let mut new_ids = HashSet::default();
|
||||
for (extension_id, entry) in extension_store.read(cx).installed_extensions()
|
||||
{
|
||||
if !entry.manifest.language_model_providers.is_empty() {
|
||||
new_ids.insert(extension_id.clone());
|
||||
}
|
||||
}
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.sync_installed_llm_extensions(new_ids, cx);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Initialize with currently installed extensions
|
||||
registry.update(cx, |registry, cx| {
|
||||
let mut initial_ids = HashSet::default();
|
||||
for (extension_id, entry) in extension_store.read(cx).installed_extensions() {
|
||||
if !entry.manifest.language_model_providers.is_empty() {
|
||||
initial_ids.insert(extension_id.clone());
|
||||
}
|
||||
}
|
||||
registry.sync_installed_llm_extensions(initial_ids, cx);
|
||||
});
|
||||
}
|
||||
|
||||
let mut openai_compatible_providers = AllLanguageModelSettings::get_global(cx)
|
||||
.openai_compatible
|
||||
.keys()
|
||||
|
||||
@@ -8,7 +8,7 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::B
|
||||
use gpui::{AnyView, App, AsyncApp, Context, Entity, Task};
|
||||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
ApiKeyState, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, IconOrSvg, LanguageModel,
|
||||
ApiKeyState, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModel,
|
||||
LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent,
|
||||
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
|
||||
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
|
||||
@@ -125,8 +125,8 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiAnthropic)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiAnthropic
|
||||
}
|
||||
|
||||
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
|
||||
@@ -30,7 +30,7 @@ use gpui::{
|
||||
use gpui_tokio::Tokio;
|
||||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCacheConfiguration,
|
||||
AuthenticateError, EnvVar, LanguageModel, LanguageModelCacheConfiguration,
|
||||
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
|
||||
LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
|
||||
LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
|
||||
@@ -426,8 +426,8 @@ impl LanguageModelProvider for BedrockLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiBedrock)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiBedrock
|
||||
}
|
||||
|
||||
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
@@ -996,7 +996,7 @@ pub fn get_bedrock_tokens(
|
||||
}
|
||||
}
|
||||
|
||||
// Tiktoken doesn't support these models, so we manually use the
|
||||
// Tiktoken doesn't yet support these models, so we manually use the
|
||||
// same tokenizer as GPT-4.
|
||||
tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages)
|
||||
.map(|tokens| (tokens + tokens_from_images) as u64)
|
||||
|
||||
@@ -19,7 +19,7 @@ use gpui::{AnyElement, AnyView, App, AsyncApp, Context, Entity, Subscription, Ta
|
||||
use http_client::http::{HeaderMap, HeaderValue};
|
||||
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Response, StatusCode};
|
||||
use language_model::{
|
||||
AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCacheConfiguration,
|
||||
AuthenticateError, LanguageModel, LanguageModelCacheConfiguration,
|
||||
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
|
||||
LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
|
||||
LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
|
||||
@@ -304,8 +304,8 @@ impl LanguageModelProvider for CloudLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiZed)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiZed
|
||||
}
|
||||
|
||||
fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
|
||||
@@ -18,12 +18,12 @@ use gpui::{AnyView, App, AsyncApp, Entity, Subscription, Task};
|
||||
use http_client::StatusCode;
|
||||
use language::language_settings::all_language_settings;
|
||||
use language_model::{
|
||||
AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError,
|
||||
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolChoice,
|
||||
LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse,
|
||||
MessageContent, RateLimiter, Role, StopReason, TokenUsage,
|
||||
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
|
||||
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
|
||||
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
|
||||
LanguageModelRequestMessage, LanguageModelToolChoice, LanguageModelToolResultContent,
|
||||
LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role,
|
||||
StopReason, TokenUsage,
|
||||
};
|
||||
use settings::SettingsStore;
|
||||
use ui::prelude::*;
|
||||
@@ -104,8 +104,8 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::Copilot)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::Copilot
|
||||
}
|
||||
|
||||
fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
|
||||
@@ -7,7 +7,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture, stream::BoxStream
|
||||
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
|
||||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
|
||||
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
|
||||
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
|
||||
@@ -127,8 +127,8 @@ impl LanguageModelProvider for DeepSeekLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiDeepSeek)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiDeepSeek
|
||||
}
|
||||
|
||||
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
|
||||
@@ -14,7 +14,7 @@ use language_model::{
|
||||
LanguageModelToolUse, LanguageModelToolUseId, MessageContent, StopReason,
|
||||
};
|
||||
use language_model::{
|
||||
IconOrSvg, LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
LanguageModelRequest, RateLimiter, Role,
|
||||
};
|
||||
@@ -164,8 +164,8 @@ impl LanguageModelProvider for GoogleLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiGoogle)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiGoogle
|
||||
}
|
||||
|
||||
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
@@ -707,7 +707,7 @@ pub fn count_google_tokens(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Tiktoken doesn't support these models, so we manually use the
|
||||
// Tiktoken doesn't yet support these models, so we manually use the
|
||||
// same tokenizer as GPT-4.
|
||||
tiktoken_rs::num_tokens_from_messages("gpt-4", &messages).map(|tokens| tokens as u64)
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ use language_model::{
|
||||
StopReason, TokenUsage,
|
||||
};
|
||||
use language_model::{
|
||||
IconOrSvg, LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
LanguageModelRequest, RateLimiter, Role,
|
||||
};
|
||||
@@ -175,8 +175,8 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiLmStudio)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiLmStudio
|
||||
}
|
||||
|
||||
fn default_model(&self, _: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
|
||||
@@ -5,7 +5,7 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::B
|
||||
use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window};
|
||||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
|
||||
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
|
||||
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
|
||||
@@ -176,8 +176,8 @@ impl LanguageModelProvider for MistralLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiMistral)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiMistral
|
||||
}
|
||||
|
||||
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
|
||||
@@ -5,7 +5,7 @@ use futures::{Stream, TryFutureExt, stream};
|
||||
use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task};
|
||||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
|
||||
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
|
||||
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
LanguageModelRequest, LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse,
|
||||
@@ -221,8 +221,8 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiOllama)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiOllama
|
||||
}
|
||||
|
||||
fn default_model(&self, _: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
@@ -249,7 +249,33 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
|
||||
}
|
||||
|
||||
// Override with available models from settings
|
||||
merge_settings_into_models(&mut models, &settings.available_models);
|
||||
for setting_model in &OllamaLanguageModelProvider::settings(cx).available_models {
|
||||
let setting_base = setting_model.name.split(':').next().unwrap();
|
||||
if let Some(model) = models
|
||||
.values_mut()
|
||||
.find(|m| m.name.split(':').next().unwrap() == setting_base)
|
||||
{
|
||||
model.max_tokens = setting_model.max_tokens;
|
||||
model.display_name = setting_model.display_name.clone();
|
||||
model.keep_alive = setting_model.keep_alive.clone();
|
||||
model.supports_tools = setting_model.supports_tools;
|
||||
model.supports_vision = setting_model.supports_images;
|
||||
model.supports_thinking = setting_model.supports_thinking;
|
||||
} else {
|
||||
models.insert(
|
||||
setting_model.name.clone(),
|
||||
ollama::Model {
|
||||
name: setting_model.name.clone(),
|
||||
display_name: setting_model.display_name.clone(),
|
||||
max_tokens: setting_model.max_tokens,
|
||||
keep_alive: setting_model.keep_alive.clone(),
|
||||
supports_tools: setting_model.supports_tools,
|
||||
supports_vision: setting_model.supports_images,
|
||||
supports_thinking: setting_model.supports_thinking,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut models = models
|
||||
.into_values()
|
||||
@@ -895,35 +921,6 @@ impl Render for ConfigurationView {
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_settings_into_models(
|
||||
models: &mut HashMap<String, ollama::Model>,
|
||||
available_models: &[AvailableModel],
|
||||
) {
|
||||
for setting_model in available_models {
|
||||
if let Some(model) = models.get_mut(&setting_model.name) {
|
||||
model.max_tokens = setting_model.max_tokens;
|
||||
model.display_name = setting_model.display_name.clone();
|
||||
model.keep_alive = setting_model.keep_alive.clone();
|
||||
model.supports_tools = setting_model.supports_tools;
|
||||
model.supports_vision = setting_model.supports_images;
|
||||
model.supports_thinking = setting_model.supports_thinking;
|
||||
} else {
|
||||
models.insert(
|
||||
setting_model.name.clone(),
|
||||
ollama::Model {
|
||||
name: setting_model.name.clone(),
|
||||
display_name: setting_model.display_name.clone(),
|
||||
max_tokens: setting_model.max_tokens,
|
||||
keep_alive: setting_model.keep_alive.clone(),
|
||||
supports_tools: setting_model.supports_tools,
|
||||
supports_vision: setting_model.supports_images,
|
||||
supports_thinking: setting_model.supports_thinking,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_into_ollama(tool: LanguageModelRequestTool) -> ollama::OllamaTool {
|
||||
ollama::OllamaTool::Function {
|
||||
function: OllamaFunctionTool {
|
||||
@@ -933,83 +930,3 @@ fn tool_into_ollama(tool: LanguageModelRequestTool) -> ollama::OllamaTool {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_merge_settings_preserves_display_names_for_similar_models() {
|
||||
// Regression test for https://github.com/zed-industries/zed/issues/43646
|
||||
// When multiple models share the same base name (e.g., qwen2.5-coder:1.5b and qwen2.5-coder:3b),
|
||||
// each model should get its own display_name from settings, not a random one.
|
||||
|
||||
let mut models: HashMap<String, ollama::Model> = HashMap::new();
|
||||
models.insert(
|
||||
"qwen2.5-coder:1.5b".to_string(),
|
||||
ollama::Model {
|
||||
name: "qwen2.5-coder:1.5b".to_string(),
|
||||
display_name: None,
|
||||
max_tokens: 4096,
|
||||
keep_alive: None,
|
||||
supports_tools: None,
|
||||
supports_vision: None,
|
||||
supports_thinking: None,
|
||||
},
|
||||
);
|
||||
models.insert(
|
||||
"qwen2.5-coder:3b".to_string(),
|
||||
ollama::Model {
|
||||
name: "qwen2.5-coder:3b".to_string(),
|
||||
display_name: None,
|
||||
max_tokens: 4096,
|
||||
keep_alive: None,
|
||||
supports_tools: None,
|
||||
supports_vision: None,
|
||||
supports_thinking: None,
|
||||
},
|
||||
);
|
||||
|
||||
let available_models = vec![
|
||||
AvailableModel {
|
||||
name: "qwen2.5-coder:1.5b".to_string(),
|
||||
display_name: Some("QWEN2.5 Coder 1.5B".to_string()),
|
||||
max_tokens: 5000,
|
||||
keep_alive: None,
|
||||
supports_tools: Some(true),
|
||||
supports_images: None,
|
||||
supports_thinking: None,
|
||||
},
|
||||
AvailableModel {
|
||||
name: "qwen2.5-coder:3b".to_string(),
|
||||
display_name: Some("QWEN2.5 Coder 3B".to_string()),
|
||||
max_tokens: 6000,
|
||||
keep_alive: None,
|
||||
supports_tools: Some(true),
|
||||
supports_images: None,
|
||||
supports_thinking: None,
|
||||
},
|
||||
];
|
||||
|
||||
merge_settings_into_models(&mut models, &available_models);
|
||||
|
||||
let model_1_5b = models
|
||||
.get("qwen2.5-coder:1.5b")
|
||||
.expect("1.5b model missing");
|
||||
let model_3b = models.get("qwen2.5-coder:3b").expect("3b model missing");
|
||||
|
||||
assert_eq!(
|
||||
model_1_5b.display_name,
|
||||
Some("QWEN2.5 Coder 1.5B".to_string()),
|
||||
"1.5b model should have its own display_name"
|
||||
);
|
||||
assert_eq!(model_1_5b.max_tokens, 5000);
|
||||
|
||||
assert_eq!(
|
||||
model_3b.display_name,
|
||||
Some("QWEN2.5 Coder 3B".to_string()),
|
||||
"3b model should have its own display_name"
|
||||
);
|
||||
assert_eq!(model_3b.max_tokens, 6000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
|
||||
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
|
||||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
|
||||
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
|
||||
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
|
||||
@@ -122,8 +122,8 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiOpenAi)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiOpenAi
|
||||
}
|
||||
|
||||
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
|
||||
@@ -4,7 +4,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
|
||||
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
|
||||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
|
||||
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
|
||||
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
|
||||
@@ -133,8 +133,8 @@ impl LanguageModelProvider for OpenAiCompatibleLanguageModelProvider {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiOpenAiCompat)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiOpenAiCompat
|
||||
}
|
||||
|
||||
fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
|
||||
@@ -4,7 +4,7 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture};
|
||||
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task};
|
||||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
|
||||
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
|
||||
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
|
||||
@@ -180,8 +180,8 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiOpenRouter)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiOpenRouter
|
||||
}
|
||||
|
||||
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
|
||||
@@ -4,7 +4,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
|
||||
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
|
||||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
|
||||
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
|
||||
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
LanguageModelRequest, LanguageModelToolChoice, RateLimiter, Role, env_var,
|
||||
@@ -117,8 +117,8 @@ impl LanguageModelProvider for VercelLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiVZero)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiVZero
|
||||
}
|
||||
|
||||
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
|
||||
@@ -4,7 +4,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
|
||||
use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window};
|
||||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
|
||||
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
|
||||
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
|
||||
@@ -118,8 +118,8 @@ impl LanguageModelProvider for XAiLanguageModelProvider {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiXAi)
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiXAi
|
||||
}
|
||||
|
||||
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
|
||||
@@ -127,16 +127,6 @@ impl LanguageServerState {
|
||||
return menu;
|
||||
};
|
||||
|
||||
let server_versions = self
|
||||
.lsp_store
|
||||
.update(cx, |lsp_store, _| {
|
||||
lsp_store
|
||||
.language_server_statuses()
|
||||
.map(|(server_id, status)| (server_id, status.server_version.clone()))
|
||||
.collect::<HashMap<_, _>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut first_button_encountered = false;
|
||||
for item in &self.items {
|
||||
if let LspMenuItem::ToggleServersButton { restart } = item {
|
||||
@@ -264,22 +254,6 @@ impl LanguageServerState {
|
||||
};
|
||||
|
||||
let server_name = server_info.name.clone();
|
||||
let server_version = server_versions
|
||||
.get(&server_info.id)
|
||||
.and_then(|version| version.clone());
|
||||
|
||||
let tooltip_text = match (&server_version, &message) {
|
||||
(None, None) => None,
|
||||
(Some(version), None) => {
|
||||
Some(SharedString::from(format!("Version: {}", version.as_ref())))
|
||||
}
|
||||
(None, Some(message)) => Some(message.clone()),
|
||||
(Some(version), Some(message)) => Some(SharedString::from(format!(
|
||||
"Version: {}\n\n{}",
|
||||
version.as_ref(),
|
||||
message.as_ref()
|
||||
))),
|
||||
};
|
||||
menu = menu.item(ContextMenuItem::custom_entry(
|
||||
move |_, _| {
|
||||
h_flex()
|
||||
@@ -381,11 +355,11 @@ impl LanguageServerState {
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip_text.map(|tooltip_text| {
|
||||
message.map(|server_message| {
|
||||
DocumentationAside::new(
|
||||
DocumentationSide::Right,
|
||||
DocumentationEdge::Top,
|
||||
Rc::new(move |_| Label::new(tooltip_text.clone()).into_any_element()),
|
||||
DocumentationEdge::Bottom,
|
||||
Rc::new(move |_| Label::new(server_message.clone()).into_any_element()),
|
||||
)
|
||||
}),
|
||||
));
|
||||
|
||||
@@ -330,8 +330,6 @@ impl LspLogView {
|
||||
let server_info = format!(
|
||||
"* Server: {NAME} (id {ID})
|
||||
|
||||
* Version: {VERSION}
|
||||
|
||||
* Binary: {BINARY}
|
||||
|
||||
* Registered workspace folders:
|
||||
@@ -342,12 +340,6 @@ impl LspLogView {
|
||||
* Configuration: {CONFIGURATION}",
|
||||
NAME = info.status.name,
|
||||
ID = info.id,
|
||||
VERSION = info
|
||||
.status
|
||||
.server_version
|
||||
.as_ref()
|
||||
.map(|version| version.as_ref())
|
||||
.unwrap_or("Unknown"),
|
||||
BINARY = info
|
||||
.status
|
||||
.binary
|
||||
@@ -1342,7 +1334,6 @@ impl ServerInfo {
|
||||
capabilities: server.capabilities(),
|
||||
status: LanguageServerStatus {
|
||||
name: server.name(),
|
||||
server_version: server.version(),
|
||||
pending_work: Default::default(),
|
||||
has_pending_diagnostic_updates: false,
|
||||
progress_tokens: Default::default(),
|
||||
|
||||
@@ -89,7 +89,6 @@ pub struct LanguageServer {
|
||||
outbound_tx: channel::Sender<String>,
|
||||
notification_tx: channel::Sender<NotificationSerializer>,
|
||||
name: LanguageServerName,
|
||||
version: Option<SharedString>,
|
||||
process_name: Arc<str>,
|
||||
binary: LanguageServerBinary,
|
||||
capabilities: RwLock<ServerCapabilities>,
|
||||
@@ -502,7 +501,6 @@ impl LanguageServer {
|
||||
response_handlers,
|
||||
io_handlers,
|
||||
name: server_name,
|
||||
version: None,
|
||||
process_name: binary
|
||||
.path
|
||||
.file_name()
|
||||
@@ -884,9 +882,7 @@ impl LanguageServer {
|
||||
window: Some(WindowClientCapabilities {
|
||||
work_done_progress: Some(true),
|
||||
show_message: Some(ShowMessageRequestClientCapabilities {
|
||||
message_action_item: Some(MessageActionItemCapabilities {
|
||||
additional_properties_support: Some(true),
|
||||
}),
|
||||
message_action_item: None,
|
||||
}),
|
||||
..WindowClientCapabilities::default()
|
||||
}),
|
||||
@@ -927,7 +923,6 @@ impl LanguageServer {
|
||||
)
|
||||
})?;
|
||||
if let Some(info) = response.server_info {
|
||||
self.version = info.version.map(SharedString::from);
|
||||
self.process_name = info.name.into();
|
||||
}
|
||||
self.capabilities = RwLock::new(response.capabilities);
|
||||
@@ -1158,11 +1153,6 @@ impl LanguageServer {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
/// Get the version of the running language server.
|
||||
pub fn version(&self) -> Option<SharedString> {
|
||||
self.version.clone()
|
||||
}
|
||||
|
||||
pub fn process_name(&self) -> &str {
|
||||
&self.process_name
|
||||
}
|
||||
|
||||
@@ -2610,8 +2610,9 @@ impl MultiBuffer {
|
||||
for range in ranges {
|
||||
let range = range.to_point(&snapshot);
|
||||
let start = snapshot.point_to_offset(Point::new(range.start.row, 0));
|
||||
let end = (snapshot.point_to_offset(Point::new(range.end.row + 1, 0)) + 1usize)
|
||||
.min(snapshot.len());
|
||||
let end = snapshot.point_to_offset(Point::new(range.end.row + 1, 0));
|
||||
let start = start.saturating_sub_usize(1);
|
||||
let end = snapshot.len().min(end + 1usize);
|
||||
cursor.seek(&start, Bias::Right);
|
||||
while let Some(item) = cursor.item() {
|
||||
if *cursor.start() >= end {
|
||||
|
||||
@@ -50,13 +50,7 @@ impl Settings for OutlinePanelSettings {
|
||||
dock: panel.dock.unwrap(),
|
||||
file_icons: panel.file_icons.unwrap(),
|
||||
folder_icons: panel.folder_icons.unwrap(),
|
||||
git_status: panel.git_status.unwrap()
|
||||
&& content
|
||||
.git
|
||||
.unwrap()
|
||||
.enabled
|
||||
.unwrap()
|
||||
.is_git_status_enabled(),
|
||||
git_status: panel.git_status.unwrap(),
|
||||
indent_size: panel.indent_size.unwrap(),
|
||||
indent_guides: IndentGuidesSettings {
|
||||
show: panel.indent_guides.unwrap().show.unwrap(),
|
||||
|
||||
@@ -128,7 +128,6 @@ use util::{
|
||||
ConnectionResult, ResultExt as _, debug_panic, defer, maybe, merge_json_value_into,
|
||||
paths::{PathStyle, SanitizedPath},
|
||||
post_inc,
|
||||
redact::redact_command,
|
||||
rel_path::RelPath,
|
||||
};
|
||||
|
||||
@@ -578,12 +577,9 @@ impl LocalLspStore {
|
||||
},
|
||||
},
|
||||
);
|
||||
log::error!(
|
||||
"Failed to start language server {server_name:?}: {}",
|
||||
redact_command(&format!("{err:?}"))
|
||||
);
|
||||
log::error!("Failed to start language server {server_name:?}: {err:?}");
|
||||
if !log.is_empty() {
|
||||
log::error!("server stderr: {}", redact_command(&log));
|
||||
log::error!("server stderr: {log}");
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -3864,7 +3860,6 @@ pub enum LspStoreEvent {
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct LanguageServerStatus {
|
||||
pub name: LanguageServerName,
|
||||
pub server_version: Option<SharedString>,
|
||||
pub pending_work: BTreeMap<ProgressToken, LanguageServerProgress>,
|
||||
pub has_pending_diagnostic_updates: bool,
|
||||
pub progress_tokens: HashSet<ProgressToken>,
|
||||
@@ -8355,7 +8350,6 @@ impl LspStore {
|
||||
server_id,
|
||||
LanguageServerStatus {
|
||||
name,
|
||||
server_version: None,
|
||||
pending_work: Default::default(),
|
||||
has_pending_diagnostic_updates: false,
|
||||
progress_tokens: Default::default(),
|
||||
@@ -9391,7 +9385,6 @@ impl LspStore {
|
||||
server_id,
|
||||
LanguageServerStatus {
|
||||
name: server_name.clone(),
|
||||
server_version: None,
|
||||
pending_work: Default::default(),
|
||||
has_pending_diagnostic_updates: false,
|
||||
progress_tokens: Default::default(),
|
||||
@@ -11422,7 +11415,6 @@ impl LspStore {
|
||||
server_id,
|
||||
LanguageServerStatus {
|
||||
name: language_server.name(),
|
||||
server_version: language_server.version(),
|
||||
pending_work: Default::default(),
|
||||
has_pending_diagnostic_updates: false,
|
||||
progress_tokens: Default::default(),
|
||||
|
||||
@@ -332,10 +332,6 @@ impl GoToDiagnosticSeverityFilter {
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct GitSettings {
|
||||
/// Whether or not git integration is enabled.
|
||||
///
|
||||
/// Default: true
|
||||
pub enabled: GitEnabledSettings,
|
||||
/// Whether or not to show the git gutter.
|
||||
///
|
||||
/// Default: tracked_files
|
||||
@@ -365,18 +361,6 @@ pub struct GitSettings {
|
||||
pub path_style: GitPathStyle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct GitEnabledSettings {
|
||||
/// Whether git integration is enabled for showing git status.
|
||||
///
|
||||
/// Default: true
|
||||
pub status: bool,
|
||||
/// Whether git integration is enabled for showing diffs.
|
||||
///
|
||||
/// Default: true
|
||||
pub diff: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default)]
|
||||
pub enum GitPathStyle {
|
||||
#[default]
|
||||
@@ -518,14 +502,7 @@ impl Settings for ProjectSettings {
|
||||
let inline_diagnostics = diagnostics.inline.as_ref().unwrap();
|
||||
|
||||
let git = content.git.as_ref().unwrap();
|
||||
let git_enabled = {
|
||||
GitEnabledSettings {
|
||||
status: git.enabled.as_ref().unwrap().is_git_status_enabled(),
|
||||
diff: git.enabled.as_ref().unwrap().is_git_diff_enabled(),
|
||||
}
|
||||
};
|
||||
let git_settings = GitSettings {
|
||||
enabled: git_enabled,
|
||||
git_gutter: git.git_gutter.unwrap(),
|
||||
gutter_debounce: git.gutter_debounce.unwrap_or_default(),
|
||||
inline_blame: {
|
||||
|
||||
@@ -92,13 +92,7 @@ impl Settings for ProjectPanelSettings {
|
||||
entry_spacing: project_panel.entry_spacing.unwrap(),
|
||||
file_icons: project_panel.file_icons.unwrap(),
|
||||
folder_icons: project_panel.folder_icons.unwrap(),
|
||||
git_status: project_panel.git_status.unwrap()
|
||||
&& content
|
||||
.git
|
||||
.unwrap()
|
||||
.enabled
|
||||
.unwrap()
|
||||
.is_git_status_enabled(),
|
||||
git_status: project_panel.git_status.unwrap(),
|
||||
indent_size: project_panel.indent_size.unwrap(),
|
||||
indent_guides: IndentGuidesSettings {
|
||||
show: project_panel.indent_guides.unwrap().show.unwrap(),
|
||||
|
||||
@@ -28,11 +28,6 @@ parking_lot.workspace = true
|
||||
paths.workspace = true
|
||||
rope.workspace = true
|
||||
serde.workspace = true
|
||||
strum.workspace = true
|
||||
text.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mod prompts;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use chrono::{DateTime, Utc};
|
||||
use collections::HashMap;
|
||||
use futures::FutureExt as _;
|
||||
@@ -23,7 +23,6 @@ use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
};
|
||||
use strum::{EnumIter, IntoEnumIterator as _};
|
||||
use text::LineEnding;
|
||||
use util::ResultExt;
|
||||
use uuid::Uuid;
|
||||
@@ -52,51 +51,11 @@ pub struct PromptMetadata {
|
||||
pub saved_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl PromptMetadata {
|
||||
fn builtin(builtin: BuiltInPrompt) -> Self {
|
||||
Self {
|
||||
id: PromptId::BuiltIn(builtin),
|
||||
title: Some(builtin.title().into()),
|
||||
default: false,
|
||||
saved_at: DateTime::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in prompts that have default content and can be customized by users.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)]
|
||||
pub enum BuiltInPrompt {
|
||||
CommitMessage,
|
||||
}
|
||||
|
||||
impl BuiltInPrompt {
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Self::CommitMessage => "Commit message",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default content for this built-in prompt.
|
||||
pub fn default_content(&self) -> &'static str {
|
||||
match self {
|
||||
Self::CommitMessage => include_str!("../../git_ui/src/commit_message_prompt.txt"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BuiltInPrompt {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::CommitMessage => write!(f, "Commit message"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum PromptId {
|
||||
User { uuid: UserPromptId },
|
||||
BuiltIn(BuiltInPrompt),
|
||||
CommitMessage,
|
||||
}
|
||||
|
||||
impl PromptId {
|
||||
@@ -104,37 +63,31 @@ impl PromptId {
|
||||
UserPromptId::new().into()
|
||||
}
|
||||
|
||||
pub fn as_user(&self) -> Option<UserPromptId> {
|
||||
pub fn user_id(&self) -> Option<UserPromptId> {
|
||||
match self {
|
||||
Self::User { uuid } => Some(*uuid),
|
||||
Self::BuiltIn { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_built_in(&self) -> Option<BuiltInPrompt> {
|
||||
match self {
|
||||
Self::User { .. } => None,
|
||||
Self::BuiltIn(builtin) => Some(*builtin),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_built_in(&self) -> bool {
|
||||
matches!(self, Self::BuiltIn { .. })
|
||||
match self {
|
||||
Self::User { .. } => false,
|
||||
Self::CommitMessage => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_edit(&self) -> bool {
|
||||
match self {
|
||||
Self::User { .. } => true,
|
||||
Self::BuiltIn(builtin) => match builtin {
|
||||
BuiltInPrompt::CommitMessage => true,
|
||||
},
|
||||
Self::User { .. } | Self::CommitMessage => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BuiltInPrompt> for PromptId {
|
||||
fn from(builtin: BuiltInPrompt) -> Self {
|
||||
PromptId::BuiltIn(builtin)
|
||||
pub fn default_content(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::User { .. } => None,
|
||||
Self::CommitMessage => Some(include_str!("../../git_ui/src/commit_message_prompt.txt")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +117,7 @@ impl std::fmt::Display for PromptId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PromptId::User { uuid } => write!(f, "{}", uuid.0),
|
||||
PromptId::BuiltIn(builtin) => write!(f, "{}", builtin),
|
||||
PromptId::CommitMessage => write!(f, "Commit message"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,16 +150,6 @@ impl MetadataCache {
|
||||
cache.metadata.push(metadata.clone());
|
||||
cache.metadata_by_id.insert(prompt_id, metadata);
|
||||
}
|
||||
|
||||
// Insert all the built-in prompts that were not customized by the user
|
||||
for builtin in BuiltInPrompt::iter() {
|
||||
let builtin_id = PromptId::BuiltIn(builtin);
|
||||
if !cache.metadata_by_id.contains_key(&builtin_id) {
|
||||
let metadata = PromptMetadata::builtin(builtin);
|
||||
cache.metadata.push(metadata.clone());
|
||||
cache.metadata_by_id.insert(builtin_id, metadata);
|
||||
}
|
||||
}
|
||||
cache.sort();
|
||||
Ok(cache)
|
||||
}
|
||||
@@ -255,6 +198,10 @@ impl PromptStore {
|
||||
let mut txn = db_env.write_txn()?;
|
||||
let metadata = db_env.create_database(&mut txn, Some("metadata.v2"))?;
|
||||
let bodies = db_env.create_database(&mut txn, Some("bodies.v2"))?;
|
||||
|
||||
metadata.delete(&mut txn, &PromptId::CommitMessage)?;
|
||||
bodies.delete(&mut txn, &PromptId::CommitMessage)?;
|
||||
|
||||
txn.commit()?;
|
||||
|
||||
Self::upgrade_dbs(&db_env, metadata, bodies).log_err();
|
||||
@@ -347,16 +294,7 @@ impl PromptStore {
|
||||
let bodies = self.bodies;
|
||||
cx.background_spawn(async move {
|
||||
let txn = env.read_txn()?;
|
||||
let mut prompt: String = match bodies.get(&txn, &id)? {
|
||||
Some(body) => body.into(),
|
||||
None => {
|
||||
if let Some(built_in) = id.as_built_in() {
|
||||
built_in.default_content().into()
|
||||
} else {
|
||||
anyhow::bail!("prompt not found")
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut prompt = bodies.get(&txn, &id)?.context("prompt not found")?.into();
|
||||
LineEnding::normalize(&mut prompt);
|
||||
Ok(prompt)
|
||||
})
|
||||
@@ -401,6 +339,11 @@ impl PromptStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the number of prompts in the store.
|
||||
pub fn prompt_count(&self) -> usize {
|
||||
self.metadata_cache.read().metadata.len()
|
||||
}
|
||||
|
||||
pub fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
|
||||
self.metadata_cache.read().metadata_by_id.get(&id).cloned()
|
||||
}
|
||||
@@ -469,38 +412,23 @@ impl PromptStore {
|
||||
return Task::ready(Err(anyhow!("this prompt cannot be edited")));
|
||||
}
|
||||
|
||||
let body = body.to_string();
|
||||
let is_default_content = id
|
||||
.as_built_in()
|
||||
.is_some_and(|builtin| body.trim() == builtin.default_content().trim());
|
||||
|
||||
let metadata = if let Some(builtin) = id.as_built_in() {
|
||||
PromptMetadata::builtin(builtin)
|
||||
} else {
|
||||
PromptMetadata {
|
||||
id,
|
||||
title,
|
||||
default,
|
||||
saved_at: Utc::now(),
|
||||
}
|
||||
let prompt_metadata = PromptMetadata {
|
||||
id,
|
||||
title,
|
||||
default,
|
||||
saved_at: Utc::now(),
|
||||
};
|
||||
|
||||
self.metadata_cache.write().insert(metadata.clone());
|
||||
self.metadata_cache.write().insert(prompt_metadata.clone());
|
||||
|
||||
let db_connection = self.env.clone();
|
||||
let bodies = self.bodies;
|
||||
let metadata_db = self.metadata;
|
||||
let metadata = self.metadata;
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
let mut txn = db_connection.write_txn()?;
|
||||
|
||||
if is_default_content {
|
||||
metadata_db.delete(&mut txn, &id)?;
|
||||
bodies.delete(&mut txn, &id)?;
|
||||
} else {
|
||||
metadata_db.put(&mut txn, &id, &metadata)?;
|
||||
bodies.put(&mut txn, &id, &body)?;
|
||||
}
|
||||
metadata.put(&mut txn, &id, &prompt_metadata)?;
|
||||
bodies.put(&mut txn, &id, &body.to_string())?;
|
||||
|
||||
txn.commit()?;
|
||||
|
||||
@@ -562,122 +490,3 @@ impl PromptStore {
|
||||
pub struct GlobalPromptStore(Shared<Task<Result<Entity<PromptStore>, Arc<anyhow::Error>>>>);
|
||||
|
||||
impl Global for GlobalPromptStore {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_built_in_prompt_load_save(cx: &mut TestAppContext) {
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("prompts-db");
|
||||
|
||||
let store = cx.update(|cx| PromptStore::new(db_path, cx)).await.unwrap();
|
||||
let store = cx.new(|_cx| store);
|
||||
|
||||
let commit_message_id = PromptId::BuiltIn(BuiltInPrompt::CommitMessage);
|
||||
|
||||
let loaded_content = store
|
||||
.update(cx, |store, cx| store.load(commit_message_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut expected_content = BuiltInPrompt::CommitMessage.default_content().to_string();
|
||||
LineEnding::normalize(&mut expected_content);
|
||||
assert_eq!(
|
||||
loaded_content.trim(),
|
||||
expected_content.trim(),
|
||||
"Loading a built-in prompt not in DB should return default content"
|
||||
);
|
||||
|
||||
let metadata = store.read_with(cx, |store, _| store.metadata(commit_message_id));
|
||||
assert!(
|
||||
metadata.is_some(),
|
||||
"Built-in prompt should always have metadata"
|
||||
);
|
||||
assert!(
|
||||
store.read_with(cx, |store, _| {
|
||||
store
|
||||
.metadata_cache
|
||||
.read()
|
||||
.metadata_by_id
|
||||
.contains_key(&commit_message_id)
|
||||
}),
|
||||
"Built-in prompt should always be in cache"
|
||||
);
|
||||
|
||||
let custom_content = "Custom commit message prompt";
|
||||
store
|
||||
.update(cx, |store, cx| {
|
||||
store.save(
|
||||
commit_message_id,
|
||||
Some("Commit message".into()),
|
||||
false,
|
||||
Rope::from(custom_content),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let loaded_custom = store
|
||||
.update(cx, |store, cx| store.load(commit_message_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
loaded_custom.trim(),
|
||||
custom_content.trim(),
|
||||
"Custom content should be loaded after saving"
|
||||
);
|
||||
|
||||
assert!(
|
||||
store
|
||||
.read_with(cx, |store, _| store.metadata(commit_message_id))
|
||||
.is_some(),
|
||||
"Built-in prompt should have metadata after customization"
|
||||
);
|
||||
|
||||
store
|
||||
.update(cx, |store, cx| {
|
||||
store.save(
|
||||
commit_message_id,
|
||||
Some("Commit message".into()),
|
||||
false,
|
||||
Rope::from(BuiltInPrompt::CommitMessage.default_content()),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let metadata_after_reset =
|
||||
store.read_with(cx, |store, _| store.metadata(commit_message_id));
|
||||
assert!(
|
||||
metadata_after_reset.is_some(),
|
||||
"Built-in prompt should still have metadata after reset"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata_after_reset
|
||||
.as_ref()
|
||||
.and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
|
||||
Some("Commit message"),
|
||||
"Built-in prompt should have default title after reset"
|
||||
);
|
||||
|
||||
let loaded_after_reset = store
|
||||
.update(cx, |store, cx| store.load(commit_message_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut expected_content_after_reset =
|
||||
BuiltInPrompt::CommitMessage.default_content().to_string();
|
||||
LineEnding::normalize(&mut expected_content_after_reset);
|
||||
assert_eq!(
|
||||
loaded_after_reset.trim(),
|
||||
expected_content_after_reset.trim(),
|
||||
"After saving default content, load should return default"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ use collections::{HashMap, HashSet};
|
||||
use editor::{CompletionProvider, SelectionEffects};
|
||||
use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab};
|
||||
use gpui::{
|
||||
App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel,
|
||||
Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle, WindowOptions,
|
||||
actions, point, size, transparent_black,
|
||||
Action, App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable,
|
||||
PromptLevel, Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle,
|
||||
WindowOptions, actions, point, size, transparent_black,
|
||||
};
|
||||
use language::{Buffer, LanguageRegistry, language_settings::SoftWrap};
|
||||
use language_model::{
|
||||
@@ -21,7 +21,7 @@ use std::sync::atomic::AtomicBool;
|
||||
use std::time::Duration;
|
||||
use theme::ThemeSettings;
|
||||
use title_bar::platform_title_bar::PlatformTitleBar;
|
||||
use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
|
||||
use ui::{Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
|
||||
use util::{ResultExt, TryFutureExt};
|
||||
use workspace::{Workspace, WorkspaceSettings, client_side_decorations};
|
||||
use zed_actions::assistant::InlineAssist;
|
||||
@@ -206,8 +206,13 @@ impl PickerDelegate for RulePickerDelegate {
|
||||
self.filtered_entries.len()
|
||||
}
|
||||
|
||||
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
|
||||
Some("No rules found matching your search.".into())
|
||||
fn no_matches_text(&self, _window: &mut Window, cx: &mut App) -> Option<SharedString> {
|
||||
let text = if self.store.read(cx).prompt_count() == 0 {
|
||||
"No rules.".into()
|
||||
} else {
|
||||
"No rules found matching your search.".into()
|
||||
};
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
@@ -675,13 +680,13 @@ impl RulesLibrary {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(built_in) = prompt_id.as_built_in() else {
|
||||
let Some(default_content) = prompt_id.default_content() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
|
||||
rule_editor.body_editor.update(cx, |editor, cx| {
|
||||
editor.set_text(built_in.default_content(), window, cx);
|
||||
editor.set_text(default_content, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1423,7 +1428,31 @@ impl Render for RulesLibrary {
|
||||
this.border_t_1().border_color(cx.theme().colors().border)
|
||||
})
|
||||
.child(self.render_rule_list(cx))
|
||||
.child(self.render_active_rule(cx)),
|
||||
.map(|el| {
|
||||
if self.store.read(cx).prompt_count() == 0 {
|
||||
el.child(
|
||||
v_flex()
|
||||
.h_full()
|
||||
.flex_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.border_l_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(
|
||||
Button::new("create-rule", "New Rule")
|
||||
.style(ButtonStyle::Outlined)
|
||||
.key_binding(KeyBinding::for_action(&NewRule, cx))
|
||||
.on_click(|_, window, cx| {
|
||||
window
|
||||
.dispatch_action(NewRule.boxed_clone(), cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
el.child(self.render_active_rule(cx))
|
||||
}
|
||||
}),
|
||||
),
|
||||
window,
|
||||
cx,
|
||||
|
||||
@@ -288,11 +288,6 @@ impl std::fmt::Debug for ContextServerCommand {
|
||||
#[with_fallible_options]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
||||
pub struct GitSettings {
|
||||
/// Whether or not to enable git integration.
|
||||
///
|
||||
/// Default: true
|
||||
#[serde(flatten)]
|
||||
pub enabled: Option<GitEnabledSettings>,
|
||||
/// Whether or not to show the git gutter.
|
||||
///
|
||||
/// Default: tracked_files
|
||||
@@ -322,25 +317,6 @@ pub struct GitSettings {
|
||||
pub path_style: Option<GitPathStyle>,
|
||||
}
|
||||
|
||||
#[with_fallible_options]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct GitEnabledSettings {
|
||||
pub disable_git: Option<bool>,
|
||||
pub enable_status: Option<bool>,
|
||||
pub enable_diff: Option<bool>,
|
||||
}
|
||||
|
||||
impl GitEnabledSettings {
|
||||
pub fn is_git_status_enabled(&self) -> bool {
|
||||
!self.disable_git.unwrap_or(false) && self.enable_status.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn is_git_diff_enabled(&self) -> bool {
|
||||
!self.disable_git.unwrap_or(false) && self.enable_diff.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
|
||||
@@ -5519,102 +5519,6 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
|
||||
SettingsPage {
|
||||
title: "Version Control",
|
||||
items: vec![
|
||||
SettingsPageItem::SectionHeader("Git Integration"),
|
||||
SettingsPageItem::DynamicItem(DynamicItem {
|
||||
discriminant: SettingItem {
|
||||
files: USER,
|
||||
title: "Disable Git Integration",
|
||||
description: "Disable all Git integration features in Zed.",
|
||||
field: Box::new(SettingField::<bool> {
|
||||
json_path: Some("git.disable_git"),
|
||||
pick: |settings_content| {
|
||||
settings_content
|
||||
.git
|
||||
.as_ref()?
|
||||
.enabled
|
||||
.as_ref()?
|
||||
.disable_git
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings_content, value| {
|
||||
settings_content
|
||||
.git
|
||||
.get_or_insert_default()
|
||||
.enabled
|
||||
.get_or_insert_default()
|
||||
.disable_git = value;
|
||||
},
|
||||
}),
|
||||
metadata: None,
|
||||
},
|
||||
pick_discriminant: |settings_content| {
|
||||
let disabled = settings_content
|
||||
.git
|
||||
.as_ref()?
|
||||
.enabled
|
||||
.as_ref()?
|
||||
.disable_git
|
||||
.unwrap_or(false);
|
||||
Some(if disabled { 0 } else { 1 })
|
||||
},
|
||||
fields: vec![
|
||||
vec![],
|
||||
vec![
|
||||
SettingItem {
|
||||
files: USER,
|
||||
title: "Enable Git Status",
|
||||
description: "Show Git status information in the editor.",
|
||||
field: Box::new(SettingField::<bool> {
|
||||
json_path: Some("git.enable_status"),
|
||||
pick: |settings_content| {
|
||||
settings_content
|
||||
.git
|
||||
.as_ref()?
|
||||
.enabled
|
||||
.as_ref()?
|
||||
.enable_status
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings_content, value| {
|
||||
settings_content
|
||||
.git
|
||||
.get_or_insert_default()
|
||||
.enabled
|
||||
.get_or_insert_default()
|
||||
.enable_status = value;
|
||||
},
|
||||
}),
|
||||
metadata: None,
|
||||
},
|
||||
SettingItem {
|
||||
files: USER,
|
||||
title: "Enable Git Diff",
|
||||
description: "Show Git diff information in the editor.",
|
||||
field: Box::new(SettingField::<bool> {
|
||||
json_path: Some("git.enable_diff"),
|
||||
pick: |settings_content| {
|
||||
settings_content
|
||||
.git
|
||||
.as_ref()?
|
||||
.enabled
|
||||
.as_ref()?
|
||||
.enable_diff
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings_content, value| {
|
||||
settings_content
|
||||
.git
|
||||
.get_or_insert_default()
|
||||
.enabled
|
||||
.get_or_insert_default()
|
||||
.enable_diff = value;
|
||||
},
|
||||
}),
|
||||
metadata: None,
|
||||
},
|
||||
],
|
||||
],
|
||||
}),
|
||||
SettingsPageItem::SectionHeader("Git Gutter"),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Visibility",
|
||||
|
||||
@@ -893,57 +893,39 @@ impl ContextMenu {
|
||||
entry_render,
|
||||
handler,
|
||||
selectable,
|
||||
documentation_aside,
|
||||
..
|
||||
} => {
|
||||
let handler = handler.clone();
|
||||
let menu = cx.entity().downgrade();
|
||||
let selectable = *selectable;
|
||||
|
||||
div()
|
||||
.id(("context-menu-child", ix))
|
||||
.when_some(documentation_aside.clone(), |this, documentation_aside| {
|
||||
this.occlude()
|
||||
.on_hover(cx.listener(move |menu, hovered, _, cx| {
|
||||
if *hovered {
|
||||
menu.documentation_aside = Some((ix, documentation_aside.clone()));
|
||||
} else if matches!(menu.documentation_aside, Some((id, _)) if id == ix)
|
||||
{
|
||||
menu.documentation_aside = None;
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
ListItem::new(ix)
|
||||
.inset(true)
|
||||
.toggle_state(if selectable {
|
||||
Some(ix) == self.selected_index
|
||||
} else {
|
||||
false
|
||||
})
|
||||
.child(
|
||||
ListItem::new(ix)
|
||||
.inset(true)
|
||||
.toggle_state(if selectable {
|
||||
Some(ix) == self.selected_index
|
||||
} else {
|
||||
false
|
||||
})
|
||||
.selectable(selectable)
|
||||
.when(selectable, |item| {
|
||||
item.on_click({
|
||||
let context = self.action_context.clone();
|
||||
let keep_open_on_confirm = self.keep_open_on_confirm;
|
||||
move |_, window, cx| {
|
||||
handler(context.as_ref(), window, cx);
|
||||
menu.update(cx, |menu, cx| {
|
||||
menu.clicked = true;
|
||||
.selectable(selectable)
|
||||
.when(selectable, |item| {
|
||||
item.on_click({
|
||||
let context = self.action_context.clone();
|
||||
let keep_open_on_confirm = self.keep_open_on_confirm;
|
||||
move |_, window, cx| {
|
||||
handler(context.as_ref(), window, cx);
|
||||
menu.update(cx, |menu, cx| {
|
||||
menu.clicked = true;
|
||||
|
||||
if keep_open_on_confirm {
|
||||
menu.rebuild(window, cx);
|
||||
} else {
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
if keep_open_on_confirm {
|
||||
menu.rebuild(window, cx);
|
||||
} else {
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(entry_render(window, cx)),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(entry_render(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,17 @@ enum IconSource {
|
||||
ExternalSvg(SharedString),
|
||||
}
|
||||
|
||||
impl IconSource {
|
||||
fn from_path(path: impl Into<SharedString>) -> Self {
|
||||
let path = path.into();
|
||||
if path.starts_with("icons/") {
|
||||
Self::Embedded(path)
|
||||
} else {
|
||||
Self::External(Arc::from(PathBuf::from(path.as_ref())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement, RegisterComponent)]
|
||||
pub struct Icon {
|
||||
source: IconSource,
|
||||
@@ -144,18 +155,9 @@ impl Icon {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an icon from a path. Uses a heuristic to determine if it's embedded or external:
|
||||
/// - Paths starting with "icons/" are treated as embedded SVGs
|
||||
/// - Other paths are treated as external raster images (from icon themes)
|
||||
pub fn from_path(path: impl Into<SharedString>) -> Self {
|
||||
let path = path.into();
|
||||
let source = if path.starts_with("icons/") {
|
||||
IconSource::Embedded(path)
|
||||
} else {
|
||||
IconSource::External(Arc::from(PathBuf::from(path.as_ref())))
|
||||
};
|
||||
Self {
|
||||
source,
|
||||
source: IconSource::from_path(path),
|
||||
color: Color::default(),
|
||||
size: IconSize::default().rems(),
|
||||
transformation: Transformation::default(),
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static REDACT_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r#"([A-Z_][A-Z0-9_]*)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)"#).unwrap()
|
||||
});
|
||||
|
||||
/// Whether a given environment variable name should have its value redacted
|
||||
pub fn should_redact(env_var_name: &str) -> bool {
|
||||
const REDACTED_SUFFIXES: &[&str] = &[
|
||||
@@ -19,31 +13,3 @@ pub fn should_redact(env_var_name: &str) -> bool {
|
||||
.iter()
|
||||
.any(|suffix| env_var_name.ends_with(suffix))
|
||||
}
|
||||
|
||||
/// Redact a string which could include a command with environment variables
|
||||
pub fn redact_command(command: &str) -> String {
|
||||
REDACT_REGEX
|
||||
.replace_all(command, |caps: ®ex::Captures| {
|
||||
let var_name = &caps[1];
|
||||
let value = &caps[2];
|
||||
if should_redact(var_name) {
|
||||
format!(r#"{}="[REDACTED]""#, var_name)
|
||||
} else {
|
||||
format!("{}={}", var_name, value)
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_redact_string_with_multiple_env_vars() {
|
||||
let input = r#"failed to spawn command cd "/code/something" && ANTHROPIC_API_KEY="sk-ant-api03-WOOOO" COMMAND_MODE="unix2003" GEMINI_API_KEY="AIGEMINIFACE" HOME="/Users/foo""#;
|
||||
let result = redact_command(input);
|
||||
let expected = r#"failed to spawn command cd "/code/something" && ANTHROPIC_API_KEY="[REDACTED]" COMMAND_MODE="unix2003" GEMINI_API_KEY="[REDACTED]" HOME="/Users/foo""#;
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,13 +76,7 @@ impl Settings for ItemSettings {
|
||||
fn from_settings(content: &settings::SettingsContent) -> Self {
|
||||
let tabs = content.tabs.as_ref().unwrap();
|
||||
Self {
|
||||
git_status: tabs.git_status.unwrap()
|
||||
&& content
|
||||
.git
|
||||
.unwrap()
|
||||
.enabled
|
||||
.unwrap()
|
||||
.is_git_status_enabled(),
|
||||
git_status: tabs.git_status.unwrap(),
|
||||
close_position: tabs.close_position.unwrap(),
|
||||
activate_on_close: tabs.activate_on_close.unwrap(),
|
||||
file_icons: tabs.file_icons.unwrap(),
|
||||
|
||||
@@ -12,11 +12,40 @@ workspace = true
|
||||
|
||||
[features]
|
||||
tracy = ["ztracing/tracy"]
|
||||
test-support = [
|
||||
"gpui/test-support",
|
||||
"gpui/screen-capture",
|
||||
"dep:image",
|
||||
"dep:semver",
|
||||
"workspace/test-support",
|
||||
"project/test-support",
|
||||
"editor/test-support",
|
||||
"terminal_view/test-support",
|
||||
"image_viewer/test-support",
|
||||
]
|
||||
visual-tests = [
|
||||
"gpui/test-support",
|
||||
"gpui/screen-capture",
|
||||
"dep:image",
|
||||
"dep:semver",
|
||||
"dep:tempfile",
|
||||
"workspace/test-support",
|
||||
"project/test-support",
|
||||
"editor/test-support",
|
||||
"terminal_view/test-support",
|
||||
"image_viewer/test-support",
|
||||
"clock/test-support",
|
||||
]
|
||||
|
||||
[[bin]]
|
||||
name = "zed"
|
||||
path = "src/zed-main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "visual_test_runner"
|
||||
path = "src/visual_test_runner.rs"
|
||||
required-features = ["visual-tests"]
|
||||
|
||||
[lib]
|
||||
name = "zed"
|
||||
path = "src/main.rs"
|
||||
@@ -74,6 +103,10 @@ gpui = { workspace = true, features = [
|
||||
"font-kit",
|
||||
"windows-manifest",
|
||||
] }
|
||||
image = { workspace = true, optional = true }
|
||||
semver = { workspace = true, optional = true }
|
||||
tempfile = { workspace = true, optional = true }
|
||||
clock = { workspace = true, optional = true }
|
||||
gpui_tokio.workspace = true
|
||||
rayon.workspace = true
|
||||
|
||||
@@ -185,7 +218,7 @@ ashpd.workspace = true
|
||||
call = { workspace = true, features = ["test-support"] }
|
||||
dap = { workspace = true, features = ["test-support"] }
|
||||
editor = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support", "screen-capture"] }
|
||||
image_viewer = { workspace = true, features = ["test-support"] }
|
||||
itertools.workspace = true
|
||||
language = { workspace = true, features = ["test-support"] }
|
||||
@@ -196,11 +229,11 @@ terminal_view = { workspace = true, features = ["test-support"] }
|
||||
tree-sitter-md.workspace = true
|
||||
tree-sitter-rust.workspace = true
|
||||
workspace = { workspace = true, features = ["test-support"] }
|
||||
image.workspace = true
|
||||
agent_ui = { workspace = true, features = ["test-support"] }
|
||||
agent_ui_v2 = { workspace = true, features = ["test-support"] }
|
||||
search = { workspace = true, features = ["test-support"] }
|
||||
|
||||
|
||||
[package.metadata.bundle-dev]
|
||||
icon = ["resources/app-icon-dev@2x.png", "resources/app-icon-dev.png"]
|
||||
identifier = "dev.zed.Zed-Dev"
|
||||
|
||||
@@ -564,11 +564,6 @@ pub fn main() {
|
||||
dap_adapters::init(cx);
|
||||
auto_update_ui::init(cx);
|
||||
reliability::init(client.clone(), cx);
|
||||
// Initialize the language model registry first, then set up the extension proxy
|
||||
// BEFORE extension_host::init so that extensions can register their LLM providers
|
||||
// when they load.
|
||||
language_model::init(app_state.client.clone(), cx);
|
||||
language_models::init_extension_proxy(cx);
|
||||
extension_host::init(
|
||||
extension_host_proxy.clone(),
|
||||
app_state.fs.clone(),
|
||||
@@ -594,6 +589,7 @@ pub fn main() {
|
||||
cx,
|
||||
);
|
||||
supermaven::init(app_state.client.clone(), cx);
|
||||
language_model::init(app_state.client.clone(), cx);
|
||||
language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
|
||||
acp_tools::init(cx);
|
||||
edit_prediction_ui::init(cx);
|
||||
|
||||
696
crates/zed/src/visual_test_runner.rs
Normal file
696
crates/zed/src/visual_test_runner.rs
Normal file
@@ -0,0 +1,696 @@
|
||||
//! Visual Test Runner
|
||||
//!
|
||||
//! This binary runs visual regression tests for Zed's UI. It captures screenshots
|
||||
//! of real Zed windows and compares them against baseline images.
|
||||
//!
|
||||
//! ## How It Works
|
||||
//!
|
||||
//! This tool uses direct texture capture - it renders the scene to a Metal texture
|
||||
//! and reads the pixels back directly. This approach:
|
||||
//! - Does NOT require Screen Recording permission
|
||||
//! - Does NOT require the window to be visible on screen
|
||||
//! - Captures raw GPUI output without system window chrome
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! Run the visual tests:
|
||||
//! cargo run -p zed --bin visual_test_runner --features visual-tests
|
||||
//!
|
||||
//! Update baseline images (when UI intentionally changes):
|
||||
//! UPDATE_BASELINE=1 cargo run -p zed --bin visual_test_runner --features visual-tests
|
||||
//!
|
||||
//! ## Environment Variables
|
||||
//!
|
||||
//! UPDATE_BASELINE - Set to update baseline images instead of comparing
|
||||
//! VISUAL_TEST_OUTPUT_DIR - Directory to save test output (default: target/visual_tests)
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gpui::{
|
||||
AppContext as _, Application, Bounds, Window, WindowBounds, WindowHandle, WindowOptions, point,
|
||||
px, size,
|
||||
};
|
||||
use image::RgbaImage;
|
||||
use project_panel::ProjectPanel;
|
||||
use settings::SettingsStore;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use workspace::{AppState, Workspace};
|
||||
|
||||
/// Baseline images are stored relative to this file
|
||||
const BASELINE_DIR: &str = "crates/zed/test_fixtures/visual_tests";
|
||||
|
||||
/// Threshold for image comparison (0.0 to 1.0)
|
||||
/// Images must match at least this percentage to pass
|
||||
const MATCH_THRESHOLD: f64 = 0.99;
|
||||
|
||||
fn main() {
|
||||
env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.init();
|
||||
|
||||
let update_baseline = std::env::var("UPDATE_BASELINE").is_ok();
|
||||
|
||||
if update_baseline {
|
||||
println!("=== Visual Test Runner (UPDATE MODE) ===\n");
|
||||
println!("Baseline images will be updated.\n");
|
||||
} else {
|
||||
println!("=== Visual Test Runner ===\n");
|
||||
}
|
||||
|
||||
// Create a temporary directory for test files
|
||||
let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
|
||||
let project_path = temp_dir.path().join("project");
|
||||
std::fs::create_dir_all(&project_path).expect("Failed to create project directory");
|
||||
|
||||
// Create test files in the real filesystem
|
||||
create_test_files(&project_path);
|
||||
|
||||
let project_path_clone = project_path.clone();
|
||||
|
||||
let test_result = std::panic::catch_unwind(|| {
|
||||
Application::new().run(move |cx| {
|
||||
// Initialize settings store first (required by theme and other subsystems)
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
|
||||
// Create AppState using the production-like initialization
|
||||
let app_state = init_app_state(cx);
|
||||
|
||||
// Initialize all Zed subsystems
|
||||
gpui_tokio::init(cx);
|
||||
theme::init(theme::LoadThemes::JustBase, cx);
|
||||
client::init(&app_state.client, cx);
|
||||
audio::init(cx);
|
||||
workspace::init(app_state.clone(), cx);
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
command_palette::init(cx);
|
||||
editor::init(cx);
|
||||
call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
|
||||
title_bar::init(cx);
|
||||
project_panel::init(cx);
|
||||
outline_panel::init(cx);
|
||||
terminal_view::init(cx);
|
||||
image_viewer::init(cx);
|
||||
search::init(cx);
|
||||
|
||||
// Open a real Zed workspace window
|
||||
let window_size = size(px(1280.0), px(800.0));
|
||||
// Window can be hidden since we use direct texture capture (reading pixels from
|
||||
// Metal texture) instead of ScreenCaptureKit which requires visible windows.
|
||||
let bounds = Bounds {
|
||||
origin: point(px(0.0), px(0.0)),
|
||||
size: window_size,
|
||||
};
|
||||
|
||||
// Create a project for the workspace
|
||||
let project = project::Project::local(
|
||||
app_state.client.clone(),
|
||||
app_state.node_runtime.clone(),
|
||||
app_state.user_store.clone(),
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
None,
|
||||
false,
|
||||
cx,
|
||||
);
|
||||
|
||||
let workspace_window: WindowHandle<Workspace> = cx
|
||||
.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
focus: false,
|
||||
show: false,
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| {
|
||||
cx.new(|cx| {
|
||||
Workspace::new(None, project.clone(), app_state.clone(), window, cx)
|
||||
})
|
||||
},
|
||||
)
|
||||
.expect("Failed to open workspace window");
|
||||
|
||||
// Add the test project as a worktree directly to the project
|
||||
let add_worktree_task = workspace_window
|
||||
.update(cx, |workspace, _window, cx| {
|
||||
workspace.project().update(cx, |project, cx| {
|
||||
project.find_or_create_worktree(&project_path_clone, true, cx)
|
||||
})
|
||||
})
|
||||
.expect("Failed to update workspace");
|
||||
|
||||
// Spawn async task to set up the UI and capture screenshot
|
||||
cx.spawn(async move |mut cx| {
|
||||
// Wait for the worktree to be added
|
||||
if let Err(e) = add_worktree_task.await {
|
||||
eprintln!("Failed to add worktree: {:?}", e);
|
||||
}
|
||||
|
||||
// Wait for UI to settle
|
||||
cx.background_executor()
|
||||
.timer(std::time::Duration::from_millis(500))
|
||||
.await;
|
||||
|
||||
// Create and add the project panel to the workspace
|
||||
let panel_task = cx.update(|cx| {
|
||||
workspace_window
|
||||
.update(cx, |_workspace, window, cx| {
|
||||
let weak_workspace = cx.weak_entity();
|
||||
window.spawn(cx, async move |cx| {
|
||||
ProjectPanel::load(weak_workspace, cx.clone()).await
|
||||
})
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
|
||||
if let Ok(Some(task)) = panel_task {
|
||||
if let Ok(panel) = task.await {
|
||||
cx.update(|cx| {
|
||||
workspace_window
|
||||
.update(cx, |workspace, window, cx| {
|
||||
workspace.add_panel(panel, window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for panel to be added
|
||||
cx.background_executor()
|
||||
.timer(std::time::Duration::from_millis(500))
|
||||
.await;
|
||||
|
||||
// Open the project panel
|
||||
cx.update(|cx| {
|
||||
workspace_window
|
||||
.update(cx, |workspace, window, cx| {
|
||||
workspace.open_panel::<ProjectPanel>(window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Wait for project panel to render
|
||||
cx.background_executor()
|
||||
.timer(std::time::Duration::from_millis(500))
|
||||
.await;
|
||||
|
||||
// Open main.rs in the editor
|
||||
let open_file_task = cx.update(|cx| {
|
||||
workspace_window
|
||||
.update(cx, |workspace, window, cx| {
|
||||
let worktree = workspace.project().read(cx).worktrees(cx).next();
|
||||
if let Some(worktree) = worktree {
|
||||
let worktree_id = worktree.read(cx).id();
|
||||
let rel_path: std::sync::Arc<util::rel_path::RelPath> =
|
||||
util::rel_path::rel_path("src/main.rs").into();
|
||||
let project_path: project::ProjectPath =
|
||||
(worktree_id, rel_path.clone()).into();
|
||||
Some(workspace.open_path(project_path, None, true, window, cx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
});
|
||||
|
||||
if let Ok(Some(task)) = open_file_task {
|
||||
if let Ok(item) = task.await {
|
||||
// Focus the opened item to dismiss the welcome screen
|
||||
cx.update(|cx| {
|
||||
workspace_window
|
||||
.update(cx, |workspace, window, cx| {
|
||||
let pane = workspace.active_pane().clone();
|
||||
pane.update(cx, |pane, cx| {
|
||||
if let Some(index) = pane.index_for_item(item.as_ref()) {
|
||||
pane.activate_item(index, true, true, window, cx);
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Wait for item activation to render
|
||||
cx.background_executor()
|
||||
.timer(std::time::Duration::from_millis(500))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Request a window refresh to ensure all pending effects are processed
|
||||
cx.refresh().ok();
|
||||
|
||||
// Wait for UI to fully stabilize
|
||||
cx.background_executor()
|
||||
.timer(std::time::Duration::from_secs(2))
|
||||
.await;
|
||||
|
||||
// Track test results
|
||||
let mut passed = 0;
|
||||
let mut failed = 0;
|
||||
let mut updated = 0;
|
||||
|
||||
// Run Test 1: Project Panel (with project panel visible)
|
||||
println!("\n--- Test 1: project_panel ---");
|
||||
let test_result = run_visual_test(
|
||||
"project_panel",
|
||||
workspace_window.into(),
|
||||
&mut cx,
|
||||
update_baseline,
|
||||
)
|
||||
.await;
|
||||
|
||||
match test_result {
|
||||
Ok(TestResult::Passed) => {
|
||||
println!("✓ project_panel: PASSED");
|
||||
passed += 1;
|
||||
}
|
||||
Ok(TestResult::BaselineUpdated(path)) => {
|
||||
println!("✓ project_panel: Baseline updated at {}", path.display());
|
||||
updated += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("✗ project_panel: FAILED - {}", e);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Close the project panel for the second test
|
||||
cx.update(|cx| {
|
||||
workspace_window
|
||||
.update(cx, |workspace, window, cx| {
|
||||
workspace.close_panel::<ProjectPanel>(window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Refresh and wait for panel to close
|
||||
cx.refresh().ok();
|
||||
cx.background_executor()
|
||||
.timer(std::time::Duration::from_millis(500))
|
||||
.await;
|
||||
|
||||
// Run Test 2: Workspace with Editor (without project panel)
|
||||
println!("\n--- Test 2: workspace_with_editor ---");
|
||||
let test_result = run_visual_test(
|
||||
"workspace_with_editor",
|
||||
workspace_window.into(),
|
||||
&mut cx,
|
||||
update_baseline,
|
||||
)
|
||||
.await;
|
||||
|
||||
match test_result {
|
||||
Ok(TestResult::Passed) => {
|
||||
println!("✓ workspace_with_editor: PASSED");
|
||||
passed += 1;
|
||||
}
|
||||
Ok(TestResult::BaselineUpdated(path)) => {
|
||||
println!(
|
||||
"✓ workspace_with_editor: Baseline updated at {}",
|
||||
path.display()
|
||||
);
|
||||
updated += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("✗ workspace_with_editor: FAILED - {}", e);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
println!("\n=== Test Summary ===");
|
||||
println!("Passed: {}", passed);
|
||||
println!("Failed: {}", failed);
|
||||
if updated > 0 {
|
||||
println!("Baselines Updated: {}", updated);
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
eprintln!("\n=== Visual Tests FAILED ===");
|
||||
cx.update(|cx| cx.quit()).ok();
|
||||
std::process::exit(1);
|
||||
} else {
|
||||
println!("\n=== All Visual Tests PASSED ===");
|
||||
}
|
||||
|
||||
cx.update(|cx| cx.quit()).ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
});
|
||||
|
||||
// Keep temp_dir alive until we're done
|
||||
drop(temp_dir);
|
||||
|
||||
if test_result.is_err() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
enum TestResult {
|
||||
Passed,
|
||||
BaselineUpdated(PathBuf),
|
||||
}
|
||||
|
||||
async fn run_visual_test(
|
||||
test_name: &str,
|
||||
window: gpui::AnyWindowHandle,
|
||||
cx: &mut gpui::AsyncApp,
|
||||
update_baseline: bool,
|
||||
) -> Result<TestResult> {
|
||||
// Capture the screenshot using direct texture capture (no ScreenCaptureKit needed)
|
||||
let screenshot = cx.update(|cx| capture_screenshot(window, cx))??;
|
||||
|
||||
// Get paths
|
||||
let baseline_path = get_baseline_path(test_name);
|
||||
let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
|
||||
.unwrap_or_else(|_| "target/visual_tests".to_string());
|
||||
let actual_path = Path::new(&output_dir).join(format!("{}.png", test_name));
|
||||
|
||||
// Create output directory
|
||||
if let Some(parent) = actual_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Save the actual screenshot
|
||||
screenshot.save(&actual_path)?;
|
||||
println!("Screenshot saved to: {}", actual_path.display());
|
||||
|
||||
if update_baseline {
|
||||
// Update the baseline
|
||||
if let Some(parent) = baseline_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
screenshot.save(&baseline_path)?;
|
||||
return Ok(TestResult::BaselineUpdated(baseline_path));
|
||||
}
|
||||
|
||||
// Compare against baseline
|
||||
if !baseline_path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Baseline image not found: {}\n\
|
||||
Run with UPDATE_BASELINE=1 to create it.",
|
||||
baseline_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let baseline = image::open(&baseline_path)
|
||||
.context("Failed to load baseline image")?
|
||||
.to_rgba8();
|
||||
|
||||
let comparison = compare_images(&baseline, &screenshot);
|
||||
|
||||
println!(
|
||||
"Image comparison: {:.2}% match ({} different pixels out of {})",
|
||||
comparison.match_percentage * 100.0,
|
||||
comparison.diff_pixel_count,
|
||||
comparison.total_pixels
|
||||
);
|
||||
|
||||
if comparison.match_percentage >= MATCH_THRESHOLD {
|
||||
Ok(TestResult::Passed)
|
||||
} else {
|
||||
// Save the diff image for debugging
|
||||
if let Some(diff_image) = comparison.diff_image {
|
||||
let diff_path = Path::new(&output_dir).join(format!("{}_diff.png", test_name));
|
||||
diff_image.save(&diff_path)?;
|
||||
println!("Diff image saved to: {}", diff_path.display());
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Screenshot does not match baseline.\n\
|
||||
Match: {:.2}% (threshold: {:.2}%)\n\
|
||||
Actual: {}\n\
|
||||
Baseline: {}\n\
|
||||
\n\
|
||||
Run with UPDATE_BASELINE=1 to update the baseline if this change is intentional.",
|
||||
comparison.match_percentage * 100.0,
|
||||
MATCH_THRESHOLD * 100.0,
|
||||
actual_path.display(),
|
||||
baseline_path.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_baseline_path(test_name: &str) -> PathBuf {
|
||||
// Find the workspace root by looking for Cargo.toml
|
||||
let mut path = std::env::current_dir().expect("Failed to get current directory");
|
||||
while !path.join("Cargo.toml").exists() || !path.join("crates").exists() {
|
||||
if !path.pop() {
|
||||
panic!("Could not find workspace root");
|
||||
}
|
||||
}
|
||||
path.join(BASELINE_DIR).join(format!("{}.png", test_name))
|
||||
}
|
||||
|
||||
struct ImageComparison {
|
||||
match_percentage: f64,
|
||||
diff_image: Option<RgbaImage>,
|
||||
diff_pixel_count: u64,
|
||||
total_pixels: u64,
|
||||
}
|
||||
|
||||
fn compare_images(baseline: &RgbaImage, actual: &RgbaImage) -> ImageComparison {
|
||||
// Check dimensions
|
||||
if baseline.dimensions() != actual.dimensions() {
|
||||
return ImageComparison {
|
||||
match_percentage: 0.0,
|
||||
diff_image: None,
|
||||
diff_pixel_count: baseline.width() as u64 * baseline.height() as u64,
|
||||
total_pixels: baseline.width() as u64 * baseline.height() as u64,
|
||||
};
|
||||
}
|
||||
|
||||
let (width, height) = baseline.dimensions();
|
||||
let total_pixels = width as u64 * height as u64;
|
||||
let mut diff_count: u64 = 0;
|
||||
let mut diff_image = RgbaImage::new(width, height);
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let baseline_pixel = baseline.get_pixel(x, y);
|
||||
let actual_pixel = actual.get_pixel(x, y);
|
||||
|
||||
if pixels_are_similar(baseline_pixel, actual_pixel) {
|
||||
// Matching pixel - show as dimmed version of actual
|
||||
diff_image.put_pixel(
|
||||
x,
|
||||
y,
|
||||
image::Rgba([
|
||||
actual_pixel[0] / 3,
|
||||
actual_pixel[1] / 3,
|
||||
actual_pixel[2] / 3,
|
||||
255,
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
diff_count += 1;
|
||||
// Different pixel - highlight in red
|
||||
diff_image.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let match_percentage = if total_pixels > 0 {
|
||||
(total_pixels - diff_count) as f64 / total_pixels as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
ImageComparison {
|
||||
match_percentage,
|
||||
diff_image: Some(diff_image),
|
||||
diff_pixel_count: diff_count,
|
||||
total_pixels,
|
||||
}
|
||||
}
|
||||
|
||||
fn pixels_are_similar(a: &image::Rgba<u8>, b: &image::Rgba<u8>) -> bool {
|
||||
// Allow small differences due to anti-aliasing, font rendering, etc.
|
||||
const TOLERANCE: i16 = 2;
|
||||
|
||||
(a[0] as i16 - b[0] as i16).abs() <= TOLERANCE
|
||||
&& (a[1] as i16 - b[1] as i16).abs() <= TOLERANCE
|
||||
&& (a[2] as i16 - b[2] as i16).abs() <= TOLERANCE
|
||||
&& (a[3] as i16 - b[3] as i16).abs() <= TOLERANCE
|
||||
}
|
||||
|
||||
fn capture_screenshot(window: gpui::AnyWindowHandle, cx: &mut gpui::App) -> Result<RgbaImage> {
|
||||
// Use direct texture capture - renders the scene to a texture and reads pixels back.
|
||||
// This does not require the window to be visible on screen.
|
||||
let screenshot = cx.update_window(window, |_view, window: &mut Window, _cx| {
|
||||
window.render_to_image()
|
||||
})??;
|
||||
|
||||
println!(
|
||||
"Screenshot captured: {}x{} pixels",
|
||||
screenshot.width(),
|
||||
screenshot.height()
|
||||
);
|
||||
|
||||
Ok(screenshot)
|
||||
}
|
||||
|
||||
/// Create test files in a real filesystem directory
|
||||
fn create_test_files(project_path: &Path) {
|
||||
let src_dir = project_path.join("src");
|
||||
std::fs::create_dir_all(&src_dir).expect("Failed to create src directory");
|
||||
|
||||
std::fs::write(
|
||||
src_dir.join("main.rs"),
|
||||
r#"fn main() {
|
||||
println!("Hello, world!");
|
||||
|
||||
let message = greet("Zed");
|
||||
println!("{}", message);
|
||||
}
|
||||
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Welcome to {}, the editor of the future!", name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_greet() {
|
||||
assert_eq!(greet("World"), "Welcome to World, the editor of the future!");
|
||||
}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.expect("Failed to write main.rs");
|
||||
|
||||
std::fs::write(
|
||||
src_dir.join("lib.rs"),
|
||||
r#"//! A sample library for visual testing.
|
||||
|
||||
pub mod utils;
|
||||
|
||||
/// Adds two numbers together.
|
||||
pub fn add(a: i32, b: i32) -> i32 {
|
||||
a + b
|
||||
}
|
||||
|
||||
/// Subtracts the second number from the first.
|
||||
pub fn subtract(a: i32, b: i32) -> i32 {
|
||||
a - b
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_add() {
|
||||
assert_eq!(add(2, 3), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subtract() {
|
||||
assert_eq!(subtract(5, 3), 2);
|
||||
}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.expect("Failed to write lib.rs");
|
||||
|
||||
std::fs::write(
|
||||
src_dir.join("utils.rs"),
|
||||
r#"//! Utility functions for the sample project.
|
||||
|
||||
/// Formats a greeting message.
|
||||
pub fn format_greeting(name: &str) -> String {
|
||||
format!("Hello, {}!", name)
|
||||
}
|
||||
|
||||
/// Formats a farewell message.
|
||||
pub fn format_farewell(name: &str) -> String {
|
||||
format!("Goodbye, {}!", name)
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.expect("Failed to write utils.rs");
|
||||
|
||||
std::fs::write(
|
||||
project_path.join("Cargo.toml"),
|
||||
r#"[package]
|
||||
name = "test-project"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
"#,
|
||||
)
|
||||
.expect("Failed to write Cargo.toml");
|
||||
|
||||
std::fs::write(
|
||||
project_path.join("README.md"),
|
||||
r#"# Test Project
|
||||
|
||||
This is a test project for visual testing of Zed.
|
||||
|
||||
## Description
|
||||
|
||||
A simple Rust project used to verify that Zed's visual testing
|
||||
infrastructure can capture screenshots of real workspaces.
|
||||
|
||||
## Features
|
||||
|
||||
- Sample Rust code with main.rs, lib.rs, and utils.rs
|
||||
- Standard Cargo.toml configuration
|
||||
- Example tests
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
"#,
|
||||
)
|
||||
.expect("Failed to write README.md");
|
||||
}
|
||||
|
||||
/// Initialize AppState with real filesystem for visual testing.
|
||||
fn init_app_state(cx: &mut gpui::App) -> Arc<AppState> {
|
||||
use client::Client;
|
||||
use clock::FakeSystemClock;
|
||||
use fs::RealFs;
|
||||
use language::LanguageRegistry;
|
||||
use node_runtime::NodeRuntime;
|
||||
use session::Session;
|
||||
|
||||
let fs = Arc::new(RealFs::new(None, cx.background_executor().clone()));
|
||||
let languages = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
let http_client = http_client::FakeHttpClient::with_404_response();
|
||||
let client = Client::new(clock, http_client, cx);
|
||||
let session = cx.new(|cx| session::AppSession::new(Session::test(), cx));
|
||||
let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx));
|
||||
let workspace_store = cx.new(|cx| workspace::WorkspaceStore::new(client.clone(), cx));
|
||||
|
||||
Arc::new(AppState {
|
||||
client,
|
||||
fs,
|
||||
languages,
|
||||
user_store,
|
||||
workspace_store,
|
||||
node_runtime: NodeRuntime::unavailable(),
|
||||
build_window_options: |_, _| Default::default(),
|
||||
session,
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,8 @@ pub(crate) mod mac_only_instance;
|
||||
mod migrate;
|
||||
mod open_listener;
|
||||
mod quick_action_bar;
|
||||
#[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
|
||||
pub mod visual_tests;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) mod windows_only_instance;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user