Compare commits
39 Commits
git-clone
...
screenshot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de1cc44dd7 | ||
|
|
ba90b55b13 | ||
|
|
1dcf1cf8dc | ||
|
|
60261963a8 | ||
|
|
f9462da2f7 | ||
|
|
61dd6a8f31 | ||
|
|
abb199c85e | ||
|
|
cebbf77491 | ||
|
|
0180f3e72a | ||
|
|
5488a19221 | ||
|
|
bb1198e7d6 | ||
|
|
69fe27f45e | ||
|
|
469da2fd07 | ||
|
|
4f87822133 | ||
|
|
9a69d89f88 | ||
|
|
54f360ace1 | ||
|
|
b2a0b78ece | ||
|
|
f1ca2f9f31 | ||
|
|
4b34adedd2 | ||
|
|
df48294caa | ||
|
|
cdc5cc348f | ||
|
|
0f7f540138 | ||
|
|
184001b33b | ||
|
|
225a2a8a20 | ||
|
|
ea37057814 | ||
|
|
77cdef3596 | ||
|
|
05108c50fd | ||
|
|
07538ff08e | ||
|
|
9073a2666c | ||
|
|
843a35a1a9 | ||
|
|
aff93f2f6c | ||
|
|
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
|
||||
|
||||
20
.github/workflows/release.yml
vendored
20
.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,15 +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}
|
||||
- name: steps::trigger_autofix
|
||||
if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
|
||||
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=true
|
||||
- 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}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: steps::cargo_install_nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
- name: steps::clear_target_dir_if_large
|
||||
@@ -93,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')
|
||||
@@ -111,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
|
||||
|
||||
55
.github/workflows/run_tests.yml
vendored
55
.github/workflows/run_tests.yml
vendored
@@ -74,18 +74,19 @@ jobs:
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||
with:
|
||||
version: '9'
|
||||
- name: ./script/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}
|
||||
- name: steps::trigger_autofix
|
||||
if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
|
||||
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=false
|
||||
- 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}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: ./script/check-todos
|
||||
run: ./script/check-todos
|
||||
shell: bash -euxo pipefail {0}
|
||||
@@ -96,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:
|
||||
@@ -116,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
|
||||
@@ -163,15 +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}
|
||||
- name: steps::trigger_autofix
|
||||
if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
|
||||
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=true
|
||||
- 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}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: steps::cargo_install_nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
- name: steps::clear_target_dir_if_large
|
||||
@@ -185,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:
|
||||
@@ -205,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
|
||||
@@ -585,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.
|
||||
|
||||
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -20638,6 +20638,7 @@ dependencies = [
|
||||
"clap",
|
||||
"cli",
|
||||
"client",
|
||||
"clock",
|
||||
"codestral",
|
||||
"collab_ui",
|
||||
"collections",
|
||||
@@ -20671,6 +20672,7 @@ dependencies = [
|
||||
"gpui",
|
||||
"gpui_tokio",
|
||||
"http_client",
|
||||
"image",
|
||||
"image_viewer",
|
||||
"inspector_ui",
|
||||
"install_cli",
|
||||
@@ -20737,6 +20739,7 @@ dependencies = [
|
||||
"task",
|
||||
"tasks_ui",
|
||||
"telemetry",
|
||||
"tempfile",
|
||||
"terminal_view",
|
||||
"theme",
|
||||
"theme_extension",
|
||||
|
||||
@@ -192,6 +192,7 @@ pub struct ToolCall {
|
||||
pub locations: Vec<acp::ToolCallLocation>,
|
||||
pub resolved_locations: Vec<Option<AgentLocation>>,
|
||||
pub raw_input: Option<serde_json::Value>,
|
||||
pub raw_input_markdown: Option<Entity<Markdown>>,
|
||||
pub raw_output: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
@@ -222,6 +223,11 @@ impl ToolCall {
|
||||
}
|
||||
}
|
||||
|
||||
let raw_input_markdown = tool_call
|
||||
.raw_input
|
||||
.as_ref()
|
||||
.and_then(|input| markdown_for_raw_output(input, &language_registry, cx));
|
||||
|
||||
let result = Self {
|
||||
id: tool_call.tool_call_id,
|
||||
label: cx
|
||||
@@ -232,6 +238,7 @@ impl ToolCall {
|
||||
resolved_locations: Vec::default(),
|
||||
status,
|
||||
raw_input: tool_call.raw_input,
|
||||
raw_input_markdown,
|
||||
raw_output: tool_call.raw_output,
|
||||
};
|
||||
Ok(result)
|
||||
@@ -307,6 +314,7 @@ impl ToolCall {
|
||||
}
|
||||
|
||||
if let Some(raw_input) = raw_input {
|
||||
self.raw_input_markdown = markdown_for_raw_output(&raw_input, &language_registry, cx);
|
||||
self.raw_input = Some(raw_input);
|
||||
}
|
||||
|
||||
@@ -1355,6 +1363,7 @@ impl AcpThread {
|
||||
locations: Vec::new(),
|
||||
resolved_locations: Vec::new(),
|
||||
raw_input: None,
|
||||
raw_input_markdown: None,
|
||||
raw_output: None,
|
||||
};
|
||||
self.push_entry(AgentThreadEntry::ToolCall(failed_tool_call), cx);
|
||||
|
||||
@@ -221,7 +221,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
let favorites = if self.selector.supports_favorites() {
|
||||
Arc::new(AgentSettings::get_global(cx).favorite_model_ids())
|
||||
AgentSettings::get_global(cx).favorite_model_ids()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
@@ -242,7 +242,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.delegate.filtered_entries =
|
||||
info_list_to_picker_entries(filtered_models, favorites);
|
||||
info_list_to_picker_entries(filtered_models, &favorites);
|
||||
// Finds the currently selected model in the list
|
||||
let new_index = this
|
||||
.delegate
|
||||
@@ -406,7 +406,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
|
||||
fn info_list_to_picker_entries(
|
||||
model_list: AgentModelList,
|
||||
favorites: Arc<HashSet<ModelId>>,
|
||||
favorites: &HashSet<ModelId>,
|
||||
) -> Vec<AcpModelPickerEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
@@ -572,13 +572,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_favorites(models: Vec<&str>) -> Arc<HashSet<ModelId>> {
|
||||
Arc::new(
|
||||
models
|
||||
.into_iter()
|
||||
.map(|m| ModelId::new(m.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
fn create_favorites(models: Vec<&str>) -> HashSet<ModelId> {
|
||||
models
|
||||
.into_iter()
|
||||
.map(|m| ModelId::new(m.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_entry_model_ids(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
|
||||
@@ -609,7 +607,7 @@ mod tests {
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/gemini"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
@@ -625,7 +623,7 @@ mod tests {
|
||||
let models = create_model_list(vec![("zed", vec!["zed/claude", "zed/gemini"])]);
|
||||
let favorites = create_favorites(vec![]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
@@ -641,7 +639,7 @@ mod tests {
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/claude"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
for entry in &entries {
|
||||
if let AcpModelPickerEntry::Model(info, is_favorite) = entry {
|
||||
@@ -662,7 +660,7 @@ mod tests {
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/gemini", "openai/gpt-5"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
let model_ids = get_entry_model_ids(&entries);
|
||||
|
||||
assert_eq!(model_ids[0], "zed/gemini");
|
||||
@@ -683,7 +681,7 @@ mod tests {
|
||||
|
||||
let favorites = create_favorites(vec!["zed/claude"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
let labels = get_entry_labels(&entries);
|
||||
|
||||
assert_eq!(
|
||||
@@ -723,7 +721,7 @@ mod tests {
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/gemini"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
|
||||
@@ -34,7 +34,7 @@ use language::Buffer;
|
||||
|
||||
use language_model::LanguageModelRegistry;
|
||||
use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
|
||||
use project::{Project, ProjectEntryId};
|
||||
use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId};
|
||||
use prompt_store::{PromptId, PromptStore};
|
||||
use rope::Point;
|
||||
use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore};
|
||||
@@ -260,6 +260,7 @@ impl ThreadFeedbackState {
|
||||
|
||||
pub struct AcpThreadView {
|
||||
agent: Rc<dyn AgentServer>,
|
||||
agent_server_store: Entity<AgentServerStore>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
project: Entity<Project>,
|
||||
thread_state: ThreadState,
|
||||
@@ -406,6 +407,7 @@ impl AcpThreadView {
|
||||
|
||||
Self {
|
||||
agent: agent.clone(),
|
||||
agent_server_store,
|
||||
workspace: workspace.clone(),
|
||||
project: project.clone(),
|
||||
entry_view_state,
|
||||
@@ -737,7 +739,7 @@ impl AcpThreadView {
|
||||
cx: &mut App,
|
||||
) {
|
||||
let agent_name = agent.name();
|
||||
let (configuration_view, subscription) = if let Some(provider_id) = err.provider_id {
|
||||
let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id {
|
||||
let registry = LanguageModelRegistry::global(cx);
|
||||
|
||||
let sub = window.subscribe(®istry, cx, {
|
||||
@@ -779,7 +781,6 @@ impl AcpThreadView {
|
||||
configuration_view,
|
||||
description: err
|
||||
.description
|
||||
.clone()
|
||||
.map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))),
|
||||
_subscription: subscription,
|
||||
};
|
||||
@@ -1088,10 +1089,7 @@ impl AcpThreadView {
|
||||
window.defer(cx, |window, cx| {
|
||||
Self::handle_auth_required(
|
||||
this,
|
||||
AuthRequired {
|
||||
description: None,
|
||||
provider_id: None,
|
||||
},
|
||||
AuthRequired::new(),
|
||||
agent,
|
||||
connection,
|
||||
window,
|
||||
@@ -1663,44 +1661,6 @@ impl AcpThreadView {
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if method.0.as_ref() == "anthropic-api-key" {
|
||||
let registry = LanguageModelRegistry::global(cx);
|
||||
let provider = registry
|
||||
.read(cx)
|
||||
.provider(&language_model::ANTHROPIC_PROVIDER_ID)
|
||||
.unwrap();
|
||||
let this = cx.weak_entity();
|
||||
let agent = self.agent.clone();
|
||||
let connection = connection.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
if !provider.is_authenticated(cx) {
|
||||
Self::handle_auth_required(
|
||||
this,
|
||||
AuthRequired {
|
||||
description: Some("ANTHROPIC_API_KEY must be set".to_owned()),
|
||||
provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID),
|
||||
},
|
||||
agent,
|
||||
connection,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.thread_state = Self::initial_state(
|
||||
agent,
|
||||
None,
|
||||
this.workspace.clone(),
|
||||
this.project.clone(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if method.0.as_ref() == "vertex-ai"
|
||||
&& std::env::var("GOOGLE_API_KEY").is_err()
|
||||
&& (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
|
||||
@@ -2153,6 +2113,7 @@ impl AcpThreadView {
|
||||
chunks,
|
||||
indented: _,
|
||||
}) => {
|
||||
let mut is_blank = true;
|
||||
let is_last = entry_ix + 1 == total_entries;
|
||||
|
||||
let style = default_markdown_style(false, false, window, cx);
|
||||
@@ -2162,36 +2123,55 @@ impl AcpThreadView {
|
||||
.children(chunks.iter().enumerate().filter_map(
|
||||
|(chunk_ix, chunk)| match chunk {
|
||||
AssistantMessageChunk::Message { block } => {
|
||||
block.markdown().map(|md| {
|
||||
self.render_markdown(md.clone(), style.clone())
|
||||
.into_any_element()
|
||||
block.markdown().and_then(|md| {
|
||||
let this_is_blank = md.read(cx).source().trim().is_empty();
|
||||
is_blank = is_blank && this_is_blank;
|
||||
if this_is_blank {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
self.render_markdown(md.clone(), style.clone())
|
||||
.into_any_element(),
|
||||
)
|
||||
})
|
||||
}
|
||||
AssistantMessageChunk::Thought { block } => {
|
||||
block.markdown().map(|md| {
|
||||
self.render_thinking_block(
|
||||
entry_ix,
|
||||
chunk_ix,
|
||||
md.clone(),
|
||||
window,
|
||||
cx,
|
||||
block.markdown().and_then(|md| {
|
||||
let this_is_blank = md.read(cx).source().trim().is_empty();
|
||||
is_blank = is_blank && this_is_blank;
|
||||
if this_is_blank {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
self.render_thinking_block(
|
||||
entry_ix,
|
||||
chunk_ix,
|
||||
md.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
},
|
||||
))
|
||||
.into_any();
|
||||
|
||||
v_flex()
|
||||
.px_5()
|
||||
.py_1p5()
|
||||
.when(is_first_indented, |this| this.pt_0p5())
|
||||
.when(is_last, |this| this.pb_4())
|
||||
.w_full()
|
||||
.text_ui(cx)
|
||||
.child(message_body)
|
||||
.into_any()
|
||||
if is_blank {
|
||||
Empty.into_any()
|
||||
} else {
|
||||
v_flex()
|
||||
.px_5()
|
||||
.py_1p5()
|
||||
.when(is_last, |this| this.pb_4())
|
||||
.w_full()
|
||||
.text_ui(cx)
|
||||
.child(message_body)
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
AgentThreadEntry::ToolCall(tool_call) => {
|
||||
let has_terminals = tool_call.terminals().next().is_some();
|
||||
@@ -2223,7 +2203,7 @@ impl AcpThreadView {
|
||||
div()
|
||||
.relative()
|
||||
.w_full()
|
||||
.pl(rems_from_px(20.0))
|
||||
.pl_5()
|
||||
.bg(cx.theme().colors().panel_background.opacity(0.2))
|
||||
.child(
|
||||
div()
|
||||
@@ -2440,6 +2420,12 @@ impl AcpThreadView {
|
||||
let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
|
||||
|
||||
let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
|
||||
let input_output_header = |label: SharedString| {
|
||||
Label::new(label)
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted)
|
||||
.buffer_font(cx)
|
||||
};
|
||||
|
||||
let tool_output_display =
|
||||
if is_open {
|
||||
@@ -2481,7 +2467,25 @@ impl AcpThreadView {
|
||||
| ToolCallStatus::Completed
|
||||
| ToolCallStatus::Failed
|
||||
| ToolCallStatus::Canceled => v_flex()
|
||||
.w_full()
|
||||
.when(!is_edit && !is_terminal_tool, |this| {
|
||||
this.mt_1p5().w_full().child(
|
||||
v_flex()
|
||||
.ml(rems(0.4))
|
||||
.px_3p5()
|
||||
.pb_1()
|
||||
.gap_1()
|
||||
.border_l_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
.child(input_output_header("Raw Input:".into()))
|
||||
.children(tool_call.raw_input_markdown.clone().map(|input| {
|
||||
self.render_markdown(
|
||||
input,
|
||||
default_markdown_style(false, false, window, cx),
|
||||
)
|
||||
}))
|
||||
.child(input_output_header("Output:".into())),
|
||||
)
|
||||
})
|
||||
.children(tool_call.content.iter().enumerate().map(
|
||||
|(content_ix, content)| {
|
||||
div().child(self.render_tool_call_content(
|
||||
@@ -2580,7 +2584,7 @@ impl AcpThreadView {
|
||||
.gap_px()
|
||||
.when(is_collapsible, |this| {
|
||||
this.child(
|
||||
Disclosure::new(("expand", entry_ix), is_open)
|
||||
Disclosure::new(("expand-output", entry_ix), is_open)
|
||||
.opened_icon(IconName::ChevronUp)
|
||||
.closed_icon(IconName::ChevronDown)
|
||||
.visible_on_hover(&card_header_id)
|
||||
@@ -2766,20 +2770,20 @@ impl AcpThreadView {
|
||||
let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
|
||||
|
||||
v_flex()
|
||||
.mt_1p5()
|
||||
.gap_2()
|
||||
.when(!card_layout, |this| {
|
||||
this.ml(rems(0.4))
|
||||
.px_3p5()
|
||||
.border_l_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
})
|
||||
.when(card_layout, |this| {
|
||||
this.px_2().pb_2().when(context_ix > 0, |this| {
|
||||
this.border_t_1()
|
||||
.pt_2()
|
||||
.map(|this| {
|
||||
if card_layout {
|
||||
this.when(context_ix > 0, |this| {
|
||||
this.pt_2()
|
||||
.border_t_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
})
|
||||
} else {
|
||||
this.ml(rems(0.4))
|
||||
.px_3p5()
|
||||
.border_l_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
})
|
||||
}
|
||||
})
|
||||
.text_xs()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
@@ -3500,138 +3504,119 @@ impl AcpThreadView {
|
||||
pending_auth_method: Option<&acp::AuthMethodId>,
|
||||
window: &mut Window,
|
||||
cx: &Context<Self>,
|
||||
) -> Div {
|
||||
let show_description =
|
||||
configuration_view.is_none() && description.is_none() && pending_auth_method.is_none();
|
||||
|
||||
) -> impl IntoElement {
|
||||
let auth_methods = connection.auth_methods();
|
||||
|
||||
v_flex().flex_1().size_full().justify_end().child(
|
||||
v_flex()
|
||||
.p_2()
|
||||
.pr_3()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(cx.theme().status().warning.opacity(0.04))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(IconName::Warning)
|
||||
.color(Color::Warning)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
.child(Label::new("Authentication Required").size(LabelSize::Small)),
|
||||
)
|
||||
.children(description.map(|desc| {
|
||||
div().text_ui(cx).child(self.render_markdown(
|
||||
desc.clone(),
|
||||
default_markdown_style(false, false, window, cx),
|
||||
))
|
||||
}))
|
||||
.children(
|
||||
configuration_view
|
||||
.cloned()
|
||||
.map(|view| div().w_full().child(view)),
|
||||
)
|
||||
.when(show_description, |el| {
|
||||
el.child(
|
||||
Label::new(format!(
|
||||
"You are not currently authenticated with {}.{}",
|
||||
self.agent.name(),
|
||||
if auth_methods.len() > 1 {
|
||||
" Please choose one of the following options:"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
))
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.mb_1()
|
||||
.ml_5(),
|
||||
)
|
||||
})
|
||||
.when_some(pending_auth_method, |el, _| {
|
||||
el.child(
|
||||
h_flex()
|
||||
.py_4()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted)
|
||||
.with_rotate_animation(2),
|
||||
)
|
||||
.child(Label::new("Authenticating…").size(LabelSize::Small)),
|
||||
)
|
||||
})
|
||||
.when(!auth_methods.is_empty(), |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.when(!show_description, |this| {
|
||||
this.border_t_1()
|
||||
.mt_1()
|
||||
.pt_2()
|
||||
.border_color(cx.theme().colors().border.opacity(0.8))
|
||||
let agent_display_name = self
|
||||
.agent_server_store
|
||||
.read(cx)
|
||||
.agent_display_name(&ExternalAgentServerName(self.agent.name()))
|
||||
.unwrap_or_else(|| self.agent.name());
|
||||
|
||||
let show_fallback_description = auth_methods.len() > 1
|
||||
&& configuration_view.is_none()
|
||||
&& description.is_none()
|
||||
&& pending_auth_method.is_none();
|
||||
|
||||
let auth_buttons = || {
|
||||
h_flex().justify_end().flex_wrap().gap_1().children(
|
||||
connection
|
||||
.auth_methods()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.map(|(ix, method)| {
|
||||
let (method_id, name) = if self.project.read(cx).is_via_remote_server()
|
||||
&& method.id.0.as_ref() == "oauth-personal"
|
||||
&& method.name == "Log in with Google"
|
||||
{
|
||||
("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
|
||||
} else {
|
||||
(method.id.0.clone(), method.name.clone())
|
||||
};
|
||||
|
||||
let agent_telemetry_id = connection.telemetry_id();
|
||||
|
||||
Button::new(method_id.clone(), name)
|
||||
.label_size(LabelSize::Small)
|
||||
.map(|this| {
|
||||
if ix == 0 {
|
||||
this.style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
} else {
|
||||
this.style(ButtonStyle::Outlined)
|
||||
}
|
||||
})
|
||||
.children(connection.auth_methods().iter().enumerate().rev().map(
|
||||
|(ix, method)| {
|
||||
let (method_id, name) = if self
|
||||
.project
|
||||
.read(cx)
|
||||
.is_via_remote_server()
|
||||
&& method.id.0.as_ref() == "oauth-personal"
|
||||
&& method.name == "Log in with Google"
|
||||
{
|
||||
("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
|
||||
} else {
|
||||
(method.id.0.clone(), method.name.clone())
|
||||
};
|
||||
.when_some(method.description.clone(), |this, description| {
|
||||
this.tooltip(Tooltip::text(description))
|
||||
})
|
||||
.on_click({
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
telemetry::event!(
|
||||
"Authenticate Agent Started",
|
||||
agent = agent_telemetry_id,
|
||||
method = method_id
|
||||
);
|
||||
|
||||
let agent_telemetry_id = connection.telemetry_id();
|
||||
this.authenticate(
|
||||
acp::AuthMethodId::new(method_id.clone()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
||||
Button::new(method_id.clone(), name)
|
||||
.label_size(LabelSize::Small)
|
||||
.map(|this| {
|
||||
if ix == 0 {
|
||||
this.style(ButtonStyle::Tinted(TintColor::Warning))
|
||||
} else {
|
||||
this.style(ButtonStyle::Outlined)
|
||||
}
|
||||
})
|
||||
.when_some(
|
||||
method.description.clone(),
|
||||
|this, description| {
|
||||
this.tooltip(Tooltip::text(description))
|
||||
},
|
||||
)
|
||||
.on_click({
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
telemetry::event!(
|
||||
"Authenticate Agent Started",
|
||||
agent = agent_telemetry_id,
|
||||
method = method_id
|
||||
);
|
||||
if pending_auth_method.is_some() {
|
||||
return Callout::new()
|
||||
.icon(IconName::Info)
|
||||
.title(format!("Authenticating to {}…", agent_display_name))
|
||||
.actions_slot(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted)
|
||||
.with_rotate_animation(2)
|
||||
.into_any_element(),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
this.authenticate(
|
||||
acp::AuthMethodId::new(method_id.clone()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
},
|
||||
)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
Callout::new()
|
||||
.icon(IconName::Info)
|
||||
.title(format!("Authenticate to {}", agent_display_name))
|
||||
.when(auth_methods.len() == 1, |this| {
|
||||
this.actions_slot(auth_buttons())
|
||||
})
|
||||
.description_slot(
|
||||
v_flex()
|
||||
.text_ui(cx)
|
||||
.map(|this| {
|
||||
if show_fallback_description {
|
||||
this.child(
|
||||
Label::new("Choose one of the following authentication options:")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
} else {
|
||||
this.children(
|
||||
configuration_view
|
||||
.cloned()
|
||||
.map(|view| div().w_full().child(view)),
|
||||
)
|
||||
.children(description.map(|desc| {
|
||||
self.render_markdown(
|
||||
desc.clone(),
|
||||
default_markdown_style(false, false, window, cx),
|
||||
)
|
||||
}))
|
||||
}
|
||||
})
|
||||
.when(auth_methods.len() > 1, |this| {
|
||||
this.gap_1().child(auth_buttons())
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_load_error(
|
||||
@@ -5880,10 +5865,6 @@ impl AcpThreadView {
|
||||
};
|
||||
|
||||
let connection = thread.read(cx).connection().clone();
|
||||
let err = AuthRequired {
|
||||
description: None,
|
||||
provider_id: None,
|
||||
};
|
||||
this.clear_thread_error(cx);
|
||||
if let Some(message) = this.in_flight_prompt.take() {
|
||||
this.message_editor.update(cx, |editor, cx| {
|
||||
@@ -5892,7 +5873,14 @@ impl AcpThreadView {
|
||||
}
|
||||
let this = cx.weak_entity();
|
||||
window.defer(cx, |window, cx| {
|
||||
Self::handle_auth_required(this, err, agent, connection, window, cx);
|
||||
Self::handle_auth_required(
|
||||
this,
|
||||
AuthRequired::new(),
|
||||
agent,
|
||||
connection,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
}
|
||||
}))
|
||||
@@ -5905,14 +5893,10 @@ impl AcpThreadView {
|
||||
};
|
||||
|
||||
let connection = thread.read(cx).connection().clone();
|
||||
let err = AuthRequired {
|
||||
description: None,
|
||||
provider_id: None,
|
||||
};
|
||||
self.clear_thread_error(cx);
|
||||
let this = cx.weak_entity();
|
||||
window.defer(cx, |window, cx| {
|
||||
Self::handle_auth_required(this, err, agent, connection, window, cx);
|
||||
Self::handle_auth_required(this, AuthRequired::new(), agent, connection, window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6015,16 +5999,19 @@ impl Render for AcpThreadView {
|
||||
configuration_view,
|
||||
pending_auth_method,
|
||||
..
|
||||
} => self
|
||||
.render_auth_required_state(
|
||||
} => v_flex()
|
||||
.flex_1()
|
||||
.size_full()
|
||||
.justify_end()
|
||||
.child(self.render_auth_required_state(
|
||||
connection,
|
||||
description.as_ref(),
|
||||
configuration_view.as_ref(),
|
||||
pending_auth_method.as_ref(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any(),
|
||||
))
|
||||
.into_any_element(),
|
||||
ThreadState::Loading { .. } => v_flex()
|
||||
.flex_1()
|
||||
.child(self.render_recent_history(cx))
|
||||
|
||||
@@ -103,8 +103,9 @@ impl Model {
|
||||
|
||||
pub fn max_output_tokens(&self) -> Option<u64> {
|
||||
match self {
|
||||
Self::Chat => Some(8_192),
|
||||
Self::Reasoner => Some(64_000),
|
||||
// Their API treats this max against the context window, which means we hit the limit a lot
|
||||
// Using the default value of None in the API instead
|
||||
Self::Chat | Self::Reasoner => None,
|
||||
Self::Custom {
|
||||
max_output_tokens, ..
|
||||
} => *max_output_tokens,
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
use anyhow::{Context as _, Result};
|
||||
use futures::AsyncReadExt as _;
|
||||
use gpui::{
|
||||
App, AppContext as _, Entity, SharedString, Task,
|
||||
App, AppContext as _, Entity, Global, SharedString, Task,
|
||||
http_client::{self, AsyncBody, Method},
|
||||
};
|
||||
use language::{OffsetRangeExt as _, ToOffset, ToPoint as _};
|
||||
@@ -300,14 +300,19 @@ pub const MERCURY_CREDENTIALS_URL: SharedString =
|
||||
SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions");
|
||||
pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token";
|
||||
pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("MERCURY_AI_TOKEN");
|
||||
pub static MERCURY_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
|
||||
|
||||
struct GlobalMercuryApiKey(Entity<ApiKeyState>);
|
||||
|
||||
impl Global for GlobalMercuryApiKey {}
|
||||
|
||||
pub fn mercury_api_token(cx: &mut App) -> Entity<ApiKeyState> {
|
||||
MERCURY_API_KEY
|
||||
.get_or_init(|| {
|
||||
cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone()))
|
||||
})
|
||||
.clone()
|
||||
if let Some(global) = cx.try_global::<GlobalMercuryApiKey>() {
|
||||
return global.0.clone();
|
||||
}
|
||||
let entity =
|
||||
cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone()));
|
||||
cx.set_global(GlobalMercuryApiKey(entity.clone()));
|
||||
entity
|
||||
}
|
||||
|
||||
pub fn load_mercury_api_token(cx: &mut App) -> Task<Result<(), language_model::AuthenticateError>> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use futures::AsyncReadExt as _;
|
||||
use gpui::{
|
||||
App, AppContext as _, Entity, SharedString, Task,
|
||||
App, AppContext as _, Entity, Global, SharedString, Task,
|
||||
http_client::{self, AsyncBody, Method},
|
||||
};
|
||||
use language::{Point, ToOffset as _};
|
||||
@@ -272,14 +272,19 @@ pub const SWEEP_CREDENTIALS_URL: SharedString =
|
||||
SharedString::new_static("https://autocomplete.sweep.dev");
|
||||
pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token";
|
||||
pub static SWEEP_AI_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("SWEEP_AI_TOKEN");
|
||||
pub static SWEEP_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
|
||||
|
||||
struct GlobalSweepApiKey(Entity<ApiKeyState>);
|
||||
|
||||
impl Global for GlobalSweepApiKey {}
|
||||
|
||||
pub fn sweep_api_token(cx: &mut App) -> Entity<ApiKeyState> {
|
||||
SWEEP_API_KEY
|
||||
.get_or_init(|| {
|
||||
cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone()))
|
||||
})
|
||||
.clone()
|
||||
if let Some(global) = cx.try_global::<GlobalSweepApiKey>() {
|
||||
return global.0.clone();
|
||||
}
|
||||
let entity =
|
||||
cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone()));
|
||||
cx.set_global(GlobalSweepApiKey(entity.clone()));
|
||||
entity
|
||||
}
|
||||
|
||||
pub fn load_sweep_api_token(cx: &mut App) -> Task<Result<(), language_model::AuthenticateError>> {
|
||||
|
||||
@@ -348,6 +348,61 @@ where
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_bracket_colorization_after_language_swap(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |language_settings| {
|
||||
language_settings.defaults.colorize_brackets = Some(true);
|
||||
});
|
||||
|
||||
let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor()));
|
||||
language_registry.add(markdown_lang());
|
||||
language_registry.add(rust_lang());
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
cx.update_buffer(|buffer, cx| {
|
||||
buffer.set_language_registry(language_registry.clone());
|
||||
buffer.set_language(Some(markdown_lang()), cx);
|
||||
});
|
||||
|
||||
cx.set_state(indoc! {r#"
|
||||
fn main() {
|
||||
let v: Vec<Stringˇ> = vec![];
|
||||
}
|
||||
"#});
|
||||
cx.executor().advance_clock(Duration::from_millis(100));
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
r#"fn main«1()1» «1{
|
||||
let v: Vec<String> = vec!«2[]2»;
|
||||
}1»
|
||||
|
||||
1 hsla(207.80, 16.20%, 69.19%, 1.00)
|
||||
2 hsla(29.00, 54.00%, 65.88%, 1.00)
|
||||
"#,
|
||||
&bracket_colors_markup(&mut cx),
|
||||
"Markdown does not colorize <> brackets"
|
||||
);
|
||||
|
||||
cx.update_buffer(|buffer, cx| {
|
||||
buffer.set_language(Some(rust_lang()), cx);
|
||||
});
|
||||
cx.executor().advance_clock(Duration::from_millis(100));
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
r#"fn main«1()1» «1{
|
||||
let v: Vec«2<String>2» = vec!«2[]2»;
|
||||
}1»
|
||||
|
||||
1 hsla(207.80, 16.20%, 69.19%, 1.00)
|
||||
2 hsla(29.00, 54.00%, 65.88%, 1.00)
|
||||
"#,
|
||||
&bracket_colors_markup(&mut cx),
|
||||
"After switching to Rust, <> brackets are now colorized"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_bracket_colorization_when_editing(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |language_settings| {
|
||||
|
||||
@@ -331,7 +331,6 @@ static mut EXTENSION: Option<Box<dyn Extension>> = None;
|
||||
pub static ZED_API_VERSION: [u8; 6] = *include_bytes!(concat!(env!("OUT_DIR"), "/version_bytes"));
|
||||
|
||||
mod wit {
|
||||
|
||||
wit_bindgen::generate!({
|
||||
skip: ["init-extension"],
|
||||
path: "./wit/since_v0.8.0",
|
||||
@@ -524,6 +523,12 @@ impl wit::Guest for Component {
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
|
||||
pub struct LanguageServerId(String);
|
||||
|
||||
impl LanguageServerId {
|
||||
pub fn new(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for LanguageServerId {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
@@ -540,6 +545,12 @@ impl fmt::Display for LanguageServerId {
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
|
||||
pub struct ContextServerId(String);
|
||||
|
||||
impl ContextServerId {
|
||||
pub fn new(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for ContextServerId {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
use gpui::{App, Context, WeakEntity, Window};
|
||||
use notifications::status_toast::{StatusToast, ToastIcon};
|
||||
use std::sync::Arc;
|
||||
use ui::{Color, IconName, SharedString};
|
||||
use util::ResultExt;
|
||||
use workspace::{self, Workspace};
|
||||
|
||||
pub fn clone_and_open(
|
||||
repo_url: SharedString,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
on_success: Arc<
|
||||
dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send + Sync + 'static,
|
||||
>,
|
||||
) {
|
||||
let destination_prompt = cx.prompt_for_paths(gpui::PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select as Repository Destination".into()),
|
||||
});
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
let mut paths = destination_prompt.await.ok()?.ok()??;
|
||||
let mut destination_dir = paths.pop()?;
|
||||
|
||||
let repo_name = repo_url
|
||||
.split('/')
|
||||
.next_back()
|
||||
.map(|name| name.strip_suffix(".git").unwrap_or(name))
|
||||
.unwrap_or("repository")
|
||||
.to_owned();
|
||||
|
||||
let clone_task = workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
let fs = workspace.app_state().fs.clone();
|
||||
let destination_dir = destination_dir.clone();
|
||||
let repo_url = repo_url.clone();
|
||||
cx.spawn(async move |_workspace, _cx| {
|
||||
fs.git_clone(&repo_url, destination_dir.as_path()).await
|
||||
})
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
if let Err(error) = clone_task.await {
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
let toast = StatusToast::new(error.to_string(), cx, |this, _| {
|
||||
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
|
||||
.dismiss_button(true)
|
||||
});
|
||||
workspace.toggle_status_toast(toast, cx);
|
||||
})
|
||||
.log_err();
|
||||
return None;
|
||||
}
|
||||
|
||||
let has_worktrees = workspace
|
||||
.read_with(cx, |workspace, cx| {
|
||||
workspace.project().read(cx).worktrees(cx).next().is_some()
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
let prompt_answer = if has_worktrees {
|
||||
cx.update(|window, cx| {
|
||||
window.prompt(
|
||||
gpui::PromptLevel::Info,
|
||||
&format!("Git Clone: {}", repo_name),
|
||||
None,
|
||||
&["Add repo to project", "Open repo in new project"],
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.ok()?
|
||||
.await
|
||||
.ok()?
|
||||
} else {
|
||||
// Don't ask if project is empty
|
||||
0
|
||||
};
|
||||
|
||||
destination_dir.push(&repo_name);
|
||||
|
||||
match prompt_answer {
|
||||
0 => {
|
||||
workspace
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
let create_task = workspace.project().update(cx, |project, cx| {
|
||||
project.create_worktree(destination_dir.as_path(), true, cx)
|
||||
});
|
||||
|
||||
let workspace_weak = cx.weak_entity();
|
||||
let on_success = on_success.clone();
|
||||
cx.spawn_in(window, async move |_window, cx| {
|
||||
if create_task.await.log_err().is_some() {
|
||||
workspace_weak
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
(on_success)(workspace, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok()?;
|
||||
}
|
||||
1 => {
|
||||
workspace
|
||||
.update(cx, move |workspace, cx| {
|
||||
let app_state = workspace.app_state().clone();
|
||||
let destination_path = destination_dir.clone();
|
||||
let on_success = on_success.clone();
|
||||
|
||||
workspace::open_new(
|
||||
Default::default(),
|
||||
app_state,
|
||||
cx,
|
||||
move |workspace, window, cx| {
|
||||
cx.activate(true);
|
||||
|
||||
let create_task =
|
||||
workspace.project().update(cx, |project, cx| {
|
||||
project.create_worktree(
|
||||
destination_path.as_path(),
|
||||
true,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let workspace_weak = cx.weak_entity();
|
||||
cx.spawn_in(window, async move |_window, cx| {
|
||||
if create_task.await.log_err().is_some() {
|
||||
workspace_weak
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
(on_success)(workspace, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -2848,15 +2848,93 @@ impl GitPanel {
|
||||
}
|
||||
|
||||
pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let path = cx.prompt_for_paths(gpui::PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select as Repository Destination".into()),
|
||||
});
|
||||
|
||||
let workspace = self.workspace.clone();
|
||||
|
||||
crate::clone::clone_and_open(
|
||||
repo.into(),
|
||||
workspace,
|
||||
window,
|
||||
cx,
|
||||
Arc::new(|_workspace: &mut workspace::Workspace, _window, _cx| {}),
|
||||
);
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let mut paths = path.await.ok()?.ok()??;
|
||||
let mut path = paths.pop()?;
|
||||
let repo_name = repo.split("/").last()?.strip_suffix(".git")?.to_owned();
|
||||
|
||||
let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?;
|
||||
|
||||
let prompt_answer = match fs.git_clone(&repo, path.as_path()).await {
|
||||
Ok(_) => cx.update(|window, cx| {
|
||||
window.prompt(
|
||||
PromptLevel::Info,
|
||||
&format!("Git Clone: {}", repo_name),
|
||||
None,
|
||||
&["Add repo to project", "Open repo in new project"],
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
Err(e) => {
|
||||
this.update(cx, |this: &mut GitPanel, cx| {
|
||||
let toast = StatusToast::new(e.to_string(), cx, |this, _| {
|
||||
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
|
||||
.dismiss_button(true)
|
||||
});
|
||||
|
||||
this.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.toggle_status_toast(toast, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
.ok()?;
|
||||
|
||||
path.push(repo_name);
|
||||
match prompt_answer.await.ok()? {
|
||||
0 => {
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace
|
||||
.project()
|
||||
.update(cx, |project, cx| {
|
||||
project.create_worktree(path.as_path(), true, cx)
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
1 => {
|
||||
workspace
|
||||
.update(cx, move |workspace, cx| {
|
||||
workspace::open_new(
|
||||
Default::default(),
|
||||
workspace.app_state().clone(),
|
||||
cx,
|
||||
move |workspace, _, cx| {
|
||||
cx.activate(true);
|
||||
workspace
|
||||
.project()
|
||||
.update(cx, |project, cx| {
|
||||
project.create_worktree(&path, true, cx)
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
|
||||
@@ -10,7 +10,6 @@ use ui::{
|
||||
};
|
||||
|
||||
mod blame_ui;
|
||||
pub mod clone;
|
||||
|
||||
use git::{
|
||||
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
|
||||
|
||||
@@ -566,22 +566,22 @@ impl Model {
|
||||
|
||||
pub fn max_token_count(&self) -> u64 {
|
||||
match self {
|
||||
Self::Gemini25FlashLite => 1_048_576,
|
||||
Self::Gemini25Flash => 1_048_576,
|
||||
Self::Gemini25Pro => 1_048_576,
|
||||
Self::Gemini3Pro => 1_048_576,
|
||||
Self::Gemini3Flash => 1_048_576,
|
||||
Self::Gemini25FlashLite
|
||||
| Self::Gemini25Flash
|
||||
| Self::Gemini25Pro
|
||||
| Self::Gemini3Pro
|
||||
| Self::Gemini3Flash => 1_048_576,
|
||||
Self::Custom { max_tokens, .. } => *max_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_output_tokens(&self) -> Option<u64> {
|
||||
match self {
|
||||
Model::Gemini25FlashLite => Some(65_536),
|
||||
Model::Gemini25Flash => Some(65_536),
|
||||
Model::Gemini25Pro => Some(65_536),
|
||||
Model::Gemini3Pro => Some(65_536),
|
||||
Model::Gemini3Flash => Some(65_536),
|
||||
Model::Gemini25FlashLite
|
||||
| Model::Gemini25Flash
|
||||
| Model::Gemini25Pro
|
||||
| Model::Gemini3Pro
|
||||
| Model::Gemini3Flash => Some(65_536),
|
||||
Model::Custom { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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::{
|
||||
@@ -154,6 +156,9 @@ impl MetalRenderer {
|
||||
layer.set_pixel_format(MTLPixelFormat::BGRA8Unorm);
|
||||
layer.set_opaque(false);
|
||||
layer.set_maximum_drawable_count(3);
|
||||
// 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];
|
||||
@@ -426,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;
|
||||
@@ -931,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 {
|
||||
@@ -1557,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 {
|
||||
|
||||
@@ -40,6 +40,11 @@ impl WindowsWindowInner {
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
let handled = match msg {
|
||||
// eagerly activate the window, so calls to `active_window` will work correctly
|
||||
WM_MOUSEACTIVATE => {
|
||||
unsafe { SetActiveWindow(handle).log_err() };
|
||||
None
|
||||
}
|
||||
WM_ACTIVATE => self.handle_activate_msg(wparam),
|
||||
WM_CREATE => self.handle_create_msg(handle),
|
||||
WM_MOVE => self.handle_move_msg(handle, lparam),
|
||||
|
||||
@@ -659,7 +659,7 @@ impl Platform for WindowsPlatform {
|
||||
if let Err(err) = result {
|
||||
// ERROR_NOT_FOUND means the credential doesn't exist.
|
||||
// Return Ok(None) to match macOS and Linux behavior.
|
||||
if err.code().0 == ERROR_NOT_FOUND.0 as i32 {
|
||||
if err.code() == ERROR_NOT_FOUND.to_hresult() {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(err.into());
|
||||
|
||||
@@ -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);
|
||||
@@ -4966,7 +4983,7 @@ impl<V: 'static> From<WindowHandle<V>> for AnyWindowHandle {
|
||||
}
|
||||
|
||||
/// A handle to a window with any root view type, which can be downcast to a window with a specific root view type.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub struct AnyWindowHandle {
|
||||
pub(crate) id: WindowId,
|
||||
state_type: TypeId,
|
||||
|
||||
@@ -1801,9 +1801,7 @@ impl Buffer {
|
||||
self.syntax_map.lock().did_parse(syntax_snapshot);
|
||||
self.request_autoindent(cx);
|
||||
self.parse_status.0.send(ParseStatus::Idle).unwrap();
|
||||
if self.text.version() != *self.tree_sitter_data.version() {
|
||||
self.invalidate_tree_sitter_data(self.text.snapshot());
|
||||
}
|
||||
self.invalidate_tree_sitter_data(self.text.snapshot());
|
||||
cx.emit(BufferEvent::Reparsed);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -295,6 +295,23 @@ impl LspInstaller for TyLspAdapter {
|
||||
})
|
||||
}
|
||||
|
||||
async fn check_if_user_installed(
|
||||
&self,
|
||||
delegate: &dyn LspAdapterDelegate,
|
||||
_: Option<Toolchain>,
|
||||
_: &AsyncApp,
|
||||
) -> Option<LanguageServerBinary> {
|
||||
let Some(ty_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await else {
|
||||
return None;
|
||||
};
|
||||
let env = delegate.shell_env().await;
|
||||
Some(LanguageServerBinary {
|
||||
path: ty_bin,
|
||||
env: Some(env),
|
||||
arguments: vec!["server".into()],
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_server_binary(
|
||||
&self,
|
||||
latest_version: Self::BinaryVersion,
|
||||
|
||||
@@ -355,7 +355,7 @@ impl LspAdapter for RustLspAdapter {
|
||||
| lsp::CompletionTextEdit::Edit(lsp::TextEdit { new_text, .. }),
|
||||
) = completion.text_edit.as_ref()
|
||||
&& let Ok(mut snippet) = snippet::Snippet::parse(new_text)
|
||||
&& !snippet.tabstops.is_empty()
|
||||
&& snippet.tabstops.len() > 1
|
||||
{
|
||||
label = String::new();
|
||||
|
||||
@@ -421,7 +421,9 @@ impl LspAdapter for RustLspAdapter {
|
||||
0..label.rfind('(').unwrap_or(completion.label.len()),
|
||||
highlight_id,
|
||||
));
|
||||
} else if detail_left.is_none() {
|
||||
} else if detail_left.is_none()
|
||||
&& kind != Some(lsp::CompletionItemKind::SNIPPET)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -1597,6 +1599,40 @@ mod tests {
|
||||
))
|
||||
);
|
||||
|
||||
// Postfix completion without actual tabstops (only implicit final $0)
|
||||
// The label should use completion.label so it can be filtered by "ref"
|
||||
let ref_completion = adapter
|
||||
.label_for_completion(
|
||||
&lsp::CompletionItem {
|
||||
kind: Some(lsp::CompletionItemKind::SNIPPET),
|
||||
label: "ref".to_string(),
|
||||
filter_text: Some("ref".to_string()),
|
||||
label_details: Some(CompletionItemLabelDetails {
|
||||
detail: None,
|
||||
description: Some("&expr".to_string()),
|
||||
}),
|
||||
detail: Some("&expr".to_string()),
|
||||
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
|
||||
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
|
||||
range: lsp::Range::default(),
|
||||
new_text: "&String::new()".to_string(),
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
&language,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
ref_completion.is_some(),
|
||||
"ref postfix completion should have a label"
|
||||
);
|
||||
let ref_label = ref_completion.unwrap();
|
||||
let filter_text = &ref_label.text[ref_label.filter_range.clone()];
|
||||
assert!(
|
||||
filter_text.contains("ref"),
|
||||
"filter range text '{filter_text}' should contain 'ref' for filtering to work",
|
||||
);
|
||||
|
||||
// Test for correct range calculation with mixed empty and non-empty tabstops.(See https://github.com/zed-industries/zed/issues/44825)
|
||||
let res = adapter
|
||||
.label_for_completion(
|
||||
|
||||
@@ -155,15 +155,15 @@ impl Model {
|
||||
pub fn max_token_count(&self) -> u64 {
|
||||
match self {
|
||||
Self::CodestralLatest => 256000,
|
||||
Self::MistralLargeLatest => 131000,
|
||||
Self::MistralLargeLatest => 256000,
|
||||
Self::MistralMediumLatest => 128000,
|
||||
Self::MistralSmallLatest => 32000,
|
||||
Self::MagistralMediumLatest => 40000,
|
||||
Self::MagistralSmallLatest => 40000,
|
||||
Self::MagistralMediumLatest => 128000,
|
||||
Self::MagistralSmallLatest => 128000,
|
||||
Self::OpenMistralNemo => 131000,
|
||||
Self::OpenCodestralMamba => 256000,
|
||||
Self::DevstralMediumLatest => 128000,
|
||||
Self::DevstralSmallLatest => 262144,
|
||||
Self::DevstralMediumLatest => 256000,
|
||||
Self::DevstralSmallLatest => 256000,
|
||||
Self::Pixtral12BLatest => 128000,
|
||||
Self::PixtralLargeLatest => 128000,
|
||||
Self::Custom { max_tokens, .. } => *max_tokens,
|
||||
|
||||
@@ -460,7 +460,7 @@ impl AgentServerStore {
|
||||
.gemini
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.ignore_system_version)
|
||||
.unwrap_or(false),
|
||||
.unwrap_or(true),
|
||||
}),
|
||||
);
|
||||
self.external_agents.insert(
|
||||
|
||||
@@ -1672,59 +1672,6 @@ impl GitStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_entries_pending_by_project_paths(
|
||||
&mut self,
|
||||
project_paths: &[ProjectPath],
|
||||
stage: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let buffer_store = &self.buffer_store;
|
||||
|
||||
for project_path in project_paths {
|
||||
let Some(buffer) = buffer_store.read(cx).get_by_path(project_path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let buffer_id = buffer.read(cx).remote_id();
|
||||
let Some(diff_state) = self.diffs.get(&buffer_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
diff_state.update(cx, |diff_state, cx| {
|
||||
let Some(uncommitted_diff) = diff_state.uncommitted_diff() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let buffer_snapshot = buffer.read(cx).text_snapshot();
|
||||
let file_exists = buffer
|
||||
.read(cx)
|
||||
.file()
|
||||
.is_some_and(|file| file.disk_state().exists());
|
||||
|
||||
let all_hunks: Vec<_> = uncommitted_diff
|
||||
.read(cx)
|
||||
.hunks_intersecting_range(
|
||||
text::Anchor::MIN..text::Anchor::MAX,
|
||||
&buffer_snapshot,
|
||||
cx,
|
||||
)
|
||||
.collect();
|
||||
|
||||
if !all_hunks.is_empty() {
|
||||
uncommitted_diff.update(cx, |diff, cx| {
|
||||
diff.stage_or_unstage_hunks(
|
||||
stage,
|
||||
&all_hunks,
|
||||
&buffer_snapshot,
|
||||
file_exists,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn git_clone(
|
||||
&self,
|
||||
repo: String,
|
||||
@@ -4253,28 +4200,6 @@ impl Repository {
|
||||
save_futures
|
||||
}
|
||||
|
||||
fn mark_entries_pending_for_stage(
|
||||
&self,
|
||||
entries: &[RepoPath],
|
||||
stage: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(git_store) = self.git_store() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut project_paths = Vec::new();
|
||||
for repo_path in entries {
|
||||
if let Some(project_path) = self.repo_path_to_project_path(repo_path, cx) {
|
||||
project_paths.push(project_path);
|
||||
}
|
||||
}
|
||||
|
||||
git_store.update(cx, move |git_store, cx| {
|
||||
git_store.mark_entries_pending_by_project_paths(&project_paths, stage, cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn stage_entries(
|
||||
&mut self,
|
||||
entries: Vec<RepoPath>,
|
||||
@@ -4283,9 +4208,6 @@ impl Repository {
|
||||
if entries.is_empty() {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
|
||||
self.mark_entries_pending_for_stage(&entries, true, cx);
|
||||
|
||||
let id = self.id;
|
||||
let save_tasks = self.save_buffers(&entries, cx);
|
||||
let paths = entries
|
||||
@@ -4351,9 +4273,6 @@ impl Repository {
|
||||
if entries.is_empty() {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
|
||||
self.mark_entries_pending_for_stage(&entries, false, cx);
|
||||
|
||||
let id = self.id;
|
||||
let save_tasks = self.save_buffers(&entries, cx);
|
||||
let paths = entries
|
||||
|
||||
@@ -790,8 +790,7 @@ impl TerminalPanel {
|
||||
}
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
let focus = pane.has_focus(window, cx)
|
||||
|| matches!(reveal_strategy, RevealStrategy::Always);
|
||||
let focus = matches!(reveal_strategy, RevealStrategy::Always);
|
||||
pane.add_item(terminal_view, true, focus, None, window, cx);
|
||||
});
|
||||
|
||||
@@ -853,8 +852,7 @@ impl TerminalPanel {
|
||||
}
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
let focus = pane.has_focus(window, cx)
|
||||
|| matches!(reveal_strategy, RevealStrategy::Always);
|
||||
let focus = matches!(reveal_strategy, RevealStrategy::Always);
|
||||
pane.add_item(terminal_view, true, focus, None, window, cx);
|
||||
});
|
||||
|
||||
@@ -1171,64 +1169,67 @@ pub fn new_terminal_pane(
|
||||
let source = tab.pane.clone();
|
||||
let item_id_to_move = item.item_id();
|
||||
|
||||
let Ok(new_split_pane) = pane
|
||||
.drag_split_direction()
|
||||
.map(|split_direction| {
|
||||
drop_closure_terminal_panel.update(cx, |terminal_panel, cx| {
|
||||
let is_zoomed = if terminal_panel.active_pane == this_pane {
|
||||
pane.is_zoomed()
|
||||
} else {
|
||||
terminal_panel.active_pane.read(cx).is_zoomed()
|
||||
};
|
||||
let new_pane = new_terminal_pane(
|
||||
workspace.clone(),
|
||||
project.clone(),
|
||||
is_zoomed,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
|
||||
terminal_panel.center.split(
|
||||
&this_pane,
|
||||
&new_pane,
|
||||
split_direction,
|
||||
cx,
|
||||
)?;
|
||||
anyhow::Ok(new_pane)
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
else {
|
||||
return ControlFlow::Break(());
|
||||
// If no split direction, let the regular pane drop handler take care of it
|
||||
let Some(split_direction) = pane.drag_split_direction() else {
|
||||
return ControlFlow::Continue(());
|
||||
};
|
||||
|
||||
match new_split_pane.transpose() {
|
||||
// Source pane may be the one currently updated, so defer the move.
|
||||
Ok(Some(new_pane)) => cx
|
||||
.spawn_in(window, async move |_, cx| {
|
||||
cx.update(|window, cx| {
|
||||
move_item(
|
||||
&source,
|
||||
&new_pane,
|
||||
item_id_to_move,
|
||||
new_pane.read(cx).active_item_index(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
// Gather data synchronously before deferring
|
||||
let is_zoomed = drop_closure_terminal_panel
|
||||
.upgrade()
|
||||
.map(|terminal_panel| {
|
||||
let terminal_panel = terminal_panel.read(cx);
|
||||
if terminal_panel.active_pane == this_pane {
|
||||
pane.is_zoomed()
|
||||
} else {
|
||||
terminal_panel.active_pane.read(cx).is_zoomed()
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let workspace = workspace.clone();
|
||||
let terminal_panel = drop_closure_terminal_panel.clone();
|
||||
|
||||
// Defer the split operation to avoid re-entrancy panic.
|
||||
// The pane may be the one currently being updated, so we cannot
|
||||
// call mark_positions (via split) synchronously.
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
cx.update(|window, cx| {
|
||||
let Ok(new_pane) =
|
||||
terminal_panel.update(cx, |terminal_panel, cx| {
|
||||
let new_pane = new_terminal_pane(
|
||||
workspace, project, is_zoomed, window, cx,
|
||||
);
|
||||
terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
|
||||
terminal_panel.center.split(
|
||||
&this_pane,
|
||||
&new_pane,
|
||||
split_direction,
|
||||
cx,
|
||||
)?;
|
||||
anyhow::Ok(new_pane)
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach(),
|
||||
// If we drop into existing pane or current pane,
|
||||
// regular pane drop handler will take care of it,
|
||||
// using the right tab index for the operation.
|
||||
Ok(None) => return ControlFlow::Continue(()),
|
||||
err @ Err(_) => {
|
||||
err.log_err();
|
||||
return ControlFlow::Break(());
|
||||
}
|
||||
};
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(new_pane) = new_pane.log_err() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_item(
|
||||
&source,
|
||||
&new_pane,
|
||||
item_id_to_move,
|
||||
new_pane.read(cx).active_item_index(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
} else if let Some(project_path) = item.project_path(cx)
|
||||
&& let Some(entry_path) = project.read(cx).absolute_path(&project_path, cx)
|
||||
{
|
||||
|
||||
@@ -121,7 +121,7 @@ impl RenderOnce for Callout {
|
||||
Severity::Info => (
|
||||
IconName::Info,
|
||||
Color::Muted,
|
||||
cx.theme().colors().panel_background.opacity(0.),
|
||||
cx.theme().status().info_background.opacity(0.1),
|
||||
),
|
||||
Severity::Success => (
|
||||
IconName::Check,
|
||||
|
||||
@@ -193,6 +193,12 @@ impl Render for ModalLayer {
|
||||
background.fade_out(0.2);
|
||||
this.bg(background)
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, _, window, cx| {
|
||||
this.hide_modal(window, cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.h(px(0.0))
|
||||
|
||||
@@ -3296,4 +3296,53 @@ mod tests {
|
||||
|
||||
assert_eq!(workspace.center_group, new_workspace.center_group);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_empty_workspace_window_bounds() {
|
||||
zlog::init_test();
|
||||
|
||||
let db = WorkspaceDb::open_test_db("test_empty_workspace_window_bounds").await;
|
||||
let id = db.next_id().await.unwrap();
|
||||
|
||||
// Create a workspace with empty paths (empty workspace)
|
||||
let empty_paths: &[&str] = &[];
|
||||
let display_uuid = Uuid::new_v4();
|
||||
let window_bounds = SerializedWindowBounds(WindowBounds::Windowed(Bounds {
|
||||
origin: point(px(100.0), px(200.0)),
|
||||
size: size(px(800.0), px(600.0)),
|
||||
}));
|
||||
|
||||
let workspace = SerializedWorkspace {
|
||||
id,
|
||||
paths: PathList::new(empty_paths),
|
||||
location: SerializedWorkspaceLocation::Local,
|
||||
center_group: Default::default(),
|
||||
window_bounds: None,
|
||||
display: None,
|
||||
docks: Default::default(),
|
||||
breakpoints: Default::default(),
|
||||
centered_layout: false,
|
||||
session_id: None,
|
||||
window_id: None,
|
||||
user_toolchains: Default::default(),
|
||||
};
|
||||
|
||||
// Save the workspace (this creates the record with empty paths)
|
||||
db.save_workspace(workspace.clone()).await;
|
||||
|
||||
// Save window bounds separately (as the actual code does via set_window_open_status)
|
||||
db.set_window_open_status(id, window_bounds, display_uuid)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Retrieve it using empty paths
|
||||
let retrieved = db.workspace_for_roots(empty_paths).unwrap();
|
||||
|
||||
// Verify window bounds were persisted
|
||||
assert_eq!(retrieved.id, id);
|
||||
assert!(retrieved.window_bounds.is_some());
|
||||
assert_eq!(retrieved.window_bounds.unwrap().0, window_bounds.0);
|
||||
assert!(retrieved.display.is_some());
|
||||
assert_eq!(retrieved.display.unwrap(), display_uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1748,26 +1748,18 @@ impl Workspace {
|
||||
window
|
||||
} else {
|
||||
let window_bounds_override = window_bounds_env_override();
|
||||
let is_empty_workspace = project_paths.is_empty();
|
||||
|
||||
let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
|
||||
(Some(WindowBounds::Windowed(bounds)), None)
|
||||
} else if let Some(workspace) = serialized_workspace.as_ref() {
|
||||
} else if let Some(workspace) = serialized_workspace.as_ref()
|
||||
&& let Some(display) = workspace.display
|
||||
&& let Some(bounds) = workspace.window_bounds.as_ref()
|
||||
{
|
||||
// Reopening an existing workspace - restore its saved bounds
|
||||
if let (Some(display), Some(bounds)) =
|
||||
(workspace.display, workspace.window_bounds.as_ref())
|
||||
{
|
||||
(Some(bounds.0), Some(display))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else if is_empty_workspace {
|
||||
// Empty workspace - try to restore the last known no-project window bounds
|
||||
if let Some((display, bounds)) = persistence::read_default_window_bounds() {
|
||||
(Some(bounds), Some(display))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
(Some(bounds.0), Some(display))
|
||||
} else if let Some((display, bounds)) = persistence::read_default_window_bounds() {
|
||||
// New or empty workspace - use the last known window bounds
|
||||
(Some(bounds), Some(display))
|
||||
} else {
|
||||
// New window - let GPUI's default_bounds() handle cascading
|
||||
(None, None)
|
||||
@@ -5673,12 +5665,24 @@ impl Workspace {
|
||||
persistence::DB.save_workspace(serialized_workspace).await;
|
||||
})
|
||||
}
|
||||
WorkspaceLocation::DetachFromSession => window.spawn(cx, async move |_| {
|
||||
persistence::DB
|
||||
.set_session_id(database_id, None)
|
||||
.await
|
||||
.log_err();
|
||||
}),
|
||||
WorkspaceLocation::DetachFromSession => {
|
||||
let window_bounds = SerializedWindowBounds(window.window_bounds());
|
||||
let display = window.display(cx).and_then(|d| d.uuid().ok());
|
||||
window.spawn(cx, async move |_| {
|
||||
persistence::DB
|
||||
.set_window_open_status(
|
||||
database_id,
|
||||
window_bounds,
|
||||
display.unwrap_or_default(),
|
||||
)
|
||||
.await
|
||||
.log_err();
|
||||
persistence::DB
|
||||
.set_session_id(database_id, None)
|
||||
.await
|
||||
.log_err();
|
||||
})
|
||||
}
|
||||
WorkspaceLocation::None => Task::ready(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,13 +15,11 @@ use extension::ExtensionHostProxy;
|
||||
use fs::{Fs, RealFs};
|
||||
use futures::{StreamExt, channel::oneshot, future};
|
||||
use git::GitHostingProviderRegistry;
|
||||
use git_ui::clone::clone_and_open;
|
||||
use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, UpdateGlobal as _};
|
||||
|
||||
use gpui_tokio::Tokio;
|
||||
use language::LanguageRegistry;
|
||||
use onboarding::{FIRST_OPEN, show_onboarding_view};
|
||||
use project_panel::ProjectPanel;
|
||||
use prompt_store::PromptBuilder;
|
||||
use remote::RemoteConnectionOptions;
|
||||
use reqwest_client::ReqwestClient;
|
||||
@@ -35,12 +33,10 @@ use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
|
||||
use session::{AppSession, Session};
|
||||
use settings::{BaseKeymap, Settings, SettingsStore, watch_config_file};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
env,
|
||||
io::{self, IsTerminal},
|
||||
path::{Path, PathBuf},
|
||||
process,
|
||||
rc::Rc,
|
||||
sync::{Arc, OnceLock},
|
||||
time::Instant,
|
||||
};
|
||||
@@ -897,41 +893,6 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
OpenRequestKind::GitClone { repo_url } => {
|
||||
workspace::with_active_or_new_workspace(cx, |_workspace, window, cx| {
|
||||
if window.is_window_active() {
|
||||
clone_and_open(
|
||||
repo_url,
|
||||
cx.weak_entity(),
|
||||
window,
|
||||
cx,
|
||||
Arc::new(|workspace: &mut workspace::Workspace, window, cx| {
|
||||
workspace.focus_panel::<ProjectPanel>(window, cx);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let subscription = Rc::new(RefCell::new(None));
|
||||
subscription.replace(Some(cx.observe_in(&cx.entity(), window, {
|
||||
let subscription = subscription.clone();
|
||||
let repo_url = repo_url.clone();
|
||||
move |_, workspace_entity, window, cx| {
|
||||
if window.is_window_active() && subscription.take().is_some() {
|
||||
clone_and_open(
|
||||
repo_url.clone(),
|
||||
workspace_entity.downgrade(),
|
||||
window,
|
||||
cx,
|
||||
Arc::new(|workspace: &mut workspace::Workspace, window, cx| {
|
||||
workspace.focus_panel::<ProjectPanel>(window, cx);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
})));
|
||||
});
|
||||
}
|
||||
OpenRequestKind::GitCommit { sha } => {
|
||||
cx.spawn(async move |cx| {
|
||||
let paths_with_position =
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use ui::SharedString;
|
||||
use util::ResultExt;
|
||||
use util::paths::PathWithPosition;
|
||||
use workspace::PathList;
|
||||
@@ -59,9 +58,6 @@ pub enum OpenRequestKind {
|
||||
/// `None` opens settings without navigating to a specific path.
|
||||
setting_path: Option<String>,
|
||||
},
|
||||
GitClone {
|
||||
repo_url: SharedString,
|
||||
},
|
||||
GitCommit {
|
||||
sha: String,
|
||||
},
|
||||
@@ -117,8 +113,6 @@ impl OpenRequest {
|
||||
this.kind = Some(OpenRequestKind::Setting {
|
||||
setting_path: Some(setting_path.to_string()),
|
||||
});
|
||||
} else if let Some(clone_path) = url.strip_prefix("zed://git/clone") {
|
||||
this.parse_git_clone_url(clone_path)?
|
||||
} else if let Some(commit_path) = url.strip_prefix("zed://git/commit/") {
|
||||
this.parse_git_commit_url(commit_path)?
|
||||
} else if url.starts_with("ssh://") {
|
||||
@@ -149,26 +143,6 @@ impl OpenRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> {
|
||||
// Format: /?repo=<url> or ?repo=<url>
|
||||
let clone_path = clone_path.strip_prefix('/').unwrap_or(clone_path);
|
||||
|
||||
let query = clone_path
|
||||
.strip_prefix('?')
|
||||
.context("invalid git clone url: missing query string")?;
|
||||
|
||||
let repo_url = url::form_urlencoded::parse(query.as_bytes())
|
||||
.find_map(|(key, value)| (key == "repo").then_some(value))
|
||||
.filter(|s| !s.is_empty())
|
||||
.context("invalid git clone url: missing repo query parameter")?
|
||||
.to_string()
|
||||
.into();
|
||||
|
||||
self.kind = Some(OpenRequestKind::GitClone { repo_url });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_git_commit_url(&mut self, commit_path: &str) -> Result<()> {
|
||||
// Format: <sha>?repo=<path>
|
||||
let (sha, query) = commit_path
|
||||
@@ -1113,80 +1087,4 @@ mod tests {
|
||||
|
||||
assert!(!errored_reuse);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_parse_git_clone_url(cx: &mut TestAppContext) {
|
||||
let _app_state = init_test(cx);
|
||||
|
||||
let request = cx.update(|cx| {
|
||||
OpenRequest::parse(
|
||||
RawOpenRequest {
|
||||
urls: vec![
|
||||
"zed://git/clone/?repo=https://github.com/zed-industries/zed.git".into(),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
match request.kind {
|
||||
Some(OpenRequestKind::GitClone { repo_url }) => {
|
||||
assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
|
||||
}
|
||||
_ => panic!("Expected GitClone kind"),
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_parse_git_clone_url_without_slash(cx: &mut TestAppContext) {
|
||||
let _app_state = init_test(cx);
|
||||
|
||||
let request = cx.update(|cx| {
|
||||
OpenRequest::parse(
|
||||
RawOpenRequest {
|
||||
urls: vec![
|
||||
"zed://git/clone?repo=https://github.com/zed-industries/zed.git".into(),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
match request.kind {
|
||||
Some(OpenRequestKind::GitClone { repo_url }) => {
|
||||
assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
|
||||
}
|
||||
_ => panic!("Expected GitClone kind"),
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_parse_git_clone_url_with_encoding(cx: &mut TestAppContext) {
|
||||
let _app_state = init_test(cx);
|
||||
|
||||
let request = cx.update(|cx| {
|
||||
OpenRequest::parse(
|
||||
RawOpenRequest {
|
||||
urls: vec![
|
||||
"zed://git/clone/?repo=https%3A%2F%2Fgithub.com%2Fzed-industries%2Fzed.git"
|
||||
.into(),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
match request.kind {
|
||||
Some(OpenRequestKind::GitClone { repo_url }) => {
|
||||
assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
|
||||
}
|
||||
_ => panic!("Expected GitClone kind"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
539
crates/zed/src/zed/visual_tests.rs
Normal file
539
crates/zed/src/zed/visual_tests.rs
Normal file
@@ -0,0 +1,539 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Visual testing infrastructure for Zed.
|
||||
//!
|
||||
//! This module provides utilities for visual regression testing of Zed's UI.
|
||||
//! It allows capturing screenshots of the real Zed application window and comparing
|
||||
//! them against baseline images.
|
||||
//!
|
||||
//! ## Important: Main Thread Requirement
|
||||
//!
|
||||
//! On macOS, the `VisualTestAppContext` must be created on the main thread.
|
||||
//! Standard Rust tests run on worker threads, so visual tests that use
|
||||
//! `VisualTestAppContext::new()` must be run with special consideration.
|
||||
//!
|
||||
//! ## Running Visual Tests
|
||||
//!
|
||||
//! Visual tests are marked with `#[ignore]` by default because:
|
||||
//! 1. They require macOS with Screen Recording permission
|
||||
//! 2. They need to run on the main thread
|
||||
//! 3. They may produce different results on different displays/resolutions
|
||||
//!
|
||||
//! To run visual tests:
|
||||
//! ```bash
|
||||
//! # Run all visual tests (requires macOS, may need Screen Recording permission)
|
||||
//! cargo test -p zed visual_tests -- --ignored --test-threads=1
|
||||
//!
|
||||
//! # Update baselines when UI intentionally changes
|
||||
//! UPDATE_BASELINES=1 cargo test -p zed visual_tests -- --ignored --test-threads=1
|
||||
//! ```
|
||||
//!
|
||||
//! ## Screenshot Output
|
||||
//!
|
||||
//! Screenshots are saved to the directory specified by `VISUAL_TEST_OUTPUT_DIR`
|
||||
//! environment variable, or `target/visual_tests` by default.
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use gpui::{
|
||||
AnyWindowHandle, AppContext as _, Empty, Size, VisualTestAppContext, WindowHandle, px, size,
|
||||
};
|
||||
use image::{ImageBuffer, Rgba, RgbaImage};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use workspace::AppState;
|
||||
|
||||
/// Initialize a visual test context with all necessary Zed subsystems.
|
||||
pub fn init_visual_test(cx: &mut VisualTestAppContext) -> Arc<AppState> {
|
||||
cx.update(|cx| {
|
||||
env_logger::builder().is_test(true).try_init().ok();
|
||||
|
||||
let app_state = AppState::test(cx);
|
||||
|
||||
gpui_tokio::init(cx);
|
||||
theme::init(theme::LoadThemes::JustBase, 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);
|
||||
project_panel::init(cx);
|
||||
outline_panel::init(cx);
|
||||
terminal_view::init(cx);
|
||||
image_viewer::init(cx);
|
||||
search::init(cx);
|
||||
|
||||
app_state
|
||||
})
|
||||
}
|
||||
|
||||
/// Open a test workspace with the given app state.
|
||||
pub async fn open_test_workspace(
|
||||
app_state: Arc<AppState>,
|
||||
cx: &mut VisualTestAppContext,
|
||||
) -> Result<WindowHandle<workspace::Workspace>> {
|
||||
let window_size = size(px(1280.0), px(800.0));
|
||||
|
||||
let project = cx.update(|cx| {
|
||||
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 window = cx.open_offscreen_window(window_size, |window, cx| {
|
||||
cx.new(|cx| workspace::Workspace::new(None, project.clone(), app_state.clone(), window, cx))
|
||||
})?;
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
/// Returns the default window size for visual tests (1280x800).
|
||||
pub fn default_window_size() -> Size<gpui::Pixels> {
|
||||
size(px(1280.0), px(800.0))
|
||||
}
|
||||
|
||||
/// Waits for the UI to stabilize by running pending work and waiting for animations.
|
||||
pub async fn wait_for_ui_stabilization(cx: &VisualTestAppContext) {
|
||||
cx.run_until_parked();
|
||||
cx.background_executor
|
||||
.timer(Duration::from_millis(100))
|
||||
.await;
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Captures a screenshot of the given window and optionally saves it to a file.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cx` - The visual test context
|
||||
/// * `window` - The window to capture
|
||||
/// * `output_path` - Optional path to save the screenshot
|
||||
///
|
||||
/// # Returns
|
||||
/// The captured screenshot as an RgbaImage
|
||||
pub async fn capture_and_save_screenshot(
|
||||
cx: &mut VisualTestAppContext,
|
||||
window: AnyWindowHandle,
|
||||
output_path: Option<&Path>,
|
||||
) -> Result<RgbaImage> {
|
||||
wait_for_ui_stabilization(cx).await;
|
||||
|
||||
let screenshot = cx.capture_screenshot(window).await?;
|
||||
|
||||
if let Some(path) = output_path {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
screenshot.save(path)?;
|
||||
println!("Screenshot saved to: {}", path.display());
|
||||
}
|
||||
|
||||
Ok(screenshot)
|
||||
}
|
||||
|
||||
/// Check if we should update baselines (controlled by UPDATE_BASELINES env var).
|
||||
pub fn should_update_baselines() -> bool {
|
||||
std::env::var("UPDATE_BASELINES").is_ok()
|
||||
}
|
||||
|
||||
/// Assert that a screenshot matches a baseline, or update the baseline if UPDATE_BASELINES is set.
|
||||
pub fn assert_or_update_baseline(
|
||||
actual: &RgbaImage,
|
||||
baseline_path: &Path,
|
||||
tolerance: f64,
|
||||
per_pixel_threshold: u8,
|
||||
) -> Result<()> {
|
||||
if should_update_baselines() {
|
||||
save_baseline(actual, baseline_path)?;
|
||||
println!("Updated baseline: {}", baseline_path.display());
|
||||
Ok(())
|
||||
} else {
|
||||
assert_screenshot_matches(actual, baseline_path, tolerance, per_pixel_threshold)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of comparing two screenshots.
|
||||
#[derive(Debug)]
|
||||
pub struct ScreenshotComparison {
|
||||
/// Percentage of pixels that match (0.0 to 1.0)
|
||||
pub match_percentage: f64,
|
||||
/// Optional diff image highlighting differences (red = different, green = same)
|
||||
pub diff_image: Option<RgbaImage>,
|
||||
/// Number of pixels that differ
|
||||
pub diff_pixel_count: u64,
|
||||
/// Total number of pixels compared
|
||||
pub total_pixels: u64,
|
||||
}
|
||||
|
||||
impl ScreenshotComparison {
|
||||
/// Returns true if the images match within the given tolerance.
|
||||
pub fn matches(&self, tolerance: f64) -> bool {
|
||||
self.match_percentage >= (1.0 - tolerance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare two screenshots with tolerance for minor differences (e.g., anti-aliasing).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `actual` - The screenshot to test
|
||||
/// * `expected` - The baseline screenshot to compare against
|
||||
/// * `per_pixel_threshold` - Maximum color difference per channel (0-255) to consider pixels equal
|
||||
///
|
||||
/// # Returns
|
||||
/// A `ScreenshotComparison` containing match statistics and an optional diff image.
|
||||
pub fn compare_screenshots(
|
||||
actual: &RgbaImage,
|
||||
expected: &RgbaImage,
|
||||
per_pixel_threshold: u8,
|
||||
) -> ScreenshotComparison {
|
||||
let (width, height) = actual.dimensions();
|
||||
let (exp_width, exp_height) = expected.dimensions();
|
||||
|
||||
if width != exp_width || height != exp_height {
|
||||
return ScreenshotComparison {
|
||||
match_percentage: 0.0,
|
||||
diff_image: None,
|
||||
diff_pixel_count: (width * height).max(exp_width * exp_height) as u64,
|
||||
total_pixels: (width * height).max(exp_width * exp_height) as u64,
|
||||
};
|
||||
}
|
||||
|
||||
let total_pixels = (width * height) as u64;
|
||||
let mut diff_pixel_count = 0u64;
|
||||
let mut diff_image: RgbaImage = ImageBuffer::new(width, height);
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let actual_pixel = actual.get_pixel(x, y);
|
||||
let expected_pixel = expected.get_pixel(x, y);
|
||||
|
||||
let pixels_match =
|
||||
pixels_are_similar(actual_pixel, expected_pixel, per_pixel_threshold);
|
||||
|
||||
if pixels_match {
|
||||
diff_image.put_pixel(x, y, Rgba([0, 128, 0, 255]));
|
||||
} else {
|
||||
diff_pixel_count += 1;
|
||||
diff_image.put_pixel(x, y, Rgba([255, 0, 0, 255]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let matching_pixels = total_pixels - diff_pixel_count;
|
||||
let match_percentage = if total_pixels > 0 {
|
||||
matching_pixels as f64 / total_pixels as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
ScreenshotComparison {
|
||||
match_percentage,
|
||||
diff_image: Some(diff_image),
|
||||
diff_pixel_count,
|
||||
total_pixels,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if two pixels are similar within a threshold.
|
||||
fn pixels_are_similar(a: &Rgba<u8>, b: &Rgba<u8>, threshold: u8) -> bool {
|
||||
let threshold = threshold as i16;
|
||||
|
||||
let diff_r = (a[0] as i16 - b[0] as i16).abs();
|
||||
let diff_g = (a[1] as i16 - b[1] as i16).abs();
|
||||
let diff_b = (a[2] as i16 - b[2] as i16).abs();
|
||||
let diff_a = (a[3] as i16 - b[3] as i16).abs();
|
||||
|
||||
diff_r <= threshold && diff_g <= threshold && diff_b <= threshold && diff_a <= threshold
|
||||
}
|
||||
|
||||
/// Assert that a screenshot matches a baseline image within tolerance.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `actual` - The screenshot to test
|
||||
/// * `baseline_path` - Path to the baseline image file
|
||||
/// * `tolerance` - Percentage of pixels that can differ (0.0 to 1.0)
|
||||
/// * `per_pixel_threshold` - Maximum color difference per channel (0-255) to consider pixels equal
|
||||
///
|
||||
/// # Returns
|
||||
/// Ok(()) if the images match, Err with details if they don't.
|
||||
pub fn assert_screenshot_matches(
|
||||
actual: &RgbaImage,
|
||||
baseline_path: &Path,
|
||||
tolerance: f64,
|
||||
per_pixel_threshold: u8,
|
||||
) -> Result<()> {
|
||||
if !baseline_path.exists() {
|
||||
return Err(anyhow!(
|
||||
"Baseline image not found at: {}. Run with UPDATE_BASELINES=1 to create it.",
|
||||
baseline_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let expected = image::open(baseline_path)
|
||||
.map_err(|e| anyhow!("Failed to open baseline image: {}", e))?
|
||||
.to_rgba8();
|
||||
|
||||
let comparison = compare_screenshots(actual, &expected, per_pixel_threshold);
|
||||
|
||||
if comparison.matches(tolerance) {
|
||||
Ok(())
|
||||
} else {
|
||||
let diff_path = baseline_path.with_extension("diff.png");
|
||||
if let Some(diff_image) = &comparison.diff_image {
|
||||
diff_image.save(&diff_path).ok();
|
||||
}
|
||||
|
||||
let actual_path = baseline_path.with_extension("actual.png");
|
||||
actual.save(&actual_path).ok();
|
||||
|
||||
Err(anyhow!(
|
||||
"Screenshot does not match baseline.\n\
|
||||
Match: {:.2}% (required: {:.2}%)\n\
|
||||
Differing pixels: {} / {}\n\
|
||||
Baseline: {}\n\
|
||||
Actual saved to: {}\n\
|
||||
Diff saved to: {}",
|
||||
comparison.match_percentage * 100.0,
|
||||
(1.0 - tolerance) * 100.0,
|
||||
comparison.diff_pixel_count,
|
||||
comparison.total_pixels,
|
||||
baseline_path.display(),
|
||||
actual_path.display(),
|
||||
diff_path.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Save an image as the new baseline, creating parent directories if needed.
|
||||
pub fn save_baseline(image: &RgbaImage, baseline_path: &Path) -> Result<()> {
|
||||
if let Some(parent) = baseline_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| anyhow!("Failed to create baseline directory: {}", e))?;
|
||||
}
|
||||
|
||||
image
|
||||
.save(baseline_path)
|
||||
.map_err(|e| anyhow!("Failed to save baseline image: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load an image from a file path.
|
||||
pub fn load_image(path: &Path) -> Result<RgbaImage> {
|
||||
image::open(path)
|
||||
.map_err(|e| anyhow!("Failed to load image from {}: {}", path.display(), e))
|
||||
.map(|img| img.to_rgba8())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_image(width: u32, height: u32, color: Rgba<u8>) -> RgbaImage {
|
||||
let mut img = ImageBuffer::new(width, height);
|
||||
for pixel in img.pixels_mut() {
|
||||
*pixel = color;
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identical_images_match() {
|
||||
let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
|
||||
let img2 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
|
||||
|
||||
let comparison = compare_screenshots(&img1, &img2, 0);
|
||||
|
||||
assert_eq!(comparison.match_percentage, 1.0);
|
||||
assert_eq!(comparison.diff_pixel_count, 0);
|
||||
assert!(comparison.matches(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_images_dont_match() {
|
||||
let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
|
||||
let img2 = create_test_image(100, 100, Rgba([0, 255, 0, 255]));
|
||||
|
||||
let comparison = compare_screenshots(&img1, &img2, 0);
|
||||
|
||||
assert_eq!(comparison.match_percentage, 0.0);
|
||||
assert_eq!(comparison.diff_pixel_count, 10000);
|
||||
assert!(!comparison.matches(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_similar_images_match_with_threshold() {
|
||||
let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
|
||||
let img2 = create_test_image(100, 100, Rgba([250, 5, 0, 255]));
|
||||
|
||||
let comparison_strict = compare_screenshots(&img1, &img2, 0);
|
||||
assert_eq!(comparison_strict.match_percentage, 0.0);
|
||||
|
||||
let comparison_lenient = compare_screenshots(&img1, &img2, 10);
|
||||
assert_eq!(comparison_lenient.match_percentage, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_size_images() {
|
||||
let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
|
||||
let img2 = create_test_image(200, 200, Rgba([255, 0, 0, 255]));
|
||||
|
||||
let comparison = compare_screenshots(&img1, &img2, 0);
|
||||
|
||||
assert_eq!(comparison.match_percentage, 0.0);
|
||||
assert!(comparison.diff_image.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_difference() {
|
||||
let mut img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
|
||||
let img2 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
|
||||
|
||||
for x in 0..50 {
|
||||
for y in 0..100 {
|
||||
img1.put_pixel(x, y, Rgba([0, 255, 0, 255]));
|
||||
}
|
||||
}
|
||||
|
||||
let comparison = compare_screenshots(&img1, &img2, 0);
|
||||
|
||||
assert_eq!(comparison.match_percentage, 0.5);
|
||||
assert_eq!(comparison.diff_pixel_count, 5000);
|
||||
assert!(comparison.matches(0.5));
|
||||
assert!(!comparison.matches(0.49));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_visual_test_smoke() {
|
||||
let mut cx = VisualTestAppContext::new();
|
||||
|
||||
let _window = cx
|
||||
.open_offscreen_window_default(|_, cx| cx.new(|_| Empty))
|
||||
.expect("Failed to open offscreen window");
|
||||
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_workspace_opens() {
|
||||
let mut cx = VisualTestAppContext::new();
|
||||
let app_state = init_visual_test(&mut cx);
|
||||
|
||||
smol::block_on(async {
|
||||
app_state
|
||||
.fs
|
||||
.as_fake()
|
||||
.insert_tree(
|
||||
"/project",
|
||||
serde_json::json!({
|
||||
"src": {
|
||||
"main.rs": "fn main() {\n println!(\"Hello, world!\");\n}\n"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let workspace_result = smol::block_on(open_test_workspace(app_state, &mut cx));
|
||||
assert!(
|
||||
workspace_result.is_ok(),
|
||||
"Failed to open workspace: {:?}",
|
||||
workspace_result.err()
|
||||
);
|
||||
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// This test captures a screenshot of an empty Zed workspace.
|
||||
///
|
||||
/// Note: This test is ignored by default because:
|
||||
/// 1. It requires macOS with Screen Recording permission granted
|
||||
/// 2. It must run on the main thread (standard test threads won't work)
|
||||
/// 3. Screenshot capture may fail in CI environments without display access
|
||||
///
|
||||
/// The test will gracefully handle screenshot failures and print an error
|
||||
/// message rather than failing hard, to allow running in environments
|
||||
/// where screen capture isn't available.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_workspace_screenshot() {
|
||||
let mut cx = VisualTestAppContext::new();
|
||||
let app_state = init_visual_test(&mut cx);
|
||||
|
||||
smol::block_on(async {
|
||||
app_state
|
||||
.fs
|
||||
.as_fake()
|
||||
.insert_tree(
|
||||
"/project",
|
||||
serde_json::json!({
|
||||
"src": {
|
||||
"main.rs": "fn main() {\n println!(\"Hello, world!\");\n}\n"
|
||||
},
|
||||
"README.md": "# Test Project\n\nThis is a test project for visual testing.\n"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let workspace = smol::block_on(open_test_workspace(app_state, &mut cx))
|
||||
.expect("Failed to open workspace");
|
||||
|
||||
smol::block_on(async {
|
||||
wait_for_ui_stabilization(&cx).await;
|
||||
|
||||
let screenshot_result = cx.capture_screenshot(workspace.into()).await;
|
||||
|
||||
match screenshot_result {
|
||||
Ok(screenshot) => {
|
||||
println!(
|
||||
"Screenshot captured successfully: {}x{}",
|
||||
screenshot.width(),
|
||||
screenshot.height()
|
||||
);
|
||||
|
||||
let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
|
||||
.unwrap_or_else(|_| "target/visual_tests".to_string());
|
||||
let output_path = Path::new(&output_dir).join("workspace_screenshot.png");
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(&output_dir) {
|
||||
eprintln!("Warning: Failed to create output directory: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = screenshot.save(&output_path) {
|
||||
eprintln!("Warning: Failed to save screenshot: {}", e);
|
||||
} else {
|
||||
println!("Screenshot saved to: {}", output_path.display());
|
||||
}
|
||||
|
||||
assert!(
|
||||
screenshot.width() > 0,
|
||||
"Screenshot width should be positive"
|
||||
);
|
||||
assert!(
|
||||
screenshot.height() > 0,
|
||||
"Screenshot height should be positive"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Screenshot capture failed (this may be expected in CI without screen recording permission): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
}
|
||||
}
|
||||
BIN
crates/zed/test_fixtures/visual_tests/project_panel.png
Normal file
BIN
crates/zed/test_fixtures/visual_tests/project_panel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
BIN
crates/zed/test_fixtures/visual_tests/workspace_with_editor.png
Normal file
BIN
crates/zed/test_fixtures/visual_tests/workspace_with_editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
@@ -15,6 +15,10 @@ When there is an appropriate language server available, Zed will provide complet
|
||||
|
||||
You can manually trigger completions with `ctrl-space` or by triggering the `editor::ShowCompletions` action from the command palette.
|
||||
|
||||
> Note: Using `ctrl-space` in Zed requires disabling the macOS global shortcut.
|
||||
> Open **System Settings** > **Keyboard** > **Keyboard Shortcut**s >
|
||||
> **Input Sources** and uncheck **Select the previous input source**.
|
||||
|
||||
For more information, see:
|
||||
|
||||
- [Configuring Supported Languages](./configuring-languages.md)
|
||||
|
||||
@@ -57,6 +57,35 @@ And to run the tests:
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
## Visual Regression Tests
|
||||
|
||||
Zed includes visual regression tests that capture screenshots of real Zed windows and compare them against baseline images. These tests require macOS with Screen Recording permission.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
You must grant Screen Recording permission to your terminal:
|
||||
|
||||
1. Run the visual test runner once - macOS will prompt for permission
|
||||
2. Or manually: System Settings > Privacy & Security > Screen Recording
|
||||
3. Enable your terminal app (e.g., Terminal.app, iTerm2, Ghostty)
|
||||
4. Restart your terminal after granting permission
|
||||
|
||||
### Running Visual Tests
|
||||
|
||||
```sh
|
||||
cargo run -p zed --bin visual_test_runner --features visual-tests
|
||||
```
|
||||
|
||||
### Updating Baselines
|
||||
|
||||
When UI changes are intentional, update the baseline images:
|
||||
|
||||
```sh
|
||||
UPDATE_BASELINE=1 cargo run -p zed --bin visual_test_runner --features visual-tests
|
||||
```
|
||||
|
||||
Baseline images are stored in `crates/zed/test_fixtures/visual_tests/` and should be committed to the repository.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error compiling metal shaders
|
||||
|
||||
@@ -112,6 +112,8 @@ And to run the tests:
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
> **Note:** Visual regression tests are currently macOS-only and require Screen Recording permission. See [Building Zed for macOS](./macos.md#visual-regression-tests) for details.
|
||||
|
||||
## Installing from msys2
|
||||
|
||||
Zed does not support unofficial MSYS2 Zed packages built for Mingw-w64. Please report any issues you may have with [mingw-w64-zed](https://packages.msys2.org/base/mingw-w64-zed) to [msys2/MINGW-packages/issues](https://github.com/msys2/MINGW-packages/issues?q=is%3Aissue+is%3Aopen+zed).
|
||||
|
||||
@@ -45,11 +45,15 @@ pub(crate) fn run_tests() -> Workflow {
|
||||
&should_run_tests,
|
||||
]);
|
||||
|
||||
let check_style = check_style();
|
||||
let run_tests_linux = run_platform_tests(Platform::Linux);
|
||||
let call_autofix = call_autofix(&check_style, &run_tests_linux);
|
||||
|
||||
let mut jobs = vec![
|
||||
orchestrate,
|
||||
check_style(),
|
||||
check_style,
|
||||
should_run_tests.guard(run_platform_tests(Platform::Windows)),
|
||||
should_run_tests.guard(run_platform_tests(Platform::Linux)),
|
||||
should_run_tests.guard(run_tests_linux),
|
||||
should_run_tests.guard(run_platform_tests(Platform::Mac)),
|
||||
should_run_tests.guard(doctests()),
|
||||
should_run_tests.guard(check_workspace_binaries()),
|
||||
@@ -106,6 +110,7 @@ pub(crate) fn run_tests() -> Workflow {
|
||||
workflow
|
||||
})
|
||||
.add_job(tests_pass.name, tests_pass.job)
|
||||
.add_job(call_autofix.name, call_autofix.job)
|
||||
}
|
||||
|
||||
// Generates a bash script that checks changed files against regex patterns
|
||||
@@ -221,6 +226,8 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob {
|
||||
named::job(job)
|
||||
}
|
||||
|
||||
pub const STYLE_FAILED_OUTPUT: &str = "style_failed";
|
||||
|
||||
fn check_style() -> NamedJob {
|
||||
fn check_for_typos() -> Step<Use> {
|
||||
named::uses(
|
||||
@@ -236,15 +243,58 @@ fn check_style() -> NamedJob {
|
||||
.add_step(steps::checkout_repo())
|
||||
.add_step(steps::cache_rust_dependencies_namespace())
|
||||
.add_step(steps::setup_pnpm())
|
||||
.add_step(steps::script("./script/prettier"))
|
||||
.add_step(steps::prettier())
|
||||
.add_step(steps::cargo_fmt())
|
||||
.add_step(steps::trigger_autofix(false))
|
||||
.add_step(steps::record_style_failure())
|
||||
.add_step(steps::script("./script/check-todos"))
|
||||
.add_step(steps::script("./script/check-keymaps"))
|
||||
.add_step(check_for_typos()),
|
||||
.add_step(check_for_typos())
|
||||
.outputs([(
|
||||
STYLE_FAILED_OUTPUT.to_owned(),
|
||||
format!(
|
||||
"${{{{ steps.{}.outputs.failed == 'true' }}}}",
|
||||
steps::RECORD_STYLE_FAILURE_STEP_ID
|
||||
),
|
||||
)]),
|
||||
)
|
||||
}
|
||||
|
||||
fn call_autofix(check_style: &NamedJob, run_tests_linux: &NamedJob) -> NamedJob {
|
||||
fn dispatch_autofix(run_tests_linux_name: &str) -> Step<Run> {
|
||||
let clippy_failed_expr = format!(
|
||||
"needs.{}.outputs.{} == 'true'",
|
||||
run_tests_linux_name, CLIPPY_FAILED_OUTPUT
|
||||
);
|
||||
named::bash(format!(
|
||||
"gh workflow run autofix_pr.yml -f pr_number=${{{{ github.event.pull_request.number }}}} -f run_clippy=${{{{ {} }}}}",
|
||||
clippy_failed_expr
|
||||
))
|
||||
.add_env(("GITHUB_TOKEN", "${{ steps.get-app-token.outputs.token }}"))
|
||||
}
|
||||
|
||||
let style_failed_expr = format!(
|
||||
"needs.{}.outputs.{} == 'true'",
|
||||
check_style.name, STYLE_FAILED_OUTPUT
|
||||
);
|
||||
let clippy_failed_expr = format!(
|
||||
"needs.{}.outputs.{} == 'true'",
|
||||
run_tests_linux.name, CLIPPY_FAILED_OUTPUT
|
||||
);
|
||||
let (authenticate, _token) = steps::authenticate_as_zippy();
|
||||
|
||||
let job = Job::default()
|
||||
.runs_on(runners::LINUX_SMALL)
|
||||
.cond(Expression::new(format!(
|
||||
"always() && ({} || {}) && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'",
|
||||
style_failed_expr, clippy_failed_expr
|
||||
)))
|
||||
.needs(vec![check_style.name.clone(), run_tests_linux.name.clone()])
|
||||
.add_step(authenticate)
|
||||
.add_step(dispatch_autofix(&run_tests_linux.name));
|
||||
|
||||
named::job(job)
|
||||
}
|
||||
|
||||
fn check_dependencies() -> NamedJob {
|
||||
fn install_cargo_machete() -> Step<Use> {
|
||||
named::uses(
|
||||
@@ -305,6 +355,8 @@ fn check_workspace_binaries() -> NamedJob {
|
||||
)
|
||||
}
|
||||
|
||||
pub const CLIPPY_FAILED_OUTPUT: &str = "clippy_failed";
|
||||
|
||||
pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob {
|
||||
let runner = match platform {
|
||||
Platform::Windows => runners::WINDOWS_DEFAULT,
|
||||
@@ -327,12 +379,23 @@ pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob {
|
||||
.add_step(steps::setup_node())
|
||||
.add_step(steps::clippy(platform))
|
||||
.when(platform == Platform::Linux, |job| {
|
||||
job.add_step(steps::trigger_autofix(true))
|
||||
.add_step(steps::cargo_install_nextest())
|
||||
job.add_step(steps::record_clippy_failure())
|
||||
})
|
||||
.when(platform == Platform::Linux, |job| {
|
||||
job.add_step(steps::cargo_install_nextest())
|
||||
})
|
||||
.add_step(steps::clear_target_dir_if_large(platform))
|
||||
.add_step(steps::cargo_nextest(platform))
|
||||
.add_step(steps::cleanup_cargo_config(platform)),
|
||||
.add_step(steps::cleanup_cargo_config(platform))
|
||||
.when(platform == Platform::Linux, |job| {
|
||||
job.outputs([(
|
||||
CLIPPY_FAILED_OUTPUT.to_owned(),
|
||||
format!(
|
||||
"${{{{ steps.{}.outputs.failed == 'true' }}}}",
|
||||
steps::RECORD_CLIPPY_FAILURE_STEP_ID
|
||||
),
|
||||
)])
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,25 @@ pub fn setup_sentry() -> Step<Use> {
|
||||
.add_with(("token", vars::SENTRY_AUTH_TOKEN))
|
||||
}
|
||||
|
||||
pub const PRETTIER_STEP_ID: &str = "prettier";
|
||||
pub const CARGO_FMT_STEP_ID: &str = "cargo_fmt";
|
||||
pub const RECORD_STYLE_FAILURE_STEP_ID: &str = "record_style_failure";
|
||||
|
||||
pub fn prettier() -> Step<Run> {
|
||||
named::bash("./script/prettier").id(PRETTIER_STEP_ID)
|
||||
}
|
||||
|
||||
pub fn cargo_fmt() -> Step<Run> {
|
||||
named::bash("cargo fmt --all -- --check")
|
||||
named::bash("cargo fmt --all -- --check").id(CARGO_FMT_STEP_ID)
|
||||
}
|
||||
|
||||
pub fn record_style_failure() -> Step<Run> {
|
||||
named::bash(format!(
|
||||
"echo \"failed=${{{{ steps.{}.outcome == 'failure' || steps.{}.outcome == 'failure' }}}}\" >> \"$GITHUB_OUTPUT\"",
|
||||
PRETTIER_STEP_ID, CARGO_FMT_STEP_ID
|
||||
))
|
||||
.id(RECORD_STYLE_FAILURE_STEP_ID)
|
||||
.if_condition(Expression::new("always()"))
|
||||
}
|
||||
|
||||
pub fn cargo_install_nextest() -> Step<Use> {
|
||||
@@ -101,13 +118,25 @@ pub fn clear_target_dir_if_large(platform: Platform) -> Step<Run> {
|
||||
}
|
||||
}
|
||||
|
||||
pub const CLIPPY_STEP_ID: &str = "clippy";
|
||||
pub const RECORD_CLIPPY_FAILURE_STEP_ID: &str = "record_clippy_failure";
|
||||
|
||||
pub fn clippy(platform: Platform) -> Step<Run> {
|
||||
match platform {
|
||||
Platform::Windows => named::pwsh("./script/clippy.ps1"),
|
||||
_ => named::bash("./script/clippy"),
|
||||
Platform::Windows => named::pwsh("./script/clippy.ps1").id(CLIPPY_STEP_ID),
|
||||
_ => named::bash("./script/clippy").id(CLIPPY_STEP_ID),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_clippy_failure() -> Step<Run> {
|
||||
named::bash(format!(
|
||||
"echo \"failed=${{{{ steps.{}.outcome == 'failure' }}}}\" >> \"$GITHUB_OUTPUT\"",
|
||||
CLIPPY_STEP_ID
|
||||
))
|
||||
.id(RECORD_CLIPPY_FAILURE_STEP_ID)
|
||||
.if_condition(Expression::new("always()"))
|
||||
}
|
||||
|
||||
pub fn cache_rust_dependencies_namespace() -> Step<Use> {
|
||||
named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "rust"))
|
||||
}
|
||||
@@ -345,16 +374,6 @@ pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step<Run> {
|
||||
))
|
||||
}
|
||||
|
||||
pub fn trigger_autofix(run_clippy: bool) -> Step<Run> {
|
||||
named::bash(format!(
|
||||
"gh workflow run autofix_pr.yml -f pr_number=${{{{ github.event.pull_request.number }}}} -f run_clippy={run_clippy}"
|
||||
))
|
||||
.if_condition(Expression::new(
|
||||
"failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'",
|
||||
))
|
||||
.add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
|
||||
}
|
||||
|
||||
pub fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
|
||||
let step = named::uses(
|
||||
"actions",
|
||||
|
||||
Reference in New Issue
Block a user